初学Java并发编程,如何看懂“锁重入”?一篇“慢动作”分解带你入门
初学Java并发编程,如何看懂“锁重入”?一篇“慢动作”分解带你入门
初学Java并发编程的你,是否也曾对着下面这段经典代码感到困惑?
这段代码出自《Java并发编程实战》一书,它通过一个子类继承父类并调用父类同步方法的例子,引出了“锁重入”的概念。但很多开发者第一次看到时,都会有一个共同的疑问:重入到底发生在哪里了?我怎么没看见?
别担心,这篇博文将用“慢动作”分解的方式,带你彻底看清这个“看不见”却至关重要的概念。
一、令人困惑的经典代码
首先,让我们回顾一下这段代码:
1 | // 程序清单 2-7 |
这里的 LoggingWidget 继承了 Widget,并重写了 doSomething() 方法。两个类的这个方法都用了 synchronized 关键字。问题是,当一个线程调用 LoggingWidget 实例的 doSomething() 方法时,究竟发生了什么?
二、慢动作前的准备:锁到底锁的是谁?
在进入慢动作分解之前,我们必须先解决一个根本问题:当synchronized用在一个方法上时,它锁住的到底是什么?如果连“锁”本身是谁都不知道,后面的“重入”就无从谈起。
synchronized关键字加锁的对象,根据其修饰的方法类型而不同:
- 修饰实例方法(非
static方法):锁是调用该方法的对象实例本身。 - 修饰静态方法(
static方法):锁是这个类本身的Class对象。
在我们的例子中,doSomething()是一个实例方法。因此,规则1适用。让我们看调用代码:
1 | LoggingWidget myWidget = new LoggingWidget(); |
当我们调用 myWidget.doSomething() 时,doSomething() 是 myWidget 这个对象实例的方法。因此,synchronized 关键字要获取的锁,就是 myWidget 这个对象实例的内置锁。
那么,调用 super.doSomething() 时呢?
这是最容易产生困惑的地方!虽然 doSomething() 的代码定义在父类 Widget 中,但执行这个方法的主体(或者说上下文)仍然是子类的实例 myWidget。super关键字只是告诉JVM去执行父类版本的代码,但执行该代码的“演员”没变,还是myWidget。
换句话-说,super.doSomething() 实际上也是在 myWidget 这个对象上执行的一个同步方法。因此,它也需要获取 myWidget 的锁。
结论就是:无论是子类重写的 doSomething(),还是通过super调用的父类 doSomething(),当通过 myWidget 实例调用时,它们要竞争和获取的都是同一个锁——myWidget 对象的锁。
理解了这一点,我们才能真正看懂接下来的“重入”过程。现在,我们带着“锁就是myWidget对象”这个清晰的认知,开始慢动作分解。
三、执行流程慢动作分解:“重入”发生点揭秘
为了看清整个过程,我们假设一个线程(叫它线程T)执行了如下代码:
1 | LoggingWidget myWidget = new LoggingWidget(); |
现在,让我们把线程T的执行过程放慢一百倍:
第1步:第一次加锁
线程T开始执行myWidget.doSomething()。LoggingWidget的doSomething()方法被synchronized修饰。这意味着,线程T必须先获得myWidget这个对象实例的锁(也叫“内置锁”或“监视器锁”)。- 假设锁是空闲的,
线程T成功获取该锁。 - 此时,JVM在内部会这样记录:
myWidget对象的锁,持有者是线程T,重入计数为1。
第2步:进入子类方法体
线程T成功拿到锁,进入方法体执行代码。- 它打印出日志信息:
System.out.println(...)。 - 接下来,它遇到了关键的一行:
super.doSomething()。线程T准备调用父类Widget的doSomething()方法。
第3步:第二次加锁(重入发生点!)
线程T开始执行父类Widget的doSomething()方法。- 注意,父类的
doSomething()方法同样被synchronized修饰了。 - 这意味着,要执行这个方法,
线程T也必须获得myWidget这个对象实例的锁。(正如我们上一节所分析的,虽然是父类的方法,但执行它的对象实例仍是myWidget,因此需要的是同一个锁)。 线程T再次尝试获取myWidget的锁。JVM进行检查:- 这个锁被持有了吗?是的。
- 持有者是谁?是
线程T。 - 现在请求锁的是谁?还是
线程T。
- 因为Java的内置锁是“可重入”的,JVM发现请求锁的线程就是当前持有锁的线程,于是允许这个请求通过。它不会让
线程T阻塞等待,而是:- 让
线程T顺利进入父类方法。 - 并将
myWidget锁的重入计数增加到2。
- 让
这就是“重入”! 它指的是一个线程可以多次获得它已经持有的同一个锁。
第4步:逐层解锁
线程T执行完父类Widget.doSomething()的方法体(这里是空的)。- 当它退出父类的
synchronized方法时,JVM将myWidget锁的重入计数减1(计数从2变回1)。锁并未完全释放,因为计数不为0。 线程T返回到子类的LoggingWidget.doSomething()方法,方法执行完毕。- 当它退出子类的
synchronized方法时,JVM再次将myWidget锁的重入计数减1(计数从1变为0)。 - 此时,重入计数为0,
myWidget的锁被完全释放,其他线程终于可以获取它了。
四、思想实验:如果锁不是可重入的,会发生什么?
为了凸显“重入”的重要性,我们想象一下如果Java的synchronized锁不是可重入的,会发生什么可怕的事情:
线程T获取myWidget的锁,进入LoggingWidget.doSomething()。(成功,锁被持有)线程T调用super.doSomething()。- 为了进入父类方法,
线程T需要再次获取myWidget的锁。 - 系统检查发现,
myWidget的锁已经被持有了。 - 由于锁“不可重入”,系统会认为有别的线程占着锁(即使是它自己),于是让
线程T进入等待状态。 线程T等待myWidget的锁被释放。但谁能释放这个锁呢?只有持有它的线程T自己!而线程T正卡在super.doSomething()的调用上,无法执行到方法末尾去释放锁。
这就形成了一个完美的死锁(Deadlock):线程在等待一个永远不可能被释放的锁,因为它自己就是那个持有锁并且无法释放的线程。程序将永远挂起。
五、核心要点总结
现在,你应该清楚地“看”到重入的发生过程了。我们来总结一下:
锁住的是谁?
synchronized修饰实例方法时,锁是调用该方法的对象实例。在子类同步方法中调用父类同步方法,锁住的仍然是同一个子类对象实例。重入在哪里发生? 在一个线程已经持有某个对象的锁时,再次请求获取该对象的锁,就会发生重入。最典型的场景就是例子中的子类同步方法调用父类同步方法。
重入如何实现? JVM为每个锁关联一个“持有线程”和一个“获取计数器”。当线程获取锁时,如果发现自己就是持有者,则简单地将计数器加一。每次退出同步块时,计数器减一。当计数器归零时,锁才被真正释放。
重入为何重要? 重入避免了在面向对象继承和封装中常见的死锁问题。它允许我们在一个同步方法内部,放心地调用本对象的其他同步方法(或父类的同步方法),而不用担心自己把自己锁死。这个“看不见”的特性,是Java并发安全的重要基石。


