并发锁相关知识
死锁
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
例如:线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
对于这种情况的代码:
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
OUT:
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
线程 A 通过
synchronized (resource1)
获得resource1
的监视器锁,然后通过Thread.sleep(1000);
让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到resource2
的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
产生死锁的必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系,
死锁相关处理
如何检测死锁?
使用 jmap、jstack 等命令查看 JVM 线程栈和堆内存的情况。
- 如果有死锁,
jstack
的输出中通常会有Found one Java-level deadlock:
的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用top
、df
、free
等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。 - 采用 VisualVM、JConsole 等工具进行排查。
如何预防和避免线程死锁?
预防:(破坏必要条件即可)
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
避免:借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。
修改线程二的代码:可以避免死锁
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 2").start();
OUT:
Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2
Process finished with exit code 0
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。
volatile
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
在 C 语言中:原始的意义就是禁用 CPU 缓存。指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。
如何保证变量的可见性?
具体如上
主内存与工作内存的直接交互:当一个变量被声明为 volatile
时,每当该变量被修改或读取时,线程会直接从主内存中读取或写入数据。这使所有线程能够看到变量的最新值。
如何禁止指令重排序 ?
如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序。
可以参考 2.5.2 禁止进行指令重排序
添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
volatile 可以保证原子性么?
volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。
不能
public class VolatileAtomicityDemo {
public volatile static int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
volatileAtomicityDemo.increase();
}
});
}
// 等待1.5秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}
对于这段代码:正常情况下输出的是 2500,而在这个地方结果是小于 2500 的。
问题在于 inc++
实际上是复合操作:
-
读取
inc
的值。 -
对
inc
加 1。 -
将
inc
的值写回内存。
而volatile
无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现: -
线程 1 对 inc 进行读取操作之后,还未对其进行修改。
-
线程 2 又读取了 inc 的值并对其进行修改(+1),再将 inc 的值写回内存。
-
线程 2 操作完毕后,线程 1 对 inc 的值进行修改(+1),再将 inc 的值写回内存。
导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。
利用 synchronized
、Lock
或者 AtomicInteger
都可以让上面的代码变为正确输出结果。
使用 synchronized
改进:
public synchronized void increase() {
inc++;
}
使用 AtomicInteger
改进:
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
使用 ReentrantLock
改进:
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally {
lock.unlock();
}
}
乐观锁和悲观锁
- 悲观锁:共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。(例如:
synchronized
和ReentrantLock
- 悲观锁在高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
- 乐观锁:线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的数据是否被其它线程修改了(例如:
AtomicInteger
、LongAdder
使用了乐观锁的 CAS 实现)- 乐观锁高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
总结:
- 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。如果乐观锁解决了频繁失败和重试这个问题的话(比如 LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
- 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考
java.util.concurrent.atomic
包下面的原子变量类)。
乐观锁的实现
乐观锁一般用版本号机制或 CAS 算法实现(CAS 算法更多)
版本号机制
一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version
值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
e.g.如果有两个操作对象对数据库进行操作的时候,第一个对象对数据库操作完成后会将
version
变为 2,然后提交的是version
和操作后的对象,而第二个操作对象操作完成的时候version
仍然还是 1,提交的时候会发现version
不一致,所以提交会被驳回!
CAS 算法
CAS 的全称是 Compare And Swap
(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 为一个原子操作,底层依赖于 CPU 的原子指令
原子操作:操作一旦开始,就不能被打断,直到操作完成。
CAS 的三个操作数:
- V(var):要更新的变量值
- E(Expected):期望值
- N(NEW):写入的新值
当且仅当 V===E
CAS 会用 N 来更新 V 的值即 V<-N
,否则放弃更新(这个时候已经被更新过了)
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
CAS 的实现
通过 C++与汇编实现,一个关键类为: Unsafe
提供的方法有:
CAS 的具体实现与操作系统以及 CPU 密切相关。
/**
* CAS
* @param o 包含要修改field的对象
* @param offset 对象中某field的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);
public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);
java.util.concurrent.atomic
包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。
原子类用于对某类型的变量进行原子操作,它利用 Unsafe 类提供的低级别原子操作方法实现无锁的线程安全性。
关于原子类的总结:Atomic 原子类总结 | JavaGuide Atomic 原子类
AtomicInteger
核心源码:
// 获取 Unsafe 实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
// 获取“value”字段在AtomicInteger类中的内存偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 确保“value”字段的可见性
private volatile int value;
// 如果当前值等于预期值,则原子地将值设置为newValue
// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
// **原子地**将当前值加 delta 并返回旧值
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
// **原子地**将当前值加 1 并返回加之前的值(旧值)
// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// **原子地**将当前值减 1 并返回减之前的值(旧值)
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}
Unsafe#getAndAddInt
源码:
// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
// 返回旧值
return v;
}
在 getAndAddInt
方法中用 do-while
体现了操作失败时,会不断重试直到成功。
即
getAndAddInt
方法会通过compareAndSwapInt
方法来尝试更新value
的值,如果更新失败,它会重新获取当前值并再次尝试更新,直到操作成功。
由于 CAS 操作可能会因为并发冲突而失败,因此通常会与 while 循环搭配使用,在失败后不断重试,直到操作成功。这就是自旋锁机制。
CAS 的问题
- ABA 问题
是 CAS 最常见的问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
解决方式:追加上版本号或者时间戳
JDK1.5 后
AtomicStampedReference
类用来解决 ABA 问题,其中的compareAndSet
方法先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 循环时间长开销大
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
如果 JVM 能够支持处理器提供的 pause 指令,那么自旋操作的效率将有所提升。
pause 指令有两个重要作用:
- 延迟流水线执行指令:pause 指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
- 避免内存顺序冲突:在退出循环时,pause 指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。
- 只能保证一个共享变量的原子操作
CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference
类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference
来执行 CAS 操作。
除了 AtomicReference 这种方式之外,还可以利用加锁来保证。
synchronized*
主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
JDK 6之前为重量级锁(效率低)
因为监视器锁(monitor)是依赖于底层的操作系统的
Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
JDK6 之后:引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销
synchronized
可以在项目中使用
偏向锁:增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。
- 在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁)
- 在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
锁升级原理详解:浅析synchronized锁升级的原理与实现 - 小新成长之路 - 博客园:
修饰场景
- 修饰实例方法
锁当前对象实例,进入要获得当前对象实例的锁
synchronized void method() {
//业务代码
}
- 修饰静态方法
锁当前对象实例,进入同步代码前要获得当前 class 的锁
synchronized static void method() {
//业务代码
}
静态 synchronized
和非静态的 synchronized
方法之间的调用互斥吗?
不互斥,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
- 修饰代码块
synchronized(object)
表示进入同步代码库前要获得给定对象的锁。
synchronized(类.class)
表示进入同步代码前要获得给定Class
的锁
synchronized(this) {
//业务代码
}
尽量不要使用 synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能。
- 对于构造方法
虽然不能直接在方法上修饰,但可以在方法内部使用(方法本身线程安全,如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。)
底层原理
使用 javap -v xx.class
的命令查看字节码文件,可以知道:
synchronized 同步语句块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
synchronized
同步语句块的实现使用的是monitorenter
和monitorexit
指令,分别为起点和终点- 有两个
monitorexit
命令的原因是保证锁在同步代码块代码正常执行(执行了 exit 后会跳过出现异常位置的 exit)以及出现异常(会在后面执行命令)的这两种情况下都能被正确释放 - 当执行
monitorenter
时,线程试图获取锁也就是获取对象监视器monitor
的持有权
monitor
基于c++
实现,每个对象都会内置一个ObjectMonitor
对象。wait/notify
等方法也依赖于monitor
对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify
等方法,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
- 在执行
monitorenter
时,会尝试获取对象的锁,如果锁的 计数器为 0 则表示锁可以被获取(获取锁成功),获取后将锁计数器设为 1 也就是加 1。如果计数器不为 0,则获取锁失败(当前线程需要阻塞等待,直到锁被另外一个线程释放为止。) monitorexit
指令执行后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
synchronized
修饰的方法并没有monitorenter
指令和monitorexit
指令,而是ACC_SYNCHRONIZED
标识,JVM 通过该ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。- 如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前
class
的锁。
但两者的本质都是对对象监视器 monitor
的获取。
对 monitor
的详解:Java锁与线程的那些事
与 volatile 有什么区别?*
volatile
是线程同步的轻量级实现,所以volatile
性能比synchronized
关键字要好。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块。volatile
能保证数据的可见性,但不能保证数据的原子性。synchronized
两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。
ReentrantLock
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
public class ReentrantLock implements Lock, java.io.Serializable {}
ReentrantLock
里面有一个内部类 Sync
,Sync
继承 AQS(AbstractQueuedSynchronizer
),添加锁和释放锁的大部分操作实际上都是在 Sync
中实现的。Sync
有公平锁 FairSync
和非公平锁 NonfairSync
两个子类。
可以指定使用公平锁与非公平锁
- 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
ReentrantLock
的底层就是由 AQS
来实现的。
关于 AQS :AQS详解
与 synchronized 的区别*
都为可重入锁:也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
Lock
实现类与 synchronized
都是可重入的。
- 由于
synchronized
锁是可重入的,同一个线程在调用method1()
时可以直接获得当前对象的锁,执行method2()
的时候可以再次获取这个对象的锁,不会产生死锁问题。 - 假如
synchronized
是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行method2()
时获取锁失败,会出现死锁问题。
public class SynchronizedDemo {
public synchronized void method1() {
System.out.println("方法1");
method2();
}
public synchronized void method2() {
System.out.println("方法2");
}
}
区别体现在:
synchronized
依赖于 JVM 而ReentrantLock
依赖于 API。synchronized
是在虚拟机层面实现的,并没有直接暴露给我们,ReentrantLock
则是 JDK 实现的,可以查看它的源代码,来看它是如何实现的。
ReentrantLock
比synchronized
增加了一些高级功能:- 等待可中断 :
ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「interrupt()
」,当前线程就会抛出InterruptedException
异常,可以捕捉该异常进行相应处理。 - 可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized
关键字与wait()
和notify()/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。 - 支持超时 :
ReentrantLock
提供了tryLock(timeout)
的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。
- 等待可中断 :
补充:
- 等待可中断:理解
lockInterruptibly()
可以响应中断- 基本功能:
lockInterruptibly()
试图获取锁,就像lock()
一样。如果锁可用,当前线程将获取锁并继续执行。如果锁已经被其他线程持有,那么当前线程会被阻塞,直到获取到锁为止。 - 中断支持:如果在呼叫
lockInterruptibly()
并被阻塞时,当前线程调用interrupt()
中断自己,那么lockInterruptibly()
会立即抛出InterruptedException
。 - 使用场景:
lockInterruptibly()
通常在需要保证响应性和可中断性的情况下使用。例如,在某些情况中,如果一个线程等待获取锁时可能长时间阻塞,可以选择通过中断来允许其他任务继续执行。
- 基本功能:
Condition
接口Condition
是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock
对象中可以创建多个Condition
实例(即对象监视器),在调度线程上更加灵活。- 使用
notify()/notifyAll()
方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock
类结合Condition
实例可以实现“选择性通知”synchronized
关键字就相当于整个 Lock 对象中只有一个Condition
实例,所有的线程都注册在它一个身上。如果执行notifyAll()
方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。Condition
实例的signalAll()
方法,只会唤醒注册在该Condition
实例中的所有等待线程。
- 支持超时:为什么需要
tryLock(timeout)
这个功能呢?- 防止死锁: 在复杂的锁场景中,可以通过允许线程在合理的时间内放弃并重试来帮助防止死锁。
- 提高响应速度: 防止线程无限期阻塞。
- 处理时间敏感的操作: 对于具有严格时间限制的操作,tryLock(timeout) 允许线程在无法及时获取锁时继续执行替代操作。
- 可中断锁和不可中断锁有什么区别?
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。 - 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁。
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。
Misc
了解即可。
ReentrantReadWriteLock
ReentrantReadWriteLock
实现了 ReadWriteLock
,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
- 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
- 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。
ReentrantReadWriteLock
其实是两把锁,一把是WriteLock
(写锁),一把是ReadLock
(读锁) 。读锁是共享锁(一把锁可以被多个线程同时获得),写锁是独占锁(一把锁只能被一个线程获得)。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
ReentrantReadWriteLock
底层也是基于 AQS 实现的。
ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。
使用场景:在读多写少的情况下,使用 ReentrantReadWriteLock
能够明显提升系统性能。
读锁为什么不能升级为写锁?(写锁可以降级为读锁,但是读锁却不能升级为写锁。)
- 这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。
- 可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。
StampedLock
StampedLock
是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition
。
不同于一般的 Lock 类,StampedLock
并不是直接实现 Lock
或 ReadWriteLock
接口,而是基于 CLH 锁独立实现的(AQS 也是基于这玩意)。
提供了三种模式的读写控制模式:读锁、写锁和乐观读。
- 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于
ReentrantReadWriteLock
的写锁,不过这里的写锁是不可重入的。 - 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于
ReentrantReadWriteLock
的读锁,不过这里的读锁是不可重入的。 - 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。
StampedLock 的性能为什么更好?
相比于传统读写锁多出来的乐观读是 StampedLock
比 ReadWriteLock
性能更好的关键原因。StampedLock
的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。
使用场景:
和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock 的替代品,性能更好。
不过,需要注意的是 StampedLock 不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。
另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用 StampedLock 之前,看看 StampedLock 官方文档中的案例。
底层原理:
StampedLock
不是直接实现 Lock
或 ReadWriteLock
接口,而是基于 CLH 锁实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock
通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。
知道 AQS 原理即可。