文章目录
- 多线程不安全的原因
- 大的层面->
- 多线程是随机调度的
- 容易产生死锁
- 小的层面->
- 内存不可见性
- 引入volatile关键字
- 指令重排序
- 不是原子性带来的隐患
- synchronized
- 锁的互斥性及作用
- 可重入性——解决死锁
- wait()和notify()
- 两个突然迸发出的疑问
多线程不安全的原因
大的层面->
多线程是随机调度的
操作系统根据CPU时间片轮转、优先级调度等调度策略,让各个线程轮流上台执行,而不是一次性做完一个线程的任务,而这个分配调度的过程是我们无法预测的,多线程任务产生与预期不符的结果—>线程不安全问题。多个线程共享数据并且可修改————线程A修改共享变量S,线程B修改共享变量S,线程C读取,由此产生:
- C在A和B修改前读取了,结果为原始值
- C在A
修改成功
后,B修改前读取了,结果为A修改后的(为何标红,因为修改到读取这里还大有文章,还存在内存不可见和指令重排序两个隐性问题) - B的修改时间早于A…
- …
容易产生死锁
锁使得多个线程在执行相同的方法体/代码块时,避免线程间同步执行任务而产生资源冲突、操作重复的问题,但是锁的存在也可能会让线程产生循环阻塞的效果,也就是死锁:
public class DemoThread {public static void main(String[] args) throws InterruptedException {
// 构造死锁代码Object locker1=new Object();Object locker2=new Object();Thread t1=new Thread(()->{synchronized(locker1){System.out.println("t1尝试获取locker1");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker2){System.out.println("t1尝试获取locker2");}}});Thread t2=new Thread(()->{synchronized(locker2){System.out.println("t2尝试获取locker2");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (locker1){System.out.println("t2尝试获取locker1");}}});t1.start();t2.start();}
}
结果是打印两句后,程序不在进行下一步。原因是t1先一步获取到了locker1
,t2先一步获取到了locker2
,要让t1释放locker2
就得让t1的代码块执行完也就是获取locker2
,而要获取locker2
就得让t2释放,t2又同时需要获取locker1
。这样两个线程都在等对方
释放锁,就双双进入阻塞(BLOCKED
)状态了。
t1尝试获取locker1
t2尝试获取locker2
使用jdk自带的jconsole.exe
观察线程t1、t2状态:
需要注意的是Java解决了可重入锁的问题(死锁的一种情况),也就是一个线程不能用锁把自己“锁住”,
public class DemoThread {public static void main(String[] args) throws InterruptedException {Object locker1=new Object();Thread t1=new Thread(()->{synchronized(locker1){synchronized (locker1){System.out.println("t1请求在锁里竞争自己这个锁");}}});t1.start();}
}
结果正常输出并且正常结束。(可重入的原理在synchronized段落细说)
t1请求在锁里竞争自己这个锁
小的层面->
内存不可见性
多线程的共享变量和对象存储在主内存
【Main Memory】中,而每个线程有自己的工作内存
【Working Memory】,实际上主内存
就是物理意义上的CPU里的“内存”,工作内存
就是CPU里的寄存器和缓存。共享变量实际存储在主内存里,而工作内存里的只是线程拷贝的副本,程序进行读取操作读取的是自己工作内存的副本值,线程修改变量时需要先修改工作内存里的副本再刷新到主内存里才算成功。而一个线程修改这个共享变量,还没刷新到主内存上,其他线程的副本还没与主内存的值同步就读取了自己工作内存的副本,产生了变量似乎更新失败的结果,这就是内存不可见(叫内存不可及时读到更好些)。
public class Main {public static boolean state=true;public static void main(String[] args) {Thread t1=new Thread(()->{while(state){//state一更新,t1就结束}});Thread t2=new Thread(()->{Scanner scanner=new Scanner(System.in);System.out.print("修改state:");state=scanner.nextBoolean();});t1.start();t2.start();}
}
然而在输入false后,没有出现程序结束的情况,原因是JIT编译器的自动优化,重复的读取操作让它将t1读取内存改为读取寄存器,而这时t2修改了值也就无法被t1察觉到,这也算内存不可见。
所以Java引入了volatile来规避这个问题。使用voltile修饰state
后,上面的程序就可以正常结束了。
public static volatile boolean state=true;
引入volatile关键字
具体逻辑是被volatile修饰的变量只要涉及到修改,那么就会强制刷新主内存,使其他线程立即可见,即在值写入主内存前,其他线程无法插一脚进去来个读操作,其他线程拷贝的副本会失效,要进行读取,必须到主内存中读取,这样也顺便刷新了自己的工作内存。
使用voltile修饰state
后,上面的程序就可以正常结束了。
volatile的这些特性都是基于内存屏障
实现的,关于内存屏障
,我想这篇文章讲解的比我会更好:《点此链接》
但volatile不具有原子性,也可以使用synchronized和原子类来解决内存不可见问题。
指令重排序
不是原子性带来的隐患
- Java指令不一定是原子性的;原子性指对某个变量的操作或访问是不可分割的,要么完全执行,要么不完全执行,且这个过程不会被其他线程中断,比如变量自增就不是原子性的,底层分为读取>自增>加载到内存上三个指令操作。这样的任务细分可以提高程序的效率但也会因为指令重排序而产生问题。
- 指令重排序是指编译器对代码的自动优化,即执行的指令不一定按代码顺序来,还有CPU对指令的动态调整,对于单线程很适用,但是在多线程中就会产生线程不安全问题。
所以在多线程场景下,对共享变量的操作会发生这样的问题:
```java
public class Main {public static int a=0;public static boolean flag=false;public static void main(String[] args) {Thread t1=new Thread(()->{a = 1; // 普通写操作flag = true; // 普通写操作});Thread t2=new Thread(()->{if (flag) { // 普通读操作System.out.println(a); // 可能输出0}});t1.start();t2.start();}
}
结果可能是
0
假定未发生内存不可见问题,可能的情况是t2对a的读指令排在t1的写指令前…
使用volatile、锁、原子类可以规避指令重排序。
synchronized
锁的互斥性及作用
synchronize [ˈsɪŋkrəˌnaɪz];锁可以解决内存不可见问题和指令重排序问题,它的操作是原子性的,可以让一段操作(代码块/方法)完全执行而中途不受其他干扰(锁的互斥性
)。例如多个线程对一个共享变量同时修改,对这个修改操作加锁就可以解决存在的内存不可见和指令重排序问题,让这个修改操作真真正正的修改刷新到主内存上了且每个线程也都看到了,读取指令也不再是插在修改指令前,只能等持有锁的线程修改变量后才能读取。
public class DemoThread {public static int count = 0;public static void main(String[] args) throws InterruptedException {//两个线程让同一个变量自增两万次//count++操作必须由持有锁的线程执行完结束后另一个线程可以获取最新count值并自增,再刷新回主内存Object lock=new Object();Thread t1=new Thread(()->{for(int i=0; i<10000; i++){synchronized(lock){count++;}}});Thread t2=new Thread(()->{for(int i=0; i<10000; i++){synchronized(lock){count++;}}});t1.start();t2.start();//让main线程等待俩线程结束再观察count值t1.join();t2.join();System.out.println("count="+count);
结果:
count=20000
修饰代码块时,锁对象可以是任意对象,不过一般都是创建一个Object对象。
其他用法时的锁对象:
- 修饰普通方法,锁对象是当前对象,相当于
synchronized(this){...}
public synchronized void add(){}
- 修饰静态方法,锁对象是类对象,也就是.class对象
public synchronized static void add(){}
可重入性——解决死锁
Java解决了可重入锁的问题,允许一个线程重复持有自己的锁,并且可以正常释放。
· ReentrantLock是java.util.concurrent.locks包的一个类,它的实例也叫锁对象,在它内部维护了两个字段,一个
state
变量(volatile int state
)和一个持有线程(ExclusiveOwnerThread
)用来记录当前持有锁的线程,每次加锁state
自增一次,state=0表示0个线程持有该锁,state>0表示锁已被持有,数值代表重入的次数,而持有线程则记录是哪个线程持有了锁(将持有锁的线程的引用赋值给ExclusiveOwnerThread
)。它是显式锁,需要你自己去手动创建才能实现可重入。
·那么我没用过ReentrantLock,咋也可以可重入的;别急,synchronized就是基于可重入锁机制实现的,在 JVM 中,锁对象(你自己定义的)会维护一个持有者的线程 ID 和锁的计数器。当线程第一次获取锁时,JVM 会记录该线程的锁状态。如果该线程再次请求同一个锁,JVM 会检查它是否已经持有该锁。如果是,它不会阻塞,而是允许线程继续执行,并且记录该线程持有锁的次数增加一次。
public class DemoThread {public static void main(String[] args) throws InterruptedException {Object locker1=new Object();//t1似乎在自己锁自己??!Thread t1=new Thread(()->{synchronized(locker1){synchronized (locker1){System.out.println("t1请求在锁里竞争自己这个锁");}}});t1.start();}
}
程序正常打印并结束。
wait()和notify()
线程调度是随机的,而调度的过程由操作系统控制,为了让多个线程有逻辑性、可控的执行,那么就需要用到wait()和notify()/notifyAll()。
- 它们在Object类里定义的,所以任意类的对象都可用这三种方法
- Java定义都必须搭配synchronized使用
- 使用wait的前提是持有锁,否则抛IllegalMonitorStateException异常
- 没有线程使用锁对象调用wait(),该锁对象调用notify()/notifyAll()不会报错,但多此一举
方法 | 说明 |
---|---|
wait() | 使用锁对象 调用,使用了该对象用作锁对象的线程释放锁 进入阻塞等待状态 |
wait(long timeout) | 使用锁对象 调用,使用了该对象用作锁对象的线程释放锁 进入阻塞等待状态,有等待时间上限。参数单位:ms |
notify() | 使用锁对象 调用,唤醒一个使用了该对象用作锁对象的线程,使之持有锁 |
notifyAll() | 使用锁对象 调用,唤醒所有使用了该对象用作锁对象的线程,调度器决定它们持有锁的先后,直到所有线程结束 |
两个突然迸发出的疑问
那么问题来了:
- 如果某个线程的锁对象调用了notify(),而且有多个线程使用该锁对象调用了wait(),是不是会像notifyAll()一样唤醒所有线程??
public class Main {public static void main(String[] args) throws InterruptedException {Object locker1=new Object();//定义锁对象Thread t1=new Thread(()->{synchronized (locker1){try {locker1.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1结束");}});Thread t2=new Thread(()->{synchronized (locker1){try {locker1.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t2结束");}});t1.start();t2.start();Thread.sleep(10);//sleep的作用是让wait执行在notify前,否则任何线程都唤醒不了synchronized (locker1){locker1.notify();}
}
结果是打印"t2结束"或"t1结束"后,另一个线程就杳无音讯了,程序也结束不了
显而易见这个notify()不会唤醒两个线程。在这种场景下,notify()会在等待的线程中挑一个线程唤醒(操作系统线程调度的范畴),让其加入等待行列,等以前持有锁的线程释放后,它立即获得锁,其他线程依旧阻塞等待。而notifyAll(),会唤醒所有线程,线程调度让它们依次获取锁,直到所有线程结束。
- 如果一个线程的任务执行到一半,该线程或者其他线程蹦出个wait强行让他释放锁进入阻塞状态,那么后面我再获得锁,任务咋进行?
public class Main {public static void main(String[] args) throws InterruptedException {Object locker1=new Object();//定义锁对象Thread t1=new Thread(()->{synchronized (locker1){try {System.out.println("调用wait");locker1.wait();//执行到这里锁被释放System.out.println("结束wait");} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1结束");}});Thread t2=new Thread(()->{synchronized (locker1){//相比于上面的例子删去了同步代码块System.out.println("t2结束");}});t1.start();t2.start();Thread.sleep(10);//sleep的作用是让wait执行在notify前,否则任何线程都唤醒不了synchronized (locker1){locker1.notify();}
}
结果是:
调用wait
t2结束
结束wait
t1结束
显而易见,运行过程应该是:t1先获取到了锁,执行到了wait()方法立即释放锁,同时t2执行完毕,main线程获取到了锁,执行notify(),t1线程又获取到了锁,从之前的执行进度继续执行。
所以线程半路被wait()打断强行阻塞,后面再获取锁会继续从被打断的进度开始继续执行。
七千字长文,点个关注再走呗😉
完。