Java多线程:公平锁和非公平锁
前言
在Java并发中,锁的种类有很多种,今天我们介绍其中一个分类:公平锁和非公平锁。
公平锁:顾名思义,对每个线程来说他们获取线程的方式是公平的,每个线程必须等到前面的线程执行完或者前面的线程取消或中断的时候,才轮到它获得锁,并且是按照顺序来执行的,先等待的先执行,类似队列,其实在底层jdk也是通过一个队列来存储排队等待获取锁的线程的,获取不到锁的话他就在队列中阻塞等待,直到被前面的线程唤醒。
非公平锁:与公平锁得到锁的方式相反,他可以不遵循公平性原则。即使当前线程之前还有其他线程在等待,它也可以去抢占锁,而不是老老实实的等待所有排在它前面线程释放锁才获得锁。不过,如果他没有抢占到锁的话,还是会跟公平锁一样在对列中排队等待被唤醒。
非公平锁的效率比公平锁高。
Synchronized应该属于非公平锁。
接下来,我将以 ReentrantLock 为例从源码的角度介绍这两种锁实现上有什么不同,为什么一个是公平的另一个是非公平的。
ReentrantLock 公平性分析
在前面的文章中我们介绍到了,ReentrantLock 是一种独占锁、可重入锁,而且当使用不同的构造函数对它进行初始化时,创建出来的锁也是不同的。使用默认即使用不带参数的构造方法时,创建的是非公平锁;而使用带参且传递的创建策略为 true 生成的是公平锁。
1 1 // 创建一个 ReentrantLock ,默认是"非公平锁"。 2 2 ReentrantLock() { 3 sync = new NonfairSync(); 4 } 5 3 // 创建策略是fair的 ReentrantLock。fair为true表示是公平锁,fair为false表示是非公平锁。 6 4 ReentrantLock(boolean fair) { 7 sync = fair ? new FairSync() : new NonfairSync(); 8 }
从源码来看,创建 ReentrantLock 实例时,其实底层生成的是 FairSync 和 NonFairSync 的实例,那这是什么类呢,我们来看一下 ReentrantLock 的UML图
从图中可以看出:
(01) ReentrantLock实现了Lock接口。
(02) ReentrantLock与sync是组合关系。ReentrantLock中,包含了Sync对象;而且,Sync是AQS的子类;更重要的是,Sync有两个子类FairSync(公平锁)和NonFairSync(非公平锁)。ReentrantLock是一个独占锁,至于它到底是公平锁还是非公平锁,就取决于sync对象是"FairSync的实例"还是"NonFairSync的实例"。
AQS 前面已经有一篇专门讲了,简单来讲就是指AbstractQueuedSynchronizer类,是java中管理“锁”的抽象类,锁的许多公共方法都是在这个类中实现。AQS是独占锁(例如,ReentrantLock)和共享锁(例如,Semaphore)的公共父类。
接下来分别以公平和非公平策略下的 ReentrantLock 对象获取锁的过程,来分析底层代码是如何体现公平和非公平性的。
1. 公平策略下的 ReentrantLock
在此状态下调用 lock() 方法最后得到的是公平锁,这是个顶层方法,所以我们从 lock() 入手,看整个获取锁的过程
lock() 方法源码如下:
final void lock() { acquire(1); }
这里说明一下“1”的含义,它是设置“锁的状态”的参数也可以称为重入数。对于“独占锁”而言,锁处于可获取状态时,它的状态值是0;锁被线程初次获取到了,它的状态值就变成了1。
由于ReentrantLock(公平锁/非公平锁)是可重入锁,所以“独占锁”可以被单个线程多此获取,每获取1次就将锁的状态+1。也就是说,初次获取锁时,通过acquire(1)将锁的状态值设为1;再次获取锁时,将锁的状态值设为2;依次类推...这就是为什么获取锁时,传入的参数是1的原因了。
可重入锁指的是在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁;synchronized也是重入锁。
aquire(int) 方法在AQS中定义,其源码如下:
1 public final void acquire(int arg) { 2 if (!tryAcquire(arg) && 3 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 4 selfInterrupt(); 5 }
说明:“当前线程”首先通过tryAcquire()尝试获取锁。获取成功的话,直接返回;尝试失败的话,进入到等待队列排序等待(前面还有可能有需要线程在等待该锁)。
对于后面的 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 以及 selfInterrupt() 方法,公平与非公平锁策略下他们是同样的方法,这里为了体现他们的区别就不对这两个方法进行介绍,如果想看这部分源码分析,可以参阅这篇博文中关于独占锁获取过程的分析。
所以我们着重来看 tryAcquire(int) 方法,公平锁的tryAcquire()在ReentrantLock.java的FairSync类中实现,源码如下:
1 protected final boolean tryAcquire(int acquires) { 2 // 获取“当前线程” 3 final Thread current = Thread.currentThread(); 4 // 获取“独占锁”的状态 5 int c = getState(); 6 // c=0意味着“锁没有被任何线程锁拥有”, 7 if (c == 0) { 8 // 若“锁没有被任何线程锁拥有”, 9 // 则判断“当前线程”是不是CLH队列中的第一个线程线程 10 if (!hasQueuedPredecessors() && 11 compareAndSetState(0, acquires)) { 12 // 若是的话,则获取该锁,并设置锁的拥有者为“当前线程” 13 setExclusiveOwnerThread(current); 14 return true; 15 } 16 } 17 else if (current == getExclusiveOwnerThread()) { 18 // 如果“独占锁”的拥有者已经为“当前线程”, 19 // 则将更新锁的状态。 20 int nextc = c + acquires; 21 if (nextc < 0) 22 throw new Error("Maximum lock count exceeded"); 23 setState(nextc); 24 return true; 25 } 26 return false; 27 }
hasQueuedPredecessors()在AQS中实现,源码如下:
//判断"当前线程"是不是在CLH队列的队首,来返回AQS中是不是有比“当前线程”等待更久的线程 public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }
Node就是CLH队列的节点,CLH也就是前面介绍概念时所提的用来存放排队等待线程的队列。Node在AQS中实现,它的数据结构如下:
1 private transient volatile Node head; // CLH队列的队首 2 private transient volatile Node tail; // CLH队列的队尾 3 4 // CLH队列的节点 5 static final class Node { 6 static final Node SHARED = new Node(); 7 static final Node EXCLUSIVE = null; 8 9 // 线程已被取消,对应的waitStatus的值 10 static final int CANCELLED = 1; 11 // 如果一个节点的状态设置为Node.SIGNAL,则说明它有后继节点,并处于阻塞状态 12 static final int SIGNAL = -1; 13 // 线程(处在Condition休眠状态)在等待Condition唤醒,对应的waitStatus的值 14 static final int CONDITION = -2; 15 // (共享锁)其它线程获取到“共享锁”,对应的waitStatus的值 16 static final int PROPAGATE = -3; 17 18 // waitStatus为“CANCELLED, SIGNAL, CONDITION, PROPAGATE”时分别表示不同状态, 19 // 若waitStatus=0,则意味着当前线程不属于上面的任何一种状态。 20 volatile int waitStatus; 21 22 // 前一节点 23 volatile Node prev; 24 25 // 后一节点 26 volatile Node next; 27 28 // 节点所对应的线程 29 volatile Thread thread; 30 31 // nextWaiter是“区别当前CLH队列是 ‘独占锁’队列 还是 ‘共享锁’队列 的标记” 32 // 若nextWaiter=SHARED,则CLH队列是“独占锁”队列; 33 // 若nextWaiter=EXCLUSIVE,(即nextWaiter=null),则CLH队列是“共享锁”队列。 34 Node nextWaiter; 35 36 // “共享锁”则返回true,“独占锁”则返回false。 37 final boolean isShared() { 38 return nextWaiter == SHARED; 39 } 40 41 // 返回前一节点 42 final Node predecessor() throws NullPointerException { 43 Node p = prev; 44 if (p == null) 45 throw new NullPointerException(); 46 else 47 return p; 48 } 49 50 Node() { // Used to establish initial head or SHARED marker 51 } 52 53 // 构造函数。thread是节点所对应的线程,mode是用来表示thread的锁是“独占锁”还是“共享锁”。 54 Node(Thread thread, Node mode) { // Used by addWaiter 55 this.nextWaiter = mode; 56 this.thread = thread; 57 } 58 59 // 构造函数。thread是节点所对应的线程,waitStatus是线程的等待状态。 60 Node(Thread thread, int waitStatus) { // Used by Condition 61 this.waitStatus = waitStatus; 62 this.thread = thread; 63 } 64 }
说明:
Node是CLH队列的节点,代表“等待锁的线程队列”。
(01) 每个Node都会一个线程对应。
(02) 每个Node会通过prev和next分别指向上一个节点和下一个节点,这分别代表上一个等待线程和下一个等待线程。
(03) Node通过waitStatus保存线程的等待状态。
(04) Node通过nextWaiter来区分线程是“独占锁”线程还是“共享锁”线程。如果是“独占锁”线程,则nextWaiter的值为EXCLUSIVE;如果是“共享锁”线程,则nextWaiter的值是SHARED
小结
我们来总结一下上面尝试获取锁的过程:首先我们要获取到当前锁的状态也就是重入数,如果当前状态位0说明当前锁处于空闲状态,想要得到锁的线程可以来取,但是因为是公平锁,不是每个想要你想要就给你,线程必须按照公平性原则来到。代码怎么做体现的呢?必须得判断当前线程是不是排在CLH队列的头节点的位置,只有你前面没有还在排队等待的节点并且当前锁的状态位0,通过CAS设置锁状态成功之后,才能将当前锁的拥有者指定为当前线程。当然,如果锁状态不为0,但是锁在当前线程手里,我们只需要更新锁的重入数就可以了。这里体现了 ReentrantLock 的重入性。否则,只要不是上述的情况,线程必须进入到CLH队列中排队等待。
2. 非公平策略下的 ReentrantLock
我们已经知道,当调用非公平策略下的 ReentrantLock 实际上调用的是 ReentrantLock.NonFairSync 的 lock() 方法,其源码如下:
1 final void lock() { 2 if (compareAndSetState(0, 1)) 3 setExclusiveOwnerThread(Thread.currentThread()); 4 else 5 acquire(1); 6 }
对比公平策略下获取锁的过程,这里的实现多了一个步骤。如果当前锁的状态为0,就先调用CAS尝试更新锁的状态,如果成功直接将当前线程设置为锁的拥有者,如果不成功再进行后续的操作。这里就体现了不公平,先尝试去抢占锁,而不去理会之前是否有其他线程正在等待获取锁。
如果当前线程未抢占到锁,则转而去执行 acquire(int) 方法,这个方法仍然调用的是 AQS 中的方法,源码如下:
1 public final void acquire(int arg) { 2 if (!tryAcquire(arg) && 3 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 4 selfInterrupt(); 5 }
同样,我们这里仍然主要讲解与公平策略下不同的地方,也就是 tryAcquire(int) 方法的实现,公非平锁的 tryAcquire() 调用的是 ReentrantLock 的 NonFairSync 类中的实现,但最后实现逻辑的代码在 Sync 类中,源码如下:
1 protected final boolean tryAcquire(int acquires) { 2 return nonfairTryAcquire(acquires); 3 } 4 5 final boolean nonfairTryAcquire(int acquires) { 6 final Thread current = Thread.currentThread(); 7 int c = getState(); 8 // 如果当前锁的状态位0,通过CAS抢占锁,更新锁状态,成功的话则将当前线程设置为锁拥有者 9 if (c == 0) { 10 if (compareAndSetState(0, acquires)) { 11 setExclusiveOwnerThread(current); 12 return true; 13 } 14 } 15 // 当前线程为锁拥有者,更新重入数 16 else if (current == getExclusiveOwnerThread()) { 17 int nextc = c + acquires; 18 if (nextc < 0) // overflow 19 throw new Error("Maximum lock count exceeded"); 20 setState(nextc); 21 return true; 22 } 23 // 否则去CLH队列排队 24 return false; 25 }
这里同样可以看到非公平的体现,与公平策略下的实现相比,当锁的状态位0时,抢占锁资源的线程不需要判断是否处于CLH队列的头部按顺序执行,无论是哪个线程都可以来争取锁。
小结
在非公平策略下 ReentrantLock 获取锁的过程中,在执行 acquire(int) 方法之前会先通过 CAS 去判断能否更改锁状态进而获使当前线程获取到锁;再执行 acquire(int) 方法时,如果当前锁状态为0,即没有线程占用锁时,它会直接通过 CAS 尝试去占用锁,而不去管当前线程之前是否还有别的线程在等待获取锁,只有所以尝试失败,当前线程才会去CLH队列排队等待。
参考资料:
java并发锁ReentrantLock源码分析一 可重入支持中断锁的实现原理
posted on 2016-04-27 11:37 Traveling_Light_CC 阅读(554) 评论(0) 编辑 收藏 举报