Java中的公平锁和非公平锁实现详解

您所在的位置:网站首页 reentrantlock可见性 Java中的公平锁和非公平锁实现详解

Java中的公平锁和非公平锁实现详解

2023-04-06 19:07| 来源: 网络整理| 查看: 265

 

前言

Java语言中有许多原生线程安全的数据结构,比如ArrayBlockingQueue、CopyOnWriteArrayList、LinkedBlockingQueue,它们线程安全的实现方式并非通过synchronized关键字,而是通过java.util.concurrent.locks.ReentrantLock来实现。 刚好对这个很感兴趣, 因此写一篇博客详细分析此 “可重入锁实现原理”。ReentrantLock的实现是基于其内部类FairSync(公平锁)和NonFairSync(非公平锁)实现的。 其可重入性是基于Thread.currentThread()实现的: 如果当前线程已经获得了执行序列中的锁, 那执行序列之后的所有方法都可以获得这个锁。

公平锁:

公平和非公平锁的队列都基于锁内部维护的一个双向链表,表结点Node的值就是每一个请求当前锁的线程。公平锁则在于每次都是依次从队首取值。 锁的实现方式是基于如下几点: 表结点Node和状态state的volatile关键字。 sum.misc.Unsafe.compareAndSet的原子操作(见附录)。

非公平锁:

在等待锁的过程中, 如果有任意新的线程妄图获取锁,都是有很大的几率直接获取到锁的。

ReentrantLock锁都不会使得线程中断,除非开发者自己设置了中断位。 ReentrantLock获取锁里面有看似自旋的代码,但是它不是自旋锁。 ReentrantLock公平与非公平锁都是属于排它锁。

ReentrantLock的可重入性分析

这里有一篇对锁介绍甚为详细的文章 朱小厮的博客-Java中的锁.synchronized的可重入性

参考这篇文章: Java内置锁synchronized的可重入性

java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以每线程为粒度的,per-invocation互斥体获得对象锁的操作是以每调用作为粒度的)

ReentrantLock的可重入性

前言里面提到,ReentrantLock重入性是基于Thread.currentThread()实现的: 如果当前线程已经获得了锁, 那该线程下的所有方法都可以获得这个锁。ReentrantLock的锁依赖只有 NonfairSync和FairSync两个实现类, 他们的锁获取方式大同小异。

可重入性的实现基于下面代码片段的 else if 语句 1 protected final boolean tryAcquire(int acquires) { 2 final Thread current = Thread.currentThread(); 3 int c = getState(); 4 if (c == 0) { 5 ... // 尝试获取锁成功 6 } 7 else if (current == getExclusiveOwnerThread()) { 8 // 是当前线程,直接获取到锁。实现可重入性。 9 int nextc = c + acquires; 10 if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); 11 return true; 12 } 13 return false; 14 }

此处有两个值需要关心:

1 /** 2 * The current owner of exclusive mode synchronization. 3 * 持有该锁的当前线程 4 */ private transient Thread exclusiveOwnerThread; -----------------两个值不在同一个类---------------- /** 5 * The synchronization state. 6 * 0: 初始状态-无任何线程得到了锁 7 * > 0: 被线程持有, 具体值表示被当前线程持有的执行次数 8 * 9 * 这个字段在解锁的时候也需要用到。 10 * 注意这个字段的修饰词: volatile 11 */ private volatile int state; ReentrantLock锁的实现分析 公平锁和非公平锁

ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取。

1 public final void acquire(int arg) { 2 if (!tryAcquire(arg) && 3 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 4 selfInterrupt(); 5 }

tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在。addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等到队列去取后续值,而非公平锁在对于新晋线程有很大优势。acquireQueued 在多次循环中尝试获取到锁或者将当前线程阻塞。selfInterrupt 如果线程在阻塞期间发生了中断,调用 Thread.currentThread().interrupt() 中断当前线程。

ReentrantLock 对线程的阻塞是基于 LockSupport.park(this); (见 AbstractQueuedSynchronizer#parkAndCheckInterrupt)。 先决条件是当前节点有限次尝试获取锁失败。

 

公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。但是当并发情况下多个线程都读取到 state == 0时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。volatile 和 CAS的结合是并发抢占的关键。

公平锁FairSync

公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有, 当前线程会执行如下步骤:

1 if (!hasQueuedPredecessors() && 2 compareAndSetState(0, acquires)) { 3 setExclusiveOwnerThread(current); 4 return true; 5 }

其中hasQueuedPredecessors是用于检查是否有等待队列的。

1 public final boolean hasQueuedPredecessors() { 2 Node t = tail; // Read fields in reverse initialization order 3 Node h = head; 4 Node s; 5 return h != t && ((s = h.next) == null || s.thread != 6 Thread.currentThread()); 7 }

 

非公平锁NonfairSync

非公平锁在实现的时候多次强调随机抢占:

1 if (c == 0) { 2 if (compareAndSetState(0, acquires)) { 3 setExclusiveOwnerThread(current); 4 return true; 5 } 6 }

 

与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁。如果被加入了等待队列后则跟公平锁没有区别。ReentrantLock锁的释放

ReentrantLock锁的释放是逐级释放的,也就是说在 可重入性 场景中,必须要等到场景内所有的加锁的方法都释放了锁, 当前线程持有的锁才会被释放!释放的方式很简单, state字段减一即可:

1 protected final boolean tryRelease(int releases) { 2 // releases = 1 int c = getState() - releases; 3 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); 4 boolean free = false; 5 if (c == 0) { 6 free = true; 7 setExclusiveOwnerThread(null); 8 } 9 setState(c); 10 return free; 11 } ReentrantLock等待队列中元素的唤醒

当当前拥有锁的线程释放锁之后, 且非公平锁无线程抢占,就开始线程唤醒的流程。 通过tryRelease释放锁成功,调用LockSupport.unpark(s.thread); 终止线程阻塞。 见代码:

1 private void unparkSuccessor(Node node) { 2 // 强行回写将被唤醒线程的状态 3 int ws = node.waitStatus; 4 if (ws < 0) 5 compareAndSetWaitStatus(node, ws, 0); 6 Node s = node.next; 7 // s为h的下一个Node, 一般情况下都是非Null的 8 if (s == null || s.waitStatus > 0) { 9 s = null; 10 // 否则按照FIFO原则寻找最先入队列的并且没有被Cancel的Node 11 for (Node t = tail; t != null && t != node; t = t.prev){ 12 if (t.waitStatus atomic.cpp > atomicwindowsx86.inline.hpp。调用的代码如是:

#define LOCK_IF_MP(mp) __asm cmp mp, 0 \ __asm je L0 \ __asm _emit 0xF0 \ __asm L0: inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { // alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx } } View Code

 

如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

intel的手册对lock前缀的说明如下:

1):确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。

2):禁止该指令与之前和之后的读和写指令重排序。

3):把写缓冲区中的所有数据刷新到内存中。

上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3