深入浅出ReentrantLock(可重入锁)

一、前言

在Java 5.0之前,在协调对共享对象的访问的时可以使用的机制只有synchronized 和 volatile。Java 5.0 增加了一种新的机制:ReentrantLock 。与之前提到过的机制相反,ReentrantLock 并不是一种替代内置加锁的方法,而是当内置解锁机制不适用时,作为一种可选择的高级功能。

二、简介

  • ReentrantLock 重入锁实现了 Lockjava.io.Serializable接口,并提供了与synchronized相同的互斥性和内存可见性,ReentrantLock 提供了可重入的加锁语义,能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞,并且与synchronized相比,它还为处理锁的不可用性提供了更高的灵活性,与此同时,ReentrantLock 还支持公平锁和非公平锁两种方式。

  • ReentrantLock类层次结构
    在这里插入图片描述

  • ReentrantLock实现了 LockSerializable接口,内部有三个内部类,Sync、NonfairSync、FairSync

  • Sync 是一个抽象类型,它继承 AbstractQueuedSynchronizer,这个AbstractQueuedSynchronizer是一个模板类,它实现了许多和锁相关的功能,并提供了钩子方法供用户实现,比如 tryAcquire、tryRelease等。Sync实现了AbstractQueuedSynchronizertryRelease方法。

  • NonfairSync和FairSync两个类继承自Sync,实现了lock方法,然后分别公平抢占和非公平抢占针对tryAcquire有不同的实现。

三、可重入性

可重入锁,也叫做 递归锁,从名字上理解,字面意思就是再进入的锁,重入性是指任意线程在获取到锁之后能够再次获取该锁而不会被锁阻塞,首先他需要具备两个条件:

  1. 线程再次获取锁:所需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次获取成功
  2. 锁的最终释放:线程重复n次获取了锁,随后在第n次释放该锁后,其它线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前线程被重复获取的次数,而被释放时,计数自减,当计数为0时表示锁已经成功释放。

2.1 锁的实现

使用ReentrantLock 案例:

 Lock lock = new ReentrantLock();

       lock.lock();
       try{
           //更新对象状态
           //捕获异常,并在必须时恢复不变性条件

       }catch (Exception e){
           e.printStackTrace();
       } finally {
           lock.unlock();
       }

上述代码中是使用Lock接口的标准使用方式,这种形式比使用内置锁(synchronized )复杂一些,必须要在 finally 块中释放锁,否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释放。

四、ReentrantLock 源码分析

在简介中我们知道 ReentrantLock继承自 Lock接口,Lock提供了一些获取锁和释放锁的方法,以及条件判断的获取的方法,通过实现它来进行锁的控制,因为它是显示锁,所以需要显示指定起始位置和终止位置,下面就来介绍一下Lock接口的方法介绍:


方法名称 方法描述
lock 用来获取锁,如果锁已被其他线程获取,则进行等待
tryLock 表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待
tryLock(long time, TimeUnit unit) 和tryLock()类似,区别在于它在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true
lockInterruptibly 获取锁,如果获取锁失败则进行等到,如果等待的线程被中断会相应中断信息
unlock 释放锁的操作
newCondition 获取Condition对象,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件wait()方法,而调用后,当前线程释放锁

ReentrantLock 也实现了上面接口的内容,同时 ReentrantLock 提供了 公平锁非公平锁两种模式,如果没有特别的去指定使用何种方式,那么 ReentrantLock 会默认为 非公平锁,首先我们来看一下 ReentrantLock 的构造函数:

    /**
     * 无参的构造函数
     */
	public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * 有参构造函数
     * 参数为布尔类型
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

从上述源码中我们可以看到:

  • ReentrantLock 优先使用的是无参构造函数,也就是非公平锁,但是当我们调用有参构造函数时,可以指定使用哪种锁来进行操作(公平锁还是非公平锁),参数为布尔类型,如果指定为 false 的话代表 非公平锁 ,如果指定为 true 的话代表的是 公平锁
  • Sync 类 是 ReentrantLock 自定义的同步组件,它是 ReentrantLock 里面的一个内部类,它继承自AQS(AbstractQueuedSynchronizer),Sync 有两个子类:公平锁 FairSync 和 非公平锁 NonfairSync
  • ReentrantLock 的获取与释放锁操作都是委托给该同步组件来实现的。下面我们来看一看非公平锁的 lock() 方法:

4.1 非公平锁 NonfairSync.lock()

1、NonfairSync.lock() 方法流程图
在这里插入图片描述

2、lock方法详解

  1. 在初始化 ReentrantLock 的时候,如果我们不传参,使用默认的构造函数,那么默认使用非公平锁,也就是 NonfairSync
  2. 当我们调用 ReentrantLocklock() 方法的时候,实际上是调用了 NonfairSynclock() 方法,代码如下:
 static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        /**
         * Performs lock.  Try immediate barge, backing up to normal
         * acquire on failure.
         */
        final void lock() {
        	//这个方法先用CAS操作,去尝试抢占该锁
        	// 快速尝试将state从0设置成1,如果state=0代表当前没有任何一个线程获得了锁
            if (compareAndSetState(0, 1))
            	//state设置成1代表获得锁成功
            	//如果成功,就把当前线程设置在这个锁上,表示抢占成功,在重入锁的时候需要
                setExclusiveOwnerThread(Thread.currentThread());
            else
           	 //如果失败,则调用 AbstractQueuedSynchronizer.acquire() 模板方法,等待抢占。
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }

  1. 调用 acquire(1) 实际上使用的是 AbstractQueuedSynchronizeracquire() 方法,它是一套锁抢占的模板,acquire() 代码比较简单:
    public final void acquire(int arg) {
    	//先去尝试获取锁,如果没有获取成功,就在CLH队列中增加一个当前线程的节点,表示等待抢占。
    	//然后进入CLH队列的抢占模式,进入的时候也会去执行一次获取锁的操作,如果还是获取不到,
    	//就调用LockSupport.park() 将当前线程挂起。那么当前线程什么时候会被唤醒呢?当
    	//持有锁的那个线程调用 unlock() 的时候,会将CLH队列的头节点的下一个节点上的线程
    	//唤醒,调用的是 LockSupport.unpark() 方法。
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
  • acquire() 会先调用 tryAcquire() 这个钩子方法去尝试获取锁,这个方法就是在 NonfairSync.tryAcquire()下的 nonfairTryAcquire(),源码如下:
	//一个尝试插队的过程
  final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //获取state值
            int c = getState();
            //比较锁的状态是否为 0,如果是0,当前没有任何一个线程获取锁
            if (c == 0) {
            	//则尝试去原子抢占这个锁(设置状态为1,然后把当前线程设置成独占线程)
                if (compareAndSetState(0, acquires)) {
                	// 设置成功标识独占锁
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //如果当前锁的状态不是0 state!=0,就去比较当前线程和占用锁的线程是不是一个线程
            else if (current == getExclusiveOwnerThread()) {
            	//如果是,增加状态变量的值,从这里看出可重入锁之所以可重入,就是同一个线程可以反复使用它占用的锁
                int nextc = c + acquires;
                //重入次数太多,大过Integer.MAX
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            //如果以上两种情况都不通过,则返回失败false
            return false;
        }
  • tryAcquire() 一旦返回 false,就会则进入 acquireQueued() 流程,也就是基于CLH队列的抢占模式,在CLH锁队列尾部增加一个等待节点,这个节点保存了当前线程,通过调用 addWaiter() 实现,这里需要考虑初始化的情况,在第一个等待节点进入的时候,需要初始化一个头节点然后把当前节点加入到尾部,后续则直接在尾部加入节点。

代码如下:

	//AbstractQueuedSynchronizer.addWaiter()
  private Node addWaiter(Node mode) {
  		// 初始化一个节点,用于保存当前线程
        Node node = new Node(Thread.currentThread(), mode);
        // 当CLH队列不为空的视乎,直接在队列尾部插入一个节点
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            //如果pred还是尾部(即没有被其他线程更新),则将尾部更新为node节点(即当前线程快速设置成了队尾)
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        // 当CLH队列为空的时候,调用enq方法初始化队列
        enq(node);
        return node;
    }
    
        private Node enq(final Node node) {
        //在一个循环里不停的尝试将node节点插入到队尾里
        for (;;) {
            Node t = tail;
            if (t == null) { // 初始化节点,头尾都指向一个空节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
  • 将节点增加到CLH队列后,进入 acquireQueued() 方法
    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            //在一个循环里不断等待前驱节点执行完毕
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {// 通过tryAcquire获得锁,如果获取到锁,说明头节点已经释放了锁
                    setHead(node);//将当前节点设置成头节点
                    p.next = null; // help GC//将上一个节点的next变量被设置为null,在下次GC的时候会清理掉
                    failed = false;//将failed标记设置成false
                    return interrupted;
                }
                //中断
                if (shouldParkAfterFailedAcquire(p, node) && // 是否需要阻塞
                    parkAndCheckInterrupt())// 阻塞,返回线程是否被中断
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
  • 如果尝试获取锁失败,就会进入 shouldParkAfterFailedAcquire() 方法,会判断当前线程是否阻塞
	/**
	* 确保当前结点的前驱结点的状态为SIGNAL
	* SIGNAL意味着线程释放锁后会唤醒后面阻塞的线程
	* 只有确保能够被唤醒,当前线程才能放心的阻塞。
	*/
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
           //如果前驱节点状态为SIGNAL
           //表明当前线程需要阻塞,因为前置节点承诺执行完之后会通知唤醒当前节点
            return true;
        if (ws > 0) {//ws > 0 代表前驱节点取消了
           
            do {
                node.prev = pred = pred.prev;//不断的把前驱取消了的节点移除队列
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
        	//初始化状态,将前驱节点的状态设置成SIGNAL
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }
  • 当进入阻塞阶段,会进入 parkAndCheckInterrupt() 方法,则会调用 LockSupport.park(this) 将当前线程挂起。代码如下:
// 从方法名可以看出这个方法做了两件事
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);//挂起当前的线程
    // 如果当前线程已经被中断了,返回true,否则返回false
    // 有可能在挂起阶段被中断了
    return Thread.interrupted();
}

4.2 非公平锁 NonfairSync.unlock()

2.1 unlock()方法的示意图
在这里插入图片描述

2.1 unlock()方法详解

  1. 调用 unlock() 方法,其实是直接调用 AbstractQueuedSynchronizer.release() 操作。

  2. 进入 release() 方法,内部先尝试 tryRelease() 操作,主要是去除锁的独占线程,然后将状态减一,这里减一主要是考虑到可重入锁可能自身会多次占用锁,只有当状态变成0,才表示完全释放了锁。

  3. 如果 tryRelease 成功,则将CHL队列的头节点的状态设置为0,然后唤醒下一个非取消的节点线程。

  4. 一旦下一个节点的线程被唤醒,被唤醒的线程就会进入 acquireQueued() 代码流程中,去获取锁。

代码如下:


public void unlock() {
        sync.release(1);
}


public final boolean release(int arg) {
		//尝试在当前锁的锁定计数(state)值上减1,
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)//waitStatus!=0表明或者处于CANCEL状态,或者是SIGNAL表示下一个线程在等待其唤醒。也就是说waitStatus不为零表示它的后继在等待唤醒。
                unparkSuccessor(h);
               //成功返回true
            return true;
        }
        //否则返回false
        return false;
}

private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
         //如果waitStatus < 0 则将当前节点清零
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
		//若后续节点为空或已被cancel,则从尾部开始找到队列中第一个waitStatus<=0,即未被cancel的节点
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);
    }

当然在 release() 方法中不仅仅只是将 state - 1 这么简单,-1 之后还需要进行一番处理,如果 -1 之后的 新state = 0 ,则表示当前锁已经被线程释放了,同时会唤醒线程等待队列中的下一个线程。

protected final boolean tryRelease(int releases) {
     int c = getState() - releases;
    //判断是否为当前线程在调用,不是抛出IllegalMonitorStateException异常
    if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
    boolean free = false;
   //c == 0,释放该锁,同时将当前所持有线程设置为null
    if (c == 0) {
          free = true;
          setExclusiveOwnerThread(null);
    }
    //设置state
    setState(c);
    return free;
 }

private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            // 从后往前找到离head最近,而且waitStatus <= 0 的节点
            // 其实在ReentrantLock中,waitStatus应该只能为0和-1,需要唤醒的都是-1(Node.SIGNAL)
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null) 
            LockSupport.unpark(s.thread);// 唤醒挂起线程
}

重点:unlock最好放在finally中,因为如果没有使用finally来释放Lock,那么相当于启动了一个定时炸弹,如果发生错误,我们很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间,这也就是 ReentrantLock 不能完全替代 synchronized 的原因,因为当程序执行控制离开被保护的代码块时,不会自动清除锁

4.3 公平锁 FairSync

FairSync相对来说就简单很多,只有重写的两个方法跟NonfairSync不同

final void lock() {
    acquire(1);
}

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&// 没有前驱节点了
            compareAndSetState(0, acquires)) {// 而且没有锁
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

五、公平锁和非公平锁的区别

  • 锁的公平性是相对于获取锁的顺序而言的。
  • 如果是一个公平锁,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO,线程获取锁的顺序和调用lock的顺序一样,能够保证老的线程排队使用锁,新线程仍然排队使用锁。
  • 非公平锁只要CAS设置同步状态成功,则表示当前线程获取了锁,线程获取锁的顺序和调用lock的顺序无关,全凭运气,也就是老的线程排队使用锁,但是无法保证新线程抢占已经在排队的线程的锁。
  • ReentrantLock默认使用非公平锁是基于性能考虑,公平锁为了保证线程规规矩矩地排队,需要增加阻塞和唤醒的时间开销。如果直接插队获取非公平锁,跳过了对队列的处理,速度会更快。

六、ReenTrantLock(可重入锁) 和 synchronized的区别

6.1 可重入性

  • ReenTrantLock(可重入锁) 的字面意思就是再进入的锁,对于 synchronized 关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程没进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

6.2 锁的实现

  • Synchronized 是依赖于JVM实现的,而 ReenTrantLock 是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。

6.3 性能的区别

  • 在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。

6.4 功能区别

  • 便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。

  • 锁的细粒度和灵活度:很明显 ReenTrantLock 优于 Synchronized ,但是 ReenTrantLock 没有办法完全取代 Synchronized

ReenTrantLock独有的能力

  1. ReenTrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。

  2. ReenTrantLock 提供了一个 Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized 要么随机唤醒一个线程要么唤醒全部线程。

  3. ReenTrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。

七、总结

ReentrantLock 是java中非常重要的一个并发工具,相比于java原生的 synchronized 有着更好的性能,学习 ReentrantLock ,我们主要需要了解它,公平锁非公平锁 的实现,以及重入锁的获取与释放的流程,还有最重要的就是要了解AQS(AbstractQueuedSynchronizer),这是实现重入锁的基础,ReentrantLock 是一个比较轻量级的锁,而且使用面向对象的思想去实现了锁的功能,比原来的synchronized 关键字更加好理解。

发布了56 篇原创文章 · 获赞 313 · 访问量 11万+

猜你喜欢

转载自blog.csdn.net/qq_14996421/article/details/102967314