Java多线程中出现的线程安全问题分析以及如何解决

文章目录

  • 前言
  • 举个栗子
  • 分析
  • 解决
  • 第二个栗子
  • 分析
  • 解决
  • volatile 的作用

前言

由于调度器的抢占式执行, 或者说随机性很强的调度行为, 会让我们捉摸不透程序实际中的运行模式, 特别是在多线程的模式下, 就容易出现线程安全的问题, 例如我们举一个非常经典的例子:

举个栗子

创建两个线程, 让两个线程同时对一个静态变量 cnt 各自进行自增操作 10000 次
例如: 线程 1 让 cnt 自增 10000 次, 线程2 同样让 cnt 自增 10000 次
按我们的原始想法来说, 各自自增 10000, 最终值应该为 20000 才合理
但是: 实际上 cnt 最终结果并非 20000

public class Tmp {
    //创建两个线程,让这俩线程同时对一个变量进行自增操作
    public static int cnt = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                cnt++;
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                cnt++;
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join(); //线程等待, 让两个线程都执行完, 在打印 cnt 最终次数
            t2.join();
        } catch (InterruptedException e) { e.printStackTrace();}
        System.out.println("最终运算结果为" + cnt);
    }
}

我们执行上述程序, 如果什么都不考虑, 那么每个线程对 cnt 分别自增 10000 次, 最终 cnt 应该是20000 , 但是实际上:

Java多线程中出现的线程安全问题分析以及如何解决_第1张图片
实际上: 这个值基本上都在 1w - 2w之间随机出现

分析

那么为什么会出现上面那种情况呢, 我们分析一波

  1. 上面两个线程都只执行了一个语句cnt ++;
  2. 而这行代码又可以具体分成三个机械指令
  3. 为了方便描述, 我给下面三个步骤起个小名
  4. ① load → 从内存中读取 cnt 的值到 CPU 中准备运算
  5. ② add → 在 CPU 的寄存器中完成自增操作
  6. ③ write → 把寄存器中的运行结果再写回内存中

在理想状态下, 我们如果想顺利将 cnt 自增到 20000, 那么站在两个线程的角度上, 应该是按照下面这种操作方式, 让一个操作完整的完成 cnt ++ 后, 再让另外一个线程去操作

Java多线程中出现的线程安全问题分析以及如何解决_第2张图片

但是操作系统对线程的调度都是很随机的, 实际上每个线程的这 3 个操作在这种情况下, 根本就没法保证执行顺序 ! 我们举个例子 ↓

Java多线程中出现的线程安全问题分析以及如何解决_第3张图片

有可能出现这种情况↑ , 例如刚开始 cnt = 0;

  1. 线程 1 读取 cnt = 0, 线程 2 也读取 cnt = 0;
  2. 线程 1 进行 cnt ++, 然后写入内存, 之后 cnt = 1;
  3. 线程 2 再进行 cnt ++, 但是由于实际上 cnt 刚刚读取的是 0 , 所以自增完 cnt = 1, 再写入内存, 之后 cnt 还是为 1
  4. 也就是说两个自增操作, cnt 仍然是 1

除了这种情况之外, 还有很多排列组合的情况, 大致如上, 不再一一列举

解决

为了解决以上问题, 我们可以使用synchronized关键字来给对象或者类加锁。加这个关键词后线程执行任务前都会上锁, 执行完任务就会解锁。 在上锁后, 其他线程需要执行任务前要上锁, 但是由于已经被锁住了, 就无法完成上锁操作, 由此进入阻塞状态, 直到锁被释放。 例如:

class Test {
    public int cnt = 0;
    public void increase() {  
        synchronized (this) { //同步代码块
            this.cnt ++;
        }
    }
}

public class Tmp {
    //创建两个线程,让这俩线程同时对一个变量进行自增操作
    public static Test test = new Test();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                test.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                test.increase();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join(); //线程等待, 让两个线程都执行完, 在打印 cnt 最终次数
            t2.join();
        } catch (InterruptedException e) { e.printStackTrace();}
        System.out.println("最终运算结果为" + test.cnt);
    }
}

第二个栗子

  1. 创建一个静态变量 test = 1;
  2. 让线程1 进入 whlie (test == 1) {} 但什么都不做的死循环, 如果循环结束, 打印"线程1结束"
  3. 在线程2 中修改 test 的值为6
  4. 按原始的想法来说, 在线程2中将 test 改为 6 之后, 线程1应该停下循环, 然后打印"线程1结束"
public class Tmp {
    public static int test = 0;  //定义一个整形变量

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (test == 0) {
                //啥都不干
            }
            System.out.println("线程1执行完了");  //循环结束, 则打印这个语句
        });

        Thread t2 = new Thread(() -> {
            Scanner in = new Scanner(System.in);
            System.out.println("输入 ->"); //在线程2中偷偷改变 test 的值
            test = in.nextInt();
        });
        t1.start();
        t2.start();

        try {
            t1.join(); //线程等待
            t2.join();
        } catch (InterruptedException e) { e.printStackTrace();}
    }
}

如上代码, 在修改test = 6之后, 线程1并不会停下来:

Java多线程中出现的线程安全问题分析以及如何解决_第4张图片

可以看出, 线程1并没有停下, 还在一直死循环(没有打印)

分析

我们再来分析一波:

  1. 在线程1的死循环中有两个机械指令
  2. ① 将 test 的值从内存中取出
  3. ② 判断是否和 0 相等
  4. 这种情况下, 死循环的判断频率非常高, 而 test 的值却一直没变化。但是在计算机中, 内存操作的速度比寄存器的速度相比, 会慢1000 + 倍, 而不断的从内存中取出一个电脑认为没变化的 test 值, 会导致:
  5. 系统或者JVM对这做出优化: 将第一次读取到的数据放在寄存器中, 然后进行反复判断, 就可以直接节省大量从内存中取值的时间, 而后我偷偷摸摸在另外一个线程中改掉 test 的值, 系统也不知情, 也不会再从内存中读取 test 值, 就出现了这种现象, 也就是内存可见性

解决

为了让上述情况的操作更有可控性, 我们可以使用关键字 volatile, 我们示范一次

上述代码在 test 前加上一个 volatile 后

public static volatile int test = 0;  

就可以按照我们的逻辑搞定了

Java多线程中出现的线程安全问题分析以及如何解决_第5张图片

那么 volatile 有在上面起到了什么作用呢

volatile 的作用

  • 通过特殊的二进制指令为这个变量增加了一个内存屏障
  • 能够让JVM在读取这个变量的时候, 知道这个变量"身份特殊", 就强制每次都要从内存中读取这个变量的值
  • 因此提升了线程安全性, 还能禁止指令重排序, 这个下回有机会再讲。

你可能感兴趣的:(笔记,java,jvm,开发语言)