Java中的悲观锁与乐观锁

概念

对数据的操作一般是这样一个过程:

  1. 从内存读数据
  2. 对数据进行修改
  3. 将修改后的数据写回内存

比如我们对账户余额的修改就可以用下面的伪代码来描述:

balance = read();
balance = balance + 100;
write(balance);

众所周知,多线程环境下对共享内存的读写会引发线程安全问题,Java中可以通过加锁的方式避免线程安全问题,按照加锁思想的不同,锁可以分为悲观锁和乐观锁

悲观锁

悲观锁总是假设最坏的情况,每次取数据的时候都认为其他线程会修改数据,所以会在每次取数据之前进行加锁。Java中的synchronized关键字和基于Lock接口的实现类其实都是悲观锁。

乐观锁

乐观锁总是假设最好的情况,每次取数据的时候都认为其他线程不会修改数据,所以在取数据之前不进行加锁,而是在回写数据的时候检查数据是否被修改过,如果没有被其他线程修改过,就进行回写,否则重新取最新的数据进行业务处理后再进行回写,直到成功(自旋锁)。Java中的乐观锁是通过CAS实现的。

CAS

CAS是Compare And Swap的缩写,“比较并交换”的意思。
CAS操作涉及到三个操作数

  • V表示内存地址
  • E表示期望的值
  • N表示要修改成的新值

算法的核心思想如下:

  1. 拿期望值E与V的值进行比较
  2. 如果相等,则将V的值更新为N,否则什么都不做
  3. 返回操作是否成功

Compare And Swap是一个不可分割的原子操作,是由CPU的底层指令实现的

CAS在Java中的应用

上面讲的可能有些抽象,下面就来看看CAS在Java中的实际应用。我们常用的java.util.current.atomic包下的原子类其实就是通过CAS来实现的,下面就以AtomicInteger的源码为例,来说明CAS是如何在不加锁的情况下保证线程安全的

初始化

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // 通过Unsafe提供的系统原语来实现Compare And Swap操作
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    // value在内存中的地址
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    // 通过volatile保证内存可见性
    private volatile int value;

get操作

public final int get() {
    return value;
}

自增操作

public final int getAndIncrement() {
    // 直接调用了unsafe的getAndAddInt
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

我们跟进去看一下

/**
  * @param var1 object
  * @param var2 value的内存地址
  * @param var4 要增加的值
  * @return 旧值
  */
  public final int getAndAddInt(Object var1, long var2, int var4) {
      int var5;
      do {
          // 获取旧值
          var5 = this.getIntVolatile(var1, var2);
         // 拿要更新的值(var5 + var4)和旧值(var5)比较,如果相等则将新值替换旧值并返回true,否则什么都不做,返回false
         // 返回false的时候,while循环会继续取最新的值并进行CAS操作直到成功为止
      } while (!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

      return var5;
  }

getIntVolatilecompareAndSwapInt是native方法,通过C/C++实现

public native int getIntVolatile(Object var1, long var2);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

举例说明

假设number的初始值为1,两个线程同时对number进行自增操作
如果没有使用CAS:
thread1读取number的值1
thread2读取number的值1
thread1进行自增后将结果(2)写回number,此时number的值为2
thread2进行自增后将结果(2)写回number,此时number的值为2
number进行两次增长后,结果应该为3才对,但这里结果为2,显然产生了线程安全问题

下面再来看看使用了CAS的情况:
thread1读取number的值1(var5 = this.getIntVolatile(var1, var2)
thread2读取number的值1(var5 = this.getIntVolatile(var1, var2)
thread1进行自增后将结果(2)写回number,此时number的值为2
thread2进行自增后将结果(2)写回number,但这里进行compareAndSwapInt操作的时候会失败。因为number的值已经被thread1修改为2了,它和期望的值(var5此时为1)不相等,所以返回false,进行下一次while循环。重新读取number的值(2),进行自增后,将结果(3)通过compareAndSwapInt写回number(此时var5值为2,和number相等,所以成功),最终number的值为3,结果正确

CAS缺点

CAS有几个缺点:

  • ABA问题。比如线程1取出内存地址V的值A,在将新值写回V之前,线程2连续两次修改V的值:A -> B -> A,线程1进行CAS比较的时候,发现内存V地址的值仍为A,CAS操作成功。但这并不代表这个过程是没有问题的,对于原子类型的变量确实不会产生问题,但对于引用类型的变量,比如对链表头的修改就可能产生问题
    ABA问题本质上是由于没有对修改操作做记录产生的,解决方案一般是添加版本号,每次修改都更新版本号,比如A(V1.1) -> B(V1.2) -> A(V1.3),最终V的值虽然还是A,但版本号变了,这样就可以区分变量是否被其他线程修改过。Java中的AtomicStampedReference/AtomicMarkableReference其实就是通过这种思想实现的
  • 循环开销。并发量很大的情况下,CAS操作可能会频繁失败,造成线程的循环等待浪费CPU资源
  • 只能对单个共享变量进行原子操作。JDK1.5之后,新增AtomicReference类来处理这种情况,可以将多个变量打包放到一个对象中

使用场景

对于悲观锁,锁一旦被某个线程占用,其他线程就只能阻塞,直到锁被释放才唤醒。线程的阻塞-唤醒涉及到用户态和内核态的上下文切换,上下文切换是一个比较大的开销。
而乐观锁实际上是一种无锁的编程思想,在资源冲突的情况下并不会进入阻塞状态,而是进行循环等待,即所谓的忙等,而忙等是一种耗CPU的操作。所以悲观锁和乐观锁各有优缺点及其适应的场景。

  • 对于资源竞争较小的情况,使用悲观锁会造成较大的线程间上下文切换开销,使用乐观锁可以获得较好的性能
  • 对于资源竞争严重的情况,乐观锁的CAS操作很容易失败,造成不断的循环等待浪费CPU资源,这种情况下宜使用悲观锁
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值