并發基礎之原子操作與原子變量

在說明原子操作之前,我們先看下面這段Java代碼:

public class AtomicTest {

? ? ? ? private static final int COUNT_TIMES = 10000 * 10000;

? ? ? ? private static volatile int counter = 0;

? ? ? ? public static void main(String[] args) {

? ? ? ? ? ? ? ? Runnable r = () -> {

? ? ? ? ? ? ? ? ? ? ? ? for (int i = 0; i < COUNT_TIMES; i++) {

? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? counter++;

? ? ? ? ? ? ? ? ? ? ? ? }

? ? ? ? ? ? ? ? };

? ? ? ? ? ? ? ? Thread t1 = new Thread(r);

? ? ? ? ? ? ? ? Thread t2 = new Thread(r);

? ? ? ? ? ? ? ? t1.start();

? ? ? ? ? ? ? ? t2.start();

? ? ? ? ? ? ? ? try {

? ? ? ? ? ? ? ? ? ? ? ? t1.join();

? ? ? ? ? ? ? ? ? ? ? ? t2.join();

? ? ? ? ? ? ? ? ? ? ? ? System.out.printf("counter = %d\n", counter);

? ? ? ? ? ? ? ? } catch (InterruptedException e) {

? ? ? ? ? ? ? ? ? ? ? ? e.printStackTrace();

? ? ? ? ? ? ? ? }

? ? ? ? }

不懂java也沒關系,上面這段代碼做的事情很簡單,開了2個線程對同一個共享整型變量分別執行一億次加1操作,我們期望最后打印出來counter的值為200000000(2億),但事與愿違,運行上面的代碼,counter的值是極有可能不等于2億的,而且每次運行結果都不一樣,總是小于2億。為什么會出現這個情況呢?從Java內存模型的角度來看,簡單的counter++的執行過程其實分為如下三步:

第一步,從主內存中加載counter的值到線程工作內存

第二步,執行加1運算

第三步,把第二步的執行結果從工作內存寫入到主內存

那么現在假設主內存中counter的值是100,兩個線程現在都同時執行counter++,則可能出現如下情況:

線程 1 從主內存中加載counter的值100到線程 1 到工作內存

線程 2 從主內存中加載counter的值100到線程 2 到工作內存

線程 1 執行加1運算得到結果101

線程 2 執行加1運算得到結果101

線程 1 把101寫入主內存中的counter變量

線程 2 把101寫入主內存中的counter變量

線程1和2都執行了+1運算,本來我們期望得到102,但卻錯誤的得到了101這個值。

從上面這個引起錯誤的流程可以看出,之所以結果錯誤,其本質是兩個線程同時操作了同一個對象,線程1執行++運算的過程中插入了線程2的++操作,也就是說從另外一個線程的角度看++操作并不是一個原子操作。

現在我們已經知道多線程并發執行counter++其結果并不正確的原因了,但怎么解決這個問題呢?既然錯誤是因為++不是一個原子操作,那么我們想辦法使其成為原子操作就可以了,所以我們可以:1,加鎖,2,使用原子變量。

首先來看加鎖,偽碼如下:

for (int i = 0; i < COUNT_TIMES; i++) {

? ? ? ? lock();

? ? ? ? counter++;

? ? ? ? unlock();

就是在執行counter++之前加鎖,防止其它線程同時執行這一句,這一句執行完之后解鎖讓其它線程有機會執行這一句。雖然這個方法可以解決問題,但大家可以自己試一下,你會發現加鎖之后性能急劇下降,主要原因是鎖沖突會造成線程等待別人解鎖而造成線程切換,這種上下文切換開銷很大。

下面我們試試使用原子變量,C語言中可以使用gcc提供的原子操作函數,Java中可以使用Atomic相關類,如下面的Java代碼:

public class AtomicTest {

? ? ? ? private static final int COUNT_TIMES = 10000 * 10000;

? ? ? ? private static volatile int counter = 0;

? ? ? ? private static volatile AtomicInteger atomCounter = new AtomicInteger(0); //Java提供的int型原子變量

? ? ? ? public static void main(String[] args) {

? ? ? ? ? ? ? ? Runnable r = () -> {

? ? ? ? ? ? ? ? ? ? ? ? for (int i = 0; i < COUNT_TIMES; i++) {

? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? atomCounter.addAndGet(1); //原子變量的加法

? ? ? ? ? ? ? ? ? ? ? ? }

? ? ? ? ? ? ? ? };

? ? ? ? ? ? ? ? Thread t1 = new Thread(r);

? ? ? ? ? ? ? ? Thread t2 = new Thread(r);

? ? ? ? ? ? ? ? t1.start();;

? ? ? ? ? ? ? ? t2.start();

? ? ? ? ? ? ? ? try {

? ? ? ? ? ? ? ? ? ? ? ? t1.join();

? ? ? ? ? ? ? ? ? ? ? ? t2.join();

? ? ? ? ? ? ? ? ? ? ? ? System.out.printf("atomCounter = %d\n", atomCounter.get());

? ? ? ? ? ? ? ? } catch (InterruptedException e) {

? ? ? ? ? ? ? ? ? ? ? ? e.printStackTrace();

? ? ? ? ? ? ? ? }

? ? ? ? }

}

代碼中使用的AtomicInteger類,其addAndGet()方法執行加法運算,這個方法執行加法操作時是原子的,所以不需要我們在代碼中加鎖。如果我們運行這段代碼,會發現它比前面提到的加鎖方法效率高很多,加鎖方法執行1億次加法所用時間是使用原子變量的好幾倍。為什么使用原子變量效率會高出這么多呢?要想找到答案,就得分析原子變量提供的原子操作是怎么實現的。

下面我們就來看看原子變量是如何實現的,首先我們看Java中怎么實現的,然后分析gcc的實現。

Java中的原子類實現在java.util.concurrent.atomic包中,找到AtomicInteger類,為了減小篇幅,這里只保留類的很小一部分來說明問題

public class AtomicInteger extends Number implements java.io.Serializable {

? ? ? ? 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;

? ? ? ? }

? ? ? ? /**

? ? ? ? ?* Atomically adds the given value to the current value.

? ? ? ? ?*

? ? ? ? ?* @param delta the value to add

? ? ? ? ?* @return the updated value

? ? ? ? ?*/

? ? ? ? public final int addAndGet(int delta) {

? ? ? ? ? ? ? ? return unsafe.getAndAddInt(this, valueOffset, delta) + delta;

? ? ? ? }

}

可以看到addAndGet方法使用了unsafe包的getAndAddInt方法:

這里用的do{ }while 循環調用compareAndSwapInt方法實現的,這個方法是一個本地方法,通過JNI調用的C++代碼,我們需要到虛擬機的目錄中去找這個方法的C++實現:

最終可以看到實現的核心是lock cmpxchg,lock前綴用于鎖總線,保證原子性和實現內存屏障功能。下面用維碼來把前面討論的東西串在一起說明一下如何保證i++這種操作的原子性

我們可以清楚的看到原子變量的原子操作也是用到了鎖,只不過這個是硬件指令級別的鎖,比我們軟件實現的鎖高效很多,更重要的是從上面的偽碼可以看出,如果出現了沖突,只是不停的循環重試,而不會切換線程。

我們又來看一下gcc怎么實現原子操作,高版本的gcc提供了一系列原子操作函數,比如__sync_fetch_and_add,這個函數實現原子的從內存中讀取一個值,然后執行加法操作,最后把結果寫入內存。看個例子:

這段代碼最后會打印出 a = 102, b = 100. 來看一下gcc怎么實現的這個函數,用gdb直接反匯編:

看<+33>行的lock xadd %ecx, 0x4(%rsp),這條xadd指令直接把ecx寄存器中的值(常量2)加到一個內存中的值(變量a)之上,看起來好像只有1次內存訪問,但事實上這條指令至少進行了2次內存訪問:首先從內存中讀取a的值,然后求和并把求和結果存入變量a之中,即:

1,從內存讀取變量a的值到寄存器

2,與2相加

3,把相加后的結果存入變量a對應的內存

這明明是三步操作為什么能夠保證原子操作呢,答案就在于xadd這個指令,cpu執行這個指令之前首先會把這條指令之前的讀寫內存操作完成,然后鎖住內存總線直到執行完上面的三步操作之后才釋放總線,在這段時間之內,其它cpu是無法訪問內存的,這條指令還有內存屏障的功能,也就是說xadd既保證原子操作,又保證執行這條指令的前和后完成內存屏障功能,所以只要多個cpu都按照規定使用xadd并發的操作變量a,就可以保證多個線程并發的執行這種加法操作而不需要我們手動的添加鎖來完成同步。當然如果你在一個線程中使用__sync_fetch_and_add執行對變量a的加法操作,然后在另外一個線程中卻使用a += 2這樣的操作的話肯定會有問題的。

總結:Java中使用的是循環調用CAS操作實現的原子變量的原子操作,而gcc使用的是xadd指令,可以看出gcc的實現方式更加簡潔,應該也是更加高效的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Java8張圖 11、字符串不變性 12、equals()方法、hashCode()方法的區別 13、...
    Miley_MOJIE閱讀 3,731評論 0 11
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,948評論 18 139
  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發崗...
    時芥藍閱讀 42,367評論 11 349
  • 雪狼 實驗小學 三年級 袁靜若 指導老師:白秋燕 同學們,你們喜歡什么動物呢?你要是問...
    燕子集閱讀 499評論 0 0
  • 我們大家一起互粉!!!!!!
    蘇鐵飯粒閱讀 188評論 0 1