前言
线程并发系列文章:
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子类的类如下:
注:以上这些类并不是直接继承自AQS,而是内部持有AQS的子类实例,通过AQS的子类实现具体的功能
通过本篇文章,你将了解到:
1、ReentrantLock 实现非公平锁
2、ReentrantLock 实现公平锁
3、ReentrantLock 实现可中断锁
4、ReentrantLock tryLock 原理
5、ReentrantLock 等待/通知
6、ReentrantLock 与synchronized 异同点
1、ReentrantLock 实现非公平锁
之前提到过,虽然AQS实现了很多功能,但是具体的获取锁、释放锁是由子类来实现的,也就是说只有子类能够决定:"什么才是获取锁,怎么获取锁?什么才是释放锁,怎么释放锁?继承自AQS的子类需要实现如下方法:
非公平锁的获取
先思考一下什么是非公平?在AQS分析里提到过:获取锁失败的线程会被加入到同步队列的队尾,如果线程A刚好释放了锁,而此时线程B也要争取锁,若是竞争成功了就直接获取锁了,而在同步队列的线程虽然比线程B更早地排队了,但反而被线程B窃取了革命的果实,这对它们来说是不公平的。
来看看ReentrantLock 是如何实现非公平锁的,先看看ReentrantLock的定义:
public class ReentrantLock implements Lock, java.io.Serializable {}
//Lock 接口里声明了获取锁、释放锁等接口。
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步骤都没有获取锁,则表示获取锁失败。
用图表示流程如下:
由图可知,非公平锁获取锁时:
一上来就开始抢占锁,失败后才开始判断是否有线程占有锁,没有人占有的话又开始抢占,这些抢占操作不成功才会进入同步队列阻塞等待别的线程释放锁。
这也是非公平的特点:不管是否有线程在排队等候锁,我就不排队,直接插队,实在不行才乖乖排队。
可以看出,独占锁的核心是:
谁能够成功将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有一定的了解,再次来看看同步队列:
同步队列是由双向链表实现的,头节点不指向任何线程。
第一个条件: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,说明同步队列正在初始化,进一步说明有节点正在准备入队,此时整体判断就是:同步队列里有节点在等待。
于是,通过上述三个条件就可以判断同步队列里是否有节点在等待。
可以看出,公平锁的公平之处在于:
先判断有没有节点(线程)先于当前线程排队等候锁的,若有则当前线程需要排队等候。
公平锁获取锁流程如下:
公平锁的释放
与非公平锁的释放逻辑一致。
小结公平锁与非公平锁:
公平与非公平体现在获取锁时策略的不同:
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 {
//子线程等待
reentrantLock.lock();
condition.await();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLock.unlock();
}
}
}).start();
Thread.sleep(2000);
//主线程通知
reentrantLock.lock();
condition.signal();
reentrantLock.unlock();
}
}
以上是利用了等待/通知 实现了简单的线程间同步。
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。