目录
一、线程安全问题
1. 线程不安全的示例
2. 线程安全的概念
3. 线程不安全的原因
二、线程不安全的解决方案
1. synchronized 关键字
1.1 synchronized 的互斥特性
1.2 synchronized 的可重入特性
1.3 死锁的进一步讨论
1.4 死锁的四个必要条件(重点)
2. volatile 关键字
3. wait 和 notify
4. wait 和 sleep 的对比(面试题)
一、线程安全问题
1. 线程不安全的示例
public class Demo {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
//线程不安全示例
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
//等t1和t2都结束再打印count
t1.join();
t2.join();
System.out.println(count);
}
}
上述代码中,对一个count分别在两个线程中循环加加50000次,预期输出结果应该是100000。
但是会发现多次运行后的结果都不会是100000,且每次运行输出的结果都不一致。
2. 线程安全的概念
概念:想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行结果是符合我们预期的,即在单线程环境应该的结果,则说这个线程是线程安全的。
3. 线程不安全的原因
(1)线程在系统中是随机调度的,是抢占式执行的,这是线程安全问题的罪魁祸首。这是系统内核的设计,是无法干预的。
(2)共享资源:多个线程同时修改同一份数据或资源时,如果没有进行适当的同步控制,就容易导致数据的不一致性和错误。前面的代码中,就是两个线程同时修改同一个变量。
(3)原子性:线程针对变量的修改操作,不是原子的。
(4)内存可见性问题
(5)指令重排序
二、线程不安全的解决方案
要想解决线程不安全示例代码的问题,就要从上述原因入手。
原因(1),无法干预。
原因(2),是一个切入点,但是在Java中,这种做法不是很普适,只是针对一些特定场景是可以做到的。因为上面的代码,就是要用多线程修改同一个变量。
原因(3),这是解决线程安全问题,最普适的方案。
比如上述代码中的count++,其实是由三步操作组成的:1.从内存把数据读到 CPU;2.进行数据更新;3.把数据写回到 CPU。因此这一个操作由于线程的抢占式执行,执行指令的相对顺序就会有很多可能:
这是正确的可能性。
在这种情况下,t1读到0并加加为1,这时t2抢到了CPU执行权,也读到0并加加为1,然后存了count=1到CPU,然后t1又抢到了CPU的执行权,还是存了count=1到CPU。最终导致两次++只有一次生效。
实际上,一个线程的 save 在另一个线程的load之前,就是ok的;反之,就都是有问题的。
1. synchronized 关键字
可以通过一些操作,把上述一系列“非原子”的操作,打包成一个“原子”操作,也就是锁。
锁,本质上也是操作系统提供的功能,通过api给应用程序了。Java(JVM)对于这样的系统api又进行了封装。即synchronized关键字。
1.1 synchronized 的互斥特性
- 进⼊ synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
就比如在学校上厕所,有以下几步:
- 关上门并上锁。此时外面还有人想在这个坑上厕所,就得阻塞等待。
- 上厕所。
- 开门并解锁。
Java中随便一个对象,都可以作为加锁的对象。
public class Demo1 {
//两个线程同时修改同一个变量,会有线程安全问题
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 < 10000; i++) {
//synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时,
//其他线程如果也执⾏到同⼀个对象, synchronized 就会阻塞等待.
//进⼊ synchronized 修饰的代码块, 相当于 加锁
//退出 synchronized 修饰的代码块, 相当于 解锁
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
如此,代码就能正确输出100000了。
这里的锁针对的是多个线程竞争同一把锁,如果多个线程使用的锁是不同的,不会产生互斥的。
- 上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统来 "唤醒". 这也就 是操作系统线程调度的⼀部分⼯作。
- 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁, ⽽是和 C 重新竞争, 并不遵守先来后到的规则。
再看一个示例:
class Counter {
private int count = 0;
//2.由于调用当前方法都需要上锁 可使用方法锁(锁对象是this)
public synchronized void add() {
count++;
}
//如果是静态方法,锁对象是类名.class(类对象)
public synchronized static void func() {
}
public int getCount() {
return count;
}
}
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
//1.直接将counter对象当作锁对象使用
/*
synchronized (counter) {
counter.add();
}*/
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
/*
synchronized (counter) {
counter.add();
}*/
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
上述代码中,由于调用add方法都需要上锁,就可以在add方法中加上synchronized关键字,将add()设成一个同步方法(方法锁)。这样代码结果一样是正确输出100000.
- 如果是普通方法,它的锁对象是this,也就是调用这个方法的对象。
- 如果是静态方法,它的锁对象是类对象,也就是类名.class 。
1.2 synchronized 的可重入特性
⼀个线程没有释放锁, 然后⼜尝试再次加锁.//第⼀次加锁, 加锁成功lock();//第⼆次加锁, 锁已经被占⽤, 阻塞等待.lock();按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第⼆个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就⽆法进行解锁操作,这时候就会死锁。
Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
class Counter2 {
private int count;
public void add() {
count++;
}
//锁对象是this
public synchronized void func() {
System.out.println("调用了func这个同步方法(方法锁)");
}
public int getCount() {
return count;
}
}
public class Demo3 {
public static void main(String[] args) {
Counter2 locker = new Counter2();
//嵌套锁示例
Thread t1 = new Thread(() -> {
synchronized (locker) {
locker.func(); //由于该方法是一个普通的同步方法,因此锁对象也是locker
}
});
t1.start();
//可重入锁
//如果某个线程加锁的时候, 发现锁已经被⼈占⽤, 但是恰好占⽤的正是⾃⼰, 那么仍然可以继续获取到锁, 并让计数器⾃增.
//解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
}
}
根据以上解释,该代码并不会造成死锁。
1.3 死锁的进一步讨论
死锁的三种比较典型的场景:
- 锁是不可重入锁,并且一个线程针对一个锁对象,连续加锁两次,通过引入可重入锁,问题就解决了,因此Java并没有这个问题。
- 两个线程两把锁。
- N个线程,M把锁。
先看场景2,两个线程两把锁:
有线程1和线程2,以及有锁A和锁B,现在,线程1和2都需要获取到 锁A 和 锁B。线程1拿到锁A,不释放A,继续获取锁B。即先让两个线程分别拿到一把锁,然后再尝试获取对方的锁。
public class Demo4 {
public static void main(String[] args) {
//循环等待造成的死锁示例
//线程一拥有锁1,线程二拥有锁2,双方在拥有自身锁的同时尝试获取对方的锁,
//最终两个线程就会进入无限等待的状态,这就是死锁。
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker1) {
//确保t2线程拿到另一个锁对象了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker2) {
System.out.println("t1线程正在执行");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
//确保t1线程拿到另一个锁对象了
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (locker1) {
System.out.println("t1线程正在执行");
}
}
});
t1.start();
t2.start();
}
}
在这个示例中,线程1拿到锁1,线程2拿到锁2,此时线程1要等待锁2释放才能继续执行,线程2也要等待锁1释放才能继续执行,就造成死锁了。
场景3,N个线程M把锁:
哲学家就餐问题,也就是在场景2的基础上更复杂了一些。
5个哲学家吃面,只有5只筷子。这个模型大部分情况是可以正常工作的。
如果出现极端情况,比如同一时刻,所有人拿起右手的筷子,并且每个人都不肯让出自己手里的筷子,此时所有人都吃不到面条了。
1.4 死锁的四个必要条件(重点)
- 1. 锁具有互斥特性。(基本特点)
- 2. 锁不可抢占:一个线程拿到锁之后,除非它自己主动释放锁,否则别人抢不走。(基本特点)
- 3. 请求和保持:一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁。(代码结构)
- 4. 循环等待:多个线程获取多个锁的过程中,出现了循环等待。如前面的场景2。(代码结构)
必要条件,意味着只要上述条件缺少一个,就不会构成死锁。
基本特点无法改变,因此我们要从后两点解决死锁问题。
- 请求和保持,就是尽量不要让锁嵌套。
- 循环等待,如果就会有锁的嵌套,例如场景2中,约定必须先获取locker1后获取locker2,这里即使出现锁嵌套,也不会死锁。即它们都先抢锁1,另一个没抢到的就阻塞等待了,等锁1执行完,锁2也更早释放了,自然就不会死锁了。
场景3中,约定每个哲学家必须先获取编号小的筷子,后获取编号大的筷子,一样能够有效避免死锁。
2. volatile 关键字
这里我们解决线程不安全的原因4:内存可见性问题。
先看一段代码:
import java.util.Scanner;
public class Demo5 {
private static int count = 0;
public static void main(String[] args) {
//内存可见性示例 - volatile
Thread t1 = new Thread(() -> {
System.out.println("t线程开始执行");
while (count == 0) {
//
}
System.out.println("t线程结束");
});
Thread t2 = new Thread(() -> {
//控制t2线程执行在t1之后
Scanner scanner = new Scanner(System.in);
count = scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码中,我们的预期效果应该是:
t1首先会进行循环,t2抢到CPU后,用户输入非0整数,就会使t1线程退出循环,结束线程。
而实际上,t1并没有真正出现退出的情况,这个问题产生的原因,就是“内存可见性”。
上述代码站在指令的角度来理解,使一个线程写,一个线程读。
while循环中,首先load从内存读取数据到CPU寄存器;然后cmp比较(同时会产生跳转),如果比较的条件成立,继续顺序执行,不成立,就跳转到另外一个地址执行。
当前循环中的执行速度是很快的,短时间内出现大量的load和cmp反复执行,而load执行消耗的时间会比cmp快很多很多。
由于load的执行速度很慢,且JVM还发现,每次在t2修改之前,load执行的结果其实是一样的,因此,JVM就把上述load操作优化了,只是第一次真正进行load,这就导致后续t2修改count,此时t1也感知不到了。
如果上述代码中,循环体存在IO操作或者阻塞操作(sleep),这就会使循环执行速度大幅度降低,此时就不会优化load,也就不会有上述问题了。
但是JVM到底优不优化,是不能确定的,此时就需要通过一些方式来控制,不让它触发优化。
Java就引入了volatile关键字,给变量修饰上这个关键字之后,编译器就不会按上述优化策略进行优化了,其作用主要有以下两个:
- 保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
- 保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
编译器对于指令重排序的前提是 "保持逻辑不发⽣变化". 这⼀点在单线程环境下比较容易判断, 但是 在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的 执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.重排序是⼀个比较复杂的话题, 涉及到 CPU 以及编译器的⼀些底层工作原理, 此处不做过多讨论.
在上述代码中,变量count加上volatile关键字,问题就解决了。
有一个注意点,volatile关键字保证的是内存可见性,但不能够保证原子性。
因此在目录一的第一个示例中,不使用synchronized而用volatile还是一样会出现同样的问题,原因就是volatile不能保证原子性。
3. wait 和 notify
假设你去一家繁忙的餐厅用餐,餐厅只有几张桌子供应,但人们排队等待就餐。这里的顾客可以比作线程,桌子可以比作共享资源,比如餐具。餐厅经理就是一个控制资源访问的调度程序。
-
等待就餐的顾客:顾客排队等待就餐,他们是等待状态的线程。他们不会一直站在那里,而是会进入一个等待区域,表示线程被挂起。
-
顾客就餐:当有桌子空出来时,经理会通知等待区域的顾客,告诉他们可以进入餐厅就餐了。这个通知过程就相当于唤醒了一个或多个等待状态的线程。
-
桌子资源:餐厅的桌子是共享资源,多个顾客(线程)需要共享这些资源。当一个顾客就餐时,这个桌子就被占用了,其他顾客需要等待它被释放。
-
餐厅经理:餐厅经理就是控制资源访问的调度程序。他会检查哪些桌子空闲,然后通知等待区域的顾客。
在这个场景中,等待区域就相当于等待池,经理的通知就相当于线程的唤醒操作,桌子就相当于共享资源,顾客就相当于线程。通过这个生活场景的类比,可以更好地理解线程的等待通知机制。
等待通知机制,就是通过条件,判定当前逻辑是否能够执行,如果不能执行,就主动wait(主动进行阻塞),把执行的机会让给别的线程,避免该线程进行一些无意义的重试。等到后续条件满足了(其他线程通知了),再让阻塞的线程被唤醒。
- 使当前执行代码的线程进行等待. (把线程放到等待队列中)
- 释放当前的锁
- 满足⼀定条件时被唤醒, 重新尝试获取这个锁.
即wait内部做的事情不仅仅是阻塞等待,还要解锁。要想解锁,就得先加上锁。
因此wait 要搭配 synchronized 来使⽤. 脱离 synchronized 使⽤ wait 会直接抛出异常.
wait 结束等待的条件:
- 其他线程调用该对象的 notify 方法. 如果是不同锁对象就没有联系了.
- wait 等待时间超时 (wait 方法提供⼀个带有 timeout 参数的版本, 来指定等待时间).
- 其他线程调⽤该等待线程的 interrupted ⽅法, 导致 wait 抛出 InterruptedException 异常.
import java.util.Scanner;
public class Demo6 {
public static void main(String[] args) throws InterruptedException {
//等待唤醒机制的示例
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1等待之后");
}
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
synchronized (locker) {
System.out.println("t2唤醒之前");
//通过scanner控制阻塞,用户输入之前,都是阻塞状态
scanner.next();
locker.notify();
System.out.println("t2唤醒之后");
}
});
t1.start();
Thread.sleep(100);
t2.start();
}
}
上述代码中,t1线程启动后先睡眠一会,保证t1先抢占到CPU,执行到wait进入等待状态。然后t2抢到CPU执行权,输入之后,notify就会唤醒上述wait操作,从而使t1回到RUNNABLE状态,并参与调度。
t1唤醒,并不是立即执行的,要先重新获取到锁,由于t2此时还没释放锁,意味着t1会从WALTING->RUNNABLE->BLOCKED。因此唤醒后,会等t2执行完毕,t1才继续执行。因此这段代码中的执行顺序是固定的。
如果这里并没有控制t1先执行,有可能t2会先抢到CPU执行权,从而先执行了notify,此时t1还没wait,locker上也没有其它任何线程wait,此时之间t2的notify就不会有任何效果(也不会抛异常),但是后续t1进入wait之后,就无法唤醒了。
还有其他的注意点:
- 当有多个线程等待时,notify只能唤醒多个等待线程中的一个,且这个唤醒的线程是随机的。
- wait操作和join一样也提供了带超时时间的版本,指定时间内如果没有被notify,就自动唤醒。
如果要用时唤醒所有等待的线程,可以使用notifyAll方法:
public class Demo7 {
public static void main(String[] args) throws InterruptedException {
//多个线程等待,notify()只能随机唤醒一个,,notifyAll()则可以唤醒所有等待线程
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1等待之后");
}
});
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("t2等待之前");
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2等待之后");
}
});
Thread t3 = new Thread(() -> {
synchronized (locker) {
System.out.println("t3唤醒之前");
locker.notifyAll();
System.out.println("t3唤醒之后");
}
});
t1.start();
t2.start();
Thread.sleep(100);
t3.start();
}
}
同样的,t1和t2被唤醒后,也需要t3执行完毕。
4. wait 和 sleep 的对比(面试题)
- 唯一的相同点:都可以让线程放弃执行⼀段时间。
不同点:
- wait是Object类中的一个方法,sleep是Thread类中的一个方法。
- wait必须在synchronized修饰的代码块或方法中使用,sleep方法可以在任何位置使用。
- wait被调用后当前线程进入BLOCK状态并释放锁,并可以通过notify和notifyAll方法进行唤醒;sleep被调用后当前线程进入TIMED_WAIT状态,不涉及锁相关的操作。