大家好,我是晓星航。今天为大家带来的是 多线程-初阶(下) 相关的讲解!😀
4. 多线程带来的的风险-线程安全 (重点)
万恶之源,罪魁祸首,多线程抢占式执行,带来的随机性。
如果没有多线程,代码执行顺序就是固定的。(只有一条路)
如果有了多线程,此时抢占式执行下,代码执行的顺序,会出现更多的变数!!!代码执行顺序的可能性就从一种情况 变成了 无数种情况!!!所以就需要保证这无数种情线程调度顺序的前提下,代码的执行结果都是正确的。
只要有一种情况下,代码结果不正确,就都视为有 bug ,线程不安全
4.1 观察线程不安全
package thread; class Counter { public int count = 0; public void add() { count++; } } public class ThreadDemo13 { public static void main(String[] args) { Counter counter = new Counter(); //搞两个线程,两个线程分别针对 counter 来 调用 5W 次的 add 方法。 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(); //等待两个线程结束 try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } //打印最终的 count 值 System.out.println("count = " + counter.count); } }
大家观察下是否适用多线程的现象是否一致?同时尝试思考下为什么会有这样的现象发生呢?
预期count结果是10_0000但是实际结果确实5~6w,为什么会出现这样的问题呢?
答:这里出现问题就是因为我们多线程调用count的时候是不确定的,导致线程不安全使得数据计算出错。
++操作本质上要分成三步:
1.先把内存中的值,读取到 CPU 的寄存器中。load
2.把 CPU 寄存器里的数值进行 +1 运行。 add
3.把得到的结果写道内存中。 save
这三个操作就是CPU上执行的三个指令。
如果是两个线程并发的执行 count++ ,此时就相当于两组 load add save 进行执行。此时不同的 线程调度顺序 就可能会产生一些结果上的差异。
那么此时我们就会有各种各样的排列组合进来导致我们程序运行的随机性很大。进而导致我们运算时的结果就会出现差错。
这里我们t2读到了t1还没(提交)的数据,就类似于前面讲的"脏读"。因此会出现各种各样的错误。
解决方法:使用synchronized来对我们的add方法进行加锁,从而避免我们的代码出现脏读问题。
package thread; /** * Created with IntelliJ IDEA. * Description: * User: 晓星航 * Date: 2023-07-14 * Time: 20:00 */ class Counter { public int count = 0; synchronized public void add() { count++; } } public class ThreadDemo13 { public static void main(String[] args) { Counter counter = new Counter(); //搞两个线程,两个线程分别针对 counter 来 调用 5W 次的 add 方法。 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(); //等待两个线程结束 try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } //打印最终的 count 值 System.out.println("count = " + counter.count); } }
注:这里的第12行代码比之前的代码多了一个synchronized(加锁)
使用了加锁操作后,我们的运行结果就变为了预期值,即避免了脏读问题(t1修改还为提交数据,t2就已经读取完t1还为修改的数据了)。
加了synchronized之后,进入方法就会加锁,除了方法就会解锁,如果两个线程同时尝试加锁,此时一个能获取锁成功,另一个只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功!!!
加锁缺点:使代码执行速度大大降低。
4.2 线程安全的概念
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
4.3 线程不安全的原因
修改共享数据 (多个线程同时修改一个变量)
上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改.
此时这个counter.count是一个多个线程都能访问到的 “共享数据”
counter.count 这个变量就是在堆上. 因此可以被多个线程共享访问.
4.3.1原子性
什么是原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁(synchronized),A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条 java 语句不一定是原子的,也不一定只是一条指令
比如刚才我们看到的 n++,其实是由三步操作组成的:
- 从内存把数据读到 CPU load
- 进行数据更新 add
- 把数据写回到 CPU save
不保证原子性会给多线程带来什么问题
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是 错误的。
这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
4.3.2可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并 发效果.
- 线程之间的共享变量存在 主内存 (Main Memory).
- 每一个线程都有自己的 “工作内存” (Working Memory) .
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程 1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.
- 初始情况下, 两个线程的工作内存内容一致.
- 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定 能及时同步.
这个时候代码中就容易出现问题.
此时引入了两个问题:
- 为啥要整这么多内存?
- 为啥要这么麻烦的拷来拷去?
- 为啥整这么多内存?
实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法. 所谓的 “主内存” 才是真正硬件角度的 “内存”.
而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存.
- 为啥要这么麻烦的拷来拷去?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也 就是几千倍, 上万倍).
比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果 只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问 内存了. 效率就大大提高了.
那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??
答案就是一个字: 贵
值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远 远快于硬盘.
对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜.
4.3.3代码顺序性
什么是代码重排序
一段代码是这样的:
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问 题,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多讨论