首頁 >Java >Java面試題 >面試真題:請你聊聊並發中的CAS 機制

面試真題:請你聊聊並發中的CAS 機制

Java学习指南
Java学习指南轉載
2023-07-26 15:05:54945瀏覽

不知道同學們有沒有經歷過這樣的面試:

面試官:請你聊聊並發中的CAS機制吧
#小明:嗯,CAS是吧,好像聽過...我想想哈(大腦飛快思考)

 2分鐘過去了...
 空氣是死一般的沈靜.. .

面試官坐不住了,清了清喉嚨:咳... 那個,能簡單說說嗎?
小明憨憨一笑:嘿嘿,我好像忘記了...
面試官:哦,沒關係,今天的面試就到這吧,你回去等通知吧
小明垂頭喪氣地離開了...


別笑,小明其實是很多人的影子,在面試過程中民聊的同學不在少數,當然我也包括在內,其實這反映出一個很殘酷的現實:基礎不紮實!

那麼問題來了,如何在面試中吊打麵試官,穩如磐石?

學呀!光說有啥用,你得學啊,買的書你得看啊,買的課你得跟著練啊,別光打遊戲追劇了,想要變強,唯有禿頭!

現在是北京時間0:08,我在碼字寫文章,你呢?

面試真題:請你聊聊並發中的CAS 機制

一個小例子說說什麼是執行緒安全性

##並發是Java程式設計的基礎,在我們日常的工作中,很多時候都會跟並發打交道,當然,這也是面試考察的重點。在同時程式設計中,被提起最多的概念是

線程安全,下面我們先來看一段程式碼,看看運行後會發生什麼:

public class Test {
    private static int inc = 0;

    public static void main(String[] args) {
     // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
        CountDownLatch countDownLatch = new CountDownLatch(1000000);
        // 设置100个线程同时执行
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
             // 循环10000次,对inc实现 +1 操作
                for (int j = 0; j < 10000; j++) {
                    inc++;
                    countDownLatch.countDown();
                }
            }).start();
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 运行完毕,期望获取的结果是 1000000
        System.out.println("执行完毕,inc的值为:" + inc);
    }
}

程式中,我創建了100個線程,每個線程中對共享變數inc進行累加10000次的操作,如果是同步執行的話,inc最終的值應該是1000000,但我們知道在多執行緒中,程式是並發執行的,也就是說不同的執行緒可能會同時讀取到主記憶體相同的值,例如這樣的場景:

  • 執行緒A在某一個瞬間讀取了主記憶體的inc值為1000,它在自己的工作記憶體1,inc變成了1001;
  • 線程B在同樣的瞬間讀取到了主記憶體的inc值為1000,它也在自己的工作記憶體中對inc的值1, inc變成了1001;
  • 他們要往主記憶體寫入inc的值的時候並沒有做任何的同步控制,所以他們都有可能把自己工作內存的1001寫入到主內存;
  • 那麼很顯然主內存在進行兩次1 操作後,實際的結果只進行了一次1,變成了1001。

這就是一個很典型的多執行緒並發修改共享變數所帶來的問題,那麼很顯然,它的運行結果也如我們分析的那樣,某些情況下達不到1000000:

执行完毕,inc的值为:962370

有些人說透過volatile關鍵字可以解決這個問題,因為volatile可以保證執行緒之間的可見性,也就是說執行緒可以讀取到主記憶體最新的變數值,然後對其進行操作。

注意了,volatile只能保證線程的可見性,而不能保證線程操作的原子性,雖然線程讀取到了主記憶體的inc的最新值,但是讀取inc 1寫入主記憶體 是三步驟操作,所以volatile無法解決共享變數執行緒安全的問題。

那麼要如何解決這個問題? Java為我們提供下面幾種解決方案。

几种保证线程安全的方案

1. 通过synchronized关键字实现同步:

public class Test {
    private static int inc = 0;

    public static void main(String[] args) {
        // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
        CountDownLatch countDownLatch = new CountDownLatch(1000000);
        // 设置100个线程同时执行
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 循环10000次,对inc实现 +1 操作
                for (int j = 0; j < 10000; j++) {
                 // 设置同步机制,让inc按照顺序执行
                    synchronized (Test.class) {
                        inc++;
                    }

                    countDownLatch.countDown();
                }
            }).start();
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行完毕,inc的值为:" + inc);
    }
}

在上面的代码中,我们给 inc ++ 外面加了一层代码,使用 synchronized 设置类锁,保证了代码的同步执行,这是一种基于JVM自身的机制来保障线程的安全性,如果在并发量比较大的情况下,synchronized 会升级为重量级的锁,效率很低。synchronized无法获取当前线程的锁状态,发生异常的情况下会自动解锁,但是如果线程发生阻塞,它是不会释放锁的

执行结果:

执行完毕,inc的值为:1000000

可以看到,这种方式是可以保证线程安全的。

2. 通过Lock锁实现同步

public class Test {
    private static int inc = 0;
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
        CountDownLatch countDownLatch = new CountDownLatch(1000000);

        // 设置100个线程同时执行
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 循环10000次,对inc实现 +1 操作
                for (int j = 0; j < 10000; j++) {
                 // 设置锁
                    lock.lock();
                    try {
                        inc++;
                    } finally {
                     // 解锁
                        lock.unlock();
                    }
                    countDownLatch.countDown();
                }
            }).start();
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行完毕,inc的值为:" + inc);
    }
}

ReentrantLock的底层是通过AQS + CAS来实现的,在并发量比较小的情况下,它的性能不如 synchronized,但是随着并发量的增大,它的性能会越来越好,达到一定量级会完全碾压synchronized。并且Lock是可以尝试获取锁的,它通过代码手动去控制解锁,这点需要格外注意。

执行结果:

执行完毕,inc的值为:1000000

3. 使用 Atomic 原子类

public class Test {
    private static AtomicInteger inc = new AtomicInteger();

    public static void main(String[] args) {
        // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
        CountDownLatch countDownLatch = new CountDownLatch(1000000);

        // 设置100个线程同时执行
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 循环10000次,对inc实现 +1 操作
                for (int j = 0; j < 10000; j++) {
                    inc.getAndAdd(1);
                    countDownLatch.countDown();
                }
            }).start();
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行完毕,inc的值为:" + inc.get());
    }
}

AtomicInteger 底层是基于 CAS 的乐观锁实现的,CAS是一种无锁技术,相对于前面的方案,它的效率更高一些,在下面会详细介绍。

执行结果:

执行完毕,inc的值为:1000000

4. 使用 LongAdder 原子类

public class Test {
    private static LongAdder inc = new LongAdder();

    public static void main(String[] args) {
        // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值
        CountDownLatch countDownLatch = new CountDownLatch(1000000);

        // 设置100个线程同时执行
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                // 循环10000次,对inc实现 +1 操作
                for (int j = 0; j < 10000; j++) {
                    inc.increment();
                    countDownLatch.countDown();
                }
            }).start();
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行完毕,inc的值为:" + inc.intValue());
    }
}

LongAdder 原子类在 JDK1.8 中新增的类,其底层也是基于 CAS 机制实现的。适合于高并发场景下,特别是写大于读的场景,相较于 AtomicInteger、AtomicLong 性能更好,代价是消耗更多的空间,以空间换时间。

执行结果:

执行完毕,inc的值为:1000000

CAS理论

讲到现在,终于我们今天的主角要登场了,她就是CAS

CAS的意思是比较与交换(Compare And Swap),它是乐观锁的一种实现机制。

什么是乐观锁?通俗的来说就是它比较乐观,每次在修改变量的值之前不认为别的线程会修改变量,每次都会尝试去获得锁,如果获取失败了,它也会一直等待,直到获取锁为止。说白了,它就是打不死的小强。

而悲观锁呢,顾名思义,就比较悲观了,每次在修改变量前都会认为别人会动这个变量,所以它会把变量锁起来,独占,直到自己修改完毕才会释放锁。说白了,就是比较自私,把好东西藏起来自己偷偷享用,完事了再拿出来给别人。像之前的synchronized关键字就是悲观锁的一种实现。

CAS是一种无锁原子算法,它的操作包括三个操作数:需要读写的内存位置(V)、预期原值(A)、新值(B)。仅当 V值等于A值时,才会将V的值设为B,如果V值和A值不同,则说明已经有其他线程做了更新,则当前线程继续循环等待。最后,CAS 返回当前V的真实值。CAS 操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。

CAS的实现

在Java中,JUC的atomic包下提供了大量基于CAS实现的原子类:

面試真題:請你聊聊並發中的CAS 機制

我们以AtomicInteger来举例说明。

AtomicInteger类内部通过一个Unsafe类型的静态不可变的变量unsafe来引用Unsafe的实例。

 // setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();

然后,AtomicInteger类用value保存自身的数值,并用get()方法对外提供。注意,它的value是使用volatile修饰的,保证了线程的可见性。

private volatile int value;

/**
 * Creates a new AtomicInteger with the given initial value.
 *
 * @param initialValue the initial value
 */
public AtomicInteger(int initialValue) {
    value = initialValue;
}

/**
 * Gets the current value.
 *
 * @return the current value
 */
public final int get() {
    return value;
}

一路跟踪incrementAndGet方法到的末尾可以看到是一个native的方法:

/**
 * Atomically increments by one the current value.
 *
 * @return the updated value
 */
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

//  getAndAddInt 方法
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;
}

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

可以看到其实incrementAndGet内部的原理就是通过compareAndSwapInt调用底层的机器指令不断比较内存旧值和期望的值,如果比较返回false就继续循环比较,如果返回true则将当前的新值赋给内存里的值,本次处理完毕。

由此我们知道,原子类实现的自增操作可以保证原子性的根本原因在于硬件(处理器)的相关指令支持。将语义上需要多步操作的行为通过一条指令来完成,CAS指令可以达到这个目的。

CAS的缺點

  • 作為樂觀鎖定的一種實現,當多執行緒競爭資源激烈的情況下,多個執行緒會發生自旋等待,會消耗一定的CPU資源。
  • CAS不可避免會出現ABA的問題,關於ABA問題的詮釋和解決方案,可以參考我的這篇文章:面試官問你:你知道什麼是ABA問題嗎?


好了,本期關於CAS的分享就到這裡結束了。 並發作為Java程式設計的基石,是一個非常重要的知識點,如果同學們對這塊的掌握比較薄弱,希望在讀完文章後能自己動手敲敲程式碼,思考一下什麼是CAS,有哪些優缺點,實作方式有哪些。 當然,並發是一個非常大的概念,這裡只是拋磚引玉,提到了其中的一個小知識點,給出了我自己學習的一點心得體會。 如果有闡述不到位或錯誤的地方,請私訊我一起討論,感謝!

我是程式設計師青戈,本期的面試問題分享到這裡就結束了,想提升自己,進階大工廠的同學一定要關注我的公眾號:Java學習指南,這裡每天會從實際的面試出發帶你學習和總結Java相關的知識,幫助你擴充技術棧,提昇個人實力。我們下期見~

以上是面試真題:請你聊聊並發中的CAS 機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:Java学习指南。如有侵權,請聯絡admin@php.cn刪除