JAVA ReentrantLock在高并发场景中公平锁与非公平锁性能对比
大家好,今天我们来深入探讨一下 Java 中 ReentrantLock 在高并发场景下公平锁与非公平锁的性能对比。ReentrantLock 作为 synchronized 关键字的增强版,提供了更灵活的锁机制,包括公平锁和非公平锁两种模式。理解它们的特性和在高并发场景下的表现对于编写高效、可靠的并发程序至关重要。
ReentrantLock 的基本概念
首先,我们回顾一下 ReentrantLock 的基本概念。ReentrantLock 是一个可重入的互斥锁,这意味着如果一个线程已经获得了锁,它可以再次获得该锁而不会被阻塞。这避免了死锁的发生。ReentrantLock 实现了 Lock 接口,提供了比 synchronized 更多的功能,例如:
- 公平锁和非公平锁: 控制锁的获取顺序。
- 可中断的锁: 允许线程在等待锁的过程中被中断。
- 超时获取锁: 允许线程在指定时间内尝试获取锁,如果超时则放弃。
- 多个条件变量: 允许线程在不同的条件下等待和唤醒。
公平锁与非公平锁的区别
ReentrantLock 的核心区别在于公平锁和非公平锁的实现方式。
- 公平锁: 按照请求锁的顺序来分配锁。如果一个线程正在等待锁,那么它会排队,当锁释放时,队列中最先等待的线程会获得锁。
- 非公平锁: 允许线程“插队”,即当锁释放时,如果有线程尝试获取锁,它可以尝试直接获取锁,而不需要排队。
这种“插队”机制可能会导致某些线程一直无法获取锁,从而造成饥饿现象。然而,非公平锁通常具有更高的吞吐量,因为它可以减少线程上下文切换的开销。
代码示例:公平锁和非公平锁的实现
下面我们通过代码示例来演示公平锁和非公平锁的使用:
公平锁:
import java.util.concurrent.locks.ReentrantLock;
public class FairLockExample {
private static ReentrantLock lock = new ReentrantLock(true); // true 表示公平锁
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取了锁");
Thread.sleep(100); // 模拟业务处理
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + " 释放了锁");
}
}, "Thread-" + i).start();
}
}
}
非公平锁:
import java.util.concurrent.locks.ReentrantLock;
public class UnfairLockExample {
private static ReentrantLock lock = new ReentrantLock(false); // false 表示非公平锁 (默认)
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取了锁");
Thread.sleep(100); // 模拟业务处理
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + " 释放了锁");
}
}, "Thread-" + i).start();
}
}
}
运行这两个示例,你会发现公平锁的线程获取锁的顺序更接近于线程启动的顺序,而非公平锁的线程获取锁的顺序则更随机。
高并发场景下的性能对比
在高并发场景下,公平锁和非公平锁的性能差异会更加明显。为了进行更严谨的性能对比,我们需要使用一些工具来测量吞吐量和延迟。这里,我们使用 JMH (Java Microbenchmark Harness) 来进行基准测试。
测试场景:
我们模拟一个简单的共享资源访问场景。多个线程并发地增加一个计数器的值。分别使用公平锁和非公平锁来保护计数器。
JMH 代码示例:
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
@State(Scope.Benchmark)
public class LockPerformanceBenchmark {
@Param({"true", "false"}) // true: 公平锁, false: 非公平锁
public boolean fair;
private ReentrantLock lock;
private int counter;
@Setup(Level.Trial)
public void setup() {
lock = new ReentrantLock(fair);
counter = 0;
}
@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Threads(8) // 模拟 8 个线程并发访问
public void incrementCounter() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(LockPerformanceBenchmark.class.getSimpleName())
.forks(1)
.warmupIterations(5)
.measurementIterations(5)
.build();
new Runner(opt).run();
}
}
代码解释:
@State(Scope.Benchmark): JMH 注解,表示该类是一个基准测试的状态对象。@Param({"true", "false"}): JMH 注解,表示fair字段是一个参数,它将在每次测试迭代中取true和false两个值。@Setup(Level.Trial): JMH 注解,表示setup()方法将在每次测试trial开始前执行。@Benchmark: JMH 注解,表示incrementCounter()方法是一个基准测试方法。@BenchmarkMode(Mode.Throughput): JMH 注解,表示测试模式为吞吐量,即每单位时间执行的操作次数。@OutputTimeUnit(TimeUnit.MILLISECONDS): JMH 注解,表示输出时间单位为毫秒。@Threads(8): JMH 注解,表示使用 8 个线程并发执行测试。
测试结果分析:
运行上述 JMH 测试,我们可以得到公平锁和非公平锁的吞吐量数据。通常情况下,非公平锁的吞吐量会高于公平锁。这是因为非公平锁允许线程“插队”,减少了线程上下文切换的开销。
示例测试结果(可能因环境而异):
| 锁类型 | 吞吐量 (ops/ms) |
|---|---|
| 公平锁 | 120 |
| 非公平锁 | 180 |
从示例结果可以看出,非公平锁的吞吐量比公平锁高出 50% 左右。
为什么非公平锁性能更高?
- 减少上下文切换: 当一个线程释放锁时,如果有一个正在运行的线程尝试获取锁,它可以直接获取锁,而不需要进入阻塞状态。这减少了线程上下文切换的开销。
- CPU 缓存亲和性: 当一个线程释放锁后,它很可能仍然在 CPU 的缓存中。如果该线程立即再次获取锁,它可以直接从缓存中读取数据,而不需要从主内存中读取数据。
公平锁的缺点:
- 更高的上下文切换开销: 线程必须按照 FIFO 的顺序获取锁,这可能会导致更多的线程上下文切换。
- 更低的吞吐量: 由于更高的上下文切换开销,公平锁的吞吐量通常低于非公平锁。
公平锁的优点:
- 避免饥饿: 每个线程都有机会获取锁,避免了某些线程一直无法获取锁的情况。
- 更公平的资源分配: 确保每个线程都能获得公平的资源分配,避免某些线程占用过多资源。
如何选择公平锁和非公平锁
选择公平锁还是非公平锁,需要根据具体的应用场景来决定。
- 如果需要保证公平性,避免饥饿现象,应该选择公平锁。 例如,在某些任务调度系统中,需要保证每个任务都有机会执行,避免某些任务一直被阻塞。
- 如果对吞吐量要求较高,可以容忍一定的公平性损失,应该选择非公平锁。 例如,在某些缓存系统中,需要尽可能地提高吞吐量,即使某些线程可能会稍微等待更长的时间。
- 默认情况下,推荐使用非公平锁。 只有在确实需要保证公平性的情况下,才应该考虑使用公平锁。
表格总结:公平锁与非公平锁的对比
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | 按照请求锁的顺序 | 允许“插队”,不保证请求顺序 |
| 公平性 | 保证公平性,避免饥饿 | 可能出现饥饿现象 |
| 吞吐量 | 较低 | 较高 |
| 上下文切换 | 较高 | 较低 |
| 适用场景 | 需要保证公平性的场景,例如任务调度系统 | 对吞吐量要求较高的场景,例如缓存系统,默认推荐 |
其他需要考虑的因素
除了公平性和吞吐量之外,还有一些其他的因素需要考虑:
- 锁的竞争程度: 如果锁的竞争程度很低,公平锁和非公平锁的性能差异可能不明显。
- 线程的调度策略: 操作系统线程调度策略也会影响锁的性能。
- 硬件环境: CPU 核心数、内存大小等硬件环境也会影响锁的性能。
实际案例分析
- 数据库连接池: 在数据库连接池中,可以使用非公平锁来提高连接获取的效率,因为连接的获取通常是短暂的,对公平性的要求不高。
- 消息队列: 在消息队列中,如果需要保证消息的顺序消费,可以使用公平锁来避免消息被乱序消费。
- Web 服务器: 在 Web 服务器中,可以使用非公平锁来提高请求处理的吞吐量,因为 Web 请求通常是短期的,对公平性的要求不高。
避免死锁的注意事项
在使用 ReentrantLock 时,一定要注意避免死锁。死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行的状态。
避免死锁的常用方法:
- 避免循环等待: 确保线程不会循环等待多个锁。
- 使用超时机制: 使用
tryLock(long timeout, TimeUnit unit)方法来设置获取锁的超时时间,避免线程无限期地等待锁。 - 按照固定的顺序获取锁: 如果需要获取多个锁,确保所有线程都按照相同的顺序获取锁。
结论和选择建议
总的来说,ReentrantLock 提供了公平锁和非公平锁两种选择,它们各有优缺点。非公平锁通常具有更高的吞吐量,但可能导致饥饿现象。公平锁可以避免饥饿,但吞吐量较低。
在实际应用中,应该根据具体的场景来选择合适的锁类型。
- 默认情况下,推荐使用非公平锁。
- 如果需要保证公平性,避免饥饿现象,才应该考虑使用公平锁。
- 在使用
ReentrantLock时,一定要注意避免死锁。
理解公平锁和非公平锁的特性,并结合具体的应用场景,才能编写出高效、可靠的并发程序。
持续学习和实践
并发编程是一个复杂而重要的领域,需要不断学习和实践才能掌握。希望今天的分享能够帮助大家更好地理解 ReentrantLock 的公平锁和非公平锁,并在实际工作中做出正确的选择。 感谢大家!