多线程的风险 --- 线程安全
Posted bit me
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程的风险 --- 线程安全相关的知识,希望对你有一定的参考价值。
✨个人主页:bit me👇
✨当前专栏:Java EE初阶👇
✨每日一语:低头赶路,敬事如仪;自知自心,其路则明。
目 录
🍸一. 线程不安全
多线程编程,最重要,也是最困难的问题就是线程安全问题,它的万恶之源,罪魁祸首就是调度器的随机调度 / 抢占式执行 这个过程
线程不安全:在随机调度之下,程序执行有多种可能,其中的某些可能导致代码出现了 bug ,线程不安全 / 线程安全问题
例如:两个线程对一个变量进行并发的自增(创建俩线程,让这俩线程同时并发的对一个变量,自增 5w 次,最终预期能一共自增 10w 次)
//创建俩线程,让这俩线程同时并发的对一个变量,自增 5w 次,最终预期能一共自增 10w 次
class Counter
//用来保存计数的变量
public int count;
public void increase()
count++;
public class Demo14
//这个实例用来进行累加
public static Counter counter = new Counter();
public static void main(String[] args)
Thread t1 = new Thread(()->
for (int i = 0; i < 50000; i++)
counter.increase();
);
Thread t2 = new Thread(()->
for (int i = 0; i < 50000; i++)
counter.increase();
);
t1.start();
t2.start();
try
t1.join();
t2.join();
catch (InterruptedException e)
e.printStackTrace();
System.out.println("count: " + counter.count);
运行结果:
再运行一次:
发现随机调度顺序不一样,结果也就不一样
那上面的 bug 如何出现的?
执行一段代码,需要让 CPU 把对应的指令从内存中读取出来,然后再执行
像 count++ 一行代码,对应三个机器指令
- 1.从内存读取数据到 CPU(load)
- 2.在 CPU 寄存器中,完成加法运算(add)
- 3.把寄存器的数据写回到内存中(sava)
指令的排序方式:
上述两种指令的排序方式恰好能到 2 。但是还有许许多多的排列组合方式,就都不一定了。
此时总和就是 1 了
这些还有许许多多就不在此举例了
根据上述的总结,俩种极端情况就是 5w 和 10w 。然后其他的情况就是 5w 和 10w 之间了。
拓展:
操作系统中的随机调度严格意义上来说其实不是 “随机调度” 。在内部他有自己的一套调度过程,我们不需要理解这个过程,了解了也无法改变这个调度。
🍹二. 线程不安全的原因
1. 操作系统的随机调度 / 抢占式执行。
【罪魁祸首,万恶之源】
2. 多个线程修改同一个变量。
【三个条件缺一不可,别的情况都没事儿】(所以写代码中可以针对这三个点进行改变进行规避,但是范围有限,不是所有的场景都可以规避掉)
3. 有些修改操作,不是原子的!
【不可拆分的最小单位,就叫原子】
通过 = 来修改,= 只对应一条机器指令,视为是原子的
通过 ++ 来修改,++ 对应三条机器指令,则不是原子的
什么是原子性?
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。(后续详解关于锁)
后果:如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。
4. 内存可见性,引起的线程安全问题。
【内存改了,但是在优化的背景下,读不到看不见】
例如一个线程读,一个线程修改:线程 1 LOAD 之后需要进行 TEST ,然后继续 LOAD 再继续 TEST ,这样循环走下去,但是,在程序运行过程中,可能会涉及到一个操作 “优化” (可能是编译器 Javac,也可能是 JVM Java,也可能是操作系统),由于 LOAD 读的操作太慢,反复读,每次读到的数据都是一样的,JVM 就对此做出了优化,不需要重复在内存中读取了,直接就重复用第一次从内存读到寄存器的数据就好了,此时在优化之后,线程 2 突然写了一个数据,由于线程 1 已经优化成读寄存器了,因此线程 2 的修改线程 1 感知不到。
上述优化在单线程环境下没问题,但是多线程情况下就可能产生问题,多线程环境太复杂,编译器/JVM/操作系统进行优化的时候就可能产生误判!!!针对这个问题,Java 引入了 volatile 关键字,让程序猿手动禁止编译器针对某个变量进行上述优化!
5. 指令重排序。
【调整代码执行顺序】
也是编译器 / JVM / 操作系统优化
优化在单线程环境下没问题,但是多线程情况下就可能产生问题,例如 Test t = new Test(); 在底层就有三个步骤:
- 创建内存空间
- 往这个内存空间上构造一个对象
- 把这个内存的引用赋值给 t
此处就容易出现指令重排序引入的问题,2 和 3 的顺序是可以调换的,在单线程下调换这俩是没影响的,多线程下就不行了。例如我们俩线程,第一个线程按照先 2 后 3 的顺序,另一个线程尝试读取 t 的引用,当第二个线程读到 t 为非 null 的时候,此时 t 就一定是一个有效对象!!!如果是按照先 3 后 2 的顺序,第二个线程读到 t 为非 null 的时候,仍然可能是一个无效对象!!!
线程安全问题重点
多线程带来的的风险 - 线程安全
线程安全
线程安全问题是多线程所涉及到的最重要的,也是最复杂的问题
观察线程不安全
public class ThreadDemo14
static class Counter
public int count = 0;
public void increase()
count++;
public static void main(String[] args) throws InterruptedException
Counter counter = new Counter();
Thread t1 = new Thread()
@Override
public void run()
for (int i = 0; i < 50000; i++)
counter.increase();
;
t1.start();
Thread t2 = new Thread()
@Override
public void run()
for (int i = 0; i < 50000; i++)
counter.increase();
;
t2.start();
t1.join();
t2.join();
// 两个线程各自自增5000次,最终预期结果,应该是10w
System.out.println(counter.count);
注意:
运行结果:
多运行几次你会发现,结果并不是10w,而且每次运行的结果都不一样
上述现象:则是线程不安全
线程不安全: 多线程并发执行某个代码时,产生了逻辑上的错误,就是"线程不安全"
线程安全的概念
和线程不安全对应,线程安全就是 多线程并发执行某个代码,没有逻辑上的错误,就是"线程安全"
线程不安全的原因
思考: 为啥会出现上述情况???
原因:
- 线程是抢占式执行的 (线程不安全的万恶之源)
抢占执行:线程之间的调度完全由内核负责,用户代码中感知不到,也无法控制
线程之间谁先执行,谁后执行,谁执行到哪里从CPU上下来,这样的过程都是用户无法控制的,也是无法感知的 - 自增操作不是原子的
每次++,都能拆分成三个步骤:
1.把内存中的数据读取到CPU中 — load
2.在CPU中,把数据+1 — increase
3.把计算结束的数据写回到内存中 — save
当CPU执行到上边三个步骤的任意一个步骤时,都可能被调度器调度走,让给其他线程来执行
画图表示:
上述代码的执行结果在范围 [5w,10w] 之间
极端情况下,
t1 和 t2 每次++ 都是纯并行的,结果就是 5w
t1 和 t2 每次++ 都是纯串行的,结果就是 10w
实际情况,一般不会这么极端,调度过程中有时候是并行,有时候是串行(多少次并行,多少次串行,这个不清楚),因此导致最终的结果是在 [5w,10w] 之间
- 多个线程尝试修改同一个变量
若一个线程修改一个变量,线程安全
若多个线程尝试读取同一个变量,线程安全
若多个线程尝试修改不同的变量,线程安全 - 内存可见性
- 指令重排序
Java 的编译器在编译代码时,会针对指令进行优化 (优化:调整指令的先后顺序,保证原有逻辑不变的情况下, 来提高程序的运行效率)
如何解决线程不安全问题?
1.抢占式执行 — (这个没法解决,操作系统内核解决)
2.自增操作非原子 — (这个有办法,可以给自增操作加上锁) 适用范围最广
3.多个线程同时修改同一个变量 — (这个不一定有办法解决,得看具体的需求)
转下篇:锁
以上是关于多线程的风险 --- 线程安全的主要内容,如果未能解决你的问题,请参考以下文章