• 2023.10.10 关于 线程安全 问题


    目录

    线程安全问题实例一

    引发线程安全的原因

    抢占式执行

    多线程修改同一变量

    操作的原子性

    指令重排序

    内存可见性问题

    线程安全问题实例二

    对于实例一

    对于实例二

    volatile 关键字

    Java 内存模型 JMM(Java Memory Model)

    修改后运行结果

    Java 标准库中线程安全的类

    多线程环境使用 ArrayList


    线程安全问题实例一

    1. class Counter {
    2. public int count = 0;
    3. public void add() {
    4. count++;
    5. }
    6. }
    7. public class ThreadDemo13 {
    8. public static void main(String[] args) throws InterruptedException {
    9. Counter counter = new Counter();
    10. // 搞两个线程,这两个线程分别针对 counter 来调用 5w 次的 add 方法
    11. Thread t1 = new Thread(() -> {
    12. for (int i = 0; i < 50000; i++) {
    13. counter.add();
    14. }
    15. });
    16. Thread t2 = new Thread(() -> {
    17. for (int i = 0; i < 50000; i++) {
    18. counter.add();
    19. }
    20. });
    21. // 启动线程
    22. t1.start();
    23. t2.start();
    24. // 等待两个线程结束
    25. t1.join();
    26. t2.join();
    27. // 打印最终的 count 值
    28. System.out.println("count = " + counter.count);
    29. }
    30. }

    运行结果:

    • 我们通过两个线程各执行 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,就是安全的

    线程安全问题实例二

    • 该实例基于 指令重排序 和 内存可见性问题
    1. import java.util.Scanner;
    2. class Test {
    3. public int count = 0;
    4. }
    5. public class ThreadDemo14 {
    6. public static void main(String[] args) {
    7. Test test = new Test();
    8. Thread t1 = new Thread(() -> {
    9. while (test.count == 0) {
    10. }
    11. });
    12. Thread t2 = new Thread(() -> {
    13. System.out.println("请输入一个数字,改变 count 值");
    14. Scanner scanner = new Scanner(System.in);
    15. test.count = scanner.nextInt();
    16. });
    17. t1.start();
    18. t2.start();
    19. }
    20. }

    运行结果:


    代码整体逻辑:

    • 线程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 的作用就是保证每次读取内存都是真的从主存中重写读取

    修改后运行结果

    Java 标准库中线程安全的类

    • Java 标准库中很多类都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施

    ArrayList

    LinkedList

    HashMap

    TreeMap

    HashSet

    TreeSet

    StringBuilder

    • 无线程安全问题时,可以放心使用
    • 有线程安全问题时,可以手动加锁
    • 相对于下方自带锁的类,不带锁的类拥有更多可选择的空间


    多线程环境使用 ArrayList

    1. 自己使用 synchronized 或者 ReentrantLock 来进行加锁
    2. Collections.synchronizedList,这里会提供一些 ArrayList 相关的方法,同时是带锁的,使用这个方法把 集合类套一层
    3. CopyOnWriteArrayList 简称 COW,也叫做 "写时拷贝"

    COW 基本思路

    • 如果针对这个 ArrayList 进行读操作,不做任何额外的工作
    • 如果进行写操作,则拷贝一份新的 ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕了,使用新的替换旧的(本质上就是一个引用之间的赋值,原子的)
    • 很明显,这种方案的优点时不需要加锁,缺点是要求这个 ArrayList 不能太大
    • 只是适用于这种数组比较小的情况下

    COW 适用场景

    • 服务器程序的配置、维护

    注意:

    • 服务器程序的配置文件,可能会需要进行修改
    • 修改配置可能就需要重启服务器才能生效
    • 但重启操作可能成本比较高,如果有 20 台服务器,每台重启用时 5 分钟,总的重启时间就是 100分钟
    • 此处的 20 台服务器绝对不能同时重启,如果全部同时重启,此时就会出现 5 分钟的服务中断,此时用户发起的请求就会没有任何响应,这是严重的事故
    • 所以很多服务器都提供了 " 热加载"(reload)功能,通过这样的功能就能实现可以不重启服务器,实现配置的更新
    • 新的配置放到新的对象中,加载过程中,请求任基于旧配置完成工作
    • 当新的对象加载完毕,使用新的对象代替旧对象,旧对象也随之释放

    • 以下是线程安全的类,使用了一些锁机制来控制,自己内置了 synchronized 加锁,相对更加安全
    Vector(不推荐)
    HashTable(不推荐)
    ConcurrentHashMap
    StringBuffer
    • 强行加锁,无选择空间

    还有虽然没有加锁,但是无法修改值为不可变对象,所以也是线程安全的

    • String

    注意:

    • 加锁这个操作有副作用,它会引入额外的时间开销
    • 我们需根据实际需求进行分析取舍,从而选择出适合的类
  • 相关阅读:
    Java进阶篇--LockSupport
    指针强化与提高
    Android-自定义三角形评分控件
    【学习心得】爬虫JS逆向通解思路
    软件测试面试题:你对测试最大的兴趣在哪里?为什么?
    JAVA注解
    structuredClone() 详解
    数据分析常用专业术语缩写及其含义
    Vue源码解析之mustache模板引擎
    065:mapboxGL在一个图层中随机添加100个标记(marker)
  • 原文地址:https://blog.csdn.net/weixin_63888301/article/details/133758215