Java并发编程(十)——重入锁(ReentrantLock)

426 阅读6分钟

重入锁可以完全替代synchronized关键字。事实上,在JDK1.5之前,重入锁的性能远远好于synchronized,但从JDK1.6后在synchronized上做了大量的优化,使得两者的性能差距并不大。

重入锁的类路径是java.util.concurrent.locks.ReentrantLock。顾名思义,Re-entrant-Lock就是可以重复进入的锁。当然,这里所说的重复是局限于同一条线程的。下面的代码演示了重入锁的简单应用。

public class ReentrantLockExample {
    public static ReentrantLock lock = new ReentrantLock();
    public static int i = 0;
    @Override
    public void run() {
        for (int j = 0; j < 1000000; j++) {
            lock.lock();
            lock.lock();
            try {
                i++;
            } finally {
                lock.unlock();
                lock.unlock();
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        ReentrantLockExample r1 = new ReentrantLock();
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r1);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

上述代码中,使用重入锁确保多线程对临界区资源i操作的安全性。也可以看出,与synchronized相比,重入锁有着显示的操作过程,开发者必须手动指定获取锁和释放锁的时机。正是因为这样,重入锁可以更灵活的控制程序逻辑。代码中连续进行了两次lock()操作,对于重入锁来说是允许这种操作的,这也是“重入”两个字的来源。但需要注意,加了多少次锁,当操作结束后也需要执行相应次数的释放操作。如果释放次数超过加锁次数,就会抛出一个java.lang.IllegalMonitorStateException异常;反之,如果释放锁的次数少于加锁次数,就相当于锁仍被当前线程持有,其他线程无法进入临界区。

除了更为灵活,重入锁还提供了一些高级功能:

  • 中断响应
    对于synchronized来说,如果一个线程在等待,那么只会有两种情况,要么获得锁进入临界区,要么就保持等待。而重入锁提供了另一种可能:在等待过程中,程序可以根据需要取消对锁的请求。这就可以对处理死锁有一定的帮助。
    下面代码中就产生了一个死锁,但得益于锁中断,我们可以轻易地解决这个问题:

    public class IntLock implements Runnable {
        public static ReentrantLock lock1 = new ReentrantLock();
        public static ReentrantLock lock2 = new ReentrantLock();
        private int order;
        
        /**
         *  控制加锁顺序,方便构造死锁
         */
        public IntLock(int order) {
            this.order = order;
        }
        
        @Override
        public void run() {
            try {
                if (order == 1) {
                    lock1.lockInterruptibly();
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {}
                    lock2.lockInterruptibly();
                } else {
                    lock2.lockInterruptibly();
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {}
                    lock1.lockInterruptibly();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if (lock1.isHeldByCurrentThread()) { 
                    lock1.unlock();
                }
                if (lock2.isHeldByCurrentThread()) {
                    lock2.unlock();
                }
                System.out.println(Thread.currentThread().getId() + ";线程退出");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        IntLock r1 = new IntLock(1);
        IntLock r2 = new IntLock(2);
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r2);    
        t1.start();t2.start();
        Thread.sleep(1000);
        t2.interrupt();
    }
    

    程序启动后,t1先占用lock1,再请求lock2;t2先占用lock2,再请求lock1。代码中对锁的请求使用了lockInterruptibly,这是一个可以响应中断的锁申请操作,即在等待锁的过程中可以响应中断。 当主线程处于休眠时,这两个线程处于死锁状态,主线程结束休眠后中断了t2,所以t2会放弃对lock1的申请,并在finally块中释放了lock2,然后t1就可以顺利获得lock2并继续执行下去。

  • 锁申请等待限时
    除了等待外部通知,显示等待也是避免死锁的一种方法。一般情况下我们无法判断一个线程为什么会长期处于等待状态——可能是因为死锁,也可能是因为产生了饥饿。如果可以给定一个等待时间,超时后让线程自动放弃,在这种情况下也是很有帮助的。

    public class TimeLock implements Runnable {
        public static ReentrantLock lock = new ReentrantLock();
        @Override
        public void run() {
            try {
                if (lock.tryLock(5, TimeUnit.SECONDS)) {
                    Thread.sleep(6000);
                } else {
                    System.out.println("get lock failed");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                if(lock.isHeldByCurrentThreads()) {
                    lock.unlock();
                }
            }
        }
        
        public static void main(String[] args) {
            TimeLock lock = new TimeLock();
            Thread t1 = new Thread(lock);
            Thread t2 = new Thread(lock);
            t1.start();
            t2.start();
        }
    }
    

    上述代码演示了显示等待的使用。tryLock()方法接收两个参数,一个表示等待时长,一个表示计时单位,代码中将单位设置为秒,即线程在这次锁请求中,最多等待5秒。如果5秒内获取到锁就会返回true,否则返回false。本例中持有锁的线程会将其占用6秒,所以另一个线程无法在5秒内获取到锁,对锁的请求会失败。 tryLock()方法有一个不带任何参数的重载,这个重载方法的作用是尝试获取锁,如果锁已被其他线程占用,当前线程并不会进行等待,而是立刻返回false

  • 公平锁
    大多数情况下,锁的申请都是非公平的。也就是说,先申请锁的线程不一定先获得锁。而公平锁则不是这样,它会按顺序保证先来后到。公平锁的一大特点就是不会产生饥饿,只要线程在等待队列中,最终肯定可以得到资源。synchronized的锁方式就是非公平的,而重入锁可以设置其是否公平。它有一个构造函数:

    public ReentrantLock(boolean fair)
    

    当参数fair为true时表示锁是公平的。公平锁虽然看起来很优美,但往往性能相较于非公平锁来说更为 低下,因为非公平锁的分配机制更倾向于继续分配给上次获得锁的线程,而公平锁由于其公平性,往往会 引起频繁的线程切换——一项非常消耗系统资源的操作。 下面的代码可以很好地突出公平锁的特点”

    public class FairLock implements Runnable {
        public static ReentrantLock fairLock = new ReentrantLock();
        
        @Override
        public void run() {
            while(true) {
                try {
                    fairLock.lock();
                    System.out.println(Thread.currentThread().getName() + " 获得锁");
                } finally {
                    fairLock.unlock();
                }
            }
        }
        
        public static void main(String[] args) throws InterruptedException{
            FairLock fair = new FairLock();
            Thread t1 = new Thread(fair, "Thread_t1");
            Thread t2 = new Thread(fair, "Thread_t2");
            t1.start();t2.start();
        }
    }
    

    在公平锁的情况下,通常会得到如下输出:

    Thread_t1 获得锁
    Thread_t2 获得锁
    Thread_t1 获得锁
    Thread_t2 获得锁
    Thread_t1 获得锁
    Thread_t2 获得锁
    Thread_t1 获得锁
    Thread_t2 获得锁
    

    可以看出,两个线程基本上是交替获得锁的,几乎不会发生同一个线程连续多次获取锁的情况,公平性得到了保证。如果使用非公平锁,情况则大大不同,输出会是下面这样:

    Thread_t1 获得锁
    Thread_t1 获得锁
    Thread_t1 获得锁
    Thread_t1 获得锁
    Thread_t1 获得锁
    Thread_t2 获得锁
    Thread_t2 获得锁
    Thread_t2 获得锁
    Thread_t2 获得锁
    

    这是由于系统调度会倾向于已经获取过锁的线程,这种方式性能很高,但没有什么公平性可言。

重入锁的实现

1. 原子状态

原子状态使用CAS(Comparea and Swap)操作来存储当前锁的状态,判断锁是否已被别的线程持有。

2. 等待队列

所有没有请求到锁的线程,会进入等待队列进行等待。持有线程释放锁后,系统就会从等待队列唤醒一个线程,继续工作。

3. 阻塞元语

park()unpark()方法分别对应挂起和恢复线程。没有得到锁的线程将会被挂起。

参考文献

  1. 《Java高并发程序设计实战》.电子工业出版社.葛一鸣、郭超编著
  2. dzone.com/articles/wh…