Java multithreaded programming those things: lock leaks

What is a lock leak

As we all know, the way we use locks is the same routine - first apply for the lock, then execute the code in the critical section, and finally release the lock, as shown in Listing 1. However, a bug in the code can cause a thread to fail to release the lock that leads the critical section after it has finished executing the code in the critical section. For example, if the someIoOperation method called by the doSomethingWithLock method in Listing 1 throws an exception (here IOException) during its execution, then the statement that releases the lock in the doSomethingWithLock method will not be executed, that is, the execution of the doSomethingWithLock method at this time The thread does not release the lock that guides the critical section after executing the code in the critical section. This phenomenon (failure) is called a lock leak (Lock Leak). Lock leaks prevent other threads from acquiring the locks they need, preventing none of those threads from completing their tasks.

Listing 1  Lock leak sample code

/**
 * 本代码是为演示“锁泄漏”而特意依照错误的方式编写的。
 * 
 * @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 {
        // ...
    }

}

 

It is worth noting that lock leaks may not always be as obvious after analysis as the above example, lock leaks have a certain concealment - even if there are lock leaks in the code, this kind of failure may not necessarily be detectable by us, And by the time we notice it, it may be too late (for example, the system is online). We'll go into this further in the next section. However, the way to circumvent the lock leak is very simple: for the above example we only need to put the release of the lock in the finally block of a try-finally statement to leak the lock, as shown in Listing 2.

 

Listing 2  Sample code to avoid lock leaks

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 {
        // ...
    }

}

The Concealment of Lock Leaks - Reentrant Locks

All locks in the Java platform are reentrant, which makes lock leaks somewhat invisible - even if the operation to release the lock is not properly placed in a finally block, and the code execution process in the critical section An exception is also thrown in the lock, and the consequences of a lock leak (other threads cannot acquire the lock) may not be immediately apparent. The so-called reentrancy means that a thread can continue to apply for the lock while holding a lock, and the thread can always successfully apply for (acquire) the lock (to be exact, there is a certain number of restrictions ). For the doSomethingWithLock method in Listing 1, it is very likely that only one thread will always execute the method when the concurrency of the system is extremely small, so even if the doSomeIoOperation method throws an exception during its execution, the thread fails to release the lock lock, since the lock in the Java platform is reentrant, the thread can continue to acquire the lock by executing the doSomethingWithLock method again, which conceals the lock leak to a certain extent. In this case, the consequences of lock leaks can only be manifested when the system concurrency increases to more than one thread executing the doSomethingWithLock method.

Lock Leak Immunity - Internal Locks

Improper use of explicit locks (implementation classes of the Lock interface) in the Java platform can cause lock leaks, but the use of internal locks (synchronized) will not cause lock leaks. For internal locks, the Java platform guarantees that the internal lock will always be released regardless of whether the code in the critical section it leads exits normally or due to an exception being thrown. The Java platform's guarantee of internal locks is actually implemented by the static compiler (javac). Next, we confirm this by looking at the Byte Code for the doSomethingWithLock method in Listing 3.

 

Listing 3  Using internal locks to avoid lock leaks

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 {
        // ...
    }

}

 

The bytecode for the doSomethingWithLock method in Listing 3 looks like this:

 

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

微信公众号:VChannel

 

Guess you like

Origin http://10.200.1.11:23101/article/api/json?id=326750599&siteId=291194637