文章目录
前言
本人是一个刚刚上路的IT新兵,菜鸟!分享一点自己的见解,如果有错误的地方欢迎各位大佬莅临指导,如果这篇文章可以帮助到你,劳请大家点赞转发支持一下!
本篇文章主要讲解了线程不安全的场景,以及如何解决线程不安全问题,内容可能有点抽象,希望大家可以慢慢咀嚼,好好吸收。
一、线程不安全场景
何为线程安全
- 如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
导致线程不安全的原因,主要是有三个场景。
1️⃣ 多个线程对同一个共享数据进行修改操作
2️⃣ 内存可见性(是编译器出现了误判后,对代码做出的错误优化)
3️⃣ 指令重排序(是编译器出现了误判后,对代码做出的错误优化)
编译器优化
编译器优化:智能的调整你的代码执行逻辑,保证程序结果不变的前提下,通过加减语句,通过语句执行顺序变换,通过一些操作,让整个程序执行的效率大大提升。
编译器对于"程序结果不变"
- 单线程下判定是非常准确的。
- 多线程下判定就不一定了,可能调整后,效率提高了,但是结果改变了(编译器出现误判),引起程序出现bug。
1. 多个线程修改同一共享数据
public class ThreadDemo2 {
private static int i = 0;//全局变量
public static void main(String[] args) throws InterruptedException {
// 创建一个线程让i++ 5000次
Thread thread1 = new Thread(() -> {
for(int j = 0;j < 5000;j++) {
i++;
}
});
// 再创建一个线程让i++ 5000次
Thread thread2 = new Thread(() -> {
for(int j = 0;j < 5000;j++) {
i++;
}
});
thread1.start();
thread2.start();
// 两个线程开始执行
thread1.join();
thread2.join();
// 等待两个线程执行完毕打印i
System.out.println(i);
}
}
上述代码,运行了三次,三次结果都不一样且都不符合预期结果的10000。
那么为什么会造成以上结果呢??
造成以上线程不安全问题主要有三个原因
1️⃣ 线程之间是抢占式执行的,CPU执行到任意一条语句都可能被调度去执行其他线程 (罪魁祸首,主要原因)
2️⃣ 多个线程修改同一个共享变量
3️⃣ 修改操作不是原子性的
前两条是代码的执行机制,而这两个机制,遇上第三个原因,就会出现大问题。
什么是原子性?
在以前,人们还没有发现中子,质子,电子时,人们认为不可分割的最小物质就是原子。
因此,我在执行一个操作时,
如果这个操作不可以被分割成几个步骤,必须一次性执行完毕,那么这个操作具备原子性。
如果这个操作可以被分割成几个步骤,可以通过CPU调度来间断性的完成这个操作,那么这个操作不具备原子性。
某个操作对应单个CPU指令,那么这个操作就是原子性的
某个操作对应多个CPU指令,那么这个操作大概率就不是原子性的
使用 ‘=’ 赋值,就是一个原子性操作。
而i++对应了三个CPU指令。
就比如上述的i++操作,是由三步操作组成的。
1️⃣load(从内存把数据读到CPU)
2️⃣add(CPU对数据进行运算)
3️⃣save(把数据写回内存)
此时已经执行了两次i++,而 i 仍等于1。
此处i++这个操作是由三个CPU指令来完成的,因此两个线程,抢占式执行,就可能存在多种指令顺序排列,因此造成bug。
2. 内存可见性
内存可见性,指 一个线程对共享变量值的修改,能够及时地被其他线程看到。
Java 内存模型 (JMM):Java虚拟机规范中定义了Java内存模型。
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
- 线程之间的共享变量存在 主内存 (Main Memory)
- 每一个线程都有自己的 “工作内存” (Working Memory)
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存
主内存与工作内存
所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存
主内存与工作内存的区别
CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍)
public class ThreadDemo3 {
private static int flag = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (flag == 0) {
//空循环,循环不结束线程不结束
}
System.out.println("循环结束,thread1线程结束");
},"循环thread");
Thread thread2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
flag = scanner.nextInt();
System.out.println("修改完毕");
},"修改thread");
thread1.start();
thread2.start();
}
}
预期效果:
用户输入一个非0的数,那么两个线程都会执行完毕。
实际效果:
用户输入了一个非0的数,thread1线程仍在执行,thread2线程执行完毕。
可以看到名为"循环thread"的线程状态为RUNNABLE,说明这个线程仍在执行。
那么为什么会导致这个问题呢?
"循环thread"线程在判断循环条件时,有两个操作。
1️⃣load 从内存读取数据到工作内存
2️⃣比较工作内存里的值是否为0
此处的两个操作,load的时间开销远远高于cmp,
因为CPU的执行速度很快,一秒钟甚至可以达到上亿次,
那么对于load操作来说,在咱们输入整数前,就已经执行了很多次了,编译器发现每次结果都一样。
此时编译器就做了一个大胆且危险的操作,
把load这个操作给优化掉了,只有第一次执行load时,才是真的执行了,
再后续都只执行cmp的操作(会一直复用第一次load操作时读取到的值)。
内存可见性,就是多线程环境下,编译器对代码优化,产生了误判,导致出现了bug。
3. 指令重排序
指令重排序问题,很难使用代码演示,大部分情况下都是正确的。
就拿创建新对象来说吧。
class House {
int area;//房子面积
// 构造方法
public House(int area) {
this.area = area;
}
public void sleep() {
}
}
我要new一个House
会有三个操作:
1️⃣向系统申请空间
2️⃣调用构造方法,初始化数据
3️⃣内存地址赋给引用
正常顺序:1️⃣2️⃣3️⃣
指令重排序后,顺序可能变为
优化后顺序:1️⃣3️⃣2️⃣
class House {
int area;//房子面积
// 构造方法
public House(int area) {
this.area = area;
}
public void sleep() {
System.out.println("睡觉");
}
private static House house;//全局变量
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
house = new House(10);
});
Thread t2 = new Thread(() -> {
if(house != null) {
house.sleep();
}
});
t1.start();
t2.start();
}
}
看上述代码,
假设t1中的new操作,被指令重排序后,执行顺序变为1️⃣3️⃣2️⃣。
假设当执行完3️⃣,未执行2️⃣时。
线程t2开始执行,此时的house不为null,但是其中的数据,方法都没有初始化。
那么此时调用sleep方法就不知道会发生什么了,很可能产生bug。
二、解决线程不安全问题
1. 多个线程修改同一共享数据
synchronized 关键字-监视器锁monitor lock
加锁操作可以让这个代码块具备原子性。
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
可以理解为,有的代码需要投资商投资才能运行。
而多个线程对同一共享数据进行修改时,
可以让修改操作变成需要投资才能运行的代码。
投资即加锁操作,
撤资即解锁操作。
public class ThreadDemo2 {
static int i = 0;
static Object locker = new Object();//创建对应投资商
public static void main(String[] args) throws InterruptedException {
// 创建一个线程让i++ 5000次
Thread thread1 = new Thread(() -> {
for(int j = 0;j < 5000;j++) {
// 进入synchronized修饰的代码块后,
// 相当投资商在给这个代码块投资,
// 其他需要该投资商投资的代码,只能阻塞等待。
synchronized (locker) {
i++;
}
// 出了该代码块相当于撤资,
// locker投资商就可以去给其他需要的代码块投资了。
}
});
// 再创建一个线程让i++ 5000次
Thread thread2 = new Thread(() -> {
for(int j = 0;j < 5000;j++) {
synchronized (locker) {
i++;
}
}
});
thread1.start();
thread2.start();
// 两个线程开始执行
thread1.join();
thread2.join();
// 等待两个线程执行完毕打印i
System.out.println(i);
}
}
任意引用类型都可以作为投资商
理解 “阻塞等待”
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁。
【注意】
-
synchronized修饰方法时,那么投资商就是这个方法所在类的对象。
-
上一个线程解锁之后, 下一个线程并不是立即就能获取到锁,而是要靠操作系统来 “唤醒”。这也就是操作系统线程调度的一部分工作
-
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待。但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则。
-
synchronized的底层是使用操作系统的mutex lock实现的。
2. 内存可见性
volatile 关键字
volatile 修饰的变量, 能够保证 "内存可见性,但是不保证原子性"
代码在写入 volatile 修饰的变量的时候
- 改变线程工作内存中volatile修饰的变量的值
- 将改变后的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候
- 从主内存中读取volatile修饰的变量的最新值到线程的工作内存中
- 从工作内存中读取volatile修饰的变量的值
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了
public class ThreadDemo3 {
private static volatile int flag = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (flag == 0) {
// 空循环,循环不结束线程不结束
}
System.out.println("循环结束,thread1线程结束");
},"循环thread");
Thread thread2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数");
flag = scanner.nextInt();
System.out.println("修改完毕");
},"修改thread");
thread1.start();
thread2.start();
}
}
3. 指令重排序
volatile 关键字
class House {
int area;// 房子面积
// 构造方法
public House(int area) {
this.area = area;
}
public void sleep() {
System.out.println("睡觉");
}
private static volatile House house;//全局变量
// 使用volatile修饰该引用类型变量,则会禁止指令重排序,
// 严格按住1,2,3的顺序来创建对象。
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
house = new House(10);
});
Thread t2 = new Thread(() -> {
if(house != null) {
house.sleep();
}
});
t1.start();
t2.start();
}
}
总结
本篇文章主要介绍了多线程不安全的场景以及如何解决,到这里多线程也还有很多知识在等待我们了解,加油!!!
路漫漫,不止修身也养性。