Java 并发编程文章系列
Java 并发编程实战 01 并发编程的 Bug 源头 https://mp.weixin.qq.com/s/QT44HS47l_ir08pCZeFU5Q
Java 并发编程实战 02Java 如何解决可见性和有序性问题 https://mp.weixin.qq.com/s/Ryud9nizdqWI25CMLL3E_g
Java 并发编程实战 03 互斥锁 解决原子性问题 https://mp.weixin.qq.com/s/B07f7qG7rC98Ge8JSndS2Q
前提
在第三篇文章最后的例子当中, 需要获取到两个账户的锁后进行转账操作, 这种情况有可能会发生死锁, 我把上一章的代码片段放到下面:
- public class Account {
- // 余额
- private Long money;
- public synchronized void transfer(Account target, Long money) {
- synchronized(this) { (1)
- synchronized (target) { (2)
- this.money -= money;
- if (this.money <0) {
- // throw exception
- }
- target.money += money;
- }
- }
- }
- }
若账户 A 转账给账户 B100 元, 账户 B 同时也转账给账户 A100 元, 当账户 A 转帐的线程 A 执行到了代码 (1) 处时, 获取到了账户 A 对象的锁, 同时账户 B 转账的线程 B 也执行到了代码 (1) 处时, 获取到了账户 B 对象的锁. 当线程 A 和线程 B 执行到了代码 (2) 处时, 他们都在互相等待对方释放锁来获取, 可是 synchronized 是阻塞锁, 没有执行完代码块是不会释放锁的, 就这样, 线程 A 和线程 B 死死的对着, 谁也不放过谁. 等到了你去重启应用的那一天... 这个现象就是死锁.
死锁的定义: 一组互相竞争资源的线程因互相等待, 导致 "永久" 阻塞的现象.
如下图:
查找死锁信息
这里我先以一个基本会发生死锁的程序为例, 创建两个线程, 线程 A 获取到锁 A 后, 休眠 1 秒后去获取锁 B; 线程 B 获取到锁 B 后 , 休眠 1 秒后去获取锁 A. 那么这样基本都会发生死锁的现象, 代码如下:
- public class DeadLock extends Thread {
- private String first;
- private String second;
- public DeadLock(String name, String first, String second) {
- super(name); // 线程名
- this.first = first;
- this.second = second;
- }
- public void run() {
- synchronized (first) {
- System.out.println(this.getName() + "获取到锁:" + first);
- try {
- Thread.sleep(1000L); // 线程休眠 1 秒
- synchronized (second) {
- System.out.println(this.getName() + "获取到锁:" + second);
- }
- } catch (InterruptedException e) {
- // Do nothing
- }
- }
- }
- public static void main(String[] args) throws InterruptedException {
- String lockA = "lockA";
- String lockB = "lockB";
- DeadLock threadA = new DeadLock("ThreadA", lockA, lockB);
- DeadLock threadB = new DeadLock("ThreadB", lockB, lockA);
- threadA.start();
- threadB.start();
- threadA.join(); // 等待线程 1 执行完
- threadB.join();
- }
- }
运行程序后将发生死锁, 然后使用 jps 命令(jps.exe 在 jdk/bin 目录下), 命令如下:
- C:\Program Files\Java\jdk1.8.0_221\bin>jps -l
- 24416 sun.tools.jps.Jps
- 24480 org.jetbrains.kotlin.daemon.KotlinCompileDaemon
- 1624
- 20360 org.jetbrains.jps.cmdline.Launcher
- 9256
- 9320 page2.DeadLock
- 18188
可以发现发生死锁的进程 id 9320, 然后使用 jstack(jstack.exe 在 jdk/bin 目录下)命令查看死锁信息.
- C:\Program Files\Java\jdk1.8.0_221\bin>jstack 9320
- "ThreadB" #13 prio=5 os_prio=0 tid=0x000000001e48c800 nid=0x51f8 waiting for monitor entry [0x000000001f38f000]
- java.lang.Thread.State: BLOCKED (on object monitor)
- at page2.DeadLock.run(DeadLock.java:19)
- - waiting to lock <0x000000076b99c198> (a java.lang.String)
- - locked <0x000000076b99c1d0> (a java.lang.String)
- "ThreadA" #12 prio=5 os_prio=0 tid=0x000000001e48c000 nid=0x3358 waiting for monitor entry [0x000000001f28f000]
- java.lang.Thread.State: BLOCKED (on object monitor)
- at page2.DeadLock.run(DeadLock.java:19)
- - waiting to lock <0x000000076b99c1d0> (a java.lang.String)
- - locked <0x000000076b99c198> (a java.lang.String)
这样我们就可以看到发生死锁的信息. 虽然发现了死锁, 但是解决死锁只能是重启应用了.
如何避免死锁的发生
1. 固定的顺序来获得锁
如果所有线程以固定的顺序来获得锁, 那么在程序中就不会出现锁顺序死锁问题.(取自《Java 并发编程实战》一书)
要想验证锁顺序的一致性, 有很多种方式, 如果锁定的对象含有递增的 id 字段(唯一, 不可变, 具有可比性的), 那么就好办多了, 获取锁的顺序以 id 由小到大来排序. 还是用转账的例子来解释, 代码如下:
- public class Account {
- // id (递增)
- private Integer id;
- // 余额
- private Long money;
- public synchronized void transfer(Account target, Long money) {
- Account account1;
- Account account2;
- if (this.id <target.id) {
- account1 = this;
- account2 = target;
- } else {
- account1 = target;
- account2 = this;
- }
- synchronized(account1) {
- synchronized (account2) {
- this.money -= money;
- if (this.money < 0) {
- // throw exception
- }
- target.money += money;
- }
- }
- }
- }
若该对象并没有唯一, 不可变, 具有可比性的的字段 (如: 递增的 id), 那么可以使用 System.identityHashCode() 方法返回的哈希值来进行比较. 比较方式可以和上面的例子一类似. System.identityHashCode() 虽然会出现散列冲突, 但是发生冲突的概率是非常低的. 因此这项技术以最小的代价, 换来了最大的安全性.
提示: 不管你是否重写了对象的 hashCode 方法, System.identityHashCode() 方法都只会返回默认的哈希值.
2. 一次性申请所有资源
只要同时获取到转出账户和转入账户的资源锁. 执行完转账操作后, 也同时释放转入账户和转出账户的资源锁. 那么则不会出现死锁. 但是使用 synchronized 只能同时锁定一个资源锁, 所以需要建立一个锁分配器 LockAllocator. 代码如下:
- /** 锁分配器(单例类) */
- public class LockAllocator {
- private final List<Object> lock = new ArrayList<Object>();
- /** 同时申请锁资源 */
- public synchronized boolean lock(Object object1, Object object2) {
- if (lock.contains(object1) || lock.contains(object2)) {
- return false;
- }
- lock.add(object1);
- lock.add(object2);
- return true;
- }
- /** 同时释放资源锁 */
- public synchronized void unlock(Object object1, Object object2) {
- lock.remove(object1);
- lock.remove(object2);
- }
- }
- public class Account {
- // 余额
- private Long money;
- // 锁分配器
- private LockAllocator lockAllocator;
- public void transfer(Account target, Long money) {
- try {
- // 循环获取锁, 直到获取成功
- while (!lockAllocator.lock(this, target)) {
- }
- synchronized (this){
- synchronized (target){
- this.money -= money;
- if (this.money < 0) {
- // throw exception
- }
- target.money += money;
- }
- }
- } finally {
- // 释放锁
- lockAllocator.unlock(this, target);
- }
- }
- }
使用 while 循环不断的去获取锁, 一直到获取成功, 当然你也可以设置获取失败后休眠 xx 毫秒后获取, 或者其他优化的方式. 释放锁必须使用 try-finally 的方式来释放锁. 避免释放锁失败.
3. 尝试获取锁资源
在 Java 中, Lock 接口定义了一组抽象的加锁操作. 与内置锁 synchronized 不同, 使用内置锁时, 只要没有获取到锁, 就会死等下去, 而显示锁 Lock 提供了一种无条件的, 可轮询的, 定时的以及可中断的锁获取操作, 所有加锁和解锁操作都是显示的(内置锁 synchronized 的加锁和解锁操作都是隐示的), 这篇文章就不展开来讲显示锁 Lock 了(当然感兴趣的朋友可以先百度一下).
总结
在生产环境发生死锁可是一个很严重的问题, 虽说重启应用来解决死锁, 但是毕竟是生产环境, 代价很大, 而且重启应用后还是可能会发生死锁, 所以在编写并发程序时需要非常严谨的避免死锁的发生. 避免死锁的方案应该还有更多, 鄙人不才, 暂知这些方案. 若有其它方案可以留言告知. 非常感谢你的阅读, 谢谢.
参考文章:
《Java 并发编程实战》第 10 章 https://book.douban.com/subject/10484692/
极客时间: Java 并发编程实战 05: 一不小心死锁了, 怎么办? https://time.geekbang.org/column/article/85001
极客时间: Java 核心技术面试精讲 18: 什么情况下 Java 程序会产生死锁? 如何定位, 修复? https://time.geekbang.org/column/article/9266
个人博客网址: https://colablog.cn/
来源: https://www.cnblogs.com/Johnson-lin/p/12874009.html