锁
约 16638 字大约 55 分钟
2025-03-12
公平锁和非公平锁都是内部类。读锁和写锁也都是内部类。
Lock 接口和synchronized同步对比它有什么优势?
在 Java 中,Lock
和 synchronized
都是用于实现线程同步的机制,能够保证多线程环境下的代码安全性,但它们在实现方式、灵活性和功能上存在一些区别。以下是 Lock
和 synchronized
的详细对比。
1. 基本概念
synchronized
:synchronized
是 Java 的关键字,用于方法或代码块上,表示一段同步代码。它通过获取对象锁来确保同一时刻只有一个线程可以执行synchronized
修饰的代码块或方法,其他线程会阻塞等待锁释放。Lock
:Lock
是java.util.concurrent.locks
包下的一个接口,是一种显式的锁机制。Lock
提供了比synchronized
更加灵活的锁操作,可以在代码中精确地控制加锁和解锁的过程。常用的实现类是ReentrantLock
。
2. synchronized
和 Lock
的主要区别
都是可重入的。
特性 | synchronized | Lock |
---|---|---|
实现方式 | JVM 层面实现,基于内部锁和对象监视器 | Java API 实现,通过代码显式控制加锁和解锁 |
灵活性 | 不支持手动控制锁的获取和释放 | 支持手动控制加锁和解锁,灵活度更高 |
锁的释放 | 自动释放锁,代码块或方法执行完后自动释放 | 必须手动释放锁,否则可能导致死锁 |
重入性 | 支持重入锁 | 支持重入锁 |
锁的公平性 | 非公平锁 | ReentrantLock 可以选择公平锁或非公平锁 |
响应中断 | 线程在等待时不可被中断 | 可响应中断 |
超时功能 | 不支持超时锁定 | 支持超时等待,允许在指定时间内尝试获取锁 |
条件变量 | 内部提供 wait() 、notify() 、notifyAll() | 提供 Condition 接口,支持多个条件变量 |
3. 主要区别详解
3.1 锁的获取和释放
synchronized
:synchronized
是隐式锁,由 JVM 自动管理。进入synchronized
代码块或方法时自动加锁,代码执行完后自动释放锁,释放锁不需要显示调用任何代码。即使异常退出,synchronized
也会自动释放锁。Lock
:Lock
是显式锁,需要在代码中手动加锁和解锁。典型用法是使用lock()
方法加锁,使用unlock()
方法解锁。必须确保解锁操作放在finally
块中,否则可能因异常导致死锁。Lock
的显式控制更加灵活,但也需要更谨慎的编写代码。
示例:
// 使用 synchronized
synchronized (this) {
// 执行同步代码
}
// 使用 Lock
Lock lock = new ReentrantLock();
try {
lock.lock();
// 执行同步代码
} finally {
lock.unlock(); // 必须手动释放锁
}
3.2 锁的公平性
synchronized
:synchronized
的锁是非公平的。锁释放后,等待锁的线程不保证按先后顺序获取锁,有可能发生“抢占”行为。Lock
:Lock
可以选择公平锁或非公平锁。通过ReentrantLock
构造方法可以指定锁的公平性:- 公平锁:等待时间最长的线程优先获得锁。
- 非公平锁:抢占式,线程有机会“插队”获取锁,性能较高。默认模式。
Lock lock = new ReentrantLock(true); // 公平锁
3.3 中断响应
synchronized
:synchronized
不支持中断响应,线程在等待获取锁的过程中无法响应中断。当一个线程阻塞在synchronized
方法或代码块上时,即使它被中断,仍然会继续等待,直到获得锁或被异常中断。Lock
:Lock
可以响应中断,支持在等待过程中中断线程。使用lockInterruptibly()
方法时,可以在等待锁时响应中断,这在某些需要控制锁等待时间的场景非常有用。
try {
lock.lockInterruptibly(); // 可以响应中断的加锁方式
// 执行同步代码
} catch (InterruptedException e) {
// 响应中断
} finally {
lock.unlock();
}
3.4 超时功能
synchronized
:synchronized
不支持超时功能。线程一旦开始等待锁,只有等到锁被释放才能获得锁,没有超时退出的机制。Lock
:Lock
支持超时等待功能,可以在指定时间内尝试获取锁。使用tryLock(long timeout, TimeUnit unit)
方法,可以指定超时时间,如果在超时内没有获得锁,则返回false
,线程可以继续其他任务。
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) { // 超时等待锁
try {
// 执行同步代码
} finally {
lock.unlock();
}
} else {
System.out.println("Could not acquire lock within 1 second.");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
3.5 条件变量
synchronized
:synchronized
使用wait()
、notify()
和notifyAll()
进行线程间的协调,所有的等待和唤醒必须依赖于对象监视器的单一条件。Lock
:Lock
提供了Condition
类来实现条件等待和通知机制,支持多个条件变量。可以通过newCondition()
方法创建多个Condition
对象,使用await()
和signal()
/signalAll()
实现等待和唤醒。Condition
比wait()
和notify()
更加灵活,可以实现更复杂的线程间协作。
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
try {
lock.lock();
condition.await(); // 等待条件
// 条件满足后执行的代码
} finally {
lock.unlock();
}
4. 使用场景对比
synchronized
:适合简单的同步需求,例如单一条件、结构简单的同步块。synchronized
的自动管理和 JVM 优化使其适合大多数场景,代码简洁,发生异常时能够自动释放锁。Lock
:适合复杂的并发控制需求。例如,需要在不同的条件变量之间切换、需要响应中断、需要超时等待、需要公平锁等情形。Lock
提供的灵活性更高,但使用时需要谨慎,确保在finally
中释放锁,避免死锁。
5. 性能比较
- 在早期的 Java 版本中,
Lock
的性能通常优于synchronized
。但是从 JDK 1.6 开始,JVM 对synchronized
进行了大量优化,例如偏向锁、轻量级锁、锁消除和锁粗化等技术,这些优化大大提高了synchronized
的性能,缩小了与Lock
的性能差距。 - 在大多数情况下,
synchronized
可以满足需求,且具有良好的性能。而Lock
在某些特定的高并发需求下可能表现更好。
6. 总结
特性 | synchronized | Lock |
---|---|---|
实现方式 | JVM 层面实现,隐式加锁与解锁 | 代码层面实现,显式加锁与解锁 |
自动释放锁 | 自动释放 | 需手动释放 |
公平性 | 非公平锁 | 支持公平锁和非公平锁 |
中断响应 | 不支持 | 支持 |
超时功能 | 不支持 | 支持 |
条件变量 | 单一条件,使用 wait() 和 notify() /notifyAll() | 多个条件,使用 Condition 的 await() 、signal() 和 signalAll() |
使用场景 | 简单的同步需求 | 复杂的并发需求,如超时、条件变量 |
性能 | 从 JDK 1.6 开始性能接近 | 在高并发场景中有较好的表现 |
在实际开发中,synchronized
适合用于一般的同步需求,代码简洁且自动管理锁的释放。而 Lock
更适合复杂的并发控制,具有更高的灵活性。开发者应根据具体需求选择合适的同步机制。
怎么理解Lock与AQS的关系?
Lock是面向锁的使用者的,他定义了使用者与锁的交互接口,隐藏了实现细节。而AQS是面向锁的实现者的,它简化了锁的实现方式,屏蔽了同步状态的管理,线程的排队,等待与唤醒等底层操作。锁和同步器很好的隔离了使用者和实现者所需关注的领域。
在 Java 中,Lock
接口和 AQS(AbstractQueuedSynchronizer,抽象队列同步器)是紧密相关的。Lock
接口定义了锁的基本行为,而 AQS 是用于实现锁和其他同步器(如信号量、倒计时锁存器等)底层的框架。AQS 通过提供通用的同步机制,简化了自定义同步器的开发。ReentrantLock
、CountDownLatch
、Semaphore
等同步器都基于 AQS 实现。
1. Lock
接口和 AQS 的关系
Lock
是接口:Lock
是一个接口,定义了锁的基本操作方法,例如lock()
、unlock()
。它本身并不提供具体的同步实现,而是依赖于 AQS 提供的同步机制。- AQS 是基础实现:AQS 是一个抽象类,主要通过队列(FIFO)和状态变量(state)来实现同步。AQS 提供了一些低层的同步方法,如
acquire()
和release()
,允许实现类利用这些方法来管理线程的排队、等待和唤醒。 - 典型实现:
ReentrantLock
是Lock
接口的一个实现,也是AQS的子类,内部依赖 AQS 来完成线程的排队和同步控制。AQS 为ReentrantLock
的非公平锁和公平锁提供了两种实现方式,使得ReentrantLock
能够灵活选择锁的策略。
2. AQS 的设计结构
AQS 的核心结构包括以下几个方面:
- 共享变量
state
:AQS 使用一个volatile
修饰的int
类型变量state
表示同步状态,state
可以根据需要定义不同的含义。- 在
ReentrantLock
中,state
表示当前锁的获取次数(可重入的次数)。 - 在
CountDownLatch
中,state
表示倒计时的计数值。
- 在
- FIFO 等待队列:AQS 内部维护了一个 FIFO 队列,称为“同步队列”,用于管理等待锁的线程。线程无法获得锁时,会被封装成
Node
结点加入到队列中,AQS 使用CLH
(Craig, Landin, and Hagersten)队列实现线程的排队。 - 模板方法:AQS 设计了多种模板方法,如
tryAcquire()
、tryRelease()
、tryAcquireShared()
、tryReleaseShared()
,供自定义同步器实现类重写。通过这些方法,AQS 可以实现互斥锁(独占锁)和共享锁的不同逻辑。
3. AQS 的工作流程
AQS 提供了同步的基础流程,包括以下几个主要步骤:
- 获取锁(
acquire
方法):线程调用acquire()
方法尝试获取锁。AQS 首先调用tryAcquire()
方法(由实现类定义)判断是否能够成功获取锁。如果tryAcquire()
返回true
,说明获取成功,否则将线程加入同步队列进行等待。 - 释放锁(
release
方法):线程调用release()
方法释放锁。AQS 会调用实现类的tryRelease()
方法,如果tryRelease()
返回true
,说明释放成功,并会唤醒同步队列中的下一个线程。 - 阻塞与唤醒:当线程无法获取锁时,AQS 会将线程封装为
Node
加入同步队列,然后阻塞线程。当锁被释放时,AQS 会唤醒队列中的第一个线程,使其尝试获取锁。
4. 独占锁与共享锁
AQS 支持两种锁模式:独占锁和共享锁。
- 独占锁:每次只能有一个线程获得锁,其他线程必须等待,如
ReentrantLock
。- AQS 使用
acquire()
和release()
方法实现独占锁的获取和释放。
- AQS 使用
- 共享锁:允许多个线程同时获取锁,适合读操作多、写操作少的场景,如
Semaphore
和ReadWriteLock
。- AQS 使用
acquireShared()
和releaseShared()
方法实现共享锁的获取和释放。
- AQS 使用
5. ReentrantLock
与 AQS 的关系
ReentrantLock
是 Lock
接口的一个实现类,基于 AQS 完成锁的操作。ReentrantLock
的独占锁功能和可重入特性,都是通过 AQS 提供的模板方法实现的。注意公平锁和非公平锁不是AQS提供的,而是基于AQS的模板方法实现的。
- 非公平锁:在非公平锁中,
ReentrantLock
会在每次请求锁时直接尝试获取锁(不保证等待最久的线程优先获取锁)。 - 公平锁:在公平锁中,
ReentrantLock
按照线程请求锁的顺序来获取锁,等待时间最长的线程最先获得锁。
ReentrantLock
的公平锁和非公平锁的实现逻辑均依赖于 AQS 提供的同步队列和状态变量。
示例:ReentrantLock
中的锁获取过程
public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock(); // 加锁
try {
// 执行线程安全的操作
} finally {
lock.unlock(); // 解锁
}
}
}
在这个例子中,当线程调用 lock()
时,ReentrantLock
会通过 AQS 尝试获取锁,如果获取失败,则进入 AQS 的同步队列等待。
6. AQS 的主要方法
AQS 提供了一些核心方法来实现同步功能,通常由具体实现类重写这些方法:
tryAcquire(int arg)
:尝试获取独占锁。具体实现类需要重写该方法来定义锁的获取逻辑。tryRelease(int arg)
:尝试释放独占锁。具体实现类需要重写该方法来定义锁的释放逻辑。tryAcquireShared(int arg)
:尝试获取共享锁。返回值为正数表示成功,0 表示成功但没有剩余许可,负数表示失败。tryReleaseShared(int arg)
:尝试释放共享锁。acquire(int arg)
:获取独占锁。如果失败,加入同步队列等待。acquireShared(int arg)
:获取共享锁。如果失败,加入同步队列等待。
7. AQS 的优点
- 简化同步器开发:AQS 提供了一个通用的框架,开发者只需继承 AQS 并实现特定的方法,就可以实现复杂的同步器。
- 支持多种锁模式:AQS 可以支持独占锁和共享锁两种模式,适应不同的并发需求。
- FIFO 排队机制:AQS 的同步队列使用 FIFO 顺序,保证锁的公平性。
8. 总结
- Lock 和 AQS 的关系:
Lock
是 Java 并发包中的接口,定义了锁的基本操作,而 AQS 是实现锁的一种底层机制,为Lock
提供了强大的同步控制支持。AQS 实现了通用的同步队列机制,使得开发者可以快速实现自定义的同步器。 - AQS 作为同步器的基础:AQS 是
ReentrantLock
、CountDownLatch
、Semaphore
和ReadWriteLock
的核心实现类,它提供了基于状态和同步队列的模板方法,使得这些同步器的实现更加简洁和高效。
通过 AQS 的支持,Java 并发包中的 Lock
和其他同步工具能够在高并发环境下保持良好的性能和线程安全性,是实现自定义锁和同步机制的重要工具。
AQS 示例:自定义同步器
我们可以通过继承 AbstractQueuedSynchronizer
来自定义同步器。下面是一个自定义的二元锁(BinaryLatch) 示例,允许两个线程同时访问资源:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
class BinaryLatch {
private final Sync sync;
public BinaryLatch() {
sync = new Sync();
}
// 定义自定义的同步器
private static class Sync extends AbstractQueuedSynchronizer {
// 0 表示未释放,1 表示已释放
@Override
protected int tryAcquireShared(int arg) {
return getState() == 1 ? 1 : -1;
}
@Override
protected boolean tryReleaseShared(int arg) {
setState(1); // 将状态设置为已释放
return true;
}
}
// 使当前线程等待
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// 释放同步器,唤醒等待线程
public void release() {
sync.releaseShared(1);
}
}
public class AQSDemo {
public static void main(String[] args) throws InterruptedException {
BinaryLatch latch = new BinaryLatch();
// 启动线程 1,等待 latch 释放
new Thread(() -> {
System.out.println("Thread 1 waiting for latch...");
try {
latch.await();
System.out.println("Thread 1 done.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// 启动线程 2,等待 latch 释放
new Thread(() -> {
System.out.println("Thread 2 waiting for latch...");
try {
latch.await();
System.out.println("Thread 2 done.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// 主线程休眠一段时间后,释放 latch
Thread.sleep(2000);
latch.release();
System.out.println("Latch released.");
}
}
输出示例:
Thread 1 waiting for latch...
Thread 2 waiting for latch...
Latch released.
Thread 1 done.
Thread 2 done.
解释:
- 这个自定义的
BinaryLatch
同步器允许两个线程等待同一个锁的释放。两个线程会调用latch.await()
进入等待状态,直到latch.release()
被调用。 - 当主线程调用
release()
后,两个等待线程将被唤醒,并继续执行。
什么是可重入,什么是可重入锁?
“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
如果是不可重入锁,先对当前对象加锁,再对当前对象加锁,因为不可重入,就会阻塞当前线程,当前线程被阻塞,此锁一直不可能释放,所以造成了死锁。
公平锁和非公平锁有什么区别?
公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好(新线程可以直接拿锁走了,不用进入队列,就少了一次上下文切换),但可能会导致某些线程永远无法获取到锁。
为什么非公平锁比公平锁性能更好?
公平锁执行流程:获取锁时,先将线程自己添加到同步队列的队尾并休眠,当某线程用完锁之后,会去唤醒同步队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。
非公平锁执行流程:当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入同步队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了一次线程休眠和恢复的操作,这样就加速了程序的执行效率。
因为ReentrentLock源码里进入休眠是调用的LockSupport.park(),这个方法会让线程进入Waiting状态。
ReentrantLock是如何实现公平锁和非公平锁的?
ReentrantLock
通过内部的 FairSync
和 NonfairSync
两个内部类实现了公平锁和非公平锁的逻辑。这两个内部类都继承自 AbstractQueuedSynchronizer
(AQS),并通过不同的方式来控制线程的锁获取顺序,从而实现公平锁和非公平锁。
1. 公平锁与非公平锁的概念
- 公平锁:公平锁按照线程请求锁的顺序来获取锁,即先请求的线程先获得锁。公平锁会检查等待队列中是否有其他线程排在前面,如果有则必须等待。
- 非公平锁:非公平锁则允许“插队”。在锁空闲时,任何线程都可以尝试直接获取锁,即使它不是等待时间最长的线程。这种设计提高了性能,但可能导致某些线程饥饿。
2. ReentrantLock 的构造方法
ReentrantLock
提供了两个构造方法,可以指定是否使用公平锁:
public ReentrantLock() {
sync = new NonfairSync(); // 默认使用非公平锁
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
- 无参构造方法默认使用非公平锁。
- 带布尔参数的构造方法允许用户指定
fair
参数为true
(公平锁)或false
(非公平锁)。
3. 公平锁和非公平锁的实现
ReentrantLock
通过 FairSync
和 NonfairSync
两个内部类实现公平锁和非公平锁的具体逻辑。这两个类都继承自 AQS
,并重写了 AQS
中的 tryAcquire()
方法,实现不同的锁获取策略。
3.1 公平锁的实现(FairSync)
在公平锁的实现中,FairSync
类通过严格检查等待队列中是否有其他线程等待来确保公平性:
static final class FairSync extends Sync {
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) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
hasQueuedPredecessors()
:这是 AQS 提供的一个方法,用于检查当前线程是否有排在前面的线程。如果返回true
,则说明有其他线程在等待队列中等待锁。- 公平性检查:在公平锁中,只有当等待队列中没有其他线程时,当前线程才能获取锁;否则需要等待。
3.2 非公平锁的实现(NonfairSync)
在非公平锁的实现中,NonfairSync
类不会检查等待队列中的其他线程,直接尝试获取锁,这样可以提升性能:
static final class NonfairSync extends Sync {
protected final boolean tryAcquire(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;
}
}
- 直接获取锁:非公平锁在获取锁时不检查等待队列,直接使用
compareAndSetState(0, acquires)
尝试获取锁。如果锁状态为 0(未被占用),则当前线程直接获取锁。 - 性能优势:非公平锁减少了对等待队列的检查操作,提升了锁的获取速度,适合高并发场景。
4. 公平锁和非公平锁的优缺点
- 公平锁的优点:公平锁保证了线程按照先后顺序获取锁,避免线程饥饿,适用于需要严格控制访问顺序的场景。
- 公平锁的缺点:由于需要维护一个有序的等待队列,公平锁的性能较低,尤其在高并发情况下,频繁的线程切换会影响吞吐量。
- 非公平锁的优点:非公平锁不需要维护等待队列顺序,减少了线程切换和调度的开销,性能更高,适合高并发的场景。
- 非公平锁的缺点:由于可能存在“插队”行为,非公平锁可能导致等待时间较长的线程饥饿。
5. 总结
- 实现方式:
ReentrantLock
的公平锁和非公平锁都是通过内部类FairSync
和NonfairSync
实现的,这两个类继承自 AQS,并重写tryAcquire()
方法来控制锁的获取顺序。 - 公平性控制:公平锁通过
hasQueuedPredecessors()
检查等待队列中是否有其他线程,确保按顺序获取锁;非公平锁则直接尝试获取锁,提高了并发性能。 - 适用场景:公平锁适合严格控制访问顺序的场景,非公平锁适合对性能要求更高的场景。
公平锁和非公平锁各有优缺点,开发者可以根据具体需求选择合适的锁策略。ReentrantLock
默认使用非公平锁,以兼顾高并发下的性能。
ReentrantReadWriteLock 是什么?
ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
ReentrantReadWriteLock
是 Java 并发包 java.util.concurrent.locks
提供的一种读写锁,允许多个线程同时读取共享资源,但在写入时只有一个线程可以进行操作,并且禁止读操作。读写锁的这种特性在读多写少的场景下可以有效地提升系统的并发性能。
ReentrantReadWriteLock
提供了读锁(共享锁)和写锁(独占锁),并通过内部的读写分离机制来实现线程间的并发控制。
1. ReentrantReadWriteLock
的基本概念
- 读锁(共享锁):读锁是共享锁,允许多个线程同时持有锁进行读操作。只要没有线程持有写锁,多个线程可以同时获取读锁并进行读取操作。
- 写锁(独占锁):写锁是独占锁,只有一个线程可以持有写锁,且写锁会阻塞其他读锁和写锁的请求。这意味着在持有写锁的情况下,其他线程无法进行读操作或写操作。
2. 读写锁的特性
- 读-读共享:多个线程可以同时持有读锁进行并发读操作,互不阻塞。
- 读-写互斥:如果一个线程持有读锁,则其他线程无法获取写锁。反之亦然,如果有线程持有写锁,则其他线程无法获取读锁。
- 写-写互斥:写锁是独占的,即同一时刻只能有一个线程持有写锁。
3. ReentrantReadWriteLock
的实现原理
ReentrantReadWriteLock
通过内部的读锁和写锁实现了读写分离,允许读写锁的独立控制。ReentrantReadWriteLock
内部包含两个锁:
- ReadLock(读锁):允许多个线程同时读取数据。
- WriteLock(写锁):只允许一个线程写入数据,其他线程(包括读线程)需要等待写操作完成才能继续。
ReentrantReadWriteLock
通过内部类 ReadLock
和 WriteLock
分别实现读锁和写锁。读锁和写锁共享一个同步状态 state
,高位用于表示读锁的状态,低位用于表示写锁的状态。
4. 读锁和写锁的获取机制
4.1 读锁的获取
当线程尝试获取读锁时:
- 如果没有其他线程持有写锁,则线程可以直接获取读锁,并增加读锁的计数。
- 如果当前有其他线程持有写锁,则该线程会被阻塞,直到写锁被释放。
4.2 写锁的获取
当线程尝试获取写锁时:
- 如果没有其他线程持有读锁或写锁,则线程可以直接获取写锁,并将写锁的状态设置为 1。
- 如果已经有其他线程持有读锁或写锁,线程将会被阻塞,直到所有读锁和写锁都被释放。
5. ReentrantReadWriteLock
的公平性
ReentrantReadWriteLock
可以通过构造方法指定是否使用公平锁。类似于 ReentrantLock
,它可以是公平锁或非公平锁:
- 公平锁:公平锁会按照线程请求的顺序来获取锁,保证等待时间最长的线程优先获得锁。
- 非公平锁:非公平锁允许“插队”,当锁空闲时,任何线程都可以尝试获取锁,提高了性能。
// 默认构造方法,非公平锁
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 公平锁
ReentrantReadWriteLock fairLock = new ReentrantReadWriteLock(true);
6. ReentrantReadWriteLock
的常用方法
- 读锁:
lock.readLock().lock()
:获取读锁。lock.readLock().unlock()
:释放读锁。
- 写锁:
lock.writeLock().lock()
:获取写锁。lock.writeLock().unlock()
:释放写锁。
7. ReentrantReadWriteLock
使用示例
以下示例展示了 ReentrantReadWriteLock
的基本用法,多个线程可以并发读取数据,但写操作必须互斥执行:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockExample {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int value;
// 读操作,允许多个线程同时读取
public int read() {
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " is reading.");
return value;
} finally {
lock.readLock().unlock();
}
}
// 写操作,只允许一个线程写入
public void write(int newValue) {
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " is writing.");
this.value = newValue;
} finally {
lock.writeLock().unlock();
}
}
public static void main(String[] args) {
ReentrantReadWriteLockExample example = new ReentrantReadWriteLockExample();
// 启动读线程
for (int i = 0; i < 3; i++) {
new Thread(() -> {
while (true) {
example.read();
try {
Thread.sleep(500); // 模拟读操作耗时
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
// 启动写线程
new Thread(() -> {
while (true) {
example.write((int) (Math.random() * 100));
try {
Thread.sleep(1000); // 模拟写操作耗时
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
说明:
- 读线程:多个读线程同时读取共享数据。由于使用了读锁,这些线程可以并发执行
read()
方法。 - 写线程:写线程在执行写操作时会持有写锁,阻止其他线程(包括读线程)访问共享数据,确保写操作的原子性。
8. ReentrantReadWriteLock
的优缺点
优点
- 提高并发性能:读写分离机制允许多个线程同时执行读操作,而写操作则独占锁资源,非常适合读多写少的场景。
- 灵活性:支持公平锁和非公平锁,可根据需求选择合适的锁策略。
- 重入性:
ReentrantReadWriteLock
是可重入的,同一线程可以多次获取同一锁。
缺点
- 读写锁切换开销:在频繁的读写切换场景下,由于线程需要频繁地切换读锁和写锁,会带来一定的性能开销。
- 不适合写多读少的场景:在写多读少的场景中,由于写锁会阻塞读锁,读写锁机制的性能优势不明显,甚至可能降低性能。
9. 适用场景
ReentrantReadWriteLock
适用于读多写少的高并发场景。例如:
- 缓存:多个线程读取缓存数据,只有当数据需要更新时才获取写锁。
- 配置文件读取:多个线程读取配置信息,只有在配置更新时才会写入。
在这种场景下,ReentrantReadWriteLock
的读写分离机制可以显著提升并发性能。
共享锁和独占锁有什么区别?
共享锁:一把锁可以被多个线程同时获得。
独占锁:一把锁只能被一个线程获得。
线程持有读锁还能获取写锁吗?
写时读会读到不完整的数据,写时并发读会读到不一致的数据
在读写锁设计中,将读锁和写锁设计成互斥的是为了确保数据的一致性和线程安全性。写操作通常会对共享数据进行修改,如果允许读线程在写线程修改数据时并发读取,可能会导致读到不一致或部分更新的数据,从而造成数据不一致或逻辑错误。因此,在 ReentrantReadWriteLock
中,设计了读写锁的互斥性:当一个线程持有写锁时,其他线程(包括读线程和写线程)都必须等待。
示例:为什么读锁和写锁需要互斥
假设有一个共享资源 sharedData
,代表一个简单的计数器。我们需要多个线程去读取它的值,同时另一个线程会定期地更新该计数器。如果读写锁没有互斥性,读线程可能会读取到不一致的数据。
下面的示例演示了如果没有互斥性,读线程在写操作进行中读取可能会导致数据不一致:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int sharedData = 0;
// 读操作
public int readData() {
lock.readLock().lock(); // 获取读锁
try {
// 模拟一个稍长的读取过程
System.out.println(Thread.currentThread().getName() + " reads: " + sharedData);
return sharedData;
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
// 写操作
public void writeData(int value) {
lock.writeLock().lock(); // 获取写锁
try {
// 模拟写操作
System.out.println(Thread.currentThread().getName() + " writes: " + value);
sharedData = value;
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
public static void main(String[] args) {
ReadWriteLockExample example = new ReadWriteLockExample();
// 启动多个读线程
for (int i = 0; i < 3; i++) {
new Thread(() -> {
while (true) {
example.readData();
try {
Thread.sleep(100); // 模拟读取间隔
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "Reader-" + i).start();
}
// 启动一个写线程
new Thread(() -> {
int value = 1;
while (true) {
example.writeData(value++);
try {
Thread.sleep(300); // 模拟写入间隔
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "Writer").start();
}
}
示例运行说明
在这个例子中:
- 读线程:持续读取
sharedData
的值,并将读取到的值输出到控制台。 - 写线程:定期更新
sharedData
的值,并将更新的值输出到控制台。
为什么读锁和写锁需要互斥
在这个例子中,ReentrantReadWriteLock
的读写锁是互斥的,因此在写线程持有写锁进行更新时,所有的读线程都会等待,直到写操作完成。这种互斥确保了读线程总能读取到完整且一致的数据。
如果读写锁没有互斥性,以下情况可能会发生:
- 读到不完整的数据:写线程可能会在修改
sharedData
的过程中被读线程读取,而此时数据尚未完全更新,导致读线程读取到的值可能是旧值或部分修改的数据。 - 数据不一致性:当写操作中途被读线程插入,读线程会看到一个尚未完成的写操作的“中间状态”,这会导致数据的逻辑不一致性。
例如,如果读线程在写线程完成 sharedData = value;
之前读取了数据,那么它可能会读取到旧的数据,导致不一致的读取结果。
总结
- 数据一致性:通过将读锁和写锁互斥,
ReentrantReadWriteLock
确保了数据的一致性。写操作会独占资源,只有在写操作完成后才允许读线程读取,从而确保数据在读线程读取时是最新且完整的。 - 线程安全:写操作通常会修改共享资源,如果允许读写操作并发进行,会导致数据不一致,甚至出现线程安全问题。因此,设计成读写互斥可以避免这些问题,提高系统的正确性和可靠性。
ReentrantReadWriteLock
的读写互斥特性,使其非常适合读多写少的高并发场景,确保了数据的线程安全和一致性。
什么是锁的升降级? RentrantReadWriteLock为什么不支持锁升级?
因为写锁是独占的,所以在并发升级为写锁时会有死锁风险。读锁是共享的,所以可以并发降级。
在 ReentrantReadWriteLock
中,写锁可以降级为读锁,但读锁不能升级为写锁。这种设计是为了避免死锁问题,并且提供了一种安全的方式来在读取数据时保持一致性。下面详细解释为什么写锁可以降级为读锁,但读锁不能升级为写锁。
1. 写锁降级为读锁的原因
先获取读锁,再释放写锁。
写锁降级为读锁是指线程在持有写锁时,可以在不释放锁的情况下获取读锁,然后再释放写锁,使得锁状态从写锁变为读锁。这种降级允许线程在完成写操作后继续以读锁的方式持有锁,从而使其他线程可以共享读锁。写锁降级为读锁的原因包括以下几点:
- 数据一致性:持有写锁的线程在完成写操作后,可能希望继续读取最新的数据。通过降级为读锁,线程可以继续保持对数据的读取访问权,同时允许其他读线程共享锁,从而提升并发性能。
- 减少锁的竞争:通过锁降级,写锁在写操作完成后可以变为读锁,避免了在读多写少场景中持有写锁对其他读线程的阻塞,从而提高系统的整体性能。
写锁降级为读锁的示例
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class LockDowngradeExample {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int data;
public void writeAndDowngrade(int newValue) {
lock.writeLock().lock(); // 获取写锁
try {
data = newValue;
System.out.println("Write completed: " + data);
// 写锁降级为读锁
lock.readLock().lock(); // 在不释放写锁的情况下获取读锁
} finally {
lock.writeLock().unlock(); // 释放写锁,但仍持有读锁
}
try {
// 继续以读锁的方式读取数据
System.out.println("Read after write: " + data);
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
}
在这个例子中,写操作完成后,线程通过获取读锁来降级锁状态,使其他读线程可以并发读取数据。
2. 为什么读锁不能升级为写锁
读锁不能升级为写锁的原因是为了避免产生死锁。在读锁升级为写锁的过程中,可能会遇到以下问题:
- 死锁风险:假设多个线程都持有读锁,并且都尝试升级为写锁,那么每个线程都会等待其他线程释放读锁,导致相互等待,最终形成死锁。
- 例如,线程 A 和线程 B 都持有读锁,如果它们同时尝试升级为写锁,它们将会陷入等待对方释放读锁的状态,因为写锁是独占的(即不能同时被多个线程持有)。
- 违反锁的设计原则:
ReentrantReadWriteLock
的设计是为了允许多个线程同时获取读锁,但写锁必须是独占的。如果允许读锁升级为写锁,将会破坏这种设计原则,导致锁机制复杂化,难以保证线程安全。
3. 避免死锁和锁膨胀的设计选择
在 ReentrantReadWriteLock
中,读锁降级为写锁会导致死锁风险,而写锁降级为读锁则不会。这是因为:
- 当线程持有写锁时,它已经独占了锁,其他线程无法获取读锁或写锁,所以线程可以自由地降级为读锁而不会引发死锁。
- 当线程持有读锁时,其他线程也可能持有读锁,尝试升级为写锁会造成死锁,因为多个线程都在等待对方释放读锁。
4. 如何实现读锁升级为写锁的需求
如果确实需要从读操作切换到写操作,应该先释放读锁,然后重新尝试获取写锁。虽然在释放读锁和获取写锁之间可能会出现短暂的时间窗口,但这是实现安全线程同步的一种折衷。
示例:通过释放读锁后获取写锁来实现升级
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class UpgradeExample {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int data;
public void readAndWrite() {
lock.readLock().lock(); // 获取读锁
try {
System.out.println("Read data: " + data);
} finally {
lock.readLock().unlock(); // 释放读锁
}
// 尝试获取写锁
lock.writeLock().lock();
try {
data++;
System.out.println("Write data: " + data);
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
}
在这个例子中,通过先释放读锁,然后获取写锁,可以避免死锁风险,同时实现读锁到写锁的转换。
总结
- 写锁可以降级为读锁:因为持有写锁的线程已经独占了资源,不存在死锁问题,降级后允许其他读线程并发访问,提升并发性能。
- 读锁不能升级为写锁:为了避免多个线程同时尝试升级导致死锁,
ReentrantReadWriteLock
禁止读锁升级为写锁。如果需要升级,可以先释放读锁,再尝试获取写锁。
用例子解释一下锁升级的底层原理
锁升级机制是 Java 虚拟机(JVM)中为了提高并发性能所做的优化。它允许 synchronized
锁根据竞争程度从低到高逐渐升级,减少不必要的性能开销。锁升级通常会经历三个阶段:偏向锁、轻量级锁、重量级锁。下面通过一个例子和 JVM 对象头的变化来解释锁升级的底层原理。
锁升级机制的基本概念
1. 对象头(Mark Word)
在 Java 中,每个对象都有一个对象头(Object Header),对象头中的一部分称为 Mark Word,用于存储对象的锁状态。不同的锁状态通过对象头的不同标志位来区分。锁状态有:
- 无锁状态
- 偏向锁
- 轻量级锁
- 重量级锁
2. 锁标志位
对象头中的锁标志位(Mark Word)的不同值代表不同的锁状态:
- 无锁状态:对象未加锁。
- 偏向锁状态:对象被一个线程持有且没有竞争时的锁状态。
- 轻量级锁状态:多个线程竞争锁时的自旋锁。
- 重量级锁状态:当自旋失败时,线程进入阻塞,锁升级为重量级锁。
锁升级的例子
示例代码
public class LockUpgradeExample {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 启动第一个线程,获取偏向锁
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " has acquired biased lock.");
// 模拟工作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t1.join(); // 等待线程1执行完毕
// 启动第二个线程,尝试获取锁,引发锁升级
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " has acquired lock after upgrade.");
// 模拟工作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t2.start();
t2.join();
}
}
分析锁升级过程
1. 偏向锁
在 t1
线程执行 synchronized (lock)
时,JVM 会尝试将对象锁标记为偏向锁。偏向锁 是为了优化单线程执行同步块的情况。当 t1
第一次进入同步块时,锁的状态会从无锁状态升级为偏向锁,并且将对象头的 Mark Word 中记录 t1
的线程 ID。
- 偏向锁触发条件:第一次执行同步块,且没有其他线程竞争。
- Mark Word 变化:Mark Word 中的锁标志位会变为偏向锁状态,记录线程
t1
的 ID。
Thread t1 has acquired biased lock.
此时,线程 t1
可以直接进入临界区而无需执行锁竞争操作,性能开销非常小。
2. 轻量级锁
当线程 t2
尝试获取锁时,JVM 检测到锁已经偏向于 t1
,这意味着锁处于偏向状态且当前线程不一致。为了保证线程安全,JVM 撤销偏向锁,锁升级为轻量级锁。
轻量级锁的核心机制是使用 CAS(Compare-And-Swap)操作。在锁升级过程中,线程 t2
尝试获取轻量级锁,如果成功,进入同步块;如果失败,线程会通过自旋等待锁的释放。
- 轻量级锁触发条件:另一个线程(
t2
)尝试获取锁,导致偏向锁撤销。 - Mark Word 变化:Mark Word 中记录了锁指针,表示锁已经从偏向锁升级为轻量级锁。
Thread t2 has acquired lock after upgrade.
在这个阶段,如果线程 t2
能够通过自旋获得锁,则不会进入阻塞状态。
3. 重量级锁
如果线程 t2
在自旋阶段无法获得锁,锁将进一步升级为重量级锁。重量级锁通过操作系统的互斥锁机制实现,当线程无法自旋成功时,直接进入阻塞状态。持有锁的线程执行完同步块后,会唤醒阻塞的线程,使其继续执行。
- 重量级锁触发条件:如果竞争激烈,自旋无法解决,轻量级锁会升级为重量级锁。
- Mark Word 变化:Mark Word 中记录重量级锁的互斥锁指针,表示锁已经进入重量级状态。
锁升级的底层原理(基于对象头和 Mark Word)
Java 对象头中的 Mark Word 是锁升级的关键机制。它保存了锁的状态信息,包括线程 ID、锁标志位等。在不同的锁状态下,Mark Word 的内容会发生变化。通过锁的升级机制,JVM 能够根据并发程度自动调整锁的级别,优化性能。
- 无锁状态:对象头中的 Mark Word 是默认状态,没有任何锁标志。
- 偏向锁状态:Mark Word 记录了线程 ID 和偏向锁标志,锁偏向于某个线程,表示没有锁竞争。
- 轻量级锁状态:Mark Word 中记录轻量级锁的锁记录(Lock Record),并使用 CAS 机制进行锁的竞争。
- 重量级锁状态:Mark Word 记录重量级锁的互斥锁指针,当线程竞争激烈时,进入阻塞状态。
JVM 参数调优
JVM 提供了一些参数来调整锁升级的行为:
-XX:+UseBiasedLocking
:控制是否启用偏向锁,默认启用。可以通过-XX:-UseBiasedLocking
禁用偏向锁。-XX:BiasedLockingStartupDelay=<N>
:控制偏向锁延迟启动,默认在 JVM 启动 4 秒后启用偏向锁。
通过调优这些参数,可以在不同的应用场景中优化锁的行为。
总结
锁升级机制是 JVM 的一种重要优化手段,通过偏向锁、轻量级锁、重量级锁逐步升级,能够动态适应不同并发竞争情况,从而提升系统的并发性能。偏向锁适用于没有竞争的场景,轻量级锁通过自旋优化锁竞争,而重量级锁则通过阻塞线程来应对激烈的竞争。JVM 的锁升级机制依赖于对象头中的 Mark Word,它通过记录不同锁状态的信息,控制锁的升级和状态变化。
轻量级锁的锁记录(Lock Record)是什么?
轻量级锁的锁记录(Lock Record) 是 Java 虚拟机(JVM)在实现轻量级锁时,为每个线程在栈帧中创建的一个用于存储锁状态和线程锁相关信息的结构。它是轻量级锁机制中非常重要的一部分,允许多个线程在无需阻塞的情况下竞争锁。
轻量级锁的工作机制
轻量级锁是为了优化多线程环境下的轻度竞争而设计的。当一个线程进入同步块时,JVM 会尝试获取轻量级锁,通过在当前线程的栈帧中创建锁记录来保存锁的相关信息。
锁记录的作用
- 存储锁状态:锁记录包含了锁的状态信息,例如当前线程是否持有锁,或者锁是否已经被其他线程持有。每个线程在进入同步代码块时,都会在其栈帧中创建一个锁记录,用于追踪该线程获取锁的过程。
- 锁的竞争机制:当一个线程想要获取轻量级锁时,它会尝试将锁对象的
Mark Word
的内容复制到它的锁记录中。然后,使用 CAS(Compare-And-Swap)操作将Mark Word
的内容替换为指向该线程的锁记录的指针。如果 CAS 操作成功,线程就获得了轻量级锁;如果失败,则意味着锁已经被其他线程获取,可能导致锁的进一步升级。 - 解锁时的恢复:当线程退出同步块时,它会检查锁记录并将对象头中的
Mark Word
恢复为解锁状态。也就是说,线程在释放轻量级锁时,会通过锁记录将对象的原始状态恢复。
轻量级锁获取的详细过程
- 锁记录的创建:当线程进入同步块时,JVM 会在该线程的栈帧中为锁对象创建一个锁记录。
- Mark Word 拷贝:线程尝试通过 CAS 操作,将对象头的
Mark Word
复制到自己的锁记录中。Mark Word
中包含了锁对象的状态信息。 - CAS 操作竞争锁:线程使用 CAS 操作尝试将对象头的
Mark Word
指向锁记录。如果 CAS 操作成功,则该线程获得轻量级锁。如果 CAS 失败,说明其他线程已经竞争到了锁,JVM 会尝试进入下一步的锁升级机制(自旋或重量级锁)。 - 解锁:当线程离开同步块时,JVM 会通过检查线程的锁记录,将对象头的
Mark Word
恢复为原始状态或无锁状态,释放锁。
锁记录的示意图
+------------------+
| Thread's Stack |
+------------------+
| Lock Record | <------ (Lock record created in the thread's stack)
+------------------+
- Mark Word:在对象头中,记录了锁的状态。当轻量级锁被获取时,Mark Word 指向该线程的锁记录。
- 锁记录:存储在当前线程的栈帧中,保存了锁的状态和线程相关的信息。
简单示例:锁记录的使用场景
假设有两个线程 t1
和 t2
,它们同时尝试获取轻量级锁:
- 当
t1
第一次尝试进入同步块时,它会在栈帧中创建一个锁记录,并通过 CAS 操作将对象头的Mark Word
指向它的锁记录。如果成功,则t1
获得轻量级锁并执行临界区代码。 - 当
t1
执行完同步代码并退出时,JVM 会恢复对象头中的Mark Word
,释放锁。 - 如果
t2
在t1
持有轻量级锁的情况下尝试进入同步块,它也会在自己的栈帧中创建一个锁记录,但它的 CAS 操作会失败,因为Mark Word
已经指向了t1
的锁记录。这时,JVM 可能会让t2
进入自旋等待,或者升级为重量级锁。
总结
- 锁记录(Lock Record) 是轻量级锁的关键数据结构,用于保存线程锁的状态。每个线程进入同步块时,JVM 会在该线程的栈帧中为锁对象创建一个锁记录。
- 轻量级锁的获取 依赖于将锁对象的
Mark Word
指向线程的锁记录,通过 CAS 操作实现锁的竞争。 - 锁记录的意义 是减少锁竞争下的阻塞和上下文切换开销,适用于低竞争环境下的锁优化场景。
用例子详细解释一下多线程竞争轻量锁的过程,要求说清楚底层原理
在 Java 中,轻量级锁是为了优化多线程下的锁竞争而引入的一种机制,旨在减少线程上下文切换和阻塞带来的开销。它通过利用**CAS(Compare-And-Swap)操作和线程栈中的锁记录(Lock Record)**来避免重量级锁的使用。当多个线程竞争同一个锁时,如果竞争不激烈,轻量级锁能够通过自旋和 CAS 操作高效地处理。
下面我会通过一个例子来详细解释多个线程竞争轻量级锁的过程,并结合底层的 JVM 实现原理说明锁记录和 Mark Word
的变化。
示例代码
我们先看一个简单的多线程示例,其中两个线程同时竞争一个同步方法。
public class LightweightLockExample {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 启动第一个线程
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
try {
Thread.sleep(1000); // 模拟持有锁的操作
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Thread-1");
// 启动第二个线程
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
}
}, "Thread-2");
t1.start();
Thread.sleep(10); // 确保 t1 先获取锁
t2.start();
t1.join();
t2.join();
}
}
多线程竞争轻量级锁的详细过程
- 初始状态:无锁状态
- 当程序开始运行时,
lock
对象的对象头中的Mark Word
处于无锁状态,所有线程都还没有尝试获取锁。此时,Mark Word
中保存了对象的一些基本信息,如哈希值等。
- 当程序开始运行时,
- 线程 t1 获取锁,进入轻量级锁状态
- 当
t1
线程进入synchronized (lock)
块时,JVM 检查lock
对象的Mark Word
。发现锁当前是无锁状态,JVM 会在t1
的栈帧中创建一个 锁记录(Lock Record),并将Mark Word
中的值复制到这个锁记录中。然后,t1
尝试通过 CAS 操作 将Mark Word
更新为指向它的锁记录的指针。如果 CAS 成功,t1
就成功获取了轻量级锁,执行同步块中的代码。
- 当
- Mark Word 的变化:锁状态从无锁状态变为轻量级锁状态,
Mark Word
中记录了指向t1
栈中锁记录的指针。 - 锁记录的作用:锁记录保存了
Mark Word
的原始值以及线程t1
持有锁的信息,方便在锁释放时恢复。
- 线程 t2 尝试获取锁,锁竞争发生
- 当
t2
线程尝试进入synchronized (lock)
块时,JVM 再次检查lock
对象的Mark Word
。此时,Mark Word
已经指向t1
的锁记录,说明锁已经被t1
持有。
- 当
- CAS 操作失败:
t2
尝试通过 CAS 操作来修改Mark Word
,但是因为Mark Word
中已经指向了t1
的锁记录,所以t2
的 CAS 操作失败。这时,t2
会进入 自旋 阶段,即尝试不断地检测锁是否被释放。如果t1
在t2
自旋期间释放了锁,t2
就可以通过重新执行 CAS 操作获取锁。 - 自旋过程:自旋是轻量级锁的一个关键优化点,它允许线程不进入阻塞状态,而是短时间内通过反复检查锁的状态来避免上下文切换和阻塞。自旋的次数通常较少,因为如果锁长时间未释放,竞争就会变得激烈,JVM 会升级为重量级锁。
- 锁释放与竞争结束
- 当
t1
完成同步代码块并退出时,JVM 会通过t1
栈中的锁记录将Mark Word
恢复为原始的无锁状态。此时,锁被释放,t2
线程可以成功获取锁。
- 当
- Mark Word 恢复:
t1
离开同步块后,JVM 使用栈中的锁记录恢复Mark Word
为无锁状态或其他线程可用的状态。 - CAS 操作成功:当
t2
检测到锁已释放后,重新执行 CAS 操作,将Mark Word
更新为指向它自己的锁记录。此时,t2
成功获取锁,并开始执行同步代码块。
- 锁升级:从轻量级锁到重量级锁(未发生在此示例中,但在高竞争时可能发生)
- 如果自旋失败,例如
t2
在多次自旋后仍然无法获取锁,JVM 会将轻量级锁升级为重量级锁。重量级锁通过操作系统的 互斥锁(Monitor) 来管理,未获取锁的线程将进入 阻塞状态,直到锁被释放。
- 如果自旋失败,例如
- Mark Word 变化:在锁升级后,
Mark Word
中将保存指向互斥锁的指针,而不是指向线程的栈帧中的锁记录。此时,锁不再依赖自旋,而是通过操作系统的调度机制管理线程的阻塞与唤醒。
轻量级锁的底层原理
- 锁记录(Lock Record):每个线程在尝试获取轻量级锁时,JVM 会在该线程的栈帧中创建一个锁记录,用于保存对象的原始
Mark Word
和锁状态。成功获取锁后,Mark Word
中会指向线程的锁记录。 - CAS 操作:轻量级锁依赖于 CAS(Compare-And-Swap)机制,线程通过 CAS 操作修改
Mark Word
来获取锁。如果 CAS 成功,线程就可以继续执行同步块;如果失败,线程将进入自旋等待或锁升级。 - 自旋机制:自旋是轻量级锁的重要特性。当线程获取锁失败时,它会在短时间内反复尝试获取锁,而不是立即进入阻塞状态。自旋可以避免线程的上下文切换开销,适合锁持有时间较短的场景。
- 锁升级:当自旋失败时,锁会从轻量级锁升级为重量级锁。重量级锁通过操作系统的互斥锁机制实现,线程将被阻塞,直到锁被释放。
总结
通过这个例子可以看到,轻量级锁通过在线程栈中创建锁记录,并利用 CAS 操作和自旋机制来减少锁竞争时的开销。当锁竞争较低时,轻量级锁可以避免线程阻塞,提高并发性能;而当竞争激烈时,锁会升级为重量级锁,线程进入阻塞状态,等待锁的释放。
这种锁升级机制让 synchronized
在不同的并发场景下表现出不同的效率,确保在轻度竞争时具有较好的性能,而在激烈竞争时仍然可以确保线程安全。
用例子详细解释一下多线程竞争重量级锁的过程,要求说清楚底层原理,必要时可以结合源码来回答。
重量级锁是 Java 虚拟机(JVM)在锁竞争非常激烈的情况下将轻量级锁升级后的锁状态。重量级锁通过操作系统的 互斥锁(Mutex) 实现,当线程竞争无法通过自旋或轻量级锁解决时,线程会进入阻塞状态,直到锁被持有的线程释放。重量级锁的开销相对较大,因为涉及到线程的阻塞和上下文切换。下面通过一个详细的例子来解释多线程竞争重量级锁的过程,并结合 JVM 的底层原理说明其工作机制。
示例代码
我们使用两个线程去竞争同一个锁,模拟重量级锁的场景。
public class HeavyweightLockExample {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 启动第一个线程
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
try {
Thread.sleep(3000); // 模拟持有锁的时间较长
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Thread-1");
// 启动第二个线程
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
}
}, "Thread-2");
t1.start();
Thread.sleep(100); // 确保 t1 先获取锁
t2.start();
t1.join();
t2.join();
}
}
重量级锁的详细过程
- 初始状态:无锁状态
- 当
t1
和t2
都没有尝试获取lock
对象时,lock
对象的Mark Word
处于无锁状态。此时Mark Word
保存的是对象的基本信息,如哈希码等。 Mark Word
的值为 001(代表无锁状态),JVM 会在Mark Word
中存储对象哈希码或其他相关信息。
- 当
- 线程 t1 获取轻量级锁
- 当
t1
线程进入synchronized (lock)
块时,JVM 首先尝试让t1
获得 轻量级锁。JVM 为t1
创建一个 锁记录(Lock Record),并将lock
对象头的Mark Word
从无锁状态更新为轻量级锁状态。t1
成功通过 CAS(Compare-And-Swap)操作获取锁。
- 当
- Mark Word 的变化:此时
Mark Word
记录了指向t1
栈中锁记录的指针,标志位为 00,表示锁处于轻量级锁状态。 - 轻量级锁的自旋和竞争未发生:因为
t1
是第一个尝试获取锁的线程,所以锁的升级还没有开始。
Thread-1 acquired the lock
- 线程 t2 竞争锁并导致锁升级
- 在
t1
持有轻量级锁的情况下,t2
线程也试图进入同步块,获取lock
锁。JVM 发现Mark Word
指向t1
的锁记录,表明锁已经被t1
持有。
- 在
- CAS 操作失败:
t2
通过 CAS 尝试获取轻量级锁,但失败了。此时 JVM 会让t2
自旋几次,等待t1
释放锁。然而,t1
持有锁的时间较长(Thread.sleep(3000)
),所以t2
的自旋最终失败。 - 锁升级为重量级锁:当自旋失败时,JVM 将轻量级锁升级为 重量级锁。重量级锁通过操作系统的互斥锁实现,
t2
线程会进入 阻塞状态,直到t1
释放锁。 - Mark Word 的变化:此时,
Mark Word
中的值变为 10(表示重量级锁状态),并且Mark Word
指向操作系统的互斥锁(Monitor),该互斥锁用于管理线程的阻塞和唤醒。
- 重量级锁的互斥机制
- 在重量级锁状态下,JVM 不再依赖自旋等待,而是使用操作系统级别的同步机制。当
t2
进入阻塞状态时,它将由操作系统管理,直到锁被释放后再被唤醒。
- 在重量级锁状态下,JVM 不再依赖自旋等待,而是使用操作系统级别的同步机制。当
- 阻塞的底层原理:
- Monitor 是 Java 中的重量级锁的实现基础。它本质上是一个互斥量,管理锁的所有权和线程的同步。当线程无法获取锁时,会进入操作系统的阻塞队列。
- 阻塞和唤醒是由操作系统通过系统调用(例如
futex
)实现的,这个过程会产生线程上下文切换的开销。
Thread-2 阻塞中,等待 Thread-1 释放锁
- 线程 t1 释放锁,t2 被唤醒
- 当
t1
执行完同步块并退出时,JVM 会通过t1
的锁记录恢复Mark Word
的状态,将锁从重量级锁状态恢复为无锁状态,或者继续为其他线程准备。
- 当
- 锁释放的过程:
t1
释放锁时,Monitor 内部会唤醒阻塞的线程t2
,t2
被操作系统调度进入就绪队列,并在获取 CPU 资源后开始执行同步代码块。t2
线程获取锁并继续执行,当t2
执行完毕后,锁会彻底释放,Mark Word
恢复为无锁状态或偏向锁状态。
Thread-2 acquired the lock
底层原理与源码解析
在 Java 的重量级锁中,Monitor
是核心概念,synchronized
关键字在 JVM 中使用 MonitorEnter 和 MonitorExit 指令实现。当锁升级为重量级锁时,Monitor
会介入管理线程的阻塞和唤醒。
synchronized
底层实现
在字节码层面上,synchronized
会生成 MonitorEnter 和 MonitorExit 字节码指令。JVM 使用这些指令来控制线程对锁的获取和释放。
- MonitorEnter:当线程进入同步块时,JVM 会执行
MonitorEnter
指令,尝试获取锁。如果锁是轻量级锁,使用 CAS 操作进行获取;如果锁已升级为重量级锁,线程会被阻塞。 - MonitorExit:当线程退出同步块时,JVM 会执行
MonitorExit
指令,释放锁。对于重量级锁,释放锁后操作系统会唤醒等待的线程。
public synchronized void someMethod() {
// 这段代码在字节码层面使用了 MonitorEnter 和 MonitorExit 指令
}
重量级锁的管理:Monitor 和 ObjectMonitor
在 JVM 中,重量级锁是通过 ObjectMonitor
实现的。ObjectMonitor
是 JVM 中实现 Monitor 的核心数据结构,它管理着线程的阻塞和唤醒。
ObjectMonitor
的关键字段:
_owner
:当前持有锁的线程。_WaitSet
:等待锁的线程队列,当线程无法获取锁时会被放入这个队列。未获取锁。_EntryList
:已经尝试获取锁但被阻塞的线程列表。已尝试获取锁但阻塞。- 还有一个计数器,记录重入的次数。
当一个线程获取锁失败时,它会被加入到 _EntryList
或 _WaitSet
中,等待被唤醒。
相关源码
在 OpenJDK 的 ObjectMonitor.cpp
文件中,可以看到 Monitor 的实现。以下是部分源码,展示了线程如何被阻塞和唤醒。
void ObjectMonitor::enter(TRAPS) {
// 检查当前线程是否已经持有锁
if (TryEnter(thread)) {
return;
}
// 如果锁已经被其他线程持有,进入阻塞状态
EnterI(thread);
}
EnterI()
函数负责将无法获取锁的线程加入到等待队列中,直到锁被释放后再唤醒。
void ObjectMonitor::EnterI(Thread *thread) {
// 将当前线程加入等待队列,阻塞它
Self->_EntryList.add(thread);
// 阻塞线程,等待唤醒
Park(thread);
}
总结
- 轻量级锁 是通过 CAS 操作和自旋来优化轻度锁竞争的情况,而当竞争激烈时,轻量级锁会升级为重量级锁。
- 重量级锁 依赖操作系统的互斥锁(Monitor),未能获取锁的线程会进入阻塞状态,直到锁被释放。
- Monitor 是 JVM 管理重量级锁的核心,
synchronized
关键字最终通过 Monitor 来控制线程的同步和阻塞。 - 重量级锁带来的线程上下文切换开销较大,因此不适合锁竞争激烈的场景,应该尽量减少锁的持有时间以提高并发性能。
ReentrantReadWriteLock底层读写状态如何设计的?
高16位为读锁,低16位为写锁。
使用StampedLock
读写锁:读写互斥。
邮戳锁:读写共享。
前面介绍的ReadWriteLock
可以解决多线程同时读,但只有一个线程能写的问题。
如果我们深入分析ReadWriteLock
,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock
。
StampedLock
和ReadWriteLock
相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
我们来看例子:
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
和ReadWriteLock
相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过tryOptimisticRead()
获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()
去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
可见,StampedLock
把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock
是不可重入锁,不能在一个线程中反复获取同一个锁。
StampedLock
还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。
小结
StampedLock
提供了乐观读锁,可取代ReadWriteLock
以进一步提升并发性能;
StampedLock
是不可重入锁。