1. 程式人生 > 實用技巧 >通過實現網站訪問計數器帶你理解 輕量級鎖CAS原理,還學不會算我輸!!!

通過實現網站訪問計數器帶你理解 輕量級鎖CAS原理,還學不會算我輸!!!

一、實現網站訪問計數器

1、執行緒不安全的做法

1.1、程式碼

package com.chentongwei.concurrency;

import static java.lang.Thread.sleep;

/**
* @Description:
* @Project concurrency
*/
public class TestCount { private static int count; public void incrCount() {
count ++;
} public static void main(String[] args) throws InterruptedException {
TestCount testCount = new TestCount();
// 開啟五個執行緒
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 每個執行緒都讓count自增100
for (int j = 0; j < 100; j++) {
testCount.incrCount();
}
}).start();
}
sleep(2000);
// 正確的情況下會輸出500
System.out.println(count);
}
}

1.2、結果

並不一定是500,極大可能小於500。不固定。

1.3、分析

很明顯上面那段程式是執行緒不安全的,為什麼執行緒不安全?因為++操作其實是類似如下的兩步驟,如下:

count ++;
||
// 獲取count
int temp = count;
// 自增count
count = temp + 1;

很明顯是先獲取在自增,那麼問題來了,我執行緒A和執行緒B都讀取到了int temp = count;這一步,然後都進行了自增操作,其實這時候就錯了因為這時候count丟了1,併發了。所以導致了執行緒不安全,結果小於等於500。

2、Synchronized保證執行緒安全

2.1、程式碼

package com.chentongwei.concurrency;

import static java.lang.Thread.sleep;

/**
* @Description:
* @Project concurrency
*/
public class TestCount { private static int count; public void incrCount() {
count ++;
} public static void main(String[] args) throws InterruptedException {
TestCount testCount = new TestCount();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
synchronized (TestCount.class) {
testCount.incrCount();
}
}
}).start();
}
sleep(2000);
System.out.println(count);
}
}

2.2、結果

500

2.3、分析

沒什麼可分析的,我用了Java的內建鎖Synchronized來保證了執行緒安全性。加了同步鎖之後,count自增的操作變成了原子性操作,所以最終輸出一定是500。眾所周知效能不好,所以繼續往下看替代方案。

3、原子類保證執行緒安全

3.1、程式碼

package com.chentongwei.concurrency;

import java.util.concurrent.atomic.AtomicInteger;

import static java.lang.Thread.sleep;

/**
* @Description:
* @Project concurrency
*/
public class TestCount { // 原子類
private static AtomicInteger count = new AtomicInteger(); public void incrCount() {
count.getAndIncrement();
} public static void main(String[] args) throws InterruptedException {
TestCount testCount = new TestCount();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
testCount.incrCount();
}
}).start();
}
sleep(2000);
System.out.println(count);
}
}

3.2、結果

500

3.3、分析

所謂原子操作類,指的是java.util.concurrent.atomic包下,一系列以Atomic開頭的包裝類。如AtomicBoolean,AtomicUInteger,AtomicLong。它們分別用於Boolean,Integer,Long型別的原子性操作。每個原子類內部都採取了CAS演演算法來保證的執行緒安全性。

二、什麼是CAS演演算法

1、概念

CAS的英文單詞Compare and Swap的縮寫,翻譯過來就是比較並替換。

2、原理

CAS機制中使用了3個基本運算元:記憶體地址V,舊的預期值A,要修改的新值B。當且僅當預期值A和記憶體值V相同時,才將記憶體值修改為B,否則什麼都不做,最後返回現在的V值。

簡單理解為這句話:我認為V的值應該是A,如果是A的話我就把他改成B,如果不是A的話(那就證明被別人修改過了),那我就不修改了,避免多人 同時修改導致資料出錯。換句話說:要想修改成功,必須保證A和V中的值是一樣的,修改前有個對比的過程。

比如:更新一個變數,只有當變數的預期值(A)和記憶體地址(V)的實際值相同時,才會將記憶體地址(V)對應的值修改為B。

我們看如下的原理圖:

1、在記憶體地址V當中,儲存著值為10的變數。

2、此時執行緒1想把變數的值增加1,對於執行緒1來說,舊的預期值A=10,要修改的新值B=11。

3、線上程1要提交更新之前,另一個執行緒2搶先一步,把記憶體地址V中的變數率先更新成了11。

4、執行緒1開始提交更新,首先進行A和地址V的實際值對比,發現A!=V,提交失敗。

5、執行緒1重新獲取記憶體地址V的當前值,並重新計算想要修改的值。此時對執行緒1來說:A=11,B=12.這個重新嘗試的過程稱為自旋

6、這一次比較幸運,沒有其他執行緒改變地址V的值。執行緒1進行比較,發現A和地址V的實際值是相等的。

7、執行緒1進行交換,把地址V的值替換為B,也就是12.

3、對比Synchronized

從思想上來講,Synchronized屬於悲觀鎖,悲觀的認為程式中的併發情況嚴重,所以嚴防死守,高併發情況下效率低下。而CAS屬於樂觀鎖,樂觀的認為程式中的併發情況不那麼嚴重,所以讓執行緒不斷去重試更新。但實際上Synchronized已經改造了,帶有鎖升級的功能。效率不亞於cas。

4、CAS缺點

(1)CPU開銷可能過大

在併發比較大的時候,若多執行緒反覆嘗試更新某個變數,卻又一直更新不成功,迴圈往復,會給CPU帶來很大的壓力。(因為是個死迴圈,下面分析底層實現就懂了。)

(2)不能保證程式碼塊的原子性

CAS機制所保證的只是一個變數的原子操作,而不能保證整個程式碼塊的原子性。比如需要保證三個變數共同進行原子性的更新,就不得不使用Synchronized或Lock等機制了。

(3)ABA問題。

下面會單獨抽出一塊地來詳細講解。這是CAS最大的漏洞。

三、CAS底層實現(Java)

1、概述

要說Java中CAS的案例,那麼最屬java.util.concurrent.atomic包下的原子類有發言權了。最經典、最簡單。

2、講解

比如我們這裡隨便找個AtomicInteger來講解CAS演演算法底層實現。

public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
} private volatile int value; public final int get() {
return value;
}
  1. 獲取當前值

  2. 當前值+1,計算出目標值

  3. 進行CAS操作,如果成功則跳出迴圈,如果失敗則重複上述步驟

如何保證獲取的當前值是記憶體中的最新值?很簡單,用volatile關鍵字來保證(保證執行緒間的可見性)。

compareAndSet方法的實現很簡單,只有一行程式碼。這裡涉及到兩個重要的物件,一個是unsafe,一個是valueOffset。

什麼是unsafe呢?

3、Unsafe

Unsafe是CAS的核心類,Java語言不像C,C++那樣可以直接訪問底層作業系統,Java無法直接訪問底層作業系統,但是JVM為我們提供了一個後門,這個後門就是unsafe。unsafe為我們提供了硬體級別的原子操作

而valueOffset是通過unsafe.objectFiledOffset方法得到,所代表的是AtomicInteger物件value成員變數在記憶體中的偏移量。我們可以簡單的把valueOffset理解為value變數的記憶體地址。

我們上面說過,CAS機制中使用了3個基本運算元:記憶體地址V,舊的預期值A,要修改的新值B。

而unsafe的compareAndSwapInt方法的引數包括了這三個基本元素:valueOffset引數代表了V,expect引數代表了A,update引數代表了B。

正是unsafe的compareAndSwapInt方法保證了Compare和Swap操作之間的原子性操作。

四、ABA問題

1、演示

執行緒1準備用CAS將變數的值由A替換為B,在此之前,執行緒2將變數的值由A替換為C,又由C替換為A。然後執行緒1執行CAS時發現變數的值仍是A,所以CAS成功,這麼看沒毛病,但是如果操作的是個連結串列呢?那就炸了,因為雖然值一樣,但是連結串列的位置不一樣了。

例如:

(1)現有一個用單向連結串列實現的堆疊,棧頂為A,這時執行緒T1已經知道A.next為B,然後希望用CAS將棧頂替換為B:

head.compareAndSet(A,B);

(2)在T1執行上面這條指令(CAS)之前,執行緒T2介入,將A、B出棧,在push三個D、C、A,如下:

(3)此時輪到執行緒T1執行CAS操作,檢測發現棧頂仍為A,所以CAS成功,棧頂變為B,但實際上B.next為null,因為B已經再上一步被移除了,成為了遊離態。所以此時的情況變為

導致了其中堆疊中只有B一個元素,C和D組成的連結串列不再存在於堆疊中,平白無故就把C、D丟掉了。

以上就是由於ABA問題帶來的隱患,各種樂觀鎖的實現中通常都會用版本戳version來對記錄或物件標記,避免併發操作帶來的問題,在Java中,AtomicStampedReference<E>也實現了這個作用,它通過包裝[E,Integer]的元組來對物件標記版本戳stamp,從而避免ABA問題。

2、生活案例

你和你前任分手後她又回來了,但是你在這期間又和其他女人...,你表面還是你,但是本質的你已經變了。把這個例子帶到程式碼裡來就是:

你有個class,裡面有個LinkedList屬性,這個連結串列裡有你和你前任,你先把它踹了,然後小蒼進來跟你...,這時候你前任就回來了,但是這期間連結串列已經發生了無感知的變化。`