目录
什么是Lock
Java并发包(java.util.concurrent,简称JUC)提供了比synchronized
关键字更灵活、更强大的锁机制,其中Lock
接口及其实现类是JUC中锁机制的核心。
Lock和synchronized的区别
-
首先synchronized是java内置关键字,在jvm层面,Lock是个java类;
-
synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁;
-
synchronized会自动释放锁(a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放 锁),Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁;
-
用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1 阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以 不用一直等待就结束了;
-
synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可判断、可公平(两者皆可)
-
Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题。
Lock的实现类
-
ReentrantLock:可重入锁,是最常用的
Lock
实现。它支持可重入性,即同一个线程可以多次获取同一把锁。ReentrantLock
还支持公平锁(fair),可以按照线程请求锁的顺序来分配锁。 -
ReentrantReadWriteLock:读写锁,允许多个线程同时读取数据,但写操作是互斥的。它包含一对锁:读锁和写锁。读锁是共享的,写锁是排他的。
-
StampedLock:引入于Java 8,是一种读写锁的替代品,提供了更好的性能。它使用“戳”(stamp)来管理锁状态,支持乐观读、写锁和验证操作。
ReentrantLock锁(Lock的默认实现)
ReentrantLock
是Java并发包java.util.concurrent.locks
中提供的一个可重入互斥锁的实现,
基本使用
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // 获取锁
try {
count++; // 保护的代码块
} finally {
lock.unlock();// 确保释放锁
}
}
public int getCount() {
return count;
}
}
-
初始化锁
new
:在Counter
类中定义了一个ReentrantLock
类型的成员变量lock
,并通过new ReentrantLock()
进行初始化。这意味着每个Counter
实例都拥有自己的锁对象,互不影响。 -
获取锁
lock
:在increment()
方法中,首先调用lock.lock()
来获取锁。如果锁已被其他线程持有,则当前线程将被阻塞,直到锁被释放。 -
执行业务代码:获取锁之后,进入临界区,执行
count++
语句。临界区是多线程环境下需要同步的代码段,确保同一时间只有一个线程可以执行这部分代码,从而避免了并发访问导致的数据不一致问题。 -
释放锁
unlock
:无论临界区代码是否正常执行完毕,都需要确保锁的释放。这是通过在finally
块中调用lock.unlock()
来实现的。使用finally
块可以保证即使在临界区代码中发生异常,锁也能被正确释放,避免死锁的发生。
ReentrantLock的特点
-
可重入性:一个线程可以重复获得同一把锁,即如果一个线程持有一个已被锁定的 ReentrantLock 对象,那么这个线程可以再次获取该锁而不会发生死锁,因为锁会维护一个持有计数来追踪同一个线程多次获得该锁。
-
可中断的锁获取操作:使用 ReentrantLock 时,线程可以响应中断。即当尝试获取一个锁的线程被中断(通过 Thread.interrupt() 方法)时,可以响应这个中断并终止获取锁的操作,而 synchronized 关键字则不具备这个能力。
-
尝试非阻塞获取:可以尝试获取锁,如果获取不到可以立即返回,而不是无限期等待。
-
可限时的锁获取操作:在获取锁时可以指定等待的时间,如果在给定的等待时间内无法获取到锁,则可以放弃获取锁的操作。
ReentrantReadWriteLock锁(读写锁)
ReentrantReadWriteLock
是 Java 并发包中的一种读写锁机制,它允许多个读线程同时访问共享资源,但只允许一个写线程访问共享资源。
基本使用
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// 读操作
readLock.lock();
try {
// 读取共享资源
} finally {
readLock.unlock();
}
// 写操作
writeLock.lock();
try {
// 修改共享资源
} finally {
writeLock.unlock();
}
下面我们来详细解释上面的代码:
1.初始化锁
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
-
创建一个
ReentrantReadWriteLock
实例lock
,它内部维护了读锁和写锁。 -
通过调用
lock.readLock()
获取读锁readLock
,该锁允许多个线程同时持有,用于保护读操作。 -
通过调用
lock.writeLock()
获取写锁writeLock
,该锁在同一时间只能由一个线程持有,用于保护写操作。
2.读操作
// 读操作
readLock.lock();
try {
// 读取共享资源
} finally {
readLock.unlock();
}
-
在读取共享资源之前,先调用
readLock.lock()
获取读锁。如果读锁可用,则立即获得;如果已有其他线程持有写锁或正在等待写锁,则当前线程将被阻塞,直到读锁可用。 -
进入
try
块,执行读取共享资源的代码。由于读锁允许多个线程同时持有,因此可以有多个线程并发执行这部分代码。 -
在
finally
块中调用readLock.unlock()
释放读锁。确保即使在读取过程中发生异常,读锁也能被正确释放,避免死锁。
3.写操作
// 写操作
writeLock.lock();
try {
// 修改共享资源
} finally {
writeLock.unlock();
}
-
在修改共享资源之前,先调用
writeLock.lock()
获取写锁。如果写锁可用,则立即获得;如果已有其他线程持有读锁或写锁,则当前线程将被阻塞,直到写锁可用。 -
进入
try
块,执行修改共享资源的代码。由于写锁在同一时间只能由一个线程持有,因此这部分代码是独占执行的,确保了写操作的原子性和一致性。 -
在
finally
块中调用writeLock.unlock()
释放写锁。确保即使在修改过程中发生异常,写锁也能被正确释放,避免死锁。
ReentrantReadWriteLock的特点
-
读写分离:内部维护了两个锁,一个读锁(共享锁)和一个写锁(独占锁)。读锁允许多个线程同时持有,而写锁在任何时候只能由一个线程持有。
-
互斥关系:读锁和写锁之间是互斥的,即当有线程持有写锁时,其他线程无法获取读锁或写锁;而读锁之间是共享的,多个线程可以同时持有读锁。
-
可重入性:支持可重入,即同一个线程可以多次获取同一把锁。如果一个线程已经持有了读锁,它可以再次获取读锁;如果持有了写锁,它可以再次获取写锁或读锁。
-
锁降级:支持从写锁降级为读锁,但不支持从读锁升级为写锁。降级过程是先获取写锁,然后获取读锁,最后释放写锁。
-
公平性:可以设置为公平锁或非公平锁。公平锁会按照线程请求锁的顺序来分配锁,而非公平锁可能会允许在队列外的线程抢到锁。
StampedLock锁(乐观读锁)
StampedLock
是 Java 8 引入的一种新型锁机制,它在传统的 ReentrantLock
和 ReentrantReadWriteLock
的基础上进行了优化和增强,通过引入乐观读锁和时间戳(stamp)的概念,提升了读写性能,尤其是在读多写少的场景下。
基本使用
import java.util.concurrent.locks.StampedLock;
public class StampedLockExample {
private double x, y;
private final StampedLock lock = new StampedLock();
// 写操作:移动坐标
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp); // 释放写锁
}
}
// 悲观读操作:获取坐标
public double distanceFromOrigin() {
long stamp = lock.readLock(); // 获取读锁
try {
return Math.sqrt(x * x + y * y);
} finally {
lock.unlockRead(stamp); // 释放读锁
}
}
// 乐观读操作:获取坐标
public double optimisticDistanceFromOrigin() {
long stamp = lock.tryOptimisticRead(); // 获取乐观读锁
double currentX = x, currentY = y;
if (!lock.validate(stamp)) { // 检查乐观读锁是否被其他写操作干扰
stamp = lock.readLock(); // 回退到悲观读锁
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp); // 释放读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
代码较为复杂,下面我们来详细解析:
1.初始化锁
private final StampedLock lock = new StampedLock();
-
创建一个
StampedLock
实例lock
,它是java.util.concurrent.locks
包中的一个锁机制,用于控制对共享资源的并发访问。
2.写操作
public void move(double deltaX, double deltaY) {
long stamp = lock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
lock.unlockWrite(stamp); // 释放写锁
}
}
-
获取写锁:调用
lock.writeLock()
方法获取写锁,返回一个long
类型的 stamp 值。写锁是独占模式,同一时间只能有一个线程持有写锁,用于保护写操作。 -
执行写操作:在获取写锁后,进入
try
块,执行修改共享资源的操作,即将坐标点(x, y)
按照给定的增量(deltaX, deltaY)
进行移动。 -
释放写锁:在
finally
块中调用lock.unlockWrite(stamp)
方法释放写锁,传入之前获得的 stamp 值。确保即使在写操作过程中发生异常,写锁也能被正确释放,避免死锁。
3.悲观读操作
public double distanceFromOrigin() {
long stamp = lock.readLock(); // 获取读锁
try {
return Math.sqrt(x * x + y * y);
} finally {
lock.unlockRead(stamp); // 释放读锁
}
}
-
获取读锁:调用
lock.readLock()
方法获取读锁,返回一个 stamp 值。读锁是共享模式,允许多个线程同时持有读锁,用于保护读操作。 -
执行读操作:在获取读锁后,进入
try
块,执行读取共享资源的操作,计算坐标点(x, y)
到原点的距离。 -
释放读锁:在
finally
块中调用lock.unlockRead(stamp)
方法释放读锁,传入之前获得的 stamp 值。确保即使在读操作过程中发生异常,读锁也能被正确释放,避免死锁。
4.乐观读操作
public double optimisticDistanceFromOrigin() {
long stamp = lock.tryOptimisticRead(); // 获取乐观读锁
double currentX = x, currentY = y;
if (!lock.validate(stamp)) { // 检查乐观读锁是否被其他写操作干扰
stamp = lock.readLock(); // 回退到悲观读锁
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp); // 释放读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
-
获取乐观读锁:调用
lock.tryOptimisticRead()
方法获取乐观读锁,返回一个 stamp 值。乐观读锁是一种无锁的数据访问方式,允许读取时不加锁,适用于读多写少的场景,可以显著提高并发性能。 -
读取数据:在获取乐观读锁后,读取共享资源的当前值
currentX
和currentY
。 -
验证 stamp:调用
lock.validate(stamp)
方法检查在读取数据期间是否有写操作发生。如果验证失败,表示数据可能已被修改。 -
回退到悲观读锁:如果验证失败,则调用
lock.readLock()
方法获取悲观读锁,重新读取共享资源的值。然后在finally
块中释放悲观读锁。 -
返回结果:根据读取的坐标值计算距离并返回
StampedLock锁的特点
锁模式
-
三种锁模式:提供了写锁、悲观读锁和乐观读锁三种模式,以满足不同的并发场景需求。
-
写锁(write lock):独占模式的锁,和ReentrantLock类似,保证写操作的排他性。
-
悲观读锁(read lock):共享模式的锁,多个线程可以同时持有读锁,但写锁需要等待。
-
乐观读锁(Optimistic Read Lock):无锁机制,基于乐观假设,在读取数据时不加锁,适用于读多写少的场景。
时间戳(Stamp)
-
锁状态表示:每个锁操作都会返回一个
long
类型的 stamp,代表锁的状态和版本信息。后续操作需要根据这个stamp来验证锁是否有效。 -
锁转换依据:根据 stamp 进行锁的转换,如乐观读锁验证失败时,可以获取悲观读锁或写锁。
性能优势
-
高并发性能:在读多写少的场景下,性能优于传统的
ReentrantReadWriteLock
,因为乐观读锁减少了线程阻塞和上下文切换。 -
减少阻塞:乐观读锁允许读操作在没有竞争的情况下快速进行,只有在检测到写操作发生时才会回退到悲观读锁或重试。
缺点
-
不可重入:StampedLock是非重入锁,这意味着同一个线程不能在持有锁时再次获取同类型的锁,否则会造成死锁。
-
读锁饥饿:在高写入负载的场景下,悲观读锁可能会被长期阻塞,导致读操作饥饿。
-
CPU飙升风险:如果线程使用writeLock()或者readLock()获得锁之后,线程还没执行完就被interrupt()的话。