• 线程安全以及解决方案


    1.线程安全的原因

    ①抢占式执行

    操作系统对线程的调度是随机的,没有规律(主要原因)

    例如:定义了一个变量count,执行count++这种操作,本质上是三个CPU指令,load(将count的值读入cpu寄存器中)、add(将寄存器的数据进行+1)、save(将寄存器中的数据读入到内存中),而CPU执行指令都是以一个指令为单位顺序进行的,试想,有两个线程同时执行count++操作,这些一个一个的指令就会抢占执行,线程一的add的操作刚完,线程二的add就抢占了下一个位置…

    线程的调度是随机的,在有些调度下,代码的逻辑会出现问题,结果会与预计结果不同,但这个是内核实现的,没有办法改变

    ②多线程修改同一个变量

    当多线程修改同一个变量时,会出现问题。一个线程修改一个变量,结果不会出现问题,多线程修改不同的变量也不会出现问题,多线程读取同一个变量也不会出现问题。

    就像刚刚提到的抢占式执行的例子,如果一个变量count,进行count++这种操作,分load、add、save,要说线程一二修改不同变量倒也没事,互不干扰,然如果修改同一变量,就会出现以下情况:
    在这里插入图片描述
    如上,这两种是正常情况,这两种执行结果与预期结果相符,但更多的是出现下面的情况:
    在这里插入图片描述
    上面只是列举了两种异常情况,实际上的异常情况更多,线程调度的顺序是随机的,两个线程的执行顺序有无数种,在有些调度顺序下,代码逻辑就会出现问题,发生线程安全问题。

    总结:这里确实可以通过调整代码,来避免线程安全问题,但是以及适用性不高;

    ③修改的操作不是原子的

    原子表示不可分割的最小单位,CPU执行指令是一条一条执行的,这一条一条的指令就可以理解为原子,也正因为count++不是原子的才会引发上述的多线程修改同一变量会引发线程安全;

    结论:既然上述1,2都没有方法很好的解决线程安全问题,那么咱就试试从这入手——修改操作,使其是原子的,也就是说,咱可以把这些多个原子操作包装成一个原子操作!(例如可以把刚刚所说的count++这个例子的的三条指令包装成一个);

    ④内存可见性

    可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

    例如,一个线程负责读数据,另一个线程负责修改数据:

     	private static int isQuit = 0;
        public static void main(String[] args) {
            Thread t1 = new Thread(() -> {
                while (isQuit == 0) {   //次数过多编译器会进行优化,volatile防止JVM优化
                    // 循环体里啥都没干.
                    // 此时意味着这个循环, 一秒钟就会执行很多很多次.
                }
                System.out.println("t1 退出!");
            });
            t1.start();
    
            Thread t2 = new Thread(() -> {
                System.out.println("请输入 isQuit: ");
                Scanner scanner = new Scanner(System.in);
                // 一旦用户输入的值, 不为 0, 此时就会使 t1 线程执行结束.
                isQuit = scanner.nextInt();
            });
            t2.start();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    这里的while(isQuit == 0),就是要先从内存中读取isQuit 的值(LOAD操作),再到寄存器中读取isQuit 的值与0进行比较(CMP操作),这里while会循环的进行这个操作(非常快),而我们知道的是,CPU读写数据最快,内存次之(与CPU差3 ~ 4个数量级),硬盘最慢(与内存差3 ~ 4个数量级);所以LOAD从内存中读取数据操作的速度相对于在CPU上进行CMP操作就要慢的多,那么编译器就要偷懒了,既然频繁的LOAD读取isQuit 这个数据,多次执行的结果还都是一样,干脆LOAD就只执行一次将CPU读内存的操作变成读取寄存器,减少读取内存的操作,也可以提高整体程序的效率。
    在这里插入图片描述
    运行上述代码:
    在这里插入图片描述

    分析:这时可以发现, 当输入数字5时,相当于修改了isQuit 这个变量的值为5,按理来说t1线程的run方法中isQuit 只要不等于0就会停下来,可是程序依旧没有停止,就出现了内存可见性问题,直接读取寄存器的值,而没有读取我们修改之后的值;

    编译器优化,在多线程情况下可能存在误判——使用volatile关键字,可以告诉JVM不允许优化

    private static volatile int isQuit = 0;
    
    • 1

    在这里插入图片描述
    可以看到,当我们线程2一修改isQuit的值,线程1就停止运行了。

    volvatile 关键字有如下两大作用:

    1. 禁止指令重排序:保证指令执行的顺序,防止 JVM 出于优化而修改指令执行顺序,引发线程安全问题。
    2. 保证内存可见性:也就是说,保证了我们读取到的数据是内存中的数据,而不是缓存,具体的,当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

    Java 内存模型 (JMM):
    Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

    ⑤指令重排序

    一段代码是这样的:

    1. 去前台取下 U 盘
    2. 去教室写 10 分钟作业
    3. 去前台取下快递

    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

    总结:JVM的代码优化在多线程情况下,也会带来一些BUG;

    2. 线程安全的解决方案

    上面提到操作不是原子的,我们可以从这里入手,将count++这个操作的三个布置包装成一个步骤变成原子的,如何做呢——“加锁”;count++之前加锁,count++之后再解锁,别的线程若是想在加锁和解锁之间进行需修改,很抱歉,修改不了,别的线程只能处于阻塞等待的线程状态(BLOCKED状态);

    Java的代码中如何进行加锁呢?

    使用synchronized关键字,synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待(BLOCKED状态).

    • 进入 synchronized 修饰的代码块, 相当于加锁
    • 退出 synchronized 修饰的代码块, 相当于 解锁
        //对实例方法加锁
        synchronized public void increase(){
            count++;
        }
    
    • 1
    • 2
    • 3
    • 4

    在这里插入图片描述

    这个锁具体是怎么执行的呢?
    锁具有抢占特性,如果这个锁没人加,有人想加,就可以立即加上,若这个锁以及被人加上了,加锁操作就会阻塞等待;如刚才的栗子,count++分三步进行,load、add、save,而线程调度是随机的过程,一旦这两个线程同时调用,这两组三个操作就会进行排列组合,就会产生线程不安全,现在使用锁,就可以使这三个操作串行执行了;如下
    在这里插入图片描述
    此时,并发执行就变成了串行执行,这个操作就会减慢执行效率,但是保证了线程安全

    3 synchronized的特性------可重入锁

    synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;

    “不可重入锁”:

    // 第一次加锁, 加锁成功 
    lock(); 
    // 第二次加锁, 锁已经被占用, 阻塞等待.  
    lock();
    
    • 1
    • 2
    • 3
    • 4

    一个线程没有释放锁, 然后又尝试再次加锁. 按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放,才能获取到第二个锁.但想要第一把锁解锁,需要执行完synchronized代码块,才可以加下一把锁,然而第二把锁一直在阻塞等待,所以第一把锁既不能解锁,第二把锁也不能加锁,就卡在这里了;
    并且,有时候由于多次嵌套,无法直接观察出是否多次加锁:

       public static  Object locker = new Object();
       public  static void increase1(){
         synchronized (locker){
            }
        }
        public static void increase2(){
           increase3();
        }
        public static void increase3(){
            increase4();
        }
        public  static void increase4(){
             //可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.
            synchronized (locker){  //synchronized属于可重入锁,防止多次加锁,产生死锁
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.

    代码示例:在下面的代码中, increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的. 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)这个代码是完全没问题的. 因为 synchronized 是可重入锁.

    static class Counter {
        public int count = 0;
        synchronized void increase() {
            count++;
       }
        synchronized void increase2() {
            increase();
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    snychronized实现可重入的底层原理:

    在可重入锁的内部, 包含了 “线程持有者”“计数器” 两个信息.

    • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
    • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

    计数器还未归0,程序就抛出异常,会不会死锁?

    分析:若程序抛出异常,并且没有catch捕捉,程序就会脱离之前的代码块,一旦脱离这层加锁的代码块,计数器就会- -,脱离多层代码块,计数器减到0,也就解锁了;

    总结:加锁时若出现异常,是不会死锁的,也是一个使得synchronized优秀到将他设计成关键字的原因了,若是C++/Python加锁解锁,都是通过对象来实现的,这时就有可能由于出现异常引起代码未执行完,解锁代码未执行引起死锁;

  • 相关阅读:
    01 ARM Cortex-M3指令集汇总
    luffy项目后端轮播图接口
    爬虫 — Json 模块和 Post 请求
    8 个精彩的免费 G​​IS 软件资源分享
    线程安全和synchronized关键字
    23年下半年软考中级软件设计师备考攻略(含报名时间)
    React复习日志大纲
    基于HTML+CSS+JavaScript仿车蚂蚁网页设计与实现 (24页)
    DHCP与静态IP:哪种适合你的网络需求?
    引入移码的目的
  • 原文地址:https://blog.csdn.net/qq_54176670/article/details/138138038