栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

从JVM角度理解Java并发(上)

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

从JVM角度理解Java并发(上)

文章目录
    • 前言
    • 一,线程基础
      • 1.1 线程的状态
      • 1.2 创建线程
      • 1.3 线程让步
      • 1.4 线程休眠
      • 1.5 等待线程结束
      • 1.6 线程中断
      • 1.7 不推荐的方法
      • 1.8 守护线程
    • 二、锁
      • 2.1 线程安全问题
      • 2.2 synchronized使用
      • 2.3 线程安全分析
      • 2.4 探究synchronized实现原理
        • 2.4.1 Java对象头
        • 2.4.2 重量级锁-Monitor
        • 2.4.3 轻量级锁
        • 2.4.4 偏向锁
        • 2.4.5 锁消除
      • 2.5 wait/notify
      • 2.6 死锁
        • 2.6.1 死锁现象
        • 2.6.2 死锁定位
    • 三、无锁
      • 3.1 CAS
        • 3.1.1 CAS的使用
        • 3.1.2 CAS效率分析
      • 3.2 原子更新基本类型
        • 3.2.1 AtomicInteger
      • 3.3 原子更新引用类型
        • 3.3.1 AtomicReference
        • 3.3.2 ABA问题
      • 3.4 原子更新数组
        • 3.4.1 AtomicIntegerArray
      • 3.5 原子更新字段
        • 3.5.1 AtomicIntegerFieldUpdater
      • 3.6 Unsafe

前言

本文内容主要参考黑马JUC并发编程,《Java并发编程的艺术》,所有代码可在github我的主页下载。

一,线程基础 1.1 线程的状态

我们知道,操作系统把线程分为了初始、可运行、运行、终止、阻塞五种状态。
在Java中,把线程分为新建、可运行、阻塞、等待、限时等待、终止六种状态,其中可运行又可分为就绪和运行两种状态,如下图所示,后续将围绕线程状态的转化介绍各种线程操作方法。

1.2 创建线程

有三种常见创建线程的方式,一种是重写run方法(继承),另外两种均采用接口(组合)。
我们先来看看采用重写的方式:

// 方式一:直接重写Thread的run方法
Thread t1 = new Thread("t1") {
	@Override
    public void run() {
    	System.out.println("I am t1");
    }
};
t1.start();

线程start以后,会自动调用线程的run方法执行。注意,直接t1.run()并不会开启一个新的线程。
学过设计模式都知道,编程推荐使用组合而不是继承,因此jdk的设计者们还提供了组和的方式。

// 方式二:实现Runnable接口,调用Thread的构造器
Runnable runnable = () -> System.out.println("I am t2");
Thread t2 = new Thread(runnable, "t2");
t2.start();

看看Thread的构造器,这里可以传入一个Runnable接口。

public Thread(Runnable target) {
	init(null, target, "Thread-" + nextThreadNum(), 0);
}

而init方法内部将该线程的target赋值为这个传入的target,这就是组合。

// 内部变量
private Runnable target;
// init方法
this.target = target;

而在调用run方法时,可以看到,如果target不为空,则会调用传入的实现Runnable接口的run方法。

public void run() {
	if (target != null) {
    	target.run();
    }
}

而Runnable接口是什么样的,想必你已经大概猜出来了,只有一个run方法的函数式接口

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

在工程实际的应用中,我们会采用一种叫做“异步”的方式去编写业务逻辑,比如老板让员工去做一件事情A,吩咐完成后,员工下去做A,老板则可以做事情B,员工完成任务后,就来向老板汇报A的工作情况。如果采用Runnable接口,可以看到执行方法run时,没有返回值,也就是说,该接口只会去做上级安排的任务,而不会给上级汇报工作情况,不符合我们上述的这种业务场景,于是,jdk开发者们在此之上,又设计了一个新的接口Callable,使线程执行拥有返回值。

@FunctionalInterface
public interface Callable {
    V call() throws Exception;
}

那么,线程有了返回值,我们怎么去拿呢?我们怎么知道线程有没有执行完?什么时候该去拿呢?
这里就要用到future模式,在这种模式下,我们先让被调用者返回一个凭证,让它在后台慢慢处理这个请求,然后可以通过凭证去取得处理后的数据。

// 方式三:使用Future模式
FutureTask ft = new FutureTask<>(() -> {
	System.out.println("I am t3");
	return 1;
});
Thread t3 = new Thread(ft, "t3");
t3.start();

具体关系图如下所示,可以看到,Callable接口被注册到了FutureTask中,由于FutureTask实现了Runnable,所以它能通过构造器注册到Thread中。

不难猜出,FutureTask实现了Runnable,它的run方法必然是调用了Callable的call方法。
而FutureTask的对象ft就是我们拿到返回值的凭证,通过调用get方法,如果线程还未执行完,则当前线程等待,直到拿到返回值。

1.3 线程让步

线程创建过后,就进入ready状态,与其他线程一起竞争cpu,通过操作系统的线程调度机制,进入cpu运行,在running状态时,可以使用yield方法使线程放弃cpu进入ready状态。

Thread.yield();

注意,ready状态的线程仍然可以竞争cpu资源,也就是说让步方法虽然放弃了cpu,但起决定因素的还是操作系统的调度,想通过让步让其他线程运行或许并不好使。

1.4 线程休眠

在线程running状态时,可以使用sleep(long m)方法使线程放弃cpu进入timed-wating状态,时间到后,就会被唤醒。

Thread.sleep(1000);

可以使用TimeUnit.SECONDS.sleep(1)替换sleep,具备更好的可读性。
线程休眠的一种典型应用是对于守护线程,需要一直运行在后台,通常使用while(true)的循环,但这样会使cpu占用过高,典型的处理是在循环内加入休眠,例如每一次休眠50ms能有非常不错的性能提升。
注意:

  1. 线程休眠、以及后文的线程阻塞,它们被唤醒的时候都是变为ready状态,竞争cpu。
  2. 线程休眠并不会释放掉它所获得的锁。
1.5 等待线程结束

线程之间存在协作关系,假如主线程让线程A去帮忙做一件事,主线程需要等待A的返回结果才能继续后面的工作,比如(a+b)*c,主线程让A去做a+b,然后它等待a+b的结果才能计算(a+b)*c,这时,就可以用等待线程结束。

t1.join();
t1.join(1000); // 限时等待

上述两种方法分别将主线程的状态从running变为waiting和timed-waiting。
其实,join方法的底层仍然是调用的wait方法(见3.2节)。

1.6 线程中断

线程中断本身不会更改线程的状态,只会更改线程的isInterrupted标志位,这是为了让程序能够在接收到中断命令后,能自行决定后续的工作。

t1.interrupt();
  • 对于正在运行的线程来讲,interrupt打断它并且把isInterrupted标志位置为true,线程是否终止由线程决定,比如程序员需要在打断后保留现场信息再退出。
Thread t1 = new Thread() {
	@Override
    public void run() {
    	while (true) {
        	System.out.println("do work1...");
        	// 检查到此时线程被中断
        	if (Thread.currentThread().isInterrupted()) {
        		System.out.println("save...");
        		System.out.println(Thread.currentThread().isInterrupted());
            	break;
        	}
		}
	}
};

中断t1后输出

...
do work1...
do work1...
save...
true
  • 对于waiting,timed-waiting这种没有在运行的线程,线程没有办法去判断是否中断,所以采用异常捕获的方式,这种方式会清除打断标记,且捕获异常后,并不会终止线程,而是把线程变为ready状态,执行异常代码块,循环仍然继续。
Thread t2 = new Thread("t2") {
	@Override
	public void run() {
    	while (true) {
        	// 在sleep的时候会抛出中断异常,此时会被捕获,但中断标志位会被清除
            try {
            	System.out.println("t2 sleep...");
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println("Interrupted when sleep");
                System.out.println(Thread.currentThread().isInterrupted());
            }
        }
    }
};

中断t2后输出

t2 sleep...
Interrupted when sleep
false
t2 sleep...
t2 sleep...

可以看到,标志位仍然为false且线程依然在运行。
两阶段终止模式
运行中的线程,我们怎么从外界将它终止,jdk最初提供了stop()方法,但这种方法类似于电脑之间拔电源,暴力中止线程会导致数据不一致,所以,这个方法现在已经废弃了,而使用其他方法代替。线程中断就是其中一种。
由于线程处于运行和阻塞(这里把等待、阻塞统称为阻塞)状态对于中断的响应不同,所以需要分两种情况考虑,下图基于一种常见的情景,线程A需要不断在后台运行,对于while(true),需要增加线程休眠来防止cpu占用过高,

Thread t3 = new Thread("t3") {
	@Override
	public void run() {
		while (true) {
        	Thread t = Thread.currentThread();
            if (t.isInterrupted()) {
            	System.out.println("save...");
                break;
            }
            System.out.println("do work...");
            try {
            	Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 重新设置打断标记
                e.printStackTrace();
                t.interrupt();
            }
		}
	}
};

补充:也可以设置停止标记stop,当标记为真时,结束循环

private volatile boolean stop = false;

LockSupport支持中断影响,它不会抛出异常,也不会清除中断标记,并且中断一次后,再使用park方法将失效。

Thread t4 = new Thread("t4") {
	@Override
	public void run() {
		System.out.println("t4 park...");
        LockSupport.park();
        System.out.println("t4 unpark...");
        System.out.println("t4 interrupted status:" + Thread.currentThread().isInterrupted());
        System.out.println("t4 park again...");
        LockSupport.park(); //不会再生效
        System.out.println("t4 unpark again...");
    }
};

输出:

t4 park...
t4 unpark...
t4 interrupted status:true
t4 park again...
t4 unpark again...
1.7 不推荐的方法
  • stop():线程终止。该方法类似于电脑拔插头,会导致数据不一致。一般使用中断来替换。
  • suspend()与resume():线程挂起与继续执行。该方法暂停的同时不会释放锁资源,一旦出现resume比suspend先执行的情况,线程将永远得不到继续执行。最奇怪的是,被挂起的线程仍会处于runnable状态。可使用LockSupport来替换。
1.8 守护线程

守护线程是一种特殊的线程,它一直在后台默默完成一些任务,当非守护线程全部结束时,守护线程会结束。
比如一个while(true)的线程,若没有把它设置为守护线程,当主线程结束时,它还会继续运行,若将它设置为守护线程,主线程结束它也会跟着结束。
以下t1为守护线程,t2为非守护线程,主线程先行退出,然后t2再退出,可以看到,t1一直等待所有线程(main, t2)执行结束,才会退出。

public class DaemonDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                while (true) {
                    try {
                        TimeUnit.SECONDS.sleep(1);
                        System.out.println("t1 work...");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                try {
                    System.out.println("t2 work...");
                    TimeUnit.SECONDS.sleep(5);
                    System.out.println("t2 out...");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        t1.setDaemon(true);
        t1.start();
        t2.start();
        TimeUnit.SECONDS.sleep(3);
        System.out.println("main out...");

    }
}

输出:

t2 work...
t1 work...
t1 work...
main out...
t1 work...
t1 work...
t2 out...
二、锁 2.1 线程安全问题

由操作系统知识可以知道,一个进程可以拥有多个线程,线程是调度的基本单位,CPU根据调度算法自行在线程中切换执行,实现并发。比如下面这一段代码,线程t1,t2分别使cnt自增5000,理论上结果应该为10000。

public class ThreadSafe {

    public static int cnt = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    cnt++;
                }
            }
        };

        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    cnt++;
                }
            }
        };

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(cnt);
    }
}

然而实际执行中,却总是比10000小。

8928

这是因为java字写码把++命令拆分为了以下三步:

  1. 取cnt的值
  2. 在线程内执行cnt+1
  3. 将计算的结果赋值给cnt

在每一步之间,如果操作系统执行线程切换,都会导致计算不能符合预期。
比如线程t1先拿到cnt的值(0),然后执行+1操作,这时线程切换,t2拿到cnt的值(0),执行+1操作并写入cnt,此时cnt为1,线程t1又拿到了cpu资源,并将它计算的结果(1)写入。这样,两次cnt++操作却得到1。
一块代码块如果存在对共享资源的多线程读写操作,这段代码块就称为临界区(比如上述cnt++)。

2.2 synchronized使用

synchronized又称为对象锁,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程想再获得这个对象锁都会被阻塞。
还是上面的那个例子,我们在进入临界区前,使用synchronized加锁。

public class SynchronizedDemo {
    public static int cnt = 0;
    public static Object o = new Object(); // 创建一个对象,供synchronized使用

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    synchronized (o) {
                        cnt++;
                    }
                }
            }
        };

        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    synchronized (o) {
                        cnt++;
                    }
                }
            }
        };

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(cnt);
    }
}

输出:

10000

可以把synchronized看做是一道门,门内是临界区代码,而对象是这把门的钥匙,谁先拿到钥匙,谁进门,后续想进入的线程都将进入阻塞状态(blocked),注意,即使进入门的线程执行了线程切换,也不会释放掉它手中的钥匙,当他执行结束时,通知所有阻塞在此门的线程(blocked->runnable),由它们再一次竞争门的钥匙。
理解了对象锁的概念之后,我们尝试改进一下SynchronizedDemo,考虑把加锁动作抽离出来,应用面向对象思想,将具体行为交由另一个对象来做。

public class SynchronizedObj {

    static class Room {
        private int cnt = 0;

        public void increment() {
            synchronized (this) {
                cnt++;
            }
        }

        public void decrement() {
            synchronized (this) {
                cnt--;
            }
        }

        public int getCnt() {
            synchronized (this) {
                return cnt;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        Room room = new Room();
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    room.increment();
                }
            }
        };

        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    room.increment();
                }
            }
        };

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(room.getCnt());
    }
}

可以看到,我们使用Room类包装了对cnt的加减等操作,程序员只需要调用它的方法而不需要关注线程安全问题。
仔细看SynchronizedObj这个例子,我们不难发现,increment()的整个方法都被synchronized包含了,这种情况下,我们可以这样进行简化:

public synchronized void increment() {
	cnt++;
}

当synchronized写在实例方法上时,相当于对this(当前对象)加锁。
同理,它也能写在静态方法上,相当于对当前类加锁:

public static synchronized void test() {
            
}

等价于

public static void test() {
	synchronized (Room.class) {
                
    }
}
2.3 线程安全分析

哪些变量的使用需要考虑线程安全?哪些变量又不用呢?
只要是多线程共享的变量就就一定要万分小心,我们知道,变量可以分为静态变量、成员变量和局部变量。
由JVM的基础可以知道:
静态变量:

  • 当声明的是基本类型的变量时,其变量名及值是放在方法区中。
  • 当声明的是引用类型的变量时,所声明的变量名放在java方法区中,该变量所指向的对象是放在堆类存中的。

成员变量:

  • 不管声明的是基本数据类型还是引用类型,变量名和值都存放在堆中。

局部变量:

  • 当声明的是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
  • 当声明的是引用类型变量时,所声明的变量名(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中的。

我们知道,方法区和堆多线程共享,JAVA虚拟机栈线程独有,变量是否线程安全,就看它是否存放在线程共享的区域就可以了。
比如对于静态变量和成员变量来讲,任何类型的变量都线程不安全;对于局部变量来讲,如果是基本类型,那么它是安全的,如果是引用类型,它就是不安全的。
特别的,对于String和Integer这两个类,都使用了final作为修饰符,为不可变类,所有线程只能读不能改,所以在任何情况下都是线程安全的。

2.4 探究synchronized实现原理 2.4.1 Java对象头

我们先来看看Java对象头的概念,Java中的每个对象都存在一个对象头,如果是数组类型,虚拟机用3个字宽存储,如果是非数组类型,则用2个字宽存储,以32位系统为例,1子宽等于4字节,32bit,这里我们关注非数组类型的对象头,它由8个字节(64bit)表示。

对于Mark Word,在无锁时的存储结构如下所示:

随着运行,Mark Word可能存储以下4种数据:

2.4.2 重量级锁-Monitor
synchronized(obj) {
	// 临界区
}

我们先来探究Mark Word状态变化的重量级锁,这也是synchronized最开始使用的方法。例如,Thread2执行以上方法,尝试将obj对象和操作系统关联,给对象加锁后,对象的锁标志位变为10(2bits),并且具有指向Monitor对象的指针(30bits),对应的Monitor对象的Owner则指向Thread2,表示被线程占用。
此时,Thread1也执行以上方法,它发现对象已经关联上了Monitor,随即检查Monitor的Owner,发现为Thread2,于是它只能进入EntryList中等待(Blocked),同理后续线程全部等待。

当Thread2执行完成后,Owner空,就会唤醒EntryList里面的所有线程,由它们再次竞争获得锁(非公平)。
下面我们来看看字节码是怎样解析以下代码的

static final Object lock = new Object();
static int counter = 0;

public static void main(String[] args) {
	synchronized(lock) {
		counter++;
	}
}


可以看到,对于方式异常的情况,也能释放锁。
总的来说,代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式(虚拟机规范并没有详细说明),monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

2.4.3 轻量级锁

Java6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,我们先来看一看轻量级锁。
线程在遇到synchronized(obj)时,JVM先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录。

假如线程1先执行,它会使用CAS将对象头中的Mark Word替换为指向锁记录的指针,并附上00,对象进入轻量级锁状态。

假如此时线程1重入synchronized(obj),会发生什么呢?
同样,它会将Mark Word复制到锁记录中,并尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,此时,它发现Mark Word为00,已经具有了轻量级锁,且判断出Mark Word指向当前线程的栈帧,为锁重入,则将栈帧中的Mark Word置null。

线程1拿到两次锁后,每释放一次就将对应的锁记录出栈,当出栈到最后一次时(锁记录的Mark Word不为null),运用CAS将对象原本的Mark Word替换回去,这样一次加锁与释放锁就完成了。
上面假设是只有一个线程的情况,假设线程2现在也来竞争锁了,我们看看线程2竞争的时机以及它对应的策略。
假如线程1已经获得锁且还未释放锁,线程2此时尝试CAS竞争,它发现对象已经进入了00状态,此时它进入自旋来获得锁。

若是自旋获得锁成功,则它获得轻量级锁。
若是失败,则进入锁膨胀,线程2为obj申请Monitor锁,更改Mark Word为重量级锁地址,标志位改为10,同时,线程2进入阻塞。

后来,线程1执行完毕准备执行CAS解锁,它发现obj的Mark Word的标志位已经为10,此时它采用重量级解锁的过程,置Owner为null,唤醒EntryList里面的线程。
总结:轻量级锁的升级过程如下所示

2.4.4 偏向锁

轻量级锁在没有竞争时,每次重入仍然需要执行CAS操作,而偏向锁则做了进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word,之后发现这个线程ID是自己的就表示没有竞争,不用再CAS。如果发现线程ID不指向自己,则升级为轻量级锁,完事之后偏向也会撤销,进入无锁状态。

一个对象创建时:

  • 如果开启了偏向锁(默认开启,使用-XX:-UseBiasedLocking禁用),那么对象创建后,Mark Word的最后三位为101,这时它的thread, epoch, age都为0
  • 偏向锁默认延迟,不会在程序启动时生效,如想避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟
  • 如果没有开启偏向锁,那么对象创建后,Mark Word的最后三位为101,这时它的hashcode, age都为0,第一次用到hashcode才会赋值

偏向锁的撤销:

  • 对象调用hashCode方法,调用hashCode方法时,Mark Word要用25bit存hashCode码,所以没有地方存线程id了
  • 其他线程使用对象,当线程发现对象的线程id不是指向自己时,就升级为轻量级锁,完事之后还原到无锁状态(撤销偏向)。当撤销偏向锁的阈值超过20之后,JVM就会以为自己偏向错了,这时便不会撤销,而是重新偏向至加锁的线程;当撤销偏向锁的阈值超过40之后,JVM就会觉得起初一开始就不应该偏向,它会将整个类的所有对象变为不可偏向的,新建的对象也是不可偏向的。
  • 调用wait/notify,这两个依赖于重量锁,所以会撤销偏向
2.4.5 锁消除

在编译的时候,JIT即时编译器会对不可能出现竞争的对象进行锁消除,如下所示,对象o处在方法内部,每一个进入方法的线程都会重新new一个对象,这种情况下,对o而言,不可能出现竞争,编译器就会消除掉synchronized。

public class LockEliminate{

    static int x = 0;

    public void b() {
        Object o = new Object();
        synchronized (o) {
            x++;
        }
    }
}

可以使用-XX:-EliminateLocks关闭这个功能。

2.5 wait/notify

当线程拿到锁进入临界区执行代码后,它可能需要等待一些准备操作完成才能执行,这时不能一直占据着锁,可以执行wait方法,将owner中的线程一刀WaitSet中(如下图的Thread-0, Thread-1),并进入waiting状态,这时,线程2才能得以进入临界区执行,当等待的操作完成时,由Owner上的线程执行notify操作,唤醒WaitSet里面的线程,线程唤醒后,又得重新竞争锁。

主要方法:

  • o.wait():让进入监视器的线程到WaitSet等待
  • o.wait(long timeout):限时等待timeout毫秒
  • o.notify():在o监视器上正在WaitSet等待的线程挑一个唤醒
  • o.notifyAll():在o监视器上正在WaitSet等待的线程全部唤醒

既然wait等待的线程在Monitor的WaitSet里面,这就意味着线程必须获得Monitor才能够调用wait方法,notify方法也一样。

public class WaitDemo {

    public final static Object o = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                System.out.println("t1 执行...");
                synchronized (o) {
                    try {
                        o.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("t1 唤醒...");
            }
        };

        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                System.out.println("t2 执行...");
                synchronized (o) {
                    try {
                        o.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("t2 唤醒...");
            }
        };

        t1.start();
        t2.start();

        TimeUnit.SECONDS.sleep(5);

        synchronized (o) {
            o.notify();
        }
    }
}

输出:

t1 执行...
t2 执行...
t1 唤醒...

由于notify()操作会随机挑选一个线程唤醒,当有多个线程等待时,只能使用notifyAll()方法,此时,就需要线程内部进行检查,避免虚假唤醒。

synchronized (o) {
	while(如果条件未满足){
		o.wait();
	}
}

sleep(long n)和wait(long n)的区别

  • sleep是Thread的方法,而wait是Object的方法
  • sleep不强制和synchronized匹配使用,wait需要
  • sleep不会释放锁,wait会
2.6 死锁 2.6.1 死锁现象

由操作系统知识可以知道,当线程拥有一把锁又去申请另一把锁时,就可能会出现死锁这种现象,若没有外力作用,谁也无法执行下去。
例如,线程1先后获取A,B两个锁,线程B先后获取B,A两个锁,某一时刻,线程1拥有了A锁且申请B锁,线程2拥有了B锁且申请A锁,此时,它们两个都无法继续执行下去。

public class DeadLockDemo {

    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();

        Thread t1 = new Thread("t1") {
            @Override
            public void run() {
                synchronized (A) {
                    System.out.println("t1 lock A");
                    synchronized (B) {
                        System.out.println("t1 lock B");
                        System.out.println("t1 work...");
                    }
                }
            }
        };

        Thread t2 = new Thread("t2") {
            @Override
            public void run() {
                synchronized (B) {
                    System.out.println("t2 lock B");
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (A) {
                        System.out.println("t2 lock A");
                        System.out.println("t2 work...");
                    }
                }
            }
        };

        t1.start();
        t2.start();
    }
}

输出:

t1 lock A
t2 lock B
2.6.2 死锁定位

可以使用jdk自带的jconsole工具,打开idea控制台,输入jconsole命令,连接到正在运行的进程。

点击左上角的线程,然后左下角有检测死锁的字样。

就可以看到,t1和t2两个线程出现了死锁。

三、无锁

前面我们已经讲解了使用锁synchronized来保证原子性,为了保证锁的效率,也引入了偏向锁和轻量级锁,但是呢,锁始终是锁,对于性能多少会有一些影响,本章我们介绍一种无锁的方式(Compare And Swap),也能保证原子性。

3.1 CAS 3.1.1 CAS的使用

我们通过一个例子认识cas并探究它与加锁方式的性能。
假如有1000个线程,分别对10000减10。

interface CountGame {

    int getCnt();

    void decrement(int num);

    static void game(CountGame game) {
        List list = new ArrayList<>(1000);
        for (int i = 0; i < 1000; i++) {
            list.add(new Thread(()->game.decrement(10), "t" + i));
        }

        long start = System.nanoTime();
        // 启动线程
        list.forEach(Thread::start);
        // 等待线程执行结束
        list.forEach(t->{
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        long end = System.nanoTime();
        System.out.println(game.getCnt() + " cost:" + (end - start)/1000_000 + "ms");
    }
}

为了达到我们期望的结果0,我们使用锁的方式保证原子性。

class CountGameForLock implements CountGame{
    private int cnt;

    public CountGameForLock(int cnt) {
        this.cnt = cnt;
    }

    public int getCnt() {
        return cnt;
    }

    @Override
    public void decrement(int num) {
        synchronized (this) {
            cnt -= num;
        }
    }

}

本节,我们介绍一种基于比较和交换的技术(Compare And Swap),即对于每一个变量,都有一个预期值与交换值,预期值是我们最开始读的时候读到的值,再线程内部经过计算过后,准备将该值放入临界区原变量,此时再读一次,若变量的值不再是我们之前读到的值(预期值),则说明在此间隙中被其他线程更改,那么本次操作放弃,重新再来一次,若变量的值与之前读到的值相等,则可以写入。

class CountGameForUnlock implements CountGame{
    private AtomicInteger cnt;

    public CountGameForUnlock(Integer cnt) {
        this.cnt = new AtomicInteger(cnt);
    }

    public int getCnt() {
        return cnt.get();
    }

    @Override
    public void decrement(int num) {
        // 不断进行比较和交换
        while (true) {
            // 预期值
            int prev = cnt.get();
            // 修改值
            int next = prev - num;
            // cas 如果cas成功 返回true
            if (cnt.compareAndSet(prev, next)) {
                break;
            }
        }
    }
}

以上的compareAndSet为原子操作,输出结果如下:

0 cost:58ms
0 cost:53ms

可以看到,使用无锁比使用锁性能更优。
补充:基于CAS的方法,我们可以大致猜出,它的值应该是用到了我们之前学的volatile关键字,因为比较交换需要保证每次读取的都是最新的值,查看AtomicInteger源代吗也是如此。

3.1.2 CAS效率分析

为什么CAS效率比synchronized更高,这是因为CAS有更少的上下文切换,我们知道,无锁情况下,即使重试失败,线程仍然在运行,而在有锁情况下,如果获取锁失败,将进入阻塞状态,这样就会发生线程上下文切换,影响效率。当然,CAS不是没有上下文切换,当线程时间片结束时,也会发生切换,这就要求CAS的使用场景要线程数较少,多核cpu,不然性能也不会很好。

3.2 原子更新基本类型

Atomic提供了以下3个类

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong
3.2.1 AtomicInteger

3个类提供的方法几乎一样,我们以AtomicInteger为例,它实现对整数的原子操作。

public class AtomicIntegerDemo {
    public static void main(String[] args) {
        AtomicInteger a = new AtomicInteger(0);
        // 加1
        a.incrementAndGet(); // 相当于++a
        a.getAndIncrement(); // 相当于a++
        // 加任意数
        a.getAndAdd(5);
        a.addAndGet(5);
        // 读取到 设置值
        a.updateAndGet(x->x*10);
        a.getAndUpdate(x->x*10);
    }
}

我们着重来看一下updateAndGet的源代码

	public final int updateAndGet(IntUnaryOperator updateFunction) {
        int prev, next;
        do {
            prev = get();
            next = updateFunction.applyAsInt(prev);
        } while (!compareAndSet(prev, next));
        return next;
    }

底层仍然是我们6.1.1节讲到的CAS,这里将数的算法抽象了出来成为函数式接口IntUnaryOperator,

// IntUnaryOperator
int applyAsInt(int operand);

这个接口只有一个方法applyAsInt,输出一个数,返回一个数,由程序员来决定对这个数的具体操作。

3.3 原子更新引用类型

Atomic提供了以下3个类

  • AtomicReference
  • AtomicReferenceFieldUpdater
  • AtomicMarkableReference
3.3.1 AtomicReference

4.3节的AtomicInteger是对基本类型做原子操作,实际上,我们也能对引用类型做原子操作,CAS对象的地址。
设有一个对象User,

class User {
    private String name;
    private int old;

    public User(String name, int old) {
        this.name = name;
        this.old = old;
    }

    public String getName() {
        return name;
    }

    public int getOld() {
        return old;
    }
}

我们原子性的对User做修改

public class AtomicReferenceDemo {

    public static AtomicReference atomicUserRef = new AtomicReference<>();

    public static void main(String[] args) {
        User user = new User("jojo", 17);
        atomicUserRef.set(user);
        User updateUser = new User("kaka", 20);
        atomicUserRef.compareAndSet(user, updateUser);
        System.out.println(atomicUserRef.get().getName() + ": " + atomicUserRef.get().getOld());
    }
}

输出

kaka: 20
3.3.2 ABA问题

什么是ABA问题,假设我们在多线程环境下使用CAS来确保原子性,线程1准备将变量从A修改到C,如果在线程1写入之前,存在线程2将A修改为B,又将B修改为A,那么到线程1写入时,就不会察觉到线程2所做的改动而执行成功。
试想一下,假如存在栈[1,2,3,3](自底向上),线程1需要修改栈顶元素3为4,在修改过程中,线程2执行出栈,栈中元素变为[1,2,3],这时线程1cas,值并没有变,修改成功,但是却不是我们想要修改的数了。
解决办法:增加版本号

public class AtomicStampedReferenceDemo {
    static AtomicStampedReference ref = new AtomicStampedReference<>(new User("jojo", 17), 0);

    public static void main(String[] args) {
        // 预期引用
        User user = ref.getReference();
        // 预期版本号
        int stamp = ref.getStamp();

        User updateUser = new User("kaka", 20);
        ref.compareAndSet(user, updateUser, stamp, stamp + 1);
    }
}
3.4 原子更新数组

Atomic提供了以下3个类

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray
3.4.1 AtomicIntegerArray
public class AtomicIntegerArrayDemo {
    static int[] value = new int[]{1, 2};

    static AtomicIntegerArray ai = new AtomicIntegerArray(value);

    public static void main(String[] args) {
        // 对0号位做更改
        ai.getAndSet(0, 3);
        System.out.println(ai.get(0));
        System.out.println(value[0]);

    }
}

输出:

3
1

数组value通过构造方法传递进去,然后AtomicIntegerArray会在当前数组复制一份,所以当AtomicIntegerArray对内部元素修改时,并不会影响传入的数组。

3.5 原子更新字段

Atomic提供了以下3个类

  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater
  • AtomicStampedReference
3.5.1 AtomicIntegerFieldUpdater
public class AtomicIntegerFieldUpdaterDemo {
    private static AtomicIntegerFieldUpdater a = AtomicIntegerFieldUpdater.newUpdater(Client.class, "old");

    public static void main(String[] args) {
        Client client = new Client("lihua", 8);
        a.compareAndSet(client, a.get(client), 10);
        System.out.println(a.get(client));
    }
}

class Client {
    private String name;
    public volatile int old;

    public Client(String name, int old) {
        this.name = name;
        this.old = old;
    }

    public String getName() {
        return name;
    }

    public int getOld() {
        return old;
    }
}

可以看到

  1. AtomicIntegerFieldUpdater是一个抽象类,使用静态方法newUpdater创建更新器,并且需要设置更新的属性。
  2. 更新的属性必须使用public volatile修饰。
3.6 Unsafe

点进AtomicInteger类的源码,我们可以看到它使用了Unsafe这个类,该类提供了以下原子方法

  • compareAndSwapInt 对int型做原子赋值操作
  • compareAndSwapLong 对long型做原子赋值操作
  • compareAndSwapObject 对对象地址做原子赋值操作

我们尝试直接调用Unsafe这个类的方法,自己实现对Teacher类的原子操作

@Data
class Teacher {
    volatile int id;
    volatile String name;
}

观察Unsafe类,存在theUnsafe这个属性,我们需要获得这个属性来执行相关操作。

// Unsafe.java
private static final Unsafe theUnsafe;

这就需要使用反射,具体实现如下所示。

public class UnsafeDemo {
    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        // 获得类中的指定字段theUnsafe
        Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
        // 由于theUnsafe私有,需要覆盖访问控制
        theUnsafe.setAccessible(true);
        // 获得这个字段的值 由于是静态字段 所以参数为null
        Unsafe unsafe = (Unsafe) theUnsafe.get(null);
        System.out.println(unsafe);

        // 1.获取域的偏移地址
        long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
        long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));

        Teacher t = new Teacher();
        // 2.执行cas操作
        unsafe.compareAndSwapInt(t, idOffset, 0, 1);
        unsafe.compareAndSwapObject(t, nameOffset, null, "lihua");

        // 3.验证
        System.out.println(t);

    }
}

输出:

sun.misc.Unsafe@7440e464
Teacher(id=1, name=lihua)

理解了Unsafe这个类后,我们就能看懂AtomicInteger的代码了。无非就是获取Unsafe,获取偏移量,使用unsafe进行cas原子操作。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/1007401.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号