概述
Lock 在 J.U.C 中是最核心的组件,,锁最重要的特性就是解决并发安全问题。 为什么要以 Lock 作为切入点呢?如果看过 J.U.C 包中的所有组件,一定会发现绝大部分的组件都有用到了 Lock。 所以通过 Lock 作为切入点使得在后续的学习过程中会更加轻松。
在 Lock 接口出现之前, Java 中的应用程序对于多线程的并发安全处理只能基于synchronized 关键字来解决。但是 synchronized 在有些场景中会存在一些短板,也就是它并不适合于所有的并发场景。 但是在 Java5 以后, Lock 的出现可以解决synchronized 在某些场景中的短板,它比 synchronized 更加灵活。
Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法, 定义成接口就意味着它定义了锁的一个标准规范,也同时意味着锁的不同实现。 实现 Lock 接口的类有很多,以下为几个常见的锁实现:
- ReentrantLock:重入锁,他是唯一一个实现了Lock接口的实现类,重入锁指的是同一个线程再次获取同一把锁时,不需要阻塞,而是把重入次数加一即可。
- ReentrantReadWriteLock:重入读写锁,它实现了 ReadWriteLock 接口,在这个类中维护了两个锁,一个是 ReadLock,一个是 WriteLock,他们都分别实现了 Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
- StampedLock:stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。
Lock 有很多的锁的实现,但是直观的实现是 ReentrantLock 重入锁。
可重入锁的设计是为了避免死锁的出现,synchronized关键字加的同步锁也是可重入锁。
假设锁不可重入:有以下代码:
public class ZhanZhen {
private int num;
public synchronized void write(){
num = 5;
}
public synchronized void read(){
write();
System.out.println(num);
}
public static void main(String[] args) {
ZhanZhen zhanZhen = new ZhanZhen();
zhanZhen.read();
}
}
上面代码,read和write方法加的是同一把同步锁,就是当前的对象。如果锁不可重入的话,调用在main方法里面调用read方法,首先由read方法获取到了锁,然后在read方法调用write的方法,因为write方法也需要获取这把锁,但是该锁已经被read方法获取了,write方法要等到read方法释放锁才能
获取锁执行代码块,而read方法要等到write方法执行完成才能释放该锁,这就导致了死锁。
假设锁是可重入的,调用在main方法里面调用read方法,首先由read方法获取到了锁,然后read方法里面调用write方法,由于执行read和write方法是同一线程,并且需要的锁是同一把锁,所以,write方法无需获取锁,只需把重入次数加1,然后write方法执行完,把重入次数减一即可。
ReentrantLock
类图如下:
lock()方法:获得锁,成功就执行同步代码块,失败就阻塞线程。直到锁释放。
unlock()方法:释放锁。
lockInterruptibly() : 和lock()方法相似, 但阻塞的线程可中断,抛出java.lang.InterruptedException异常。
tryLock() // 非阻塞获取锁;尝试获取锁,如果成功返回 true,释放返回false。
tryLock(long timeout, TimeUnit timeUnit) :带有超时时间的获取锁方法,超时时间内阻塞,如果在这段时间获取锁成功,返回true,否则返回false。
ReentrantLock实现原理
我们知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串行执行,从而达到线程安全性的目的。 在 synchronized 中,使用了偏向锁、轻量级锁、乐观锁。基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销,同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争和同步的目的。那么在 ReentrantLock 中,也一定会存在这样的需要去解决的问题。 就是在多线程
竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?
AQS:在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它是一个同步工具也是 Lock 用来实现线程同步的核心组件。 如果搞懂了 AQS,那么 J.U.C 中绝大部分的工具都能轻松掌握。
从使用层面来说,AQS的功能分两种:独占和共享:
独占:每次只能有一个线程持有锁,比如ReentrantLock 就是以独占方式实现的互斥锁。
共享:允 许 多 个 线 程 同 时 获 取 锁 , 并 发 访 问 共 享 资 源 , 比 如ReentrantReadWriteLock。
AQS内部实现
AQS 队列内部维护的是一个 FIFO 的双向链表(要对双端链表有一个清晰的理解才行),链表的每个 Node 其实是由线程封装,当线程争抢锁失败后会封装成 Node 加入到 ASQ 队列中去; 当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点(线程)。
Node节点的组成:该节点是AbstractQueuedSynchronizer的成员内部类。
static final class Node {
//共享节点,类变量来的
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
//节点的状态CANCELLED
static final int CANCELLED = 1;
//节点的状态SIGNAL
static final int SIGNAL = -1;
//节点的状态CONDITION
static final int CONDITION = -2;
//节点的状态PROPAGATE
static final int PROPAGATE = -3;
//当前节点的等待状态
volatile int waitStatus;
//前驱节点
volatile Node prev;
//后驱节点
volatile Node next;
//节点封装的线程
volatile Thread thread;
//存储在condition队列中的后继节点
Node nextWaiter;
//是否为共享锁
final boolean isShared() {
return nextWaiter == SHARED;
}
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() {
}
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
通过ReentrantLock源码分析作为切入点,来看看AQS是如何实现线程同步的:以同步器的非公平锁为例子:
调用时序图入下:
- 从ReentrantLock的lock接口为入口:建议画图直观理解,画同步队列的变化情况。
public void lock() {
//该方法调用了同步器Sync的lock方法,而Sync继承了AQS(AbstractQueuedSynchronizer)
sync.lock();
}
2. 我们跟踪非公平同步器NonfairSync的lock方法。
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
//这个是AbstractQueuedSynchronizer的锁状态。
private volatile int state;
假设有3个线程竞争锁(假设当前锁处于空闲状态state=0),进来了上面的一个方法,进行第一个判断compareAndSetState,这个方法是使用cas乐观锁的方式来更新锁的状态(不懂cas要去补下功课),当锁的状态为0表示当前锁处于空闲状态,大于0表示已经有其他线程占用了该锁。
第一个线程进来了
调用了compareAndSetState方法,因为当前锁属于空闲状态,所以修改值会成功,返回true,第一个线程就进入了这个if分支,调用setExclusiveOwnerThread方法。
//这个方法很简单,就是把当前同步器的exclusiveOwnerThread 成员变量赋值为调用它的线程,
//exclusiveOwnerThread 表示的是当前是哪个线程独占这把锁。调用该方法表示当前线程获取到了锁。
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
第二个线程进来了:
调用了compareAndSetState方法,因为当前锁属于上锁状态(state!=1),所以修改值会失败,返回false,所以第二个线程就进入了这个else分支分支,调用acquire方法。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这个方法有四步:
调用tryAcquire、addWaiter、acquireQueued、selfInterrupt四个方法。
tryAcquire:
//这个是NonfairSync的tryAcquire方法
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
//此时acquires=1
//获取当前线程
final Thread current = Thread.currentThread();
//获取当前同步锁的锁状态
int c = getState();
if (c == 0) {
//c == 0表示此时锁处于空闲状态,原因是当线程2走到这里之前,线程1已经释放了锁。
//这里再次尝试获得锁,因为可能存在线程持有锁时间很短的情况,假如此时线程1已经释放
//了锁,此时再次尝试获得锁就可能会成功,这就避免了把线程阻塞进入同步队列这种情况
//是对性能提高做的一个小技巧。
if (compareAndSetState(0, acquires)) {
//跟前面那个 获取锁成功
setExclusiveOwnerThread(current);
//返回true表示获取锁成功
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
//getExclusiveOwnerThread方法是获取当前占用锁的线程,如果跟当前线程是同一线程,就无需再次获取锁,而是把重入次数加一。
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
//设置状态,state记录的就是重入次数,次数为0代表锁空闲。
setState(nextc);
//返回true表示获取锁成功
return true;
}
//返回false表示获取锁失败
return false;
//该方法逻辑总结就是尝试再次使用cas获取锁或者添加重入次数而获取锁成功,如果这两个都不成功,就获取失败。
}
如果tryAcquire返回false,表示获取锁失败,就会执行addWaiter方法。
//这个方法就是把线程封装成Node添加进双端队列中。
private Node addWaiter(Node mode) {
//mode 传递的参数是Node.EXCLUSIVE,表示的该锁是排它锁的标志。
//创建一个Node,使用当前线程和mode
Node node = new Node(Thread.currentThread(), mode);
//获得当前同步器所使用的双端链表的尾部节点。
Node pred = tail;
if (pred != null) {
//如果尾部节点不为空,就进入这里,当时由于是第二个线程,此时双端链表还没有元素,所以尾部节点是空的,此时就不走这里。
//把当前节点的前驱节点设置为双端链表的尾部节点
node.prev = pred;
//使用cas把尾部节点指向新增的节点。上面的不用cas,这里要使用的原因是node节点属于当前线程,所以修改是不存在线程安全的,而pred 尾部节点是共享的,所以要cas保证原子性
//上面这两步的作用就是往双端链表的尾部节点
if (compareAndSetTail(pred, node)) {
//如果修改成功,就意味着新增的节点已经添加到了双端队列
//所以此时就把原本的尾部节点的后驱引用指向新增的节点,此时就彻底完成了双端节点的新节点添加
pred.next = node;
//返回新增节点
return node;
}
}
//两种情况会走这里
//1. 如果尾部节点为空
//2. 尾部节点不为空但是添加到双端队列失败(compareAndSetTail返回false)的节点就会走这里。详情看下面
enq(node);
return node;
}
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}
private Node enq(final Node node) {
//这个方法就是为上面其他获取不到锁的线程并且没有成功添加到双端队列的线程进行兜底添加
//使用了自旋的方式。
//如果是上面第一种情况,会至少进行两次自旋
for (;;) {
//获取双端队列的尾部节点
Node t = tail;
if (t == null) { // Must initialize
//如果尾部节点为空,那就是上面的第一种情况,就会进入这里。
//compareAndSetHead是往头结点设置一个空节点,也就是没有实际封装线程的空节点。
//进入这里后,需要进行字段第二次,第二次就会走else分支,因为在这里已经设置好了尾节点。
if (compareAndSetHead(new Node()))
//成功后把尾结点执行头结点
tail = head;
} else {
//尾结点不为空进入这里。进入这里的原因是经过了上面if分支的第一次自旋设置了尾结点,或者从上面方法的第二种情况进来。
//把要新增的节点的前驱设置为尾节点。
node.prev = t;
//cas设置尾节点指向新节点
//这里通过不断自旋,知道把获取不到锁的线程封装成节点添加到双端队列中
if (compareAndSetTail(t, node)) {
//原来的尾节点的后驱引用设置为新节点。
t.next = node;
return t;
}
}
}
}
执行acquireQueued方法:
final boolean acquireQueued(final Node node, int arg) {
//此时arg是1,node是添加到双端队列的新节点
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获取不到锁的线程就是被阻塞在这个自旋里面
//获取当前节点的前驱节点
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
//前驱节点为头节点并且成功获取了锁,就进入这里
//进入这里代表该线程获得了锁,才能跳出自旋。
//设置当前节点为头节点,并把当前节点的线程、等置空。
setHead(node);
//从队列中删除前驱节点
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//没有获取锁,就阻塞。
interrupted = true;
}
} finally {
if (failed)
//这个兜底方法是对没有获取锁,但是通过一些手段,比如出错异常等而跳出自旋。但是实际上没有获取锁,就会调用这个方法。
cancelAcquire(node);
}
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
//pred前驱节点
//node当前节点
//这个方法返回的在线程获取锁失败后,是否应该进行阻塞
//获取前驱节点的节点状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
//如果当前节点的前驱节点的节点状态为SIGNAL状态,当前节点就能进行阻塞。返回true。
//只有这个分支返回true。
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
if (ws > 0) {
/*
* 前驱节点的状态大于0,也就是属于cancel状态,这些节点应该直接从队列里面去除。
* 一直循环,知道删除到的节点不属于cancle为止。
*/
do {
//这里相当于:
//node.prev=pred.prev //当前节点的前驱引用执行前驱节点的前驱引用
//pred=pred.prev //前驱节点=原前驱节点的前驱节点。
//熟悉双端链表,再画个图,这里就比较好理解了。
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* 将前驱节点的节点状态设置为SIGNAL
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
//总体逻辑是
//1. 如果前驱节点的状态为SIGNAL,就返回true,表示当前节点可以阻塞。
//2. 如果前驱节点的状态为cancle,就从队列去除。返回false。
//3. 其他情况就把前驱节点状态修改为SIGNAL
//意味着执行后,队列节点除了最后一个节点,其他都为SIGNAL
}
private final boolean parkAndCheckInterrupt() {
//如果shouldParkAfterFailedAcquire方法返回true后,才执行这个方法,进行线程阻塞。
LockSupport.park(this);
return Thread.interrupted();
}
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
//把线程置空
node.thread = null;
// Skip cancelled predecessors
Node pred = node.prev;
while (pred.waitStatus > 0)
//将当前节点的前置节点waitStatus 为cancel的节点去掉,知道遇到第一个不为cancel的节点。
node.prev = pred = pred.prev;
Node predNext = pred.next;
//将当前节点的状态设置为CANCELLED
node.waitStatus = Node.CANCELLED;
//
if (node == tail && compareAndSetTail(node, pred)) {
//如果当前节点是尾部节点,就删除我们
compareAndSetNext(pred, predNext, null);
} else {
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
解锁unlock:
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方法尝试释放锁
protected final boolean tryRelease(int releases) {
//此时releases=1
//减少重入次数
int c = getState() - releases;
//如果解锁的线程不等于当前占用锁的线程,就抛出异常IllegalMonitorStateException
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
//如果c=0,那就带表重入次数等于0,表示如果减少了重入次数后,锁就是空闲状态。
//就表示这次释放锁是完全释放锁。
free = true;
//把锁的当前占用线程设置为null
setExclusiveOwnerThread(null);
}
//设置锁的重入次数(状态)
setState(c);
//返回是否是否成功,这里返回true的情况是经过这次释放后,锁的重入次数为0这个情况才返回true。
return free;
}
private void unparkSuccessor(Node node) {
/*
*释放锁时传入的node是头节点
*/
int ws = node.waitStatus;
if (ws < 0)
//如果头节点的状态为小于0,设置为0
compareAndSetWaitStatus(node, ws, 0);
/*
*
*/
//获取头节点的下一个节点,因为头节点是不存在线程的,也就是说头节点只是一个用于入口的空节点。
//如果你从上面的上锁逻辑 用图一步一步跟着话出双端链表元素,会发现头节点是一个空节点
//所以真正是从第二个节点开始的
Node s = node.next;
if (s == null || s.waitStatus > 0) {
//当第二个节点为空或者状态为Cancel时进入
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
//从尾向前遍历,找到最后一个状态小于等于0的节点,把s赋值。
//为什么不从头遍历找第一个状态小于等于0的节点呢,因为这样的效率更高,
//原因是在上锁的时候,是先链接前驱节点再链接后驱节点的,如果从头遍历,使用后驱节点,可能会出现断链的情况
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
//唤醒该线程
LockSupport.unpark(s.thread);
//总体逻辑是找到双端链表从第二个节点开始第一个状态小于等于0的节点的线程进行唤醒。
}
上锁的总体逻辑是:
- 获取锁成功的线程直接返回执行同步代码块。
- 获取不到锁的线程从尾部加入到同步双端队列中,并在一个自旋内阻塞,等待唤醒后再自旋内获取锁成功就跳出自旋。
- 同一个线程获取同一把锁把AQS的state(重入次数+1)。
- 获取到锁的线程将会从双端队列中删除。
- 如果要关注,清晰理解双端队列节点的变化,可以通过画图进行直观体现。
释放锁的逻辑:
只有把锁的重入次数减到0才进行对同步队列中的线程进行唤醒。如果没有减到0的释放锁就仅仅把AQS的state减一。
如果重入次数减到0,就把双端队列从第二个节点开始,往后找,找到第一个节点状态小于等于0的节点来唤醒去获取锁。
以上也就是AQS的大致原理,通过一个同步双端队列来控制锁的同步情况。
公平锁和非公平锁的区别:
公平锁和非公平的区别是公平锁不可以插队,非公平锁可以插队。
//非公平锁:
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
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);
return true;
}
return false;
}
//公平锁
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;
}
非公平锁多了一个通过cas获取锁的逻辑,那就意味着新来的线程可能比在同步队列中的线程更快的获得锁(插队)。
公平锁就没有这一步,意味着新来的线程必须先进入阻塞队列,排队被唤醒。
还有在tryAcquire时,公平锁多了个hasQueuedPredecessors()的判断,这个判断是判断同步队列中是否有线程在等待,如果有,当前线程就不能获取锁,也一步也是为了防止插队。
公平锁就是先进来的线程先获取锁,不能插队。