☕导航小助手☕
🍚写在前面
🍛一、线程安全概述
🧇🧇1.1 什么是线程安全问题
🦪🦪1.2 存在线程安全问题的实例
🍜二、线程安全问题及其解决办法
🍣🍣2.1 案例分析
🍤🍤2.2 造成线程不安全的原因
🥩🥩2.3 线程加锁操作解决 原子性问题
🧀🧀🧀2.3.1 什么是加锁
🍞🍞🍞2.3.2 使用 synchronized关键字 进行加锁
🍰🍰🍰2.3.3 synchronized 使用示例
🍱三、Java标准库里面的线程安全类
写在前面
线程的安全性问题,是多线程编程中 最最重要的部分,也是线程中最最复杂的部分~
据说,它是一种挑战人脑智商的极限的问题~
同时,对于这个问题,将分为两篇博客 一一的介绍开来~
下面,让我们一起来看看吧 ......
一、线程安全概述
1.1 什么是线程安全问题
线程安全问题 出现的 "罪魁祸首",正是 调度器的 随机调度 / 抢占式执行 这个过程~
在随机调度之下,多线程程序执行的时候,有无数种可能性,有无数种可能的排列方式~
在这些排列顺序中,有的排列方式 逻辑是正确的,但是有的排列方式 可能会引出 bug~
对于多线程并发时,会使程序出现 bug 的代码 称作线程不安全的代码,这就是线程安全问题~
接下来,举出一个典型的例子,来观察一番 到底什么是线程安全问题~
1.2 存在线程安全问题的实例
创建两个线程,让这两个线程 同时并发 对一个变量,自增 5w 次,最终预期能够一共自增 10w 次~
package thread;
class Counter {
//用来保存计数的变量
public int count;
public void increase() {
count++;
}
}
public class Demo14 {
public static void main(String[] args) {
//这个实例用来进行累加
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count:" + counter.count);
}
}
运行结果:
很明显,我们可以发现 程序运行的结果 都是 小于 10w 次的,即便是运行多次,结果也都是小于 10w 次~
事实上,正确的结果是,得到的数字 count 在 5w 到 10w 之间~
这是怎么回事呢?
我们接下来慢慢分析~
二、线程安全问题及其解决办法
2.1 案例分析
按理来说,上述实例 运行的结果 count 应该等于 10w~
可是 连续运行多次,就会发现 每一次运行的结果都不一样,但都是小于 10w,这是为什么呢?
这个就是 线程不安全的问题~
其原因主要是:随机调度的顺序不一样,就导致程序运行的结果不一样~
上述的 bug 是怎么形成的呢?
这个得需要站在硬件的角度来理解:
像 count++ 这一行代码,其实对应的是 三个机器指令:
- 首先 需要从内存中读取数据到 CPU,这个指令称为 load指令
- 其次 在 CPU 寄存器中,完成加法运算,这个指令称为 add指令
- 最后 把寄存器的指令 写回到内存中,这个指令称为 save指令
这个在 JavaEE初阶 的第一篇文章中提到过,不清楚的可以跳转过去看一看~
🚪传送门:【JavaEE初阶】计算机是如何工作的🚪
这三个步骤,如果是在单线程下执行,那是没有任何问题的~
但是如果是在多线程下执行,那就不一定了~
现在,我们可以以一条时间轴,来画一下其中常见的情况:
load 是把内存中的数据读到 寄存器里,add是在寄存器里面进行加法操作,save是把寄存器里面的值放回到内存~
情况一:
对两个数进行自增操作,内存 初始值为 0,两个线程进行并发执行,进行两次自增~
寄存器A 表示 线程1 所用的寄存器,寄存器B 表示 线程2 所用的寄存器~
通过上述的执行过程,我们可以看到,两个线程 各自增一次,预期 自增两次,实际上的结果是 2,没有任何问题~
看起来是没有任何问题的,可是实际情况下 这个可是多线程,只是出现无数种情况的其中一种而已,只是这种排列方式恰好没有问题(其他的排列方式就不一定了)~
情况二:
对两个数进行自增操作,内存 初始值为 0,两个线程进行并发执行,进行两次自增~
寄存器A 表示 线程1 所用的寄存器,寄存器B 表示 线程2 所用的寄存器~
如上所示,明明在内存里面自增了两次,但是最终内存的值 仍然是 1~
这就是典型的线程不安全导致的 bug~
情况三:
对两个数进行自增操作,内存 初始值为 0,两个线程进行并发执行,进行两次自增~
寄存器A 表示 线程1 所用的寄存器,寄存器B 表示 线程2 所用的寄存器~
如上所示,最终的内存中保存的是 1~
这是典型的线程不安全的问题~
其他的情况:
由于是多线程,所以有无数种情况 ~
总之,在无数中的排列顺序情况下,只有 "先执行完第一个线程,再执行完第二个线程" 以及 "先执行完第二个线程",再执行完第一个线程 的这两种情况,是没有问题的~
剩下的情况,全部都是和正确结果不匹配~
回到最初的代码程序,我们就可以知道:
在极端情况下,如果所有的执行排列都是 "先执行完第一个线程,再执行完第二个线程" 以及 "先执行完第二个线程",那么此时的总和就是 10w~
在极端情况下,如果所有的执行排列顺序 是不包括这两种情况的其他情况,那么此时总和就是 5w~
更实际的情况下,调度器具体调度出多少种这两种极端的情况,我们是无法确定的~
因此 最终的结果是 5w ~ 10w !!!!!!
操作系统的随机调度,其实不是 "真随机",而是 操作系统内核的调度器调度线程,其内部是有一套 逻辑 / 算法,来支持这一调度过程 ~
即 每种出现的排列情况下不是均等的,所以不可以通过排列组合的情况下算出每种情况 出现的概率的 ~
2.2 造成线程不安全的原因
(一)操作系统的 随机调度 / 抢占式执行~
这个是 万恶之源、罪魁祸首!!!!!!
这个是 操作系统内核 实现的时候,就是这样设计的,因此 我们改不了(就算可以改得了自己的电脑,也改不了其他的人的那么多电脑),对此 我们是无能为力的~
(二)多个线程 修改 同一个变量~
如果只是一个线程修改变量,没有线程安全问题!!!
如果是多个线程读同一个变量,也没有线程安全问题!!!
如果是多个线程修改不同的变量,还是没有线程安全问题!!!
但是,多个线程修改同一个变量,那就有了线程安全问题了~
所以,在写代码的时候,我们可以针对这个要点进行控制(可以通过调整程序的设计,来去规避 多个线程修改同一个变量)~
但是,此时的 "规避方法" 是有适用范围的,不是所有的场景都可以规避掉(这个得要看具体的场景)~
(三)有些修改操作,不是 原子的修改,更容易触发 线程安全问题~
在 MySQL数据库中说过,不可拆分的最小单位 就叫做原子~
如:赋值操作来修改(=,只对应一条机器指令),就是视为原子的~
像之前通过 ++操作 来修改(对应三条机器指令),就不是原子的~
(四)内存可见性 引起的线程安全问题~
内存可见性,这个就是另外一个场景了:一个线程写,一个线程读的场景~
这个场景 就特别容易因为 内存可见性 而引发问题~
线程1:进行反复的 读 和 判断 ~
线程2:在某个环节下进行修改~
如果是正常的情况下,线程1 在读和判断,线程2 突然写了一下 => 这是正常的,在线程2 写完之后,线程1 就能立即读到内存的变化,从而让判断出线变化~
但是,在程序运行过程中,可能会涉及到一个操作 —— "优化" (可能是编译器 javac,也可能是 JVM java,也可能是操作系统 的行为)~
那么 由于 线程1 频繁的进行 load test 操作,就很有可能会被优化成 load test test......操作(会认为 一直读的都是一样的值,所以不需要再读了)~
每次 load操作 都是读内存操作,每次 test操作 都是在读寄存器,读内存操作 要比 读寄存器操作 慢上几千倍、上万倍~
正是由于 load操作 读的太慢,再加上 反复读,每一次读到的数据又一样,所以 JVM 就做出了这样的优化,就不再重复的从内存中读了,直接就复用第一次从内存读到寄存器的数据就好了~
那么,如果在优化之后,线程2 突然又写了一个数据~
由于 线程1 已经优化成读寄存器了,因此 线程2 的修改,线程1 感知不到 =>这就叫做 内存可见性问题(内存改了,但是在 优化 的背景下,读不到、看不见了)~
所谓优化,是指在执行正确的前提下,来做出变化 使得性能更优~
一定要保证程序的逻辑是正确的,再说效率问题!!!
上述场景的优化,在单线程场景下,没有问题;但是在多线程情况下,就可能会出现问题:多线程环境太复杂,编译器 / JVM / 操作系统 进行优化的时候就可能产生误判~
针对这个问题,Java 引入了 volatile关键字,让程序猿手动的禁止 编译器 / JVM / 操作系统 对某个变量进行上述优化!!!
(五)指令重排序,也可能引起线程不安全
指令重排序,也是 操作系统 / 编译器 / JVM 优化操作!!!
它调整了代码的执行顺序,达到加快速度的效果~
比如说,张三媳妇 要张三去到超市买一些蔬菜,并且给了他一张清单:
- 西红柿
- 鸡蛋
- 茄子
- 小芹菜
调整顺序后,也是符合张三媳妇 对张三的要求:买到了四样菜,并且效率也是得到了提高~
至于买的过程是什么样子的,张三媳妇并不关心~
这个就叫做 指令重排序!!!
指令重排序,也会引发线程不安全~
如:
此处,就容易出现指令重排序引入的问题:
2 和 3 的顺序是可以调换的~
在单线程下,调换这两的顺序,是没有影响的;但是如果在多线程条件下,那么是会出现 多线程不安全:
假设 另一个线程,尝试读取 t 的引用,如果是按照 2、3的顺序,第二个线程读到 t 为 非null 的时候,此时 t 就一定是一个有效对象;如果是按照 3、2的顺序,第二个线程读到 t 为 非null 的时候,仍然可能是一个无效对象!!!
总结:
线程安全问题出现的五种原因:
前三种原因 是更普遍的~
- 系统的随机调度(万恶之源、无能为力)
- 多个线程同时修改同一个变量(部分规避)
- 修改操作不是原子的(有办法改善的)
后两种原因,是 编译器 / JVM / 操作系统 搞出的幺蛾子(但是 总体上来说还是利大于弊的)
- 内存可见性
- 指令重排序
编译器 / JVM / 操作系统 误判了,导致把不应该优化的地方给优化了,逻辑就变了,bug 就出现了(当然,后两种原因 也可以用 volatile关键字 来进行解决)~
2.3 线程加锁操作解决 原子性问题
现在先重点来介绍一下 解决线程安全问题出现的第三种原因的方法(原子性),通过 加锁操作,来把一些不是原子的操作打包成一个原子的操作!!!
加锁在 Java 中有很多方式来实现,其中最常用的就是 synchronized(用法其实也挺简单的,我们需要注意的是它的拼写和发音)~
2.3.1 什么是加锁
举个简单明了的例子,假设 你要去银行ATM机 取钱(我们都知道,ATM机 是放在一个单独的小房子里面的,每个小房子都有一把锁),如果你进去了,那么这个锁就会自动的锁起来,别人就进不去了,除非是 你已经取钱成功了 并且 自己已经出来了,下一个人才可以继续使用到 ATM机~
取钱成功了,说明 取钱的几个步骤是成功了的,那么我们希望,去 ATM机 取钱的这些步骤,是能够一气呵成的(如果 不一气呵成,万一走的时候忘记啥步骤,取钱没有成功,大大咧咧的走了;后面的人一顿操作猛如虎,把你的钱取走了咋搞)~
为了使这些步骤一气呵成,引入的办法就是 加锁~
加锁:
即 在你进去的时候,门就被锁了,其他的人就进不去了~
然后你就可以完成 刷卡、输入密码 等等的操作,等这些操作都完成了之后,再把锁给打开,然后你就可以出去了~
下一个人也就可以进来重复和你一样的操作了~
实际上,银行里面的 ATM机 就是这样设计的~
此时的 "你" 指的就是 "线程","ATM机" 指的就是 "对象","门上的锁" 指的就是 "锁","其他人" 指的就是 "其他的线程"~
在 Java中,加锁的方式有很多种,其中最常见的加锁方式就是用 synchronized关键字 进行加锁~
2.3.2 使用 synchronized关键字 进行加锁
synchronized 从字面意思上翻译叫做 "同步",其实 实际上它所起的是 互斥的效果~
在一开始的时候,列举了一个典型的线程不安全的例子:创建两个线程,让这两个线程 同时并发 对一个变量,自增 5w 次,最终预期能够一共自增 10w 次~
package thread;
class Counter {
//用来保存计数的变量
public int count;
public void increase() {
count++;
}
}
public class Demo14 {
public static void main(String[] args) {
//这个实例用来进行累加
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count:" + counter.count);
}
}
那么,怎么使用 synchronized关键字 来解决这个线程不安全的问题呢?
—— 很简单,我们在上面的 increase() 方法 前面加上 synchronized关键字即可(写在 void 之前都可以):
此时,我们再执行程序,发现无论再运行多少次,发现运行结果是正确的了:
那么,为什么加锁之后,就可以来实现 线程安全的保障呢?
LOCK 这个指令是互斥的,当 线程t1 进行 LOCK 之后,t2 也尝试 LOCK,那么 t2 的 LOCK 就不会直接成功!!!
所以说,在加锁的情况下,线程的三个指令就被岔开了,就可以保证 一个线程 save 之后,另一个线程才 load,于是此时的计算结果就准了~
2.3.3 synchronized 使用示例
(一)synchronized 直接修饰普通方法
public class Demo14 {
public synchronized void methond() {
}
}
(二)synchronized 修饰静态方法
public class Demo14 {
public synchronized static void method() {
}
}
(三)修饰代码块
public class Demo14 {
public void method() {
synchronized (this) {
}
}
}
() 里面的 this 指的是:是针对哪个对象进行加锁!!!
加锁操作,是针对一个对象来进行的!!!
我们要重点理解,synchronized 锁的是什么:两个线程竞争的是同一把锁,才会产生阻塞操作(即 两个线程尝试使用两把不同的锁,不会产生阻塞操作)~
如:
换句话说,1号滑稽 进入1号坑位,只是针对 1号坑位 进行了加锁,别人想要进入 1号坑位,就需要阻塞等待;但是 如果想要进入其他的 空闲坑位,那么则不需要等待~
这里的 滑稽老铁 指的就是 线程,坑位(的门上的锁,其实就是 synchronized() 括号里面的东西) 指的就是 要加锁的对象~
注意:
- 在Java里,任何一个对象,都可以用来做 锁对象,即 都可以放在 synchronized() 的括号中;其它的主流语言 都是专门搞了一类特殊的对象,用来作为 锁对象(大部分的正常对象 不能用来加锁)!
- 每个对象,内存空间中都会有一个特殊的区域 —— 对象头(JVM自带的,对象的一些特殊的信息)
- synchronized 写到普通方法上 相当于是对 this(可创建出多个实例) 进行加锁,synchronized 写到静态方法上 相当于是对 类对象(整个 JVM 里只有一个) 进行加锁,synchronized (类名.class)~
三、Java标准库里面的线程安全类
在Java标准库里面,很多线程都是不安全的,如:例如,ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder~
当然,还是有一些是线程安全的,如:Vector (不推荐使用),HashTable (不推荐使用),ConcurrentHashMap (推荐),StringBuffer,String~
需要注意的是,加锁也是有代价的,它会牺牲很大的运行速度(毕竟,加锁涉及到了一些线程的阻塞等待,以及 线程的调度),所以可以视为,一旦使用了锁,我们的代码基本上就和 "高性能" 说再见了~
这一篇博客暂时就介绍到这里了,关于 线程安全性 的剩下的内容,将会在下一篇博客中细细介绍~
如果感觉这一篇博客对你有帮助的话,可以一键三连走一波,非常非常感谢啦 ~