Java并发常见知识点总结(中)
一、⭐️JMM(Java 内存模型)
JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题:JMM(Java 内存模型)详解 。
二、⭐️volatile 关键字
如何保证变量的可见性?
在 Java 中,使用 volatile
关键字可以确保变量的可见性。具体而言:
volatile
关键字会使得每次对变量的读写操作都直接与主内存进行交互,而不是使用线程本地的缓存。这意味着,任何一个线程对volatile
变量的修改,其他线程立即可以看到。volatile
保证的是 可见性,即确保一个线程修改的值对于其他线程是立刻可见的,但它不保证 原子性。如果涉及复合操作(如自增),仍然需要额外的同步机制(如synchronized
或Atomic
类)来确保原子性。
总结:
volatile
保证可见性。synchronized
保证原子性和可见性。
两者在 Java 内存模型(JMM)中的工作原理:
- 当一个线程修改
volatile
变量时,JVM会确保修改后的值立即写回到主内存。 - 其他线程在读取该变量时,会直接从主内存中读取,而不是从线程缓存中获取。
如何禁止指令重排序?
在 Java 中,volatile
关键字除了保证变量的可见性外,还可以通过禁止指令重排序来确保程序的正确性。
指令重排序:
JVM 和 CPU 在执行代码时,可能会对指令进行优化,改变指令的执行顺序,从而提高效率。但是,这种重排序可能会破坏多线程程序的正确性。
使用 volatile
修饰变量,可以禁止 JVM 对该变量相关的指令进行重排序。具体做法是,通过在读写 volatile
变量时插入内存屏障(memory barrier),防止指令重排序。内存屏障会确保变量的读写操作在特定的顺序中进行。
在 Java 中,volatile
通过内存屏障来实现指令重排序的禁止,而 Unsafe
类也提供了相应的方法:
public native void loadFence();
public native void storeFence();
public native void fullFence();
这些方法能直接插入内存屏障,阻止指令重排序,但它们的使用比 volatile
要复杂一些。
举例:双重检验锁实现单例模式
在实现线程安全的单例模式时,volatile
关键字用于防止指令重排序,从而避免多线程环境中的问题:
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
为什么需要 volatile
?uniqueInstance = new Singleton();
这段代码分为三步执行:
- 分配内存空间
- 初始化对象
- 将对象引用赋值给
uniqueInstance
由于指令重排序的特性,这三步的执行顺序可能变为 1 -> 3 -> 2,这样 uniqueInstance
可能会被线程 T2 访问到,但此时它还未初始化,从而导致问题。通过使用 volatile
,可以确保这三步按正确的顺序执行,从而避免出现未初始化的实例。
volatile 可以保证原子性么?
volatile
不能保证原子性。
volatile
保证的是变量的可见性,即确保一个线程对变量的修改能立即对其他线程可见。但是,volatile
不能保证对变量的复合操作(如自增、累加)是原子性的。
例如,在以下代码中,inc++
不是一个原子操作,它分为三步:
- 读取
inc
的当前值。 - 对
inc
进行加1操作。 - 将新的值写回
inc
。
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();
}
});
}
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}
尽管 volatile
确保了 inc
的可见性,但由于 inc++
是一个复合操作,多个线程并发执行时,会导致操作不原子化,产生数据竞争(race condition),最终结果会小于预期的 2500
。
原因:
- 线程 1 和线程 2 可以在读取和写入过程中相互干扰,导致相同的值被更新多次,而不是每次都独立递增。
如何保证原子性?
- 使用
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();
}
}
这些方式都可以确保 inc++
操作的原子性,从而解决数据竞争问题。
三、⭐️乐观锁和悲观锁
什么是悲观锁?
悲观锁是一种假设最坏情况的锁机制,它认为在任何时刻,多个线程可能会竞争访问共享资源,因此会采取加锁的方式来避免资源被并发修改。在悲观锁中,每次访问共享资源时,都会加锁,确保同一时刻只有一个线程能够操作该资源,其他线程会被阻塞,直到当前持有锁的线程释放锁。
特点:
- 共享资源每次只能被一个线程访问,其他线程会被阻塞。
- 线程阻塞后,等待持有锁的线程释放锁后才能继续访问资源。
实现:
- 在 Java 中,
synchronized
和ReentrantLock
都是悲观锁的实现。
// 使用 synchronized 实现悲观锁
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}
// 使用 ReentrantLock 实现悲观锁
private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}
缺点:
- 性能问题:高并发环境下,多个线程竞争锁会导致线程阻塞,产生上下文切换,从而增加性能开销。
- 死锁问题:如果多个线程相互等待对方持有的锁,就会发生死锁,导致程序无法继续执行。
总结:
悲观锁是一种保证线程安全的方式,但它在高并发环境下可能带来较大的性能开销,并且容易产生死锁问题。
什么是乐观锁?
乐观锁是一种假设最好的情况的锁机制,它认为多个线程访问共享资源时不会发生冲突,因此不会加锁,也无需阻塞其他线程。乐观锁的核心思想是在提交修改时进行冲突检测,检查共享资源是否被其他线程修改过,常见的方式是使用版本号机制或CAS(Compare-And-Swap)算法。
特点:
- 假设无冲突:在访问资源时,不加锁,假设其他线程不会修改资源。
- 提交时验证:在提交修改时,检查资源是否被其他线程修改过,若没有被修改,则提交操作;若修改过,则重试。
实现方式:
- 在 Java 中,
java.util.concurrent.atomic
包下的原子变量类(如AtomicInteger
、AtomicLong
)就是利用乐观锁的CAS算法来实现的。
// 使用 LongAdder 来优化高并发情况下的性能
LongAdder sum = new LongAdder();
sum.increment();
优点:
- 无锁竞争:乐观锁不会阻塞线程,避免了锁竞争的问题,适用于并发读多、写少的场景。
- 性能优势:相比悲观锁,乐观锁在读操作频繁的场景中,性能通常更好,因为没有锁的开销。
缺点:
- 频繁冲突重试:如果写操作频繁发生,可能会导致多个线程频繁检测冲突并重试,影响性能,甚至可能造成 CPU 资源浪费。
- 适用场景有限:乐观锁更适用于竞争较少的场景,主要是针对单个共享变量的原子操作。
理论应用:
- 悲观锁:适用于写多的场景,竞争激烈时,锁定资源避免冲突。
- 乐观锁:适用于读多的场景,读操作多于写操作时,避免加锁带来的性能损耗。
总之,乐观锁适用于资源冲突较少的场景,而悲观锁适用于高竞争的写操作场景。选择哪种锁取决于具体的应用场景和性能需求。
如何实现乐观锁?
乐观锁一般通过 版本号机制 或 CAS(Compare-And-Swap) 算法来实现。下面是两种方式的详细介绍:
版本号机制
版本号机制通常通过在数据表中加入一个版本号字段(如 version
)来实现。每次数据修改时,版本号会增加。当一个线程读取数据时,也读取当前的版本号。提交修改时,会验证版本号是否匹配,若匹配则提交更新,否则重试。
示例:
假设数据库中帐户信息表有 version
字段,当前 version = 1
,余额 balance = 100
。
- 操作员 A:读取数据
version = 1
,余额balance = 100
,并扣款 $50,余额变为 $50。 - 操作员 B:也读取数据
version = 1
,余额balance = 100
,并扣款 $20,余额变为 $80。 - 操作员 A:提交修改,
version = 1
更新余额为 $50,version
变为 2。 - 操作员 B:提交修改时发现
version = 1
与数据库中的version = 2
不匹配,更新失败,B 被拒绝修改。
这样避免了操作员 B 的修改覆盖操作员 A 的操作结果。
CAS 算法
CAS(Compare-And-Swap) 是乐观锁的核心实现方式。CAS 是一种原子操作,它通过比较当前值与预期值是否相等来决定是否进行更新,若相等则更新,否则失败并重试。
CAS 涉及三个操作数:
- V:当前变量值(Value)。
- E:预期值(Expected)。
- N:新的值(New)。
CAS 操作: 当 V == E
时,CAS 原子地将 V
设置为 N
,否则操作失败,返回失败信息。
示例:
假设线程 A 要修改变量 i
的值为 6,初始值为 1:
- 线程 A:期望
i = 1
,准备将i
修改为 6。 - CAS 比较
i
当前值(V = 1
)与预期值(E = 1
),若相等,则将i
设置为 6(N = 6
)。 - 若其他线程已修改
i
的值(例如i = 2
),CAS 操作失败,线程 A 会重试或放弃操作。
CAS 是一种 无锁操作,并且能够保证原子性,广泛应用于高并发环境下。
底层实现:
Java 的 sun.misc.Unsafe
类提供了 compareAndSwap
方法来支持 CAS 操作,如下:
// 对象的 CAS 操作
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);
// 整数类型的 CAS 操作
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int update);
// 长整型的 CAS 操作
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);
CAS 操作依赖底层的原子指令,因此能够确保线程安全。
总结
- 版本号机制:适用于对资源进行版本控制,使用数据库中的版本号来判断是否可以提交修改,常见于数据库操作。
- CAS 算法:通过比较预期值与当前值是否相等,决定是否更新变量。它是一个原子操作,广泛应用于高性能并发环境。
乐观锁相比悲观锁在无冲突的场景下具有更好的性能,但在高竞争情况下可能会带来较高的重试成本。
Java 中 CAS 是如何实现的?
在 Java 中,CAS(Compare-And-Swap,比较并交换)操作主要依赖 Unsafe
类来实现。Unsafe
是一个提供低级别、不安全操作的类,常用于 JVM 内部或高性能库中,而不推荐普通开发者直接使用。
sun.misc.Unsafe
类提供了 compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
等方法来实现对 Object
、int
、long
类型的 CAS 操作。它们都使用底层原子指令来确保线程安全。
Unsafe
类提供的 CAS 方法
以下是 Unsafe
类提供的 CAS 方法签名:
/**
* 以原子方式更新对象字段的值。
*/
boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
/**
* 以原子方式更新 int 类型的对象字段的值。
*/
boolean compareAndSwapInt(Object o, long offset, int expected, int x);
/**
* 以原子方式更新 long 类型的对象字段的值。
*/
boolean compareAndSwapLong(Object o, long offset, long expected, long x);
这些方法是 native
方法,表示它们是由 C/C++ 实现的,直接调用底层硬件指令来完成原子操作,而不是使用 Java 实现的。这种方法确保了线程间的并发操作不会造成冲突或不一致。
Java 中 CAS 操作的具体实现
Java 并未直接实现 CAS,而是通过 Unsafe
类的底层调用(通常通过 JNI 调用 C++ 代码)来执行这些操作。Unsafe
的 CAS 方法通过原子指令来保证操作的原子性。
举例来说,Java 提供了 AtomicInteger
类,它通过 Unsafe
类的方法来实现对整数类型变量的原子操作。
AtomicInteger
使用 CAS 实现原子操作
AtomicInteger
类是 Java 中常用的原子变量类之一,它的核心操作如 compareAndSet
、getAndAdd
等,都是基于 CAS 实现的。下面是 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;
// 使用 CAS 操作,尝试将值从 expect 更新为 update
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
// 使用 CAS 操作,尝试将值加上 delta,并返回更新前的值
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
// 使用 CAS 操作,尝试将值加 1,并返回更新前的值
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
其中,compareAndSet
方法使用 unsafe.compareAndSwapInt
来进行 CAS 操作。
getAndAddInt
方法实现:
// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 获取对象 o 在内存偏移量 offset 处的值
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS 操作
return v;
}
在 getAndAddInt
方法中,首先通过 getIntVolatile
获取当前值,并使用 compareAndSwapInt
进行 CAS 操作。如果 CAS 操作失败(说明在获取值与设置值的过程中,其他线程已经修改了该值),则会重新获取当前值并重试,直到操作成功。这就是 自旋锁机制,即线程会不断尝试 CAS 直到成功。
CAS 的优势与缺点
优势:
- 无锁并发:CAS 操作通过原子指令实现,无需加锁,因此不会带来锁竞争和线程阻塞。
- 高性能:在无竞争的情况下,CAS 操作的性能比传统的加锁机制更好。
缺点:
- ABA 问题:如果变量经历了多次相同的值变化(例如从 A -> B -> A),CAS 可能会错误地认为值没有变化,从而导致数据不一致。解决办法可以使用版本号等机制。
- 自旋带来的 CPU 占用:在高并发的情况下,如果 CAS 操作频繁失败,线程会不断重试,导致较高的 CPU 占用。
- 适用范围有限:CAS 操作适用于较简单的值更新,对于复杂的数据结构,CAS 无法直接应用。
总结
在 Java 中,CAS 是通过 Unsafe
类提供的底层原子指令实现的。Unsafe
类通过调用 C/C++ 代码的原子操作来确保线程安全,常见的 CAS 操作有 compareAndSwapInt
、compareAndSwapLong
和 compareAndSwapObject
。Java 的 AtomicInteger
等类通过这些 CAS 操作实现了高效的无锁并发操作。在使用 CAS 时需要注意解决 ABA 问题,并考虑适当的重试策略来避免频繁的自旋导致性能问题。
CAS 算法存在哪些问题?
CAS(Compare-And-Swap)算法的核心优势是无锁并发,但它也存在一些问题,最典型的是 ABA 问题,以及由于自旋导致的性能开销问题。以下是 CAS 算法常见的问题和解决思路:
1. ABA 问题
ABA 问题 是 CAS 操作中的一个经典问题。在 CAS 操作中,线程读取了一个变量的值(例如 A),并在准备进行更新时再次检查该值,如果它仍然是 A,则认为没有其他线程修改过该值。然而,在这段时间内,该值可能已经被改成了 B,再被修改回 A。此时,CAS 操作可能错误地认为变量没有被修改过,从而发生逻辑错误。
解决思路:
- 版本号或时间戳:解决 ABA 问题的一种方法是给共享变量附加一个版本号或时间戳。每次更新该变量时,不仅要更新其值,还要更新版本号或时间戳。这样,CAS 操作就能通过检查版本号或时间戳来避免误判。
例如,Java 提供了 AtomicStampedReference
类,它结合了版本号的概念:
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) {
Pair<V> current = pair;
return expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
在这个类中,expectedStamp
和 newStamp
就是用来解决 ABA 问题的版本号或时间戳。
2. 自旋时间长导致的性能开销
CAS 在失败时通常会进行自旋(即不断重试),这意味着如果 CAS 操作长时间无法成功,它会不断消耗 CPU 资源,导致性能开销。这种情况在高并发环境中尤其严重,可能导致 CPU 利用率飙升,影响系统性能。
解决思路:
- 自旋次数限制:可以限制自旋的次数,当自旋超过一定次数时,放弃自旋,转而使用其他方式(如加锁或挂起)。
pause
指令:现代 CPU 提供了pause
指令,它可以延迟指令的执行,减少 CPU 资源消耗。特别是在自旋操作中,pause
指令有助于减少内存顺序冲突,并提升自旋效率。通过该指令,线程可以在不断自旋时让 CPU 休息,从而避免无效的 CPU 时间浪费。
3. 只能保证一个共享变量的原子操作
CAS 操作的设计是针对单一共享变量的,它只能保证对一个共享变量的原子操作。当需要对多个共享变量进行原子操作时,CAS 就显得不够用,必须采取其他策略。
解决思路:
AtomicReference
类:Java 提供了AtomicReference
类来保证引用对象的原子操作,可以通过将多个变量封装为一个对象并使用AtomicReference
来进行原子操作。例如,如果需要保证对多个变量的原子操作,可以将它们封装到一个对象中,并对该对象进行 CAS 操作:
AtomicReference<MyObject> atomicRef = new AtomicReference<>(new MyObject());
加锁机制:对于多个共享变量需要同步的情况,使用加锁机制是解决问题的另一种方法。例如,使用
ReentrantLock
来保证多个变量的原子性。
4. 可能导致 CPU 资源浪费
由于 CAS 在某些情况下需要频繁重试(特别是在高并发时),如果多个线程在争夺同一个变量的更新时,可能会导致大量的自旋,进而浪费 CPU 资源。此时,CAS 操作的效率会显著下降,特别是在锁竞争非常激烈时。
解决思路:
- 自旋时间限制与退让策略:可以通过限制自旋次数,或者在自旋一定次数后让当前线程休眠或放弃尝试,从而减少无效自旋导致的 CPU 资源浪费。
总结
CAS 算法的核心问题包括 ABA 问题、自旋开销、仅适用于单一共享变量和可能导致 CPU 资源浪费等。解决这些问题的常见方法包括引入版本号或时间戳解决 ABA 问题,使用 pause
指令优化自旋效率,利用 AtomicReference
或加锁机制来处理多个共享变量的原子操作,以及通过限制自旋次数来避免过度的 CPU 占用。在实际应用中,根据不同的场景选择合适的解决方案,可以有效提升 CAS 算法的性能和可靠性。
四、synchronized 关键字
synchronized 是什么?有什么用?
synchronized
是 Java 中的一个关键字,用于实现线程同步。它的主要作用是确保多线程环境中,同一时间内,只有一个线程可以执行某个方法或代码块,从而避免线程安全问题。具体来说,synchronized
保证了对共享资源的访问是互斥的,解决了多个线程并发访问共享资源时可能出现的数据竞争和冲突问题。
用法:
修饰实例方法:当
synchronized
修饰实例方法时,它会锁住当前对象实例,这意味着在同一个对象上,只有一个线程能执行该方法。public synchronized void doSomething() { // 需要同步的代码 }
修饰静态方法:当
synchronized
修饰静态方法时,它会锁住该类的Class
对象,意味着在整个类的范围内,所有线程对于该类的静态方法的访问是互斥的。public synchronized static void doSomething() { // 需要同步的代码 }
修饰代码块:
synchronized
还可以修饰代码块,这种方式可以更精细地控制同步的粒度。它会锁住指定的对象,可以是实例对象或类对象。public void doSomething() { synchronized (this) { // 需要同步的代码 } }
工作原理:
synchronized
实现了 互斥锁(mutex),在执行同步代码时,它会为对象或类加锁,只有获得锁的线程才能执行该代码块。其他线程会在此时被阻塞,直到锁被释放。
- 每个对象都有一个与之关联的监视器锁(monitor),当一个线程要访问
synchronized
修饰的代码时,它首先需要获得该对象的监视器锁。如果该锁被其他线程持有,则当前线程会被阻塞,直到该锁被释放。
性能考虑:
在 Java 6 之前,synchronized
是基于操作系统的原生锁(如 Mutex Lock
)实现的,这使得它成为一种“重量级锁”,性能相对较低,尤其是在高并发的情况下。因为每次获取和释放锁都涉及到操作系统的上下文切换,导致了性能开销。
不过,在 Java 6 之后,JVM 引入了多种优化策略来提升 synchronized
的性能,主要包括:
自旋锁:当一个线程尝试获取锁时,JVM 会先尝试自旋一段时间而不是立即挂起线程,等待锁释放。这可以减少上下文切换的开销,尤其是在锁争用较轻的情况下。
适应性自旋锁:根据锁的竞争情况,JVM 会动态调整自旋的次数。如果锁竞争激烈,则减少自旋次数,增加线程挂起的次数。
锁消除:JVM 在编译过程中会识别并消除不需要同步的代码块,避免不必要的锁操作。
锁粗化:当多次操作在同一个对象上使用锁时,JVM 会将这些操作合并为一个大的锁操作,减少多次加锁和解锁的开销。
偏向锁和轻量级锁:这两种锁机制在锁竞争不激烈时可以减少不必要的同步操作,提升性能。
- 偏向锁:默认情况下,一个线程会“偏向”于某个对象的锁,避免了锁的竞争,直到另一个线程请求锁时才会进行升级。
- 轻量级锁:在没有竞争的情况下,线程会通过自旋来尝试获取锁,而不会立即阻塞。
总结:
synchronized
用于确保多个线程访问共享资源时的同步性,避免数据竞争问题。- 在 Java 6 之后,
synchronized
的性能得到了显著优化,减少了原来的性能开销。 - 在高并发环境下,
synchronized
仍然是一个重要的同步机制,但如果需要更加细粒度的控制或高效的并发处理,也可以考虑使用ReentrantLock
等其他锁机制。
如何使用 synchronized?
synchronized
关键字的使用方式主要有三种,分别是修饰实例方法、修饰静态方法以及修饰代码块。它们都用于实现线程同步,确保在多线程环境中某一时刻只有一个线程能够访问某个资源或执行某段代码。
1. 修饰实例方法(锁当前对象实例)
当 synchronized
修饰一个实例方法时,表示该方法是同步的,进入同步代码之前,必须获取当前对象实例的锁。也就是说,同一时刻,只有一个线程可以执行该方法,对于不同的实例对象,锁是相互独立的。
public class MyClass {
synchronized void method() {
// 业务代码
}
}
- 进入同步方法前,线程需要获得当前实例对象的锁(即
this
)。 - 如果有多个线程访问同一个实例对象的同步方法,那么它们会按照顺序排队,互斥执行。
2. 修饰静态方法(锁当前类)
当 synchronized
修饰一个静态方法时,表示该方法是同步的,进入同步代码前,必须获取当前类的锁。由于静态方法属于类本身,不依赖于对象实例,因此该锁是针对整个类的锁,而不是某个实例对象。
public class MyClass {
synchronized static void method() {
// 业务代码
}
}
- 进入同步方法前,线程需要获得当前类的锁(即
MyClass.class
)。 - 如果有多个线程访问同一个类的静态同步方法,它们会互斥执行,因为锁是作用于类的。
- 注意:静态方法和实例方法之间是互斥的,但不同线程访问不同实例对象的静态同步方法时,不会发生锁冲突。
3. 修饰代码块(锁指定对象/类)
synchronized
还可以用于修饰代码块,这种方式可以精确控制同步的粒度和锁的范围。你可以指定一个对象或类来作为锁,这样只有在获取到该锁的情况下,代码块中的内容才会执行。
- 锁对象实例:使用
synchronized(this)
来加锁当前实例对象。 - 锁类对象:使用
synchronized(ClassName.class)
来加锁类对象。
public class MyClass {
void method() {
synchronized(this) {
// 业务代码
}
}
void method2() {
synchronized(MyClass.class) {
// 业务代码
}
}
}
synchronized(this)
:表示要获得当前对象实例的锁。synchronized(MyClass.class)
:表示要获得类MyClass
的锁,适用于静态资源的同步。
总结:
- 实例方法:
synchronized
修饰的实例方法是给当前对象实例加锁,确保同一个对象实例的同步。 - 静态方法:
synchronized
修饰的静态方法是给整个类加锁,确保类的所有实例之间的同步。 - 代码块:
synchronized
修饰代码块时可以指定锁对象或锁类,能够精确控制同步的范围和粒度。
注意:
- 不要使用
synchronized(String a)
等代码进行锁定,因为字符串常量池的缓存机制可能导致不同的对象实例在常量池中共享同一个锁,这可能导致不可预期的并发问题。
构造方法可以用 synchronized 修饰么?
构造方法不能使用 synchronized
关键字修饰。synchronized
是用来控制方法或代码块的同步性,而构造方法本身不允许加锁,因为在构造方法执行时,当前对象还没有完全初始化,因此锁住构造方法本身并不符合设计的目的。
不过,你可以在构造方法内部使用 synchronized
代码块,以控制构造方法中涉及到的共享资源的同步访问。
线程安全与构造方法
构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要额外的同步措施来保证整个构造过程的线程安全。例如,若构造方法中有多线程访问某些共享对象或静态资源时,可以使用 synchronized
关键字对相关代码块进行同步,确保每次只有一个线程可以访问这些资源。
示例
public class MyClass {
private static int counter = 0;
// 构造方法内部使用 synchronized 代码块
public MyClass() {
synchronized (MyClass.class) {
counter++;
System.out.println("Counter: " + counter);
}
}
}
在上面的例子中,MyClass
的构造方法内部使用了 synchronized
代码块来同步对静态变量 counter
的访问,确保在多线程环境下,counter
的更新操作是线程安全的。
总结
- 构造方法不能用
synchronized
修饰,但可以在构造方法内使用synchronized
代码块进行同步。 - 构造方法是线程安全的,但如果构造方法中涉及到共享资源操作,仍然需要额外的同步措施来确保线程安全。
synchronized 底层原理
synchronized
关键字是 JVM 实现同步的基础,它通过 对象监视器(monitor) 来确保线程安全。其底层实现涉及锁的获取和释放,以及同步语句块和方法的字节码转换。
字节码指令
synchronized
语句块或方法在字节码层面的实现是通过 monitorenter
和 monitorexit
指令来控制锁的获取和释放。
- monitorenter:当执行到此指令时,线程会尝试获取对象的锁。如果对象锁为空,锁计数器为 0,当前线程将锁计数器加 1,并持有锁。如果锁已被其他线程持有,当前线程将会阻塞。
- monitorexit:当执行到此指令时,线程释放锁,将对象锁的计数器减 1。如果锁计数器为 0,其他线程可以尝试获取锁。
同步方法
对于 synchronized
修饰的方法,JVM 在字节码中通过 ACC_SYNCHRONIZED
标志来标记该方法需要同步。当线程调用此方法时,JVM 会尝试获取当前对象的锁(实例方法为实例锁,静态方法为类锁)。该方法没有 monitorenter
和 monitorexit
指令,JVM 会通过标志来处理同步。
对象监视器(monitor)
每个 Java 对象都隐式地拥有一个 对象监视器(monitor),这是 JVM 用于管理锁的内部机制。对象监视器通过 ObjectMonitor
类实现,负责协调多个线程对该对象的并发访问。当一个线程进入同步代码块或方法时,它会获取该对象的监视器,并在退出时释放监视器。
总结
synchronized
的底层机制是通过 对象监视器 和字节码指令(monitorenter
和 monitorexit
)来实现的。同步方法通过 ACC_SYNCHRONIZED
标志来标识,并确保线程对共享资源的互斥访问。
JDK 1.6 之后 synchronized
锁的优化和锁升级原理
在 JDK 1.6 及之后的版本中,Java 对 synchronized
锁进行了大量的优化,目的是提高并发性能,减少锁的开销。这些优化主要包括 自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁 和 轻量级锁。
1. 偏向锁(Biased Locking)
偏向锁是为了避免没有竞争的情况下获取锁的开销。偏向锁会优先将锁分配给第一个请求的线程。如果该线程后续还需要获取锁,它将不再进行同步操作,而直接获取锁。只有在出现竞争时,偏向锁才会被撤销,锁会升级为轻量级锁或重量级锁。
偏向锁的引入主要是为了减少没有竞争的情况下的锁获取开销。但是偏向锁在多线程高度竞争的情况下会增加性能开销,因此在 JDK 15 中偏向锁被默认关闭,在 JDK 18 中彻底废弃。
2. 轻量级锁(Lightweight Locking)
轻量级锁是通过 CAS(比较并交换)操作来实现的。它的核心思想是在没有竞争的情况下,线程会尝试通过 CAS 原子操作获取锁,而不是阻塞当前线程。如果获取锁成功,线程就可以进入同步代码块;如果获取失败(即存在竞争),锁会升级为重量级锁。
轻量级锁通过减少线程挂起的次数来提高性能,避免了重量级锁所涉及的操作系统调度开销。它适用于竞争较少的场景。
3. 自旋锁(Spin Lock)
自旋锁是在轻量级锁的基础上进行优化的。当一个线程尝试获取锁时,如果锁已经被其他线程持有,它不会立即阻塞等待,而是会在短时间内反复尝试获取锁,这就是自旋。通过不断“自旋”,线程避免了频繁的上下文切换(即不需要进行操作系统级的线程调度),从而提高了性能。
自旋锁有一个问题是,如果锁一直被占用,线程将一直进行自旋,占用 CPU 资源。因此,JVM 实现了 适应性自旋锁,它会根据当前线程的竞争情况动态调整自旋的次数。
4. 适应性自旋锁(Adaptive Spinning)
适应性自旋锁是对自旋锁的一种改进,它根据不同的竞争情况动态调整自旋次数。JVM 会根据历史情况(比如锁的获取失败次数)来决定自旋的次数。如果锁的竞争非常激烈,JVM 会减少自旋次数,避免浪费 CPU 资源。相反,如果锁的竞争较少,JVM 会增加自旋次数,从而提高性能。
5. 锁消除(Lock Elimination)
锁消除技术的目标是消除那些根本不需要锁的代码段。在 JDK 6 之后,JVM 会对代码进行逃逸分析,检测到某些锁操作在编译时就能够确定该锁对象永远不会被共享(比如局部变量),因此不需要加锁。这样做能够有效减少不必要的锁操作,提高程序执行效率。
6. 锁粗化(Lock Coarsing)
锁粗化是一种通过将多个小范围的同步块合并成一个大的同步块来减少锁的开销的方法。当 JVM 检测到多个相邻的同步代码块锁住的是同一个对象时,它会将这些同步块合并成一个大同步块,从而减少锁操作的次数。
锁的升级过程
锁的升级是为了应对不同程度的竞争,JVM 会根据竞争的情况动态升级锁的状态。锁的状态有四种,分别是:
- 无锁(No Locking):当没有任何线程请求锁时,锁是无状态的,不存在任何同步操作。
- 偏向锁(Biased Locking):当只有一个线程访问同步代码块时,JVM 会为该线程偏向锁。偏向锁在没有竞争的情况下避免了同步操作。
- 轻量级锁(Lightweight Locking):当有多个线程请求锁,但竞争不激烈时,JVM 会使用轻量级锁。线程通过 CAS 操作获取锁,避免了阻塞线程。
- 重量级锁(Heavyweight Locking):当锁的竞争非常激烈时,JVM 会使用重量级锁。线程会被阻塞,直到获取锁。重量级锁的开销较大,因为它涉及到操作系统的线程调度。
锁的升级过程是不可降级的
锁可以从偏向锁升级为轻量级锁,再升级为重量级锁,但一旦升级为重量级锁,就不会再降级。这个策略是为了避免反复的锁状态切换,提高性能。
总结
JDK 1.6 之后,Java 对 synchronized
锁的底层实现进行了大量优化,包括自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁和轻量级锁等技术。这些优化极大地减少了锁操作的开销,提高了多线程并发性能。锁的升级过程会根据线程竞争的激烈程度逐步升级,从偏向锁到轻量级锁,再到重量级锁,而锁一旦升级到重量级锁后就不可降级。
为什么 JDK 18 废弃偏向锁?
在 JDK 15 中,偏向锁被默认关闭(通过 -XX:+UseBiasedLocking
仍可启用)。在 JDK 18 中,偏向锁被彻底废弃,无法通过任何命令行参数启用。官方的主要原因包括以下两个方面:
1. 性能收益不明显
偏向锁的初衷是为了优化单线程访问同步代码块的性能,特别是在单线程环境下频繁访问同步方法时。它通过将锁“偏向”第一个获取锁的线程,从而避免了竞争带来的同步开销。
偏向锁适用于早期的 Java 集合类(如
HashTable
和Vector
)等,它们通过synchronized
关键字实现同步。当这些集合类在单线程环境中频繁使用时,偏向锁能显著减少同步开销。然而,随着
ConcurrentHashMap
等高性能集合类的出现,这些类通过更高效的并发控制(如分段锁、CAS 操作等)避免了synchronized
的使用,偏向锁带来的性能提升变得不再明显。偏向锁的优势仅体现在 无竞争 的单线程场景。如果有线程竞争,偏向锁的撤销操作就会成为性能瓶颈。撤销偏向锁的操作需要线程在全局安全点(safe point)暂停后,检查并撤销偏向锁,带来额外的性能开销。
2. JVM 内部维护成本过高
偏向锁的实现引入了很多复杂的代码,增加了 HotSpot 虚拟机的维护成本,尤其是在代码重构和系统优化时,增加了理解和管理的难度。
- 偏向锁需要与 JVM 的其他组件紧密配合,导致很多部分变得更为复杂且易出错。
- 实现偏向锁的过程中涉及了全局安全点的同步和锁的撤销机制,这增加了 JVM 的维护成本,并使得该机制变得更加难以理解和调试。
总结
JDK 18 废弃偏向锁的主要原因是其带来的性能收益在现代应用中已不再明显,尤其是多线程竞争的场景下,偏向锁的撤销操作反而带来性能开销。再者,偏向锁的实现增加了 JVM 的复杂性,影响了系统的可维护性。因此,OpenJDK 决定废弃并移除偏向锁,简化代码并提高系统的整体效率。
synchronized 和 volatile 的区别
synchronized
和 volatile
都是用于解决多线程并发问题的关键字,但它们的作用和适用场景不同:
1. 作用
volatile
:主要用于保证变量的 可见性。它确保一个线程对变量的修改对其他线程是立即可见的。volatile
禁止 JVM 对该变量的值进行缓存,所有的读写操作都直接作用于主内存。适用于简单的共享变量(如状态标识、标志位等),但是 无法保证原子性。synchronized
:用于保证 可见性 和 原子性。它通过加锁机制保证一个线程对资源的修改对其他线程是可见的,同时确保在一个线程执行同步代码块时,其他线程不能同时执行该代码块。synchronized
可以用于修饰方法或代码块,适用于需要协调线程访问共享资源的场景。
2. 性能
volatile
:由于其只是对单个变量进行同步,且操作相对简单(没有锁竞争的开销),因此比synchronized
更轻量级,性能更高,适用于只有单个线程修改的简单场景。synchronized
:由于涉及到线程阻塞、锁的竞争、上下文切换等操作,相对较为消耗性能,尤其是高并发时。
3. 保证的特性
volatile
:- 只保证变量的 可见性,即一个线程对变量的修改对其他线程是可见的。
- 不保证原子性,多个线程同时读写
volatile
变量时,可能会导致数据不一致。
synchronized
:- 保证 可见性:通过内存屏障,确保一个线程对变量的修改能及时对其他线程可见。
- 保证 原子性:当一个线程持有同步锁时,其他线程无法访问同步代码块,避免了数据竞争问题。
4. 适用场景
volatile
:适用于状态标志、单一变量的更新,常见于控制循环、停止线程等场景,如boolean
类型的标志位,或者类似于singleton
单例模式中的双重检查锁(DCL
)中的变量。synchronized
:适用于对 多个共享变量 进行操作的场景,尤其是需要保证操作的原子性和同步性时,例如数据库操作、资源计数等复杂的多线程并发操作。
5. 实现方式
volatile
:通过内存屏障 (load
、store
),确保对变量的写入操作直接反映到主内存,读操作直接从主内存中读取。synchronized
:通过获取锁(对象锁或类锁)来确保同步,涉及操作系统级别的上下文切换和线程调度。
总结
volatile
主要解决的是变量的 可见性 问题,适用于简单的共享变量操作,性能高但不能保证原子性。synchronized
解决的是 原子性 和 可见性 问题,适用于需要保证多个线程对共享资源的同步访问,性能相对较低。
五、ReentrantLock
ReentrantLock 介绍
ReentrantLock
是 Java 中的一个显式锁实现,它实现了 Lock
接口,提供了比 synchronized
关键字更灵活、更强大的锁控制。与 synchronized
相似,ReentrantLock
提供了互斥性,即同一时间只有一个线程可以持有锁,但它的特点和功能更加丰富。它是一个 可重入锁,也就是说,同一线程可以多次获得同一把锁而不会发生死锁。
主要特性
可重入性:
- 一个线程如果已经获取了锁,则可以再次获取锁而不会被阻塞。
独占性:
- 与
synchronized
类似,同一时间只有一个线程能够持有ReentrantLock
。
- 与
灵活性:
ReentrantLock
提供了比synchronized
更加丰富的功能,比如轮询、超时、响应中断等。
公平性和非公平性:
- 默认情况下,
ReentrantLock
使用非公平锁(NonfairSync
),也可以通过构造函数指定使用公平锁(FairSync
)。公平锁会保证线程获取锁的顺序与请求锁的顺序一致,而非公平锁则是基于竞争,可能导致线程饥饿问题。
- 默认情况下,
主要方法
lock()
:- 获取锁,如果锁被其他线程占用,调用线程会被阻塞直到获取到锁。
unlock()
:- 释放锁,允许其他线程获取锁。如果当前线程没有持有锁,会抛出
IllegalMonitorStateException
异常。
- 释放锁,允许其他线程获取锁。如果当前线程没有持有锁,会抛出
tryLock()
:- 尝试获取锁,如果锁不可用(被其他线程占用),立即返回
false
,而不阻塞当前线程。这个方法有多种重载,支持等待超时的情况。
- 尝试获取锁,如果锁不可用(被其他线程占用),立即返回
lockInterruptibly()
:- 获取锁,如果当前线程在等待锁时被中断,它将抛出
InterruptedException
,而不像lock()
一样阻塞线程。
- 获取锁,如果当前线程在等待锁时被中断,它将抛出
newCondition()
:- 创建一个与当前锁关联的
Condition
对象,支持更细粒度的线程协作(类似于Object.wait()
、Object.notify()
,但是Condition
提供了更多控制)。
- 创建一个与当前锁关联的
公平锁和非公平锁
公平锁(Fair Lock):公平锁会按照线程请求的顺序分配锁,即最先请求锁的线程会最先获得锁。公平锁的实现会有额外的性能开销,因为它需要维护线程的等待队列,确保公平性。
非公平锁(Non-Fair Lock):非公平锁没有严格的顺序,它可能会导致线程饥饿问题,但相对而言,性能更好。默认情况下,
ReentrantLock
使用非公平锁。
实现原理
ReentrantLock
是基于 AQS(AbstractQueuedSynchronizer
)来实现的,AQS 是 Java 并发包中的一个基础类,提供了通过 FIFO 队列来管理锁和同步状态的能力。ReentrantLock
通过继承 AQS
的内部类 Sync
来实现具体的锁逻辑。Sync
中有两个子类:
FairSync
:实现公平锁的逻辑。NonfairSync
:实现非公平锁的逻辑。
AQS
提供了基于队列的同步机制,使得线程可以在请求锁时进入一个等待队列,并按照先来先服务的方式进行调度。
总结
ReentrantLock
提供了比synchronized
更灵活的控制,支持公平锁和非公平锁、超时、轮询、响应中断等功能。- 它是基于
AQS
实现的,能够精确控制线程的同步与调度。 - 适用于需要复杂同步逻辑的场景,尤其是在多个线程竞争资源时。
公平锁和非公平锁的区别
公平锁:
- 锁的获取遵循 先到先得 的原则,确保按请求锁的顺序来分配锁。
- 公平锁会确保没有线程被饿死,即所有线程都会按照顺序获得锁。
- 性能较差,因为它需要维护一个队列来保证线程获取锁的顺序,这会增加上下文切换的频率,从而降低性能。
非公平锁:
- 锁的获取不遵循严格的顺序,当前线程释放锁后,后申请的线程可能会抢先获取锁。
- 非公平锁的性能更好,因为它减少了对线程的排队和上下文切换的开销。
- 可能导致 线程饥饿,即某些线程长期得不到锁,从而无法执行。
总结
- 公平锁:保证线程获取锁的顺序,但性能较差。
- 非公平锁:性能较好,但可能会导致某些线程无法获取到锁。
synchronized
与 ReentrantLock
的区别
1. 锁的类型与实现机制
- synchronized:是 Java 内置的关键字,依赖于 JVM 实现。它是一个重量级锁,操作由 JVM 层面控制,不需要显式地调用方法来加锁和解锁。同步机制内置在代码结构中。
- ReentrantLock:是
java.util.concurrent.locks
包中的一个类,依赖于 API 层面实现。它是一个显式的锁,必须通过lock()
和unlock()
来手动控制锁的获取与释放,提供了更多的灵活性。
2. 可重入性
- 两者都支持可重入性:一个线程如果已经获得了锁,仍然可以再次进入被该锁保护的代码块而不会导致死锁。
3. 高级功能
- ReentrantLock 提供了比
synchronized
更多的高级功能:- 等待可中断:可以在等待锁的过程中响应中断。使用
lockInterruptibly()
方法,允许线程在阻塞状态下响应中断。 - 公平锁与非公平锁:可以通过构造函数选择是否使用公平锁。公平锁确保线程按请求顺序获取锁,非公平锁则允许后请求的线程先获得锁,具有更好的性能。
- 超时锁:
ReentrantLock
支持tryLock(long time, TimeUnit unit)
方法,可以指定等待锁的超时时间,超时后自动放弃获取锁,避免死锁和线程饥饿。 - 多个条件变量:
ReentrantLock
可以与多个Condition
变量配合使用,提供灵活的线程协调机制(而synchronized
只支持一个wait
/notify
)。
- 等待可中断:可以在等待锁的过程中响应中断。使用
4. 性能差异
- ReentrantLock 提供了更多的功能,但这些功能也带来了一些性能开销。对于大部分简单场景,
synchronized
的性能可能会更好,因为它是 JVM 层面优化的。 - ReentrantLock 通过显式的锁控制,使得开发者可以根据具体需求选择适合的锁策略,比如公平锁或非公平锁。
5. 使用灵活性
- synchronized:自动管理锁的获取与释放。它的简洁性和容易使用是其优点,但灵活性较差。
- ReentrantLock:必须手动调用
lock()
和unlock()
,更灵活,但更容易出错(例如在没有finally
代码块中忘记解锁可能会导致死锁)。
6. 使用场景
- synchronized 更适合简单的同步场景,特别是对于代码块或方法的同步,开发者只需关注同步的目标对象。
- ReentrantLock 更适合复杂的同步需求,尤其是需要控制锁的获取顺序(公平锁)、响应中断或超时等需求的场景。
总结
synchronized
简单、自动管理锁,适用于简单的线程同步需求。ReentrantLock
提供了更多灵活的功能,如中断响应、超时等待和公平锁机制,适用于复杂的同步控制。
可中断锁和不可中断锁的区别
可中断锁
- 获取锁的过程中,线程可以响应中断,允许线程在等待锁时被中断。
- 常见实现:
ReentrantLock
,它提供了lockInterruptibly()
方法,线程可以在等待锁的过程中响应中断。
不可中断锁
- 获取锁的过程中,线程无法中断,只能等待锁被释放后才能继续执行。
- 常见实现:
synchronized
,一旦进入等待锁的状态,线程将一直阻塞直到获取到锁。
六、ReentrantReadWriteLock
ReentrantReadWriteLock
在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock
。
ReentrantReadWriteLock 是什么?
ReentrantReadWriteLock
实现了 ReadWriteLock
接口,是一个可重入的读写锁,旨在提供高效的并发控制,允许多个线程并发读取,同时确保写操作的独占性。
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
}
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
锁的控制规则
- 读读互斥:多个线程可以同时持有读锁,不会互斥。
- 读写互斥:读锁和写锁之间是互斥的,即一个线程持有读锁时,其他线程无法获取写锁,反之亦然。
- 写写互斥:多个线程获取写锁时,必须独占锁。
组成
ReentrantReadWriteLock
实际上有两把锁:
- WriteLock(写锁):独占锁,只能被一个线程持有。
- ReadLock(读锁):共享锁,允许多个线程同时持有。
实现
ReentrantReadWriteLock
基于 AQS(Abstract Queued Synchronizer)实现,支持公平锁和非公平锁。
- 公平锁:线程按请求锁的顺序来获取锁。
- 非公平锁:不保证请求锁的顺序,可能导致一些线程“饥饿”。
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
ReentrantReadWriteLock 适合什么场景?
ReentrantReadWriteLock
适合用于读多写少的场景,因为它允许多个线程同时读取数据,提高了读操作的并发性和效率,同时保证了写操作的线程安全性。在这种场景下,ReentrantReadWriteLock
能有效减少锁竞争,提升系统的整体性能。
具体应用场景包括:
- 缓存系统中的数据读取。
- 数据库中频繁查询但少量更新的情况。
共享锁和独占锁的区别
- 共享锁:允许多个线程同时持有该锁。多个线程可以并发地读取共享资源,但不能对资源进行修改。
- 独占锁:每次只能有一个线程持有该锁。其他线程必须等待,直到持有该锁的线程释放锁。
线程持有读锁还能获取写锁吗?
- 线程持有读锁时,不能获取写锁。因为在获取写锁时,如果发现当前有读锁(即使是当前线程持有的),也会获取失败,写锁的获取会被阻塞。
- 线程持有写锁时,可以继续获取读锁。因为写锁是独占锁,线程已经持有写锁,可以在持有写锁期间获得读锁,不会发生冲突,除非有其他线程持有写锁。
读锁为什么不能升级为写锁?
- 性能问题:读锁是共享锁,允许多个线程并发读取,而写锁是独占锁。读锁升级为写锁可能导致其他线程的读锁被阻塞,进而引发线程争夺,降低系统的并发性和性能。
- 死锁问题:如果两个线程都持有读锁并尝试升级为写锁,它们会相互等待对方释放锁,这可能会导致死锁的发生。死锁是不可避免的,因为每个线程都在等待对方释放资源,从而无法继续执行。
七、StampedLock
StampedLock
面试中问的比较少,不是很重要,简单了解即可。
StampedLock 是什么?
StampedLock
是 JDK 1.8 引入的一种性能更好的读写锁。与传统的读写锁不同,StampedLock
不支持条件变量 Condition
,且不可重入。它是基于 CLH 锁 独立实现的,类似于 AQS(Abstract Queued Synchronizer)。
public class StampedLock implements java.io.Serializable {
}
主要特性
StampedLock
提供了三种锁模式:
- 写锁:独占锁,任何线程在持有写锁时,其他线程无法获取读锁或写锁。
- 读锁(悲观读):共享锁,允许多个线程同时持有读锁,但若有线程持有写锁,则其他线程的读锁会被阻塞。
- 乐观读:允许多个线程同时获取乐观读锁并访问共享资源。在获取写锁时,乐观读锁可以并行存在。
锁的转换
StampedLock
支持三种锁之间的转换:
tryConvertToWriteLock(long stamp)
:尝试将当前锁转换为写锁。tryConvertToReadLock(long stamp)
:尝试将当前锁转换为读锁。tryConvertToOptimisticRead(long stamp)
:尝试将当前锁转换为乐观读锁。
锁的获取与释放
当线程获取锁时,StampedLock
返回一个 long
类型的时间戳(stamp),用于后续的锁释放操作。如果返回的时间戳为 0,则表示锁获取失败。由于 StampedLock
不支持重入,线程每次获取锁都会返回新的时间戳。
// 写锁
public long writeLock() {
long s, next;
return ((((s = state) & ABITS) == 0L &&
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
next : acquireWrite(false, 0L));
}
// 读锁
public long readLock() {
long s = state, next;
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
next : acquireRead(false, 0L));
}
// 乐观读
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
使用场景
- 读多写少的场景:
StampedLock
提供了乐观读和悲观读,可以在读操作较多的情况下提高性能。 - 高并发的资源访问:相比于传统的
ReentrantReadWriteLock
,StampedLock
在某些场景下能提供更高的并发性能。
StampedLock 的性能为什么更好?
StampedLock
的性能比传统的 ReadWriteLock
更好,关键在于它引入了 乐观读 模式。
乐观读:允许多个线程同时获取乐观读锁,并且不会阻塞写线程。写线程仍然可以获取写锁,而不需要等待所有的读锁释放。这个特性大大提高了读多写少场景下的并发性,减少了写线程的阻塞等待时间。
减少线程饥饿:在传统的
ReadWriteLock
中,写锁是独占的,写线程可能会因为长时间有读线程持有读锁而导致饥饿现象。StampedLock
通过乐观读模式允许写线程在一定条件下获取写锁,避免了线程饥饿,从而提高了系统的吞吐量。高效锁控制:相比于传统的锁,
StampedLock
在没有争用的情况下能高效地获取锁,且能有效地避免锁竞争和线程的阻塞。通过将读锁和写锁的获取分开管理,StampedLock
提供了比ReentrantReadWriteLock
更灵活的并发控制。
StampedLock 适合什么场景?
- 读多写少的场景:
StampedLock
与ReentrantReadWriteLock
类似,适合用于读多写少的业务场景。因为StampedLock
提供了乐观读和悲观读模式,能够在保证线程安全的前提下,提高读操作的并发性和性能。 - 性能要求高的场景:如果在某些情况下对性能有较高要求,并且不涉及条件变量
Condition
或中断支持,StampedLock
可以替代ReentrantReadWriteLock
提供更好的性能。
注意事项
- 不可重入:
StampedLock
不支持重入,因此需要注意避免死锁或逻辑错误。 - 不支持条件变量
Condition
:如果需要基于条件变量的复杂同步控制,StampedLock
不适合。 - 中断支持较差:
StampedLock
对中断的支持较弱,使用不当可能导致 CPU 占用过高,特别是在高并发场景中。 - 复杂性:虽然性能较好,但
StampedLock
的使用相对复杂,容易因错误使用引发生产环境中的问题,因此在使用前需要仔细阅读官方文档和案例。
StampedLock 的底层原理
StampedLock
是基于 CLH 锁(Craig, Landin, and Hagersten)实现的,CLH 锁是一种高效的自旋锁改进,使用隐式队列来管理线程的排队。与传统的锁相比,CLH 锁通过队列避免了传统自旋锁的忙等待问题,使得线程竞争和等待更加高效。StampedLock
通过 CLH 锁实现了对不同类型锁的管理,同时利用状态变量 state
来表示锁的状态和锁的类型。
核心原理
CLH 锁队列:
StampedLock
使用 CLH 锁队列来管理线程竞争。每个线程在请求锁时会被加入队列,等待锁的释放。CLH 锁的优势在于避免了传统自旋锁中的忙等待,通过隐式的链表管理线程,保证线程按顺序获得锁,从而提高效率。状态值
state
:StampedLock
使用一个long
类型的状态值state
来表示当前锁的状态。这个状态值被用来区分不同的锁类型(读锁、写锁、乐观读锁)。每次获取锁时,StampedLock
会返回一个时间戳(stamp
),并使用该时间戳来释放锁。由于StampedLock
不支持重入,它每次获取锁时都会生成一个新的时间戳,确保线程在获取锁时不会陷入重入死锁问题。乐观读锁:与传统的读写锁不同,
StampedLock
提供了乐观读模式。在乐观读模式下,线程可以在没有获取锁的情况下进行读取操作,前提是没有其他写线程争用锁。乐观读提升了并发性能,避免了不必要的阻塞。锁的转换功能:
StampedLock
支持不同锁模式间的转换。例如,它允许线程在持有写锁的情况下将其转换为读锁,或从读锁转换为乐观读锁,这提供了更大的灵活性来优化性能。
AQS 与 StampedLock 的相似性
StampedLock
的底层实现与 AQS(Abstract Queued Synchronizer)非常相似,AQS 是一个框架,提供了构建锁和同步器的基础。StampedLock
通过一个队列来管理等待线程,并利用状态变量控制锁的状态。尽管 StampedLock
没有直接继承 AQS,但其实现原理与 AQS 很接近,都是基于队列和状态变量来处理线程同步。
总结
StampedLock
提供了比传统的 ReentrantReadWriteLock
更高效的并发控制,尤其是在读多写少的场景下。通过 CLH 锁的队列机制、状态变量的管理以及乐观读模式,StampedLock
提供了更强的灵活性和更低的锁竞争开销,适合用于高并发场景。但它的使用复杂度较高,需要小心管理线程状态,避免不当使用导致性能下降或死锁问题。
八、Atomic 原子类
Atomic 原子类部分的内容我单独写了一篇文章来总结:Atomic 原子类总结 。
参考
- 《深入理解 Java 虚拟机》
- 《实战 Java 高并发程序设计》
- Guide to the Volatile Keyword in Java - Baeldung:https://www.baeldung.com/java-volatile
- 不可不说的 Java“锁”事 - 美团技术团队:https://tech.meituan.com/2018/11/15/java-lock.html
- 在 ReadWriteLock 类中读锁为什么不能升级为写锁?:https://cloud.tencent.com/developer/article/1176230
- 高性能解决线程饥饿的利器 StampedLock:https://mp.weixin.qq.com/s/2Acujjr4BHIhlFsCLGwYSg
- 理解 Java 中的 ThreadLocal - 技术小黑屋:https://droidyue.com/blog/2016/03/13/learning-threadlocal-in-java/
- ThreadLocal (Java Platform SE 8 ) - Oracle Help Center:https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html