搞懂重入锁

发布于 2018-09-15  45 次阅读


前言

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

公平锁:

  • 公平和非公平锁的队列都基于锁内部维护的一个双向链表,表结点Node的值就是每一个请求当前锁的线程。公平锁则在于每次都是依次从队首取值。
  • 锁的实现方式是基于如下几点:

-
- 表结点Node和状态state的volatile关键字。
- 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 语句


protectedfinalbooleantryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { ... // 尝试获取锁成功 } elseif (current == getExclusiveOwnerThread()) { // 是当前线程,直接获取到锁。实现可重入性。 int nextc = c + acquires; if (nextc \< 0) thrownew Error("Maximum lock count exceeded"); setState(nextc); returntrue; } returnfalse; }

此处有两个值需要关心:

    /\*\*

     \* The current owner of exclusive mode synchronization.

     \* 持有该锁的当前线程

     \*/

    privatetransient Thread exclusiveOwnerThread;

     -----------------两个值不在同一个类----------------

    /\*\*

     \* The synchronization state.

     \* 0: 初始状态-无任何线程得到了锁

     \* \> 0: 被线程持有, 具体值表示被当前线程持有的执行次数

     \*

     \* 这个字段在解锁的时候也需要用到。

     \* 注意这个字段的修饰词: volatile

     \*/

    privatevolatileint state;

#

ReentrantLock锁的实现分析

公平锁和非公平锁

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

publicfinalvoidacquire(int arg) {

    if (!tryAcquire(arg) &&

        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))

        selfInterrupt();

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

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

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

公平锁FairSync

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

if (!hasQueuedPredecessors() &&

    compareAndSetState(0, acquires)) {

    setExclusiveOwnerThread(current);

    returntrue;

}

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

    publicfinalbooleanhasQueuedPredecessors() {

        Node t = tail; // Read fields in reverse initialization order

        Node h = head;

        Node s;

        return h != t &&

            ((s = h.next) == null || s.thread != Thread.currentThread());

    }

非公平锁NonfairSync

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

if (c == 0) {

    if (compareAndSetState(0, acquires)) {

        setExclusiveOwnerThread(current);

        returntrue;

    }

}

#

ReentrantLock锁的释放

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

protectedfinalbooleantryRelease(int releases) {

    //  releases = 1

    int c = getState() - releases;

    if (Thread.currentThread() != getExclusiveOwnerThread())

        thrownew IllegalMonitorStateException();

    boolean free = false;

    if (c == 0) {

        free = true;

        setExclusiveOwnerThread(null);

    }

    setState(c);

    return free;

}

ReentrantLock等待队列中元素的唤醒

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

privatevoidunparkSuccessor(Node node) {

    // 强行回写将被唤醒线程的状态

    int ws = node.waitStatus;

    if (ws \< 0)

        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;

    // s为h的下一个Node, 一般情况下都是非Null的

    if (s == null || s.waitStatus \> 0) {

        s = null;

        // 否则按照FIFO原则寻找最先入队列的并且没有被Cancel的Node

        for (Node t = tail; t != null && t != node; t = t.prev)

            if (t.waitStatus \<= 0)

                s = t;

    }

    // 再唤醒它

    if (s != null)

        LockSupport.unpark(s.thread);

}

#

ReentrantLock内存可见性分析

针对如下代码:

try {

    lock.lock();

    i ++;

} finally {

    lock.unlock();

}

可以发现哪怕在不使用 volatile关键字修饰元素i的时候, 这里的i 也是没有并发问题的。

CAS和volatile, Java并发的基石

volatile 是Java语言的关键字, 功能是保证被修饰的元素(共享变量):

任何进程在读取的时候,都会清空本进程里面持有的共享变量的值,强制从主存里面获取;
任何进程在写入完毕的时候,都会强制将共享变量的值写会主存。
volatile 会干预指令重排。
volatile 实现了JMM规范的 happen-before 原则。

在多核多线程CPU环境下, CPU为了提升指令执行速度,在保证程序语义正确的前提下,允许编译器对指令进行重排序。也就是说这种指令重排序对于上层代码是感知不到的,我们称之为 processor ordering.

JMM 允许编译器在指令重排上自由发挥,除非程序员通过 volatile 等 显式干预这种重排机制,建立起同步机制,保证多线程代码正确运行。见文章:Java并发:volatile内存可见性和指令重排

当多个线程之间有互相的数据依赖的之后, 就必须显式的干预这个指令重排机制

CAS是CPU提供的一门技术。在单核单线程处理器上,所有的指令允许都是顺序操作;但是在多核多线程处理器上,多线程访问同一个共享变量的时候,可能存在并发问题。

使用CAS技术可以锁定住元素的值。Intel开发文档, 第八章
编译器在将线程持有的值与被锁定的值进行比较,相同则更新为更新的值。
CAS同样遵循JMM规范的 happen-before 原则。
JAVA CAS原理深度分析博客

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

JSR-133编译器编写手册

JMM规范经历了多代迭代, JSR-133为较为通用的一版规范。

编译器编写手册文档见: The JSR-133 Cookbook for Compiler Writers (非官方指南)

上面小章节描述到了 volatile可以避免掉的指令重排, 那它怎么避免的呢?

在内存的读写过程中, 无非 读/写 两者操作的四种组合:

  • LoadStore
  • LoadLoad
  • StoreStore
  • StoreLoad

volatile关键字通过提供"内存屏障"的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。而大多数的处理器都支持内存屏障的指令。

  • volatile读操作的后面插入一个LoadLoad屏障。
  • volatile写操作的后面插入一个StoreLoad屏障。

那这个StoreLoad /LoadLoad有什么用处呢? 见 Intel开发文档, 第八章。 简单的说StoreLoad就是触发后续指令中的线程缓存回写到内存; 而LoadLoad会触发线程重新从主存里面读数据进行处理。

Synchronization mechanisms in multiple-processor systems may depend upon a strong memory-ordering model. Here, a program can use a locking instruction such as the XCHG instruction or the LOCK prefix to ensure that a read-modify-write operation on memory is carried out atomically. Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory.

ReentrantLock内存可见性

在上述博客中的: ReentrantLock锁的实现分析#公平锁和非公平锁 中讲到:ReentrantLock 通过 volatile 和 CAS 的搭配实现锁的功能。

顺带的, volatile 关键字修饰的 state 字段读和后续的锁释放中的 state 字段写, 共同组成了保证ReentrantLock内存可见性的内存屏障。 此屏障保证了ReentrantLock的内存可见性

CAS的类似volatile内存屏障原理

参见文章 深入理解Java内存模型(五)——锁
如下文档部分摘录

volatile是通过在Java编译时,添加字节码来实现内存屏障功能。

CAS通过本地JNI调用,Java代码为 Unsafe.java, 层次调用为:unsafe.cpp \> 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

  }

}

如上面源代码所示,程序会根据当前处理器的类型来决定是否为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写的内存语义。

Comments


享受技术带来的乐趣。