线程安全以及解决方案

本文详细探讨了线程安全问题的根源,包括抢占式执行、多线程修改同一变量、操作非原子性、内存可见性和指令重排序。重点介绍了synchronized的特性,尤其是可重入锁,以及如何通过加锁确保线程安全。
摘要由CSDN通过智能技术生成

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

这里的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;

在这里插入图片描述
可以看到,当我们线程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++;
    }

在这里插入图片描述

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

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

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

“不可重入锁”:

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

一个线程没有释放锁, 然后又尝试再次加锁. 按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放,才能获取到第二个锁.但想要第一把锁解锁,需要执行完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属于可重入锁,防止多次加锁,产生死锁
        }
    }

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

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

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

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

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

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

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

  • 24
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值