【JavaEE初阶】第五节.多线程 ( 基础篇 ) 线程安全问题(上篇)

作者简介:大家好,我是未央;

博客首页:未央.303

系列专栏:JavaEE初阶

每日一句:人的一生,可以有所作为的时机只有一次,那就是现在!!!!

前言

一、线程安全的概述

1.1 什么是线程安全问题

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

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

2.1 案例分析

2.2 造成线程不安全的原因 

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

2.3.1 什么是加锁

2.3.2 使用 synchronized关键字 进行加锁 

2.3.3 synchronized 使用示例 

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

总结



前言

今天我们将进入到线程基础篇当中有关线程安全的问题,线程安全对于我们学习线程有着非常重要的作用;今天我们将通过本节学习,能够更好的认识到线程安全以及解决线程安全的问题;就让我们进入到今天的学习当中!!!!!!!!


一、线程安全的概述

1.1 什么是线程安全问题

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

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

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

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

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


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);
    }
}

 运行结果:

说明:

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

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

这是怎么回事呢? 我们接下来慢慢分析;

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

2.1 案例分析

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

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

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

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

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

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

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

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

注意:

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

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

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


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

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

情况一:

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

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

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

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


情况二:

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

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

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

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


情况三:

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

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

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

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


其他的情况:

说明:

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

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

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

总结:

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

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

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

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

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

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

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

2.2 造成线程不安全的原因 

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

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

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


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

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

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

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

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

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

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


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

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

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

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


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

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

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

内存可见性;

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

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

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


但是,在程序运行过程中,可能会涉及到一个操作 —— "优化" (可能是编译器 javac,也可能是 JVM java,也可能是操作系统 的行为);

那么 由于 线程1 频繁的进行 load   test 操作,就很有可能会被优化成 load   test   test......操作(会认为 一直读的都是一样的值,所以不需要再读了);

 

 


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

那么,如果在优化之后,线程2 突然又写了一个数据;由于 线程1 已经优化成读寄存器了,因此 线程2 的修改,线程1 感知不到 =>这就叫做 内存可见性问题(内存改了,但是在 优化 的背景下,读不到、看不见了);

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

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

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

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


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

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

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

举例说明:

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

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

调整顺序后,也是符合张三媳妇 对张三的要求:买到了四样菜,并且效率也是得到了提高;

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

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

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

如:

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

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

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

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

总结:

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

前三种原因 是更普遍的;

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

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

4.内存可见性;
5.指令重排序;

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

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

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

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


2.3.1 什么是加锁

举例说明:

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

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

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

加锁:

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

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

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

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

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

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

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 之前都可以):

或者:

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


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

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

所以说,在加锁的情况下,线程的三个指令就被岔开了,就可以保证 一个线程 save 之后,另一个线程才 load,于是此时的计算结果就准了 

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 锁的是什么:两个线程竞争的是同一把锁,才会产生阻塞操作(即 两个线程尝试使用两把不同的锁,不会产生阻塞操作)

如:举例说明:

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

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

注意:

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

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

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

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

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


总结

今天有关线程安全问题的上篇就讲到这里,下一节内容我们将继续探讨线程安全的问题,让我们下期再见!!!!!!

  • 20
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 19
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值