基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

Java锁:乐观锁与悲观锁

知识点图片

本文讲解Java中的悲观锁与乐观锁。悲观锁(如synchronized)假定冲突,先加锁再操作;乐观锁(CAS机制)假定无冲突,更新时才验证。文章对比了两者的原理、性能及适用场景。

我们来详细、清晰地讲解一下Java中的乐观锁和悲观锁。

这是一个在并发编程中非常核心和重要的概念。

核心思想:对“冲突”的态度

理解这两种锁最简单的方式,就是从它们的名字入手,它们代表了两种截然不同的并发处理哲学:

  • 悲观锁 (Pessimistic Locking):它非常“悲观”,总是假设最坏的情况,即每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。这样别人想拿这个数据就会阻塞,直到它拿到锁。
  • 乐观锁 (Optimistic Locking):它非常“乐观”,总是假设最好的情况,即每次去拿数据的时候都认为别人不会修改,所以不会上锁。但是在更新的时候会判断一下,在此期间别人有没有去更新这个数据。

打个比方:

  • 悲观锁:就像去一个只有一个卫生间的公共场所。你进去后,先把门反锁 (lock),这样可以保证在你使用期间,绝对不会有人闯进来。用完之后,你再把锁打开 (unlock),下一个人才能进去。整个过程是“先加锁,再操作”。
  • 乐观锁:就像在图书馆修改一份公共文档。你先把文档内容复制一份到你的草稿纸上(读取原始值和版本号),然后在草稿纸上修改。改完后,你想把你的修改提交回去。提交时,你先检查一下公共文档的版本号是不是和你开始看的时候一样。
    • 如果一样,说明没人动过,你就可以安全地把你的修改覆盖上去,并更新版本号。
    • 如果不一样,说明在你修改期间,已经有人提交了新版本。你的修改就失败了,你需要重新读取新版本,再次修改,然后尝试提交。整个过程是“不加锁,先操作,提交时验证”。

一、 悲观锁 (Pessimistic Locking)

悲观锁在Java中的实现,主要依赖于Java提供的同步机制。

1. 实现方式

  • synchronized 关键字:这是Java中最基本的悲观锁实现。它可以修饰方法或代码块,确保同一时间只有一个线程能执行被它保护的代码。
  • java.util.concurrent.locks.Lock 接口:特别是它的实现类 ReentrantLock。它提供了比 synchronized 更高级、更灵活的锁定功能,例如可中断的锁、尝试获取锁、公平锁等。

这两种方式都是独占锁,即一个线程获取了锁,其他线程就必须等待。

2. 代码示例 (synchronized)

假设我们有一个简单的计数器,需要保证多线程下的线程安全。

java
public class PessimisticCounter {
    private int count = 0;

    // 使用 synchronized 关键字修饰方法,实现悲观锁
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

在这个例子中,任何线程想要调用 increment() 方法,都必须先获得 PessimisticCounter 实例的锁。如果锁已经被其他线程持有,那么当前线程就会被阻塞,直到锁被释放。

3. 优缺点

  • 优点
    • 简单易用synchronized 的使用非常直观。
    • 数据一致性强:在锁的保护下,数据的一致性可以得到严格的保证。
  • 缺点
    • 性能开销大:无论是否存在竞争,都会进行加锁、解锁操作。线程的阻塞和唤醒会涉及到用户态和内核态的切换,这是一个非常耗时的操作。
    • 可能导致死锁:如果多个线程互相等待对方持有的锁,就会产生死锁。
    • 吞吐量低:因为同一时间只允许一个线程操作,其他线程都在等待,无法并行处理。

二、 乐观锁 (Optimistic Locking)

乐观锁本身不是一种具体的锁,而是一种处理并发的思想。它通常通过 CAS (Compare-And-Swap) 机制来实现。

1. 核心机制:CAS (比较并交换)

CAS 是一种原子操作,它包含三个操作数:

  • V:要更新的内存地址(变量)
  • A:预期的旧值 (Expected Value)
  • B:要更新的新值 (New Value)

操作过程是:当且仅当内存地址 V 的当前值等于预期旧值 A 时,才会将 V 的值更新为新值 B。否则,什么也不做。整个比较和交换的过程是一个原子操作,不会被其他线程中断。

2. 实现方式

在Java中,乐观锁的实现主要依赖于 java.util.concurrent.atomic 包下的一系列原子类。

  • AtomicInteger, AtomicLong, AtomicBoolean 等:对基本数据类型进行原子操作。
  • AtomicReference:对引用类型进行原子操作。

这些类内部都使用了 Unsafe 类的 CAS 方法来实现原子更新。

3. 代码示例 (AtomicInteger)

我们用乐观锁来重写上面的计数器。

java
import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticCounter {
    // 使用 AtomicInteger 来存储 count
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        // 使用 CAS 实现无锁更新
        while (true) {
            int current = count.get(); // 1. 读取当前值 (A)
            int next = current + 1;    // 2. 计算新值 (B)
            
            // 3. 尝试用 CAS 更新
            // 如果 count 的当前值仍然是 current,就把它更新为 next,并返回 true
            // 如果在 1 和 3 之间,有其他线程修改了 count 的值,那么这里的 CAS 操作就会失败,返回 false
            if (count.compareAndSet(current, next)) {
                // 更新成功,退出循环
                break;
            }
            // 如果 CAS 失败,说明有竞争,循环会继续,进行下一次尝试(重试)
        }
    }

    public int getCount() {
        return count.get();
    }
}

这个 while(true) 循环是一种常见的 CAS 使用模式,通常被称为“自旋”。如果更新失败,它会不断地重试,直到成功为止。

4. 优缺点

  • 优点
    • 性能好,吞吐量高:在并发冲突不激烈的情况下,避免了线程阻塞和上下文切换的开销,性能远超悲观锁。
    • 无死锁问题:因为没有“锁定”资源,所以不会产生死锁。
  • 缺点
    • CPU消耗:如果并发冲突非常激烈,会导致大量线程反复重试(自旋),这会消耗大量 CPU 资源。
    • ABA 问题:这是乐观锁的一个经典问题。
      • 问题描述:一个变量的值原来是A,变成了B,然后又变回了A。另一个线程在做CAS操作时,发现它的值仍然是A,就认为它没有被修改过,于是操作成功。但实际上,这个值中间是发生过变化的。在某些场景下,这会导致严重的问题。
      • 解决方案:使用版本号机制。在变量前面追加上版本号,每次变量更新的时候把版本号加一。java.util.concurrent.atomic.AtomicStampedReference 就是用来解决ABA问题的,它将一个“版本戳(stamp)”和引用绑定在一起。
    • 只能保证单个共享变量的原子操作:对于多个共享变量的操作,CAS 无法直接保证原子性。需要使用 AtomicReference 将多个变量封装成一个对象来进行原子更新,或者使用更复杂的无锁数据结构。

总结与对比

特性 悲观锁 (Pessimistic Locking) 乐观锁 (Optimistic Locking)
核心思想 总是假设有冲突,先加锁再操作。 总是假设无冲突,先操作,提交时验证。
数据一致性 依靠独占锁来强制保证。 依靠 CAS 和版本号机制来验证保证。
Java实现 synchronized, java.util.concurrent.locks.Lock java.util.concurrent.atomic 包下的原子类
性能 线程阻塞和唤醒开销大,在冲突激烈时表现可能更好。 无阻塞,无上下文切换,在冲突不激烈时性能极高。
适用场景 写多读少,并发冲突非常激烈的场景。对数据一致性要求极高,且临界区代码执行时间短。 读多写少,并发冲突不激烈的场景。追求高吞吐量。
缺点 性能差,可能产生死锁。 冲突激烈时CPU空转严重,存在ABA问题,对复杂场景支持不佳。

如何选择?

  • 竞争激烈程度
    • 如果资源竞争非常激烈,线程等待的概率很高,使用悲观锁可能更合适。因为乐观锁的不断重试会消耗大量CPU,而悲观锁直接让线程休眠,CPU可以去做别的事情。
    • 如果资源竞争不激烈,或者大部分是读操作,使用乐观锁可以获得更高的吞吐量和性能。
  • 业务场景
    • 对于更新频率极高、且必须保证数据绝对正确(例如金融领域的账户余额),悲观锁是更稳妥的选择。
    • 对于一些允许失败和重试的场景(例如Web应用的库存扣减,失败后可以提示用户“操作失败,请重试”),乐观锁是很好的选择。

在现代的高并发应用中,乐观锁(特别是CAS)的应用越来越广泛,因为它在大多数情况下能提供更好的性能。Java并发包(JUC)中的许多高级工具,如 ConcurrentHashMap, ThreadPoolExecutor 等,其底层都大量使用了CAS技术来实现高效的无锁或低锁并发。

00:00
00:00