Skip to content

JAVA 线程不安全问题以及相关解决方案

造成线程不安全的常见 5 点原因

线程不安全,就是在多线程运行结束后,结果或者过程并没有按照我们的预期那样执行,则为线程不安全,即产生了 BUG
出现以下 5 种情况,一般都会照成线程不安全

1. 抢占式执行

我们使用多线程时,线程的调度执行过程是由系统内核来操作的,谁先调度执行完全看内核心情,因此视为“伪随机”,这是我们程序员无能为力的问题,这也是导致线程不安全的罪魁祸首

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

由于多线程会抢占式执行,当多个线程修改同一个变量时,其中一个线程的计算过程还没完整结束,另外一个线程就开始调用了一个不完整的计算结果,从而导致最终的结果不准确(可调整代码来规避,但是针对这一点下手普适性不高)

3. 修改的操作不是原子的

原子表示不可分割的最小单位 像 count++这种操作,本质上是 3 个 cpu 指令来完成的;load,add,save(这三个操作单独拎出来都可以说是原子的)cpu 执行指令,都是以“一个指令”为单位进行执行,一个指令相当于 cpu 上的“最小单位”,不能说把指令(load/add/save)执行一半就把线程调度走了综上所述:我们一般着眼于第三点来反制线程不安全,即把多个操作通过特殊手段,打包成一个原子操作,也就是说把零散的指令组合成一个完整的指令,因为 cpu 一次执行一个完整指令,从而避免了一个线程还没执行完所有应该执行的指令就被调度走了

4. 内存可见性问题

JVM 的代码优化引入 BUG 编译器很聪明,它会自己帮我们进行代码优化提升效率,但在多线程情况下,编译器容易出现误判 除非能保证优化后,逻辑与之前的逻辑是等价的,否则就会出现 BUG 编译器优化对单线程没有影响,并且会提升效率,而面对多线程,可能导致第 5 点问题,也就是 “指令重排序”

5. 指令重排序

定义一个 count 变量,让它从 0 自增到 10w,创建两个线程,每个线程自增 5w 次,按照这个逻辑,count 可以自增到 10w 吗?
java
//定义Counter类,让count自增10w次
class Counter{
    public  int count;

    public  void increase(){
        count++;
    }
}

public class Demo1 {
    //实例化Counter对象
     public static Counter counter = new Counter();

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++){
                counter.increase();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++){
                counter.increase();
            }
        });

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

        thread1.join();
        thread2.join();

        System.out.println("count: " + counter.count);
    }
}

alt text 由代码运行的结果来看,并没有达到 10w 次,因此出现了线程不安全问题 造成该结果的原因是线程的抢占式执行;

我们重点在于解决第三点,从第三点入手反制

也就是用特殊手段让 count++变成原子的 -------”加锁“

即使用 synchronized 关键字来时代码进行“加锁”

返回到用两个线程自增 count10w 次的代码,也就是让每个线程都独立完成自己的任务后,另外的线程才开始调度执行,所以我们要对线程 1 进行加锁,当线程 1 正在运行的时候,线程 2 乖乖的阻塞等待带 xianc1 解锁,线程 1 解锁后线程 2 开始执行调度,线程 2 也要进行加锁,所以线程 2 在执行调度的时候,线程 1 反过来阻塞等待

因此线程 1 与线程 2 会产生“锁竞争”,也就是线程 1 还没解锁,线程 2 不动,线程 2 执行时还没解锁,线程 1 不动,当然,哪个线程先执行由内核说了算,谁先执行,另外的就阻塞等待

具体做法:在 count++之前加锁,++完后解锁,count 在加锁和解锁之间进行++,别的线程想修改,改不了(别的线程只能阻塞等待,阻塞等待的线程状态为 BLOCKED)

下面研究如何给代码加锁-----synchronized

1、syn 的最基本用法:使用 syn 关键字来修饰一个普通方法,当进入方法时,加锁,方法执行完后,解锁(看起来就像串行了)

注:锁竞争会多线程存在串行,但是也仅仅是加锁的部分串行,其他代码都还在并发执行

alt text 如图所示,在 increase 方法进行加锁,当两个线程同时调用到 increase 方法时,则加锁,另外一个线程阻塞等待

这样就避免了由于线程的抢占式执行而产生的 bug alt text

2、syn 可以修饰代码块:用于在一个方法中有些代码需要加锁有些不需要

而要完成这种做法,我们要明确锁对象,由于 Java 的特殊性,锁对象可以任意选择,只要线程之间都是通过这个锁对象来对代码进行加锁,产生了锁竞争即可一般我们用 this 来当锁对象,含义就是此时调用的对象,谁调用就对谁进行加锁,所以我们对代码进行修改一下

alt text

alt text

用于我们补充了锁对象为 this,并且两个线程中的 counter 对象是同一个东西,都调用了 increase,所以会产生锁竞争

alt text

3、用 syn 来修饰静态方法,因为静态的就一份,不同的对象来调用静态的东西,这个东西永远是这个东西,因此也会产生锁竞争

alt text

alt text