2023.10.10 关于 线程安全 问题

目录

线程安全问题实例一

引发线程安全的原因

抢占式执行

多线程修改同一变量

操作的原子性

指令重排序

内存可见性问题

线程安全问题实例二

如何解决上述线程安全问题 

volatile 关键字

Java 内存模型 JMM(Java Memory Model)

Java 标准库中线程安全的类


线程安全问题实例一

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);
    }
}

运行结果:

2023.10.10 关于 线程安全 问题_第1张图片

2023.10.10 关于 线程安全 问题_第2张图片

  • 我们通过两个线程各执行 5000 次 count 自增操作,count 的理想结果应为 100000,但是运行结果却相差甚大
  • 我们运行两次该代码,发现两次运行的结果也不同

了解 count++ 操作

  • 该操作本质上要分成三步
  • 先把内存中的值,读取到 CPU 寄存器中(load)
  • 再把 CPU 寄存器里的数值进行 +1 运算(add)
  • 最后把得到的结果写回到内存中(save)

引发线程安全的原因

抢占式执行

  • 多线程的调度是随机且毫无规律的
  • 抢占式执行是线程不安全的主要原因

多线程修改同一变量

  • 依据开头实例,两个线程并发执行对同一变量进行自增 5000 的操作,运行结果与期望值不符

2023.10.10 关于 线程安全 问题_第3张图片

  • 出现问题的关键是线程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();
    }
}

运行结果:

2023.10.10 关于 线程安全 问题_第4张图片


代码整体逻辑:

  • 线程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 ,当执行进入该方法时,就会加锁,直到该方法执行完毕,就会解锁

2023.10.10 关于 线程安全 问题_第5张图片

  • 如果两个线程同时尝试加锁,此时一个能获取锁成功,另一个只能阻塞等待(BLOCKED)一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功

2023.10.10 关于 线程安全 问题_第6张图片

  •  synchronized 关键字的引入,每次执行 add 方法时都多了加锁和解锁的操作,有原来的 并发执行 转变为 串行执行,从而减慢了执行效率,但是保证了线程的安全性
  • 所以我们需要根据需求进行分析取舍,只追求校率,不再乎准确率,可以不加锁,如果以准确率为前提条件,加锁操作就显得十分有必要了

修改后运行结果

2023.10.10 关于 线程安全 问题_第7张图片

注意:

  • 在加锁区间(lock -> unlock 区间)中,CPU 不是一定要一口气执行完,中间也是可以有调度切换的,即使执行到一半 CPU 调度切换执行其他,当其余线程要想获取该方法时,还是会被阻塞(BOLCKED),无法获取该方法
  • 虽然加锁之后,代码执行效率降低了,但是还是要比单线程执行要快
  • 因为加锁仅针对 count++ 加锁,但除了 count++ 外还有 for 循环代码,for循环代码可以并发执行,只是 count++ 变为串行执行,还是要比单线程全串行执行要快

对于实例二

2023.10.10 关于 线程安全 问题_第8张图片

volatile 关键字

  • volatile 关键字有两大作用
  • 禁止指令重排序:保证指令执行的顺序,防止编译器优化而修改指令执行顺序,引发线程安全问题
  • 保证内存可见性:保证了读取到的数据时内存中的数据,而不是缓存,简单来说就是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值

Java 内存模型 JMM(Java Memory Model)

  • JMM 定义了Java 程序中多线程并发访问共享内存(主存)的行为规范
  • volatile 关键字禁止了编译器优化,避免了直接读取 CPU 寄存器中缓存的数据,而是每次重新读内存
  • 站在 JMM 角度看 volatile
  • 正常程序执行过程中,会把主内存的数据,先加载到工作内存中,再进行计算处理
  • 编译器优化可能会导致不是每次都真的取读取主内存,而直接读取工作内存中的缓存数据(导致内存可见性问题)
  • 而 volatile 的作用就是保证每次读取内存都是真的从主存中重写读取

修改后运行结果

2023.10.10 关于 线程安全 问题_第9张图片

Java 标准库中线程安全的类

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

ArrayList

LinkedList

HashMap

TreeMap

HashSet

TreeSet

StringBuilder

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

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

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

  • String

注意:

  • 加锁这个操作有副作用,它会引入额外的时间开销
  • 我们需根据实际需求进行分析取舍,从而选择出适合的类

你可能感兴趣的:(多线程,java,jvm,开发语言)