1、可重入锁与ReentrantLock

可重入锁指的是该锁能够在支持在同一个线程下对资源的重复加锁。JDK 提供了ReentrantLock来实现可重入锁,众所周知,Synchronized 是支持可重入锁的,并且是隐式的支持,适用范式如下

  1. // 伪代码
  2. synchronized(this){
  3. // doSomething...
  4. // 尝试隐式重入锁
  5. synchronized(this){
  6. // doSomething...
  7. }
  8. }

但 ReentrantLock 并不支持隐式的重进入,其适用范式如下

  1. Lock lock = new ReentrantLock();
  2. lock.lock();
  3. try{
  4. // doSomething...
  5. lock.lock(); // 再次获取锁,ReentrantLock支持重进入,因此并不会阻塞
  6. lock.unlock;
  7. }finally{
  8. lock.unlock;
  9. }

ReentrantLock 不仅支持锁的重进入,还支持公平锁和非公平锁。所谓的公平指的是,在某个线程释放锁之后,剩下的线程能够按照绝对的时间顺序,先入先得的方式获取到锁也就是FIFO,这种方式实现的锁称之为公平锁,反之称之为非公平锁。ReentrantLock 无参构造方法中实现默认实现的的非公平锁,如果需要实现公平锁,则使用ReentrantLock的有参构造方法 new ReentrantLock(false)

  1. public ReentrantLock(boolean fair) {
  2. sync = fair ? new FairSync() : new NonfairSync();
  3. }

2、ReentrantLock 实现重进入原理

结合着之前实现的一些Sync,可以根据可重入锁的概念实现一个简单的可重入锁。进入同一个线程支持多次获得锁,那么在实现Sync的 acquired(int arg) 方法的时候,如果已经获取到锁并且如果锁的持有线程是当前线程的话,那么state+1,否则才返回失败,进入同步队列。在释放的时候,只有state == 0 的时候才表示真正的释放锁。

  1. protected boolean tryAcquire(int arg) {
  2. // 尚未被线程线程获取到同步状态,那么通过CAS设置同步状态
  3. if (同步状态 == 0 && compareAndSetState(current, arg)) {
  4. setExclusiveOwnerThread(thread);
  5. return true;
  6. } else if (持有同步状态的线程() == 当前线程) {
  7. // 已经被当前线程获取到锁,因为支持可重入,这里需要同步状态增加相应的值
  8. setState(current + arg);
  9. return true;
  10. }
  11. // 如果已经获取到同步状态,但是同步状态持有的线程不等于当前线程,返回失败
  12. return false;
  13. }
  14. @Override
  15. protected boolean tryRelease(int arg) {
  16. if (current == 0 || current < arg) {
  17. throw new IllegalMonitorStateException();
  18. }
  19. int newState = current - arg;
  20. compareAndSetState(current, newState);
  21. // 只有同步互状态小于0的时候,才真正的释放同步状态
  22. if (同步状态 == 0) {
  23. setExclusiveOwnerThread(null);
  24. return true;
  25. }
  26. return false;
  27. }

事实上, ReentrantLock 实现的也是同样的类似的逻辑,在可重入锁的实现逻辑中:

  1. state = 0 表示没有线程持有同步状态,表示为锁的层级即资源尚未加锁
  2. state > 0 表示线程持有同步状态的次数,表现在锁的层级即对资源加锁的次数

笔者这里通过ReentrantLock内部的非公平的同步器展示锁的可重入的实现逻辑,可以看到实现逻辑和上面的伪代码实现的过程非常类似,这里不做过多的赘述。

  1. // 同步状态的释放
  2. protected final boolean tryAcquire(int acquires) {
  3. return nonfairTryAcquire(acquires);
  4. }
  5. final boolean nonfairTryAcquire(int acquires) {
  6. final Thread current = Thread.currentThread();
  7. int c = getState();
  8. if (c == 0) {
  9. if (compareAndSetState(0, acquires)) {
  10. setExclusiveOwnerThread(current);
  11. return true;
  12. }
  13. }
  14. else if (current == getExclusiveOwnerThread()) {
  15. int nextc = c + acquires;
  16. if (nextc < 0) // overflow
  17. throw new Error("Maximum lock count exceeded");
  18. setState(nextc);
  19. return true;
  20. }
  21. return false;
  22. }
  23. // 同步状态的释放源码
  24. protected final boolean tryRelease(int releases) {
  25. int c = getState() - releases;
  26. if (Thread.currentThread() != getExclusiveOwnerThread())
  27. throw new IllegalMonitorStateException();
  28. boolean free = false;
  29. if (c == 0) {
  30. free = true;
  31. setExclusiveOwnerThread(null);
  32. }
  33. setState(c);
  34. return free;
  35. }


3、公平锁与非公平锁

开篇中已经大致说明了公平锁与非公平锁的概念,并且2章节中,也演示了非公平锁的获取机制。这里引用文章https://tech.meituan.com/2018/11/15/java-lock.html的图来描述公平锁和非公平锁
image.png

如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。

但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示:

image.png

通过源码我们可以看到, 公平锁与非公平锁实现的逻辑非常类似,仅仅多了一行代码!hasQueuedPredecessors() 即判断当前节点的是否有前驱节点,如果有那么说明队列的前面还有其他节点等待获取同步状态,因此需要等待前驱节点获取并释放锁完成之后才能继续尝试获取锁。
image.png

3.1 为什么默认是非公平锁?

在学习线程的时候,笔者曾经提到,多线程能够最大化的利用多核CPU的能力,但是在一些设计存在问题的多线程的代码中,多线程反而会降低程序的性能,这是因为CPU在执行多线程的时候,其实是快速切换线程实现的,通过快速切换实现多线程的效果。切换线程会引发线程上下文的切换,因此会降低吞吐量,导致程序性能降低,所以ReentrantLock默认使用非公平锁实现,而非公平锁。

公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁

4、参考文章

  1. 美团佳琪-不可不说的Java“锁”事 https://tech.meituan.com/2018/11/15/java-lock.html
  2. 方腾飞 - Java 并发编程的艺术