悲观锁
悲观锁(Pessimistic Locking)是一种悲观的并发控制机制,它基于悲观的假设,即并发冲突会时常发生,因此在访问共享资源(如数据库记录或共享变量)之前,会先获取独占性的锁,以防止其他线程对资源的并发读写。
悲观锁适用于写操作频繁、读操作较少的场景,能够确保数据一致性,但会引入较大的性能开销和线程切换的开销。
实现方式
在 Java 中,可以使用以下方式实现悲观锁:
synchronized
关键字:使用synchronized
关键字可以实现对共享资源的悲观锁。通过在方法或代码块中加上synchronized
关键字,只允许一个线程进入同步区域,并对共享资源进行操作。其他线程需要等待当前线程释放锁才能进入同步区域。
synchronized (sharedObject) {
// 进入同步区域,操作共享资源
}
ReentrantLock
类:ReentrantLock
是Java.util.concurrent
包提供的可重入锁实现。相较于 Synchronized,ReentrantLock 提供了更精细的锁控制,包括手动获取锁、手动释放锁、可重入性等特性。
注意事项
-
悲观锁的使用需要考虑锁的粒度,过大的锁粒度可能会影响并发性能,过小的锁粒度可能会导致频繁的锁竞争。
-
使用悲观锁时,应确保获取锁和释放锁的操作是成对出现的,否则可能会导致死锁或资源泄漏等问题。
-
需要谨慎处理异常情况,确保在异常发生时能够正确释放锁,避免其他线程被阻塞。
ReentrantLock
在Java并发编程中,ReentrantLock
是一个非常强大且灵活的锁机制,ReentrantLock
(重入锁)是实现了 Lock接口 的一个类,也是在实际编程中使用频率很高的一个锁,相比于 synchronized
关键字提供了更多的功能和灵活性。
如尝试非阻塞地获取锁、可中断地获取锁、以及锁的超时获取等。支持重入性,能够对共享资源重复加锁,即当前线程获取该锁后再次获取不会被阻塞。这些特性使得 ReentrantLock
成为实现悲观锁(Pessimistic Locking)的理想选择。
ReentrantLock
继承了 AQS
(AbstractQueuedSynchronizer
的缩写,即 抽象队列同步器
,是 Java.util.concurrent
中的一个基础工具类),内部有一个抽象类 Sync
,实现了一个同步器。
使用 ReentrantLock
需要进行显式地加锁和释放锁操作,如下所示:
ReentrantLock lock = new ReentrantLock();
lock.lock(); // 加锁
try {
// 临界区代码
} finally {
lock.unlock(); // 释放锁
}
特性
ReentrantLock
提供了与 synchronized
类似的互斥访问资源的能力,但它还提供了一些额外的特性:
-
可重入性:同一个线程可以多次获取同一个
ReentrantLock
锁,而不会产生死锁。这使得在一个方法中调用另一个需要同步访问的方法成为可能。在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
由于锁会被获取 n 次,那么只有锁在被释放同样的 n 次之后,该锁才算是完全释放成功。
-
可中断性:
ReentrantLock
提供了可中断的锁获取方式,即可以在等待锁时响应中断。 -
公平性:
ReentrantLock
可以选择公平性或非公平性的获取锁方式。-
在公平模式下,等待时间较长的线程更容易获得锁
-
在非公平模式下,则不保证线程获取锁的顺序。
-
-
条件变量支持:
ReentrantLock
通常与Condition
配合使用,提供了与条件变量相关联的Condition
对象,能够更灵活地实现精确的线程等待和唤醒操作。可以方便地实现多路选择通知,更加精确的线程等待和通知机制。
synchronized
只能通过wait
和notify
/notifyAll
方法唤醒一个线程或者唤醒全部线程(单路通知)
公平锁和非公平锁
公平锁(Fair Lock)和非公平锁(Unfair Lock)是针对锁的获取顺序而言的。
ReentrantLock
内部有两个非抽象类 NonfairSync
和 FairSync
,即 非公平同步器 和 公平同步器,它们都继承了 Sync
类,都调用了 AOS
(AbstractOwnableSynchronizer,这个类于 JDK 1.6 引入。用于表示锁与持有者之间的关系(独占模式)) 的 setExclusiveOwnerThread
方法,即 公平锁和非公平锁都是独占锁。
公平锁
公平锁(Fair Lock):当锁处于可用状态时,锁会先分配给等待时间最长的线程,也就是先排队的线程。
这样能够保证线程获取锁的顺序与线程启动的顺序一致,避免了等待时间过长的情况,确保较低优先级的线程也有机会获取到锁。在公平锁的情况下,锁的获取顺序是按照线程请求锁的顺序(FIFO)来进行排序的。
公平锁比非公平锁的性能更差一些,因为需要维护队列,而队列的操作是会对性能产生影响的。此外,使用公平锁时还可能出现 活锁
现象,即一个线程不断尝试获取锁,但总是失败的情况。
如果希望保证响应时间足够短且资源利用率不低,可以使用公平锁。
非公平锁
非公平锁(Unfair Lock):当锁处于可用状态时,锁会立即分配给一个准备好的线程,而不考虑其他等待获取锁的线程。在非公平锁的情况下,获取锁的线程是随机选择的,不具有先来先服务的特点。
在一般情况下,使用非公平锁的性能会更好,因为非公平锁减少了线程上下文的切换,从而提高了并发性。
创建 ReentrantLock
ReentrantLock
的无参构造方法是构造非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
ReentrantLock
的有参构造方法可传入一个 boolean 值,true 时为公平锁,false 时为非公平锁:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
公平锁的实现方式与非公平锁的实现方式基本一致,只是在获取锁时增加了判断当前节点是否有前驱节点的逻辑判断
使用 ReentrantLock
时,锁必须在 try 代码块开始之前获取,并且加锁之前不能有异常抛出,否则在 finally
块中就无法释放锁( ReentrantLock
的锁必须在 finally
中手动释放)
ReentrantLock lock = new ReentrantLock();
// ...
lock.lock();
try {
doSomething();
doOthers();
} finally {
lock.unlock();
}
ReentrantLock 使用示例
下面是一个使用 ReentrantLock
实现悲观锁的简单示例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
// 使用 ReentrantLock 作为悲观锁
private final Lock lock = new ReentrantLock();
private int count = 0;
// 线程安全地增加计数
public void increment() {
lock.lock(); // 尝试获取锁
try {
count++; // 访问共享资源
} finally {
lock.unlock(); // 释放锁
}
}
// 获取当前计数
public int getCount() {
lock.lock(); // 尝试获取锁
try {
return count; // 访问共享资源
} finally {
lock.unlock(); // 释放锁
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 假设有多个线程同时调用 increment() 方法
// 这里为了演示,我们只用一个线程来模拟
for (int i = 0; i < 1000; i++) {
counter.increment();
}
System.out.println("Final count: " + counter.getCount());
}
}
在这个示例中,Counter
类中的 increment()
和 getCount()
方法都使用了 ReentrantLock
来确保线程安全。尽管在这个简单的例子中我们只使用了单个线程来调用 increment()
方法,但在多线程环境下,ReentrantLock
会确保 count
变量的增加操作是线程安全的。
ReentrantReadWriteLock
在并发场景中,为了解决线程安全问题,我们通常会使用关键字 sychronized
或者 JUC
包(Java Util Concurrent
Java 并发工具包)中实现了 Lock 接口的 ReentrantLockopen
。但它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。
而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性,而如果在这种业务场景下,依然使用独占锁的话,很显然会出现性能瓶颈。针对这种读多写少的情况,Java 提供了另外一个实现 Lock 接口的 ReentrantReadWriteLock
。
ReentrantReadWriteLock
读写锁,它是对传统的互斥锁(如 ReentrantLock)的扩展,可以允许多个线程同时读取共享资源,而对写操作进行互斥,提供了读写分离的机制。
特性
ReentrantReadWriteLock
具有以下特点:
-
读锁共享性:多个线程可以同时获取读锁,读取共享资源,而不会互斥。这使得多个线程可以同时读取数据,提高了并发性能。
-
写锁独占性:一旦线程获取了写锁,其他线程无法获取读锁或写锁。这样可以确保只有一个线程进行写操作,保持数据的一致性。
-
可重入性:和 ReentrantLock 一样,ReentrantReadWriteLock 支持重入,同一个线程可以多次获取读锁或写锁。
-
锁降级:读写锁支持锁降级,即写锁降级,是一种允许写锁转换为读锁的过程。不支持锁升级。
由于读锁是共享的,所以当存在读锁时,写操作会被阻塞。这使得写操作的优先级较高,可以防止写操作长时间被读操作阻塞。
ReentrantReadWriteLock
管理读锁和写锁的机制使得读写操作可以并发进行,读锁和写锁是分离的,实现了读写、写读、写写的过程互斥,从而提高了并发性能。
适用于读操作远远多于写操作的场景,允许多线程同时读取共享资源,避免了读-读之间的互斥。
但在读操作和写操作的频率相差不大,或者读操作频率较高的情况下,仍然可能导致写操作长时间被延迟,影响系统的响应性能。
写锁降级
ReentrantReadWriteLock
的内部实现使用了 写锁降级 的机制,即一个线程在持有写锁的同时可以获取读锁,并逐步释放写锁,从而实现了锁的降级。
写锁降级是一种允许写锁转换为读锁的过程。通常的顺序是:
-
获取写锁:线程首先获取写锁,确保在修改数据时排它访问。
-
获取读锁:在写锁保持的同时,线程可以再次获取读锁。
-
释放写锁:线程保持读锁的同时释放写锁。
-
释放读锁:最后线程释放读锁。
这样,写锁就降级为读锁,允许其他线程进行并发读取操作,但仍然排除其他线程的写操作。
-
获取读锁:首先尝试获取读锁来检查某个缓存是否有效。
-
检查缓存:如果缓存无效,则需要释放读锁,因为在获取写锁之前必须释放读锁。
-
获取写锁:获取写锁以便更新缓存。此时,可能还需要重新检查缓存状态,因为在释放读锁和获取写锁之间可能有其他线程修改了状态。
-
更新缓存:如果确认缓存无效,更新缓存并将其标记为有效。
-
写锁降级为读锁:在释放写锁之前,获取读锁,从而实现写锁到读锁的降级。这样,在释放写锁后,其他线程可以并发读取,但不能写入。
-
使用数据:现在可以安全地使用缓存数据了。
-
释放读锁:完成操作后释放读锁。
读写状态的记录
AQS 内部的 state
字段(int 类型,32 位),用于描述有多少线程持有锁。
-
同步状态的低 16 位用来表示写锁的获取次数
-
同步状态的高 16 位用来表示读锁被获取的次数
如果是重入锁的话 state 值就是重入的次数
读锁和写锁
ReentrantReadWriteLock
内部维护了两把锁,分别为 读锁 ReadLock
和 写锁 WriteLock
ReadLock
和 WriteLock
是靠 AQS
(AbstractQueuedSynchronizer
的缩写,即 抽象队列同步器
,是 Java.util.concurrent
中的一个基础工具类)的子类 Sync
实现的锁
写锁
写锁的获取
ReentrantReadWriteLock
的写锁是排他锁(独享锁),而实现写锁的同步语义是通过重写 AQS
中的 tryAcquire
方法实现的:
首先获取写锁当前的同步状态,当读锁已经被读线程获取或者写锁已经被其他写线程获取,则写锁获取失败;否则,获取写锁成功并支持重入,增加写状态 exclusiveCount
。
tryAcquire()
除了重入条件(当前线程为获取写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取。
原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放
写锁释放通过重写 AQS
的 tryRelease
方法实现,与 ReentrantLock 的释放基本一致:
-
判断当前线程是否持有写锁,若未持有则抛出
IllegalMonitorStateException
异常。 -
将写状态变量减去对应的计数值 releases
int nextc = getState() - releases;
因为写状态是由同步状态的低 16 位表示的,只需要用当前同步状态直接减去写状态。 -
如果计数器为 0,则唤醒一个等待写锁的线程
-
如果计数器不为 0,则需要唤醒所有等待的线程
读锁
读锁的获取
读锁是一种共享式锁,同一时刻该锁可以被多个读线程获取。实现共享式同步组件的同步语义需要通过重写 AQS
(AbstractQueuedSynchronizer
的缩写,即 抽象队列同步器
,是 Java.util.concurrent
中的一个基础工具类) 的 tryAcquireShared
方法和 tryReleaseShared
方法
首先获取写锁的同步状态,如果写锁已经其他被获取,获取读锁失败,进入等待状态,如果当前线程获取了写锁或者写锁未被获取,当前线程增加读状态,获取读锁成功,利用 CAS 更新同步状态。
如果 CAS 失败或者已经获取读锁的线程再次获取读锁时,是通过 fullTryAcquireShared
方法实现的
读锁的释放
读锁释放的实现主要通过重写 AQS 的方法 tryReleaseShared
实现
-
判断当前线程是否持有读锁,若未持有则抛出
IllegalMonitorStateException
异常。 -
将当前线程的读锁计数器减 1。
-
如果当前线程的读锁计数器为 0,从读锁的等待队列中唤醒一个线程。
-
如果当前线程的读锁计数器还不为 0,则说明当前线程还持有至少一个读锁,不需要释放锁。