Java.util.concurrent 是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等。并发包的作者是大名鼎鼎的 Doug Lea。我们在接下来的课程中,回去剖析一些经典的比较常用的组件的设计思想
Lock 在 J.U.C 中是最核心的组件,前面我们讲 synchronized 的时候说过,锁最重要的特性就是解决并发安全问题。为什么要以 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 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程
void lock() // 如果锁可用就获得锁,如果锁不可用就阻塞直到锁释放
void lockInterruptibly() // 和lock()方法相似, 但阻塞的线程可中断 , 抛出java.lang.InterruptedException 异常
boolean tryLock() // 非阻塞获取锁;尝试获取锁,如果成功返回 true
boolean tryLock(long timeout, TimeUnit timeUnit) //带有超时时间的获取锁方法
void unlock() // 释放锁
重入锁,表示支持重新进入的锁,也就是说,如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。synchronized 和 ReentrantLock 都是可重入锁。很多同学不理解为什么锁会存在重入的特性,那是因为对于同步锁的理解程度还不够,比如在下面这类的场景中,存在多个加锁的方法的相互调用,其实就是一种重入特性的场景。
比如调用 demo 方法获得了当前的对象锁,然后在这个方法中再去调用demo2,demo2 中的存在同一个实例锁,这个时候当前线程会因为无法获得demo2 的对象锁而阻塞,就会产生死锁。重入锁的设计目的是避免线程的死锁。
public class App {
public synchronized void demo(){ //main获得对象锁
System.out.println("demo");
demo2();
}
public void demo2(){
synchronized (this) { //增加重入次数就行
System.out.println("demo2");
}//减少重入次数
}
public static void main(String[] args) {
App rd=new App();
new Thread(rd::demo).start();
}
}
public class AtomicDemo {
private static int count = 0;
static Lock lock = new ReentrantLock();
public static void inc() {
lock.lock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
lock.unlock();
}
public static void main(String[] args) throws
InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
AtomicDemo.inc();
}).start();
;
}
Thread.sleep(3000);
System.out.println("result:" + count);
}
}
我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写所在同一时刻可以允许多个线程访问,但是在写线程访问时,所有的读线程和其他写线程都会被阻塞。读写锁维护了一对锁,一个读锁、一个写锁;一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量.
public class LockDemo {
static Map<String, Object> cacheMap = new HashMap<>();
static ReentrantReadWriteLock rwl = new
ReentrantReadWriteLock();
static Lock read = rwl.readLock();
static Lock write = rwl.writeLock();
public static final Object get(String key) {
System.out.println("开始读取数据");
read.lock(); //读锁
try {
return cacheMap.get(key);
} finally {
read.unlock();
}
}
public static final Object put(String key, Object value) {
write.lock();
System.out.println("开始写数据");
try {
return cacheMap.put(key, value);
} finally {
write.unlock();
}
}
}
在这个案例中,通过 hashmap 来模拟了一个内存缓存,然后使用读写所来保证这个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候,读锁不会被阻塞,因为读操作不会影响执行结果。在执行写操作是,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线程会被阻塞,只有当写锁释放以后,其他读写操作才能继续执行。使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性
⚫读锁与读锁可以共享
⚫ 读锁与写锁不可以共享(排他)
⚫ 写锁与写锁不可以共享(排他)
我们知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串 行执行,从而达到线程安全性的目的。在 synchronized 中,我们分析了偏向锁、 轻量级锁、乐观锁。基于乐观锁以及自旋锁来优化了 synchronized 的加锁开销, 同时在重量级锁阶段,通过线程的阻塞以及唤醒来达到线程竞争和同步的目的。 那么在ReentrantLock中,也一定会存在这样的需要去解决的问题。就是在多线程 竞争重入锁时,竞争失败的线程是如何实现阻塞以及被唤醒的呢?
在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它 是一个同步工具也是Lock用来实现线程同步的核心组件。如果你搞懂了AQS,那 么J.U.C中绝大部分的工具都能轻松掌握
从使用层面来说,AQS的功能分为两种:独占和共享 独占锁,每次只能有一个线程持有锁,比如前面给大家演示的ReentrantLock就是 以独占方式实现的互斥锁 共 享 锁 , 允 许 多 个 线 程 同 时 获 取 锁 , 并 发 访 问 共 享 资 源 , 比 如 ReentrantReadWriteLock
AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。
所以双向链表可以从任 意一个节点开始很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线 程争抢锁失败后会封装成Node加入到ASQ队列中去;当获取锁的线程释放锁以 后,会从队列中唤醒一个阻塞的节点(线程)。
当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加节点的场景
里会涉及到两个变化
以ReentrantLock作为切入点,来看看在这个场景中是如何使用AQS来实现线程 的同步的
调用ReentrantLock中的lock()方法,源码的调用过程我使用了时序图来展现。
这个是reentrantLock获取锁的入口
public void lock() {
sync.lock();
}
sync实际上是一个抽象的静态内部类,它继承了AQS来实现重入锁的逻辑,我们前面说过AQS是一个同步队列,它能够实现线程的阻塞以及唤醒,但它并不具备 业务功能,所以在不同的同步场景中,会继承AQS来实现对应场景的功能
Sync有两个具体的实现类,分别是:
NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他 线程等待,新线程都有机会抢占锁
FailSync: 表示所有线程严格按照FIFO来获取锁
以非公平锁为例,来看看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);
}
通过 cas 乐观锁的方式来做比较并替换,这段代码的意思是,如果当前内存中的 state的值和预期值expect相等,则替换为update。更新成功返回true,否则返 回false
这个操作是原子的,不会出现线程安全问题,这里面涉及到Unsafe这个类的操作, 以及涉及到state这个属性的意义。 state 是 AQS 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入 锁的实现来说,表示一个同步状态。它有两个含义的表示
Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包 括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发的,比如 Netty、 Hadoop、Kafka等;
Unsafe 可认为是 Java 中留下的后门,提供了一些低层次操作,如直接内存访问、 线程的挂起和恢复、CAS、线程同步、内存屏障 而 CAS 就是 Unsafe 类中提供的一个原子操作,第一个参数为需要改变的对象, 第二个为偏移量(即之前求出来的 headOffset 的值),第三个参数为期待的值,第 四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值var4相等,则 更新为新的期望值 var5,如果更新成功,则返回true,否则返回false
一个Java对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存 里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存地址的字节 偏移。用于在后面的compareAndSwapInt中,去根据偏移量找到对象在内存中的 具体位置
所以stateOffset表示state这个字段在AQS类的内存中相对于该类首地址的偏移 量
在hotspot源码unsafe.cpp文件中,可以找到compareAndSwarpInt的实现
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);//将Java对象解析成JVM的oop(普通对象指针),
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);//根据对象p和地址偏移量找到地址
return (jint)(Atomic::cmpxchg(x, addr, e)) == e; //基于cas比较并替换, x表示需要更新的值,addr表示state 在内存中的地址,e表示预期值
UNSAFE_END
acquire 是 AQS 中的方法,如果 CAS 操作未能成功,说明 state 已经不为 0,此 时继续acquire(1)操作
➢ 大家思考一下,acquire方法中的1的参数是用来做什么呢?
这个方法的主要逻辑是
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这个方法的作用是尝试获取锁,如果成功返回true,不成功返回false 它是重写 AQS 类中的 tryAcquire 方法,并且大家仔细看一下 AQS 中 tryAcquire 方法的定义,并没有实现,而是抛出异常。按照一般的思维模式,既然是一个不实 现的模版方法,那应该定义成abstract,让子类来实现呀?大家想想为什么
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();//获取当前执 行的线程
int c = getState();//获得 state 的值
//表示无锁状态
if (c == 0) {
//cas 替换 state 的 值,cas 成功表示获取锁成功
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;
}
当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成 Node. 入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状 态。
意味着重入锁用到了AQS的独占锁功能
private Node addWaiter(Node mode) {
//把 当前线程封装为 Node
Node node = new Node(Thread.currentThread(), mode);
//tail 是 AQS 中表示同比队列队尾的属性,默认 是 null
Node pred = tail;
//tail 不为空的情况下,说明队列中存在节点
if (pred != null) {
//把当前线程的 Node 的 prev 指向 tail
node.prev = pred;
//通过 cas 把 node 加入到 AQS 队列,也就是设置为 tail
if (compareAndSetTail(pred, node)) {
//设置成功以后,把原 tail 节点的 next 指向当前 node
pred.next = node;
return node;
}
}
//tail=null,把 node 添加到同步队列
enq(node);
return node;
}
enq就是通过自旋操作把当前节点加入到队列中
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
假设3个线程来争抢锁,那么截止到enq方法运行结束之后,或者调用addwaiter 方法结束后,AQS中的链表结构图
通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给 acquireQueued方法,去竞争锁
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//获 取当前节点的 prev 节点
final Node p = node.predecessor();
//如 果是 head 节点,说明有资格去争抢锁
if (p == head && tryAcquire(arg)) {
//获取锁成功,也就是 ThreadA 已经释放了锁,然后设置 head 为 ThreadB 获得执行权 限
setHead(node);
// 把原 head节点从链表中移除
p.next = null; // help GC
failed = false;
return interrupted;
}
//ThreadA 可能还没释放锁,使得 ThreadB 在执 行 tryAcquire 时会返回 false
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
//并且返回当前线程 在等待过程中有没有中断过。
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这个方法在前面分析过,就是通过state的状态来判断是否处于无锁状态,然后在 通过cas进行竞争锁操作。成功表示获得锁,失败表示获得锁失败
如果ThreadA的锁还没有释放的情况下,ThreadB和ThreadC来争抢锁肯定是会 失败,那么失败以后会调用shouldParkAfterFailedAcquire方法 Node 有 5 中状态,分别是:CANCELLED(1),SIGNAL(-1) 、CONDITION(2)、PROPAGATE(-3)、默认状态(0)
CANCELLED: 在同步队列中等待的线程等待超时或被中断,需要从同步队列中取 消该Node的结点, 其结点的waitStatus为CANCELLED,即结束状态,进入该状 态后的结点将不会再变化
SIGNAL: 只要前置节点释放锁,就会通知标识为SIGNAL状态的后续节点的线程
CONDITION: 和Condition有关系,后续会讲解
PROPAGATE:共享模式下,PROPAGATE状态的线程处于可运行状态
0:初始状态
这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是 否应该被挂起。
返回false时,也就是不需要挂起,返回true,则需要调用parkAndCheckInterrupt 挂起当前线程
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
//前置节点的 waitStatus
int ws = pred.waitStatus;
//如果前置节点为 SIGNAL,意 味着只需要等待其他前置节点的线程被释放
if (ws == Node.SIGNAL)
//返回 true,意味着可以直接放心的挂 起了
return true;
//ws 大于 0,意味着 prev 节点取消了排 队,直接移除这个节点就行
if (ws > 0) {
do {
//相当于: pred=pred.prev; node.prev=pred;
node.prev = pred = pred.prev;
//这里采用循 环,从双向列表中移除 CANCELLED 的节点
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//利用 cas 设置 prev 节点的状态为 SIGNAL(1)
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
使用LockSupport.park挂起当前线程编程WATING状态
Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是 thread.interrupt(); 如果有触发过中断请求,那么这个方法会返回当前的中断标识 true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回true,意味 着在acquire方法中会执行selfInterrupt()
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
selfInterrupt: 标识如果当前线程在acquireQueued中被中断过,则需要产生一 个中断请求,原因是线程在调用acquireQueued方法的时候是不会响应中断请求 的
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
selfInterrupt: 标识如果当前线程在acquireQueued中被中断过,则需要产生一 个中断请求,原因是线程在调用acquireQueued方法的时候是不会响应中断请求 的
通过acquireQueued方法来竞争锁,如果ThreadA还在执行中没有释放锁的话, 意味着ThreadB和ThreadC只能挂起了。
LockSupport类是Java6引入的一个类,提供了基本的线程同步原语。LockSupport 实际上是调用了Unsafe类里的函数,归结到Unsafe里,只有两个函数
unpark函数为线程提供“许可(permit)”,线程调用park函数则等待“许可”。这个有 点像信号量,但是这个“许可”是不能叠加的,“许可”是一次性的。
permit 相当于 0/1 的开关,默认是 0,调用一次unpark 就加 1变成了 1.调用一次
park会消费permit,又会变成0。 如果再调用一次park会阻塞,因为permit已 经是0了。直到permit变成1.这时调用unpark会把permit设置为1.每个线程都 有一个相关的permit,permit最多只有一个,重复调用unpark不会累积
如果这个时候ThreadA释放锁了,那么我们来看锁被释放后会产生什么效果
ReentrantLock.unlock
在unlock中,会调用release方法来释放锁
public final boolean release(int arg) {
if (tryRelease(arg)) {//释放锁成功
Node h = head;//得到 aqs 中 head 节点
//如果 head 节点不 为空并且状态!=0.调用 unparkSuccessor(h)唤醒后续节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这个方法可以认为是一个设置锁状态的操作,通过将state状态减掉传入的参数值 (参数是1),如果结果状态为0,就将排它锁的Owner设置为null,以使得其它的线程有机会进行执行。
在排它锁中,加锁的时候状态会增加 1(当然可以自己修改这个值),在解锁的时 候减掉1,同一个锁,在可以重入后,可能会被叠加为2、 3、 4这些值,只有unlock() 的次数与 lock()的次数对应才会将 Owner 线程设置为空,而且也只有这种情况下 才会返回true
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;//获得 head 节点的状态
if (ws < 0)
// 设置 head 节点 状态为 0
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;//得到 head 节点的下一个节点
if (s == null || s.waitStatus > 0) {
//如果下一个节点为 null 或者 status>0 表示 cancelled 状态.
//通过从尾部节点开始扫描,找到距离 head 最近的一个 waitStatus<=0 的节点
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null) //next 节点不为空,直接唤醒这个线程即可
LockSupport.unpark(s.thread);
}
我们再回到 enq 那个方法、。在标注为红色部分的代码来看一个新的节点是如何加入到链表中的
通过锁的释放,原本的结构就发生了一些变化。head节点的waitStatus变成了0, ThreadB被唤醒
通过ReentrantLock.unlock,原本挂起的线程被唤醒以后继续执行,应该从哪里执 行大家还有印象吧。 原来被挂起的线程是在 acquireQueued 方法中,所以被唤 醒以后继续从这个方法开始执行
AQS.acquireQueued
这个方法前面已经完整分析过了,我们只关注一下 ThreadB 被唤醒以后的执行流 程。
由于ThreadB的prev节点指向的是head,并且ThreadA已经释放了锁。所以这 个时候调用tryAcquire方法时,可以顺利获取到锁
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)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
图解分析
锁的公平性是相对于获取锁的顺序而言的,如果是一个公平锁,那么锁的获取顺序 就应该符合请求的绝对时间顺序,也就是 FIFO。 在上面分析的例子来说,只要 CAS 设置同步状态成功,则表示当前线程获取了锁,而公平锁则不一样,差异点 有两个
FairSync.tryAcquire
final void lock() {
acquire(1);
}
非公平锁在获取锁的时候,会先通过CAS进行抢占,而公平锁则不会
FairSync.tryAcquire
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;
}
这个方法与 nonfairTryAcquire(int acquires)比较,不同的地方在于判断条件多了 hasQueuedPredecessors()方法,也就是加入了[同步队列中当前节点是否有前驱节点]的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁, 因此需要等待前驱线程获取并释放锁之后才能继续获取锁。