乐观锁
乐观锁是一种乐观的并发控制策略。它的核心理念是,认为在大多数情况下,数据不会发生冲突,因此不会立即阻塞其他线程的读取操作。在乐观锁机制中,线程读取数据时并不持有锁,而是在更新数据时检查是否有其他线程已经修改了这个数据。
在Java 中的 StampedLock、AtomicInteger、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();
}
}
}
ReadWriteLock的缺点:
如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写
StampedLock
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() {
//获取一个乐观读锁(同时获取乐观锁的版本号stamp)
long stamp = stampedLock.tryOptimisticRead();
double currentX = x;
double currentY = y;
//检查乐观读锁后是否有其他写锁写入
//检查乐观锁版本号
if(!stampedLock.validate(stamp)) {
//获取悲观锁,读的时候不允许写
stamp = stampedLock.readLock();
try {
currentX = x;
currentY = y;
}finally {
stampedLock.unlockRead(stamp);//释放悲观锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。
悲观锁
相对于乐观锁,悲观锁采用了悲观的态度。它假设在整个操作过程中,其他线程可能随时修改数据,因此在进行读取或写入操作时,会将数据锁定,阻止其他线程对数据的并发访问。
悲观锁的实现通常依赖于底层的锁机制,如数据库的行级锁或Java中的synchronized关键字。当线程获得悲观锁后,其他线程必须等待锁被释放才能继续执行。
Java 中的 Synchronized 和 ReentrantLock 是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock不管是否持有资源,它都会尝试去加锁。
优点:
适用于写多读少或写多读多的场景,能有效避免数据冲突。
编程模型相对简单,不需要处理冲突和重试。
缺点:
锁的开销较大,可能导致性能瓶颈。
可能导致死锁问题,特别是在复杂的多锁场景下。
死锁
死锁指的是多个线程在运行的过程中,都需要获取对方线程所持有的锁(资源),导致处于长期无限等待的状态
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
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的锁
}
}
死锁的四个必要条件:
资源互斥(Mutual Exclusion):至少有一个资源处于非共享模式,即一次只允许一个线程或进程访问资源,其他线程或进程必须等待。
请求等待(Hold and Wait):线程或进程至少持有一个资源,并且在等待获取其他资源的同时不释放已持有的资源。
不可剥夺(No Preemption):资源只能由持有者显式释放,其他线程或进程不能强制剥夺已分配的资源。
循环等待(Circular Wait):存在一个资源的循环链,每个线程或进程都在等待下一个线程或进程所持有的资源。
如何避免死锁:
1. 每次只占用不超过1个锁。
2. .按照相同的顺序申请锁。
3. 使用信号量。
Semaphore信号量
Semaphore本质上就是一个信号计数器,用于限制同一时间的最大访问数量,通过合理使用信号量,我们可以有效地控制资源的访问,避免竞争条件和死锁问题,保证系统的稳定性和性能。
public class SemaphoreDemo{
// 任意时刻仅允许最多3个线程获取许可:
final Semaphore semaphore = new Semaphore(3);
public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}
同时,信号量的错误使用也可能导致其他问题,比如资源饥饿(resource starvation),因此在使用信号量时需要仔细考虑设计和实现。