从面试官的角度深入理解悲观锁与乐观锁

简历就是秒杀项目,高并发高可用,面试一问悲观锁和乐观锁是啥,你绞尽脑汁想了半天就蹦出几个词…

相信很多程序员都有过这样的经历:简历上写着”精通Java”、”精通高并发”、”精通JUC”,但面试时被问到悲观锁和乐观锁的具体实现时,却支支吾吾说不出个所以然。今天我们就来深入剖析这两个重要概念。

什么是悲观锁?

悲观锁的核心思想很简单:悲观地认为如果不严格加锁,那么多线程执行一定会出问题

因此,悲观锁的策略是:

  • 每次访问共享资源都必须加锁
  • 线程必须获得锁之后才能正常执行
  • 如果获取不到锁,线程就阻塞等待

悲观锁确实好使,但它也确实慢。为什么慢呢?

  1. 大量读操作场景:如果程序中存在大量的读操作,实际上没有必要每次读的时候都加锁
  2. 同步代码块执行时间短:如果同步代码块执行的时间非常短,远小于线程阻塞切换的时间,那加锁就得不偿失了

乐观锁:不加锁的解决方案

那有没有一种方法可以不加锁就对多线程访问共享资源做出限制呢?有!这就是乐观锁

CAS(Compare And Swap)就是乐观锁的一个经典实现

CAS的工作原理

让我用一个生动的例子来解释CAS的工作原理:

想象女神的门口有个牌子:

  • 牌子是0:代表空闲
  • 牌子是1:代表女神比较繁忙

当A、B两个线程看到门上的牌子是0时:

  1. A线程快速跑过去,把牌子上的0改成了1,然后进入女神房间
  2. B线程走到门口一看,牌子是1不是0,所以B进不去了

对应到代码里:

  • 女神 = 共享资源
  • 牌子上的值 = 状态值
  • 0 = oldValue(期望值)
  • 1 = newValue(新值)

CAS的三个步骤

Compare And Swap

  1. Compare(对比):对比牌子上的值和自己第一次看到的oldValue是否一致
  2. Swap(交换):如果一致,就将牌子上的值和自己的newValue交换

当线程B来对比时,发现它的oldValue是0,但牌子上已经是1了,不一致!这说明已经有线程在访问共享资源了。

线程B进不去怎么办?可以自旋等待,不断尝试用CAS进行修改,直到线程B耐心耗尽为止。

乐观锁的本质

乐观锁的本质其实是不加锁。当线程想要访问共享资源时,总是乐观地认为没有人和它竞争,所以不会加锁,只是比较状态值是否等于预期值:

  • 相等:说明真的没有被其他线程修改,然后修改状态值并访问共享资源
  • 不相等:说明被其他线程修改了,需要重试

CAS的原子性保证

等等,这里有个问题:Compare And Swap是先比较再交换,这是两步操作,怎么保证原子性?

有人可能会说:”保证原子性你加锁就行了”。但这不是扯淡吗?CAS就是为了在不加锁的情况下保证并发安全,为了不加锁得先加把锁?

硬件层面的支持

在Java中,直接使用CAS需要调用Unsafe类的方法:

  • compareAndSwapInt()
  • compareAndSwapLong()
  • compareAndSwapObject()

这些方法都是由native修饰的本地方法,也就是说是C++实现的。

但即便用C++代码写,比较交换也是两步操作,C++也没有办法保证原子性。怎么办?

这里的C++本地方法依赖了CPU的原子指令。比如在X86架构下,指令是compareAndExchange,这个指令是由硬件直接支持、确保原子性的。

硬件是怎么做的呢?其实是:

  • 通过锁定总线避免其他CPU访问共享资源
  • 或者锁定缓存行来实现

CAS真的无锁吗?

万万没想到,一个简单的CAS操作软件居然是无法实现的,最终还是要依赖硬件来实现。

那CAS真的无锁吗?

从硬件层面看

锁定总线或者锁定缓存行的操作其实就是硬件锁。想想之前悲观锁的概念,是不是硬件层面悲观地认为不锁定总线就会出现线程安全问题,所以做了锁定总线的操作?

所以从硬件上来看,CAS其实还是加锁的悲观锁

从软件层面看

但是硬件的操作性能很高,代价很低,而且不阻塞线程。从软件层面来看,它没有用操作系统的mutex,避免了软件层面的锁竞争和线程阻塞问题。

所以从软件层面上来说,它就是无锁的

Atomic类的实现原理

当你了解了CAS,你就自然而然地了解了AtomicInteger是怎么实现的:

java

1
2
3
4
5
6
7
8
9
// AtomicInteger的实现原理
public class AtomicInteger {
private volatile int value; // volatile保证可见性

public final int getAndIncrement() {
// 使用CAS保证原子性
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
  • volatile保证可见性
  • CAS保证原子性

AtomicLong的实现你也知道了,无非就是换了个long类型的变量。

AtomicReference的实现

那么问题来了:AtomicReference能保证原子地修改一个对象,它是怎么实现的?

答案是:AtomicReference比较交换比较的是对象的地址

数据库层面的乐观锁

除了Java代码层面的乐观锁,数据库操作层面也可以采用CAS的思想实现乐观锁。

库存场景示例

sql

1
UPDATE 某张表 SET 库存 = 新值 WHERE 库存 = 旧值

当库存等于之前的旧值时才能修改为新值,这就是CAS。

版本号机制

更常用的是给表里增加一个版本号字段:

sql

1
UPDATE 某张表 SET 库存 = 新值, 版本号 = 版本号 + 1 WHERE 版本号 = 旧版本号

怎么保证原子性? 单条SQL的原子性是由数据库直接保证的,所以你不需要考虑。

数据库层面真的无锁吗?

从数据库层面来看,执行UPDATE语句需要加行锁或者表锁。以加行锁为例,为什么加行锁?因为数据库悲观地认为不加行锁就会出现并发安全问题。

所以:

  • 从数据库底层来看:还是加锁了(悲观锁)
  • 从上层的SQL来看:没加锁

多层级视角的理解

CAS到底是乐观还是悲观?加锁还是无锁?从不同的角度、不同的层级来看就是不一样的

  • 对我们写业务代码这种偏上层的开发来说:CAS就是无锁的
  • 从底层的视角来看:就未必是无锁了

你也不用纠结CAS是否真的无锁,你只需要知道它性能很好就可以了。

CAS的经典问题

ABA问题

CAS会产生一个经典问题:ABA问题

当线程1读取一个值为A,想修改为C,但线程2拿到时间片后把值从A修改为B,然后又修改为A。当线程1拿到时间片执行CAS操作时,发现还是A,以为没有被修改过,其实已经被修改过了。

库存场景的ABA

第一个线程读取库存为100,然后第二个线程扣减了库存,然后又退货了给库存再加回100。第一个线程拿到时间片后发现库存还是100,继续操作。

这种情况下ABA问题其实不需要解决

栈操作的ABA

线程1读取栈顶元素为A,然后线程2做了两次出栈操作。线程1的CAS对比发现栈顶还是A,这个时候线程1将A修改为B,修改的那个A还是曾经读取的那个A吗?

并不是!这里就很容易出问题,因为这是两个不同的元素

ABA问题的解决方案

CAS只进行值的比较,值相同但未必就是同一个数据。怎么解决?

一般会使用版本号,版本号一直递增,把值对比改为版本号的对比就可以了。这也是AtomicStampedReference的原理。

自旋性能问题

CAS操作往往是配合自旋一块用的。当线程CAS操作失败了就不断地自旋重试,如果长时间不成功就很浪费CPU。

这个问题怎么解决呢?

synchronized的轻量级锁就是CAS加自旋,自旋的时间长了或者线程竞争大了就会浪费CPU,然后升级为重量级锁。

synchronized的重量级锁通过操作系统的mutex,加上锁监视器的等待队列、对象池等,实现了一套线程阻塞唤醒的机制来避免长时间自旋导致的CPU性能问题。

总结

悲观锁和乐观锁的核心区别在于对并发冲突的态度:

悲观锁

  • 悲观地认为一定会发生冲突
  • 每次访问都加锁
  • 性能开销大,但安全性高

乐观锁(CAS)

  • 乐观地认为不会发生冲突
  • 不加锁,只在更新时检查
  • 性能好,但有ABA问题和自旋开销

无论是悲观锁还是乐观锁,在不同的层级都可能有不同的实现方式。关键是要根据具体的业务场景选择合适的并发控制策略。