初学Java并发编程,如何看懂“锁重入”?一篇“慢动作”分解带你入门

初学Java并发编程的你,是否也曾对着下面这段经典代码感到困惑?

这段代码出自《Java并发编程实战》一书,它通过一个子类继承父类并调用父类同步方法的例子,引出了“锁重入”的概念。但很多开发者第一次看到时,都会有一个共同的疑问:重入到底发生在哪里了?我怎么没看见?

别担心,这篇博文将用“慢动作”分解的方式,带你彻底看清这个“看不见”却至关重要的概念。

一、令人困惑的经典代码

首先,让我们回顾一下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 程序清单 2-7
public class Widget {
public synchronized void doSomething() {
// ...
}
}

public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}

这里的 LoggingWidget 继承了 Widget,并重写了 doSomething() 方法。两个类的这个方法都用了 synchronized 关键字。问题是,当一个线程调用 LoggingWidget 实例的 doSomething() 方法时,究竟发生了什么?

二、慢动作前的准备:锁到底锁的是谁?

在进入慢动作分解之前,我们必须先解决一个根本问题:当synchronized用在一个方法上时,它锁住的到底是什么?如果连“锁”本身是谁都不知道,后面的“重入”就无从谈起。

synchronized关键字加锁的对象,根据其修饰的方法类型而不同:

  1. 修饰实例方法(非static方法):锁是调用该方法的对象实例本身
  2. 修饰静态方法(static方法):锁是这个类本身Class 对象。

在我们的例子中,doSomething()是一个实例方法。因此,规则1适用。让我们看调用代码:

1
2
LoggingWidget myWidget = new LoggingWidget();
myWidget.doSomething();

当我们调用 myWidget.doSomething() 时,doSomething()myWidget 这个对象实例的方法。因此,synchronized 关键字要获取的锁,就是 myWidget 这个对象实例的内置锁

那么,调用 super.doSomething() 时呢?

这是最容易产生困惑的地方!虽然 doSomething() 的代码定义在父类 Widget 中,但执行这个方法的主体(或者说上下文)仍然是子类的实例 myWidgetsuper关键字只是告诉JVM去执行父类版本的代码,但执行该代码的“演员”没变,还是myWidget

换句话-说,super.doSomething() 实际上也是在 myWidget 这个对象上执行的一个同步方法。因此,它也需要获取 myWidget 的锁

结论就是:无论是子类重写的 doSomething(),还是通过super调用的父类 doSomething(),当通过 myWidget 实例调用时,它们要竞争和获取的都是同一个锁——myWidget 对象的锁。

理解了这一点,我们才能真正看懂接下来的“重入”过程。现在,我们带着“锁就是myWidget对象”这个清晰的认知,开始慢动作分解。

三、执行流程慢动作分解:“重入”发生点揭秘

为了看清整个过程,我们假设一个线程(叫它线程T)执行了如下代码:

1
2
LoggingWidget myWidget = new LoggingWidget();
myWidget.doSomething(); // 线程T开始执行

现在,让我们把线程T的执行过程放慢一百倍:

第1步:第一次加锁

  1. 线程T 开始执行 myWidget.doSomething()
  2. LoggingWidgetdoSomething() 方法被 synchronized 修饰。这意味着,线程T必须先获得 myWidget 这个对象实例的锁(也叫“内置锁”或“监视器锁”)。
  3. 假设锁是空闲的,线程T 成功获取该锁。
  4. 此时,JVM在内部会这样记录:myWidget对象的锁,持有者是线程T重入计数为1

第2步:进入子类方法体

  1. 线程T 成功拿到锁,进入方法体执行代码。
  2. 它打印出日志信息:System.out.println(...)
  3. 接下来,它遇到了关键的一行:super.doSomething()线程T 准备调用父类 WidgetdoSomething() 方法。

第3步:第二次加锁(重入发生点!)

  1. 线程T 开始执行父类 WidgetdoSomething() 方法。
  2. 注意,父类的 doSomething() 方法同样被 synchronized 修饰了
  3. 这意味着,要执行这个方法,线程T 也必须获得 myWidget 这个对象实例的锁。(正如我们上一节所分析的,虽然是父类的方法,但执行它的对象实例仍是myWidget,因此需要的是同一个锁)。
  4. 线程T 再次尝试获取 myWidget 的锁。JVM进行检查:
    • 这个锁被持有了吗?是的。
    • 持有者是谁?是线程T
    • 现在请求锁的是谁?还是线程T
  5. 因为Java的内置锁是“可重入”的,JVM发现请求锁的线程就是当前持有锁的线程,于是允许这个请求通过。它不会让线程T阻塞等待,而是:
    • 线程T顺利进入父类方法。
    • 并将myWidget锁的重入计数增加到2

这就是“重入”! 它指的是一个线程可以多次获得它已经持有的同一个锁。

第4步:逐层解锁

  1. 线程T 执行完父类 Widget.doSomething() 的方法体(这里是空的)。
  2. 当它退出父类的 synchronized 方法时,JVM将myWidget锁的重入计数减1(计数从2变回1)。锁并未完全释放,因为计数不为0。
  3. 线程T 返回到子类的 LoggingWidget.doSomething() 方法,方法执行完毕。
  4. 当它退出子类的 synchronized 方法时,JVM再次将myWidget锁的重入计数减1(计数从1变为0)。
  5. 此时,重入计数为0,myWidget的锁被完全释放,其他线程终于可以获取它了。

四、思想实验:如果锁不是可重入的,会发生什么?

为了凸显“重入”的重要性,我们想象一下如果Java的synchronized锁不是可重入的,会发生什么可怕的事情:

  1. 线程T 获取myWidget的锁,进入LoggingWidget.doSomething()。(成功,锁被持有)
  2. 线程T 调用super.doSomething()
  3. 为了进入父类方法,线程T需要再次获取myWidget的锁。
  4. 系统检查发现,myWidget的锁已经被持有了
  5. 由于锁“不可重入”,系统会认为有别的线程占着锁(即使是它自己),于是让线程T进入等待状态
  6. 线程T 等待myWidget的锁被释放。但谁能释放这个锁呢?只有持有它的线程T自己!而线程T正卡在super.doSomething()的调用上,无法执行到方法末尾去释放锁。

这就形成了一个完美的死锁(Deadlock):线程在等待一个永远不可能被释放的锁,因为它自己就是那个持有锁并且无法释放的线程。程序将永远挂起。

五、核心要点总结

现在,你应该清楚地“看”到重入的发生过程了。我们来总结一下:

  1. 锁住的是谁? synchronized修饰实例方法时,锁是调用该方法的对象实例。在子类同步方法中调用父类同步方法,锁住的仍然是同一个子类对象实例

  2. 重入在哪里发生? 在一个线程已经持有某个对象的锁时,再次请求获取该对象的锁,就会发生重入。最典型的场景就是例子中的子类同步方法调用父类同步方法

  3. 重入如何实现? JVM为每个锁关联一个“持有线程”和一个“获取计数器”。当线程获取锁时,如果发现自己就是持有者,则简单地将计数器加一。每次退出同步块时,计数器减一。当计数器归零时,锁才被真正释放。

  4. 重入为何重要? 重入避免了在面向对象继承和封装中常见的死锁问题。它允许我们在一个同步方法内部,放心地调用本对象的其他同步方法(或父类的同步方法),而不用担心自己把自己锁死。这个“看不见”的特性,是Java并发安全的重要基石。