Skip to content

死锁活锁和饥饿

在使用锁的时候,可能会因为使用不当产生死锁、活锁和饥饿的现象。

在单体应用中,加锁是否能解决所有的线程安全问题?

不能,因为加锁使用不当会有死锁、活锁和饥饿等问题。

死锁

什么是死锁?

死锁指的是两个或多个线程之间,互相占用着对方请求的资源,而且不会释放已持有的资源,造成了多线程之间无限等待的现象,就叫做死锁。

死锁发生后,会浪费大量的系统资源,并且在高并发下存在严重的安全隐患,甚至导致整个系统崩溃。

死锁产生的条件

  1. 互斥

    某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。

  2. 请求和保持

    一个进程因请求资源而阻塞时,对已获得的资源保持不放。

  3. 不可抢占

    进程已获得的资源,在末使用完之前,其它进程不能强行剥夺;

  4. 循环等待

    若干进程之间形成一种头尾相接的循环等待资源关系。

当以上四个条件均满足,必然会造成死锁,发生死锁的进程无法进行下去,它们所持有的资源也无法释放。这样会导致CPU的吞吐量下降。所以死锁情况是会浪费系统资源和影响计算机的使用性能的。那么,解决死锁问题就是相当有必要的了。

如何避免死锁

避免死锁就要破坏死锁产生的条件,破坏一个或多个就可以避免死锁。

  1. 互斥 - 不可破坏

    由于互斥条件是要访问的共享资源决定的,是保证多线程安全的前提,所以不能破坏。

  2. 破坏请求和保持条件

    • 一次性分配进程需要的所有资源。(破坏请求条件)
    • 只要进程需要的资源有一个得不到分配,即使进程需要的其它资源有空闲,也不分配给该进程。(破坏保持条件)
  3. 破坏不可抢占

    当某进程获取部分资源后,得不到其它资源时,可以立即或者有限等待释放已占有的资源。

  4. 破坏循环等待

    将资源按序编号,进程申请资源时必须统一按照顺序申请。

手写实现死锁代码

java
@Slf4j
public class DeadLock implements Runnable {

    private ReentrantLock oneLock;
    private ReentrantLock twoLock;

    public DeadLock(ReentrantLock oneLock, ReentrantLock twoLock) {
        this.oneLock = oneLock;
        this.twoLock = twoLock;
    }

    @Override
    public void run() {
      	//第一把锁
        oneLock.lock();
        try {
            log.info(Thread.currentThread().getName() + "======>第一把锁加锁");
            Thread.sleep(100);
            log.info(Thread.currentThread().getName() + "======>开始加第二把锁");
          	//请求第二把锁
            twoLock.lock();
            try {
                log.info(Thread.currentThread().getName() + "======>第二把锁加锁");
                Thread.sleep(100);
            } finally {
                twoLock.unlock();
                log.info(Thread.currentThread().getName() + "======>第二把锁解锁完成!!!");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            oneLock.unlock();
            log.info(Thread.currentThread().getName() + "======>第一把锁解锁完成!!!");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLock aLock=new ReentrantLock();
        ReentrantLock bLock=new ReentrantLock();
      	//开启两个线程
        DeadLock threadA = new DeadLock(aLock,bLock);
        DeadLock threadB = new DeadLock(bLock,aLock);
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.submit(threadA);
        executorService.submit(threadB);
        Thread.sleep(20000);
    }

}

//output
pool-1-thread-2======>第一把锁加锁
pool-1-thread-1======>第一把锁加锁
pool-1-thread-1======>开始加第二把锁
pool-1-thread-2======>开始加第二把锁

使用 synchronized 测试死锁

java
public class DeadLockTest {

    private static ReentrantLock lockA = new ReentrantLock(false);

    private static ReentrantLock lockB = new ReentrantLock(false);

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("第1个线程");
            update();
        }).start();
        new Thread(() -> {
            System.out.println("第2个线程");
            add();
        }).start();

    }

    @SneakyThrows
    public static void add() {
        synchronized (lockA) {
            Thread.sleep(500);
            System.out.println("add");
            update();
            System.out.println(Thread.currentThread());
        }
    }

    @SneakyThrows
    public static void update() {
        synchronized (lockB) {
            Thread.sleep(500);
            System.out.println("update");
            System.out.println(Thread.currentThread());
            add();
        }
    }

}
  • 保证获取锁顺序一致
  • 使用带过期时间的锁

如何排查死锁

  1. 使用 jps 命令查看运行中的 java 进程 Id。

    image-20250528173028477

  2. 使用 jstack 分析线程状态。

    jstack 进程Id
    • 线程状态

      通过分析进程可以得到,DeadLockTest 进程的两个线程分别为 pool-1-thread-2 (简称2)和 pool-1-thread-1(简称1)。

      通过打印的线程信息可以发现,线程 2 和 1 的线程状态都是 WAITING,其中线程 2 在等待锁 <0x000000076b4b2640> , 线程 1 在等待锁 <0x000000076b4b2670>。而线程 1 和线程 2 本质上等待的都是对方已经持有的锁,进而引发了死锁问题。

      image-20250528172914682.png

    • 死锁现象

      同时 jstack 命令分析出了进程中存在的死锁问题,并分析出了死锁的原因。

      image-20250528172924885

活锁

活锁产生的条件

出现活锁的前提是采用加锁阻塞便释放已占有资源的方式,这也是避免死锁的一种方式。

在破坏死锁产生的条件不可抢占时。当某进程获取部分资源后,得不到其它资源时,可以立即或者有限等待释放已占有的资源。

当两个线程互相持有了对方需要的资源,请求对方资源阻塞后,会释放已占有的资源。然后重新申请资源,再次请求对方的资源,继续阻塞释放已占有的资源。可能会发生多次循环,进而导致资源的浪费,这就是活锁的现象。


比如有两个线程 1 和 2,分别需要锁 A 和 锁 B。

  • 线程 1 的加锁顺序为:A -》B;
  • 线程 2 的加锁顺序为:B -》A;

线程 1 和 2 按照加锁顺序进行,很可能发生下列情况:

  • 线程 1

    加锁A -> 加锁B -> 阻塞(线程2占有B锁)-> 释放锁A ->(循环) 加锁A -> 加锁B -> 阻塞(线程2占有B锁)-> 释放锁A

  • 线程 2

    加锁B -> 加锁A -> 阻塞(线程1占有A锁)-> 释放锁B ->(循环) 加锁B -> 加锁A -> 阻塞(线程1占有A锁)-> 释放锁B

根据例子可以发现活锁的现象就像两个人从独木桥两端过桥一样,当人到大桥中间时发现对向来人,主动谦让退回桥头,如此往复,谁也过不了桥。

如何避免活锁

在因为获取资源阻塞后,释放已占有资源时采用随机时间释放,增加谦让已占有资源的随机性。

手写实现活锁代码

java
@Slf4j
public class LivelockTest implements Runnable {

    private ReentrantLock oneLock;
    private ReentrantLock twoLock;

    public LivelockTest(ReentrantLock oneLock, ReentrantLock twoLock) {
        this.oneLock = oneLock;
        this.twoLock = twoLock;
    }

    @SneakyThrows
    @Override
    public void run() {
        while (true) {
            oneLock.lock();
            try {
                log.info(Thread.currentThread().getName() + "======> lock -----"+oneLock.hashCode());
                Thread.sleep(100);
                //尝试加第二把锁(加不上直接释放第一把锁)
                if (twoLock.tryLock()) {
                    try {
                        log.info(Thread.currentThread().getName() + "======> lock -----"+twoLock.hashCode());
                    } finally {
                        twoLock.unlock();
                        log.info(Thread.currentThread().getName() + "======> unlock!!!-----"+twoLock.hashCode());
                    }
                    return;
                }
            } finally {
                oneLock.unlock();
                log.info(Thread.currentThread().getName() + "======> unlock!!!-----"+oneLock.hashCode());
            }
        }

    }

    public static void main(String[] args) throws InterruptedException {
        ReentrantLock aLock = new ReentrantLock();
        ReentrantLock bLock = new ReentrantLock();
        LivelockTest threadA = new LivelockTest(aLock, bLock);
        LivelockTest threadB = new LivelockTest(bLock, aLock);
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.submit(threadA);
        executorService.submit(threadB);
        Thread.sleep(20000);
    }

}

pool-1-thread-1======> lock -----374251926
pool-1-thread-2======> lock -----1532567941
pool-1-thread-1======> unlock!!!-----374251926
pool-1-thread-1======> lock -----374251926
pool-1-thread-2======> unlock!!!-----1532567941
pool-1-thread-2======> lock -----1532567941
pool-1-thread-1======> unlock!!!-----374251926
pool-1-thread-2======> lock -----374251926
pool-1-thread-2======> unlock!!!-----374251926
pool-1-thread-2======> unlock!!!-----1532567941
pool-1-thread-1======> lock -----374251926
pool-1-thread-1======> lock -----1532567941
pool-1-thread-1======> unlock!!!-----1532567941
pool-1-thread-1======> unlock!!!-----374251926

饥饿

饥饿现象发生在多个线程竞争共享资源的情况下,若某个线程很久得不到共享资源无法执行下去,这时就是发生了饥饿。

如何避免饥饿

  1. 公平分配资源

    线程有优先级的属性,优先级越高,越容易获取资源。而锁有非公平锁和公平锁之分,在使用非公平锁时,不是按照申请锁的顺序来占有锁,就容易造成一些优先级低的线程发生饥饿现象。而公平锁是按照申请锁的顺序来占有锁,能够保证线程按照申请顺序来占有锁,进而避免了饥饿现象的发生。

  2. 减少持有锁的时间

  3. 保证资源充足

生产者消费者-饥饿问题

在生产者消费者模式的读者写者问题中,若读者优先,即读进程读取临界区资源时,写进程不允许操作临界区。若读进程过多,就会造成写进程一直等待,进而发生饥饿问题。