Appearance
JUC 线程活跃性问题
本文介绍在多线程编程中会出现的几个问题,以及这些问题的一些解决方案。
1. 多把锁
首先来看一个例子,在一个大房间中,有两个小房间:书房和卧室。现在有一群人想进书房学习,另一群人想进卧室睡觉,如果现在只有一把锁用来锁住大房间,那么注定其他人要在门外等待。所以我们可以设计两把小锁,一把用来锁书房,一把用来锁卧室,这样睡觉和学习可以同时进行互不干扰,提高了并发度。
代码演示如下:
java
public static void main(String[] args) {
BigRoom bigRoom = new BigRoom();
for (int i = 1; i <= 3; i++) {
new Thread(()->{
while (true) {
bigRoom.study();
Sleeper.sleep(1000);
}
},"study" + i).start();
}
for (int i = 1; i <= 4; i++) {
new Thread(()->{
while (true) {
bigRoom.sleep();
Sleeper.sleep(1000);
}
},"sleep" + i).start();
}
}java
@Slf4j
class BigRoom{
// 书房
private Object studyRoom = new Object();
// 卧室
private Object bedroom = new Object();
public void study(){
synchronized (studyRoom){
log.info("进入书房,开始学习");
Sleeper.sleep(new Random().nextInt(3) * 1000);
log.info("结束学习");
}
}
public void sleep(){
synchronized (bedroom){
log.info("进入卧室,开始睡觉");
Sleeper.sleep(new Random().nextInt(5) * 1000);
log.info("睡觉结束");
}
}
}结果如下:
txt
14:37:05.312 [study1] INFO : 进入书房,开始学习
14:37:05.312 [sleep1] INFO : 进入卧室,开始睡觉
14:37:05.314 [study1] INFO : 结束学习
14:37:05.314 [sleep1] INFO : 睡觉结束
14:37:05.314 [study2] INFO : 进入书房,开始学习
14:37:05.314 [study2] INFO : 结束学习
14:37:05.314 [sleep4] INFO : 进入卧室,开始睡觉
14:37:05.314 [study3] INFO : 进入书房,开始学习
14:37:07.315 [study3] INFO : 结束学习
14:37:07.316 [study1] INFO : 进入书房,开始学习
14:37:07.316 [study1] INFO : 结束学习
14:37:07.316 [study2] INFO : 进入书房,开始学习
14:37:07.319 [sleep4] INFO : 睡觉结束
......但是,由于多把锁的存在,会造成死锁或活锁问题。
2. 死锁问题
2.1 概念及演示
死锁指的是:多个线程互相等待对方持有的资源,而导致线程无法继续执行的问题。
例如,线程t1持有对象锁A,想进一步获取对象锁B;而线程t2持有对象锁B,想进一步获取对象锁A。这就会导致线程t1和t2互相等待而无法继续执行。代码演示如下:
java
@Slf4j
public class DeadLockDemo {
public static void main(String[] args) {
Object lockA = new Object();
Object lockB = new Object();
new Thread(()->{
synchronized (lockA){
Sleeper.sleep(1000);
synchronized (lockB){
log.info("t1线程正常执行");
}
}
},"t1").start();
new Thread(()->{
synchronized (lockB){
Sleeper.sleep(1000);
synchronized (lockA){
log.info("t2线程正常执行");
}
}
},"t2").start();
}
}执行上述代码,会发现控制台什么都没有输出。
2.2 哲学家就餐问题
哲学家就餐问题也是经典的死锁问题:
- n 个哲学家围坐在餐桌旁,n 只筷子分别摆放在任意两个哲学家中间,下图中n=5;
- 哲学家每次都先从左边拿筷子,再从右边拿筷子,两个筷子都拿到之后才可以进餐,进完餐之后放下两个筷子;

我们可以用代码演示如下:
java
public static void main(String[] args) {
Chopstick c1 = new Chopstick(1);
Chopstick c2 = new Chopstick(2);
Chopstick c3 = new Chopstick(3);
Chopstick c4 = new Chopstick(4);
Chopstick c5 = new Chopstick(5);
new Philosopher("阿基米德",c1, c5).start();
new Philosopher("赫拉克利特",c5, c4).start();
new Philosopher("亚里士多德",c4, c3).start();
new Philosopher("柏拉图",c3, c2).start();
new Philosopher("苏格拉底",c2, c1).start();
}java
@Slf4j
class Philosopher extends Thread{
private Chopstick left;
private Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right){
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
eat();
}
}
public void eat(){
synchronized (left){
synchronized (right){
log.info("吃饭");
Sleeper.sleep(new Random().nextInt(3) * 1000);
}
}
}
}java
@Getter
@AllArgsConstructor
@ToString
class Chopstick{
private int id;
}运行上述程序,会发现一会儿之后,程序就会卡住不动了,说明发生了死锁。
2.3 死锁定位方法
我们可以使用命令行或图形化的方式来定位死锁问题(下面的演示以DeadLockDemo代码为例):
2.3.1 jps+jstack
首先使用 jps 获取进程id,然后使用jstack 进程id查看


可以看到在jstack命令输出最后会显示死锁情况。
2.3.2 jconsole
我们可以直接使用jconsole图形化界面来检测死锁情况。
首先打开jconsole,然后连接到程序:

连接完成后,找到线程页,点击【检测死锁】:

会显示发生死锁的线程,以及死锁情况:

2.4 解决死锁的方法
2.4.1 统一加锁顺序
在DeadLockDemo示例中,由于两个线程加锁顺序不同,所以导致了两个线程互相持有对方所需的资源,而互相等待暂停执行。如果我们调整加锁的顺序,使得两个线程的加锁顺序一致,那么就能避免死锁问题。例如:
java
@Slf4j
public class DeadLockDemoImproved {
public static void main(String[] args) {
Object lockA = new Object();
Object lockB = new Object();
new Thread(()->{
synchronized (lockA){
Sleeper.sleep(1000);
synchronized (lockB){
log.info("t1线程正常执行");
}
}
},"t1").start();
new Thread(()->{
synchronized (lockA){
Sleeper.sleep(1000);
synchronized (lockB){
log.info("t2线程正常执行");
}
}
},"t2").start();
}
}结果:
txt
15:55:04.159 [t1] INFO : t1线程正常执行
15:55:05.165 [t2] INFO : t2线程正常执行但是,有时候并不能简单改变加锁顺序来解决死锁问题,比如下面的转账程序:
java
class Transaction{
public static void transfer(Account from, Account to, long amount){
synchronized (from){
synchronized (to){
if(from.getBalance() > amount){
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
}
}
}
}
}java
@Data
@AllArgsConstructor
class Account{
private int id;
private String name;
private long balance;
}由于转账的账户不固定,有可能发生如下情况:
- 线程t1执行账户A向账户B转账;
- 线程t2执行账户B向账户A转账;
- 由于线程t1和线程t2对账户加锁的顺序不同,所以会发生死锁;
所以,对于账户的加锁顺序,我们可以根据某个确定的顺序来加锁,例如,根据账户的ID大小来加锁,先对ID小的账户加锁,再对ID大的账户加锁:
java
class Transaction{
public static void transfer(Account from, Account to, long amount){
Account lockFirst = from;
Account lockSecond = to;
if (from.getId() > to.getId()) {
lockFirst = to;
lockSecond = from;
}
synchronized (lockFirst){
synchronized (lockSecond){
if(from.getBalance() > amount){
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
}
}
}
}
}2.4.2 避免线程持有锁并等待锁
如果一个线程请求一个锁时,这个锁已经被其他线程持有,那么这个线程不再阻塞等待这个锁,而是释放掉所持有的所有锁,让其他线程先执行。
需要注意的是:synchronized 无法主动释放锁,因此这种死锁的避免方式只能使用 Lock 来实现。
在下一章的ReentrantLock中会详细说明这种方式。
3. 活锁问题
避免死锁的方式二(避免持有锁并等待锁)是存在问题的,如下图所示,在极端情况下,两个线程循环反复执行以下逻辑:
线程 t1 获取 lock1 锁,线程 t2 获取到 lock2 锁
线程 t1 尝试获取 lock2 锁失败之后,释放掉 lock1 锁,与此同时,线程 t2 尝试获取 lock1 锁失败之后,释放掉 lock2 锁
然后线程 t1 和 t2 再重复执行上述逻辑,这就导致线程 t1 和 t2 一直循环加锁、尝试加锁、释放锁;
把上面这种情况叫做活锁,即多个线程都处于活跃状态,但由于它们的行为互相干扰,导致没有一个线程能够真正向前推进,完成其任务。

活锁与死锁的区别:处于死锁状态的两个线程均处于阻塞状态,不消耗 CPU 资源,相反,处于活锁状态的两个线程,仍然在不停的执行加锁、尝试加锁、释放锁等代码逻辑,消耗 CPU 资源,比起死锁,活锁的性能损耗更大。
发生活锁的概率比发生死锁的概率更低,但一旦发生,就会造成严重后果,CPU做无用功。
解决活锁的方式是让线程在执行的过程中,暂停随机的一小段时间,打破两个线程的持续同步即可。
4. 线程饥饿
死锁和活锁是两个或两个以上线程的整体状态,饥饿则是单个线程的状态,是指某个线程因为无法获取到继续执行所需的资源(比如 CPU 时间片、锁、内存等),而长期阻塞的状态。
举个例子,假如你在排队打饭,但是有源源不断的人来插队,导致你始终吃不上饭,这就是饥饿。
在 Java 中,导致线程饥饿的常见原因有如下:
- 线程优先级设置不当: Java 提供了线程优先级,但其行为在不同操作系统和 JVM 实现上差异很大且不可靠。如果系统中高优先级的线程持续处于可运行状态,低优先级的线程可能很难获得 CPU 时间片,导致饥饿。
- 独占锁的非公平性: 默认情况下,
synchronized关键字和ReentrantLock的非公平模式都是非公平锁。这意味着当多个线程竞争同一个锁时,新来的线程有可能会比已经等待了很长时间的线程更早获得锁。如果一个线程反复地在竞争锁中失败,就可能导致饥饿。 - 资源持有时间过长: 如果一个线程长时间持有某个共享资源或锁,其他需要这个资源的线程就会长时间等待,可能导致饥饿。
- 任务处理机制不公平: 在某些队列或任务处理系统中,如果总是优先处理短任务或某些特定类型的任务,那些长时间运行或优先级较低的任务可能永远得不到执行机会。
避免线程饥饿可以从以下方面思考:
- 谨慎使用线程优先级: 尽量不要过度依赖线程优先级来控制执行顺序,或者仅在非常明确且了解其局限性的情况下使用。
- 使用公平锁: 对于
ReentrantLock,可以使用构造函数new ReentrantLock(true)创建公平锁。公平锁会按照线程请求锁的顺序来分配锁,从而避免饥饿。(注意:公平锁通常性能低于非公平锁)。 - 缩短锁或资源的持有时间: 设计代码时,尽量减少线程持有共享资源或锁的时间。
- 使用带超时的锁或等待机制: 在尝试获取锁或等待条件时,使用带有超时参数的方法(如
lock.tryLock(long timeout, TimeUnit unit)),避免无限期等待。 - 设计公平的任务调度策略: 在构建自定义的线程池或任务队列时,考虑如何公平地分配执行机会。
附录
Sleeper
由于每次调用Thread.sleep()时都需要处理异常,所以可以封装一下简化代码:
java
public class Sleeper {
public static void sleep(long n){
try {
Thread.sleep(n);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}