Java-并发-关于锁的一切
摘要
本文简要说下Java中的各种锁和类锁机制,还有一些相关的如sleep/yield join等,分析其实现原理,做简单比较。
请点击右侧目录,挑选感兴趣的章节观看。
注意:最近发现本文所讲偏向锁和轻量级锁的代码分析章节有误,请大家移驾参阅死磕Synchronized底层实现–概论系列文章,查看源码分析。待后续有时间我会改正本文内容。
引用一张美团关于锁的思维导图,出处为 不可不说的Java“锁”事
1 Thread相关方法
Thread 相关方法,是锁和类锁代码中大量使用的一些基本方法。第一张简单提一下。
1.1 sleep
1.1.1 基本概念
sleep
方法如其名,就是让线程休息下,直到指定时间耗尽。- 最大的特点就是阻塞过程中,不释放线程拥有的对象锁(ObjectMonitor)。
- sleep过程,会让出CPU时间片给其他线程执行。
- 底层使用linux系统的
pthread_cond_timedwait
方法实现。 - sleep方法可被中断
1.1.2 实现原理
请点击这里
1.1.3 Sleep对比Wait
- wait会释放ObjectMonitor控制权;sleep不会
- wait逻辑复杂,需要首先调用synchronized获取ObjectMonitor控制权,才能调用wait,且wait后还有放入WaitSet逻辑,唤醒时还有一系列复杂操作;而sleep实现简单,不需要别的线程唤醒
- wait与sleep都能被中断(除了sleep(0),当然对他中断没有意义)
- sleep和wait都会使得线程进入(TIMED) WAITING状态
- sleep和wait都会放弃cpu资源
1.2 yield
1.2.1 基本概念
- 该方法是给调度器一个提示,当前线程愿意放弃占有的CPU使用权。但注意,调度器可以忽略该提示。
- yield只是一个探索式的尝试,期望改善多线程场景下某些线程过度使用CPU的情况。该方法的使用应经过长期性能测试,以确保它实际上具有所需的效果。
- 用户编码中很少能正确使用该方法。因为可能在调试或测试的时候能达到预期,但在生产环境高并发环境下有可能导致bug!
- 该方法在jdk的如JUC并发包内被用设计来做并发控制
1.2.2 实现原理
请点击这里
1.3 join
1.3.1 基本概念
join方法主要用来等待其他线程运行结束,再继续运行自己的线程代码。
1.3.2 实现原理
请点击这里
1.4 interrupt
可参考java线程阻塞中断和LockSupport的常见问题
2 锁的基本概念
2.1 乐观锁和悲观锁
引用一张美团关于乐观锁和悲观锁的图,出处为 不可不说的Java“锁”事
2.1.1 悲观锁
即在同步环境中,线程认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改,然后再使用数据,使用完后释放锁,此后其他线程继续竞争锁。
悲观锁适合写多读少的场景。
Java中的悲观锁典型实现有:
- synchronized
- Lock
比如ReentrantLock
2.1.2 乐观锁
即在同步环境中,线程认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据那一刻判断目标是不是原有期望值:
- 如果是则认为没有别的线程改变,当前线程将自己修改的数据写入;
- 如果数据已经不等于期望值,则根据不同的实现方式执行不同的操作,最常见的有自动重试、报错、忽略等。
乐观锁适合读多写少的场景,不适用写多的场景,因为会有很多次重试。
乐观锁在Java中是通过使用LockFree来实现,典型实现有:
2.2 公平锁和非公平锁
2.2.1 公平锁
2.2.1.1 概念
公平锁是指多个线程按照申请锁的顺序来获取锁。
具体来说,线程在申请锁时,如果获取不到锁则进入FIFO的等待队列中排队。
2.2.1.2 小结
- 优点
等待锁的线程不会饿死,因为总会FIFO轮到自己(除非某个持有锁的线程卡死了) - 缺点
整体吞吐效率相对非公平锁较低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
2.2.1.3 例子
上图描述了必须从管理员处拿到许可的人才能打水的场景,打水后还要把许可证还给管理员。每次只能有一个人拿到许可打水,其他人必须排队。
当管理员得到返还的许可后,会把许可颁发给等待队列中的第一个人,唤醒他并给与许可。
2.2.1.4 ReentrantLock中的实现
参考这里
2.2.2 非公平锁
2.2.2.1 概念
在多线程场景下,每个线程要获取锁时会先尝试直接获取锁,如果获取不到才会进入FIFO等待队列等待。
但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
2.2.2.2 小结
- 优点
可以减少唤醒线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。 - 缺点
处于等待队列中的线程可能会由于总是被其他后来的线程直接申请到锁,从而导致饿死或者等很久才会获得锁。
2.2.2.3 例子
上图描述了必须从管理员处拿到许可的人才能打水的场景,打水后还要把许可证还给管理员。每次只能有一个人拿到许可打水,其他人必须排队。
不同的是,当管理员得到返还的许可后,如果此时刚好有一个人跑过来说我要打水,这个时候管理员可能直接把许可颁发给这个插队者,而不管等待队列中的人,这就是非公平锁。
当然,如果插队失败,则还是和公平锁一样老老实实去排队。
2.2.2.4 ReentrantLock中的实现
参考这里
2.3 可重入锁和不可重入锁
2.3.1 可重入锁
2.3.1.1 概念
又名递归锁。
同一个线程,获取锁以后,遇到又需要该锁对象的时候,可直接获取锁执行,不需要再重新进行申请锁的流程。
常见的可重入锁有:
- synchronized
比如以下两个方法中,doSomething中调用内部方法时就是重入锁:public class ReentrantTest { public synchronized void doSomething() { System.out.println("方法1执行..."); doOthers(); } public synchronized void doOthers() { System.out.println("方法2执行..."); } }
- ReentrantLock
2.3.1.2 例子
还是以前面提到过的打水举例,这里不同的是每个打水者有多个水桶,在从管理员处得到许可后可直接多次使用不同水桶打水,打完后依然还给管理员。
2.3.3 好处
- 同一个线程快速反复获取锁,不需要每次都尝试获取锁
- 同一个线程,在程序中多个子流程中需要使用同一个锁的时候,如果不使用排他的可重入锁,就会发生死锁
2.3.4 使用场景
可参考:
- 线程需要执行多个需同一个对象锁的子流程
2.3.2 不可重入锁
2.3.2.1 概念
此时同一个线程也不能重复获取同一个锁,比如前面那个ReentrantTest
代码中就会出现第一个方法执行而第二个方法无法执行的情况。
常见的不可重入锁有:
- NonReentrantLock
-
申请锁
NonReentrantLock继承自AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。NonReentrantLock获取锁时,直接去获取并尝试更新当前status的值,如果此时status != 0则会导致其获取锁失败,当前线程阻塞。
-
释放锁
在释放锁时,非可重入锁是在确定当前线程是持有锁的线程之后直接将status置为0,将锁释放即可。
-
2.3.2.2 例子
此时管理员非常严格,规定每个人获得每个许可后只能打一桶水。
2.4 独享锁和共享锁
2.4.1 独享锁
2.4.1.1 概念
独享锁也叫排他锁,是指该锁每次只能被一个线程所持有。如果一个线程对某个对象加上排它锁后,则其他线程不能再对该对象加任何类型的锁。
获得排它锁的线程即能读数据又能修改数据。
独享锁的典型实现:
- synchronized
- JUC中Lock的实现类如ReentrantLock。
2.4.2 共享锁
共享锁是指该锁可被多个线程同时持有。如果一个线程对某个对象加上共享锁后,则其他线程只能对该对象再加共享锁,不能再加排它锁。
获得共享锁的线程只能读数据,不能修改数据,所以也叫读锁。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
共享锁的典型实现:
- ReentrantReadWriteLock
其中ReadLock为共享锁,WriteLock为排他锁
2.5 对象锁和类锁
3 LockSupport
3.1 基本概念
- LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。
- LockSupport中有一个许可的概念
- 当调用
park()
方法时,如果拥有许可就立刻返回;否则也许会阻塞 - 调用
unpark()
会使得本来不可用的许可变为可用状态,解除线程阻塞 parkNanos
可指定超时时长、parkUntil
可指定截止时间戳
- 当调用
- 和
java.util.concurrent.Semaphore
中许可的概念不同,LockSupport的许可每个线程最多能拥有1个 - LockSupport的线程
park
可因中断、timeout或unpark甚至是毫无理由的返回,所以一般是通过循环检查附加条件是否满足 - LockSupport的park行为可被中断,但不会抛出
InterruptedException
。此时可通过interrupted(会清除中断标记位)或isInterrupted方法判断是否发生中断 - LockSupport是通过调用Unsafe函数中的
UNSAFE.park
和UNSAFE.unpark
实现阻塞和解除阻塞的。
3.2 实现原理
请查阅源码解读:Java-并发-锁-LockSupport
3.3 LockSupport和其他技术的区别
3.3.1 LockSupport和wait/notify区别
-
LockSupport中的阻塞和唤醒操作是直接作用于Thread对象的,更符合我们队线程阻塞这个语义的理解,使用起来也更方便;
-
而wait/notify的调用是面向
Object
的,线程的阻塞/唤醒对Thread本身来说是被动的。而且notify是随机唤醒的,无法精确地控制唤醒的线程以及唤醒的时机。代码上来说也很麻烦,稍不注意就会写错。
3.3.2 LockSupport.parkNanos与Thread.Sleep区别
Java中LockSupport.parkNanos与Sleep的区别是什么?
- 都会使得线程阻塞
- 关于中断
- LockSupport的park行为可被中断,但不会抛出InterruptedException。此时可通过interrupted(会清除中断标记位)或isInterrupted方法判断是否发生中断;
- Sleep过程中遇到中断调用,会抛出
InterruptedException
,但会清除中断标记位。
- 关于唤醒
- LockSupport的线程park可因中断、timeout或unpark甚至是毫无理由的返回,所以一般是通过循环检查附加条件是否满足退出循环条件。
- Sleep唤醒的可能原因是中断或设置的时间到了。
3.4 例子
3.4.1 普通使用例子
import java.util.concurrent.locks.LockSupport;
public class LockParkDemo1 {
private static Thread mainThread;
public static void main(String[] args) {
InnerThread it = new LockParkDemo1().new InnerThread();
Thread td = new Thread(it);
mainThread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + " start it");
td.start();
System.out.println(Thread.currentThread().getName() + " block");
// LockSupport.park(Thread.currentThread());
LockSupport.park();
System.out.println(Thread.currentThread().getName() + " continue");
}
class InnerThread implements Runnable{
@Override
public void run() {
int count = 5;
while(count>0){
System.out.println("count=" + count);
count--;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+" wakup others");
LockSupport.unpark(mainThread);
}
}
}
程序输出结果如下:
main start it
main block
Thread-0 wakup others
main continue
3.4.2 Blocker及调试例子
- 代码很简单,如下:
import java.util.concurrent.locks.LockSupport;
/**
* Created by chengc on 2018/12/15.
*/
public class BlockerTest
{
public static void main(String[] args)
{
Thread.currentThread().setName("Messi");
LockSupport.park("YangGuang");
}
}
jps
查看该进程pid:
$ jps
73900 BlockerTest
jstack
:
$ jstack -l 73900
"Messi" #1 prio=5 os_prio=31 tid=0x00007fe34c822000 nid=0x1b03 waiting on condition [0x0000700006470000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x000000076ac8fcc0> (a java.lang.String)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at demos.concurrent.lock.park.BlockerTest.main(BlockerTest.java:13)
Locked ownable synchronizers:
- None
可以看到我们的主线程Messi
处于WAITING
状态,而且原因是parking
。Blocker
对象时个java.lang.String
。
3.5 应用
AQS(AbstractQueuedSynchronizer)
就是利用了LockSupport的相关方法来控制线程阻塞或者唤醒。
public final void awaitUninterruptibly() {
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
boolean interrupted = false;
while (!isOnSyncQueue(node)) {
LockSupport.park(this);
if (Thread.interrupted())
interrupted = true;
}
if (acquireQueued(node, savedState) || interrupted)
selfInterrupt();
}
4 synchronized
4.1 基本概念
- 每次只能有一个线程进入临界区
- 可保证临界区内共享变量的可见性和有序性
- 成功进入
synchronized
区域的线程可以拿到对象的Object-Monitor。具体有3种用法,作用域不同,在后面例子中介绍。 - 对于拿到锁的线程来说,同一个对象的
synchronized
具有可重入性 - 一个线程拿到synchronized锁后,其他线程必须无条件等到,直到释放,而无法强制剥夺锁,也无法强制正在等锁的线程中断等待或超市退出!
- 不要将可变对象作为synchronized
- 如果相互等待对方的synchronized 对象,可能出现死锁
- synchronized锁是非公平的
4.2 实现原理
关于synchronized的实现原理可以查看这篇文章: Java-并发-锁-synchronized
4.3 ReentrantLock对比synchronized
ReentrantLock和synchronized对比如下:
可重入 | 等待可中断 | 公平性 | 绑定对象数 | 性能优化 | 自动释放 | |
---|---|---|---|---|---|---|
synchronized | 支持 | 不支持 | 非公平 | 只能1个 | 较多 | 抛异常退出同步块可自动释放锁 |
ReentrantLock | 支持 | 支持 | 非公平/公平 | 可以绑定多个Condition | - | 必须在finally中释放,否则可能永不释放 |
4.4 锁优化
4.4.1 基本概念
4.4.1.1 什么是锁优化
从JDK 1.6
开始HotSpot虚拟机团队花了很多精力实现各种锁的优化技术,主要目的很明显就是为了更少的阻塞、更少的竞争、更高效的获取锁和释放锁,说白了就是提高多线程需要访问共享区间的执行效率。
4.4.1.2 锁与对象
因为一些锁信息放在Java对象头中,所以这里先介绍下Java对象结构。
JavaHeap
中的对象主要包括以下三部分。
4.4.1.2.1 实例数据。
这部分是对象真正存储的有效信息,各种类型字段内容,还包括父类继承过来的信息。
4.4.1.2.2 字节对齐填充
只是占位符,因为HotSpot内存管理要求对象起始地址必须是8字节整数倍,即对象长度必是8字节整数倍。而对象头一般来说已经是整数倍,所以字节填充主要是为实例数据填充。
4.4.1.2.3 对象头
Mark Word
即对象运行时数据。他的内部字节长度分布与含义非固定,节约空间以存储更多有效数据。存有如hashCode、分代年龄、锁标志信息等。Klass Pointer
即类型指针(用于确定对象属于的类),指向方法区中的该对象Class类型对象。- 如果对象是数组,还有个数组长度。
下面是一个32位的HotSpot虚拟机中 MarkWord示意图:
Mark Word
中的最后2bit就是锁状态的标志位,用来记录当前对象的锁状态:
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄 |
轻量级锁定 | 00 | 指向锁记录的指针 |
膨胀(重量级锁) | 10 | 执行重量级锁定的指针 |
GC标记 | 11 | 空,不需要记录信息 |
可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄 |
注意上图中的后方1bit还会在无锁和偏向锁时不同以区分两种锁状态,因为他们的最后2bit锁标志位都是01。
jdk8/hotspot/src/share/vm/oops/markOop.hpp
描述了对象头部信息,有兴趣的读者可以看看。
4.4.1.3 Monitor
这里的Monitor是指对象的Monitor,可以理解为一个同步工具或一种同步机制,每一个Java对象就有一把锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
synchronized最初实现同步的需要阻塞或唤醒线程,因为阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。这个依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,这就是JDK 6之前synchronized效率低的原因。
后来为了减少获得锁和释放锁带来的性能消耗,引入了轻量级锁
和偏向锁
,详细可见锁升级。
4.4.2 自旋锁-SpinLock
4.4.2.1 思想
引用一张美团关于自旋锁的图,出处为 不可不说的Java“锁”事
使用互斥锁的时候,往往阻塞时间其实很短,但线程阻塞和唤醒操作由用户态转为内核态,性能开销大。
这个时候自旋锁产生了,他的思想很朴素,前提是有多于1个CPU:
- 线程一请求并获取锁
- 线程二请求锁,发现线程一持有锁
- 线程二并不放弃CPU进入等待,而是进入空循环即自旋,看是否自旋完成后线程一很快就释放锁了,如果释放了当前线程可不比阻塞而是直接使用,避免了线程切换、组合、唤醒等开销
- 如果自旋次数超过阈值(可以使用
-XX:PreBlockSpin
修改自旋次数,默认为10次)后发现所仍未释放,则阻塞该线程。
4.4.2.2 小结
- 优点
在总是能较短时间获取锁、线程竞争不激烈时,可仅自旋而不是线程阻塞和唤醒,对性能提升大。 - 缺点
自旋锁的问题显而易见,就是等待锁的时候占有CPU资源空跑。 - 常见实现
TicketLock、CLHlock、MCSlock
4.4.3 自适应自旋
这个自适应自旋锁思想也很朴素,相当于基于HBO(历史)的优化:
- 如果前一次获取锁很快,那本次就允许自旋次数多一些如100,因为JVM认为这一次也能成功获取锁
- 如果锁很难获取,自旋锁很少成功,那甚至可以直接不自旋,直接阻塞线程进行等待
4.4.4 锁消除
锁消除,顾名思义,就是JVM在编译器运行时会扫描代码,当检查到那些不可能存在共享区竞争但却有互斥同步的代码,直接将这样的多此一举的锁消除。
除了那些经验不足的编程人员会写无意义的同步代码,还有很多是JVM帮程序加上的,比如以下代码:
public String connectStrs(String str1, String str2, String str3){
return str1 + str2 + str3;
}
会因为String
是不可变类,反复产生新对象,所以被JVM自动优化成以下形式(JDK1.5之前版本,1.5之后是StringBuilder了):
public String connectStrs(String str1, String str2, String str3){
StringBuffer sb = new StringBuffer;
sb.append(str1);
sb.append(str2);
sb.append(str3);
}
此时,StringBuffer
是带锁的了。
锁消除的主要依据是逃逸分析,详见Java-JVM-逃逸分析。这里简单说下,就是指代码中的位于JavaHeap的所有数据都不会逃逸导致被其他线程访问,那就将可将他们作为栈内数据,作为线程私有。这样一来同步锁就没有意义了,可以消除。
比如这个例子中sb
对象只会在connectStrs
方法内活动,不会逃逸,其他线程不能访问本线程的sb对象。所以这里虽然有StringBuffer的锁,但能被自动安全消除。
注意:这里锁消除的动作是发生在JIT编译后运行时,而在解释执行时仍然会加锁运行!
4.4.5 锁粗化
这个名字有点诡异,其实说白了就是扩大锁的范围。
什么?不是说好了要尽量减小同步锁的适用范围,缩短占有锁的时间吗?!
其实,JVM是会在反复在段代码中对同一对象加锁、甚至加锁操作出现在循环体中的情况进行锁粗化优化的。此时,就算没有线程金资格证,也会由于频发加锁导致不必要性能开销。
比如
public String optimizedConnectStrs(String str1, String str2, String str3){
StringBuffer sb = new StringBuffer;
sb.append(str1);
sb.append(str2);
sb.append(str3);
}
这种情况每个append
都会执行如下代码:
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
也就是说会反复对sb
这个对象监视器加synchronized同步锁。
此时,JVM就会进行优化,将锁包住多次append操作的起始,只需加锁一次。这就是所谓锁粗化。
4.4.6 锁升级
4.4.6.1 基本概念
-
这一个概念针对synchronized
-
前面已经提到过Java中4种锁状态,随着锁竞争开始,这几种锁之间有锁升级的关系:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
-
锁只能升级不能降级
这么做的原因是缩短锁的获取释放周期,提升效率。 -
无锁
无锁就是没有底资源锁定,所有线程都能访问并修改同一个字段,但需要探测冲突,没冲突时才能修改成功,否则继续循环尝试。比如CAS就是无锁实现。无锁适合写少读多场景。
下面从最重的锁开始反向介绍各种锁。
4.4.6.2 重量级锁
4.4.6.2.1 基本概念
由操作系统来管理锁。
重量级锁就是前面提到过的传统的基于ObjectMonitor
的锁synchronized
,底层使用MutexLock
,等待线程会进入阻塞状态。
使用这类互斥锁的时候,往往阻塞时间其实很短,但线程阻塞和唤醒操作会有用户态和内核态转换,性能开销大。
尤其是在被synchronized修饰的简单代码如get set之类的简单方法,此时状态转换消耗时间可能比用户代码执行时间还长(不仅状态转换耗时,还要维护锁计数器、检查被阻塞线程是否需要唤醒等)!
4.4.6.2.2 MutexLock对比SpinLock
上述的monitorLock底层采用MutexLock实现,他和自旋锁SpinLock对比如下:
MutexLock | SpinLock | |
---|---|---|
原理 | 尝试获取锁,若可得到就占有;若不能,就阻塞等待 | 尝试获取锁,若可得到就占有。若不能,空转并持续尝试直到获取 |
使用场景 | 当线程进入阻塞没有很大问题,或需要等待一段足够长的时间才能获取锁。比如高并发写、线程竞争激烈场景。 | 当线程不应该进入睡眠如中断处理等或只需等待非常短的时间就能获取锁。比如写少读多。 |
缺点 | 引起线程切换和线程调度开销大 | 线程空跑CPU等待,浪费资源 |
4.4.6.3 轻量级锁
4.4.6.3.1 思想
JDK1.6后引入
。
该轻量级锁的名字是相对于传统的那些锁来说,认为传统同步锁(重量级锁)开销极大,大部分锁其实在同步期间并没有竞争,没必要使用重量级锁导致不必要开销。
轻量级锁是指当锁为偏向锁时被其他线程访问,偏向锁就会升级为轻量级锁,而其他线程会通过自旋的形式等待并尝试获取锁,不会阻塞,从而提高性能。
轻量级锁加锁过程图:
轻量级锁主要思想是让拥有锁的线程内部存锁对象的对象头的Mark Word,而且锁对象的Mark Word也有指针指向拥有锁的线程,以此标记拥有锁的是谁,详细流程如下:
-
代码进入同步块时,如果该同步对象为无锁状态(锁标志位为
01
且偏向锁标志位为0
),JVM就会在当前线程的栈中建立一个Lock Record
(锁记录)空间,用于存锁对象头的Mark Word
的内容拷贝,名为Displaced Mark Word
。 -
将锁对象的
Mark Word
复制到Lock Record
空间。 -
JVM以CAS(锁对象, MarkWord, DisplacedMarkWord) 即把锁对象的MarkWord更新为指向复制的
Lock Record
的指针,并将Lock Record
里的owner指针指向锁对象的Mark Word。 -
如果第3步成功,就认为该线程拥有这个对象锁。此时将
MarkWord
最后两bit
标记为 00,表示轻量级锁状态。 -
如果第3步失败,JVM就检查锁对象的
MarkWord
是否指向当前线程的栈帧。- 如果是,就说明当前线程拥有了该对象锁,这是锁重入,可以开始执行同步块内代码;
- 否则说明此时被其他线程拥有锁。此时会判断当前等待线程数:
- 如果只有一个就通过自旋等待;
- 如果自旋次数超过阈值或有多个线程等待锁或等待过程中又有线程来访时就膨胀为重量级锁,此时会标记为10。而锁对象的Mark Word也变为指向重量级锁的指针。在膨胀过程中,其他线程全部阻塞等待,而当前线程会使用4.1章节中提到的自旋锁等待膨胀完成,避免阻塞。待膨胀为重量级锁完成后重新竞争同步锁。
轻量级锁膨胀为重量级锁的过程可以在
jdk8/hotspot/src/share/vm/runtime/synchronizer.cpp
的ObjectMonitor * ATTR ObjectSynchronizer::inflate (Thread * Self, oop object)
方法代码中看到,这里不再展开。
解锁过程如下:
- CAS(锁对象, DisplacedMarkWord, MarkWord)
- 如果第1步成功,同步结束
- 第1步失败,说明其他线程尝试过获取该锁。此时不仅要释放锁,同时需要唤醒被挂起等待的线程,锁也要膨胀为重量级锁。
4.4.6.3.2 小结
轻量级锁的依据是大部分锁在同步期间没有竞争,从而用CAS+自旋方式避免了使用互斥量开销,避免线程阻塞、唤醒和线程切换开销。
但如果线程竞争锁激烈的场景,就会额外加大CAS的开销。此时反而效率低于所谓的重量级锁了。
4.4.6.4 偏向锁
4.4.6.4.1 思想
JDK1.6后引入
。
相对于轻量级锁是消除无竞争时用CAS
消除同步原语,偏向锁是直接在无竞争时消除所有同步。
偏向锁名字的偏字含义是类锁会偏向于第一个获取他的线程,如果后续一直没有其他线程访问该锁,则持有偏向锁的线程永远不需要再进行同步了。
当开启了偏向锁配置(-XX:+UserBiasedLocking
)时,偏向锁加锁过程如下:
- 当锁对象第一次被某个线程获取,JVM就将对象头中的锁标志位设为01,即可偏向模式,同时以CAS方式更新偏向锁内容,如将线程ID指向当前线程等信息。
- 如果上一步CAS成功,那么持有该偏向锁的线程以后每次获取该锁进入同步块时,检查
Mark Word
中线程ID是否是当前线程ID。如果是,那么可以直接执行同步块代码,JVM可以不用再进行加解锁、更新偏向信息等同步操作,效率提高很多 - 当有别的线程开始获取该锁时,可偏向模式结束,进入安全点(
SafePoint
)。此时需撤销偏向锁,会导致stop the word
暂停拥有偏向锁线程,判断是否处于被锁定状态:- 如果此时已锁定,就重设为轻量级锁(00)。
- 如果无锁,就设为未锁定状态(01)。
偏向锁的释放:
- 偏向锁释放锁的动作是被动的,如加锁过程中第三步即在其他线程尝试获取竞争偏向锁时才会触发偏向锁释放过程。上面说的安全点指在该时间点上没有代码运行。
4.4.6.4.2 小结
-
优点
在线程无竞争时消除同步原语。具体来说,偏向锁使得线程仅需在获取锁进入同步块时有JVM一些相关同步操作,后面每次该线程进入同步块都不再需要额外操作(比轻量级锁更轻,不需像轻量级锁那样每次做CAS了,而偏向锁只需要在首次进入时在置换ThreadID的时候依赖一次CAS原子指令即可),对线程性能提高十分有利。
-
缺点
但对于竞争激烈的场景中,偏向锁反而低效,此时可以考虑禁用偏向锁。
4.4.6.5 锁升级小结
初始分配对象时分为开启/不开启偏向锁模式。注意,初始时,偏向锁模式开启,但是拥有锁线程ID为0,代表未锁定。
4.4.7 锁优化小结
在学习了前面几种类别的锁后,再把synchronized加锁过程串起来讲一下,前提已经打开偏向锁:
- 第一次进入的线程获取偏向锁,将
ownerId
设为自己 - 后序进入的该线程都会检查该锁对象ownerID,如果是自己就直接利用偏向锁执行同步块
- 后续进入的其他线程检查到锁对象ownerID不是自己,偏向锁模式结束,升级为轻量级锁。复制一份
Mark Word
为Displaced Mark Word
,且CAS(锁对象, MarkWord, DisplacedMarkWord) - 以后每次进入时,CAS前先检查
5 wait notify
wait
notify
还有个notifyAll
都是线程通信的常用手段。
有一个先导概念就是对象锁和类锁,他们其实都是对象监视器Object Monitor
,只不过类锁是类对象的监视器,可以看另一篇文章:
Java-并发-锁-synchronized之对象锁和类锁
5.1 基本概念
5.1.1 wait
- 作用
顾名思义,wait其实就是线程用来做阻塞等待的。 - 超时参数
在JDK的Object中,wait方法分为带参数和无参数版本,这里说的参数就是等待超时的参数。 - 中断
其他线程在当前线程执行wait之前或正在wait时,对当前线程调用中断interrupted
方法,会抛出InterruptedException
,且中断标记会被自动清理。
5.1.2 notify
- 该方法用来任意唤醒一个在对象锁的等待集的线程(其实看了源码会发现不是任意的,而是一个WaitQueue,FIFO)。
- 但要注意,被唤醒的线程不会马上开始运行,因为对象锁还被调用
notify
的线程拥有,直到退出synchronized
块。 - 唤醒后的线程跟其他线程一起竞争该同步对象锁。
- 注意,该方法和wait方法一样也必须是拥有该对象同步对象锁的线程才能调用,否则抛出
IllegalMonitorStateException
。
5.1.3 notifyAll
- 该方法用来唤醒所有在对象锁的等待集的线程。
- 但要注意,被唤醒的线程不会马上开始运行,因为对象锁还被调用
notifyAll
的线程拥有。 - 唤醒后的线程跟其他线程一起竞争该同步对象锁。
- 注意,该方法和wait方法一样也必须是拥有该对象同步对象锁的线程才能调用,否则抛出
IllegalMonitorStateException
。
5.2 实现原理
请参考文档Java-多线程-wait/notify
5.3 wait与sleep比较
经常面试会问这个问题,往往我们都是网上查资料死记硬背。现在我们都看完了源码(sleep源码点这里),可以得出以下结论
- wait会释放ObjectMonitor控制权;sleep不会
- wait逻辑复杂,需要首先调用synchronized获取ObjectMonitor控制权,才能调用wait,且wait后还有放入WaitSet逻辑,唤醒时还有一系列复杂操作;而sleep实现简单,不需要别的线程唤醒
- wait与sleep都能被中断(除了sleep(0),当然对他中断没有意义)
5.4 Condition.await/signal对比wait/notify
5.4.1 Condition和Object关系
等待 | 唤醒 | 唤醒全部 | |
---|---|---|---|
Object | wait | notify | notifyAll |
Condition | await | signal | signalAll |
5.4.2 wait和await对比
中断 | 超时精确 | Deadline | |
---|---|---|---|
wait | 可中断 | 可为纳秒 | 不支持 |
await | 支持可中断/不可中断 | 可为纳秒 | 支持 |
5.4.3 notify和signal对比
全部唤醒 | 唤醒顺序 | 执行前提 | 逻辑 | |
---|---|---|---|---|
notify | 支持,notifyAll | 随机(jdk写的,其实cpp源码是一个wait_queue,FIFO) | 拥有锁 | 从wait_list取出,放入entry_list,重新竞争锁 |
signal | 支持,signalAll | 顺序唤醒 | 拥有锁 | 从condition_queue取出,放入wait_queue,重新竞争锁 |
5.4.4 底层原理对比
- Object的阻塞和唤醒,前基于synchronized的。底层实现是在cpp级别,调用synchronized的线程对象会放入entry_list,竞争到锁的线程处于
active
状态。调用wait方法后,线程对象被放入wait_queue。而notify会按FIFO方法从wait_queue中取得一个对象并放回entry_list,这样该线程可以重新竞争synchronized同步锁了。 - Condition的阻塞唤醒,是基于lock的。lock维护了一个wait_queue,用于存放等待锁的线程。而Condition也维护了一个condition_queue。当拥有锁的线程调用await方法,就会被放入condition_queue;当调用signal方法,会从condition_queue选头一个满足要求的节点移除然后放入wait_queue,重新竞争lock。
5.4.5 应用场景对比
- Object使用比较单一,只能针对一个条件。
- 一个ReentrantLock可以有多个Condition,对应不同条件。比如在生产者消费者可以这样实现:
private static ReentrantLock lock = new ReentrantLock();
private static Condition notEmpty = lock.newCondition();
private static Condition notFull = lock.newCondition();
// 生产者
public void produce(E item) {
lock.lock();
try {
while(isFull()) {
// 数据满了,生产者就阻塞,等待消费者消费完后唤醒
notFull.await();
}
// ...生产数据代码
// 唤醒消费者线程,告知有数据了,可以消费
notEmpty.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
// 消费者
public E consume() {
lock.lock();
try {
while(isEmpty()) {
// 数据空了,消费者就阻塞,等待生产者生产数据后唤醒
notEmpty.await();
}
// ...消费数据代码
// 唤醒生产者者线程,告知有数据了,可以消费
notFull.signal();
return item;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return null;
}
这样好处就很明显了。如果使用Object,那么唤醒的时候也许就唤醒了同类的角色线程。而使用condition可以在只有一个锁的情况下,实现我们想要的只唤醒对方角色线程的功能。
6 CAS
6.1 基本概念
可参考
JDK中大量代码使用了CAS,底层是调用的sun.misc.Unsafe
,如Unsafe.compareAndSwapInt
方法:
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
该方法第一个参数为对象,第二个参数为指定field
在对象中的偏移量,第三个为期望值,最后一个是要更新的目标值。
CAS的基本思想就是原子性的执行以下两个操作:
- 比较对象中的field当前值是否为期望值
- 如果是就更新为指定值,否则不更新
那么,java是怎么实现这个操作的原子性的呢?我们接着往下看
6.2 实现原理
透过前面的代码,可以看到compareAndSwapInt
是一个JNI
调用。
在jdk8/hotspot/src/share/vm/prims/unsafe.cpp
中可以找到以下内容:
{CC"compareAndSwapInt", CC"("OBJ"J""I""I"")Z", FN_PTR(Unsafe_CompareAndSwapInt)},
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);
// 获取该filed内存地址
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 调用Atomic.cmpxchg方法
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
这个原子性是现代处理器新增的硬件指令支持的,在IA64、x86指令群中有cmpxchg指令完成的CAS功能。
具体是使用LOCK CMPXCHAG
指令实现。
CMPXCHAG
指令本身不是原子性的,他用于比较并交换操作数,CPU对CAS的原语支持。
所以需要加上LOCK
,由CPU保证被其修饰的指令的原子性,实现原理(详情参见:多处理器下的数据一致性):
-
依赖内存有序模型,来保证读取指令有序;
-
通过总线锁或缓存一致性,保证被修饰指令操作的数据一致性:
- 当访问的数据在系统内存时,通过在总线使用锁实现原子性(保证只有一个CPU能使用);
- 当访问的数据在处理器的缓存时,通过缓存一致性协议实现原子性;
常见的缓存一致性协议有:MESI,MESIF(MESIF是缓存行的状态标识,M:Modified, E: Exclusive, S:Shared, I:Invalid, F: Forwad),通过标记缓存行的状态和处理器间的通讯来实现。
举个LOCK栗子
-
Java的DCL中若返回的变量不加volatile修饰,则可能会由于指令重排导致另一个线程获取到一个非完全初始化的对象。
而当volatile修饰的变量所在的代码段成为热点,被JIT编译为汇编代码后,会增加LOCK前缀来禁止指令重拍和数据一致;
LOCK CMPXCHAG
保证原子性的不同CPU个数场景:
- 单核
无需加LOCK前缀,即使增加也会被替换为nop - 多核
需要加LOCK前缀
鉴于本人能力有限,就不再继续向下了。有兴趣的读者可以研究下jdk8/hotspot/src/share/vm/runtime/atomic.cpp
也可参考文章:
6.3 AtomicInteger的CAS例子
AtomicInteger的原子自增方法incrementAndGet
底层就用了CAS方法,代码如下:
public final int incrementAndGet() {
// 这里在getAndAddInt就完成了自增并返回了原始值,这里加1就是得到的新值作为结果返回
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
Unsafe.getAndAddInt
如下
// 获取对象var1的偏移量为var2的值,并加上var4,直到成功
// 返回的是原来的值
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 获取该值
var5 = this.getIntVolatile(var1, var2);
// 如果该值为var5,且成功替换为新值var5+var4就返回,否则一直循环该过程
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
6.4 ABA
6.5 CAS问题
- ABA
- 不适合写多的场景
竞争大,重试很多,持续自旋,CPU开销大 - 只能保证一个共享变量的原子操作
多个变量操作时无法保证原子性。可以使用AtomicReference
来保证引用对象之间的原子性,所以可把多个变量放在一个对象里来进行CAS操作。
7 ReentrantLock
7.1 基本概念
ReentrantLock
是使用最广的、最出名的AQS(AbstractQueuedSynchronizer)
系列的可重入锁。
它属于是高层API。和synchronized对比如下:
可重入性 | 等待可中断 | 公平性 | 绑定对象数 | 性能优化 | |
---|---|---|---|---|---|
synchronized | 支持 | 不支持 | 非公平 | 只能1个 | 较多 |
ReentrantLock | 支持 | 支持 | 非公平/公平 | 可以多个 | - |
- 等待可中断
获取锁时可以指定一个超时时间,如果超过这个时间还没有拿到锁就放弃等待 - 公平性
默认非公平锁,也可设为公平锁:- 公平锁就是按线程申请锁时候FIFO的方式获取锁;
- 而非公平锁没有这个规则,所有线程来时都先去直接竞争锁,竞争不到再排队。
- 绑定对象
- 一个
synchronized
绑定一个Object用来wait
,notify
等操作; - 而ReentrantLock可以newCondition多次等到多个Condition实例,执行
await
,signal
等方法。
- 一个
7.2 实现原理
限于篇幅,这里可以大概说下其原理。
7.2.1 AQS
AQS全称AbstractQueuedSynchronizer
,他是ReentrantLock
内部类NonfairSync
和FairSync
的父类Sync
的父类,其核心组件如下:
-
state,int 类型,用来存储许可数
-
Node双向链表,存储等待锁的线程
该
Node
就是AQS的内部类,这里可以简单看看Node定义:static final class Node { // 表明等待的节点处于共享锁模式,如Semaphore:addWaiter(Node.SHARED) static final Node SHARED = new Node(); // 表明等待的节点处于排他锁模式,如ReentranLock:addWaiter(Node.EXCLUSIVE) static final Node EXCLUSIVE = null; // 线程已撤销状态 static final int CANCELLED = 1; // 后继节点需要unpark static final int SIGNAL = -1; // 线程wait在condition上 static final int CONDITION = -2; // 使用在共享模式头Node有可能处于这种状态, 表示锁的下一次获取可以无条件传播 static final int PROPAGATE = -3; // 这个waitStatus就是存放以上int状态的变量,默认为0 // 用volatile修饰保证多线程时的可见性和顺序性 volatile int waitStatus; // 指向前一个Node的指针 volatile Node prev; // 指向后一个Node的指针 volatile Node next; // 指向等待的线程 volatile Thread thread; // condition_queue中使用,指向下一个conditionNode的指针 Node nextWaiter; // 判断是否共享锁模式 final boolean isShared() { return nextWaiter == SHARED; } // 返回前驱结点,当前驱结点为null时抛出NullPointerException final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } // 用来初始化wait队列的构造方法;也被用来做共享锁模式 Node() { } // 在addWaiter方法时,将指定Thread以指定模式放置 Node(Thread thread, Node mode) { this.nextWaiter = mode; this.thread = thread; } // Condition使用的构造方法 Node(Thread thread, int waitStatus) { this.waitStatus = waitStatus; this.thread = thread; } }
AQS的Node等待队列双向链表如下图:
7.2.2 非公平锁的实现
默认采用非公平的实现NonFairSync
。
7.2.2.1 lock()
lock()
方法流程如下图:
可以看到,lock()
方法最核心的部分就是可重入获取许可(state),以及拿不到许可时放入一个AQS实现的双向链表中,调用LockSupport.park(this)
将自己阻塞。就算阻塞过程被中断唤醒,还是需要去拿锁,直到拿到为止,注意,此时在拿到锁之后还会调用selfInterrupt()
方法对自己发起中断请求。
7.2.2.2 unlock()
7.2.3 公平锁的实现
他的实现和非公平锁有少许区别:
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
// 这里不再有非公平锁的
// if (compareAndSetState(0, 1))setExclusiveOwnerThread(Thread.currentThread());
// 也就是说,公平锁中,必须按规矩办事,不能抢占
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 这里多了一个 !hasQueuedPredecessors(),也就是不需要考虑wait链表
// 否则就老实按流程走acquireQueued方法
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;
}
}
下面看看的hasQueuedPredecessors
实现
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
// h != t 代表wait链表不为空状态
// (s = h.next) == null代表wait链表已经初始化
// s.thread != Thread.currentThread()代表当前线程不是第一个在wait链表排队的线程
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
7.2.4 使用场景
可参考:
7.2.5 小结
- 非公平锁比起公平锁来说,唯一区别就是非公平锁可以快速用
compareAndSetState(0, acquires)
进行抢占,而公平锁必须老老实实FIFO形式排队;但unlock
唤醒的时候是没有区别的。 - state采用
volatile
,保证有序性和可见性 - 大量使用如
unsafe.compareAndSwapInt(this, stateOffset, expect, update);
此类的CAS操作,保证原子性,同时在竞争小的时候效率胜过synchronized
- 所谓的加锁就是
AQS.state++
。且该锁是可重入的,每次就state加1,unlock一次减一。两个操作必须一一对应,否则其他等待锁的线程永远等待。 - 所谓的等待锁阻塞,就是放在一个Node链表里,然后用
LockSupport.park(this)
阻塞 - 就算用中断唤醒已经等待锁而阻塞的线程,依然必须直到获取锁才能执行。且在其后如果执行可中断操作,会发生中断!
- ReentrantLock具有可重入性
- 获取锁
重入锁先使用CAS方式尝试获取锁并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行;如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1
,表示当前线程再次获取到锁。 - 释放锁
重入锁先获取当前status的值,在当前线程是持有锁的线程的前提下如果status-1 == 0
,则表示当前线程之前所有重复获取锁的操作都已经被释放完毕,然后该线程才会真正释放锁;其他时候只是执行更新state=state-1
。
- 获取锁
7.3 ReentrantLock对比synchronized
ReentrantLock和synchronized对比如下:
可重入 | 等待可中断 | 公平性 | 绑定对象数 | 性能优化 | |
---|---|---|---|---|---|
synchronized | 支持 | 不支持 | 非公平 | 只能1个 | 较多 |
ReentrantLock | 支持 | 支持 | 非公平/公平 | 可以多个 | - |
8 Condition
8.1 基本概念
Condition
类其实是位于java.util.concurrent.locks
的一个接口类。他的一个常用实现类是AQS的非静态内部类ConditionObject
:
public class ConditionObject implements Condition, java.io.Serializable
虽说ConditionObject是public
修饰,但不能直接使用,因为他是非静态内部类,必须先实例化AQS的实例。而AQS定义如下:
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable
很明显,他是一个抽象类,不能直接实例化。也就是说必须使用继承他的子类才能实例化,从而使用ConditionObject
。
我们最常使用的是配套ReentrantLock和Condition使用:
ReentrantLock lock = new ReentrantLock(true);
Condition condition = lock.newCondition();
condition.await();
condition.signal();
下面简单分析下后面3步代码实现:
8.2 实现原理
具体请查阅Java-并发-Condition
这里只给出流程总结:
await
方法,将当前线程加入AQS.condition_queue
,且会顺便清理其中不为CONDITION
状态的结点await
方法,让当前线程释放所有的锁许可(state归0)await
方法,将当前线程阻塞,直到该线程被放入了AQS.wait_queue
signal
方法,将从AQS.condition_queue
队列的头结点开始往后遍历,从AQS.condition_queue
中将该线程结点移除,并放回AQS.wait_queue
,并根据前驱结点是否已经撤销或异常按需唤醒当前结点。注意,此过程只要成功移动一个节点,遍历就结束了,也就是说每次signal
方法最多只能从AQS.condition_queue
中移动一个结点到AQS.wait_queue
。signal
方法,在上述遍历移动节点过程中会顺便清理掉AQS.condition_queue
中那些状态不为CONDITION
的结点await
方法,阻塞的线程因为被signal
方法重新放入AQS.wait_queue
而被其他前驱结点唤醒,此时有几种情况:- 意外情况。LockSupport有可能会因为意外导致线程唤醒,该情况和情况2处理相同,需要再次判断节点是否已经放入
AQS.wait_queue
。 - 被
wait_queue
中该结点的前驱结点执行unlock
方法时唤醒。处理同情况1 - 其他线程调用
signal
方法前调用中断方法唤醒,需要重设interruptMode
- 其他线程调用
signal
方法后调用中断方法唤醒,需要重设interruptMode
- 意外情况。LockSupport有可能会因为意外导致线程唤醒,该情况和情况2处理相同,需要再次判断节点是否已经放入
await
方法,该节点调用acquireQueued
走申请锁许可流程。注意,如果此时申请不到锁,线程又会被LockSupport.park
阻塞。await
方法,会又一次顺便清理其中不为CONDITION
状态的结点await
方法,按阻塞前后收到中断请求的情况按需发起中断await
方法返回,可继续执行用户代码
8.3 await/signal对比wait/notify
上面wait/notify章节已经比较过了 ,请点击这里查看
8.4 小结
Condition特点如下:
- Condition必须搭配AQS.sync使用
- await过程可中断
- Condition的阻塞唤醒,是基于
lock
的。lock维护了一个wait_queue
,用于存放等待锁的线程。而Condition也维护了一个condition_queue
。当拥有锁的线程调用await
方法,就会被放入condition_queue
;当调用signal方法,会从condition_queue
选头一个满足要求的节点移除然后放入wait_queue
,重新竞争lock。 - 一个lock可以对应多个Condition,在多条件情况十分方便
9 ReadWriteLock
9.1 基本概念
现在大家开发程序,大多是在多线程场景,就会用到各种锁。但其实往往读和读之间是不冲突的,是无状态无修改的,不应该互相互斥。我们往往只需在读写或者写与写之间互斥即可。在JDK中就直接提供了一个ReadWriteLock
,互斥关系如下表:
读 | 写 | |
---|---|---|
读 | 不互斥 | 互斥 |
写 | 互斥 | 互斥 |
ReadWriteLock的其他重要知识点如下:
- 读写锁分为读锁和写锁,分开使用
- 读锁允许读可并发,但此时写锁申请会被阻塞
- 写锁不允许任何并发,一旦有线程拥有写锁,其他线程的读写锁申请全被阻塞
- ReadWriteLock适用于写少读多的场景
- ReadWriteLock也被称为
共享-独占锁
- 读写锁都可重入,调用了几次
lock
就必须配套调用几次unlcok
使用时一般是这几个api:
// 获得读写锁实例
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 读锁加锁,可与其他线程读锁共享,阻塞其他写锁申请,可重入
lock.readLock().lock()
// 读锁解锁,调用次数必须和lock相同
lock.readLock().unlock();
// 写锁加锁,阻塞其他线程读写锁申请,可重入
lock.writeLock().lock();
// 写锁解锁,调用次数必须和lock相同
lock.writeLock().unlock();
9.2 实现原理
9.2.1 流程分析
9.2.2 源码分析
10 StampedLock
10.1 基本概念
10.2 实现原理
11 Unsafe.park
已经在第二章的park cpp分析一节中分析过了。
LockSupport.park底层就是用的Unsafe.park,而JDK中很多地方使用了Unsafe。这两个锁偏底层,建议用基于他们或AQS的高级锁如ReentrantLock
、CountDownLatch
、CyclicBarrier
等。
12 性能对比
Java 8 StampedLock,ReadWriteLock以及synchronized的比较
好文推荐
- 不可不说的Java“锁”事
美团技术团队