Skip to content

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;
  • 哲学家每次都先从左边拿筷子,再从右边拿筷子,两个筷子都拿到之后才可以进餐,进完餐之后放下两个筷子;

image-20250506145748962

我们可以用代码演示如下:

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查看

image-20250506152910065

image-20250506153029984

可以看到在jstack命令输出最后会显示死锁情况。

2.3.2 jconsole

我们可以直接使用jconsole图形化界面来检测死锁情况。

首先打开jconsole,然后连接到程序:

image-20250506153237482

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

image-20250506153330626

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

image-20250506153410398

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 一直循环加锁、尝试加锁、释放锁;

把上面这种情况叫做活锁,即多个线程都处于活跃状态,但由于它们的行为互相干扰,导致没有一个线程能够真正向前推进,完成其任务。

image

活锁与死锁的区别:处于死锁状态的两个线程均处于阻塞状态,不消耗 CPU 资源,相反,处于活锁状态的两个线程,仍然在不停的执行加锁、尝试加锁、释放锁等代码逻辑,消耗 CPU 资源,比起死锁,活锁的性能损耗更大。

发生活锁的概率比发生死锁的概率更低,但一旦发生,就会造成严重后果,CPU做无用功。

解决活锁的方式是让线程在执行的过程中,暂停随机的一小段时间,打破两个线程的持续同步即可。

4. 线程饥饿

死锁和活锁是两个或两个以上线程的整体状态,饥饿则是单个线程的状态,是指某个线程因为无法获取到继续执行所需的资源(比如 CPU 时间片、锁、内存等),而长期阻塞的状态。

举个例子,假如你在排队打饭,但是有源源不断的人来插队,导致你始终吃不上饭,这就是饥饿。

在 Java 中,导致线程饥饿的常见原因有如下:

  1. 线程优先级设置不当: Java 提供了线程优先级,但其行为在不同操作系统和 JVM 实现上差异很大且不可靠。如果系统中高优先级的线程持续处于可运行状态,低优先级的线程可能很难获得 CPU 时间片,导致饥饿。
  2. 独占锁的非公平性: 默认情况下,synchronized 关键字和 ReentrantLock 的非公平模式都是非公平锁。这意味着当多个线程竞争同一个锁时,新来的线程有可能会比已经等待了很长时间的线程更早获得锁。如果一个线程反复地在竞争锁中失败,就可能导致饥饿。
  3. 资源持有时间过长: 如果一个线程长时间持有某个共享资源或锁,其他需要这个资源的线程就会长时间等待,可能导致饥饿。
  4. 任务处理机制不公平: 在某些队列或任务处理系统中,如果总是优先处理短任务或某些特定类型的任务,那些长时间运行或优先级较低的任务可能永远得不到执行机会。

避免线程饥饿可以从以下方面思考:

  • 谨慎使用线程优先级: 尽量不要过度依赖线程优先级来控制执行顺序,或者仅在非常明确且了解其局限性的情况下使用。
  • 使用公平锁: 对于 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);
        }
    }
}

参考资料

[1] https://www.cnblogs.com/lidong422339/p/17489274.html