Java 并发之 ReentrantLock 深入分析(与Synchronized区别)

前言

线程并发系列文章:

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子类的类如下:


Java 并发之 ReentrantLock 深入分析(与Synchronized区别)_第1张图片
image.png

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

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

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

1、ReentrantLock 实现非公平锁

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

Java 并发之 ReentrantLock 深入分析(与Synchronized区别)_第2张图片
image.png

非公平锁的获取

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

public class ReentrantLock implements Lock, java.io.Serializable {}
//Lock 接口里声明了获取锁、释放锁等接口。
Java 并发之 ReentrantLock 深入分析(与Synchronized区别)_第3张图片
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步骤都没有获取锁,则表示获取锁失败。

用图表示流程如下:


Java 并发之 ReentrantLock 深入分析(与Synchronized区别)_第4张图片
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有一定的了解,再次来看看同步队列:

Java 并发之 ReentrantLock 深入分析(与Synchronized区别)_第5张图片
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,说明同步队列正在初始化,进一步说明有节点正在准备入队,此时整体判断就是:同步队列里有节点在等待。
于是,通过上述三个条件就可以判断同步队列里是否有节点在等待。
可以看出,公平锁的公平之处在于:

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

公平锁获取锁流程如下:


Java 并发之 ReentrantLock 深入分析(与Synchronized区别)_第6张图片
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

1、Android各种Context的前世今生
2、Android DecorView 一窥全貌(上)
3、Android DecorView 一窥全貌(下)
4、Window/WindowManager 不可不知之事
5、View Measure/Layout/Draw 真明白了
6、Android事件分发全套服务
7、Android invalidate/postInvalidate/requestLayout 彻底厘清
8、Android Window 如何确定大小/onMeasure()多次执行原因
9、Android事件驱动Handler-Message-Looper解析
10、Android 键盘一招搞定
11、Android 各种坐标彻底明了
12、Android Activity/Window/View 的background
13、Android IPC 之Service 还可以这么理解
14、Android IPC 之Binder基础
15、Android IPC 之Binder应用
16、Android IPC 之AIDL应用(上)
17、Android IPC 之AIDL应用(下)
18、Android IPC 之Messenger 原理及应用
19、Android IPC 之获取服务(IBinder)
20、Android 存储基础
21、Android 10、11 存储完全适配(上)
22、Android 10、11 存储完全适配(下)
23、Java 并发系列不再疑惑

你可能感兴趣的:(Java 并发之 ReentrantLock 深入分析(与Synchronized区别))