【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

2年前 (2022) 程序员胖胖胖虎阿
109 0 0

☕导航小助手☕

  🍚写在前面

        🍛一、线程安全概述

                   🧇🧇1.1 什么是线程安全问题

                   🦪🦪1.2 存在线程安全问题的实例

        🍜二、线程安全问题及其解决办法

                   🍣🍣2.1 案例分析

                   🍤🍤2.2 造成线程不安全的原因

                   🥩🥩2.3 线程加锁操作解决 原子性问题

                                   🧀🧀🧀2.3.1 什么是加锁

                                   🍞🍞🍞2.3.2 使用 synchronized关键字 进行加锁

                                   🍰🍰🍰2.3.3 synchronized 使用示例

        🍱三、Java标准库里面的线程安全类


写在前面

线程的安全性问题,是多线程编程中 最最重要的部分,也是线程中最最复杂的部分~

据说,它是一种挑战人脑智商的极限的问题~

同时,对于这个问题,将分为两篇博客 一一的介绍开来~

下面,让我们一起来看看吧 ......

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

一、线程安全概述

1.1 什么是线程安全问题

线程安全问题 出现的 "罪魁祸首",正是 调度器的 随机调度 / 抢占式执行 这个过程~

在随机调度之下,多线程程序执行的时候,有无数种可能性,有无数种可能的排列方式~

在这些排列顺序中,有的排列方式 逻辑是正确的,但是有的排列方式 可能会引出 bug~

对于多线程并发时,会使程序出现 bug 的代码 称作线程不安全的代码,这就是线程安全问题~

接下来,举出一个典型的例子,来观察一番 到底什么是线程安全问题~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

1.2 存在线程安全问题的实例

创建两个线程,让这两个线程 同时并发 对一个变量,自增 5w 次,最终预期能够一共自增 10w 次~

package thread;

class Counter {
    //用来保存计数的变量
    public int count;

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

public class Demo14 {
    public static void main(String[] args) {
        //这个实例用来进行累加
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count:" + counter.count);
    }
}

 运行结果:

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

很明显,我们可以发现 程序运行的结果 都是 小于 10w 次的,即便是运行多次,结果也都是小于 10w 次~

事实上,正确的结果是,得到的数字 count 在 5w 到 10w 之间~

这是怎么回事呢? 

我们接下来慢慢分析~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

二、线程安全问题及其解决办法

2.1 案例分析

按理来说,上述实例 运行的结果 count 应该等于 10w~

可是 连续运行多次,就会发现 每一次运行的结果都不一样,但都是小于 10w,这是为什么呢?

这个就是 线程不安全的问题~

其原因主要是:随机调度的顺序不一样,就导致程序运行的结果不一样~

上述的 bug 是怎么形成的呢?

这个得需要站在硬件的角度来理解:

像 count++ 这一行代码,其实对应的是 三个机器指令:

  1. 首先 需要从内存中读取数据到 CPU,这个指令称为 load指令
  2. 其次 在 CPU 寄存器中,完成加法运算,这个指令称为 add指令
  3. 最后 把寄存器的指令 写回到内存中,这个指令称为 save指令

这个在 JavaEE初阶 的第一篇文章中提到过,不清楚的可以跳转过去看一看~

🚪传送门:【JavaEE初阶】计算机是如何工作的🚪

这三个步骤,如果是在单线程下执行,那是没有任何问题的~

但是如果是在多线程下执行,那就不一定了~ 


现在,我们可以以一条时间轴,来画一下其中常见的情况:

load 是把内存中的数据读到 寄存器里,add是在寄存器里面进行加法操作,save是把寄存器里面的值放回到内存~

情况一:

对两个数进行自增操作,内存 初始值为 0,两个线程进行并发执行,进行两次自增~

寄存器A 表示 线程1 所用的寄存器,寄存器B 表示 线程2 所用的寄存器~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

通过上述的执行过程,我们可以看到,两个线程 各自增一次,预期 自增两次,实际上的结果是 2,没有任何问题~

看起来是没有任何问题的,可是实际情况下 这个可是多线程,只是出现无数种情况的其中一种而已,只是这种排列方式恰好没有问题(其他的排列方式就不一定了)~ 


情况二:

对两个数进行自增操作,内存 初始值为 0,两个线程进行并发执行,进行两次自增~

寄存器A 表示 线程1 所用的寄存器,寄存器B 表示 线程2 所用的寄存器~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

如上所示,明明在内存里面自增了两次,但是最终内存的值 仍然是 1~

这就是典型的线程不安全导致的 bug~


情况三:

对两个数进行自增操作,内存 初始值为 0,两个线程进行并发执行,进行两次自增~

寄存器A 表示 线程1 所用的寄存器,寄存器B 表示 线程2 所用的寄存器~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

如上所示,最终的内存中保存的是 1~

这是典型的线程不安全的问题~ 


其他的情况:

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

 【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

由于是多线程,所以有无数种情况 ~

总之,在无数中的排列顺序情况下,只有 "先执行完第一个线程,再执行完第二个线程" 以及 "先执行完第二个线程",再执行完第一个线程 的这两种情况,是没有问题的~

剩下的情况,全部都是和正确结果不匹配~


 回到最初的代码程序,我们就可以知道:

在极端情况下,如果所有的执行排列都是 "先执行完第一个线程,再执行完第二个线程" 以及 "先执行完第二个线程",那么此时的总和就是 10w~

在极端情况下,如果所有的执行排列顺序 是不包括这两种情况的其他情况,那么此时总和就是 5w~

更实际的情况下,调度器具体调度出多少种这两种极端的情况,我们是无法确定的~

因此 最终的结果是 5w ~ 10w !!!!!!


操作系统的随机调度,其实不是 "真随机",而是 操作系统内核的调度器调度线程,其内部是有一套 逻辑 / 算法,来支持这一调度过程 ~

即 每种出现的排列情况下不是均等的,所以不可以通过排列组合的情况下算出每种情况 出现的概率的 ~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

2.2 造成线程不安全的原因

(一)操作系统的 随机调度 / 抢占式执行~

这个是 万恶之源、罪魁祸首!!!!!!

这个是 操作系统内核 实现的时候,就是这样设计的,因此 我们改不了(就算可以改得了自己的电脑,也改不了其他的人的那么多电脑),对此 我们是无能为力的~

(二)多个线程 修改 同一个变量~

如果只是一个线程修改变量,没有线程安全问题!!!

如果是多个线程读同一个变量,也没有线程安全问题!!!

如果是多个线程修改不同的变量,还是没有线程安全问题!!!

但是,多个线程修改同一个变量,那就有了线程安全问题了~

所以,在写代码的时候,我们可以针对这个要点进行控制(可以通过调整程序的设计,来去规避 多个线程修改同一个变量)~

但是,此时的 "规避方法" 是有适用范围的,不是所有的场景都可以规避掉(这个得要看具体的场景)~

(三)有些修改操作,不是 原子的修改,更容易触发 线程安全问题~

在 MySQL数据库中说过,不可拆分的最小单位 就叫做原子~

如:赋值操作来修改(=,只对应一条机器指令),就是视为原子的~

像之前通过 ++操作 来修改(对应三条机器指令),就不是原子的~

(四)内存可见性 引起的线程安全问题~

内存可见性,这个就是另外一个场景了:一个线程写,一个线程读的场景~

这个场景 就特别容易因为 内存可见性 而引发问题~

线程1:进行反复的 读 和 判断 ~

线程2:在某个环节下进行修改~

如果是正常的情况下,线程1 在读和判断,线程2 突然写了一下 => 这是正常的,在线程2 写完之后,线程1 就能立即读到内存的变化,从而让判断出线变化~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)但是,在程序运行过程中,可能会涉及到一个操作 —— "优化" (可能是编译器 javac,也可能是 JVM java,也可能是操作系统 的行为)~

那么 由于 线程1 频繁的进行 load   test 操作,就很有可能会被优化成 load   test   test......操作(会认为 一直读的都是一样的值,所以不需要再读了)~【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

每次 load操作 都是读内存操作,每次 test操作 都是在读寄存器,读内存操作 要比 读寄存器操作 慢上几千倍、上万倍~

正是由于 load操作 读的太慢,再加上 反复读,每一次读到的数据又一样,所以 JVM 就做出了这样的优化,就不再重复的从内存中读了,直接就复用第一次从内存读到寄存器的数据就好了~ 

那么,如果在优化之后,线程2 突然又写了一个数据~

由于 线程1 已经优化成读寄存器了,因此 线程2 的修改,线程1 感知不到 =>这就叫做 内存可见性问题(内存改了,但是在 优化 的背景下,读不到、看不见了)~

所谓优化,是指在执行正确的前提下,来做出变化 使得性能更优~

一定要保证程序的逻辑是正确的,再说效率问题!!!

上述场景的优化,在单线程场景下,没有问题;但是在多线程情况下,就可能会出现问题:多线程环境太复杂,编译器 / JVM / 操作系统 进行优化的时候就可能产生误判~

针对这个问题,Java 引入了 volatile关键字,让程序猿手动的禁止 编译器 / JVM / 操作系统 对某个变量进行上述优化!!! 

(五)指令重排序,也可能引起线程不安全

指令重排序,也是 操作系统 / 编译器 / JVM 优化操作!!!

它调整了代码的执行顺序,达到加快速度的效果~

比如说,张三媳妇 要张三去到超市买一些蔬菜,并且给了他一张清单:

  1. 西红柿
  2. 鸡蛋
  3. 茄子
  4. 小芹菜

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)调整顺序后,也是符合张三媳妇 对张三的要求:买到了四样菜,并且效率也是得到了提高~

至于买的过程是什么样子的,张三媳妇并不关心~

这个就叫做 指令重排序!!!

指令重排序,也会引发线程不安全~

如:

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

此处,就容易出现指令重排序引入的问题:

2 和 3 的顺序是可以调换的~

在单线程下,调换这两的顺序,是没有影响的;但是如果在多线程条件下,那么是会出现 多线程不安全:

假设 另一个线程,尝试读取 t 的引用,如果是按照 2、3的顺序,第二个线程读到 t 为 非null 的时候,此时 t 就一定是一个有效对象;如果是按照 3、2的顺序,第二个线程读到 t 为 非null 的时候,仍然可能是一个无效对象!!!


总结:

线程安全问题出现的五种原因:

前三种原因 是更普遍的~

  1. 系统的随机调度(万恶之源、无能为力)
  2. 多个线程同时修改同一个变量(部分规避)
  3. 修改操作不是原子的(有办法改善的)

后两种原因,是 编译器 / JVM / 操作系统 搞出的幺蛾子(但是 总体上来说还是利大于弊的)

  1. 内存可见性
  2. 指令重排序

编译器 / JVM / 操作系统 误判了,导致把不应该优化的地方给优化了,逻辑就变了,bug 就出现了(当然,后两种原因 也可以用 volatile关键字 来进行解决)~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

2.3 线程加锁操作解决 原子性问题

现在先重点来介绍一下 解决线程安全问题出现的第三种原因的方法(原子性),通过 加锁操作,来把一些不是原子的操作打包成一个原子的操作!!!

加锁在 Java 中有很多方式来实现,其中最常用的就是 synchronized(用法其实也挺简单的,我们需要注意的是它的拼写和发音)~ 


2.3.1 什么是加锁

举个简单明了的例子,假设 你要去银行ATM机 取钱(我们都知道,ATM机 是放在一个单独的小房子里面的,每个小房子都有一把锁),如果你进去了,那么这个锁就会自动的锁起来,别人就进不去了,除非是 你已经取钱成功了 并且 自己已经出来了,下一个人才可以继续使用到 ATM机~

取钱成功了,说明 取钱的几个步骤是成功了的,那么我们希望,去 ATM机 取钱的这些步骤,是能够一气呵成的(如果 不一气呵成,万一走的时候忘记啥步骤,取钱没有成功,大大咧咧的走了;后面的人一顿操作猛如虎,把你的钱取走了咋搞)~

为了使这些步骤一气呵成,引入的办法就是 加锁~

加锁:

即 在你进去的时候,门就被锁了,其他的人就进不去了~

然后你就可以完成 刷卡、输入密码 等等的操作,等这些操作都完成了之后,再把锁给打开,然后你就可以出去了~

下一个人也就可以进来重复和你一样的操作了~

实际上,银行里面的 ATM机 就是这样设计的~

此时的 "你" 指的就是 "线程","ATM机" 指的就是 "对象","门上的锁" 指的就是 "锁","其他人" 指的就是 "其他的线程"~

在 Java中,加锁的方式有很多种,其中最常见的加锁方式就是用 synchronized关键字 进行加锁~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

2.3.2 使用 synchronized关键字 进行加锁

synchronized 从字面意思上翻译叫做 "同步",其实 实际上它所起的是 互斥的效果~

在一开始的时候,列举了一个典型的线程不安全的例子:创建两个线程,让这两个线程 同时并发 对一个变量,自增 5w 次,最终预期能够一共自增 10w 次~

package thread;

class Counter {
    //用来保存计数的变量
    public int count;

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

public class Demo14 {
    public static void main(String[] args) {
        //这个实例用来进行累加
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count:" + counter.count);
    }
}

那么,怎么使用 synchronized关键字 来解决这个线程不安全的问题呢?

—— 很简单,我们在上面的 increase() 方法 前面加上 synchronized关键字即可(写在 void 之前都可以):

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

此时,我们再执行程序,发现无论再运行多少次,发现运行结果是正确的了:

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)


那么,为什么加锁之后,就可以来实现 线程安全的保障呢?

LOCK 这个指令是互斥的,当 线程t1 进行 LOCK 之后,t2 也尝试 LOCK,那么 t2 的 LOCK 就不会直接成功!!!

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)所以说,在加锁的情况下,线程的三个指令就被岔开了,就可以保证 一个线程 save 之后,另一个线程才 load,于是此时的计算结果就准了~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

2.3.3 synchronized 使用示例

(一)synchronized 直接修饰普通方法

public class Demo14 {
    public synchronized void methond() {

   }
}

(二)synchronized 修饰静态方法

public class Demo14 {
    public synchronized static void method() {

   }
}

(三)修饰代码块

public class Demo14 {
    public void method() {
        synchronized (this) {
            
       }
   }
}

() 里面的 this 指的是:是针对哪个对象进行加锁!!!

加锁操作,是针对一个对象来进行的!!!

我们要重点理解,synchronized 锁的是什么:两个线程竞争的是同一把锁,才会产生阻塞操作(即 两个线程尝试使用两把不同的锁,不会产生阻塞操作)~

如:

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

换句话说,1号滑稽 进入1号坑位,只是针对 1号坑位 进行了加锁,别人想要进入 1号坑位,就需要阻塞等待;但是 如果想要进入其他的 空闲坑位,那么则不需要等待~

这里的 滑稽老铁 指的就是 线程,坑位(的门上的锁,其实就是 synchronized() 括号里面的东西)  指的就是 要加锁的对象~

注意:

  1. 在Java里,任何一个对象,都可以用来做 锁对象,即 都可以放在  synchronized() 的括号中;其它的主流语言 都是专门搞了一类特殊的对象,用来作为 锁对象(大部分的正常对象 不能用来加锁)!
  2. 每个对象,内存空间中都会有一个特殊的区域 —— 对象头(JVM自带的,对象的一些特殊的信息)【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)
  3. synchronized 写到普通方法上 相当于是对 this(可创建出多个实例) 进行加锁,synchronized 写到静态方法上 相当于是对 类对象(整个 JVM 里只有一个) 进行加锁,synchronized (类名.class)~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

三、Java标准库里面的线程安全类

在Java标准库里面,很多线程都是不安全的,如:例如,ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder~

当然,还是有一些是线程安全的,如:Vector (不推荐使用),HashTable (不推荐使用),ConcurrentHashMap (推荐),StringBuffer,String~

需要注意的是,加锁也是有代价的,它会牺牲很大的运行速度(毕竟,加锁涉及到了一些线程的阻塞等待,以及 线程的调度),所以可以视为,一旦使用了锁,我们的代码基本上就和 "高性能" 说再见了~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

这一篇博客暂时就介绍到这里了,关于 线程安全性 的剩下的内容,将会在下一篇博客中细细介绍~

如果感觉这一篇博客对你有帮助的话,可以一键三连走一波,非常非常感谢啦 ~

【JavaEE初阶】多线程 _ 基础篇 _ 线程安全问题(上篇)

相关文章

暂无评论

暂无评论...