• 总结线程安全问题的原因和解决方案


    一. 线程安全问题

    概念

    首先, 线程安全的意思就是在多线程各种随机调度的情况下, 代码不出现 bug 的情况. 如果在多线程调度的情况下, 出现 bug, 那么就是线程不安全.

    二. 观察线程不安全的情况

    下面我们用多线程来累加一个数, 观察线程不安全的情况:

    用两个线程, 每个线程对 counter 进行5000次自增.预期结果10000.

    1. Class Counter {
    2. public int count = 0;
    3. public void increase() {
    4. count++;
    5. }
    6. }
    7. public class Demo {
    8. public static Counter counter = new Counter();
    9. public static void main(String[] args) throws InterruptedException {
    10. Thread t1 = new Thread( () -> {
    11. for (int i = 0; i < 5000; i++) {
    12. counter.increase();
    13. }
    14. });
    15. Thread t2 = new Thread( () -> {
    16. for (int i = 0; i < 5000; i++) {
    17. counter.increase();
    18. }
    19. });
    20. t1.start();
    21. t2.start();
    22. t1.join();
    23. t2.join();
    24. System.out.println("count : " + counter.count);
    25. }
    26. }

    那我们再来看运行结果:

     

     根据截图的结果我们可以看到, 每一次运行结果, count 的值都不一样, 反正不是10000.

    那么为什么会出现这种问题呢?

    首先我们要知道进行的 count++ 的操作, 底层 是三条指令在CPU上完成的.

    1. load -> 把内存的数据读取到 CPU 寄存器上
    2. add -> 把 CPU 中寄存器上的值进行 +1
    3. save -> 把寄存器中的值, 写回到内存中

    因为当前是两个线程一起修改一个变量(修改共享数据), 每次的修改是三个步骤(不是原子的), 并且线程之间的调度顺序的不确定的.

    什么是原子性?

    我们设想一个场景, 大家在厕所方便的时候, 假设, A 这个人进去了之后还没出来, 而如果A没有锁门的话, 那么B是不是也可以进去呢. 这显然是不行的, (而这个锁门就是需要一定的机制来保证安全). 这里A就是不具备原子性的. 而 A 要是把门锁好, 那么B就进不去了, 这样就保证了原子性.

    有的时候也把这个现象叫做同步互斥.

    因此, 两个线程在真正执行这些操作的时候, 就有可能会有很多种执行的排列顺序.下面来看图:

     按照时间轴的形式画图, 在内存中真实存在的情况不止这几种, 而在上图的排列方式中, 没有问题的只有下面两种情况:

     表面上是并发执行, 其实差不多是串行执行了.

    以下图为例, 这个时候多线程自增就会产生 "线程安全问题"!!!

     假设两个线程在两个CPU核心上运行:

    按照上图的时间轴在内存中自增的话, 就发生问题了:

    开始时 t1 线程先进行 load 操作, 

     然后 t2 线程进行 load 操作, 再 add, 然后再内存中 count++

     这个时候, t1线程也进行 add 和 save, 这个时候就出问题了, 看图:

     类似于这种情况, 就会出现线程安全问题了. 因此再最开始我们进行自增操作的时候, 得到的值不是10000.

    三. 线程不安全的原因

    1. 抢占式执行

    线程不安全的罪魁祸首

    我们都知道多线程的调度执行过程是随机的, 这是内核实现的, 咱们无能为力, 我们能做到的就是, 在写多线程代码的时候, 需要考虑到的就是, 在任意的调度情况情况下, 咱们的代码都能运行出正确的结果.

    2. 多个线程修改同一个变量

    有时可以从这里入手, 来规避线程安全问题, 但是普适性不高

    注意我加颜色的汉字,  一个线程修改一个变量没事, 多个线程修改同一个变量没事, 多个线程修改不同变量还是没事, 但只要多个线程修改同一个变量的话, 问题就出来了. 

    3. 修改操作不是原子的

    解决线程安全问题, 最常见的办法, 就是从这入手

    像上面 count++ 操作, 本质上是三条指令: load add save 

    CPU 执行指令, 都是以 "一个指令" 为单位进行执行的, 一个指令就相当于 CPU 上的最小单位, 不会发生指令执行到一半, 线程被调度走了的情况. 

    4. 内存可见性问题

    也会产生线程不安全问题

    这是 JVM 的代码优化引出的 BUG

    编译器优化:

    程序猿写代码写好之后在机器上运行, 因为程序猿水平参差不齐, 大佬们的代码正确又高效, 而我这样的菜鸟写代码经常出BUG效率还低, 这个时候, 写编译器的大佬, 就想办法, 让编译器有了一定的优化能力, 就是编译器把你代码里面的逻辑等价转换为另一种逻辑, 转换之后逻辑不变, 但是效率变高了.

    举个栗子:

    假设我老妈让我去超市里面买上图中的四样东西,中午做饭用,  如果我按照列表上的顺序1>2>3>4去买东西的话, 那么我就会东跑跑西跑跑, 而我要是从超市入口进去, 然后按着箭头的方向去买的话, 那么我就会少走一些路, 结果是一样的, 效率提升了, 这种过程就是编译器优化.

     

    5. 指令重排序

    也会引发线程不安全

    假设我们这里有这样三行代码,  这是指令重排序前的情况:

    在前面我就说有三种指令 LOAD ADD SAVE, 而发生指令重排序的话, 

     在这个过程中, 就可能发生线程不安全问题.

    四. 解决线程不安全问题

    还是继续来看开始的代码. 顺着三中的各种引发线程不安全的原因来看, 首先 抢占式执行 这个我们无能为力, 再看 多个线程修改同一个变量 也没有关系, 接下来就是原子性的问题了, 所以这里的解决办法就是用一些办法, 来使得 count++ 操作变成原子的.

    加锁

    而要使操作变得原子呢, 我们需要做的就是加锁.

    举个例子, 大家回家了之后肯定要关门吧, 把门关上外人就进不来, 这就是加锁, 然后等你有事情要出门了, 你在把门打开出去, 这就是解锁. 在这个过程中, 外人进不去也就是互斥

    解决上述线程不安全问题, 类似这样, 在 count++ 之前加锁, 等 count++ 完之后解锁. 在加锁和解锁这个时间里, 别的线程想要修改是不行的, 修改不了的, 只能阻塞等待, 这里阻塞等待的线程状态就是 BLOCKED.

    synchronized

    1.对方法加锁

    在 Java 中, 进行加锁, 使用 synchronized 关键字

    把 synchronized 加到increase()方法上,  这是最基本的使用, 使用 synchronized 来修饰一个普通方法, 当进入方法的时候会加锁, 方法执行完之后, 就解锁.

    我们来看加上 synchronized 之后的代码执行效果:

    这个时候我们的count结果就是10000了.

    下面来看下具体这个锁是怎么执行的呢, 他的内部是怎么工作的呢?

    加锁之后, 就是在线程前后多了两个操作, LOCK 和 UNCLOCK. 

    假设 t1 线程先加锁, 那么 t2 线程就会出现阻塞, 这个时候和串行执行没啥区别了.

    本来线程调度是随机的过程, 容易出现线程安全问题, 现在使用锁, 就使得线程能串行执行了.

    加锁前我们要想好锁哪段代码, 锁的范围不一样, 对于代码的执行效果影响差距很大, 锁的代码越多, 我们称之为 "锁的粒度越大", 锁的代码越少, 称之为 "锁的粒度越小". 

    下面我们试试一个线程加锁, 一个线程不加锁, 我们来看看是否线程安全?

    1. class Counter {
    2. public int count = 0;
    3. public synchronized void increase() {
    4. count++;
    5. }
    6. public void increase2() {
    7. count++;
    8. }
    9. }
    10. public class Demo3 {
    11. public static Counter counter = new Counter();
    12. public static void main(String[] args) throws InterruptedException {
    13. Thread t1 = new Thread( () -> {
    14. for (int i = 0; i < 5000; i++) {
    15. counter.increase();
    16. }
    17. });
    18. Thread t2 = new Thread( () -> {
    19. for (int i = 0; i < 5000; i++) {
    20. counter.increase2();
    21. }
    22. });
    23. t1.start();
    24. t2.start();
    25. t1.join();
    26. t2.join();
    27. System.out.println("count : " + counter.count);
    28. }
    29. }

    我们的代码变成这样, 让 t1 线程加锁, t2 线程不加锁.我们直接看运行结果:

    可以看到结果不是10000, 出问题了.

    这就说明, 只给一个线程加锁是没啥用的, 一个线程加锁的话, 不涉及锁竞争, 也就不会发生阻塞等待, 也就不会 并发修改->串行修改 

    就好比, A 这个人追到了女神, 但是出来一个 B , 不讲武德, 他挖墙脚, 但要是 A 官宣了, 那么就相当于加锁了, 就是安全的, B 就需要阻塞等待.

    2. 修饰代码块

    也就是把需要加锁的逻辑放到 synchronized 修饰的代码块中, 也可以起到加锁的作用.

     其中 (this) 被我们称为锁对象, 就是针对当前对象加锁, 谁调用这个方法就对谁加锁.

  • 相关阅读:
    Linux常用命令——clock命令
    springcloudalibaba架构(27):将微服务的配置内容转移到nacos
    做自媒体一直没有收入,要不要放弃?
    数学建模笔记:TOPSIS方法(优劣解距离法)和熵权法修正
    做一个Springboot文件上传-阿里云
    Could not load dynamic library ‘libcudart.so.11.0‘; dlerror: libcudart.so.11.0:
    2. Java并发编程-互斥锁、死锁
    Spring底层原理学习笔记--第十讲--(aop之agent增强)
    伦敦银最新均线分析系统怎么操作?
    Windows用户如何将cpolar内网穿透配置成后台服务,并开机自启动?
  • 原文地址:https://blog.csdn.net/qq_52592775/article/details/127751737