【多线程】线程安全问题
线程安全问题是多线程编程中非常重要,它涉及到可执行文件在多线程环境下对共享资源的正确访问和操作。
一. 线程安全的概念
线程安全是指在多线程环境下,程序能够正确地处理共享资源,确保数据的完整性和一致性
意味着:
一个代码,不管在单线程下执行,还是多线程下执行,都不会产生bug,那么就是线程安全
一个代码,在单线程下运行正确,但是多线程下运行产生bug,那么就是“线程不安全”
典型的线程不安全示例 —— 多线程同时修改共享变量导致结果错误
public class Demo_1 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(()->{
for (int i = 0; i < 5_0000; i++) {
count++;
}
});
Thread thread1 =new Thread(()->{
for (int i = 0; i < 5_0000; i++) {
count++;
}
});
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println("count: "+count);
}
}
按道理说,应该输出10w ,但是结果不是,没有达到预期的结果,则说明该线程存在线程安全问题
为什么没有输出预期结果?
cpu在运行可执行文件的时候,本质上是在执行指令,count++这个操作由3个代码组成
- 从内存中读取数据到cpu的寄存器中
- 将cpu寄存器中的数值+1
- 将寄存器中被修改后的值写回内存中
如果是一个线程,执行三条指令(执行顺序不发生改变),那么结果肯定是正确的
如果是两个线程(并发执行),可能会出现在原来线程1三条有序的指令中间插入线程2的指令(如图),会导致执行的结果不可预测
出现这种情况的根本原因:线程的随机调度和抢占式执行
注意:这里的并发执行,是并发+并行,具体那种,看cpu调度
二. 线程不安全的原因
(1)线程间的执行方式
- 抢占式执行和随机调度的机制导致了线程之间的执行顺序不可预测
(2)多个线程修改同一个变量
- 一个线程修改同一个变量,不会造成线程不安全
- 如果只是读取变量内容,不会造成线程不安全(没有修改)
- 如果两个不同的变量,也不会造成线程不安全(没有相互覆盖)
(3)多线程修改变量的操作,本质上不是原子性
- 每个cpu指令都是原子性的,要么不执行,要么执行完
- 如果一个操作是由多个cpu指令组成,线程在执行一半的时候,容易会被调度走,导致其他的指令插入进来
注意:+=,-= 操作也是非原子,= 是原子
(4)内存可见性问题
- 常发生在一个线程读数据,一个线程写数据,由于代码优化的功能,导致数据被修改,执行读数据操作的线程没有发现
(5)指令重排序问题
- 如果一个操作是由多个cpu指令组成,在操作中指令的顺序发生了改变,导致出现bug
知道了造成线程不安全原因,就可以对症下药,我们经常针对原因3做出操作,我们可以使用锁让多个指令打包在一起,成为“整体”
Thread thread = new Thread(()->{
for (int i = 0; i < 5_0000; i++) {
synchronized (object){
count++;
}
}
});
加锁的目的,是为了把三个操作,打包成一个原子的操作。
三. 线程不安全的解决
1. 锁
(1)锁的作用
锁用于 控制多个线程对共享资源的访问,确保同一时间只有一个线程能操作临界区资源
(2)锁的特点
锁互斥 / 锁竞争
- 如果一个线程,针对一个对象上锁后,其他的线程,如果尝试向这个对象上锁,就会发生阻塞
- 如果这个对象被释放(解开锁),才不会被阻塞
- 如果是两个不同的对象上锁,那么就不会发生锁竞争
- 如果一个线程加锁,一个线程不加锁,也不会发生锁竞争
加锁的核心一定是产生锁竞争,没有发生锁互斥,那么加锁没有意义
(3)synchronized关键字
在java中使用synchronized关键字,来表示锁
锁对象:在使用的时候,必须要有一个锁对象,
- 在java中,任何一个对象都可以做为一个锁对象
- 锁竞争不取决于操作的对象是什么,而是取决于是否在操作同一个对象。
- 如果两个线程操作的是同一个对象,那么就会产生竞争。如果不是同一个对象,则不会产生竞争。
synchronized (object){
count++;
}
- 在 synchronized关键字中,进入{ }表示加锁,出了{ }表示解锁
- 加锁的目的,是为了把三个操作,打包成一个原子的操作。 并不是加锁之后,执行三个操作过程中,线程就不调度了,只是保证了其他线程无法“插队”
- 本质上synchronized关键字是通过调用系统的 API来进行加锁的。
可重入性
Thread t = new Thread(()->{
// 可重锁
// 同一个线程内,连续使用两次锁,不会发生死锁
synchronized (object) {
synchronized (object) {
System.out.println("111");
}
}
});
在java中,锁具有可重入性,所有这种情况是正确的(在c++/c,python等中出现这种情况会发生死锁)
一个线程进行加锁的时候,发现锁已经被使用了,那么正常情况下就会进入阻塞状态,但是如果被使用的对象是自己,那么就可以继续加锁
(4)常见的写法
1. 修饰实例方法
public synchronized void method() {
// 同步代码块
}
每次只有一个线程可以执行这个方法。
2. 修饰静态方法
public static synchronized void method() {
// 同步代码块
}
因为静态方法属于类而不是实例,所以锁定的是类的对象,影响的是所有实例。
3. 修饰代码块
static Object o =new Object();
synchronized(o) {
// 同步代码块
}
最常用的写法
(5)死锁
1. 死锁经典案例
1. 一个线程两把锁
在java中,锁具有可重入性,所有出现反复加锁的情况不会发生死锁
2. 两个线程两把锁
类似于房间钥匙在车里,车钥匙在房间里
public class Demo_4 {
static Object A = new Object();
static Object B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (A){
//休眠保证thread1线程可以拿到o2锁
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("thread线程状态(尝试获取B锁):"+Thread.currentThread().getState());
synchronized (B){
System.out.println("拿到了两把锁");
}
}
});
Thread t2 = new Thread(()->{
synchronized (B){
//休眠保证thread线程可以拿到o1锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("thread1线程状态(尝试获取o1锁):"+Thread.currentThread().getState());
synchronized (A){
System.out.println("拿到了两把锁");
}
}
});
t1.start();
t2.start();
}
}
public class Demo_4 {
static Object A = new Object();
static Object B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (A){
//休眠保证thread1线程可以拿到o2锁
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("thread线程状态(尝试获取B锁):"+Thread.currentThread().getState());
synchronized (B){
System.out.println("拿到了两把锁");
}
}
});
Thread t2 = new Thread(()->{
synchronized (B){
//休眠保证thread线程可以拿到o1锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("thread1线程状态(尝试获取o1锁):"+Thread.currentThread().getState());
synchronized (A){
System.out.println("拿到了两把锁");
}
}
});
t1.start();
t2.start();
}
}
两个进程发生死锁现象,t1拿到了A锁,t2拿到了B锁。t1此时想要在拿到B锁的条件是,t2能够释放B锁;t2想要拿到A锁的条件是,t1能够释放A锁,两个线程互不相让,所以产生了死锁。
产生了死锁,两个线程就卡住了,导致后面的代码无法正常执行
如何解决这种死锁?
规定好加锁的顺序就解决这种死锁问题,比如都规定只有先拿到了A锁,才能拿B锁
3. N个线程M把锁
哲学家就餐问题:五个哲学家在一个圆桌上吃饭,桌子上只有5个筷子,怎么安排?
其实在大都数情况下,筷子的数量是够的,可以正常完成就餐情况
但是如果在同一时刻,所有的哲学家同时拿起左边的筷子,那么就会发生死锁,不能正常就餐
解决死锁的方法:
- 增加一个筷子/去掉一个哲学家
- 增加一个计数器,限制最多可以多少人同时就餐
- 引入加锁顺序的规则
- 银行家算法
可以对这五个筷子(锁)进行编号,规定每个哲学家(线程) ,获取筷子的时候,必须先获得编号小的,才能获得编号大的
2. 死锁产生的必要条件
(1)互斥使用
一个线程获得这个锁,那么另外一个线程想获得这个锁,就只能阻塞等待
(2)不可抢占
一个线程获得这个锁,只能主动解锁,别的线程不能强行把锁抢走
(3)占有且等待
一个线程至少占有一个锁,并且等待获取其他锁
(4)循环等待/环路等待
存在一个线程等待环路,环路中的每个线程都在等待下一个线程所占有的资源。
解除死锁
上面的四种产生的必要条件,只要破坏其中的一个,那么就会解除死锁
通常解除循环等待这个必要条件,通过指定一定的规则,就可以避免循环等待
2. volatile关键字
如果一个线程进行读操作,一个线程进行写操作,这时候会出现“ 内存可见性 ”引起的线程安全问题
import java.util.Scanner;
//线程不安全问题:内存可见性
public class Demo_5 {
//这里即使修改flg的值也不会影响thread1线程的执行---flg变量的读写存在可见性问题
volatile static int flg = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (flg == 0) {
}
System.out.println("执行完毕");
});
Thread thread2 = new Thread(() -> {
System.out.println("修改flg的值");
Scanner scanner = new Scanner(System.in);
flg = scanner.nextInt();
scanner.close();
});
thread2.start();
thread1.start();
}
}
我们会发现,即使输入一个非0的数,线程1还是没有结束
因为这里JVM对代码进行了优化,比较flg是否等于0,flg中的值是缓存中寄存器中的值
这里的核心步骤:从内存中读取flg的值 和 拿着寄存器中的值和0比较
从内存中读取flg的值开销比较大,由于内存的执行速度非常快(1秒几亿次),在执行几百次,发现flg的值没有发生任何改变,JVM就会以为flg是一直不变的,那么就会省略从内存中读取的操作,一直在使用之前缓存的值,提高了执行效率(代码优化功能)
所以在多线程中,线程2修改了其中的值,线程1会因为代码优化功能没有看见内存中这个值的变化
解决方案
在java中,提供了关键字volatile可以使JVM提供的代码优化功能强制关闭,每次都必须从内存中读取数据
volatile static int flg = 0;
开销会变大,效率变低,但是数据的准确性提高了
volatile关键字的核心功能:保证内存的可见性和禁止指令重排序
四. 线程饥饿问题
线程饥饿:在并发环境中,某些线程因资源分配或调度机制不合理,长期无法获取所需资源,无法执行其任务。饥饿的线程仍处于活跃状态(如等待队列中),但资源被其他线程持续抢占。
举例说明:
模拟情况:假如一群人排队上厕所,这时候出现一个人,他去上厕所(上锁),发现厕所没有纸,于是他从厕所出来了(释放锁),但是他又跑进厕所(上锁)看现在有没有纸,发现没有又跑出来(解锁),就一直这样反复横跳,导致后面的人不能上,工作人员也不能往里面存放纸。
对于线程来说:有一个线程上锁又解锁,解锁又上锁,一直循环,导致后面线程不能使用这个资源
饥饿的线程处于活跃状态(如等待队列中),但资源被其他线程持续抢占。
解决方案
在java中提供关键字wait( )和notify( ),这两个关键字要搭配一起使用
(1)wait
wait在使用的时候,使用的对象是锁
使用wait方法执行的操作
- 让当前线程释放
- 让线程进入WAITING状态或者TIMED_WAITING(是否带有时间参数)
- 检测是否有其他线程使用notify方法,如果有则被唤醒,重新获取锁
这些操作是原子的,是一气呵成的,不能被其他指令插入
结束等待条件
- 其他线程调用该对象的 notify 方法.
- 如果wait方法带有时间参数,等待时间超时
- 使用 interrupted 方法提前唤醒
public class Demo_6 {
static Object A = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
synchronized (A){
try {
System.out.println("准备进入wait状态,等待notify唤醒");
A.wait();
System.out.println("被唤醒");
} catch (InterruptedException e) {
System.out.println("被interrupt唤醒");
}
}
});
Thread t2 = new Thread(()->{
synchronized (A){
try {
//确保t1进入wait状态
Thread.sleep(1000);
System.out.println("准备唤醒A锁");
A.notify();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
执行过程:
- 在这里t1线程会先执行,拿到锁,打印"准备进入wait状态,等待notify唤醒",执行wait方法(释放锁,进入阻塞状态)
- t2线程执行,拿到锁,先执行sleep方法(保证t1先拿到锁),打印"准备唤醒A锁",然后执行notify操作,唤醒t1线程
- 这时候t1线程会尝试申请锁,但是由于t2线程还没有释放锁,会发生锁互斥(t1进入阻塞状态)
- t2执行完并释放锁,t1会得到锁资源,执行wait后面的内容,打印"被唤醒"
(2)notify
notify在使用的时候,使用的对象是锁,notify方法可以把wait阻塞的线程唤醒,这两个方法经常一起搭配使用
1)notify和wait之间是依靠锁对象联系在一起,如果是两个不同的对象,那么就无法唤醒
//wait和notify不是同一个对象 public class Demo_7 { static Object A = new Object(); static Object B = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (A) { try { System.out.println("准备进入wait状态,等待notify唤醒"); A.wait(); System.out.println("被唤醒"); } catch (InterruptedException e) { System.out.println("被interrupt唤醒"); } } }); Thread t2 = new Thread(() -> { synchronized (A) { try { //确保t1进入wait状态 Thread.sleep(1000); System.out.println("准备唤醒A锁"); B.notify(); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); t2.start(); } }
2)如果有多个wait 但是只有一个notify(前提锁对象都相同),那么就会随机唤醒一个
public class Demo_8 { static Object A = new Object(); public static void main(String[] args) { Thread t1 = new Thread(()->{ synchronized (A){ try { A.wait(); System.out.println("t1 被唤醒"); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); Thread t2 = new Thread(()->{ synchronized (A){ try { A.wait(); System.out.println("t2 被唤醒"); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); Thread t3 = new Thread(()->{ synchronized (A){ try { A.wait(); System.out.println("t3 被唤醒"); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); Thread t4 = new Thread(()->{ synchronized (A){ try { Thread.sleep(1000); A.notify(); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); t1.start(); t2.start(); t3.start(); t4.start(); } }
这里会随机唤醒一个
也可以使用notifyAll唤醒这个对象的所有等待线程
Thread t4 = new Thread(()->{ synchronized (A){ try { Thread.sleep(1000); A.notifyAll(); } catch (InterruptedException e) { throw new RuntimeException(e); } } });
notify方法在使用的时候,即使没有wait,也不会产生副作用
五. 常见的阻塞状态
1) 线程进入了 WAITING 状态(死等),必须要被唤醒
- 如果是wait( ),需要使用 notify()唤醒
- 当线程调用wait( ) 或 join()进入等待,可以调用 interrupt() 触发异常,导致提前唤醒。
- join 的自然结束
2) 线程进入了 BLOCKED 状态,必须持有锁的线程释放锁,操作系统来负责唤醒
3) 线程进入了 TIMED_WAITING 状态(sleep,wait,join),由操作系统会计时,时间到了之后进行唤醒
- 时间到了操作系统进行唤醒
- 如果是wait( ),需要使用 notify()唤醒
- 当线程调用wait( ) 或 join()或 sleep()进入等待,可以使用interrupt()触发异常,导致提前唤醒
- join的自然结束
六. sleep(),wait() 区别
(1) 所属类不同
- sleep(): Thread类的静态方法,可以在任何地方使用
- wait() : Object类的实例方法,必须在锁内使用(由锁对象调用)
(2) 锁的释放行为
- sleep(): 不释放锁
- wait() : 释放锁
(3) 唤醒条件
- sleep(): 时间到了,被操作系统唤醒,或者被异常唤醒
- wait() : 其他线程调用同一对象的notify,时间到了,被操作系统唤醒(有时间参数),也可以被异常唤醒(特殊手段)
(4) 线程状态变化
- sleep(): 线程只能进入TIMED_WAITING状态
- wait() :线程进入WAITING状态或者TIMED_WAITING状态
点赞的宝子今晚自动触发「躺赢锦鲤」buff!