Android技术知识Android开发经验谈Android开发

Java 并发之 ReentrantLock 深入分析(与Syn

2021-10-21  本文已影响0人  小鱼人爱编程

前言

线程并发系列文章:

Java 线程基础
Java 线程状态
Java “优雅”地中断线程-实践篇
Java “优雅”地中断线程-原理篇
真正理解Java Volatile的妙用
Java ThreadLocal你之前了解的可能有误
Java Unsafe/CAS/LockSupport 应用与原理
Java 并发"锁"的本质(一步步实现锁)
Java Synchronized实现互斥之应用与源码初探
Java 对象头分析与使用(Synchronized相关)
Java Synchronized 偏向锁/轻量级锁/重量级锁的演变过程
Java Synchronized 重量级锁原理深入剖析上(互斥篇)
Java Synchronized 重量级锁原理深入剖析下(同步篇)
Java并发之 AQS 深入解析(上)
Java并发之 AQS 深入解析(下)
Java Thread.sleep/Thread.join/Thread.yield/Object.wait/Condition.await 详解
Java 并发之 ReentrantLock 深入分析(与Synchronized区别)
Java 并发之 ReentrantReadWriteLock 深入分析
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(原理篇)
Java Semaphore/CountDownLatch/CyclicBarrier 深入解析(应用篇)
最详细的图文解析Java各种锁(终极篇)
线程池必懂系列

前面两篇文章分析了AQS实现的核心功能,如独占锁、共享锁、可中断锁,条件等待等。而AQS是抽象类,需要子类实现,接下来几篇将重点分析这些子类实现的功能,常见的封装AQS子类的类如下:


image.png

注:以上这些类并不是直接继承自AQS,而是内部持有AQS的子类实例,通过AQS的子类实现具体的功能

通过本篇文章,你将了解到:

1、ReentrantLock 实现非公平锁
2、ReentrantLock 实现公平锁
3、ReentrantLock 实现可中断锁
4、ReentrantLock tryLock 原理
5、ReentrantLock 等待/通知
6、ReentrantLock 与synchronized 异同点

1、ReentrantLock 实现非公平锁

之前提到过,虽然AQS实现了很多功能,但是具体的获取锁、释放锁是由子类来实现的,也就是说只有子类能够决定:"什么才是获取锁,怎么获取锁?什么才是释放锁,怎么释放锁?继承自AQS的子类需要实现如下方法:

image.png

非公平锁的获取

先思考一下什么是非公平?在AQS分析里提到过:获取锁失败的线程会被加入到同步队列的队尾,如果线程A刚好释放了锁,而此时线程B也要争取锁,若是竞争成功了就直接获取锁了,而在同步队列的线程虽然比线程B更早地排队了,但反而被线程B窃取了革命的果实,这对它们来说是不公平的。
来看看ReentrantLock 是如何实现非公平锁的,先看看ReentrantLock的定义:

public class ReentrantLock implements Lock, java.io.Serializable {}
//Lock 接口里声明了获取锁、释放锁等接口。
image.png

Sync/NonfairSync/FairSync是ReentrantLock里的静态内部类,Sync继承自AbstractQueuedSynchronizer,而NonfairSync、FairSync,继承自Sync。
ReentrantLock 非公平锁的构造:

    public ReentrantLock() {
        sync = new NonfairSync();
    }

可以看出,ReentrantLock 默认实现非公平锁。
获取非公平锁:

#ReentrantLock.java
        final void lock() {
            //设置state=1
            if (compareAndSetState(0, 1))
                //设置成功,记录获取锁的线程
                setExclusiveOwnerThread(Thread.currentThread());
            else
                //尝试修改失败后走到此,这是AQS里的方法
                acquire(1);
        }

此处再说明一下compareAndSetState(0,1),典型的CAS操作,尝试将state由0修改为1,若是发现state不是0,说明已经有线程修改了state,这个修改者可能是别的线程,也可能是自己,此时CAS失败。
继续来看acquire(xx):

#AbstractQueuedSynchronizer.java
    public final void acquire(int arg) {
        //由子类实现
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

其它方法在AQS里已经分析过,此处重点是分析tryAcquire(xx)。

#ReentrantLock.java
        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }

        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //获取同步状态
            int c = getState();
            if (c == 0) {
                //说明此时没人占有锁
                //尝试占用锁
                if (compareAndSetState(0, acquires)) {
                    //成功,则设置占有锁的线程为当前线程
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //c!=0,说明有线程占有锁了
            else if (current == getExclusiveOwnerThread()) {
                //若是占有锁的线程是当前线程,则判断是线程重入了锁
                //直接增加同步状态
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                //设置状态
                setState(nextc);
                return true;
            }

            //都不满足,则认为抢占锁失败
            return false;
        }

可以看出,非公平锁的获取锁的关键逻辑就在nonfairTryAcquire(xx)里:

1、先判断当前是否有线程占有锁,若没有,则尝试获取锁。
2、若已有线程占有锁,判断占有的线程是不是自己,若是则增加同步状态,表示是重入。
3、若1、2步骤都没有获取锁,则表示获取锁失败。

用图表示流程如下:


image.png

由图可知,非公平锁获取锁时:

一上来就开始抢占锁,失败后才开始判断是否有线程占有锁,没有人占有的话又开始抢占,这些抢占操作不成功才会进入同步队列阻塞等待别的线程释放锁。
这也是非公平的特点:不管是否有线程在排队等候锁,我就不排队,直接插队,实在不行才乖乖排队。

可以看出,独占锁的核心是:

谁能够成功将state从0修改为1,谁就能够获取锁。
换句话说,state>0,表示该锁已被占用。

非公平锁的释放

既然有lock过程,那么当然有unlock过程:

#ReentrantLock.java
    public void unlock() {
        sync.release(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;
    }

依然的,只分析tryRelease(xx):

#ReentrantLock.java
    protected final boolean tryRelease(int releases) {
            //已有的同步状态 - 待释放的同步状态
            int c = getState() - releases;
            //只有获取锁的线程才能释放锁
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                //释放后,同步状态为0,说明已经没有线程占有锁了
                free = true;
                //没有占有锁了,标记置null
                setExclusiveOwnerThread(null);
            }
            //对于独占锁来说,c!=0,说明是线程重入,还没释放完
            //设置释放后的同步状态值
            setState(c);
            return free;
        }

可以看出,非公平锁释放核心逻辑在tryRelease(xx)里:

将state值-1,若是最后state==0,说明已经完全释放锁了。
只有持有锁的线程才能修改state,因此修改state无需CAS。

2、ReentrantLock 实现公平锁

公平锁的获取

既然非公平锁的特点是插队,那么公平锁就需要老老实实排队,重点是如何判断队列里是否有线程等待。

#ReentrantLock.java
    static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            //没有尝试获取锁
            acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                //当前没有线程占有锁
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                //重入
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

当调用lock()时,并没有直接抢占锁,当判断锁没有被任何线程占有时,也没有立刻去抢占锁,而是先判断当前同步队列里是否有线程在排队等候锁:

#AbstractQueuedSynchronizer.java
    public final boolean hasQueuedPredecessors() {
        Node t = tail;
        Node h = head;
        Node s;
        //三个判断条件
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

该方法返回true,表示有(正在插入)节点在同步队列里等待。
理解上面的判断需要对AQS有一定的了解,再次来看看同步队列:


image.png

同步队列是由双向链表实现的,头节点不指向任何线程。
第一个条件:h != t
照理来说,h!=t 就能说明同步队列里至少有两个节点,为什么还需要后面的判断呢?
想象一种场景:线程A获取了锁,此时线程B想要获取锁但失败了,于是B就加入到了同步队列,此时同步队列里有两个节点:头节点和B线程节点(head即是头节点,尾节点指向B节点)。某个时刻,A释放了锁,并唤醒了B,B醒来后再去调用tryAcquire(xx)去获取锁(这整个逻辑是AQS里实现,和ReentrantLock没关系)。
而当B调用tryAcquire(xx)时会通过hasQueuedPredecessors(xx)判断是否有节点在同步队列里等待,此时h!=t,但是因为等待的节点是B自己,实际上B是不再需要再插入等待队列的。
因此仅仅是h!=t的判断是不够的,需要再判断等待的节点是否是当前节点本身。

第二个条件:s.thread != Thread.currentThread()
判断同步队列里的第一个等待(非头节点)的节点是否是当前节点本身,s 有可能为空,因此需要判空,于是有如下判断:

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

你可能已经发现了,源码里的判断是"||"而非"&&",也就是说若h.next==null,也可作为同步队列有节点等待的依据,这是基于什么场景考虑的呢?

第三个条件:(s = h.next) == null
理解这个问题需要考虑并发场景,先看看同步队列是如何初始化的:

#AbstractQueuedSynchronizer.java
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) {
                //当前队列为空,将头节点指向新的节点
                if (compareAndSetHead(new Node()))
                    //再将尾节点指向头节点
                    tail = head;
            } else {
                ...
            }
        }
    }

初始的时候头节点(head),尾节点(tail)都为null,此时头节点指向新的节点,但是还没来得及执行tail=head。这个时候hasQueuedPredecessors(xx)被另一个线程执行了,然后判断h!=t(h==Node,t==null),结果为true。
若是此时h.next==null,说明同步队列正在初始化,进一步说明有节点正在准备入队,此时整体判断就是:同步队列里有节点在等待。
于是,通过上述三个条件就可以判断同步队列里是否有节点在等待。
可以看出,公平锁的公平之处在于:

先判断有没有节点(线程)先于当前线程排队等候锁的,若有则当前线程需要排队等候。

公平锁获取锁流程如下:


image.png

公平锁的释放

与非公平锁的释放逻辑一致。

小结公平锁与非公平锁:

公平与非公平体现在获取锁时策略的不同:
1、公平锁每次都会检查队列是否有节点等待,若没有则抢占锁,否则就去排队等候。
2、非公平锁每次都会先去抢占锁,实在不行才排队。
3、公平锁、非公平锁在释放锁的逻辑上是一致的。

3、ReentrantLock 实现可中断锁

AQS 能够实现可中断锁与不可中断锁,ReentrantLock 只是借助了AQS完成了此功能:

#ReentrantLock.java
    public void lockInterruptibly() throws InterruptedException {
        //核心在AQS里实现
        sync.acquireInterruptibly(1);
    }

可中断用白话一点地说:

若是线程在同步队列里等待,外界调用了Thread.interrupt,结果就是被中断的线程被唤醒,放弃获取锁,并抛出中断异常。

4、ReentrantLock tryLock 原理

有些时候,我们并不想一上来就去获取锁,万一锁被别的线程占有了,那么当前线程就会阻塞住。也就是说仅仅想要尝试一次获取锁,若不成功则直接退出,不去排队,这个方法能满足需求:

#ReentrantLock.java
    public boolean tryLock() {
        return sync.nonfairTryAcquire(1);
    }

当然,排队也接受,只是需要限时,也就是说我就等待这么长时间,时间到了还是没获取锁,那么我就不再排队等候了,退出争抢锁的流程。

#ReentrantLock.java
    public boolean tryLock(long timeout, TimeUnit unit)
            throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
    }

5、ReentrantLock 等待/通知

等待/通知机制基于等待队列,这部分也是AQS实现的,ReentrantLock 封装了相应的接口。

#ReentrantLock.java
    public Condition newCondition() {
        return sync.newCondition();
    }

#AbstractQueuedSynchronizer.java
    final ConditionObject newCondition() {
            return new ConditionObject();
    }

实际上就是生成了ConditionObject对象,并操作这个对象。
ConditionObject 是AQS里的非静态内部类。
注:等待通知机制需要配合独占锁使用

public class TestThread {
    static ReentrantLock reentrantLock = new ReentrantLock();
    static Condition condition = reentrantLock.newCondition();
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    //子线程等待
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        
        Thread.sleep(2000);
        //主线程通知
        condition.notify();
    }
}

以上是利用了等待/通知 实现了简单的线程间同步。

6、ReentrantLock 与synchronized 异同点

分别分析了synchronized与ReentrantLock原理与应用,是时候来总结两者的异同点了。网上写这部分的文章很多,但是有些错误的点人云亦云,以讹传讹,导致广为传播,本次将全面对比两者以及深究其中原理。

相同点

基本数据结构

都包含:volatile + CAS + 同步队列 + 等待队列(等待/通知)。
这些数据结构是AQS 实现的,并非ReentrantLock.java 里实现的,只是为了方便比对,才这么写。

实现功能

1、都能实现独占锁功能。
2、都能实现非公平锁功能。
3、都能实现可重入锁功能。
4、都是悲观锁。
5、都能实现不可中断锁。

不同点

实现方式

1、synchronized 是关键字,ReentrantLock是类。
2、synchronized 由JVM实现(主要是C++),ReentrantLock 由Java代码实现。
3、synchronized 能修饰方法和代码块,ReentrantLock 只能修饰代码块。
4、synchronized 保护的方法/代码块发生异常能够自动释放锁,ReentrantLock 保护的代码块发生异常不会主动释放,因此需要在finally里主动释放锁。

提供给外界功能

1、ReentrantLock 能够实现公平锁,而synchronized 不能。
2、ReentrantLock 能够实现共享锁,而synchronized 不能。
3、ReentrantLock 能够实现可中断锁,而synchronized 不能。
4、ReentrantLock 能够实现限时等待锁,而synchronized 不能。
5、ReentrantLock 能够检测当前锁是否被占用,而synchronized 不能。
6、ReentrantLock 能够绑定多个条件,而synchronized 只能绑定一个条件。
7、ReentrantLock 能够获取同步队列、等待队列长度,而synchronized 不能。

性能区别
synchronized 在jdk1.6 以后增加了偏向锁、轻量级锁、锁消除、锁粗化等技术,大大优化了synchronized 性能。
现在没有明确的数据/理论表明 ReentrantLock 比synchronized 更快,官方也仅仅是推荐使用synchronized。

很多文章说:"synchronized 使用了mutex,陷入内核态,而ReentrantLock 使用CAS,是CPU的特殊指令云云",由此证明synchronized 更耗性能。
这种说法是有问题的,还记得我们说过jdk1.6之前synchronized 为啥是重量级锁的原因:

线程的挂起需要保存上下文,唤醒需要恢复回来,这过程耗费资源。

现在来对比一下synchronized、 ReentrantLock在高并发的场景下如何处理线程的挂起与唤醒的。
先说synchronized,当线程发现锁被占用时,处理逻辑如下:

1、将线程加入等待队列,并挂起线程。
2、挂起方式:ParkEvent.park(xx)--->NPTL.pthread_cond_wait(xx)/NPTL.pthread_cond_timedwait
NPTL是Linux glibc下实现的,用的是futex。

再说ReentrantLock,当线程发现锁被占用时,处理逻辑如下:

1、将线程加入等待队列,并挂起线程。

2、挂起方式:AQS--->LockSupport.park()--->Unsafe.park(xx)--->Parker.park(xx)--->NPTL.pthread_cond_wait(xx)/NPTL.pthread_cond_timedwait

由此可以看出,synchronized 与ReentrantLock 底层挂起线程实现方式是一致的。

接着来看所谓的:"ReentrantLock 使用CAS,而synchronized 使用底层xx东西"。
先说ReentrantLock,当抢占锁时使用CAS,CAS是一次性操作,也就是它只有两种结果:

要么成功,要么失败。
在ReentrantLock 或者AQS 里并没有一直循环使用CAS 抢占锁的实现方式,也就是说线程没有获取到锁,最终的结果还是被挂起,也即是调用上面分析的挂起方法。
CAS 调用栈:AQS.compareAndSetState(xx)--->Unsafe.compareAndSwapInt(xx)--->Atomic::cmpxchg(xx)
其中Atomic是原子操作类,也就是说cmpxchg(xx) 是原子函数,不可打断的。

而synchronized,当抢占锁时使用CAS,同样的CAS调用栈如下:

Atomic::cmpxchg_ptr(xx)
因为synchronized 本身就是C++实现的语义,因此直接调用了Atomic。

通过比对源码分析ReentrantLock 和 synchronized的CAS、线程挂起方式,发现两者底层实现是一致的。那么上面的言论就可以被证伪了。

两者使用场景

ReentrantLock 在JUC 下各种并发数据结构被广泛应用者,比如LinkedBlockingQueue、DelayQueue等。
当然synchronized也不甘示弱,比如StringBuffer、MessageQueue、jdk 1.8 之后的hashMap实现等都使用了synchronized。

可以看出,ReentrantLock 提供了更灵活、更细的控制锁的方式,而synchronized 操作更简单。
如果你想要某项功能,请查看上面的异同点,找出符合自己需求的锁

下篇将会分析ReentrantReadWriteLock 原理及其应用。

本文基于jdk1.8。

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android/Java

上一篇下一篇

猜你喜欢

热点阅读