Java中的可重入锁与公平锁
Java中的可重入锁
在Java的并发编程领域,锁机制是控制多线程访问共享资源的重要手段。可重入锁是一种特殊类型的锁,它允许同一个线程对同一把锁进行多次获取。这一特性在实际编程中有着重要的意义。
可重入锁的原理
从本质上讲,可重入锁是通过为每个锁关联一个持有线程和一个计数器来实现的。当一个线程获取锁时,如果该锁没有被其他线程持有,那么这个线程就成为锁的持有者,同时计数器设置为1。如果同一个线程再次获取该锁,计数器会递增。每次线程释放锁时,计数器会递减,当计数器为0时,锁被完全释放,其他线程可以获取。
代码示例
下面通过一个简单的代码示例来展示可重入锁的使用:
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread thread = new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取到锁,第一次进入");
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取到锁,第二次进入");
} finally {
lock.unlock();
}
System.out.println(Thread.currentThread().getName() + " 第一次进入部分即将结束");
} finally {
lock.unlock();
}
});
thread.start();
}
}
在上述代码中,我们创建了一个ReentrantLock
实例。在main
方法中启动了一个线程,该线程在获取锁后,又再次获取锁,这展示了可重入锁的可重入特性。第一次获取锁时,计数器变为1,第二次获取锁时,计数器变为2。当内层代码块执行完毕释放锁时,计数器减为1,外层代码块执行完毕再次释放锁,计数器减为0,锁被完全释放。
可重入锁的应用场景
- 多层方法调用:在一个类的方法中,可能会调用其他方法,而这些方法也需要访问共享资源。如果使用可重入锁,就可以避免因重复获取锁而导致的死锁问题。例如,在一个业务逻辑类中,
methodA
调用methodB
,而这两个方法都需要对某个共享资源进行操作,使用可重入锁可以确保线程在调用过程中顺利获取锁。
public class BusinessLogic {
private static final ReentrantLock lock = new ReentrantLock();
public void methodA() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 在 methodA 中获取到锁");
methodB();
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 在 methodB 中获取到锁");
} finally {
lock.unlock();
}
}
}
- 递归调用:当一个方法递归调用自身时,可重入锁可以确保每次递归都能顺利获取锁。例如,在计算阶乘的方法中,如果需要对共享资源进行操作,使用可重入锁就可以保证递归过程的正确性。
public class FactorialCalculator {
private static final ReentrantLock lock = new ReentrantLock();
public int factorial(int n) {
lock.lock();
try {
if (n == 0 || n == 1) {
return 1;
} else {
return n * factorial(n - 1);
}
} finally {
lock.unlock();
}
}
}
Java中的公平锁
公平锁是另一种重要的锁机制,它强调线程获取锁的顺序。公平锁会按照线程请求锁的先后顺序来分配锁,先请求的线程先获取锁,这与非公平锁形成了鲜明的对比,非公平锁可能会让后请求的线程优先获取锁。
公平锁的原理
公平锁的实现依赖于一个等待队列。当一个线程请求锁时,如果锁不可用,该线程会被放入等待队列。当锁被释放时,等待队列中最前面的线程会被唤醒并获取锁。这样就保证了线程获取锁的公平性。
代码示例
在Java中,ReentrantLock
默认是非公平锁,但可以通过构造函数来创建公平锁。以下是一个公平锁的代码示例:
import java.util.concurrent.locks.ReentrantLock;
public class FairLockExample {
private static final ReentrantLock fairLock = new ReentrantLock(true);
public static void main(String[] args) {
Thread[] threads = new Thread[5];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
fairLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 获取到公平锁");
} finally {
fairLock.unlock();
}
});
}
for (Thread thread : threads) {
thread.start();
}
}
}
在上述代码中,我们通过new ReentrantLock(true)
创建了一个公平锁。多个线程启动后,它们会按照请求锁的顺序依次获取锁,打印出获取到公平锁的信息。
公平锁的应用场景
- 资源分配公平性要求高:在一些场景中,如多用户共享的文件系统资源分配,每个用户的请求应该按照先后顺序得到处理。使用公平锁可以确保先发出请求的用户先获得资源,避免某些用户长时间等待。
- 避免线程饥饿:在高并发环境下,如果使用非公平锁,可能会导致某些线程长时间无法获取锁,即线程饥饿。公平锁可以有效地避免这种情况,保证每个线程都有机会获取锁。例如,在一个多线程的任务调度系统中,每个任务都希望能按照提交的顺序得到执行,使用公平锁可以满足这一需求。
可重入锁与公平锁的比较
- 性能方面
- 可重入锁:由于可重入锁在实现上相对简单,不需要维护等待队列等复杂的数据结构,因此在高并发环境下,尤其是竞争不激烈的情况下,性能表现较好。例如,在一个只有少量线程竞争锁的系统中,可重入锁可以快速地被获取和释放,减少线程上下文切换的开销。
- 公平锁:公平锁因为需要维护等待队列,每次锁的获取和释放都需要对队列进行操作,这增加了额外的开销。在高并发环境下,频繁的队列操作会导致性能下降。例如,在一个有大量线程竞争锁的场景中,公平锁的队列维护操作会消耗大量的CPU时间,降低系统的整体性能。
- 公平性方面
- 可重入锁:可重入锁并不保证公平性,它可能会让后请求的线程先获取锁。在某些情况下,这可能会导致部分线程长时间等待,出现线程饥饿现象。例如,在一个多线程的任务处理系统中,如果使用可重入锁,可能会有一些任务长时间得不到执行。
- 公平锁:公平锁严格按照线程请求锁的先后顺序分配锁,确保了公平性,避免了线程饥饿问题。这在一些对公平性要求较高的场景中非常重要,如银行排队叫号系统,必须按照客户排队的顺序进行服务。
- 使用场景选择
- 可重入锁:适用于对性能要求较高,对公平性要求不严格的场景。例如,在一个内部系统的缓存更新操作中,更关注的是操作的快速执行,而不太在意线程获取锁的顺序,此时可重入锁是一个较好的选择。
- 公平锁:适用于对公平性要求较高,对性能要求相对较低的场景。例如,在一个面向公众的在线服务系统中,为了保证每个用户都能得到公平的服务,需要使用公平锁来确保请求按照先后顺序处理。
深入理解可重入锁与公平锁的实现细节
可重入锁的实现细节
- AQS框架:在Java中,
ReentrantLock
是基于AbstractQueuedSynchronizer(AQS)框架实现的。AQS是一个用于构建锁和同步器的框架,它提供了一个FIFO的等待队列来管理等待获取资源的线程。ReentrantLock
通过继承AQS并重写其方法来实现可重入锁的功能。 - 获取锁的过程:当调用
lock()
方法获取锁时,ReentrantLock
首先尝试使用tryAcquire(int)
方法获取锁。在ReentrantLock
的实现中,tryAcquire(int)
方法会检查当前锁是否被其他线程持有。如果锁未被持有,当前线程将成为锁的持有者,并将AQS中的同步状态(state)设置为1。如果锁已经被当前线程持有,同步状态会递增。如果锁被其他线程持有,当前线程会被封装成一个节点加入到AQS的等待队列中,并进入阻塞状态。 - 释放锁的过程:当调用
unlock()
方法释放锁时,ReentrantLock
会调用tryRelease(int)
方法。该方法会将同步状态递减,如果同步状态减为0,表示锁已经完全释放,此时会唤醒等待队列中的一个线程。
public class ReentrantLock {
private final Sync sync;
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair? new FairSync() : new NonfairSync();
}
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = -5179523762034025860L;
abstract void lock();
@Override
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
@Override
protected final boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
}
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
@Override
void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
@Override
protected boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
@Override
void lock() {
acquire(1);
}
@Override
protected 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)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
final boolean nonfairTryAcquire(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)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
上述代码展示了ReentrantLock
的部分实现,其中Sync
类继承自AbstractQueuedSynchronizer
,NonfairSync
和FairSync
分别实现了非公平锁和公平锁的获取锁逻辑。
公平锁的实现细节
- 公平性的保证:公平锁在获取锁时,会调用
hasQueuedPredecessors()
方法检查等待队列中是否有前驱节点。如果有前驱节点,说明有其他线程先于当前线程请求锁,当前线程会进入等待队列。只有当等待队列中没有前驱节点且锁可用时,当前线程才能获取锁。这确保了线程按照请求锁的先后顺序获取锁。 - 等待队列的管理:AQS的等待队列是一个双向链表,每个节点封装了一个等待获取锁的线程。当线程请求锁失败时,会创建一个节点并加入到等待队列的尾部。当锁释放时,会从等待队列的头部唤醒一个线程。公平锁通过严格按照等待队列的顺序唤醒线程来保证公平性。
- 性能影响:由于公平锁需要频繁地操作等待队列,包括节点的插入、删除和唤醒操作,这在高并发环境下会带来较大的性能开销。相比之下,非公平锁在获取锁时不需要检查等待队列,直接尝试获取锁,因此在性能上更具优势。但公平锁在保证公平性方面具有不可替代的作用,适用于对公平性要求较高的场景。
可重入锁与公平锁在实际项目中的选择与优化
在实际项目中的选择
- 考虑业务需求:如果业务对公平性有严格要求,如在线票务系统,每个用户的购票请求应该按照先后顺序处理,以保证公平竞争,此时应选择公平锁。而如果业务对性能更为关注,对公平性要求不高,如内部的缓存更新操作,可选择可重入锁(非公平锁)。
- 分析并发场景:在并发量较低、竞争不激烈的场景中,可重入锁和公平锁的性能差异并不明显,此时可以根据业务需求选择。但在高并发、竞争激烈的场景中,可重入锁(非公平锁)通常能提供更好的性能,因为它减少了等待队列操作的开销。然而,如果需要避免线程饥饿问题,即使在高并发场景下,公平锁可能也是必要的选择。
- 结合系统架构:如果系统架构中存在多个层次的锁,需要考虑不同层次锁的特性。例如,在分布式系统中,可能会使用分布式锁,此时可以在本地使用可重入锁提高性能,而在分布式层面根据业务需求选择公平或非公平的分布式锁。
优化策略
- 减少锁的粒度:无论是可重入锁还是公平锁,都可以通过减少锁的粒度来提高性能。即将大的锁范围划分为多个小的锁范围,使得不同线程可以同时访问不同的资源,减少锁竞争。例如,在一个多线程操作的数据库缓存系统中,可以为每个缓存分区设置独立的锁,而不是使用一个全局锁。
- 合理设置锁的超时时间:在使用锁时,可以设置合理的超时时间,避免线程长时间等待锁。如果在超时时间内未能获取锁,线程可以执行其他操作或进行重试。这在一定程度上可以提高系统的响应性,避免线程饥饿。例如,在一个网络请求处理系统中,设置锁的超时时间可以防止因锁等待时间过长而导致的请求超时。
- 使用读写锁:如果系统中读操作远多于写操作,可以考虑使用读写锁(如
ReentrantReadWriteLock
)。读写锁允许多个线程同时进行读操作,而只允许一个线程进行写操作。这样可以在保证数据一致性的前提下,提高系统的并发性能。例如,在一个新闻资讯网站的后台管理系统中,大量用户读取新闻内容,而只有管理员进行写操作,使用读写锁可以有效地提高系统性能。
通过深入理解可重入锁与公平锁的原理、实现细节、应用场景以及优化策略,开发人员可以在Java并发编程中更加灵活地选择和使用锁机制,提高系统的性能和稳定性。无论是在性能优先的场景,还是公平性至上的业务需求下,都能找到最合适的解决方案。同时,合理的优化策略可以进一步提升系统在高并发环境下的表现,满足不断增长的业务需求。在实际项目中,要根据具体情况进行全面的分析和权衡,选择最适合的锁机制,并进行针对性的优化,以构建高效、稳定的并发系统。在不断发展的技术领域,对锁机制的研究和应用也在持续演进,开发人员需要保持学习,紧跟技术发展的步伐,以应对日益复杂的并发编程挑战。