Java 多线程3——三种线程不安全的BUG+解决方案


前言

本人是一个刚刚上路的IT新兵,菜鸟!分享一点自己的见解,如果有错误的地方欢迎各位大佬莅临指导,如果这篇文章可以帮助到你,劳请大家点赞转发支持一下!

本篇文章主要讲解了线程不安全的场景,以及如何解决线程不安全问题,内容可能有点抽象,希望大家可以慢慢咀嚼,好好吸收。


一、线程不安全场景

何为线程安全

  • 如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的

导致线程不安全的原因,主要是有三个场景。

1️⃣ 多个线程对同一个共享数据进行修改操作
2️⃣ 内存可见性(是编译器出现了误判后,对代码做出的错误优化)
3️⃣ 指令重排序(是编译器出现了误判后,对代码做出的错误优化)

编译器优化
编译器优化:智能的调整你的代码执行逻辑,保证程序结果不变的前提下,通过加减语句,通过语句执行顺序变换,通过一些操作,让整个程序执行的效率大大提升。

编译器对于"程序结果不变"

  • 单线程下判定是非常准确的。
  • 多线程下判定就不一定了,可能调整后,效率提高了,但是结果改变了(编译器出现误判),引起程序出现bug。

1. 多个线程修改同一共享数据

public class ThreadDemo2 {
    private static int i = 0;//全局变量
    public static void main(String[] args) throws InterruptedException {
        // 创建一个线程让i++ 5000次
        Thread thread1 = new Thread(() -> {
            for(int j = 0;j < 5000;j++) {
                i++;
            }
        });
        // 再创建一个线程让i++ 5000次
        Thread thread2 = new Thread(() -> {
            for(int j = 0;j < 5000;j++) {
                i++;
            }
        });
        thread1.start();
        thread2.start();
        // 两个线程开始执行
        thread1.join();
        thread2.join();
        // 等待两个线程执行完毕打印i
        System.out.println(i);
    }
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上述代码,运行了三次,三次结果都不一样且都不符合预期结果的10000。

那么为什么会造成以上结果呢??


造成以上线程不安全问题主要有三个原因
1️⃣ 线程之间是抢占式执行的,CPU执行到任意一条语句都可能被调度去执行其他线程 (罪魁祸首,主要原因)
2️⃣ 多个线程修改同一个共享变量
3️⃣ 修改操作不是原子性的

前两条是代码的执行机制,而这两个机制,遇上第三个原因,就会出现大问题。


什么是原子性?

在以前,人们还没有发现中子,质子,电子时,人们认为不可分割的最小物质就是原子。

因此,我在执行一个操作时,

如果这个操作不可以被分割成几个步骤,必须一次性执行完毕,那么这个操作具备原子性。

如果这个操作可以被分割成几个步骤,可以通过CPU调度来间断性的完成这个操作,那么这个操作不具备原子性。

某个操作对应单个CPU指令,那么这个操作就是原子性的
某个操作对应多个CPU指令,那么这个操作大概率就不是原子性的

使用 ‘=’ 赋值,就是一个原子性操作。
而i++对应了三个CPU指令。


就比如上述的i++操作,是由三步操作组成的。

1️⃣load(从内存把数据读到CPU)
2️⃣add(CPU对数据进行运算)
3️⃣save(把数据写回内存)

在这里插入图片描述

此时已经执行了两次i++,而 i 仍等于1。

此处i++这个操作是由三个CPU指令来完成的,因此两个线程,抢占式执行,就可能存在多种指令顺序排列,因此造成bug。


2. 内存可见性

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

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

在这里插入图片描述

  • 线程之间的共享变量存在 主内存 (Main Memory)
  • 每一个线程都有自己的 “工作内存” (Working Memory)
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存

主内存与工作内存
所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存

主内存与工作内存的区别
CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍)


public class ThreadDemo3 {
    private static int flag = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (flag == 0) {
                //空循环,循环不结束线程不结束
            }
            System.out.println("循环结束,thread1线程结束");
        },"循环thread");

        Thread thread2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag = scanner.nextInt();
            System.out.println("修改完毕");
        },"修改thread");

        thread1.start();
        thread2.start();
    }
}

预期效果:
用户输入一个非0的数,那么两个线程都会执行完毕。
在这里插入图片描述

实际效果:
用户输入了一个非0的数,thread1线程仍在执行,thread2线程执行完毕。
在这里插入图片描述

可以看到名为"循环thread"的线程状态为RUNNABLE,说明这个线程仍在执行。


那么为什么会导致这个问题呢?

"循环thread"线程在判断循环条件时,有两个操作。
1️⃣load 从内存读取数据到工作内存
2️⃣比较工作内存里的值是否为0

此处的两个操作,load的时间开销远远高于cmp,
因为CPU的执行速度很快,一秒钟甚至可以达到上亿次,
那么对于load操作来说,在咱们输入整数前,就已经执行了很多次了,编译器发现每次结果都一样。

此时编译器就做了一个大胆且危险的操作,
把load这个操作给优化掉了,只有第一次执行load时,才是真的执行了,
再后续都只执行cmp的操作(会一直复用第一次load操作时读取到的值)。

内存可见性,就是多线程环境下,编译器对代码优化,产生了误判,导致出现了bug。


3. 指令重排序

指令重排序问题,很难使用代码演示,大部分情况下都是正确的。

就拿创建新对象来说吧。

class House {
    int area;//房子面积

    // 构造方法
    public House(int area) {
        this.area = area;
    }
    
    public void sleep() {
        
    }
}

我要new一个House
会有三个操作:
1️⃣向系统申请空间
2️⃣调用构造方法,初始化数据
3️⃣内存地址赋给引用

正常顺序:1️⃣2️⃣3️⃣

指令重排序后,顺序可能变为

优化后顺序:1️⃣3️⃣2️⃣


class House {
    int area;//房子面积

    // 构造方法
    public House(int area) {
        this.area = area;
    }

    public void sleep() {
        System.out.println("睡觉");
    }
    private static House house;//全局变量

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            house = new House(10);
        });

        Thread t2 = new Thread(() -> {
            if(house != null) {
                house.sleep();
            }
            
        });
        t1.start();
        t2.start();
    }
}

看上述代码,

假设t1中的new操作,被指令重排序后,执行顺序变为1️⃣3️⃣2️⃣。

假设当执行完3️⃣,未执行2️⃣时。

线程t2开始执行,此时的house不为null,但是其中的数据,方法都没有初始化。

那么此时调用sleep方法就不知道会发生什么了,很可能产生bug。


二、解决线程不安全问题

1. 多个线程修改同一共享数据

synchronized 关键字-监视器锁monitor lock

加锁操作可以让这个代码块具备原子性。

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

可以理解为,有的代码需要投资商投资才能运行。
而多个线程对同一共享数据进行修改时,
可以让修改操作变成需要投资才能运行的代码。

投资即加锁操作,
撤资即解锁操作。


public class ThreadDemo2 {
    static int i = 0;
    static Object locker = new Object();//创建对应投资商
    public static void main(String[] args) throws InterruptedException {
        // 创建一个线程让i++ 5000次
        Thread thread1 = new Thread(() -> {
            for(int j = 0;j < 5000;j++) {
                // 进入synchronized修饰的代码块后,
                // 相当投资商在给这个代码块投资,
                // 其他需要该投资商投资的代码,只能阻塞等待。
                synchronized (locker) {
                    i++;
                }
                // 出了该代码块相当于撤资,
                // locker投资商就可以去给其他需要的代码块投资了。
            }
        });
        // 再创建一个线程让i++ 5000次
        Thread thread2 = new Thread(() -> {
            for(int j = 0;j < 5000;j++) {
                synchronized (locker) {
                    i++;
                }
            }
        });
        thread1.start();
        thread2.start();
        // 两个线程开始执行
        thread1.join();
        thread2.join();
        // 等待两个线程执行完毕打印i
        System.out.println(i);
    }
    
}

在这里插入图片描述


任意引用类型都可以作为投资商

理解 “阻塞等待”
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁。

【注意】

  • synchronized修饰方法时,那么投资商就是这个方法所在类的对象。

  • 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁,而是要靠操作系统来 “唤醒”。这也就是操作系统线程调度的一部分工作

  • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待。但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则。

  • synchronized的底层是使用操作系统的mutex lock实现的。


2. 内存可见性

volatile 关键字

volatile 修饰的变量, 能够保证 "内存可见性,但是不保证原子性"

代码在写入 volatile 修饰的变量的时候

  • 改变线程工作内存中volatile修饰的变量的值
  • 将改变后的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候

  • 从主内存中读取volatile修饰的变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile修饰的变量的值

加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了


public class ThreadDemo3 {
    private static volatile int flag = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (flag == 0) {
                // 空循环,循环不结束线程不结束
            }
            System.out.println("循环结束,thread1线程结束");
        },"循环thread");

        Thread thread2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数");
            flag = scanner.nextInt();
            System.out.println("修改完毕");
        },"修改thread");

        thread1.start();
        thread2.start();
    }
}

在这里插入图片描述


3. 指令重排序

volatile 关键字

class House {
    int area;// 房子面积

    // 构造方法
    public House(int area) {
        this.area = area;
    }

    public void sleep() {
        System.out.println("睡觉");
    }
    private static volatile House house;//全局变量
    // 使用volatile修饰该引用类型变量,则会禁止指令重排序,
    // 严格按住1,2,3的顺序来创建对象。

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            house = new House(10);
        });

        Thread t2 = new Thread(() -> {
            if(house != null) {
                house.sleep();
            }
            
        });
        t1.start();
        t2.start();
    }
}

总结

本篇文章主要介绍了多线程不安全的场景以及如何解决,到这里多线程也还有很多知识在等待我们了解,加油!!!

路漫漫,不止修身也养性。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值