目录
什么是线程安全?
若某个代码,无论是在单个线程下执行,还是在多个线程下执行,都不会产生 bug,则这种情况就称为 线程安全;而如果这个代码在单线程下正确运行,但在多线程下,就可能会产生 bug,则这种情况就称为 线程不安全 或 存在线程安全问题,即若多线程环境下代码运行的结果是符合我们预期的(在单线程环境下应该的结果),则说明是 线程安全 的
我们来看一个典型的例子:
public class ThreadDemo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
观察运行结果:
count = 8614
t1 线程循环 5000 次, t2 线程也循环 5000 次,预期结果应该为 10000,为什么结果是 8614呢?
并且,我们重写运行,再次观察运行结果:
此时结果与第一次不一样,也不正确,这又是为什么呢?
上述代码中,出现了实际运行结果与预期不符合的情况,也就是出现了 bug,上述循环自增的代码,就属于是 存在线程安全问题 的代码
发现了问题之后,我们就来分析出现上述 bug 的原因:
循环体中的 count++,即 count += 1,而这个 count++,其实是由 三个 CPU 指令 构成的
1. load 从内存中读取数据到 CPU 寄存器中
2. add 将寄存器中的值 + 1
3. save 将寄存器中的值写回内存中
若一个线程执行上述三个指令,当然不会出现问题
但如果是两个线程并发执行上述操作, 由于线程之间的调度顺序是不确定的,此时就会存在问题:
若 t1 先执行这三个指令,t2 再执行这三个指令,此时就进行了两次 ++,此时内存中的 count = 2
但是,由于线程的随机调度,可能当 t1 刚执行完 load指令,就被调度走了:
假设此时 count = 0,t1 将此时的值 0 加载到寄存器中,此时线程被调度走,t2 线程开始执行 load 指令,也将此时 count 的值 0 加载到寄存器中, 并进行 add 和 save,此时 count 的值为 1,此时线程被调度走,t1 线程继续执行 add 和 save 操作,由于前面加载的值为 0,因此,add 后值为 1,此时再将 1 写回到内存中
由此,我们就可以看出:
t1 和 t2 各进行了一次 ++ 操作,理论上来说count 应该 + 2,即结果为2,但进行两次 ++ 操作后,由于 t1 线程 和 t2 线程在执行 load 操作时,加载的值都为 0,也就导致两次都将结果修改为1,此时产生的结果就相当于只进行了一次 ++ 操作
除了上述情况,还可能存在很多种情况:
各种情况的出现也就导致了结果的不确定性
线程不安全的原因
从上述分析过程,我们就可以看出,导致线程不安全的原因有:
1. 线程调度是随机的,这也就使一个程序在多线程环境下,执行顺序存在很多的变数,也就需要我们保证 在任意执行顺序下,代码都能正常工作
2. 代码中多个线程修改同共享数据(同一个变量)
若一个线程修改一个变量,此时不会出现问题
若多个线程读取同一个变量,此时由于变量的内容固定不变,此时也不会出现问题
若多个线程修改不同的变量,此时不会产生相互覆盖的情况,也不会出现问题
但若多个线程修改同一个变量,此时可能会产生相互覆盖的情况,就会出现问题
3. count++ 这个修改操作,本身不具有 "原子性"
什么是原子性?
即 这个操作要么都执行完,要么不执行,例如:
在进行转账操作时(A账户向B账户转账):
第一步:A 账户 -1000 元
第二步:B账户 +1000 元
若这两个操作不是原子的,第一步执行成功,但第二步执行失败了,此时 A 账户中的 1000 元就平白无故消失了。此时 让 A-1000 B+1000 作为一组操作,这组操作要么一起成功,要么一起失败,此时这组操作就具有原子性
指令具有原子性,一条指令要么执行完成,要么不执行
但一条 Java 语句不一定是原子的,也不一定只有一条指令
count++ 这个操作,就是由多个 CPU 指令构成,一个线程执行这些指令,执行到一半,可能就被调度走了,这个操作就被打断了,此时就很可能会出现问题
4. 内存可见性问题(后面详细介绍)
5. 指令重排序问题(在文章:单例模式:饿汉模式、懒汉模式-CSDN博客 中进行了详细介绍,在这里就不再介绍了)
在分析了 线程不安全 的原因之后,我们就可以尝试解决线程不安全问题了
我们来针对原因寻找解决方案:
针对 1 ,由于系统内部已经实现了 抢占式 执行,我们修改不了,也就无法解决问题
针对 2,有的时候我们可以修改代码结构,但有的情况下,必须对共享数据进行修改,此时也就调整不了,我们也无法解决问题
针对 3,count++ 由 3 个指令构成的,我们也不能修改,但是,我们可以想办法将这三个指令打包到一起,成为一个 整体,让其要么一起执行,要么都不执行,使 count++ 这个操作具有原子性
那么,该如何实现呢?
通过 加锁 这样的操作,我们就可以实现将这三个指令打包为一个整体
什么是 锁?
我们来看一个例子:
假设有一个共享的储物柜,A 正在使用这个储物柜,若 A 未对其进行加锁,则其他人就可以随时打开储物柜,看到储物柜里面的内容,或是拿走里面的东西;若 A 对其进行加锁,则其他人就不能打开这个储物柜。若 B 也想使用这个储物柜,想对其进行加锁,只能等待 A 解开锁之后,才能尝试对其进行加锁
从上述例子,我们就可以看出,锁 具有 互斥、排他 这样的特性
在多线程中,我们也可以尝试对相关操作进行加锁,例如,我们就可以为上述 count++ 进行加锁,保证一个线程执行完 count++ 中的三个指令后,另一个线程才能尝试加锁,继续执行
那么,在 Java 中,该如何进行加锁呢?
在 Java 中,加锁的方式有多种,其中,最主要使用的方式,是 synchronized 关键字,接下来,我们就来学习 synchronized
synchronized
synchronized 会起到 互斥 的效果,某个线程执行到 某个对象的 synchronized 中时,若其他线程也执行到 同一个对象 synchronized 就会 阻塞等待
进入 synchronized 修饰的代码块,相当于 加锁
退出 synchronized 修饰的代码块,相当于 解锁
接下来,我们就对 count++ 操作进行加锁:
此时,出现了受查异常,这是因为进行加锁的时候,需要先准备好 锁对象,加锁解锁操作都是依托于这个 锁对象 来进行的
若一个线程对一个对象加锁之后,其他线程再尝试对这个对象进行加锁,就会发生锁冲突(锁竞争),产生阻塞(BLOCKED),一直阻塞到前一个线程释放锁为止
若两个不同的线程,针对不同的对象进行加锁,此时也就不会有锁竞争,也不会产生阻塞
还是以上面那个例子进行理解:若 A 和 B 都想使用同一个储物柜(锁对象),此时就会产生 锁竞争,若 A 加上锁,B 也就只能等待 A 解锁后才能尝试加锁;但若 A 和 B 想使用的是两个不同的储物柜,此时就不会产生锁竞争
因此,我们随便创建一个对象,让两个线程都对其进行加锁:
import java.util.Date;
public class ThreadDemo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 随便创建一个锁对象
Object locker = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
我们观察加锁后的运行结果:
当对 count++ 操作使用 synchronized 进行加锁后,synchronized 会调用 系统的 API 进行加锁,而系统的 API 本质上是靠 CPU 上的特定指令完成加锁
需要注意的是,加锁将 count++ 这个操作中的三个指令打包成一个整体,变成 "原子" 的了,但并不是说进行了加锁之后,这三个指令的执行过程中线程就不调度了,线程仍然可能会被调度出 CPU ,但是,加锁的线程被调度走后,其他同样对这个对象进行加锁的线程就不能 "插队执行",即通过 锁竞争,让第二个线程的指令无法插入到第一个线程的指令中间
每次 count++ 操作执行前进行加锁,count++ 操作执行完后进行解锁,保证 count++ 操作执行过程中不会有其他线程的 count++ 指令插入到其中,从而保证 count++ 操作计算出结果的正确性
但是,如果对一个线程加锁,另一个线程不加锁,此时会不会存在线程安全问题?
import java.util.Date;
public class ThreadDemo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 随便创建一个对象
Object locker = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
运行结果:
只 t2 线程进行加锁操作,t1 线程不加锁,此时就不会发生锁竞争
在 t1 线程执行 count++ 指令过程中,被调度走,由于未加锁,t2 线程的 count++ 指令就会穿插在里面执行;而当 t2 线程执行 count++ 指令过程中,被调度走,虽然 t2 线程对 count++ 进行了加锁,但只有 t2 线程对其进行了加锁操作,t1 线程不会与 t2线程竞争同一把锁,此时不会产生锁竞争,也就不会阻塞,t1 线程的 count++ 指令也会穿插在里面执行
由此,我们也可以发现:能否保证线程安全,首先要分析多个线程之间,能否发生 锁竞争(锁冲突)
若两个线程,针对两个不同的对象加锁,是否会存在线程安全问题?
import java.util.Date;
public class ThreadDemo13 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 随便创建一个对象
Object locker = new Object();
Object locker2 = new Thread();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (locker2) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
观察此时的运行结果:
同样的,我们分析这两个线程是否会发生 锁竞争,由于 t1 线程对 locker 对象进行加锁,t2 线程对 locker2 对象进行加锁,针对两个不同的对象进行加锁,此时也就不会有锁竞争,也就不会产生阻塞,因此,也就不能解决线程安全问题
若我们不自定义锁对象,而是将 count 放到一个 Test t 对象中,通过其提供的 add 方法来进行修改,加锁时 锁对象为 this,此时能否保证线程安全呢?
class Test {
private int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
public int getCount() {
return this.count;
}
}
public class ThreadDemo14 {
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
t.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
t.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + t.getCount());
}
}
要判断是否存在线程安全问题,首先要分析清楚 两个线程之间是否存在锁竞争,即 两个线程是否针对同一个对象加锁,也就是说,要弄明白 synchronized() 中的是否是同一个对象
此时,也就能保证线程安全
我们观察此时的运行结果:
符合预期结果,是线程安全的
上述我们 使用 synchronized(this),进行加锁,也可以等价写作:将 synchronized 加到方法上
synchronized public void add() {
count++;
}
如果我们对 Test.class 进行加锁呢?
class Test {
private int count = 0;
public void add() {
synchronized (Test.class) {
count++;
}
}
public int getCount() {
return this.count;
}
}
public class ThreadDemo14 {
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
t.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
t.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + t.getCount());
}
}
同样的,我们来分析 synchronized() 中的是否是同一个对象
Test.class 获取到的是 Test 的类对象,在一个 Java 进程中,一个类的类对象都是只有一个
因此,t1 拿到的类对象,和t2 拿到的类对象,是同一个对象,此时存在锁竞争,也就可以保障线程安全
我们来看此时的运行结果:
若我们 将 synchronized 加到 static 方法上,也就等价于给 类对象 加锁:
synchronized public static void add() {
count++;
}
使用 synchronized 加锁会起到 互斥 的效果,此外,synchronized 还有一些其他的特性,我们来接着学习
可重入
我们来看一个例子:
public class ThreadDemo15 {
public static void main(String[] args) {
Object locker = new Object();
Thread t = new Thread(() -> {
synchronized (locker) {
System.out.println("thread");
}
});
t.start();
}
}
此时成功打印出 thread
但,若我们针对 locker 加两次锁,此时又能否成功打印出 thread?
public class ThreadDemo15 {
public static void main(String[] args) {
Object locker = new Object();
Thread t = new Thread(() -> {
synchronized (locker) {
synchronized (locker) {
System.out.println("thread");
}
}
});
t.start();
}
}
我们观察运行结果:
此时也成功打印出 thread
当我们第一次对 locker 进行加锁后,第二次再对其进行加锁,此时 locker 对象已经处于加锁状态,为什么没有出现阻塞的情况呢?
其中,关键就在于:这两次加锁,是在同一个线程进行的,由于当前是同一个线程,此时的锁对象,就知道了第二次加锁的线程就是持有锁的线程,此时,就直接放行通过,不会出现阻塞,而这个特性,称之为 可重入,也就是说,不会出现自己把自己锁死的情况
如何理解 自己把自己锁死?
一个线程,首先进行第一次加锁,加锁成功,此时没有释放锁,并再次尝试对同一个对象进行加锁,由于锁已经被占用,会产生阻塞,要一直等待 第一次的锁被释放才能获取到锁,但释放第一个锁也需要该线程来完成,但该线程此时正在阻塞状态,不能释放锁,也就无法进行解锁操作,这时候,就出现了 死锁
这也就相当于 家钥匙落家里了,要进家门,需要打开门锁,但钥匙在家里;要想拿到钥匙,需要打开家门, 但此时门锁上了,此时就只能在门口死等
上述这种锁,称之为 不可重入锁
synchronized 是 可重入锁,因此,就不会出现 自己把自己锁死 这样的问题
可重入锁是如何实现的呢?
对于可重入锁,内部会持有两个信息:
1. 当前这个锁是被哪个线程所持有的
2. 加锁次数的计数器
计数器初始值为0,当第一次进行加锁时,会将计数器 + 1,表明这个对象被该线程加锁了,同时记录加锁的线程
第二次加锁时,发现加锁的线程和持有锁的线程是同一个线程,就将计数器+1;若加锁的线程不是持有锁的线程,则当前线程阻塞
当第一次解锁时,将计数器 - 1,并判断计数器的数值是否为0,若不为0,则不会解锁
当第二次解锁时,将计数器 - 1,此时计数器的数值为0,则此时才真正进行解锁
也就是说,synchronized 在最外层 { 进行加锁,最外层 } 进行解锁,此时,无论同一个线程对同一个对象加锁多少次,都能保证在正确的时机解锁
在使用不可重入锁时,可能会出现 死锁 的情况,但是,出现死锁的情况不止这一种,接下来,我们就来学习 死锁
死锁
死锁,是多线程中的一类经典问题,加锁能够帮助我们解决线程安全问题,但当我们加锁方式不当时,就可能会产生 死锁
死锁常常出现在以下三种情景:
1. 一个线程,一把锁
这个锁是不可重入锁,且一个线程对这把锁加锁两次,此时就会出现 死锁
2. 两个线程,两把锁
t1 线程获取到了锁 A
t2 线程获取到了锁 B
此时,t1 尝试获取 锁B,t2 尝试获取 锁A,此时,t1 线程阻塞,t2线程也阻塞,出现了 死锁
这种情况,就相当于 将家门钥匙锁车里,车钥匙锁家里,即进不了家门,也开不了车门
public class ThreadDemo16 {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 并不释放 locker1,尝试获取 locker2
synchronized (locker2) {
System.out.println("t1 线程拿到了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 并不释放 locker2,尝试获取 locker1
synchronized (locker1) {
System.out.println("t2 线程拿到两把锁");
}
}
});
t1.start();
t2.start();
}
}
此时,t1 和 t2 线程都会进入阻塞状态,t1 拿不到两把锁,t2 也拿不到这两把锁
3. N 个线程 M 把锁
假设 有 5 个线程,5把锁,每个线程需要获取到两把锁,由于线程的随机调度,每个线程什么时候进行加锁操作是不确定的
假设某一时刻,每个线程都获取到了一把锁,此时,每个线程都会尝试获取另一把锁,此时,由于所有的线程都未释放第一把锁,也就没有线程获取到第二把锁,此时也就都进入阻塞状态,产生了 死锁
根据上述三种情况,我们来分析产生死锁的条件:
1. 互斥,锁具有互斥性,当一个线程拿到了这把锁,另一个线程想要获取,就需要阻塞等待
2. 不可抢占,当一个线程拿到锁之后,只能等待其主动解锁,其他线程不能将锁强行解锁
3. 请求保持,当一个线程拿到锁 A 后,在不对 A 进行解锁的情况下,尝试获取 锁B
4. 出现了循环(环路)等待的情况
分析了 死锁 产生的条件,我们就可以尝试破坏其中的任一一条,从而避免产生 死锁
针对 1,互斥是锁的基本特性,因此不好破坏
针对 2,不可抢占也是锁的基本特性,也不好破坏
针对 3,这与我们的代码结构有关系,若此时必须要获取到两把锁才能进行后续操作,我们也不好修改
针对 4,出现了循环,也与我们的代码结构有关系,要想避免循环等待,我们可以指定一定的加锁顺序规则,从而有效避免循环等待
例如,对于情况2,可以让 t1 和 t2 线程都先对 locker1 进行加锁,然后再对 locker2 进行加锁:
public class ThreadDemo16 {
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 并不释放 locker1,尝试获取 locker2
synchronized (locker2) {
System.out.println("t1 线程拿到了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 并不释放 locker1,尝试获取 locker2
synchronized (locker2) {
System.out.println("t2 线程拿到两把锁");
}
}
});
t1.start();
t2.start();
}
}
此时就不会出现循环等待
对于情况3,同样的,我们对这5把锁进行编号,约定每个线程在获取锁时,先获取编号小的锁,再获取编号大的锁
学习了 死锁 之后,接下来,我们就来学习由 内存可见性 引起的线程安全问题
内存可见性
我们来看一个例子:
import java.util.Scanner;
public class ThreadDemo17 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行,并输入非 0 的值:
但我们发现,此时 t1 线程并没有真的结束
为什么会出现这种情况呢?
这就与 内存可见性 有关,我们来具体分析:
其中,核心指令有两条:
1. load 将内存中 flag 的值读取到 CPU 寄存器中
2. 将寄存器中的值与 0 进行比较
在上述循环过程中,会反复执行 1 和 2,且执行速度非常快
在等待我们输入的过程中(也就是几秒钟的时间)这两个指令已经循环了很多次了,且 load 操作的开销远远超过 比较 的开销(访问寄存器的操作速度远远超过访问内存的速度),频繁的执行 load 和 比较操作,而且 load 开销大,结果还没有变化(出现变化都是几秒钟之后了,此时这两个指令已经执行了上亿次了)
此时,JVM 就可能会对代码做出优化,将上述的 load 操作给优化掉,即,只有前几次操作会进行 load,发现 load 的结果不会变化,且静态分析代码,flag 的值也没有变化,就将 load 操作优化掉了,即每次都从寄存器中读取之前缓存的值,从而大幅度提高循环的执行速度
这也就是 内存可见性问题,t2 线程修改了内存,但 t1 线程没有看到这个内存的变化
若我们修改上述代码:
import java.util.Scanner;
public class ThreadDemo17 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
再次运行,并输入非0的值:
此时 t1 线程就能够退出循环
这是为什么呢?
这是因为,当我们不加 sleep 时,一秒钟能够进行上百亿次循环,load 操作的整体开销就会非常大,优化的迫切程度就会比较高,加上 sleep 之后(即使是 sleep 1ms,1s 也只是循环 1000 次),load 的整体开销也就没有那么大了,优化的迫切程度就降低了
由此,我们可以看出,内存可见性,其实高度依赖编译器的优化问题,什么时候编译器进行优化,什么时候不进行优化,都不能确定,且,我们稍微改动代码,就会出现不同的结果
那么,我们该如何确保无论代码结构如何调整都不会出现内存可见性问题呢?
我们可以使用 volatile 来强制关闭上述优化,使得每次循环都会重新从内存中读取数据
import java.util.Scanner;
public class ThreadDemo17 {
public static volatile int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag == 0) {
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
此时即使不加 sleep,也不会出现问题