赞
踩
本人是一个刚刚上路的IT新兵,菜鸟!分享一点自己的见解,如果有错误的地方欢迎各位大佬莅临指导,如果这篇文章可以帮助到你,劳请大家点赞转发支持一下!
本篇文章主要讲解了线程不安全的场景,以及如何解决线程不安全问题,内容可能有点抽象,希望大家可以慢慢咀嚼,好好吸收。
何为线程安全
导致线程不安全的原因,主要是有三个场景。
1️⃣ 多个线程对同一个共享数据进行修改操作
2️⃣ 内存可见性(是编译器出现了误判后,对代码做出的错误优化)
3️⃣ 指令重排序(是编译器出现了误判后,对代码做出的错误优化)
编译器优化
编译器优化:智能的调整你的代码执行逻辑,保证程序结果不变的前提下,通过加减语句,通过语句执行顺序变换,通过一些操作,让整个程序执行的效率大大提升。
编译器对于"程序结果不变"
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。
内存可见性,指 一个线程对共享变量值的修改,能够及时地被其他线程看到。
Java 内存模型 (JMM):Java虚拟机规范中定义了Java内存模型。
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
主内存与工作内存
所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 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。
指令重排序问题,很难使用代码演示,大部分情况下都是正确的。
就拿创建新对象来说吧。
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。
synchronized 关键字-监视器锁monitor lock
加锁操作可以让这个代码块具备原子性。
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实现的。
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(); } }
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(); } }
本篇文章主要介绍了多线程不安全的场景以及如何解决,到这里多线程也还有很多知识在等待我们了解,加油!!!
路漫漫,不止修身也养性。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。