{T}

死锁与不可变性

必然死锁的示例

死锁是什么?有什么危害?

什么是死锁

发生在并发中:死锁一定发生在并发场景中。为保线程安全,常给程序使用各种并发安全工具尤其是锁,但若使用不当,就可能导致死锁。

互不相让:死锁是一种状态。当两个(或多个)线程(或进程)相互持有对方所需资源,却又都不主动释放自己持有的资源,导致大家都获取不到所需资源,所有相关线程(或进程)都无法继续执行,在状态改变之前都不能向前推进,就称为死锁状态。通俗地讲,死锁就是两个或多个线程(或进程)被无限期地阻塞,相互等待对方手中资源的状态。

生活中的例子:下图用图示展示生活中的死锁情况:

两个绅士互相鞠躬后谁也不愿先起身,都希望对方先起身,导致无人可以先起身,形成相互等待状态,是典型的死锁。

两个线程的例子:用动画展示两个线程发生死锁的情况:

线程 A 持有锁 A,线程 B 持有锁 B。线程 A 尝试获取锁 B(获取不到,因线程 B 未释放),线程 B 尝试获取锁 A(同样获取不到,因锁 A 被线程 A 持有)。两者相互持有对方想要的资源却不释放,形成相互等待并一直等待下去。

多个线程造成死锁的情况:死锁也存在于多个线程中,若多个线程之间依赖关系呈环形(存在环路的依赖关系),也可能发生死锁:

线程 1 持有锁 A、线程 2 持有锁 B、线程 3 持有锁 C。线程 1 想持有锁 B(获取不到,因锁 B 在线程 2 手中);线程 2 想获取锁 C(获取不到,因锁 C 在线程 3 手中);线程 3 想获取锁 A(获取不到,因锁 A 在线程 1 手中)。线程 1、2、3 相互之间形成环,这就是多线程中发生死锁的情况。

死锁的影响

死锁的影响在不同系统中不一样,大小部分取决于当前系统或环境对死锁的处理能力。

数据库中:数据库系统软件的设计考虑了监测死锁及从死锁中恢复。执行事务时可能需要获取多把锁并一直持有到事务完成,某事务持有的锁可能在其他事务中也需要,因此两个事务之间可能发生死锁。若无外部干涉,两个事务将永远等待。数据库检测到死锁时,根据策略不同可能选择放弃某一个事务,被放弃的事务释放所持有的锁,使其他事务继续进行。程序可重新执行被强行终止的事务,此时它可顺利执行,因为所有竞争资源的事务都已执行完毕并释放资源。

JVM 中:JVM 对死锁的处理能力不如数据库强。若 JVM 中发生死锁,JVM 不会自动处理,一旦死锁发生就会陷入无穷等待。

几率不高但危害大

死锁问题和其它并发安全问题一样是概率性的,即使存在发生死锁的可能性,也不是 100% 会发生。若每个锁的持有时间很短,发生冲突概率就低,死锁概率也低。但在线上系统,每天可能有几千万次"获取锁""释放锁"操作,在巨量次数面前,系统发生问题的几率被放大,只要某几次操作有风险,就可能导致死锁发生。

因死锁"不一定会发生",提前找出死锁成为难题。压力测试可检测一部分可能发生死锁的情况,但不足以完全模拟真实长期运行的场景,无法把所有潜在可能发生死锁的代码都找出来

一旦发生死锁,根据发生死锁线程的职责不同,可能造成子系统崩溃、性能降低甚至整个系统崩溃等后果。死锁往往发生在高并发、高负载情况下,可能直接影响很多用户。

发生死锁的例子

必然会发生死锁的例子:

java
/**
 * 描述:     必定死锁的情况
 */
public class MustDeadLock implements Runnable {

public int flag;
    static Object o1 = new Object();
    static Object o2 = new Object();

public void run() {
        System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("线程2获得了两把锁");
                }
            }
        }
    }

public static void main(String[] argv) {
        MustDeadLock r1 = new MustDeadLock();
        MustDeadLock r2 = new MustDeadLock();
        r1.flag = 1;
        r2.flag = 2;
        Thread t1 = new Thread(r1, "t1");
        Thread t2 = new Thread(r2, "t2");
        t1.start();
        t2.start();
    }
}

代码中有 int 类型的 flag(标记位),并新建 o1、o2 作为 synchronized 的锁对象。

run 方法先打印当前线程名和 flag 的值:

  • flag 等于 1:先获取 o1 锁,休眠 500 毫秒,再尝试获取 o2 锁并打印"线程1获得了两把锁"。
  • flag 等于 2:先获取 o2 锁,休眠 500 毫秒,再获取 o1 锁并打印"线程2获得了两把锁"。

main 方法新建两个本类实例并把 flag 分别设为 1 和 2,新建 t1、t2 两个线程执行,分别执行 r1 和 r2。

程序的一种执行结果:

java
线程t1的flag为1
线程t2的flag为2

程序执行到此时仍在继续执行、并未停止,永远不会打印"线程 1 获得了两把锁"或"线程 2 获得了两把锁",此时发生了死锁。

对发生死锁的过程进行分析

  • 线程 1 运行,发现 flag 为 1,先获得 o1 锁,然后休眠 500 毫秒:

  • 在线程 1 启动并休眠期间,线程 2 也启动。因线程 2 的 flag 为 2,进入 if (flag == 2) 代码块,先获取 o2 锁,然后开始 500 毫秒休眠:

  • 线程 1 的 500 毫秒休眠结束后,尝试获取 o2 锁,此时 o2 被线程 2 持有,线程 1 无法获取:

  • 线程 2 苏醒后,尝试获取 o1 锁,此时 o1 已被线程 1 持有:

现在的状态是:线程 1 卡在获取 o2 锁的位置,线程 2 卡在获取 o1 锁的位置,两者形成相互等待,需要对方持有的资源才能继续执行,从而形成死锁。该例中若线程 2 比线程 1 先启动,情况类似,最终也会形成死锁。这就是一个"必然发生死锁的例子":


死锁的四个必要条件

发生死锁的 4 个必要条件

发生死锁有 4 个缺一不可的必要条件:

  • 第 1 个叫互斥条件:每个资源每次只能被一个线程(或进程,下同)使用。若每个人都能拿到所需资源就不需要等待,不可能发生死锁。
  • 第 2 个是请求与保持条件:当线程因请求资源而阻塞时,需对已获得的资源保持不放。若在请求资源时阻塞后会自动释放手中资源(如锁),别人就能拿到刚释放的资源,不会形成死锁。
  • 第 3 个是不剥夺条件:线程已获得的资源,在未使用完之前不会被强行剥夺。数据库例子中可能强行剥夺某事务持有的资源,就不会发生死锁。要发生死锁必须满足不剥夺条件,即线程获得某资源后别人不能剥夺,才可能形成死锁。
  • 第 4 个是循环等待条件:只有若干线程之间形成头尾相接的循环等待资源关系时才可能形成死锁。两个线程间"循环等待"意味着互相持有对方所需资源、互相等待;三个及以上线程中则需形成环路,例如依次请求下一个线程已持有的资源。

案例解析

以下面必然死锁的例子验证是否满足这 4 个条件:

java
/**
 * 描述:     必定死锁的情况
 */
public class MustDeadLock implements Runnable {

public int flag;
    static Object o1 = new Object();
    static Object o2 = new Object();

public void run() {
        System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("线程2获得了两把锁");
                }
            }
        }
    }

public static void main(String[] argv) {
        MustDeadLock r1 = new MustDeadLock();
        MustDeadLock r2 = new MustDeadLock();
        r1.flag = 1;
        r2.flag = 2;
        Thread t1 = new Thread(r1, "t1");
        Thread t2 = new Thread(r2, "t2");
        t1.start();
        t2.start();
    }
 }

该代码的具体分析和执行结果已在上一节介绍,这里重点分析 4 个必要条件。

第 1 个互斥条件:使用的是 synchronized 互斥锁,锁对象 o1、o2 只能同时被一个线程获得,满足互斥条件。

第 2 个请求与保持条件:满足。线程 1 获得 o1 后想尝试获取 o2 时被阻塞,但它不会自动释放 o1,而是对已获得资源保持不放:

第 3 个不剥夺条件:该代码中 JVM 不会主动把某线程持有的锁剥夺,满足不剥夺条件:

第 4 个循环等待条件:例子中两个线程都想获取对方已持有的资源,即线程 1 持有 o1 等待 o2,线程 2 持有 o2 等待 o1,这是一个环路,形成循环等待:

例子中确实满足这 4 个必要条件。今后可从这 4 个必要条件出发解决死锁问题:只要破坏任意一个条件就可以消除死锁,这也是解决死锁策略中重点考虑的内容。


死锁的定位

定位死锁是解除死锁、恢复运行、优化代码的前置步骤。定位死锁有两种方式:命令行工具 jstack 与代码方式 ThreadMXBean

命令:jstack

jstack 可查看 Java 线程信息。对明显死锁可直接检测;对不明显死锁,可分析线程状态、发现锁的相互依赖关系,同样有助于定位。

以第 67 讲的必然死锁类 MustDeadLock 为例:

java
/**
 * 描述:     必定死锁的情况
 */
public class MustDeadLock implements Runnable {

public int flag;
    static Object o1 = new Object();
    static Object o2 = new Object();

public void run() {
        System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
    }

public static void main(String[] argv) {
        MustDeadLock r1 = new MustDeadLock();
        MustDeadLock r2 = new MustDeadLock();
        r1.flag = 1;
        r2.flag = 2;
        Thread t1 = new Thread(r1, "t1");
        Thread t2 = new Thread(r2, "t2");
        t1.start();
        t2.start();
     }
}

死锁发生后程序不会停止。执行 ${JAVA_HOME}/bin/jps 查看 Java 进程 pid:

java
56402 MustDeadLock
56403 Launcher
56474 Jps
55051 KotlinCompileDaemon

第一行为 MustDeadLock 的 pid 56402。执行 ${JAVA_HOME}/bin/jstack 56402,输出中包含线程获取锁的信息(哪个线程获取哪个锁、锁在哪条语句获取、正在等待/持有的锁)。截取与死锁相关的部分:

java
Found one Java-level deadlock:
=============================
"t2":
  waiting to lock monitor 0x00007fa06c004a18 (object 0x000000076adabaf0, a java.lang.Object),
  which is held by "t1"
"t1":
  waiting to lock monitor 0x00007fa06c007358 (object 0x000000076adabb00, a java.lang.Object),
  which is held by "t2"

Java stack information for the threads listed above:
===================================================
"t2":
	at lesson67.MustDeadLock.run(MustDeadLock.java:31)
	- waiting to lock <0x000000076adabaf0> (a java.lang.Object)
	- locked <0x000000076adabb00> (a java.lang.Object)
	at java.lang.Thread.run(Thread.java:748)
"t1":
	at lesson67.MustDeadLock.run(MustDeadLock.java:19)
	- waiting to lock <0x000000076adabb00> (a java.lang.Object)
	- locked <0x000000076adabaf0> (a java.lang.Object)
	at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock

输出首先打印 Found one Java-level deadlock,随后给出详情:t2 想获取尾号 af0 的锁(被 t1 持有),同时持有尾号 b00 的锁;t1 想获取尾号 b00 的锁(被 t2 持有),同时持有尾号 af0 的锁,形成依赖环路。末尾打印 Found 1 deadlock.。jstack 定位了死锁并指出哪个线程、要获取哪把锁、形成何种环路,据此可修改代码避免死锁。

jstack 通过分析线程持有的锁和需要的锁,判断是否存在循环依赖形成的死锁。

代码:ThreadMXBean

使用 ThreadMXBean 工具类以代码方式定位死锁:

java
public class DetectDeadLock implements Runnable {

public int flag;
    static Object o1 = new Object();
    static Object o2 = new Object();

public void run() {
        System.out.println(Thread.currentThread().getName()+" flag = " + flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("线程1获得了两把锁");
                }
            }
        }
    }

public static void main(String[] argv) throws InterruptedException {
        DetectDeadLock r1 = new DetectDeadLock();
        DetectDeadLock r2 = new DetectDeadLock();
        r1.flag = 1;
        r2.flag = 2;
        Thread t1 = new Thread(r1,"t1");
        Thread t2 = new Thread(r2,"t2");
        t1.start();
        t2.start();
        Thread.sleep(1000);
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
        if (deadlockedThreads != null && deadlockedThreads.length > 0) {
            for (int i = 0; i < deadlockedThreads.length; i++) {
                ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadlockedThreads[i]);
                System.out.println("线程id为"+threadInfo.getThreadId()+",线程名为" + threadInfo.getThreadName()+"的线程已经发生死锁,需要的锁正被线程"+threadInfo.getLockOwnerName()+"持有。");
            }
        }
    }
}

本类基于 MustDeadLock 升级。main 中新增逻辑:Thread.sleep(1000) 确保死锁形成后,调用 ThreadMXBean.findDeadlockedThreads() 获取 deadlockedThreads 数组;当数组非空且长度大于 0 时,逐个打印线程 id、线程名及其所需锁被哪个线程持有:

java
t1 flag = 1
t2 flag = 2
线程 id 为 12,线程名为 t2 的线程已经发生死锁,需要的锁正被线程 t1 持有。
线程 id 为 11,线程名为 t1 的线程已经发生死锁,需要的锁正被线程 t2 持有。

前两行为死锁发生前打印;后两行为检测结果。ThreadMXBean 同样可定位死锁。在业务代码中加入此类检测,可在死锁发生时及时定位并执行报警等处理,增强健壮性。

总结

发生死锁时,既可用 jstack 命令定位,也可在代码中利用 ThreadMXBean 定位。


死锁的解决策略

线上发生死锁应该怎么办

修复死锁的最佳时机在于"防患于未然"。线上发生死锁时,为尽快减小损失,应保存 JVM 信息、日志等"案发现场"数据,然后立刻重启服务。重启后再次立刻发生死锁的几率不大(死锁需满足多个前提条件且并发度足够高才发生),可暂时保证服务可用,再基于保存的信息排查死锁、修改代码、重新发布。

常见修复策略

常见的死锁修复策略有三种:避免策略检测与恢复策略鸵鸟策略

避免策略

避免策略的思路是优化代码逻辑,从根本上消除发生死锁的可能性。发生死锁的一个主要原因是顺序相反地获取不同锁,可通过调整锁的获取顺序避免。

转账时避免死锁(示意性示例,重点在于如何避免死锁而非转账业务逻辑):

(1)发生了死锁

转账系统为保证线程安全,在转账前需获取到转出账户和转入账户两把锁,未获取到两把锁前不能操作余额;若转出余额大于账户余额也不能转账。代码:

java
public class TransferMoney implements Runnable {

int flag;
    static Account a = new Account(500);
    static Account b = new Account(500);

static class Account {

public Account(int balance) {
            this.balance = balance;
        }

int balance;
    }

@Override
    public void run() {
        if (flag == 1) {
            transferMoney(a, b, 200);
        }
        if (flag == 0) {
            transferMoney(b, a, 200);
        }
    }

public static void transferMoney(Account from, Account to, int amount) {
        //先获取两把锁,然后开始转账
        synchronized (to) {
            synchronized (from) {
                if (from.balance - amount < 0) {
                    System.out.println("余额不足,转账失败。");
                    return;
                }
                from.balance -= amount;
                to.balance += amount;
                System.out.println("成功转账" + amount + "元");
            }
        }
    }

public static void main(String[] args) throws InterruptedException {
        TransferMoney r1 = new TransferMoney();
        TransferMoney r2 = new TransferMoney();
        r1.flag = 1;
        r2.flag = 0;
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("a的余额" + a.balance);
        System.out.println("b的余额" + b.balance);
        }
    }
  • flag 为标记位,控制不同线程执行不同逻辑:flag=1 时从 a 转给 b,flag=0 时从 b 转给 a。
  • transferMoney 先获取 synchronized (to)synchronized (from) 两把锁,再判断余额:余额不足则 return,足够则对 from 减余额、to 加余额。
  • 执行结果:
java
成功转账200元
成功转账200元
a的余额500
b的余额500

此时未发生死锁,因为每把锁持有时间短、释放快,低并发下不易死锁。

在两个 synchronized 之间加入 Thread.sleep(500)(模拟网络迟延):

java
public static void transferMoney(Account from, Account to, int amount) {
    //先获取两把锁,然后开始转账
    synchronized (to) {
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (from) {
            if (from.balance - amount < 0) {
                System.out.println("余额不足,转账失败。");
                return;
            }
            from.balance -= amount;
            to.balance += amount;
            System.out.println("成功转账" + amount + "元");
        }
    }
}

运行后很大概率发生死锁:控制台不打印任何语句,程序不停止。死锁主因是两个线程获取两把锁的顺序相反(第一个线程的"转出账户"正是第二个线程的"转入账户")。

(2)实际上不在乎获取锁的顺序

转账并不在乎两把锁的相对获取顺序,只要最终拿到两把锁即可安全操作。可使用 HashCode 的值决定顺序,保证线程安全。修复后的 transferMoney

java
public static void transferMoney(Account from, Account to, int amount) {

int fromHash = System.identityHashCode(from);
    int toHash = System.identityHashCode(to);
    if (fromHash < toHash) {
        synchronized (from) {
            synchronized (to) {
                if (from.balance - amount < 0) {
                    System.out.println("余额不足,转账失败。");
                    return;
                }
                from.balance -= amount;
                to.balance += amount;
                System.out.println("成功转账" + amount + "元");
            }
        }
    } else if (fromHash > toHash) {
        synchronized (to) {
            synchronized (from) {
                if (from.balance - amount < 0) {
                    System.out.println("余额不足,转账失败。");
                    return;
                }
                from.balance -= amount;
                to.balance += amount;
                System.out.println("成功转账" + amount + "元");
            }
        }
    }
}

根据两个 Account 的 identityHashCode 大小决定加锁顺序,使各线程获取锁的顺序一致,不再出现相反顺序,从而避免死锁。

(3)有主键就更安全、方便

HashCode 通用(每个对象都有),但仍有极小概率相同。实际生产中需排序的往往是实体类,而实体类一般有主键 ID(唯一、不重复),直接用主键 ID 大小决定获取锁的顺序,即可确保避免死锁,无需计算 HashCode。

检测与恢复策略

避免策略通过逻辑让死锁不发生;检测与恢复策略先允许系统发生死锁,然后再解除。系统可记录锁调用信息形成"锁的调用链路图",定时用死锁检测算法检测环路,发现死锁后用死锁恢复机制(如剥夺某资源)解开。

方法 1 —— 线程终止

逐个终止陷入死锁的线程,线程终止时释放资源,从而解开死锁。终止顺序的考量指标:

  • (1)优先级:先终止优先级低的线程(如前台线程涉及界面显示,优先级通常高于后台线程)。
  • (2)已占用资源、还需要的资源:若某线程已占有大量资源、只差最后一点资源即可完成任务,系统可能不优先终止它,而是终止别的线程促成其完成。
  • (3)已经运行时间:对已运行很久、很快能完成任务的线程不轻易终止;可终止刚启动的线程并重新启动,成本更低。

具体算法和策略需根据实际业务调整。

方法 2 —— 资源抢占

不终止整个线程,只剥夺其已获得的资源(让线程回退几步、释放资源),后果比终止线程更小、成本更低。缺点:若算法不好,被抢占的线程可能始终是同一个线程,造成线程饥饿(长期被剥夺资源、得不到运行)。

鸵鸟策略

若系统发生死锁的概率不高且后果不严重,可选择先忽略,死锁发生后再人工修复(如重启服务)。如内部系统并发量极低、可能几年不发生死锁,考虑投入产出比无需特殊处理,需根据业务场景合理选择。

总结

线上发生死锁时,应在保存重要数据后优先恢复线上服务。三种修复策略:一是避免策略,改变锁的获取顺序,防止相反顺序获取锁;二是检测与恢复策略,允许死锁发生但发生后有解决方案;三是鸵鸟策略。


经典的哲学家就餐问题

问题描述

哲学家就餐问题也被称为刀叉问题或吃面问题,如图:

有 5 个哲学家,面前各有一双筷子(左手一根、右手一根)。刀叉还是筷子不重要,重要的是必须同时持有左右两边的两个才能吃饭。哲学家的行为可抽象为思考、吃饭,吃饭时必须用一双筷子而不能只用一根。

1. 主流程

哲学家想吃饭时,先尝试拿起左手筷子,再拿起右手筷子;若某根筷子被他人使用则等待,用完放回后即可拿起。这是哲学家就餐的最主要流程。

2. 流程的伪代码

java
while(true) {
    // 思考人生、宇宙、万物...
    think();

// 思考后感到饿了,需要拿筷子开始吃饭
    pick_up_left_chopstick();
    pick_up_right_chopstick();
    eat();
    put_down_right_chopstick();
    put_down_left_chopstick();

// 吃完饭后,继续思考人生、宇宙、万物...
}

while(true) 是无限循环。每个循环中:先思考(时长可随机),饿后准备吃饭;吃饭前先拿左手筷子再拿右手筷子,吃完先放回右手筷子再放回左手筷子,随后进入下一循环。

有死锁和资源耗尽的风险

死锁风险如图(动画):

拿起左手筷子后,下一步去拿右手筷子。多数情况下右手筷子空闲,或需等右边哲学家吃完释放。若每个哲学家同时拿起左手筷子,就形成环形依赖:每个人都拿着左手筷子、缺少右手筷子,无人能吃饭,也无人放下筷子,陷入死锁(相互等待)。代码:

java
public class DiningPhilosophers {

public static class Philosopher implements Runnable {

private Object leftChopstick;
        private Object rightChopstick;

public Philosopher(Object leftChopstick, Object rightChopstick) {
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }

@Override
        public void run() {
            try {
                while (true) {
                    doAction("思考人生、宇宙、万物、灵魂...");
                    synchronized (leftChopstick) {
                        doAction("拿起左边的筷子");
                        synchronized (rightChopstick) {
                            doAction("拿起右边的筷子");
                            doAction("吃饭");
                            doAction("放下右边的筷子");
                        }
                        doAction("放下左边的筷子");
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

private void doAction(String action) throws InterruptedException {
            System.out.println(Thread.currentThread().getName() + " " + action);
            Thread.sleep((long) (Math.random() * 10));
        }
    }

public static void main(String[] args) {
        Philosopher[] philosophers = new Philosopher[5];
        Object[] chopsticks = new Object[philosophers.length];
        for (int i = 0; i < chopsticks.length; i++) {
            chopsticks[i] = new Object();
        }
        for (int i = 0; i < philosophers.length; i++) {
            Object leftChopstick = chopsticks[i];
            Object rightChopstick = chopsticks[(i + 1) % chopsticks.length];
            philosophers[i] = new Philosopher(rightChopstick, leftChopstick);
            new Thread(philosophers[i], "哲学家" + (i + 1) + "号").start();
        }
    }
}
  • 内部类 Philosopher 构造时传入左右手筷子,实现 Runnablerun 中是无限循环,多次调用 doAction(打印当前字符串并随机休眠)。
  • 随机休眠模拟真实场景中思考、吃饭、拿筷时间各不相同。
  • run 流程:思考 → 获取左边筷子锁(打印"拿起左边的筷子")→ 获取右边筷子锁(打印"拿起右边的筷子""吃饭""放下右边的筷子",退出同步块释放锁)→ 打印"放下左边的筷子",退出同步块释放锁。
  • main 新建 5 个哲学家和对应数量筷子(筷子仅作锁对象,定义为普通 Object)。最后一个哲学家右手的筷子经取余操作实际是第一根筷子。

一种可能执行结果:

java
哲学家1号 思考人生、宇宙、万物...
哲学家3号 思考人生、宇宙、万物...
哲学家2号 思考人生、宇宙、万物...
哲学家4号 思考人生、宇宙、万物...
哲学家5号 思考人生、宇宙、万物...
哲学家4号 拿起左边的筷子
哲学家5号 拿起左边的筷子
哲学家1号 拿起左边的筷子
哲学家3号 拿起左边的筷子
哲学家2号 拿起左边的筷子

5 个哲学家思考时间相近,几乎同时拿起左手筷子,陷入死锁状态:无人拿到右手筷子、无人吃饭、无穷等待,即经典的哲学家就餐问题。

多种解决方案

要解决死锁,破坏死锁四个必要条件中的任何一个即可。

1. 服务员检查

引入服务员:哲学家要吃饭时先询问服务员能否拿筷子。服务员判断拿筷子有无死锁可能,有则不允许该哲学家吃饭。

2. 领导调节

基于死锁检测和恢复策略引入领导定期巡视:发现死锁就剥夺某哲学家的筷子让其放下,该哲学家牺牲后其他哲学家都能吃饭。

3. 改变一个哲学家拿筷子的顺序

利用死锁避免策略从逻辑上避免死锁:让 4 个哲学家先拿左边再拿右边,有一名哲学家相反,先拿右边再拿左边,就不会出现循环等待同一边筷子的情况。

死锁解决

"改变一个哲学家拿筷子的顺序"的代码实现:

java
public static void main(String[] args) {
    Philosopher[] philosophers = new Philosopher[5];
    Object[] chopsticks = new Object[philosophers.length];
    for (int i = 0; i < chopsticks.length; i++) {
        chopsticks[i] = new Object();
    }
    for (int i = 0; i < philosophers.length; i++) {
        Object leftChopstick = chopsticks[i];
        Object rightChopstick = chopsticks[(i + 1) % chopsticks.length];
        if (i == philosophers.length - 1) {
            philosophers[i] = new Philosopher(rightChopstick, leftChopstick);
        } else {
            philosophers[i] = new Philosopher(leftChopstick, rightChopstick);
        }
        new Thread(philosophers[i], "哲学家" + (i + 1) + "号").start();
    }
}

主要变化:实例化哲学家对象时,最后一个哲学家(i == philosophers.length - 1)传入的筷子顺序相反,使其先拿起右边筷子再拿起左边筷子。运行结果所有哲学家都能正常思考和就餐,不发生死锁。

总结

哲学家就餐问题蕴含死锁风险,代码演示了发生死锁的情况;解决方案包括死锁检测与恢复、死锁避免,其中死锁避免(改变一个哲学家拿筷子的顺序)给出了代码示例。


final 的三种用法

final 的作用

final 是 Java 关键字,含义为"这是无法改变的"。它共有三种用法,可修饰变量方法,且修饰不同地方时效果、含义和侧重点不同,需分开介绍。

final 修饰变量

作用

final 修饰变量使其一旦被赋值就不能被修改,只能赋值一次。对已赋值的 final 变量再次赋值会报编译错误:

java
/**
 * 描述:     final变量一旦被赋值就不能被修改
 */
public class FinalVarCantChange {

public final int finalVar = 0;

public static void main(String[] args) {
        FinalVarCantChange finalVarCantChange = new FinalVarCantChange();
//        finalVarCantChange.finalVar=9;     //编译错误,不允许修改final的成员变量
    }
}

目的

使用 final 的目的有两点:

  • 设计角度:创建一旦赋值就不能改变的量,如声明常量时通常带 final
java
public static final int YEAR = 2021;

使常量清晰、不易出错。

  • 线程安全角度不可变对象天生线程安全,无需额外同步。若 final 修饰的是基本数据类型,自然具备不可变性质,自动保证线程安全。

赋值时机

被 final 修饰的变量分三类,赋值时机各不相同:

  • 成员变量(类中非 static 修饰的属性);
  • 静态变量(类中 static 修饰的属性);
  • 局部变量(方法中的变量)。

(1)成员变量

成员变量(非 static 属性)被 final 修饰后有三种赋值时机:

  • 声明时在等号右边直接赋值:
java
public class FinalFieldAssignment1 {
    private final int finalVar = 0;
}
  • 在构造函数中赋值:
java
class FinalFieldAssignment2 {
    private final int finalVar;

public FinalFieldAssignment2() {
        finalVar = 0;
    }
}
  • 在类的构造代码块中赋值(不常用):
java
class FinalFieldAssignment3 {
    private final int finalVar;

{
        finalVar = 0;
    }
}

final 成员变量必须从三种情况中任选一种完成赋值,不能完全不赋值(final 语法规定)。

空白 final

声明 final 变量后未在等号右侧立即赋值的情况称为"空白 final",可增加灵活性:在构造函数中根据情况对 final 变量赋不同值,赋值后保持不变。示例:

java
/**
 * 描述:     空白final提供了灵活性
 */
public class BlankFinal {

//空白final
    private final int a;

//不传参则把a赋值为默认值0
    public BlankFinal() {
        this.a = 0;
    }

//传参则把a赋值为传入的参数
    public BlankFinal(int a) {
        this.a = a;
    }
}

两个构造函数分别将 a 赋值为 0 或传入参数,调用不同构造函数得到不同赋值,但赋值后不可更改。

(2)静态变量

静态变量(static 属性)被 final 修饰后只有两种赋值时机:

  • 声明时在等号右边直接赋值:
java
/**
 * 描述:     演示final的static类变量的赋值时机
 */
public class StaticFieldAssignment1 {
    private static final int a = 0;
}
  • 在静态 static 初始代码块中赋值:
java
class StaticFieldAssignment2 {

private static final int a;

static {
        a = 0;
    }
}

注意:不能用普通非静态初始代码块给静态 final 变量赋值;static 的 final 变量不能在构造函数中赋值

(3)局部变量

局部变量(方法中的变量)修饰为 final,含义为一旦赋值就不能改变。它无构造函数、无初始代码块,故不限定具体赋值时机,只要求在使用之前必须赋值(与非 final 局部变量要求一致):

java
/**
 * 描述:     本地变量的赋值时机:使用前赋值即可
 */
public class LocalVarAssignment1 {

public void foo() {
        final int a = 0;//等号右边直接赋值
    }
}

class LocalVarAssignment2 {

public void foo() {
        final int a;//这是允许的,因为a没有被使用
    }
}

class LocalVarAssignment3 {

public void foo() {
        final int a;
        a = 0;//使用前赋值
        System.out.println(a);
    }
}
  • LocalVarAssignment1:等号右边直接赋值。
  • LocalVarAssignment2:未使用该 final 变量,始终不赋值也合法。
  • LocalVarAssignment3:先声明不赋值,使用前赋值后再使用,合法。

特殊用法:final 修饰参数

final 可修饰方法参数,表示无法在方法内部修改该参数

java
/**
 * 描述:     final参数
 */
public class FinalPara {
    public void withFinal(final int a) {
        System.out.println(a);//可以读取final参数的值
//        a = 9; //编译错误,不允许修改final参数的值
    }
}

可读取参数值,但修改(如 a = 9)报编译错误。

final 修饰变量的核心可总结为:一旦被赋值就不能被修改

final 修饰方法

早期 Java 版本将 final 方法转为内嵌调用,消除方法调用开销以提高效率;后期 JVM 自动优化,无需再用 final 做此优化。目前使用 final 修饰方法的唯一原因:将该方法锁定,任何继承类都不能修改其含义,即被 final 修饰的方法不可以被重写(不能被 override)。示例:

java
/**
 * 描述:     final的方法不允许被重写
 */
public class FinalMethod {

public void drink() {
    }

public final void eat() {
    }
}

class SubClass extends FinalMethod {

@Override
    public void drink() {
        //非final方法允许被重写
    }

//    public void eat() {}//编译错误,不允许重写final方法

//    public final SubClass() {} //编译错误,构造方法不允许被final修饰
}

SubClass 重写非 final 的 drink 合法;重写 final 的 eat 报编译错误。同时构造方法不允许被 final 修饰

特例:final 的 private 方法

java
/**
 * 描述:     private方法隐式指定为final
 */
public class PrivateFinalMethod {

private final void privateEat() {
    }
}

class SubClass2 extends PrivateFinalMethod {

private final void privateEat() {//编译通过,但这并不是真正的重写
    }
}

类中所有 private 方法都隐式自动被 final 修饰,额外加 final 无效果。private 方法对子类不可见,谈不上重写:上例中子类和父类的 privateEat 是彼此独立的方法,仅方法名相同。若在子类 privateEat 上加 @Override,会提示 Method does not override method from its superclass,证明这不是一次真正的重写。

final 修饰类

final 修饰类的含义为该类"不可被继承":

java
/**
 * 描述:     测试final class的效果
 */
public final class FinalClassDemo {
    //code
}

//class A extends FinalClassDemo {}//编译错误,无法继承final的类

class A extends FinalClassDemo 报编译错误。给类加 final 代表不允许任何人继承、不可能有子类,一定程度上保证线程安全。经典例子是 String 类被 final 修饰,对保证其不可变性很重要(见第 74 讲)。

注意点:

  • 类加 final 不代表其中成员变量自动加 final,两者不存在相互影响。
  • final 类不会有子类,更不可能发生方法重写,因此 final 类中所有方法(无论权限修饰符)都会自动、隐式地指定为 final

如果必须使用 final 方法或类,请说明原因

使用 final 类或方法应注明原因,因为未来维护者可能不理解(用 final 修饰方法后不能重写,修饰类后不能继承)。说明原因可避免后续维护困惑。

多数情况下不必急于声明 final,可到开发中后期再决定,以更清楚各类的交互方式;可能根本不需要 final,或可重构代码将 final 应用在更小范围的类或方法上,减小影响。

总结

final 用在变量、方法或类上含义截然不同:修饰变量意味着一旦被赋值就不能被修改;修饰方法意味着不能被重写;修饰类意味着不能被继承。

final 修饰变量时,成员变量、静态变量、局部变量的赋值时机各有不同;利用空白 final 可增加灵活性;特例是 final 修饰参数,代表不允许改变参数内容。对方法或类使用 final 最好注明原因、描述设计思想。

下一节将讲解为什么加了 final 却依然无法拥有不变性。


final 与不可变性

什么是不变性

如果对象在被创建之后,其状态就不能修改了,那么它就具备"不变性"(Immutable)。以 Person 类为例:

java
public class Person {

final int id = 1;
    final int age = 18;
}

对象的 idage 都被 final 修饰,创建后不可变:

java
public class Person {

final int id = 1;
    final int age = 18;

public static void main(String[] args) {
        Person person = new Person();
//        person.age=5;//编译错误,无法修改 final 变量的值
    }
}

修改 age 编译不通过,该对象具备不变性。

final 修饰对象时,只是引用不可变

final 修饰指向对象类型(而非 8 种基本数据类型)的变量时,只保证这个变量的引用不可变,对象本身的内容依然可以变化。示例:

java
/**
 * 描述:     final变量一旦被赋值就不能被修改
 */
public class FinalVarCantChange {

private final int finalVar = 0;
    private final Random random = new Random();
    private final int array[] = {1,2,3};

public static void main(String[] args) {
        FinalVarCantChange finalVarCantChange = new FinalVarCantChange();
//        finalVarCantChange.finalVar=9;     //编译错误,不允许修改final的变量(基本类型)
//        finalVarCantChange.random=null;    //编译错误,不允许修改final的变量(对象)
//        finalVarCantChange.array = new int[5];//编译错误,不允许修改final的变量(数组)
    }
}

int 变量、Random 对象、数组被 final 修饰后,试图修改(finalVar=9random=nullarray=new int[5])都无法编译。此规则对基本类型无歧义;对对象类型,final 只保证引用不可变,对象本身仍可变(Java 中数组也是对象)。示例:

java
class Test {
    public static void main(String args[]) {
       final int arr[] = {1, 2, 3, 4, 5};  //  注意,数组 arr 是 final 的
       for (int i = 0; i < arr.length; i++) {
           arr[i] = arr[i]*10;
           System.out.println(arr[i]);
       }
    }
}

输出:

java
10
20
30
40
50

final 数组引用不可变,但数组内容被 for 循环修改(乘以 10)。非数组对象同理:

java
class Test {
    int p = 20;
    public static void main(String args[]){
       final Test t = new Test();
       t.p = 30;
       System.out.println(t.p);
    }
}

输出 30t 被 final 修饰,但成员变量 p 被成功改为 30。

结论:final 修饰指向对象的变量时,对象本身的内容依然可以变化

final 和不可变的关系

  • final 确保变量的引用保持不变;不变性强调对象内容本身(创建后状态不能改变),而非引用。二者不同。
  • 类对象要具备不变性,必须保证创建后所有内部状态(含成员变量的内部属性)永远不变,即所有成员变量的状态都不允许变化。

"把类中所有属性都声明为 final 即可保证不可变"这条规则不完全正确,通常只适用于所有属性都是基本类型的情况。如:

java
public class Person {

final int id = 1;
    final int age = 18;
}

idage 是基本类型且都加 final,Person 对象确实具备不变性。

但若类中有 final 修饰的对象类型成员变量,其内部成员变量仍可变(final 只保证引用不变),一旦对象类型内容变化,整个类就不具备不变性。结论:不变性并不意味着简单用 final 修饰所有类属性,类的对象就具备不变性

包含对象类型成员变量但具备不变性的例子

java
public class ImmutableDemo {

private final Set<String> lessons = new HashSet<>();

public ImmutableDemo() {
        lessons.add("第01讲:为何说只有 1 种实现线程的方法?");
        lessons.add("第02讲:如何正确停止线程?为什么 volatile 标记位的停止方法是错误的?");
        lessons.add("第03讲:线程是如何在 6 种状态之间转换的?");
    }

public boolean isLesson(String name) {
        return lessons.contains(name);
    }
}

lessons 是 final 且 private 的 Set(HashSet),引用不变且外部无法访问;ImmutableDemo 无任何方法修改 lessons 内容,仅在构造函数中添加初始值。ImmutableDemo 对象创建后无任何机会修改 lessons,且它是唯一成员变量且构造完毕后不能变,因此该对象具备不变性。这是"包含对象类型成员变量的类对象具备不可变性"的例子。

总结

不变性指对象创建后状态不可修改。用 final 修饰对象类型变量时,只能保证引用不变,对象内容自身依然可变。final 与不变性的关系:仅把所有成员变量都用 final 修饰并不能代表类的对象就具备不变性。


String 不可变的设计

String 是不可变的

在 Java 中,字符串是一个常量,String 对象创建后其值无法改变(不考虑反射等特殊行为)。给字符串 s 赋值新值:

java
String s = "lagou";
s = "la";

背后实际是新建字符串 "la" 并把 s 引用指向它,原 "lagou" 对象保持不变。调用 subString()replace() 等方法同样只是新建一个字符串,不改变原对象内容:

java
String lagou = "lagou";
lagou = lagou.subString(0, 4);

lagou.subString(0, 4) 建立新字符串 "lago",不影响原 "lagou",内存中同时存在 "lagou""lago" 两个对象

背后原因看 String 类重要源码:

java
public final class String
    implements Java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
	//...
}

关键属性是 private final 的 char 数组 value,存储字符串每一位字符。value 被 final 修饰,引用不可修改;除构造函数外,没有任何其他方法修改 value 数组内容,且 value 是 private,外部无法访问,最终使 value 不可变。

其他类继承 String 重写方法修改 value 的可能性不存在:String 类被 final 修饰,不可被继承,没有任何人可通过扩展或覆盖行为破坏 String 的不变性

String 不可变的好处

将 String 设计为不可变主要有四个好处

字符串常量池

String 不可变使字符串常量池可用。内容相同的两个字符串变量指向同一个对象,无需创建第二个相同内容的新对象:

java
String s1 = "lagou";
String s2 = "lagou";

s1s2 都指向常量池中同一个 "lagou",如图:

结合 String 应用的广泛性,可节省大量内存空间。利用常量池要求 String 不可变,否则会出问题:

java
String s1 = "lagou";
String s2 = "lagou";
s1 = "LAGOU";
System.out.println(s2);

若 String 可变,把 s1 指向的对象改为大写后 s2 会跟着变化,打印大写:

java
LAGOU

这与预期不符,也无法实现常量池复用。实际因 String 不可变,上述程序仍打印小写 "lagou",不同字符串互不影响,符合预期。

用作 HashMap 的 key

String 不可变使其方便用作 HashMap(或 HashSet)的 key。key 最重要的要求是不可变,才能检索存储在 HashMap 里的 value。HashMap 基于 Hash 工作,需对象始终拥有相同 Hash 值。若 String 可变,内容变化则 Hash 码跟着变,用该 key 查找时找不回之前的 value。

缓存 HashCode

String 不可变可缓存 HashCodeString 类中有 hash 属性:

java
/** Cache the hash code for the String */
private int hash;

因 String 不可变,对象创建后 HashCode 不变,可缓存起来。每次需用 HashCode 时不需重新计算,直接返回缓存值,提高效率,使字符串非常适合用作 HashMap 的 key。其他不具备不变性的普通类对象每次都要重算 HashCode,效率较低。

线程安全

String 不可变保证线程安全,因为不变性对象一定是线程安全的,无需额外措施。String 可被多个线程安全共享,对多线程编程很重要,避免了很多不必要的同步操作。

总结

String 是不可变的;其不可变性带来四个好处:可以使用字符串常量池、适合作为 HashMap 的 key、缓存 HashCode、保证线程安全。

版本差异(旧版 → Java 21)

特性旧版(Java 8/11)Java 21
不可变对象手工 final + 防御性拷贝新增 record 声明式不可变
死锁排查jstack + ThreadMXBean不变;虚拟线程下需注意堆栈归属
不可变集合Collections.unmodifiable*不变;List.copyOf(10+)等更方便
虚拟线程死锁虚拟线程阻塞同样会死锁,需用 jstack 分析

虚拟线程下的死锁注意点:虚拟线程在 synchronized 块内阻塞时不会卸载载体线程,若大量虚拟线程同时阻塞在锁上,会造成载体线程耗尽(针垫效应)。优先使用 ReentrantLock 或避免长期持锁。

注: "字符串常量池"参考了王磊老师的《Java 源码剖析 34 讲》的 01 讲的部分内容; "缓存 HashCode"和"多线程安全"思路参考自 Deecyn:https://juejin.cn/post/6844904006909689864