目录
什么是乐观锁和悲观锁?
悲观锁(Pessimistic Locking), 具有强烈的独占和排他特性。它指的是对数据被外界修改持保守态度。因此,在整个执行过程中,将处于锁定状态。所以,悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源 或者 数据 锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。Java 中的 Synchronized 和 ReentrantLock 是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock不管是否持有资源,它都会尝试去加锁。
乐观锁(Optimistic Locking) ,相对悲观锁而言,乐观锁的加锁机制更为宽松。它的思想与悲观锁恰好相反,它总认为资源和数据不会被别人修改,所以读取时不会加锁,但是在写入的时候会判断当前数据是否被修改过。Java 中的 StampedLock和AtomicInteger是一种乐观锁思想的实现。
ReadWriteLock
ReentrantLock保证了只有一个线程可以执行临界区代码。
但是在有些情况下,这种保护有点过头。我们发现,它在任何时刻都只允许一个线程进入,但是当我们读取数据的时候是不会修改数据的,读操作实际上是允许多个线程同时调用的 。
实际开发中我们想要的是: 允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。
读 | 写 | |
---|---|---|
读 | 允许 | 不允许 |
写 | 不允许 | 不允许 |
使用ReadWriteLock可以解决这个问题,它保证:
- 只允许一个线程写入(写入时不允许有其他操作)
- 没有写入时,允许多个线程同时读
下面使用 ReadWriteLock 来实现这个功能: 我们需要创建一个ReadWriteLock类的实例,分别获取写锁和读锁。
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
把读写操作分别用两把不同的锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
但是它存在一个潜在的问题: 如果有线程正在读,写线程将会阻塞,直到读线程释放锁之后才有可能竞争到写锁,即读的过程中不允许写。
StampedLock
Java 8进一步提升并发执行效率,引入了新的读写锁: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() 验证版本号,如果读取过程中没有发生写入,版本号不变,验证成功,进行下一步操作。
- 如果发生过写入操作,版本号会发生变化,验证失败,那么此时会通过readLock() 获取悲观读锁,再次读取。
- 由于写入的概率不高,程序在大部分情况下可以通过乐观读锁读取数据,极少数情况下使用悲观读锁进行读取。
总结: StampedLock把读锁细分为乐观读锁和悲观读锁,能进一步提高并发效率。但这也是有代价的: 1. 代码更加复杂; 2. StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。
Semaphore(信号量)
通过各种锁的实现,我们发现锁的目的其实是为了保护一种受限资源,保证了同一时刻只有一个线程访问(ReentrantLock), 或者只有一个线程能写入(ReadWriteLock)。
还有一种受限资源,它需要保证同一时刻最多只有N个线程能访问,比如同一时刻最多创建100个数据库连接,最多允许10个用户下载等。
这种限制数量的锁,可以用Lock数组来实现,但是非常麻烦。类似需求常见更适合Semaphore信号量。它的本质就是一个信号计数器,用于限制同一时间的最大线程数。
例如,最多允许3个线程同时访问:
- 在类中创建Semaphore实例,并在构造方法中指定最多访问的数量;
- 通过acquire()获取,在finally块中调用release()方法释放;
- 当满足指定数量时其他线程会阻塞,直到满足条件为止。
public class AccessLimitControl {
// 任意时刻仅允许最多3个线程获取许可:
final Semaphore semaphore = new Semaphore(3);
public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}
也可以使用 tryAcquire() 指定等待时间:
if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
// 指定等待时间3秒内获取到许可:
try {
// TODO:
} finally {
semaphore.release();
}
}
什么是死锁?
多个线程在运行过程中,都需要获取对方所持有的资源,导致处于无限等待的状态。
例如:
public class DeadLock {
private static Object lockA = new Object();
private static Object lockB = new Object();
public void add() throws InterruptedException {
synchronized (lockA) { // 获得lockA的锁
Thread.sleep(100); // 线程休眠
synchronized (lockB) { // 获得lockB的锁
System.out.println("执行add()");
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec() {
synchronized (lockB) { // 获得lockB的锁
synchronized (lockA) { // 获得lockA的锁
System.out.println("执行dec()");
} // 释放lockA的锁
} // 释放lockB的锁
}
}
当不同的线程在执行不同的方法时,由于各自持有的资源不同,但同时又需要对方持有的资源,这个时候就可能导致死锁:
- 线程1:进入add(),获得lockA;
- 线程2:进入dec(),获得lockB;
随后:
- 线程1:准备获得lockB,失败,等待中;
- 线程2:准备获得lockA,失败,等待中。
死锁发生后没有任何机制可以解除, 只能强制结束JVM进程。
死锁发生的条件
产生死锁的四个必要条件:
- 资源互斥: 所分配的资源进行排他性控制,也就是说线程进入synchronized代码块后,其他线程就不能获得这个资源,同一时刻只能被一个线程使用;
- 不可剥夺: 线程在未使用完所分配的资源时,不能被剥夺,只能等待占有者主动释放锁;
- 请求等待: 当线程因请求资源而被阻塞时,没有额外的尝试机制,会一直等待下去同时不释放自身持有的资源,直到请求的资源被释放(使用ReentrantLock关键字可以提供额外的尝试机制);
- 循环等待: 线程之间的相互等待。
如何发现死锁
- 通过jps命令,显示本地所有JVM进程,查找当前JVM进程的进程号。
- 通过jstack命令,显示当前虚拟机栈的栈信息,查找产生死锁的线程。
如何避免死锁
- 每次只占用不超过1个锁。
- 按照相同的顺序申请锁。
- 破坏不可剥夺条件: 当因访问不到资源而被阻塞时,释放以获得的资源以避免死锁。
- 使用信号量: 保证最多只有一个线程持有两个锁,不存在多个线程竞争。
public class DeadLock {
private Semaphore semaphoreA = new Semaphore(1);
private Semaphore semaphoreB = new Semaphore(1);
public void add() throws InterruptedException {
semaphoreA.acquire();
Thread.sleep(1000);
semaphoreB.acquire();
try {
System.out.println("执行add()");
} finally {
semaphoreB.release();
semaphoreA.release();
}
}
public void dec() throws InterruptedException {
while (semaphoreB.tryAcquire()) {
Thread.sleep(100);
if (semaphoreA.tryAcquire()) {
try {
System.out.println("执行dec()");
return;
} finally {
semaphoreA.release();
semaphoreB.release();
}
} else {
semaphoreA.release();
semaphoreB.release();
}
}
}
}