原创文章首发于CSDN@碳基肥宅:https://blog.csdn.net/wyd_333/article/details/130305311
一、线程不安全的样例
下面就是一个线程不安全的例子。该代码中创建了一个counter变量,同时分别创建了两个线程t1和t2,让这两个线程针对同一个counter令其自增5w次:
class Counter { private int count = 0; //让count增加 public void add() { count++; } //获得count public int get() { return count; } } public class Test { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); // 创建两个线t1和t2,让这两个线程分别对同一个counter自增5w次 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(); // main线程等待两个线程都执行结束,然后再查看结果 t1.join(); t2.join(); System.out.println(counter.get()); } }
按理来说,最终输出counter的结果应当是10w次。但我们运行程序后发现,不但结果不是10w,而且每次运行的结果都不一样——实际结果看起来像一个随机值:
那么这里,实际结果与预期结果不相符,就可以认为是出现了由多线程引起的bug,即线程安全问题。
二、 导致线程安全问题的原因及解决措施
1、***本质原因:线程的无序调度(抢占式执行)
线程安全问题的出现与线程的调度随机性密切相关。线程的无序调度也可以理解为抢占式执行。
线程的抢占式执行指的是在多线程系统中,操作系统会对多个线程进行调度,并且可以随时中断正在执行的线程,转而执行另一个线程。
以上述的counter代码为例。count++这一语句,本质上是由3个CPU指令构成:
- load。把内存中的数据读取到CPU寄存器中。
- add。把寄存器中的值进行 +1 运算。
- save。把寄存器中的值写回到内存中。
CPU需要分三步走才能完成这一自增操作。如果是单线程中,这三步没有任何问题;但在多线程编程中,情况就会不同。由于多线程调度顺序是不确定的,实际执行过程中,这俩线程的count++操作的指令排列顺序会有很多种不同的可能:
此处这俩线程的指令的排列顺序(执行先后),有很多种排列的情况
上面只给出了非常小的一部分可能,事实上实际中可能的情况是大量的。而不同的排列顺序下,程序执行的结果可能是截然不同的!我们以其中的两种可能的情况,分析它的执行过程。
注意:t1和t2是两个线程,它们可能运行在不同的CPU核心上,也可能运行在同一个CPU核心上(但是是分时复用也即并发的)。这两种情况的最终效果是一致的,我们选择复杂度相对低的情况来进行演示,即两个线程在不同的CPU核心上运行。
正常的情况
有问题的情况
而所有的指令排列情况中,实际上只有下面这两种情况能得到正确的结果:
“顺序执行”,正确的情况
因此, 由于实际中,线程的调度顺序是无序的,我们并不能确定这俩线程在自增过程中经历了什么,也不能确定到底有多少次指令是“顺序执行”的,有多少次指令是“交错执行”的。最终得到的结果也就成了变化的数值。
针对上述的counter代码,还有一些补充:最终的结果count一定是小于等于10w的,但结果不一定大于5w。参考以下情况:
t1和t2彼此都会给对方带来很多无效自增。如上面图中t2就产生了两次无效自增。不过,count小于5w的情况出现的概率是非常小的。
归根到底,线程安全问题全是因为线程的无序调度。这导致了线程中指令的执行顺序不确定,从而导致了变化的结果。可以说,线程的无需调度(抢占式执行)是真正的罪魁祸首、万恶之源!
注意:有同学可能会想到,join()能防止线程的抢占执行。不过如果用join(),线程之间是串行执行的,如果这样的话还用多线程干嘛,直接让一个线程串行执行就好了。毕竟,多线程编程的初心就是进行并发编程,更好地利用多核CPU。
2、多个线程修改同一变量(多线程修改共享数据)
这个原因有三个关键词:多个,修改,同一。
也就是说,下面这三种情况,是线程安全的:
- 一个线程修改同一个变量(不涉及多线程) -> 安全。
- 多个线程读取同一个变量(变量的值不发生变化,最终的结果没有变数) -> 安全。
- 多个线程修改不同的变量(各自改各自的,相互之间不影响,和第1条本质上一样) ->安全。
上面的线程不安全的代码中,涉及到了多个线程针对 counter.count 变量的修改。此时,这个 counter.count 是一个多个线程都能访问到的共享数据。
counter.count 这个变量在堆上,可以被多个线程共享访问
3、修改操作不是原子的
原子指的是不可分割的最小单位。
像上面提到的count++自增操作就不是原子的,它可以再拆分成3个操作:load,add,save。单个CPU指令,就不可再拆分。因此,如果某个操作对应单个CPU指令,那么它就是原子的(如赋值操作=);但如果某个操作对应多个CPU指令,它大概率就不是原子的。
假设一个线程正在对一个变量进行操作。中途有其他线程插入进来了,如果这个操作被打断,结果就可能是错误的。 这点也和线程的抢占式调度密切相关。如果线程不是 “抢占” 的, 就算没有原子性,也问题不大。
正是因为不是原子的,导致两个线程的指令排列存在诸多变数。如果某个操作的原子的,那么指令之间就不会插入其他的指令,指令的排列也就不会存在诸多变数;既然不存在诸多变数,那么结果也就是确定的,此时就是线程安全的。
可以通过synchronized加锁解决这个问题。
4、内存可见性问题引起的不安全
内存可见性引起的不安全问题与上面的counter代码无关,我们重新书写一个演示代码。
先书写这样一段代码:
// 预期:在t2线程中输入一个非0的数,t1线程中循环终止 public class Test2 { public static int flag = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while(flag == 0) { // 空着 } System.out.println("循环结束!t1结束!"); }); Thread t2 = new Thread(() -> { Scanner reader = new Scanner(System.in); System.out.println("请输入一个整数:"); flag = reader.nextInt(); }); t1.start(); t2.start(); } } // 执行效果 // 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
由于两个线程共用同一块内存空间,flag作为一个内存中的变量,两个线程用的是同一个flag。
对于这段代码,预期的效果是:t1通过 flag == 0 作为条件进行循环,初始情况将进入循环;t2通过控制台输入一个整数,一旦用户输入了非0值,此时 t1 的循环就会立即结束,从而 t1 线程退出。
但当我们运行程序时会发现:当输入非0值1时,t1线程并没有结束。
通过jconsole也能观察到实际效果:t1线程仍然在执行,处于RUNNABLE状态。
这里,实际效果不等于预期效果,因此再次出现了bug,也即线程不安全问题。 这个问题的出现,就是由内存可见性引起的。
在上面的代码中,while (flag == 0) 中的这个 flag == 0 可以分为两步:load和cmp。其中,load的时间开销远远大于cmp(虽然读内存比读硬盘要快几千倍,但是读寄存器又要比读内存快几千倍)。
并且,该while循环空转的转速极快,能达到每秒钟上亿次。(这点非常重要,如果代码中有如sleep()这样大大降低循环执行速度的代码,则不会有上面bug的产生。因为根据下面编译器优化发生的条件,当循环速度(次数)下降,load就已不再是主要开销,编译器就没必要优化了。这样一来,代码是能够正常运行的。)
这时编译器发现:1、load的开销很大;2、每次load的结果都一样。于是编译器就做了一个非常大胆的操作,即把load给优化掉了(就是去掉了)。只有第一次执行load才真正执行了,后续循环都只cmp而不load了(相当于是复用了之前寄存器中的值)。
正常情况:工作内存每次都从主内存中load值
Bug情况:load这一步被取消,工作内存不再每次都从主内存中读取flag值
(这是编译器优化的手段,编译器优化是指能够智能地调整你的代码执行逻辑,在保证程序结果不变的前提下,通过加减语句、语句变换等一些列操作,让整个程序执行的效率大大提升。编译器的优化是一件非常普遍的事情。)
编译器对于“保证程序结果不变”的判定,在单线程下是非常准确的,但是在多线程的情况下就不一定了。可能导致在调整之后,虽然效率高了,但程序结果变了。即:编译器出现了误判,从而引起了bug。
总结:所谓的内存可见性问题,就是多线程环境下编译器对代码优化产生了误判,最终导致我们的代码 出现bug。可以用volatile关键字解决这个问题。
5、指令重排序引起的不安全
指令重排序,也是编译器优化的策略。在保证整体逻辑不变的情况下,编译器通过调整代码执行的顺序,让程序更高效。
比方说你去菜市场买菜,要买:1、土豆,2、黄瓜,3、鸡蛋,4、番茄 这四种菜,这四种菜在菜市场的摊位分布如下:
这时聪明的你肯定想到,既然按照清单上的购买顺序效率太低,那就不按照清单的顺序了,怎么方便怎么顺路,就怎么买~
编译器对程序的优化也是类似的。
但谈到优化,都得保证调整之后的结果和之前是不变的。单线程下容易保证,但多线程就不好说了。比如下面这个代码,就可能会因为指令重排序而出现问题:
这其中,s = new Student(); 这一操作实际上可以分成 3 步:
这个过程就好比买房:
先拿到钥匙还是先装修,问题都不大,最终都能拿到房子。
同样的,单线程中,先执行步骤2还是先执行步骤3,最终结果都是相同的。但多线程情况下,就可能出现如下问题:
可以用volatile关键字解决这个问题。
三、解决线程安全问题
1、synchronized关键字:保证修改操作的原子性
(1)什么是加锁
以上述counter代码为例:能有办法让count++操作变成原子的吗?当然是有的,这个方法就是加锁。如果给count++加锁,就能保证count++操作的原子性。
Java中虽然有读写锁,但一般不会特别去区分。默认情况下,用的就是一个很普通的加锁。
加锁的操作就好比我们在学校上厕所。有人进了厕所之后,就要把厕所门锁上,这时候他就可以在里面干一些事情而此时其他人无法进入厕所了。等到他干完了事情,再把锁打开,他就可以从厕所里出来了。当有人正在使用厕所的时候,如果其他人也想用,那么他们只能进行阻塞等待。
锁的核心操作有两个:1、加锁;2、解锁。一旦某个线程加了锁之后,其它线程也想加锁,就不能直接加上,就必须阻塞等待,直到拿到锁的那个线程释放了锁为止。
注意:线程调度是抢占式执行的,当1号老哥释放锁之后,等待锁的2号、3号、4号、5号谁能抢先一步拿到锁、成功加锁,是不确定的。 每个线程都有概率拿到锁,这完全看系统是如何调度的。
(2)如何进行加锁:synchronized关键字-监视器锁monitor lock
synchronized的读音:https://fanyi.baidu.com/#en/zh/synchronized
synchronized是Java中的关键字,可以直接使用这个关键字来实现加锁效果。
前面提到,锁的两个核心操作是加锁和解锁。此处使用代码块的方式来表示:
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
{ } 就可以想象成厕所~
synchronized( 锁对象 ) { }
下面这张图也非常形象地表示锁竞争的情况:
注意:( )里的锁对象可以写作任意一个Object对象(是类类型即可,内置类型不行)。此处写了this,相当于将Counter counter = new Counter()的这个counter实例作为锁对象。this指向的是当前对象,add作为成员方法,观察代码可知,每次都是counter实例来调用add,this指向的就是当前对象counter。
使用this,哪个实例调用的add()就是对哪个实例加锁。
在这个代码中,线程t1和t2给同一个锁对象(this,即counter)加了锁,就会产生锁竞争。t1拿到锁,t2就得阻塞。此时就可以保证自增操作count++是原子的,不会受多线程抢占式调度的影响了。 这时再运行程序,程序的执行结果就是10w了:
时间轴图:
加锁,本质上是把并发的变成了串行的。但加锁与join()有本质区别:join()是让两个线程完整地进行串行,加锁是两个线程的某个小部分串行了,其它的大部分都是并发的。 上述代码的所有步骤中,只有count++这一步是串行的,其它的操作如创建变量i,判定条件,调用add()和add()返回等操作全是并行的。
可见,加锁会导致阻塞。代码阻塞对程序的效率是有一定的影响的。此处加了锁,要比不加锁更慢一些,但肯定要不串行更快;同时也比不加锁算得更准。
(3)synchronized修饰方法
修饰普通成员方法
如果直接用synchronized修饰成员方法,这就相当于以this为锁对象:
修饰静态成员方法
如果synchronized修饰静态方法(static,即类方法),此时就不是给this加锁了,而是给类对象加锁。
补充:这里的类对象指的是 Counter.class。我们在.java源代码文件中编写的代码被javac编译为.class(二进制字节码文件)后,就可以被JVM执行了。而JVM要想执行这个.class文件,就得先把该文件的内容读取到内存中。这个将.class文件内容读取到内存中的操作叫做类加载。
在内存中,用类对象来表示.class文件内容。在.class文件中,描述了类的方方面面的详细信息,包括但不限于:1、类的名字;2、类有哪些属性,属性的名字、类型、访问权限;3、类有哪些方法,方法的名字,参数、类型、访问权限;4、类继承自哪个类;5、类实现了哪些接口……
此处的类对象,就相当于是“实例的图纸”。有了这个图纸,才能了解到这个实例是啥样的,进一步地才可以使用反射 API 来获取这里的一些信息。
(4)手动指定一个锁对象
更多时候,还是由我们自己来手动指定一个锁对象:
synchronized()括号中写什么都行,只要是一个Object实例。事实上,锁对象没有什么特别的,就是一个吉祥物,唯一的作用还是这句话:如果多个线程针对同一个对象加锁,就会产生锁竞争;多个线程针对不同的对象加锁,就不会有锁竞争。锁对象仅仅是起到一个标识的效果。
2、volatile 关键字:能保证内存可见性
(1)volatile 修饰的变量,能够保证其 “内存可见性”。
volatile的读音:https://fanyi.baidu.com/#en/zh/volatile
在上面引起线程不安全的原因中,提到了内存可见性问题:例如在下面的代码中,由于t1线程中while循环的转速极快,而将flag变量load进内存这一指令步骤耗费了主要开销,于是编译器就将load这一步骤优化掉了。这样一来,t1线程中的flag并不是每次循环都从内存中读取的,而是第一次从内存中读取到的值;且t2线程中对flag作出的更改在t1线程中感知不到。因此,该程序的运行结果并不会符合我们的预期。
// 预期:在t2线程中输入一个非0的数,t1线程中循环终止 public class Test2 { public static int flag = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while(flag == 0) { // 空着 } System.out.println("循环结束!t1结束!"); }); Thread t2 = new Thread(() -> { Scanner reader = new Scanner(System.in); System.out.println("请输入一个整数:"); flag = reader.nextInt(); }); t1.start(); t2.start(); } } // 执行效果 // 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
而如果一个变量被 volatile 修饰,那么此时编译器就会禁止上述优化。换句话说,volatile 关键字能保证每次都从内存重新读取数据。
我们给flag加上volatile关键字修饰,再次运行上述代码。这时,代码的bug解决了,程序能够按照预期执行:
在加上volatile之前,由于t1线程中编译器优化掉了对flag的load这一指令,因此我们在t2线程中对flag作出的更改,在t1中感知不到。而加上了volatile之后,它能保证每一次while循环的条件判断都重新读取内存中flag的值,那么t2中对flag的修改在t1中能够立即感知到,这样一来t1的循环就能正确退出。
给flag变量加上volatile,能恢复正常情况
直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存),速度非常快,但是可能出现数据不一致的情况。而加上 volatile , 强制读写内存, 速度是慢了,但是数据变的更准确了。这也印证了数据的准确性和程序效率往往不能兼得。
(2)volatile 不保证原子性
注意:volatile 和 synchronized 有着本质的区别。synchronized 能够保证原子性,而volatile 保证的是内存可见性,与原子性无关。
*至于 synchronized 是否也能保证内存可见性,是众说纷纭,存在争议的。
结论:
volatile适用于一个线程频繁读,一个线程写的情况。
synchronized适用于多个线程写的情况。
3、volatile关键字:禁止指令重排序
上面引起线程安全问题的原因中,提到了因为编译器优化造成指令重排序而导致的问题:
volatile关键字可以禁止指令重排序。如果用volatile关键字修饰s,那么创建对象时候就会禁止指令重排序,就能够保证执行顺序是 1->2->3 了。(PS:这个场景通过加锁也可以解决问题。)
四、补充:加锁的注意事项
加锁其实是一个比较低效的操作,因为加锁就可能涉及到阻塞等待。因此,实际情况中是“非必要,不加锁”。(如果不加锁程序就无法执行或执行结果错误,这就是不得不加锁的情况,此时必须加锁。)