WHCSRL 技术网

【高频面试】锁与CAS详解(⭐建议收藏)

🔥【高频面试】锁🔒与CAS详解

❤️‍ 大家好,我是java厂长,今天带你们了解高频面试之Java锁🔒!❤️‍

关于作者

  • 作者介绍

🍓 博客主页:作者主页

🍓 简介:JAVA领域优质创作者🥇、一名在校大三学生🎓、在校期间参加各种省赛、国赛,斩获一系列荣誉🏆。

🍓 关注我:关注我学习资料、文档下载统统都有,每日定时更新文章,励志做一名JAVA资深程序猿👨‍💻。


此篇博文对Java学习理解底层很有帮助!

一. 悲观锁与乐观锁

​ 乐观锁和悲观锁问题,是出现频率比较高的面试题。本文将由浅入深,逐步介绍它们的基本概念、实现方式(含实例)、适用场景,以及可能遇到的面试官追问,希望能够帮助你打动面试官。

​ 乐观锁和悲观锁是两种思想,主要是解决并发场景下的数据争夺的问题。

  • 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
  • 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。

二、实现方式

​ 悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。

​ 乐观锁的实现方式有两种:CAS机制和版本号机制。

1)CAS(Compare And Swap)

​ CAS的原理很简单,包含三个值

  • 需要读写的内存位置(V)
  • 预期原来的值(A)
  • 期待更新的值(B)。

如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,返回true。否则处理器不做任何操作,返回false。

实现CAS最重要的一点,就是比较和交换操作的一致性,否则就会产生歧义。

CAS操作逻辑如下:如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。

这里引出一个新的问题,既然CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?答案是:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。

比如当前线程比较成功后,准备更新共享变量值的时候,这个共享变量值被其他线程更改了,那么CAS函数必须返回false。

要实现这个需求,java中提供了Unsafe类,它提供了三个函数,分别用来操作基本类型int和long,以及引用类型Object。

在这里插入图片描述

参数的意义:

var1和 var2:表示这个共享变量的内存地址。这个共享变量是var1对象的一个成员属性,var2表示这个共享变量在var1类中的内存偏移量。所以通过这两个参数就可以直接在内存中修改和读取共享变量值。

var4: 表示预期原来的值。

var5: 表示期待更新的值。

并发比较低的时候用CAS比较合适,并发比较高用synchronized比较合适。

接下来以Java中的自增操作( i++ )为例,看一下悲观锁和CAS分别是如何保证线程安全的。在Java中自增操作不是原子操作,它实际上包含三个独立的操作:第一步是读取i值;第二步是加1;第三步是将新值赋值给i

package com.zmz.lock;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @ProjectName: Juc
 * @Package: com.zmz.lock
 * @ClassName: LockTest
 * @Author: 张晟睿
 * @Date: 2021/10/17 14:50
 * @Version: 1.0
 */

public class LockTest {

    //线程不安全
    private static int num1 = 0;
    //使用乐观锁
    private static AtomicInteger num2 = new AtomicInteger(0);
    //使用悲观锁
    private static int num3 = 0;
    private static synchronized void addNum3(){
        num3++;
    }

    public static void main(String[] args) throws Exception {
        //开启2000个线程  自增
        for(int i = 0; i < 2000; i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    num1++;
                    num2.getAndIncrement();
                    addNum3();
                }
            }).start();
        }

        Thread.sleep(2000);//休眠2s
        System.out.println("1、线程不安全:" + num1);
        System.out.println("2、乐观锁(AtomicInteger):" + num2);
        System.out.println("3、悲观锁(synchronized):" + num3);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49

在这里插入图片描述

通过实验,我们发现并发执行自增操作,导致计算结果的不准确。在上面的代码测试中:num1没有进行任何线程安全方面的保护,num2使用了乐观锁(CAS),num3使用了悲观锁(synchronized)。运行程序,使用2000个线程同时对num1、num2和num3进行自增操作,可以发现:num2和num3的值总是等于2000,而num1的值常常小于2000。

首先来介绍AtomicInteger。AtomicInteger是java.util.concurrent.atomic包提供的原子类,利用CPU提供的CAS操作来保证原子性;这个包里面提供了一组原子变量类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。可以对基本数据、数组中的基本数据、对类中的基本数据进行操作。原子变量类相当于一种泛化的volatile变量,能够支持原子的和有条件的读-改-写操作。除了AtomicInteger外,还有AtomicBoolean、AtomicLong、AtomicReference等众多原子类。

下面看一下AtomicInteger的源码,了解下它的自增操作getAndIncrement()是如何实现的(JAVA8)

在这里插入图片描述

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

    //Unsafe用于实现对底层资源的访问
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    //valueOffset是value在内存中的偏移量
    private static final long valueOffset;
    
    /**
     * 通过Unsafe获得valueOffset
     * Unsafe类是用来在任意内存地址位置处读写数据,可见,对于普通用户来说,使用起来还是比较危险的。
     * public native long objectFieldOffset(Field var1);方法用于获取某个字段相对Java对象的“起始地址”的偏移量,方法返回值和参数如下
     * AtomicInteger.class.getDeclaredField("value")是拿到atomicInteger的value字段的field对象
     * valueoffset是拿到value的相对于AtomicInteger对象的地址偏移量
     * 
     */
    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    
    /**
     * 以原子方式将值设置为给定的更新值
     *
     * @param expect 预期值
     * @param update 新值
     * @return 成功返回true 返回false表明实际值不等于预期值,设置失败
     */
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    
    /**
     * 原子上增加一个当前值。
     *
     * @return 之前的值
     */ 
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
     /*
     getAndAddInt函数的方法体,传进来的var4是1,每调用一次增加1.compareAndSwapInt前面解释过了
     public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
     }

     */
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55

2)版本号机制

​ 版本号机制也可以用来实现乐观锁。版本号机制的主要思想是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改时,同时读取版本号version的值,若刚才读取到的version值为当前数据库中的version值相等时才更新,则版本号加1;否则重试更新操作,直到更新成功。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。

举一个简单的银行取钱的例子:

假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  • 操作员 A 此时读出版本号( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  • 接下来在操作员 A 操作的过程中,操作员B 也读入此余额及版本号( version=1 ),并从其帐户余额中扣除 $30 ( $100-$30 )。
  • 操作员 A 完成了修改工作,将数据版本号加一,此时版本号( version=2 )、帐户余额( balance=$50 ),提交至数据库更新,此时,提交数据版本 > 数据库记录当前版本,数据被更新,并且数据库记录 version 更新为 2 。
  • 操作员 B 完成了操作,也将版本号+1( version=2 )试图向数据库提交数据( balance=$70 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 当前最后更新的version与操作员第一次的版本号相等 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
  • 这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。

三、面试官问:乐观锁加锁吗?

在面试时,曾遇到面试官如此追问。下面是我对这个问题的理解:

(1)乐观锁本身是不加锁的,只是在更新数据的时候会判断一下数据是否被其他线程已经更新过了

(2)有时乐观锁可能与加锁操作两者同时使用

四、面试官问:CAS有哪些缺点?

面试到这里,我可能就要恭喜你大概率是面试通过了🥰🥰🥰🥰,面试官可能已经中意你了。不过面试官准备对你发起最后的进攻:你知道CAS这种实现方式有什么缺点吗?

1)一次性只能保证一个共享变量的原子性

​ 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。

2)循环会耗时

​ 我们可以看到getAndAddInt方法执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

​ 在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。

3)存在ABA问题(重点)

​ 先简单解释一下什么是ABA

​ 假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:

​ (1)线程1读取内存中数据为A;

​ (2)线程2将该数据修改为B;

​ (3)线程2将该数据修改为A;

​ (4)线程1对数据进行CAS操作

​ 在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。

​ 在AtomicInteger的例子中,ABA似乎没有什么危害。但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。

​ 对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。所以JAVA中提供了AtomicStampedReference/AtomicMarkableReference来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。

问题:如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?

​ 如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”(原子标记参考 ),它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚"ABA"问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

五、适用场景

​ 乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景;下面从两个方面进行说明。

1)功能限制

​ 与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。

​ 例如,CAS只能保证一个共享变量的原子操作,当涉及到多个变量时,CAS是无能为力的,而 synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。

2)竞争激烈程度

​ 如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:

​ 1️⃣当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。

​ 2️⃣当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。


希望对各位的面试有帮助,也希望小伙伴们多多支持厂长,留下你们的爱心💕和赞👍!
💗最后厂长祝大家能够拿到心仪的Offer💗
推荐阅读