目录
一、初识线程安全
什么是线程安全问题
理解线程不安全的原因
原因总结
二、解决线程不安全
加锁🔐
锁对象
synchronized几种使用方式
死锁🔏
死锁的三个场景
(1)一个线程针对一把锁连续加锁两次
(2)两个线程两把锁
(3)N个线程M个锁
如何解决死锁问题
三、内存可见性问题
什么是内存可见性问题
volatile 关键字
多线程章节中,最重要的话题就是线程安全。因为多个线程同时执行某个代码的时候,可能会引起一些奇怪的bug,理解了线程安全,才能避免/解决上述的bug。
一、初识线程安全
什么是线程安全问题
public class Demo18 {private static int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count=" + count);}
}
预期是10w结果和实际不一样,而且每次运行的结果都不一样
理解线程不安全的原因
上面代码中的cout++操作,其实在CPU视角来看,是3个指令
1)把内存中的数据,读取到CPU寄存器里 load
2)把CPU寄存器里的数据+1 add
3)把寄存器的值,写回内存 save
CPU在调度执行线程的时候,说不上啥时候,就会把线程给切换走(抢占式执行,随机调度)。指令是CPU执行的最基本单位,要调度,至少把当前执行完,不会执行一半调度走。但是由于cout++是三个指令,可能会出现CPU执行了其中的1个指令或者2个指令或者3个指令调度走的情况,这是都有可能无法预测的。
基于上面的情况,两个线程同时对count进行++就容易出现bug。
上述的执行顺序,只是一种可能的调度顺序,由于调度过程是"随机"的,因此就会产生很多其他的执行顺序。上述过程中,明明是++了两次但是最终结果,还是1,因为这两次加的过程中,结果出现了"覆盖"。
由于循环5w次过程中,也不知道有多少次的执行顺序,是这种正确情况,有多少次是其他的出错情况,因此最终的结果,是不确定的值,而且这个值,一定小于10W。
对于多线程代码来说,最大的困难,就在于"随机调度,抢占式执行",是多线程编码的"罪魁祸首,万恶之源”。
面试的时候,被问到,线程不安全的原因,你也可以尝试给面试官画图。
其他所有原因总结
1️⃣线程在操作系统中,随机调度,抢占式执行 [根本原因]
2️⃣多线程,同时修改同一个变量(如果是多个线程读取变量或只有一个线程或修改不同的变量都不会)
3️⃣修改操作,不是"原子"的(count++ 背后是三个指令,这个操作不是原子的)4️⃣内存可见性问题
5️⃣指令重排序
二、解决线程不安全
第一个原因,无法干预,操作系统内核,负责的工作,咱们作为应用层的程序员,无法干预。第二个原因,可以让线程修改不同的变量,可能可行,取决于实际的需求,有的场景能这么改,有的场景不能这么改,取决于实际的需求,在Java中这个方案不算很普适的方案,但是有的语言,更青睐这个方案,erlang这个语言,就是采取这个方案,解决并发编程中的"线程安全"问题的,它没有变量,所有的"变量"都是"常量",不能修改,自然也就不必担心上述的线程安全问题了。
加锁🔐
解决线程安全问题,最主要的办法,就是把"非原子"的修改,变成"原子"--“加锁”。
此处的加锁,并不是真的让count++变成原子的,也没有干预到线程的调度,只不过是通过这种加锁的方式,使一个线程在执行count++的过程中,其他的线程的count++不能插队进来。
下面结合代码来看:Java中提供了synchronized关键字,来完成加锁操作
public class Demo18 {private static int count = 0;private static Object locker = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}}});t1.start();t2.start();t1.join();t2.join();System.out.println("count=" + count);}
}
进入代码块就会进行加锁,出了代码块就会进行解锁
本质上是把随机的并发执行过程,强制变成了串行,从而解决了刚才的线程安全问题
锁对象
上述代码有效的前提是,两个线程,都加锁了,而且是针对同一个对象加锁
锁对象作用,就是用来区分,多个线程,是否是针对"同一个对象"加锁",是针对同一个对象加锁,此时就会出现"阻塞"(锁竞争/锁冲突)。不是针对同一个对象加锁,此时不会出现"阻塞",两个线程仍然是随机调度的并发执行。锁对象,填哪个对象,不重要,重要的是,多个线程是否是同一个锁对象。
锁对象,肯定得是个对象,不能拿int,double这种内置类型,来写到()里,但是其他的类型,只要是Object(或者是子类)都是可以的,例如字符串就可以。
或者
⚠️注意:咱们此处的加锁后的代码本质上比join的串行执行,效率还是要高的。加锁,就是变成"串行执行",那么是否就没必要使用多线程了?当然不是的,加锁,只是把线程中的一小部分逻辑,变成"串行执行",剩下的其他部分,仍然是可以并发执行的。
如果是3个线程针对同一个对象加锁,也是类似的情况。其中某个线程先加上锁,另外两个线程阻塞等待(哪个线程拿到锁,这个过程不可预期的)。拿到锁的线程释放了锁之后,剩下两个线程谁先拿到锁呢?也是顺序不确定的。123,比如最开始1拿到锁,2、3阻塞等待1释放锁之后,2和3谁先拿到锁?不一定,也是随机的,即使2先加锁,3后加锁,也不一定谁先拿到。此处synchronized是JVM提供的功能,synchronized底层实现就是JVM中,通过C++代码来实现的,进一步的,也是依靠操作系统提供的API实现的加锁,操作系统的API则是来自于CPU上支持的特殊的指令来实现的。
synchronized几种使用方式
synchronized还可以修饰一个方法
class Counter {public int count = 0;public void add() {count++;}
}public class Demo19 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (counter){counter.add();}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (counter){counter.add();}}});t1.start();t2.start();t1.join();t2.join();System.out.println("counter=" + counter.count);}
}
或者
class Counter {public int count = 0;synchronized public void add() {count++;}
}public class Demo19 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {counter.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println("counter=" + counter.count);}
}
1)synchronized(){}
圆括号指定锁对象
2)synchronized修饰一个普通的方法
相当于针对this加锁
3)synchronized修饰一个静态的方法
相当于针对对应的类对象加锁
锁是解决线程安全问题典型的做法,关于锁内部的原理和特性,Java其他的锁的实现,后面慢慢展开。
死锁🔏
死锁的三个场景
(1)一个线程针对一把锁连续加锁两次
class Counter {public int count = 0;public void add() {synchronized (this) {count++;}}
}public class Demo19 {public static void main(String[] args) throws InterruptedException {Counter counter = new Counter();Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (counter) {counter.add();}}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {synchronized (counter) {counter.add();}}});t1.start();t2.start();t1.join();t2.join();System.out.println("counter=" + counter.count);}
}
1)里面的synchronized要想拿到锁,就需要外面的synchronized释放锁
2)外面的synchronized要释放锁,就需要执行到}
3)要想执行到}就需要执行完这里的add
4)但是add正阻塞着
上面一顿分析猛如虎,结果一运行,结果出来了,没有死锁呀,这里没有死锁,是Java的synchronized做了特殊处理。同样的代码,换成C++/Python就会死锁,Java为了减少程序员写出死锁的概率,引入了特殊机制,解决上述的死锁问题,"可重入锁"。加锁的时候,是需要判定,当前这个锁,是否是被占用的状态,可重入锁,就是在锁中,额外记录一下,当前是哪个线程,对这个锁加锁了。对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何加锁操作,也不会进行任何的"阻塞操作"而是直接放行,往下执行代码。
可重入锁引入之后,为了避免,出现上述一个线程连续加锁两次就死锁的情况,synchronized就是可重入锁,可重入锁内部记录了当前是哪个线程持有的锁,后续加锁的时候都会进行判定,还会通过一个引用计数维护当前已经加锁几次了,并且描述出何时真正释放锁。
(2)两个线程两把锁
public class Demo20 {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {System.out.println("t1 加锁 locker1 完成");//这里的 sleep 是为了确保,t1 和 t2 都先分别拿到 locker1 和 locker2 然后再分别拿对方的锁//如果没有 sleep 执行顺序就不可控,可能出现某个线程一口气拿到两把锁,另一个线程还没执行呢,无法构造出死锁try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}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) {e.printStackTrace();}synchronized (locker1) {System.out.println("t2 加锁 locker1 完成");}}});t1.start();t2.start();}
}
借助第三方工具也可以看到两线程都是BLOCKED的状态
(3)N个线程M个锁
死锁经典模型:哲学家就餐问题
如何解决死锁问题
死锁产生的四个必要条件:
·互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
·不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
·请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
·循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
其中最容易破坏的就是"循环等待"
最常用的一种死锁阻止技术就是锁排序,假设有N个线程尝试获取M把锁,就可以针对M把锁进行编号(1,2,3......M)N个线程尝试获取锁的时候,都按照固定的按编号由小到大顺序来获取锁。这样就可以避免环路等待。
每个滑稽加锁的时候一定是先拿起编号小的筷子,后拿起编号大的筷子。同一时刻,所有线程拿起第一根筷子。
public class Demo20 {private static Object locker1 = new Object();private static Object locker2 = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (locker1) {System.out.println("t1 加锁 locker1 完成");//这里的 sleep 是为了确保,t1 和 t2 都先分别拿到 locker1 和 locker2 然后再分别拿对方的锁//如果没有 sleep 执行顺序就不可控,可能出现某个线程一口气拿到两把锁,另一个线程还没执行呢,无法构造出死锁try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t1 加锁 locker2 完成");}}});Thread t2 = new Thread(() -> {synchronized (locker1) {System.out.println("t2 加锁 locker1 完成");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}synchronized (locker2) {System.out.println("t2 加锁 locker2 完成");}}});t1.start();t2.start();}
}
三、内存可见性问题
什么是内存可见性问题
如果一个线程修改,另一个线程读取,这样的代码是否会有线程安全呢?
import java.util.Scanner;public class Demo21 {private static int n = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while (n == 0) {// 啥都不写}System.out.println("t1 线程结束循环");});Thread t2 = new Thread(() -> {Scanner sc = new Scanner(System.in);System.out.println("请输入一个整数:");n = sc.nextInt();});t1.start();t2.start();}
}
上述问题的原因就是“内存可见性问题”
内存可见性问题,本质上,是编译器/JVM对代码进行优化的时候优化出bug,如果代码是单线程的,编译器/JVM,代码优化一般都是非常准确的,优化之后,不会影响到逻辑。但是代码如果是多线程的,编译器/JVM的代码优化,就可能出现误判(编译器/JVM的bug),导致不该优化的地方,也给优化了,于是就造成了内存可见性问题了。编译器为啥要做上述的代码优化?为啥不老老实实的按照程序员写的代码,一板一眼执行,主要是因为,有的程序员,写出来的代码,太低效了,为了能够降低程序员的门槛,即使你代码写的一般,最终执行速度也不会落下风。因此主流编译器,都会引入优化机制(优化手段是多种多样的),优化编译器自动调整你的代码,保持原有逻辑不变的前提下,提高代码的执行效率,代码优化的效果是非常明显的。
解决方案一:
此处即使sleep时间非常短,但是刚才的内存可见性问题就消失了,t2的修改就能被t1感知到。说明加入sleep之后,刚才谈到的针对读取n内存数据的优化操作,不再进行了。和读内存相比,sleep开销是更大的,远远超过了读取内存就算把读取内存操作优化掉,也没有意义,杯水车薪。
volatile 关键字
如果代码中,循环里没有sleep,又希望代码能够没有bug的正确运行呢?volatile关键字修饰一个变量,提示编译器说,这个变量是"易变"的。编译器进行上述优化的前提是编译器认为,针对这个变量的频繁读取,结果都是固定的,此时,编译器就会禁止上述的优化,确保每次循环都是从内存中重新读取数据。
import java.util.Scanner;public class Demo21 {private static volatile int n = 0;public static void main(String[] args) {Thread t1 = new Thread(() -> {while (n == 0) {// 啥都不写}System.out.println("t1 线程结束循环");});Thread t2 = new Thread(() -> {Scanner sc = new Scanner(System.in);System.out.println("请输入一个整数:");n = sc.nextInt();});t1.start();t2.start();}
}
编译器的开发者,知道这个场景中,可能出现误判,于是就把权限交给了程序员,让程序员能够部分的干预到优化的进行。让程序员显式的提醒编译器,这里别优化。引入volatile的时候,编译器生成这个代码的时候,就会给这个变量的读取操作,附近生成一些特殊的指令,称为"内存屏障",后续JVM执行到这些特殊指令,就知道了,不能进行上述优化了。
⚠️注意:volatile只是解决内存可见性问题,不能解决原子性问题。如果两个线程针对同一个变量进行修改(count++),volatile无能为力:
public class Demo22 {private static volatile int count = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();t1.join();t2.join();System.out.println("count =" + count);}
}