Java多线程编程那些事:锁泄漏
什么是锁泄漏
众所周知,我们对锁的使用方式都是用同一个套路——先申请锁,再执行临界区中的代码,最后释放锁,如清单1所示。尽管如此,代码的错误可能导致一个线程在其执行完临界区代码之后未能释放引导这个临界区的锁。例如,清单1中的doSomethingWithLock方法所调用的someIoOperation方法如果在其执行期间抛出了异常(这里是IOException),那么doSomethingWithLock方法中的释放锁的语句将不会被执行,即此时doSomethingWithLock方法的执行线程在执行完临界区代码之后并没有释放引导该临界区的锁lock,这种现象(故障)就被称为锁泄漏(Lock Leak)。锁泄漏会导致其他线程无法获得其所需的锁,从而使得这些线程都无法完成其任务。
清单1 锁泄漏示例代码
/**
* 本代码是为演示“锁泄漏”而特意依照错误的方式编写的。
*
* @author viscent
*
*/
public class LockLeakExample {
ReentrantLock lock = new ReentrantLock();
// ...
public static void main(String[] args) {
LockLeakExample example = new LockLeakExample();
Thread t;
for (int i = 0; i < 24; i++) {
t = new Thread(new Runnable() {
@Override
public void run() {
try {
example.doSomethingWithLock();
} catch (IOException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public void doSomethingWithLock() throws IOException {
lock.lock();// 申请锁
// 临界区开始
someIoOperation();
// 临界区结束
lock.unlock();// 释放锁
}
public void someIoOperation() throws IOException {
// ...
}
}
值得注意的是,锁泄漏可能并不总是像上述例子那样经过分析显得那么明显,锁泄漏具有一定的隐蔽性——即使代码中存在锁泄漏,但是这种故障并不一定能够被我们察觉,而等到我们察觉的时候可能为时已晚(比如系统已经上线)。下一节我们会进一步介绍这点。不过,锁泄漏的规避方法非常简单:对于上述例子中我们只需要将锁的释放这个操作放在一个try-finally语句的finally块中就可以锁泄漏,如清单2所示。
清单2 避免锁泄漏示例代码
public class LockleakAvoidance {
ReentrantLock lock = new ReentrantLock();
// ...
public static void main(String[] args) {
LockLeakExample example = new LockLeakExample();
Thread t;
for (int i = 0; i < 24; i++) {
t = new Thread(new Runnable() {
@Override
public void run() {
try {
example.doSomethingWithLock();
} catch (IOException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public void doSomethingWithLock() throws IOException {
lock.lock();// 申请锁
try {
// 临界区开始
someIoOperation();
// 临界区结束
} finally {
lock.unlock();// 确保“释放锁”这个操作总是能够被执行到
}
}
public void someIoOperation() throws IOException {
// ...
}
}
锁泄漏的隐蔽性——可重入锁
Java平台中的所有锁都是可重入的(Reentrant),这使得锁泄漏具有一定的隐蔽性——即使释放锁的操作没有被正确地放在finally块中,并且临界区中的代码执行过程中也抛出了异常,锁泄漏所导致的后果(其他线程无法获得锁)也不一定就能立刻显现出来。所谓可重入,是指一个线程在持有一个锁的情况下仍然可以继续申请这个锁,并且这个线程总是可以成功申请到(获得)这个锁(确切的说,是有一定的次数限制的)。对于清单1中的doSomethingWithLock方法,在系统的并发量极小的情况下极有可能始终只有一个线程在执行该方法,那么即使doSomeIoOperation方法在其执行过程中抛出异常而导致这个线程未能释放锁lock,由于Java平台中的锁是可重入的,该线程后续再次执行doSomethingWithLock方法仍然可以继续获得锁lock,这就一定程度上掩盖了锁泄漏。这种情形下,锁泄漏所导致的后果只有等到系统并发量增大到多于1个线程执行doSomethingWithLock方法才能够显现出来。
锁泄漏免疫——内部锁
Java平台中的显式锁(Lock接口的实现类)的不当使用会造成锁泄漏,但是内部锁(synchronized)的使用不会造成锁泄漏。对于内部锁,无论其所引导的临界区中的代码是正常退出还是由于抛出异常而退出,Java平台会保证这个内部锁总是会被释放。Java平台对内部锁的这种保障实际上是由静态编译器(javac)来实现的。下面,我们通过查看清单3中的doSomethingWithLock方法对应的字节码(Byte Code)来证实这点。
清单3 使用内部锁避免锁泄漏
public class SynchronizedLockLeakFree {
// ...
public static void main(String[] args) {
SynchronizedLockLeakFree example = new SynchronizedLockLeakFree();
Thread t;
for (int i = 0; i < 24; i++) {
t = new Thread(new Runnable() {
@Override
public void run() {
try {
example.doSomethingWithLock();
} catch (IOException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public void doSomethingWithLock() throws IOException {
synchronized (this) {// 申请锁
// 临界区开始
someIoOperation();
// 临界区结束
}// 释放锁
}
public void someIoOperation() throws IOException {
// ...
}
}
清单3中的doSomethingWithLock方法对应的字节码如下所示:
public void doSomethingWithLock() throws java.io.IOException;
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: invokevirtual #43; // Method someIoOperation:()V
8: aload_1
9: monitorexit
10: goto 16
13: aload_1
14: monitorexit
15: athrow
16: return
Exception table:
from to target type
4 10 13 any
13 15 13 any
上面的字节码中,每一行代码中“:”后面的字符串代表Java虚拟机的指令,“:”前面的数字代表指令相对于其所在的方法的偏移位置(字节)。monitorenter和monitorexit这两个指令的作用分别是申请内部锁和释放内部锁,athrow指令的作用抛出异常。当临界区中的代码没有产生异常时,代码的执行路径是3->4->5->8->9,即“申请锁->调用someIoOperation方法->释放锁”。从上述异常表(Exception Table)中可以看出,位于4字节到10字节之间的指令执行时若产生异常,则代码会转到位于13字节处的指令继续执行。因此,如果临界区中的代码(即someIoOperation方法调用)执行时产生了异常,那么此时代码的执行路径会是3->4->5->13->14->15。由此可见,Java虚拟机会在抛出异常前执行monitorexit指令以释放内部锁。
用模板方法模式避免锁泄漏
使用显式锁的时候,为了避免锁泄漏我们必须确保线程在退出临界区后一定会释放锁。但是,直接使用try-catch-finally语句来确保这点存在两个问题:首先,这种方法是不太可靠的,新手甚至于“老手”容易忘记将Lock.unlock()调用放在finally块中;其次,这种方法会导致大量的样板式(Boilerplate)代码,这违反了DRY(Don’t Repeat Yourself)原则。有鉴于此,我们考虑可以使用模板方法(Template Method)模式来避免锁泄漏,如清单4所示。
清单4 使用模板方法模式避免锁泄漏
public class LockTemplate {
final protected ReentrantLock lock;
public LockTemplate(ReentrantLock lock) {
this.lock = lock;
}
public LockTemplate() {
this(new ReentrantLock());
}
public void doWithLock(Runnable task) {
lock.lock();
try {
task.run();
} finally {
lock.unlock();
}
}
}
有了LockTemplate这个工具之后,我们可以使用一个Runnable实例来表示临界区中的代码,而锁的申请与释放则由LockTemplate.doWithLock来考虑。
总结
锁泄漏是代码错误导致的一个线程未能释放其持有的锁从而导致其他线程无法获得相应锁的故障。内部锁的使用不会导致锁泄漏,显式锁使用不当会导致锁泄漏。Lock.unlock()总是应该被放到finally块中。模板方法模式可以用来避免锁泄漏。
参考资料
1、 黄文海.Java多线程编程实战指南(核心篇).电子工业出版社,2017
微信公众号: