并发编程——ReentrantLock

ReentrantLock

在这里插入图片描述
AbstractQueuedSynchronizer(一般简称AQS)是并发编程中的一个核心类,多线程的锁管理也主要在这个类中得到完美实现。Sync作为ReentrantLock的内部类,继承了AbstractQueuedSynchronizer。

说明:

  1. 为了精简代码,能够简化的地方都是用了Java8的Lamda表达式进行了简化。
    2.对于try-catch需要捕获的异常,使用lombok插件的@SneakyThrows进行处理。
    3.代码中使用的注释为行尾注释,属于不优雅的注释,阿里代码规范插件会检测这种“不文明现象”,并非是我懒得调整,而是注释均是从本人自己构建的JDK8源码的阅读环境中添加的注释,如果改变了代码的行结构,会导致Java

什么是可重入

ReentrantLock从单词的命名上,意为可重入锁,那么可重入的意思是什么呢?

public class App {
    private static ReentrantLock lock = new ReentrantLock();
    public static void main( String[] args ) {
        Thread t1 = new Thread(App::testReentreant,"t1");
        Thread t2 = new Thread(App::testReentreant,"t2");
        Thread t3 = new Thread(App::testReentreant,"t3");
        t1.start();
        t2.start();
        t3.start();
    }
    
    @SneakyThrows
    private static void testReentreant() {
        try {
            System.out.println(Thread.currentThread().getName() + 1);
            lock.lock();
            System.out.println(Thread.currentThread().getName() + 2);
            lock.lock();
            // Thread.sleep(5000);
        }finally{
            int count = lock.getHoldCount();
            while(count > 0){
                lock.unlock();
                --count;
            }
        }
    }
}

代码挺简单,就是创建三个线程去获取锁,分别命名为t1,t2,t3,在testReentreant方法中,还没有释放锁的情况下,再次去执行lock.lock()方法,不会造成死锁或者报错,这就是可重入的定义。

另外,synchronized关键字控制的锁也是属于可重锁

public class SychronizedTest {
    public static void main(String[] args) {
        Thread t1 = new Thread(SychronizedTest::testSychronized);
        Thread t2 = new Thread(SychronizedTest::testSychronized);
        t1.start();
        t2.start();
    }

    private static void testSychronized() {
        synchronized (SychronizedTest.class) {
            System.out.println(Thread.currentThread().getName() + "-----1");
            synchronized (SychronizedTest.class) {
                System.out.println(Thread.currentThread().getName() + "-----2");
            }
        }
    }
}

是可以正常运行的。言归正传继续回到主题上来。

原理概览

留意上边代码的关于锁释放部分:

try{
	...
}finally{
   int count = lock.getHoldCount();
   while(count > 0){
       lock.unlock();
       --count;
   }
}

第一点,unlock要放在finally里面,因为finally是必定会执行的,用来保证哪怕是抛出异常后,锁也一定会被释放,如果idea中使用了阿里代码规约的插件,同样也会要求这样做。另外,既然锁支持重入,那么如何区分锁的重入次数呢,必然是计数器,ReentrantLock中对锁的重入进行了次数的计数。
在这里插入图片描述
初始值为0,当第一次有线程获得锁之后,会对state进行+1操作。采用volatile进行修饰,解决了指令重排的问题。

下边这段代码的目的是为了完全释放锁,因为所可能被多次重入,因此要判断锁的重入次数来对应释放的次数。

int count = lock.getHoldCount();
while(count > 0){
    lock.unlock();
    --count;
}

ReentrantLock的类型

ReentrantLock的构造方法有两个,分别是无参构造和一个带有boolean值得构造:

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

注意到NonfairSyncFairSync,上边说Sync是继承与AQS的一个类,同时也是ReentrantLock的内部类,从类图中可以看到Sync中包含了两个子类刚好对对应了NonfairSyncFairSync。这就是ReentrantLock的公平锁和非公平锁的实现。

避免再次回到顶部去看图,此处冗余粘贴。


因为默认实现是非公平锁,因此先从非公平锁进行切入。

非公平锁

本次探索,笔者使用Demo进行猜测和Debug验证,关于Idea中使用Debug,我会另写一篇文章详细总结我使用到的比较适用的功能,虽然可能不华丽,但是一定会很实用,届时会附上传送门。

public class App {
    private static ReentrantLock lock = new ReentrantLock();
    public static void main( String[] args ) {
        Thread t1 = new Thread(App::testReentreant,"t1");
        Thread t2 = new Thread(App::testReentreant,"t2");
        Thread t3 = new Thread(App::testReentreant,"t3");
        t1.start();
        t2.start();
        t3.start();
    }

    @SneakyThrows
    private static void testReentreant() {
        try {
            System.out.println(Thread.currentThread().getName() + 1);
            lock.lock();
            // System.out.println(Thread.currentThread().getName() + 2);
            // lock.lock();
            // Thread.sleep(5000);
        }finally{
            int count = lock.getHoldCount();
            while(count > 0){
                lock.unlock();
                --count;
            }
        }
    }
}

还是之前的例子,注释掉了重入部分。

通过Debug,我们设置线程抢到锁的顺序为t1->t2->t3,并从抢占锁释放锁两个方向去探索。

抢占锁

在这里插入图片描述
源码中的代码相对较多而且较为复杂,我按照蓝色序号进行分析系,只分析关键部分代码。

① t1获取到锁

线程进入到ReentrantLock后立即开始执行CAS操作来设置state,如果设置成功,则成功获得锁。获得锁之后,将当前锁的独占线程设置为当前线程此处为t1

CAS:参数分别为:
int expect:代表当前的数,假设为0
int update:代表我需要数,假设为1
类似于SQL中的:
update ReentrantLock set state = 1 where state = 0;这个方法调用了Unsafe类的方法,采用C语言来实现,能够保证方法执行的原子性,而且只有一个线程能够执行成功。

compareAndSetState(0, 1)
final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

源码中经常会出现if语句不添加大括号的情况,这是很不优雅的。

因为t1是第一个线程,因此成功获得了锁,并将state设置为1
线程t1获取到锁后,线程t2也执行到了CAS操作,理所应当t2执行CAS未成功,因此往else方向执行acquire(1)方法。

②t2获取锁失败
public final void acquire(int arg) {
    if (!tryAcquire(arg) && 
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

留意if语句中的两个条件都是函数,而且使用了&&进行连接,因此只有tryAcquire方法返回false的时候,acquireQueued(addWaiter(Node.EXCLUSIVE), arg)才会执行,tryAcquire方法实现的功能就是抢占锁,调用了内部类Sync的nonfairTryAcquire方法:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();// 获取当前的state值
    if (c == 0) {// 侥幸情况下,如果正好在重试获取锁的时候,刚好上个线程释放了锁,则可以获取到锁(非公平的特点)
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);// 如果在重试的时候获取锁成功了,就设置独占
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {// 如果当前线程就是这把锁的持有者
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);// 执行state+1(state就是重入次数)
        return true;
    }
    return false;// 此处对应上个线程还没有释放锁,侥幸方式获取锁失败
}

大部分逻辑可以从注释中理解,主要逻辑就是:
判断state的值是不是0,如果是0,则说明锁被释放了,那么我就执行CAS,如果执行成功旧设置为独占;
否则判断自身是不是本来就有这把锁,如果是自己的锁,就执行重入——state+1。
如果也不是自己持有的锁,那就返回false.

③将t2放入到队列中去

当上边返回false的时候,acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法就可以被执行。首先addWaiter(Node.EXCLUSIVE)会将t2放入到阻塞队列中去。

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);// 将当前线程封装成一个Node
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {// 如果尾结点不是null
        node.prev = pred;// 将节点放在尾结点后边
        if (compareAndSetTail(pred, node)) {// 把当前节点设置为尾结点
            pred.next = node;
            return node;
        }
    }
    enq(node);// 如果尾结点为空,则需要初始化链表
    return node;
}

t2是第一个放入队列中的节点,此时队列还不存在,因此会执行enq(node)方法,enq方法的主要功能就是如果队列存在,就将当前线程的节点从尾部插入,否则会初始化队列:创建一个空的节点作为头节点,将当前线程(t2)插入尾部。

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize 如果尾结点是null
            if (compareAndSetHead(new Node()))// 尝试new一个新节点设置为头节点(如果头节点为null的话)
                tail = head;// 如果设置头节点成功了,说明现在是一个初始化阶段,因此头节点也是尾结点(这段代码没有return)
        } else {// 如果尾结点不为null,将当前节点作为尾结点插入
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

这部分执行完毕后,代码回到

public final void acquire(int arg) {
    if (!tryAcquire(arg) && 
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
④t2自旋获取锁

开始执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)),从方法名看出意思为在已经在队列中的情况下再去获取锁

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) { // 自旋
            final Node p = node.predecessor();// 获取上一个节点
            if (p == head && tryAcquire(arg)) {// 自旋的时候再次尝试去获取锁,tryAcquire
                setHead(node);// 获取成功
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&// 获取失败,为了节省开销,不再获取锁
                parkAndCheckInterrupt())// 获取锁失败,则执行LockSupport.park(this)进行阻塞
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

其中for (;;)不带循环条件表示无条件循环,需要在方法体内实现跳出循环,否则会无限循环下去。这部分主要为两个if判断。

第二个if是实现线程的阻塞,表示在多次获取锁都没获取成功,说明上一个线程还在使用,为了节省性能开支,暂时不去获取锁了。第一个if主要是去获取锁,tryAcquire(arg)方法上边已经介绍过,是获取锁的逻辑;

第一个if主要是去获取锁,tryAcquire(arg)方法上边已经介绍过;而上一行代码是一个关键的地方。final Node p = node.predecessor()

final Node predecessor() throws NullPointerException {
    Node p = prev;
    if (p == null)
        throw new NullPointerException();
    else
        return p;
}

可以看到返回的是上一个节点,这里想必大家都会有疑问,为什么不是使用当前线程的节点取抢占锁呢,其实只要看拿到这个节点后,用来做什么,这样就能明白了。if (p == head && tryAcquire(arg)),和上边一样,是用&&连接的,也就是说只有当p是头节点的时候,才会取获取锁回顾这个tryAcquire(arg)方法是调用了Sync的nonfairTryAcquire方法,方法中有这么一段:final Thread current = Thread.currentThread(),就是获取锁成功了之后,设置的独占线程还是当前线程(t2),所以至于你用谁去获取锁都无所谓,只要最后独占线程还是当前线程就好了。但是必须明确一个点:如果当前线程的上一个节点不是头节点,是不会去获取锁的,也就是说只有第二个节点的线程才能有权利拿到锁。

⑤t1上尉释放锁,t2进入阻塞

截止目前,t1还没有执行释放锁的操作,因此t2获取锁也失败,因此进入了阻塞状态。if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())。(放在if语句中,判断结束,方法也执行结束了)其中parkAndCheckInterrupt()是调用了LockSupport.park(this)来完成阻塞。

t2进入阻塞状态后,让t3执行,逻辑基本和t2一致无法获取锁,最后进入队列进行阻塞。只有当t1释放了锁之后,t2才有机会去抢占锁,t3是不能够抢占锁的。

此时队列的结构是这样的:
在这里插入图片描述
头节点是一个new出来的节点,next指向t2,t2的next执行t3.

释放锁
t1释放锁并唤醒t2
sync.release(1);

执行释放锁,且传入的参数为1。

public final boolean release(int arg) {
    if (tryRelease(arg)) {// 尝试释放锁
        Node h = head;// 如果锁完全释放了,则可以唤醒队列中的线程去抢占锁
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;// 代表未完全释放锁
}

依然是执行if中的tryRelease(arg)方法。

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;//每次执行unlock只会释放一次,因此重入需要释放多次
    if (Thread.currentThread() != getExclusiveOwnerThread())// 判断当前线程是否是占有该锁的线程
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {// 判断c=0则是完全释放了否则还没有完全释放
        free = true;
        setExclusiveOwnerThread(null);// 如果完全释放了就将独占的线程设置为null
    }
    setState(c);// 将新的state赋值给state
    return free;
}

因为传入的参数是1,因此每次都是对state执行减1,如果减到了0,则设置独占线程为null,返回true,否则返回false。

if (tryRelease(arg)) {
    Node h = head;
    if (h != null && h.waitStatus != 0)
        unparkSuccessor(h);
    return true;
}

tryRelease返回true的时候,锁就已经完全释放了,这段代码就会执行,当h.waitStatus != 0判断为true的时候,执行unparkSuccessor(h)。那么h.waitStatus代表什么呢?

       /**
         * Status field, taking on only the values:
         *   SIGNAL:     The successor of this node is (or will soon be)
         *               blocked (via park), so the current node must
         *               unpark its successor when it releases or
         *               cancels. To avoid races, acquire methods must
         *               first indicate they need a signal,
         *               then retry the atomic acquire, and then,
         *               on failure, block.
         *   CANCELLED:  This node is cancelled due to timeout or interrupt.
         *               Nodes never leave this state. In particular,
         *               a thread with cancelled node never again blocks.
         *   CONDITION:  This node is currently on a condition queue.
         *               It will not be used as a sync queue node
         *               until transferred, at which time the status
         *               will be set to 0. (Use of this value here has
         *               nothing to do with the other uses of the
         *               field, but simplifies mechanics.)
         *   PROPAGATE:  A releaseShared should be propagated to other
         *               nodes. This is set (for head node only) in
         *               doReleaseShared to ensure propagation
         *               continues, even if other operations have
         *               since intervened.
         *   0:          None of the above
         *
         * The values are arranged numerically to simplify use.
         * Non-negative values mean that a node doesn't need to
         * signal. So, most code doesn't need to check for particular
         * values, just for sign.
         *
         * The field is initialized to 0 for normal sync nodes, and
         * CONDITION for condition nodes.  It is modified using CAS
         * (or when possible, unconditional volatile writes).
         */

这是JDK对于h.waitStatus的注释说明:
SIGNAL: 这个节点的后继节点被(或即将)阻塞(通过park),所以当前节点在释放或取消时必须取消其后继节点的park。为了避免竞争,acquire方法必须首先表明它们需要一个信号,然后重试原子获取,然后在失败时阻塞。
CANCELLED: 由于超时或中断,该节点被取消。节点永远不会离开这个状态。带有取消节点的线程不会再阻塞。
CONDITION: 此节点当前位于条件队列中。在传输之前,它不会被用作同步队列节点,此时状态将被设置为0。(此值的使用与领域的其他用途无关,但简化了机制。)。
PROPAGATE: releaseShared应该传播到其他节点。这是在doreleasshared中设置的(仅针对头部节点),以确保传播继续,即使其他操作已经介入。
0: 除上述之外。

回到上边代码,当waitStatus不为0的时候,就会执行unparkSuccessor(h)方法,唤醒线程(唤醒的是当前节点的下一个节点的线程t2)。

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;// 当锁完全释放之后,该值会变为-1
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);// 将watiStatus从-1修改为0
    Node s = node.next;// 获取当前节点的下一个节点
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);//执行下一个节点的线程唤醒
}
t2唤醒t3

唤醒t2后,t1执行exit退出了,此时t2执行自旋获取锁,获取锁完成后将自己节点设置为头节点,同时删除thread属性和prev指针,然后将原来的头节点指向null,便于GC回收。

setHead(node);
p.next = null; // help GC
private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

那么为什么不直接将原来的head指向t3的节点呢?如果按这样,那么需要操作的步骤如下:

在这里插入图片描述

如果是直接直接删除head,需要操作的步骤如下:

在这里插入图片描述

很明显直接删除head会更省事。

此时的节点信息变成这样:

在这里插入图片描述
之后的释放逻辑变重复之前的步骤即可。

公平锁

公平锁的实现大部分和非公平锁一致,较为明显的差异是公平锁不会进行插队,而是老老实实进行排队,只会在队列无线程在等待的时候,才会取执行CAS操作去设置state

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {// state=0表示当前无锁,可以直接CAS,更改状态并设置独占
        if (!hasQueuedPredecessors() && // 只有当前不存在等待线程的时候才会取CAS替换
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {// 如果当前线程和独占线程(即重入)
        int nextc = c + acquires;// state+1
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}
}

单独看这段代码的对比:
公平锁:

if (!hasQueuedPredecessors() && // 只有当前不存在等待线程的时候才会取CAS替换
    compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(current);
    return true;
}

非公平锁:

if (compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(current);// 如果在重试的时候获取锁成功了,就设置独占
    return true;
}

区别就在!hasQueuedPredecessors() &&,公平锁会进行公平排队,只有头节点的下一个节点才能获取锁。
在这里插入图片描述

总结

非公平锁会有三次机会获取锁,公平锁在第一次获取锁时有两次机会获取锁,后续节点只有第二个线程可以有一次机会获取锁。释放锁的时候都是唤醒头节点的下一个节点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值