RelaxHeart王琦
作者RelaxHeart王琦·2019-05-10 18:39
研发工程师·RelaxHeart

重入锁ReentrantLock

字数 10257阅读 688评论 0赞 0

灵活的重入锁


重入锁ReenterLock我把它理解为是synchronized的增强版,因为重入锁可以完全替代synchronized关键字。在JDK 5.0的早期版本中,重入锁的性能远远好于synchronized,但从JDK6.0开始对synchronized做了大量的优化工作,是的两者的性能差距并不大了。

重入锁使用java.util.concurrent.locks.ReentrantLock类来实现。下面是一段最见到那的重入锁使用案例:

/**
 * @Author: 王琦 <QQ.Eamil>1124602935@qq.com</QQ.Eamil>
 * @Date: 2019-5-3 0003 22:43
 * @Description: 重入锁ReentrantLock的简单使用案例
 */
public class ReenterLock implements Runnable {

    // 重入锁
    public static ReentrantLock lock = new ReentrantLock();

    // 共享变量i
    public static int i = 0;

    @Override
    public void run() {
        for (int j = 0; j<1000000; j++){
            // 加锁
            lock.lock();
            try{
                i++;
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReenterLock reenterLock = new ReenterLock();
        Thread t1 = new Thread(reenterLock);
        Thread t2 = new Thread(reenterLock);
        t1.start(); t2.start();
        t1.join();  t2.join();
        System.out.println("i = " + i);
    }
}

上述代码中我们使用了重入锁保护临界区资源i,确保多线程对i操作的安全性。从这段代码可以看到,与synchronized相比,冲入锁有着显示的操作过程。开发人员必须手动指定何时加锁,何时释放锁。因此重入锁对逻辑控制的灵活性也远远好于synchronized。但值得注意的是,在退出临界区时,必须记得释放锁,否则其他线程就没有机会在访问临界区了。

有些同学可能对重入锁这“重入”二字有点糊涂,锁就是锁嘛,为什么叫重入锁呢。之所以这么叫是因为这种锁时可以反复进入的。当然,这里的反复仅仅局限于一个线程。我们可以修改上述代码中加锁的代码段:

@Override
    public void run() {
        for (int j = 0; j<1000000; j++){
            // 加锁
            lock.lock();
            lock.lock();
            try{
                i++;
            } finally {
                // 释放锁
                lock.unlock();
                lock.unlock();
            }
        }
    }

在这种情况下一个线程会连续两次获得同一把锁。如果不允许这么操作,那同一个线程在第二次获得锁的时候将会和自己产生死锁(线程会卡死在第2次获取锁的过程中)。需要注意的是如果一个线程可以多此获得锁(即多次lock.lock()),那么相应的也要对应次数的释放锁(lock.unlock()),相反,如果释放次数少了,相当于当前线程还是持有锁,那其他线程也无法进入临界区。

除了上述灵活性外,重入锁还提供了一些高级功能。比如,重入锁可以提供终端处理的能力。

中断响应


对于synchronized来说,如果一个线程在等待锁,那么结果只有两种:要么持有锁,要么保持等待。而使用重入锁则提供了另外一种可能:那就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做是非常有必要的。比如:如果你和你的朋友约好一起去打球,如果你等了半小时朋友还没到,突然接到他的电话有时来不来了,那么你一定就扫兴的打道回府或者自己走了。中断正是提供了一套类似的机制,如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无需在等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的。

下面的代码产生了一个死锁,但得益于锁中断,我们可以很轻易的解决这个死锁:

/**
 * @Author: 王琦 <QQ.Eamil>1124602935@qq.com</QQ.Eamil>
 * @Date: 2019-5-3 0003 23:05
 * @Description: 重入锁高级特性:锁中断
 */
public class IntLock implements Runnable {

    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;

    /**
     * 控制加锁顺序,方便构造死锁
     * @param lock
     */
    public IntLock(int lock){
        this.lock = lock;
    }

    @Override
    public void run() {
        try{
            if (lock == 1){
                lock1.lockInterruptibly();
                Thread.sleep(500);
                lock2.lockInterruptibly();
            } else {
                lock2.lockInterruptibly();
                Thread.sleep(500);
                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 intLock1 = new IntLock(1);
        IntLock intLock2 = new IntLock(2);

        Thread t1 = new Thread(intLock1);
        Thread t2 = new Thread(intLock2);

        t1.start(); t2.start();
        Thread.sleep(1000);
        t2.interrupt();

    }
}

线程t1和t2启动后, t1先占用lock1,在占用lock2; t2先占用lock2,在请求lock1.因此很容易形成t1和t2之间的相互等待。在这里,对锁的请求,统一使用lockInterruptibly()方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,可以响应中断。

执行结果:

java.lang.InterruptedException
   at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:944)
   at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1263)
   at java.base/java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:317)
   at cn.relaxheart.designModel.reenterLock.IntLock.run(IntLock.java:34)
   at java.base/java.lang.Thread.run(Thread.java:835)
14:线程退出
13:线程退出

可以看出,中断后,两个线程双双退出。但真正完成工作的只有t1.而t2线程则放弃其任务直接退出,释放资源。

锁申请等待时间


除了等待外部通知之外,要避免四速还有另外一种方法,那就是限时等待。依然以约朋友打球为例,如果朋友迟迟不来,又无法联系到他。那么,在等待1~2小时后,我想大部分人都会扫兴离开。对线程来说也是这样。通常,我们无法判断为什么一个线程迟迟拿不到锁。也许是因为死锁了,也许是因为产生了饥饿。但如果给定一个等待时间,让线程自动放弃,那么对系统来说是又意义的。我们可以使用tryLock()方法进行一次显示等待。
下面代码展示了限时等待锁的使用:

/**
 * @Author: 王琦 <QQ.Eamil>1124602935@qq.com</QQ.Eamil>
 * @Date: 2019-5-3 0003 23:34
 * @Description:  重入锁高级特性:限时等待锁
 */
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.isHeldByCurrentThread()){
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        TimeLock timeLock = new TimeLock();
        Thread t1 = new Thread(timeLock);
        Thread t2 = new Thread(timeLock);

        t1.start();
        t2.start();
    }
}

在这里,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示即使单位。这里的单位设置为秒,时长为5,表示线程在这个锁请求中,最多等待5秒。如果超过5秒还没有得到锁,就会返回false。如果成功获得锁,则返回true。

在这个例子中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法在5秒的等待时间内获得锁,因此,请求锁会失败。

ReentrantLock.tryLock()方法也可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,因此也不会产生死锁。下面演示了这种使用方式:

/**
 * @Author: 王琦 <QQ.Eamil>1124602935@qq.com</QQ.Eamil>
 * @Date: 2019-5-3 0003 23:53
 * @Description: 无描述信息
 */
public class TryLock implements Runnable {

    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;

    public TryLock(int lock){
        this.lock = lock;
    }

    @Override
    public void run() {
        if (lock == 1) {
            while (true){
                if (lock1.tryLock()){
                    try {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException e) {

                        }

                        if (lock2.tryLock()) {
                            try {
                                System.out.println(Thread.currentThread().getId() + ":My Job done");
                            } finally {
                                lock2.unlock();
                            }
                        }
                    } finally {
                        lock1.unlock();
                    }
                }
            }
        } else {
            while (true){
                if (lock2.tryLock()){
                    try {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException e) {

                        }

                        if (lock1.tryLock()) {
                            try {
                                System.out.println(Thread.currentThread().getId() + ":My Job done");
                            } finally {
                                lock1.unlock();
                            }
                        }
                    } finally {
                        lock2.unlock();
                    }
                }
            }
        }
    }

    public static void main(String[] args) {
        TryLock tryLock1 = new TryLock(1);
        TryLock tryLock2 = new TryLock(2);

        Thread t1 = new Thread(tryLock1);
        Thread t2 = new Thread(tryLock2);

        t1.start();
        t2.start();
    }
}

上述代码中,采用了非常容易死锁的加锁顺序。也就是先让t1获得lock1, 再让t2获得lock2,接着做反向请求,让t1申请lock2, t2申请lock1.在一般情况下,这会导致t1 和 t2相互等待,从而引起死锁。

但是使用tryLock()后,这种情况就大大改善了。由于线程不会傻傻地等待,而是不停地尝试,因此,只要执行足够长的时间,线程总是会得到所需要的资源,从而正常执行(这里以线程同时获得lock1,lock2两把锁,作为其可以正常执行的条件)。在同时获得lock1 和 lock2后,线程就打印出标志着任务完成的信息“My Job done”。

执行结果:

13:My Job done
14:My Job done

公平锁


在大多数情况下,所得申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么当锁A可用时,是线程1可以获得锁还是线程2可以获得锁呢?这个是不一定的。系统只是会从这个锁的等待队列中随机的挑选一个。因此不能保证其公平性。
而公平的锁则不会是这样的,它会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是:它不会产生饥饿现象。只要你排队,最终还是可以等到资源的。如果我们使用synchronized关键字进行锁控制,那么产生的锁就是非公平的。而重入锁允许我们对其公平性进行设置。它有一个如下的构造函数:

public ReentrantLock (boolean fair)

当参数fair为true时,表示锁时公平的。公平锁看起来很优美,但是要实现公平锁必然要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能相对也非常低下,因此,默认情况下,锁时非公平的。如果没有特别的需求,也不需要使用公平锁。公平锁和非公平锁在线程调度上也是又极大差别的。下面代码可以很好的突出公平锁的特点:

/**
 * @Author: 王琦 <QQ.Eamil>1124602935@qq.com</QQ.Eamil>
 * @Date: 2019-5-4 0004 0:15
 * @Description: 重入锁之公平锁
 */
public class FairLock implements Runnable {

    // 创建一个公平锁:fairLock
    public static ReentrantLock fairLock = new ReentrantLock(true);

    @Override
    public void run() {
        while (true){
            try {
                fairLock.lock();
                System.out.println(Thread.currentThread().getName() + "获得锁");
            } finally {
                fairLock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        FairLock fairLock = new FairLock();
        Thread t1 = new Thread(fairLock, "Thread_t1");
        Thread t2 = new Thread(fairLock, "Thread_t2");

        t1.start();
        t2.start();
    }
}

代码很简单,我们直接看运行结果:
image.png

image.png

从结果可以很明显的看出两个线程基本上是交替获得锁的,几乎不会发生一个线程连续多此获得锁的可能,从而公平性也得到了保障。如果不使用公平锁,那么情况就完全不一样了:
image.png
image.png

可以看出来根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的,但是无公平性可言。

总结


一、ReentrantLock的几个重要方法整理如下:
(1) lock():获得锁,如果锁已经被占用,则等待。
(2) lockInterruptibly():获得锁,但优先响应中断。
(3) tryLock():尝试获得锁,如果成功,返回true,失败返回false。该方法不等待,立即返回。
(4) tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
(5) unlock():释放锁。

二、就重入锁的实现来看,它主要几种在Java层面。在重入锁的实现中,主要包含三个要素:
(1) 原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有。
(2) 等待队列。所有没有请求到锁的线程,会进入等待队列进行等待。持有线程释放锁后,系统就能从等待队列中唤醒一个线程,继续工作。
(3) 阻塞原语park()和unpark(),用来挂起和回复线程。

三、延申:重入锁的好搭档:Condition条件,这里就不说了,感兴趣的同学自己去查一下。

更多博文:https://www.relaxheart.cn/to/master/blog

如果觉得我的文章对您有用,请点赞。您的支持将鼓励我继续创作!

0

添加新评论0 条评论

Ctrl+Enter 发表

作者其他文章

X社区推广