JavaEE 线程安全
最近重新开始复习多线程的知识,故此将线程安全这块重点单拎出来做文章记录,方便后续回忆
文章目录
- [JavaEE 线程安全](#JavaEE 线程安全)
-
- [1. 概念](#1. 概念)
- [2. 如何解决线程安全问题](#2. 如何解决线程安全问题)
-
- [2.1 synchronized](#2.1 synchronized)
- [2.2 特性](#2.2 特性)
- [3. 死锁](#3. 死锁)
- [4. 内存可见性引起的线程安全问题](#4. 内存可见性引起的线程安全问题)
- [5. 线程饥饿](#5. 线程饥饿)
1. 概念
什么是线程安全问题?很直观的说,就是一段代码,在单线程的环境下没有问题,但是在多线程的环境下却出现了问题,我们则可称这段代码存在线程安全问题。
我们用代码举例:
我现在有两个线程,我希望通过这两个线程来对一个变量count进行加法运算,每个线程各加50_0000,最后在主线程输出count
代码如下:
java
public class Test1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 1;i <= 500000;i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 1;i <= 500000;i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
正常在单线程环境下,进行上述的代码操作,最后得到的count会等于100_0000,但是在多线程的环境下,上述操作得到的结果却不是我们所意料到的结果:
为什么相差这么多?且每次运行的结果都不一样!
根本原因便是线程之间的调度是无序的 ,随机调度 的,也称抢占式执行
正因如此,在上述代码中,两个线程对count这个对象的修改操作会产生很多种情况:
注:count++这个操作本质上是由三条CPU指令构成的:
- load:把内存中的数据加载到寄存器中;
- add:将寄存器中的数据进行加1运算;
- save:将寄存器中的数据写回到内存中;
合理的情况:
不合理的情况:
上述的不合理情况还有多种,这里举最典型的情况,如上图,可以看到在多线程的环境下,线程的随机调度导致无法保证操作的原子性,导致t1线程值还没修改完,t2线程就对count进行了修改,最后依旧被t1线程修改后的旧值所覆盖,这也是我们上述代码得到结果不准确且不唯一的原因!
对此也得出一个结论:导致线程安全问题的根本原因 是线程的随机调度 ,同时在代码结构上也与多个线程同时对同一个对象进行修改有关;
那么既然问题出现了,我们就要想办法解决它!
2. 如何解决线程安全问题
对于线程安全问题,我们常规的解决方案便是-加锁 🔒,在java中加锁的方式有很多,最常用的便是使用synchronized
关键字
2.1 synchronized
针对上面提出的线程安全问题,我们先通过synchronized给出一个解决方案示例:
java
public class Test1 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object(); // 定义锁对象
Thread t1 = new Thread(() -> {
for (int i = 1;i <= 500000;i++) {
synchronized(locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 1;i <= 500000;i++) {
synchronized(locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
使用synchronized加完锁后,我们的代码就能够正确的输出示例结果了:
synchronized之所以能够用来解决线程安全问题,是因为它让两个线程产生了锁竞争关系,即:
两个线程对同一个锁对象 进行加锁,当对象已经被一个线程加上锁后,另一个线程想要再给这个对象加锁时,就会进入阻塞状态(BLOCKED),直到前一个线程释放锁,才能解除阻塞状态回复就绪状态
注 :在加锁之前我们需要先准备一个锁对象,而在Java中,任意 一个对象都可以为锁对象,
要注意是对同一个锁对象进行加锁才会产生锁冲突,如果是两个线程对不同的锁对象进行加锁,是不会产生锁冲突的,进而线程也不会进入阻塞状态,无法解决线程安全问题
同时,synchronized可以加在方法上,也可以加在类上:
- 加在方法上,锁对象便是调用这个方法的对象,相当于给调用这个方法的对象加锁
- 加在类上,锁对象便是这个类对象,相当于给这个类对象加锁
2.2 特性
synchronized 的加锁效果,可以称为"互斥性" ,而它除了互斥性之外,还有另一个特性,即可重入
举个栗子🌰:
java
public class Test2 {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
synchronized (locker) {
System.out.println("我进来了");
}
}
System.out.println("我出来了");
});
t1.start();
}
}
依据上述我们对加锁的描述,当一个锁对象被一个线程加锁后,再有线程对其加锁,就会产生阻塞,可这里为什么没有呢?
原因就在于,synchronized是一个可重入锁 ,当同一个线程对一个锁对象进行加锁时,并不会产生互斥效果:
当一个锁对象第一次被加锁时,会有一个计数器进行计数 + 1,并记录下当前加锁的线程,当锁对象再次要被加锁时,会先判断当前加锁的线程是否为第一次加锁的线程,若不同则进入阻塞,若相同,则计数器 + 1,且不会进入阻塞;当走出当前代码块时,计数器 - 1,直到最后计数器为 0 时,才会释放锁
也正是因为这种特性,让我们的线程不会进入循环阻塞,从而不会进入死锁状态!
3. 死锁
可以看到,加锁能够帮我们解决线程安全问题,但如果我们加锁的方式不对,则可能会导致死锁问题的出现!
死锁可以理解为线程在对锁对象进行上锁时进入阻塞卡死状态,即这个锁对象已经被其它线程上锁了,但给这个锁对象上锁的线程却一直没有释放锁,这样后面想加锁的线程也会一直处于阻塞状态,无法进行它的工作了
上述我们在讲一个线程一把锁 时说过,因为synchronized是可重入锁,同一线程对一把锁重复加锁不会进入阻塞状态,进而也不会出现死锁的情况
可如果是两个线程两把锁呢?举个栗子🌰
有t1、t2两个线程,锁对象A、B,A对象已经被t1线程加锁,B对象已经被t2线程加锁,此时再让t1对B加锁,t2对A加锁,会发生什么?
java
public class Test3 {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("进来A-B了");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("进来B-A了");
}
}
});
t1.start();
t2.start();
}
}
上述代码的结果是,什么都没有输出,并且程序一直在运行!
原因就是:
- 已经给A加锁的线程t1,想要给已经被线程t2加锁的B加锁,加锁不了,进入阻塞;
- 已经给B加锁的线程t2,想要给已经被线程t1加锁的A加锁,加锁不了,进入阻塞;
两个线程都进入了阻塞,同时又不释放自己的锁,所以这两个线程都进入循环等待状态,这就产生死锁问题了!
针对上述死锁问题,解决方法就是,统一约定好线程对锁对象的加锁顺序,如都先对A加锁,再对B加锁:
java
public class Test3 {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) { // 统一先对A加锁,再对B加锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("进来A-B了");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (A) { // 统一先对A加锁,再对B加锁
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("进来B-A了");
}
}
});
t1.start();
t2.start();
}
}
两个线程两把锁的问题解决了,如果是N个线程M把锁🔒呢?
这里引入一个比较经典的问题:哲学家就餐问题
有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃面,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗面条,每两个哲学家之间有一根筷子,因为用一根筷子很难吃到面条,所以假设哲学家必须用两根筷子吃东西。他们只能使用自己左右手边的那两根筷子。
此时,如果所有的哲学家都想吃面了,都拿起了彼此左边的筷子,那么所有的哲学家都吃不上面了,彼此都在等待右边的人放下一根筷子后才能吃上面,可是都不放,那么也就进入了死锁状态!
这种情况下,最具有普适性的方法就是:制定规则
给每根筷子编号,指定每次拿筷子的顺序都是先拿起序号小的筷子,如:
假设由2号哲学家先拿,它会先拿起序号小的1号筷子,依次类推,3拿2,4拿5,5拿4,最后1会去拿1号筷子,发现这根筷子已经被2号哲学家拿了,所以会进入阻塞状态,这样5号筷子就空出来了,5号哲学家就能拿到一双完整的筷子了,之后等它吃完面放下筷子,前面的哲学家也都能按顺序吃到面了!!
综上所述,对于死锁问题,我们可以通过制定规则指定加锁顺序来解决问题!
4. 内存可见性引起的线程安全问题
前面我们提到过线程安全问题很大情况下是由线程的随机调度引起的问题,但还存在着其它问题,如内存可见性导致的线程安全问题
举个栗子🌰
我们定义一个变量flag,t1线程在flag不等于0的时候会一直循环,通过t2线程输入一个数据来修改flag进而使t1线程退出循环::
java
import java.util.Scanner;
public class Test4 {
private static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
// 这里不写内容,是为了引出内存可见性的问题
}
System.out.println("循环结束");
});
Thread t2 = new Thread(() -> {
System.out.println("请输入flag的值:");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
按照我们的预期,在通过t2线程输入非0参数后t1就会退出循环进而输出循环结束,可结果却是退出循环失败了:
这是为什么呢?
原因:我们在判断是否退出循环时是依据 flag == 0
这条语句进行判断的,而这条语句通过cpu指令分析为以下两句:
- load读取内存flag的值到寄存器中
- 拿着寄存器中的值与0进行判断
在上述过程中,我们通过t2线程来输入数据,可能需要几秒钟的时间,可是在计算机里眼里,t1线程在等待你输入的时候已经经历了无数次的循环,进而从内存中读取值到寄存器中的load指令也被调用了无数次,而我们在循环中又没有编写内容,这样相对大的开销,会让我们的JVM开始判断你这个步骤是否是多余的存在,进而,它帮我们做出了代码优化,将从内存中读取值到寄存器的步骤给省去了,直接从寄存器中获取值进行判断, t2线程修改了内存的数据,而t1线程却不知道内存中的变化,这就称为"内存可见性"问题,而这也是我们在输入值后t1线程却没有退出循环的原因!
那如何解决上述问题?Java给我们提供了一个解决方案,那就是使用volatile
关键字:
volatile关键字能够强制读取内存,可以确保每次循环条件都能从内存中重新读取数据了(强制读取内存虽然开销是大了,但是我们数据的准确性却提高了)
给变量加上volatile后,可以看到t1正确退出了循环:
注:JMM中提到的工作内存其实就相当于cpu中的寄存器和cpu中的缓存,而主存便是内存
5. 线程饥饿
在多线程中,可能存在这样一种情况:
一个线程在拿到锁之后,其它线程想要在给这个锁对象进行加锁,就会进入阻塞状态,等待这个拿到锁的线程解锁,可如果这个线程发现自己要执行的逻辑前提条件不满足,于是就解锁了,解锁后,在其它线程还没反应过来之后又把这个锁对象给加锁了,然后又发现条件未满足,开始解锁,又拿到锁...重复上述过程,其它线程一直拿不到锁,这种现象就称为线程饥饿(也称线程饿死)
对于这种情况,我们需要这个线程主动放弃对锁的竞争,进入阻塞状态,等到能够支持其业务逻辑的条件满足后再给它唤醒
对此,我们可以使用 wait 和 notify 方法来实现上述要求:
- wait:让线程进入阻塞状态(WAITING),并释放锁,通过notify唤醒,需在synchronized中使用(释放锁的前提是有拿到锁);
- notify :用来唤醒进入阻塞状态的线程,这里唤醒的线程与调用notify的必须是同一个锁对象,也需在synchronized中使用;
举个栗子🌰
java
public class Test5 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1进入wait之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1进入wait之后");
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker) {
System.out.println("t2调用notify之前");
locker.notify();
System.out.println("t2调用notify之后");
}
});
t1.start();
t2.start();
}
}
输出结果如下:
整体流程如下:
t1线程在调用wait后,释放锁,进入阻塞状态(WAITING),之后t2拿到锁,调用notify,此时t1线程被唤醒(多个线程被wait情况下,由waiting状态队列随机唤醒一个线程),若此时锁还未被释放,会进行锁竞争,进入阻塞状态(BLOCKING),等到锁被释放后,若能拿到锁,则接着在调用wait方法的位置下继续处理逻辑
注 :Java也提供了一个notifyAll
方法,能够唤醒所有(同个锁对象)进入阻塞状态的线程,但这些线程被唤醒后与notify一样也都需要进行锁竞争