目录
线程安全问题实例一
引发线程安全的原因
抢占式执行
多线程修改同一变量
操作的原子性
指令重排序
内存可见性问题
线程安全问题实例二
如何解决上述线程安全问题
volatile 关键字
Java 内存模型 JMM(Java Memory Model)
线程安全问题实例一
class Counter { public int count = 0; public void add() { count++; } } public class ThreadDemo13 { public static void main(String[] args) throws InterruptedException { 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(); // 等待两个线程结束 t1.join(); t2.join(); // 打印最终的 count 值 System.out.println("count = " + counter.count); } }
运行结果:
- 我们通过两个线程各执行 5000 次 count 自增操作,count 的理想结果应为 100000,但是运行结果却相差甚大
- 我们运行两次该代码,发现两次运行的结果也不同
了解 count++ 操作
- 该操作本质上要分成三步
- 先把内存中的值,读取到 CPU 寄存器中(load)
- 再把 CPU 寄存器里的数值进行 +1 运算(add)
- 最后把得到的结果写回到内存中(save)
引发线程安全的原因
抢占式执行
- 多线程的调度是随机且毫无规律的
- 抢占式执行是线程不安全的主要原因
多线程修改同一变量
- 依据开头实例,两个线程并发执行对同一变量进行自增 5000 的操作,运行结果与期望值不符
- 出现问题的关键是线程t1 和线程t2 的 load 指令
- 两个线程 load 的 count 值均为对方修改 count 之后的值,此时是安全的,否则不安全
补充:
- String 是不可变对象,其天然就是线程安全的
- erlang 这个编程语言,其语法中就不存在 变量 这一概念,所有的数据都是不可变的,这样的语言更适合并发编程,其出现线程安全问题的概率大大降低
操作的原子性
- 针对解决线程安全问题,从操作原子性入手是主要的手段
- 原子为不可被拆分的基本单位
- count++ 操作分为三个 CPU 指令,像 load、add、save 这样的 CPU 执行指令符合原子性的特点
- 也正是因为 count++ 操作不是原子性的,从而会导致线程不安全的情况
- 但是如果将 count++ 操作的三个CPU指令,包装成一个原子操作,这三个要么全部一起执行,要么不执行,在执行这三个指令时,CPU不能调度执行其他指令,从而就能很好的解决上述实例所出现的问题
指令重排序
- 本质是编译器优化出现 bug
- 编译器会根据你写的代码,在保持逻辑不变的前提下,进行相应的优化,调整代码的执行顺序,从而加快程序的执行效率
内存可见性问题
- 指一个线程在使用对象状态时另一个线程在同时修改该状态
- 我们需要确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化
- 如果看不到修改后的变化,便会出现安全问题
总结:
- 以上五种为典型原因,并不是全部原因
- 一个代码的线程安全与否,主要应该具体对其进行分析,不能一概而论
- 运行多线程代码,只要其没有 bug,就是安全的
线程安全问题实例二
- 该实例基于 指令重排序 和 内存可见性问题
import java.util.Scanner; class Test { public int count = 0; } public class ThreadDemo14 { public static void main(String[] args) { Test test = new Test(); Thread t1 = new Thread(() -> { while (test.count == 0) { } }); Thread t2 = new Thread(() -> { System.out.println("请输入一个数字,改变 count 值"); Scanner scanner = new Scanner(System.in); test.count = scanner.nextInt(); }); t1.start(); t2.start(); } }
运行结果:
代码整体逻辑:
- 线程t1 的工作内容是通过 while 循环快速且不断对 count 值进行读取并与 0 进行大小比较
- 线程t2 的工作内容是读取控制台输入的数字 1,并将其赋值给 count 变量
- 预期结果:当线程t2 将 count 值改变时,此时线程t1 读取到 count != 0 ,从而能够直接结束 while 循环,线程t1 和线程t2 均运行完成,程序停止运行
- 实际结果:线程t2 将 count 值改为 1 后,程序仍未停止,说明线程t1 并未结束 while 循环
预期结果与实际结果不一致原因:
- 线程t1 的 while(test.count == 0) 分为两个步骤
- 从内存中读取 count 的值到寄存器中(load 指令)
- 在寄存器中的 count 与 0 进行值比较(cmp 指令)
- 因为 while 内无额外逻辑代码,所以这两个指令会十分快速的循环执行
- CPU 读写数据最快,内存次之,硬盘最慢,且他们之间均相差 3~4个数量级
- 所以相比 load 指令要不断从内存中读取数据,cmp 指令直接在 CPU 上进行执行就要慢了很多很多
- 编译器快速频繁的 load 读取 count 值,且多次 load 的 count 值还是一样的
- 因为一般没有人能修改该代码,所以此时编译器就会认为反正读到的结果都是固定的,直接将代码优化为仅读取一次 count 值,此时代码的效率就会显著提高
- 这时我们的线程t2 读取控制台输入的数字 1 并赋值给了 count
- 但是因为编译器将 while(test.count == 0) 代码优化成了仅读取一次 count 值,所以程序并不会因为 线程t2 将 count 值 修改为了 1 从而结束循环、结束程序执行
- 从而上述是一个典型的 内存可见性问题 和 指令重排序问题(编译器优化问题)
总结:
- 编译器优化在多线程情况下可能存在误判的情况
如何解决上述线程安全问题
对于实例一
- 为了将 count++ 操作的三个指令包装成一个原子操作,我们可以进行加锁操作
- 使用 synchronized 关键字来修饰普通方法 add ,当执行进入该方法时,就会加锁,直到该方法执行完毕,就会解锁
- 如果两个线程同时尝试加锁,此时一个能获取锁成功,另一个只能阻塞等待(BLOCKED)一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功
- synchronized 关键字的引入,每次执行 add 方法时都多了加锁和解锁的操作,有原来的 并发执行 转变为 串行执行,从而减慢了执行效率,但是保证了线程的安全性
- 所以我们需要根据需求进行分析取舍,只追求校率,不再乎准确率,可以不加锁,如果以准确率为前提条件,加锁操作就显得十分有必要了
修改后运行结果
注意:
- 在加锁区间(lock -> unlock 区间)中,CPU 不是一定要一口气执行完,中间也是可以有调度切换的,即使执行到一半 CPU 调度切换执行其他,当其余线程要想获取该方法时,还是会被阻塞(BOLCKED),无法获取该方法
- 虽然加锁之后,代码执行效率降低了,但是还是要比单线程执行要快
- 因为加锁仅针对 count++ 加锁,但除了 count++ 外还有 for 循环代码,for循环代码可以并发执行,只是 count++ 变为串行执行,还是要比单线程全串行执行要快
对于实例二
volatile 关键字
- volatile 关键字有两大作用
- 禁止指令重排序:保证指令执行的顺序,防止编译器优化而修改指令执行顺序,引发线程安全问题
- 保证内存可见性:保证了读取到的数据时内存中的数据,而不是缓存,简单来说就是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值
Java 内存模型 JMM(Java Memory Model)
- JMM 定义了Java 程序中多线程并发访问共享内存(主存)的行为规范
- volatile 关键字禁止了编译器优化,避免了直接读取 CPU 寄存器中缓存的数据,而是每次重新读内存
- 站在 JMM 角度看 volatile
- 正常程序执行过程中,会把主内存的数据,先加载到工作内存中,再进行计算处理
- 编译器优化可能会导致不是每次都真的取读取主内存,而直接读取工作内存中的缓存数据(导致内存可见性问题)
- 而 volatile 的作用就是保证每次读取内存都是真的从主存中重写读取
修改后运行结果