• 线程安全问题


    1.什么是线程安全问题

    代码1如下,创建两个线程,实现并发编程,分别让变量n自增2w次

    1. public class Demo3 {
    2. public static int n = 0;
    3. public static void main(String[] args) throws InterruptedException {
    4. Thread t1 = new Thread(() -> {
    5. for (int i = 0; i < 20000; i++) {
    6. n++;
    7. }
    8. });
    9. Thread t2 = new Thread(() -> {
    10. for (int i = 0; i < 20000; i++) {
    11. n++;
    12. }
    13. });
    14. //两个线程同时启动
    15. t1.start();
    16. t2.start();
    17. t1.join();
    18. t2.join();
    19. System.out.println(n);
    20. }
    21. }

    代码1的输出结果应该是4w,但实际输出结果却相差很大,且无论运行多少次,都很难能输出4w。

    代码2如下:

    1. public class Demo3 {
    2. public static int n = 0;
    3. public static void main(String[] args) throws InterruptedException {
    4. Thread t1 = new Thread(() -> {
    5. for (int i = 0; i < 20000; i++) {
    6. n++;
    7. }
    8. });
    9. Thread t2 = new Thread(() -> {
    10. for (int i = 0; i < 20000; i++) {
    11. n++;
    12. }
    13. });
    14. t1.start();
    15. t1.join();//等待t1线程执行完毕
    16. t2.start();
    17. t2.join();//等待t2线程执行完毕
    18. System.out.println(n);
    19. }
    20. }

     结果是,无论执行多少次代码,结果都是4w,与预想结果相同。

    代码2其实就是让多个线程一个一个执行,相当于是单线程编程,输出结果与预想的相同。但在多线程并发编程去执行时,输出结果与预想的却差异很大。这就是线程安全问题。

    【总结】

    同一段代码,放在多线程并发编程的环境中执行,发生 输出结果 与 预想结果 有差异,放在单线程环境中执行,不会出现差异的情况,称之为线程安全问题

    2. 为什么会发生线程安全问题

    站在CPU的角度,对变量n进行自增操作会涉及到三步操作:

    (1)将变量加载到内存中(load

    (2)对变量进行++(add

    (3)将变量进行保存(save)。

    代码1实现的是多线程并发编程,多个线程交替执行。由于线程的调度是随机的,且执行n++要做的三步操作是非原子操作,这就会导致多个线程同时执行时,这三步操作可能会被穿插执行,出现以下这种情况:

    出现以上这种情况时,即使t1和t2线程都对n进行了自增操作,但最终n只自增了一次。这也是导致代码1出现线程安全问题的关键原因。 只有保证每个线程执行修改操作时,不会有其他线程穿插进来,这样才能保证线程安全。即以下这种情况时,才是线程安全的。

    由于线程的调度是随机的,每个线程在对变量n进行自增的过程中,我们无法确定多少次自增才是线程安全的,多少次自增是线程不安全的,也因此代码1很难达到预想的结果。

    【线程安全问题原因总结】

    (1)在操作系统中,线程的调度是随机的,即抢占式执行

    (例如代码1中,t1在对n进行自增时,完成自增的三步操作还只执行了前一或前两步时,t2就开始执行了)

    (2)多个线程,对同一个变量进行修改(三个关键词:多个线程,同一变量,修改)

    (3)修改操作是非原子的(操作是非原子的,也就是该操作不是一气呵成的)

    (4)内存可见性问题(代码1尚未涉及,下面继续说明)

    (5)指令重排序问题(代码1尚未涉及,下面继续说明)

    3.如何避免线程安全问题

    3.1解决 由于修改操作所导致的线程安全问题

    想要使得代码是线程安全的,就要从导致线程安全问题的原因入手。其中,原因(1)是操作系统的特征,我们无法改变;有些代码无法避免变量修改操作;因此,要解决由修改变量所导致的线程安全问题,只能从原因(3)入手,即让修改操作变成原子的。 我们可以对关键代码进行加锁,这样当一个线程拿到锁之后,其他线程就无法获得锁,只能阻塞等待。这样就能使线程是安全的。

    代码3如下:

    1. public class Demo3 {
    2. public static int n = 0;
    3. public static Object locker = new Object();
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t1 = new Thread(() -> {
    6. synchronized (locker){
    7. for (int i = 0; i < 20000; i++) {
    8. n++;
    9. }
    10. }
    11. });
    12. Thread t2 = new Thread(() -> {
    13. synchronized (locker){
    14. for (int i = 0; i < 20000; i++) {
    15. n++;
    16. }
    17. }
    18. });
    19. t1.start();
    20. t2.start();
    21. t1.join();//等待t1线程执行完毕
    22. t2.join();//等待t2线程执行完毕
    23. System.out.println(n);
    24. }
    25. }

    代码3的执行结果是,无论运行多少次,输出结果都是4w,说明线程是安全的。 

    值得注意的是,t1和t2线程去竞争同一把锁时,才能保证代码3是线程安全的,因为只有出现锁竞争时,没拿到锁的线程只能阻塞等待,这样才不会出现线程安全问题。否则每个线程拿的是不同的锁的话,加锁是没有意义的。

    3.2解决 由于 内存可见性 和 指令重排序 所导致的线程安全问题

    3.2.1 什么是内存可见性

    代码4如下:

    1. public class Demo {
    2. public static int quit = 0;
    3. public static void main(String[] args) {
    4. Thread t1 = new Thread(()->{
    5. while (quit == 0){
    6. }
    7. System.out.println("t1结束!");
    8. });
    9. t1.start();
    10. Thread t2 = new Thread(()->{
    11. System.out.println("请输入quit的值:");
    12. Scanner scanner = new Scanner(System.in);
    13. quit = scanner.nextInt();//只要输入非0值,t1线程就会结束
    14. });
    15. t2.start();
    16. }
    17. }

    代码4,从代码逻辑上看,当输入非0值时,t1线程线程就会执行结束且输出t1结束,但实际上t1线程一直无法结束,一直在无限循环中 。导致这种情况的原因就是 内存可见性问题。

    定义的变量quit会存储到内存中,当代码执行到while循环条件判断时,会有load(去内存中读取quit的值,将quit的值加载到寄存器中)和cmp(读取寄存器中变量的值,比较quit和0是否相等)两条指令,进而决定是否继续循环。

    由于代码4中循环体中没有其他代码,while循环在短时间内会循环很多次

    又因为load操作 相比于 直接读取寄存器 要多耗费许多时间(load一次耗费的时间可以cmp数千上万次)

    且多次循环判断时,编译器发现quit的值并没有发生变化

    因此编译器做出一个大胆的决定,对代码进行了优化,决定只在第一次循环时才去进行load数据,之后再进行循环判断时直接读取寄存器中的值

    由于以上原因,即使在t2线程改变quit的值,t1没有感知到内存中quit的值发生改变,一直是从寄存器中读取数据,没有去内存load数据,也因此t1线程一直无法结束。这就是内存可见性问题。

    【解决内存可见性问题】

    解决内存可见性问题也很简单,只需在定义quit时加上volatile关键字,让编译器知道,每次要用quit的值时总是去内存中load不要直接读取寄存器中的值,即不要对代码进行优化。这样就可以避免由于内存可见性所导致的线程安全问题。

    在日常代码中,编译器是否会将代码进行优化,我们也感知不到,因此适当地加上volatile关键字是比较靠谱的选择。

    3.2.2 什么是指令重排序

    指令重排序,就是在保证代码逻辑不变的前提下,调整指令的执行顺序,从而提高线程执行的效率。在单线程环境中,存在指令重排序时不影响线程正常执行,但在多线程的并发执行的环境下就可能会出现问题。

    代码5如下:

    1. class MySingleton3{
    2. //懒汉模式,等到被调用的时候才实例化对象
    3. private static MySingleton3 instance = null;
    4. private static Object locker = new Object();
    5. private static MySingleton3 getInstance(){
    6. //考虑到 if语句 和 实例化对象操作 是 非原子操作,可能会涉及到线程安全问题,则把这两个操作 加锁
    7. //由于只有第一次调用该类时才需要加锁,则先判断是否要加锁
    8. 1if(instance == null){
    9. synchronized (locker){
    10. 2if(instance == null){//如果instance是空,则实例化对象
    11. MySingleton3 mySingleton3 = new MySingleton3();
    12. }
    13. }
    14. }
    15. return instance;
    16. private MySingleton3(){}
    17. }
    18. }

    在(2)if语句中,new操作会涉及到三条指令:

    <1>申请内存空间

    <2>在内存空间上构造对象

    <3>把内存的地址,赋值给instance

    在不影响代码逻辑的前提下,编译器可能按照1,2,3顺序执行,也可能会按照1,3,2顺序执行。instance还是空时,假设线程1执行new操作按照1,3,2顺序执行,刚执行到1,3时,instance指向的还是非法对象,线程2就开始执行了,并且执行(1)if语句,instance为非空,直接返回instance,返回的是非法的instance。这时,就发生了线程安全问题。

    【解决指令重排序问题】

    同样,在定义instance时加上volatile关键字,就能保证在new操作的过程中,不会发生指令重排序。

  • 相关阅读:
    [C++]:8.C++ STL引入+string(介绍)
    施工企业数字化转型如何避免IT技术与企业管理的“两张皮”
    Excel自定义排序和求和
    好看的html网站维护源码
    分布式服务框架总是要学的,大佬的笔记就赠于你吧
    Java异常编程题(巩固语法)
    优化Java代码效率和算法设计,提升性能
    C/C++陷阱——临时变量的产生和特性
    Swift方法mutating关键字的本质
    linux笔记:MOOC Linux开发环境及应用
  • 原文地址:https://blog.csdn.net/m0_67872788/article/details/136306999