第 3 章 ReentrantLock锁的用法及基本原理(随笔)

在深入了解ReentrantLock锁之前我们先来了解一些基本概念。

1. 锁的一些基本分类

公平锁:多个线程申请锁时是相对公平的,在申请锁时如果有其它线程已经占用了锁,则进行排队等待处理(排队先进来的先获取到锁,能够避免线程饥饿)。
非公平锁:多个线程申请锁时相对不公平,与公平锁相同,都会存在排队的情况,但对于一个新线程需要获取锁时不是先排队等待,而是先尝试获取锁,不成功时再进入队列排队等待(谁抢到就是谁的,抢不到就排队),ReentrantLock锁默认是非公平锁。
悲观锁:每次更新和读取都加锁,对于读多写少这种场景不利,适用于于写多读少的场景。
乐观锁:更新失败则重试,适用一个标记位来控制,适用于多读的场景,例如CAS机制。
可重入锁:同一线程多次进入无需重新获取锁,例如synchronized和ReentrantLock。
共享锁(读锁):一个锁可以同时被多个线程拥有,ReentrantReadWriteLock的读锁为共享锁,写锁为排他锁
排他锁(写锁):独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁,synchronized和ReentrantLock就是排他锁。

可中断锁:所谓的中断锁指的是锁在执行时可被中断,也就是在执行时可以接收 interrupt 的通知,从而中断锁执行。 ​

2. ReentrantLock锁的基本原理

2.1 ReentrantLock框架

这里以JDK1.9为基准,解释ReentrantLock锁的基本框架和原理,ReentrantLock应用框架如下:

   //1.首先创建一个ReentrantLock对象
    private Lock lock = new ReentrantLock();
    
    //2.线程加锁
    lock.lock();
    try {
        ...
    } finally {
      //3.线程释放锁
      lock.unlock();
    }

我们再深入ReentrantLock源码看看ReentrantLock加锁解锁做了什么事情?
先看下ReentrantLock代码框架:

public class ReentrantLock implements Lock, java.io.Serializable {

   private final Sync sync;

     /**
     * 默认创建非公平锁
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * fair为true表示是公平锁,fair为false表示是非公平锁
     */

    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    } 
    // ...省略其它代码
}

ReentrantLock继承了Lock和Serializable两个接口,同时有两个构造方法,分别可初始化创建非公平锁和公平锁。

2.2 ReentrantLock加锁逻辑

以ReentrantLock默认非公平锁为例来看源代码是如何实现加锁的,这里先梳理下ReentrantLock非公平锁框架来说明加锁流程。

//1.加锁调用入口
lock.lock();       
   ⬇️
   ⬇️
//2.调用ReentrantLock中的lock方法
public void lock() {
    sync.lock();
}
   ⬇️
   ⬇️
//3.调用ReentrantLock内部抽象类Sync中的lock方法拿锁
//@ReservedStackAccess注释表示该方法对堆栈溢出特别敏感
@ReservedStackAccess
final void lock() {
    if (!initialTryLock())
    	acquire(1);
}
   ⬇️
   ⬇️
//4.调用initialTryLock()去拿锁,如果拿到则返回true,并讲锁状态置为1,拿不到则返回false进入下一步
//initialTryLock()有两个实现类,分别对应非公平锁和公平锁,这里以非公平锁为例进行说明
final boolean initialTryLock() {      
    ...
    //通过cas(compareAndSetState)的方式拿锁,拿到锁则将锁状态置为1,并将当前线程设置为独占锁访问权限
    if (compareAndSetState(0, 1)) { // first attempt is unguarded
        setExclusiveOwnerThread(current);
        return true;
    }
    //省略中间代码 
    ...
}
   ⬇️
   ⬇️
//5.如果拿不到锁则调用acquire(1)尝试加入队列,acquire是AQS抽象类中的方法
//acquire(1)这里会调用tryAcquire尝试再拿一次锁,如果还是拿不到则调用acquire加入队列
public final void acquire(int arg) {
    if (!tryAcquire(arg))
        acquire(null, arg, false, false, false, 0L);
}
   ⬇️
   ⬇️
//6.调用AQS中的acquire方法执行入队操作
final int acquire(Node node, int arg, boolean shared,
                   boolean interruptible, boolean timed, long time) {
//省略中间代码
...
}

ReentrantLock非公平锁加锁流程的调用关系上面已经标出,用流程图表示可能更直观。

大家可能注意到lock()方法有个注解,这里提一下@ReservedStackAccess注解,有的小伙伴可能对它比较陌生,它主要的作用是会保护被注解的方法,通过添加一些额外的空间,防止在多线程运行的时候出现栈溢出。

下面重点讲一下ReentrantLock中的compareAndSetState(CAS)拿锁机制和拿锁失败后的AQS(AbstractQueuedSynchronizer)入队机制。

2.3 ReentrantLock的compareAndSetState(CAS)机制

根据compareAndSetState原文代码注释可知,如果当前线程锁的state等于预期值,则原子性(如果有不清楚原子操作的小伙伴可以自行查找)地将锁的state设置为给定的更新值(默认为1)。
参数:
expect–期望值
update–更新值
返回值:
如果CAS成功,则返回true,否则返回False。

/*
Atomically sets synchronization state to the given updated value if the current state value equals the expected value. This operation has memory semantics of a volatile read and write.
Params:
expect – the expected value update – the new value
Returns:
true if successful. False return indicates that the actual value was not equal to the expected value.
*/
protected final boolean compareAndSetState(int expect, int update) {
    return U.compareAndSetInt(this, STATE, expect, update);
}

ReentrantLock非公平锁中的CAS调用了来自Unsafe类中的compareAndSetInt方法来保证其原子性,其中U是一个 Unsafe类(可以提供硬件级别的原子操作,直接访问内存,可以获取某个属性在内存中的位置,也可以修改对象的字段值)对象,而STATE字段表示锁状态state在AbstractQueuedSynchronizer(AQS)类中的偏移量(可以理解为state的内存地址),通过这个偏移量可以直接访问state。

    // Unsafe
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long STATE
        = U.objectFieldOffset(AbstractQueuedSynchronizer.class, "state");
    private static final long HEAD
        = U.objectFieldOffset(AbstractQueuedSynchronizer.class, "head");
    private static final long TAIL
        = U.objectFieldOffset(AbstractQueuedSynchronizer.class, "tail");

我们再来看下compareAndSetInt干了什么?

    /**
     * Atomically updates Java variable to {@code x} if it is currently
     * holding {@code expected}.
     *
     * <p>This operation has memory semantics of a {@code volatile} read
     * and write.  Corresponds to C11 atomic_compare_exchange_strong.
     *
     * @return {@code true} if successful
     */
    @IntrinsicCandidate
    public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x);

compareAndSetInt方法主要就是原子性地将该目标对象(0)内存地址(offset)中的值改为目标值(x),这个方法是一个本地(native)的方法,底层是通过C++实现的,其中o为对象,offset为该对象的内存地址。

state本身定义为volatile类型,当把变量声明为volatile类型后,编译器与程序运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序,保证了其修饰变量的可见性和有序性,因此多线程操作时state时保证了新值能立即同步到主内存,以及线程每次使用前都会从从主内存刷新。

    /**
     * The synchronization state.
     */
    private volatile int state;

用一个简单的图来表示ReentrantLock锁state的CAS机制。

总结下state的CAS过程:

1)线程A首先调用getState()方法获取state在主内存中的状态,state默认值为0,并将该值保存在自己的工作内存中;

2)将工作内存中获取到的state值与传入的expect中的值比较;

3)如果相等则根据state的内存地址执行CAS原子操作;

4)将工作内存中的update值交换到主内存中state中,从而将state的值改为1,修改成功返回true,否则返回false。

CAS机制本质上是一种乐观锁机制,与synchronized不同的是CAS在更新共享数据的时候不会对共享数据阻塞式加锁,但是在正式更新数据之前会检查数据是否被其他线程改变过,如果未被其他线程改变过就将共享变量更新成最新值,如果发现共享变量已经被其他线程更新过了,就自旋重试(非阻塞式),直到成功为止。它的优点如下:

1)可以保证变量操作的原子性;
2)并发量不是很高的情况下,使用CAS机制比使用锁机制效率更高;
3)在线程对共享资源占用时间较短的情况下,使用CAS机制效率也会较高。

但是CAS机制也有以下缺点:

1) ABA问题。就是一个共享值本来是A,被改成了B,又变回了A,这个时候使用CAS是检查不出变化的,但实际上却被更新了两次。举个例子,以ReentrantLock锁state为例,例如线程A获取到的state的值本身为0,但是在cmpare阶段另一个线程B将state值由0改为1,释放后又改为了0,这个时候线程A是检查不出来state已经被改过的。解决这个问题的一种常用思路就是加入版本号,这样线程在compare阶段就可以感知到是否有其它线程更改了这个共享值。

2)多线程场景可能存在循环时间长开销大的问题。CAS从阻塞机制变成了非阻塞机制,虽然减少了线程之间等待的时间。但如果多线程之间竞争程度大的时候​,自旋CAS长时间不成功,会占用大量的CPU资源。一种解决思路是通过JVM支持处理器提供的「pause指令」, 能让自旋失败时cpu睡眠一小段时间再继续自旋,从而使得读操作的频率低很多,另一种思路是如果线程之间竞争非常大,建议使用锁的方式而非CAS。

3)只能保证一个共享变量的原子操作。Java中的CAS机制只能保证共享变量操作的原子性,而不能保证代码块的原子性。如果想要实现多个变量的原子操作可以使用java.util.

concurrent.atomic 包下的AtomicReference,或者直接使用同步锁机制来保证。

2.4 ReentrantLock的AQS核心acquire入队流程

线程在拿锁失败之后会调用AQS中的acquire方法进行入队操作,入队之前会调用tryAcquire尝试再次拿锁,如果还是失败则调用acquire加入队列。

    public final void acquire(int arg) {
        //在第一次拿锁失败之后会调用tryAcquire再次拿锁
        if (!tryAcquire(arg))
            //如果还是失败则调用acquire加入队列
            acquire(null, arg, false, false, false, 0L);
    }

在理解acquire入队方法前,我们有必要先了解它的参数,参数解析如下

    /**
     * Main acquire method, invoked by all exported acquire methods.
     *
     * @param node null unless a reacquiring Condition --node节点
     * @param arg the acquire argument   --获取锁次数,为1表示已经有线程拿到了锁
     * @param shared true if shared mode else exclusive --true为共享模式,false为独占模式
     * @param interruptible if abort and return negative on interrupt  --是否可中断线程
     * @param timed if true use timed waits  --线程阻塞时间
     * @param time if timed, the System.nanoTime value to timeout  --中断超时时间
     * @return positive if acquired, 0 if timed out, negative if interrupted --入队则返回positive,中断则返回negative,超时返回0
     */
    final int acquire(Node node, int arg, boolean shared,
                      boolean interruptible, boolean timed, long time) {
   ... }

AQS会将线程封装成一个CLH队列中的虚拟节点,即Node,我们先看下Node的数据结构,发现它其实就是一个双向链表结构。这种结构的特点是每个数据节点都有两个指针,分别指向该节点的后继节点和前驱节点,该队列是先进先出队列(FIFO队列),其最主要的作用是存储等待的线程,AQS最主要的功能也就是维护这个队列。我们看下Node的源码,其含义我用注释详细进行标注,相信大家很容易看懂~

    /** CLH Nodes */
    abstract static class Node {
        volatile Node prev;       //前置指针,指向前驱节点
        volatile Node next;       //后置指针,指向后置节点
        Thread waiter;            //队列中的等待线程数量
        volatile int status;      //节点(线程)的状态,有3个值分别为WAITING、CANCELLED、COND,注意区别于CAS中锁的状态值state

        // methods for atomic operations
        //casPrev的作用是原子性地将this对象的prev成员变量从c修改为v(可清理prev节点)
        //weakCompareAndSetReference相比CAS更加轻量级,但不保证有序性
        final boolean casPrev(Node c, Node v) {  // for cleanQueue
            return U.weakCompareAndSetReference(this, PREV, c, v);
        }
        //作用类似于casPrev
        final boolean casNext(Node c, Node v) {  // for cleanQueue
            return U.weakCompareAndSetReference(this, NEXT, c, v);
        }
        //原子操作,用于信号量的实现,当一个线程释放了一个信号量时,它需要将信号量的状态设置为未被占用,这个方法的作用就是将this对象的status成员变量按位与~v 的结果返回
        final int getAndUnsetStatus(int v) {     // for signalling
            return U.getAndBitwiseAndInt(this, STATUS, ~v);
        }
        //用于在队列之外分配节点时将当前节点的prev指向指定的节点p
        final void setPrevRelaxed(Node p) {      // for off-queue assignment
            U.putReference(this, PREV, p);
        }
        //用于在队列之外分配节点时修改节点状态,将节点状态设置为s
        final void setStatusRelaxed(int s) {     // for off-queue assignment
            U.putInt(this, STATUS, s);
        }
        //将当前节点状态清0,上面提到STATUS有3种状态
        final void clearStatus() {               // for reducing unneeded signals
            U.putIntOpaque(this, STATUS, 0);
        }
        
        //以下三个静态变量分别代表status、next和prev的偏移量,通过偏移量
        //可直接访问其具体的值
        private static final long STATUS
            = U.objectFieldOffset(Node.class, "status");
        private static final long NEXT
            = U.objectFieldOffset(Node.class, "next");
        private static final long PREV
            = U.objectFieldOffset(Node.class, "prev");
    }

我们用图直观的表示下内存中CLH队列的数据结构,其中pre,status,next分别通过其偏移量PREV,STATUS,NEXT访问。

这里留一个问题大家思考下,有小伙伴可能注意到代码中使用weakCompareAndSetReference,看它的源码最终也是返回compareAndSetReference来实现原子操作,那这里为啥用它而不直接用CAS?知道的小伙伴欢迎在评论区留言~

@IntrinsicCandidate
public final boolean weakCompareAndSetReference(Object o, long offset,
                                                Object expected,
                                                Object x) {
    return compareAndSetReference(o, offset, expected, x);
}

梳理完CLH队列数据结构后,我们再来看下整个AQS中的数据结构是什么样的?除了前面提到的Node外,还有2个volatile修饰的Node类型节点head和tail,这2个节点跟等待队列的入队出队相关,后面再细讲,我们可以把AQS队列看作CLH队列的扩展。

    /**
     * Head of the wait queue, lazily initialized.
     */
    private transient volatile Node head;

    /**
     * Tail of the wait queue. After initialization, modified only via casTail.
     */
    private transient volatile Node tail;

    /**
     * The synchronization state.
     */
    private volatile int state;

我们再来看下AQS的核心acquire方法做了什么?下面的代码我会用注释进行标识

    /*
     * @param node          node节点 
     * @param arg           加锁次数
     * @param shared        是否是共享线程队列,true为共享模式,false为独占模式
     * @param interruptible 是否是可中断线程
     * @param timed         线程阻塞时间
     * @param time          中断超时时间
     */
 final int acquire(Node node, int arg, boolean shared,
                      boolean interruptible, boolean timed, long time) {
        Thread current = Thread.currentThread();   //获取当前线程
        byte spins = 0, postSpins = 0;   //自旋变量及之前的自旋变量,自旋重试直到唤醒线程
        boolean interrupted = false, first = false; //中断变量值interrupted,first表示第一次进入acquire方法
        Node pred = null;      //存储该线程节点的前置节点

        /*
         * 这里有一段注释解释AQS队列逻辑:
         *  首先检查节点是否为first
         *    if so, ensure head stable, else ensure valid predecessor
         *  if node is first or not yet enqueued, try acquiring
         *  else if node not yet created, create it
         *  else if not yet enqueued, try once to enqueue
         *  else if woken from park, retry (up to postSpins times)
         *  else if WAITING status not set, set and retry
         *  else park and clear WAITING status, and check cancellation
         */
        //循环自旋拿锁
        for (;;) {
            if (!first && (pred = (node == null) ? null : node.prev) != null &&
                !(first = (head == pred))) {
                if (pred.status < 0) {
                    cleanQueue();           // predecessor cancelled
                    continue;
                } else if (pred.prev == null) {
                    Thread.onSpinWait();    // ensure serialization
                    continue;
                }
            }
            if (first || pred == null) {
                boolean acquired;
                try {
                    if (shared)
                        acquired = (tryAcquireShared(arg) >= 0);
                    else
                        acquired = tryAcquire(arg);
                } catch (Throwable ex) {
                    cancelAcquire(node, interrupted, false);
                    throw ex;
                }
                if (acquired) {
                    if (first) {
                        node.prev = null;
                        head = node;
                        pred.next = null;
                        node.waiter = null;
                        if (shared)
                            signalNextIfShared(node);
                        if (interrupted)
                            current.interrupt();
                    }
                    return 1;
                }
            }
            if (node == null) {                 // allocate; retry before enqueue
                if (shared)
                    node = new SharedNode();
                else
                    node = new ExclusiveNode();
            } else if (pred == null) {          // try to enqueue
                node.waiter = current;
                Node t = tail;
                node.setPrevRelaxed(t);         // avoid unnecessary fence
                if (t == null)
                    tryInitializeHead();
                else if (!casTail(t, node))
                    node.setPrevRelaxed(null);  // back out
                else
                    t.next = node;
            } else if (first && spins != 0) {
                --spins;                        // reduce unfairness on rewaits
                Thread.onSpinWait();
            } else if (node.status == 0) {
                node.status = WAITING;          // enable signal and recheck
            } else {
                long nanos;
                spins = postSpins = (byte)((postSpins << 1) | 1);
                if (!timed)
                    LockSupport.park(this);
                else if ((nanos = time - System.nanoTime()) > 0L)
                    LockSupport.parkNanos(this, nanos);
                else
                    break;
                node.clearStatus();
                if ((interrupted |= Thread.interrupted()) && interruptible)
                    break;
            }
        }
        return cancelAcquire(node, interrupted, interruptible);
    }

为什么需要可中断锁?

不可中断锁的问题是,当出现“异常”时,只能一直阻塞等待,别无其他办法,比如下面这个程序。下面的这个程序中有两个线程,其中线程 1 先获取到锁资源执行相应代码,而线程 2 在 0.5s 之后开始尝试获取锁资源,但线程 1 执行时忘记释放锁了,这就造成线程 2 一直阻塞等待的情况,实现代码如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class InterruptiblyExample {
    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

        // 创建线程 1
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                lock.lock();
                System.out.println("线程 1:获取到锁.");
                // 线程 1 未释放锁
            }
        });
        t1.start();

        // 创建线程 2
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 先休眠 0.5s,让线程 1 先执行
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 获取锁
                System.out.println("线程 2:等待获取锁.");
                lock.lock();
                try {
                    System.out.println("线程 2:获取锁成功.");
                } finally {
                    lock.unlock();
                }
            }
        });
        t2.start();
    }
}

以上代码执行的结果如下:

  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值