乐观锁 & 悲观锁

悲观锁


悲观锁的核心思想是在共享资源被操作时假设冲突一定会发生全部都加锁.

实现方式

  • synchronized : Java 中的关键字,可以修饰方法或代码块,保证同一时刻只有一个线程可以执行被修饰的代码。
  • ReentrantLock: Java java.util.concurrent.locks 包下的一个类,提供了比 synchronized 更广泛的锁操作。例如,它可以实现公平锁、非公平锁,可以被中断地获取锁等。

缺点

  1. 多个线程在持有锁并请求对方已持有的锁时,会造成相互等待,导致所有线程都无法继续执行。
  2. 当一个线程获取锁失败后,会被阻塞(挂起),操作系统需要将其从运行态切换到等待态。当锁被释放时,又需要将等待的线程唤醒,这个线程上下文的切换会消耗一定的 CPU 资源。
  3. 只要有一个线程持有了锁,其他所有想要访问该资源的线程都必须等待,这会降低系统的并发性能和吞吐量。

如何解决以上问题?

打破使得死锁成立的四个条件

  • 打破资源只能被单个线程使用的情况,让多个线程可同时使用
  • 破坏保持与堵塞,当一个线程持有资源并请求其他资源失败时,要求线程放弃已持有的资源
  • 允许剥夺条件,当线程持有锁并满足一定条件的情况下允许其他线程剥夺该线程的锁。
  • 破坏循环等待条件 顺序分配资源 按固定的编号来分配资源给进程
示例

通过破坏循环等待条件,我们可以给锁进行编号,当获取锁时按顺序获取,禁止先获取C再反过来获取A和B。

private static final ReentrantLock lockA = new ReentrantLock(); // 编号 1
private static final ReentrantLock lockB = new ReentrantLock(); // 编号 2
private static final ReentrantLock lockC = new ReentrantLock(); // 编号 3

public void correctMethod() {
    lockA.lock();           // 先拿 1
    try {
        lockB.lock();       // 再拿 2
        try {
            lockC.lock();   // 最后拿 3
            try {
                // 业务代码
            } finally {
                lockC.unlock();
            }
        } finally {
            lockB.unlock();
        }
    } finally {
        lockA.unlock();
    }
}

//通过固定的逻辑来确保不同线程中获取锁的顺序一致
int fromId = fromAccount.getId();
int toId = toAccount.getId();
Account firstLock = fromId < toId ? fromAccount : toAccount;
Account secondLock = fromId < toId ? toAccount : fromAccount;

synchronized(firstLock) {
    synchronized(secondLock) {
        // 执行转账逻辑
    }
}

允许剥夺条件可以通过带超时的锁来实现,当等待一段时间依旧无法获取到之后彻底放弃不再等待.

boolean gotA = lockA.tryLock(1, TimeUnit.SECONDS);
boolean gotB = lockB.tryLock(1, TimeUnit.SECONDS);

if (!gotA || !gotB) {
    // 超时未获取到,主动放弃已持有的锁(相当于"剥夺")
    if (gotA) lockA.unlock();
    if (gotB) lockB.unlock();
    return; // 或抛异常 / 重试
}

// lockInterruptibly():允许外部线程通过 interrupt() 强制中断等待
lockA.lockInterruptibly(); // 可被中断,相当于外部"剥夺"锁资格

破坏保持与堵塞可以通过设置申请锁为 一次性申请 / 申请失败则全部释放 解决

ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();

void transfer() throws InterruptedException {
    if (lockA.tryLock(1, TimeUnit.SECONDS) &&
        lockB.tryLock(1, TimeUnit.SECONDS)) {
        try {
            doTransfer();
        } finally {
            lockA.unlock();
            lockB.unlock();
        }
    } else {
        throw new TransferException("获取锁超时,请稍后重试");
    }
}

破坏互斥常见的是只读和单写多读等情况,可以使用 volatile ReentrantReadWriteLock CopyOnWriteArrayList 等实现

// 1. volatile:保证可见性,但不保证原子性(适用于单写多读的场景)
private volatile boolean flag = false;

// 写线程:只有一个线程写入
public void writer() {
    flag = true;  // volatile 写,直接刷新到主内存
}

// 读线程:多个线程可以同时读取
public void reader() {
    if (flag) {   // volatile 读,直接从主内存读取
        // 读取操作不需要加锁,多个读线程可同时执行
    }
}

// 2. ReentrantReadWriteLock:读写锁,允许多个线程同时读,但写独占
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Map<String, String> cache = new HashMap<>();

// 读锁:共享锁,多个线程可同时持有
public String get(String key) {
    rwLock.readLock().lock();
    try {
        return cache.get(key);  // 多个读线程可以并发执行
    } finally {
        rwLock.readLock().unlock();
    }
}

// 写锁:独占锁,只能有一个线程持有
public void put(String key, String value) {
    rwLock.writeLock().lock();
    try {
        cache.put(key, value);  // 写操作时,所有读/写都被阻塞
    } finally {
        rwLock.writeLock().unlock();
    }
}

// 3. CopyOnWriteArrayList:写时复制,适合读多写少
private final List<String> list = new CopyOnWriteArrayList<>();

// 读操作:无锁,多个线程可同时读取
public void read() {
    for (String item : list) {  // 读的时候不需要加锁
        System.out.println(item);
    }
}

// 写操作:复制整个数组,修改后替换引用
public void write(String item) {
    list.add(item);  // 写时复制,不影响正在读的线程
}

// 4. StampedLock:乐观读(Java 8),进一步减少锁竞争
private final StampedLock stampedLock = new StampedLock();
private double x, y;

// 乐观读:不加锁,假设没有写操作
public double distanceFromOrigin() {
    long stamp = stampedLock.tryOptimisticRead();  // 获取乐观读戳
    double currentX = x, 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);
}

适用场景

适合写多读少的情况,因为读操作并不对资源进行修改不会出现线程问题,而写操作要求强数值一致性所以适合悲观锁.

乐观锁


它总是假设最好的情况,认为数据在被自己操作时不会被其他线程修改。所以它不会加锁,而是在更新数据时,去检查在此期间有没有其他线程修改过数据。

实现方式

concurrent.atomic: Java 的 java.util.concurrent.atomic 包下的一系列原子类,如 AtomicInteger, AtomicLong 等。它们内部使用了 CAS 机制来实现原子操作。

机制

版本控制


在数据库中加入版本属性,当线程操作时需要携带当前操作数据的版本号,操作完成后比较此时版本与数据库中版本是否相同.

CAS(Compare And Swap)

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值 (Var)
  • E:预期值 (Expected)
  • N:拟写入的新值 (New)
CAS(内存地址, 期望值, 新值):
    if (地址 == 期望值):
        地址 = 新值
        return true
    else:
        return false

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

ABA 问题

指的是 当线程 1 要将 值 A 修改时被暂停或者挂起,此时其他线程进入把 A 修改为 B,又修改为 A,然后线程 1 恢复看到值依旧为 A 然后修改的情况。

为什么 ABA 有问题?

如果不是单纯的数字而是其他或者有业务状态的信息就完全不同了。

比如一个栈

A->B->C
  • Thread0 要获取 A.next 然后将栈顶修改为 B
  • 此时 Thread0 被挂起,Thread1 进入弹出 A B C
  • Thread1 又压入新的 A
  • Thread0 进入 对比 A 的预期值和 实际被新压入的 A 发现一致,然后将 A 弹出
  • Thread0 把 A.next 设置成了栈顶,于是就出现了 栈顶为 null 的情况

ABA 解决方式

让被修改的值具有唯一性、可校验性,例如版本号、时间戳、或者单向增加的计数器。保证不会出现被修改后值相同但无法发现情况
Java 中可以使用:

  1. AtomicStampedReference(版本号,最常用)
  2. AtomicMarkableReference(布尔标记位)

如果是纯计数无状态型的且不要求精确可以使用 LongAdder 实现

适用场景

适合读多写少的情况。因为读操作不需要加锁,可以提高并发性能,写操作时通过版本号或 CAS 来保证数据一致性。

对比

特性悲观锁乐观锁
核心思想假设冲突必然发生,先加锁再操作假设冲突不发生,更新时检查
实现方式synchronized, ReentrantLockCAS, 版本号
适用场景写多读少读多写少
并发性能较低(线程阻塞、上下文切换)较高(无阻塞)
死锁风险有无

来源

Javaguide# 乐观锁和悲观锁详解

Updated