溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

如何解讀Java多線程與并發模型中的共享對象

發布時間:2022-01-11 11:40:15 來源:億速云 閱讀:132 作者:柒染 欄目:編程語言

本篇文章為大家展示了如何解讀Java多線程與并發模型中的共享對象,內容簡明扼要并且容易理解,絕對能使你眼前一亮,通過這篇文章的詳細介紹希望你能有所收獲。

以下內容如無特殊說明均指代Java環境。

共享對象

使用Java編寫線程安全的程序關鍵在于正確的使用共享對象,以及安全的對其進行訪問管理。在第一章我們談到Java的內置鎖可以保障線程安全,對于其他的應用來說并發的安全性是在內置鎖這個“黑盒子”內保障了線程變量使用的邊界。談到線程的邊界問題,隨之而來的是Java內存模型另外的一個重要的含義,可見性。Java對可見性提供的原生支持是volatile關鍵字。

volatile關鍵字

volatile 變量具備兩種特性,其一是保證該變量對所有線程可見,這里的可見性指的是當一個線程修改了變量的值,那么新的值對于其他線程是可以立即獲取的。其二 volatile 禁止了指令重排。

雖然 volatile 變量具有可見性和禁止指令重排序,但是并不能說 volatile 變量能確保并發安全。

public class VolatileTest {

public static volatile int a = 0;

public static final int THREAD_COUNT = 20;

public static void increase() {a++;

}

public static void main(String[] args) 

throws InterruptedException

 {

Thread[] threads = new Thread[THREAD_COUNT];

for (int i = 0;

 i < THREAD_COUNT; i++)

 {

threads[i] = new Thread(new Runnable() 

{

public void run() {for (int i = 0;

 i < 1000;

 i++) {increase();

}

}

}

);

threads[i].start();

}

while (Thread.activeCount() > 2)

 {

Thread.yield();

}

System.out.println(a);

}

}

按照我們的預期,它應該返回 20000 ,但是很可惜,該程序的返回結果幾乎每次都不一樣。

問題主要出在 a++ 上,復合操作并不具備原子性, 雖然這里利用 volatile 定義了 a ,但是在做 a++ 時, 先獲取到最新的 a 值,比如這時候最新的可能是 50,然后再讓 a 增加,但是在增加的過程中,其他線程很可能已經將 a 的值改變了,或許已經成為 52、53 ,但是該線程作自增時,還是使用的舊值,所以會出現結果往往小于預期的 2000。如果要解決這個問題,可以對 increase() 方法加鎖。

volatile 適用場景

volatile 適用于程序運算結果不依賴于變量的當前值,也相當于說,上述程序的 a 不要自增,或者說僅僅是賦值運算,例如 boolean flag = true 這樣的操作。

volatile boolean shutDown =false;

public voidshutDown()

 {

shutDown =true;

}

public voiddoWork()

 {while(!shutDown)

 {

System.out.println("Do work "+ Thread.currentThread().getId());

}

}

代碼2.1:變量的可見性問題

在代碼2.1中,可以看到按照正常的邏輯應該打印10之后線程停止,但是實際的情況可能是打印出0或者程序永遠不會被終止掉。其原因是沒有使用恰當的同步機制以保障線程的寫入操作對所有線程都是可見的。

我們一般將volatile理解為synchronized的輕量級實現,在多核處理器中可以保障共享變量的“可見性”,但是不能保障原子性。關于原子性問題在該章節的程序變量規則會加以說明,下面我們先看下Java的內存模型實現以了解JVM和計算機硬件是如何協調共享變量的以及volatile變量的可見性。

Java內存模型

我們都知道現代計算機都是馮諾依曼結構的,所有的代碼都是順序執行的。如果計算機需要在CPU中運算某個指令,勢必就會涉及對數據的讀取和寫入操作。由于程序數據的大部分內容都是存儲在主內存(RAM)中的,在這當中就存在著一個讀取速度的問題,CPU很快而主內存相對來說(相對CPU)就會慢上很多,為了解決這個速度階梯問題,各個CPU廠商都在CPU里面引入了高速緩存來優化主內存和CPU的數據交互。針對上面的技術我特意整理了一下,有很多技術不是靠幾句話能講清楚,所以干脆找朋友錄制了一些視頻,很多問題其實答案很簡單,但是背后的思考和邏輯不簡單,要做到知其然還要知其所以然。如果想學習Java工程化、高性能及分布式、深入淺出。微服務、Spring,MyBatis,Netty源碼分析的朋友可以加我的Java進階群:591240817,群里有大牛直播講解技術,以及Java大型互聯網技術的視頻免費分享

此時當CPU需要從主內存獲取數據時,會拷貝一份到高速緩存中,CPU計算時就可以直接在高速緩存中進行數據的讀取和寫入,提高吞吐量。當數據運行完成后,再將高速緩存的內容刷新到主內存中,此時其他CPU看到的才是執行之后的結果,但在這之間存在著時間差。

看這個例子:

int counter = 0; counter = counter + 1;復制代碼

代碼2.2:自增不一致問題

代碼2.2在運行時,CPU會從主內存中讀取counter的值,復制一份到當前CPU核心的高速緩存中,在CPU執行完成加1的指令之后,將結果1寫入高速緩存中,最后將高速緩存刷新到主內存中。這個例子代碼在單線程的程序中將正確的運行下去。

但我們試想這樣一種情況,現在有兩個線程共同運行該段代碼,初始化時兩個線程分別從主內存中讀取了counter的值0到各自的高速緩存中,線程1在CPU1中運算完成后寫入高速緩存Cache1,線程2在CPU2中運算完成后寫入高速緩存Cache2,此時counter的值在兩個CPU的高速緩存中的值都是1。

此時CPU1將值刷新到主內存中,counter的值為1,之后CPU2將counter的值也刷新到主內存,counter的值覆蓋為1,最終的結果計算counter為1(正確的兩次計算結果相加應為2)。這就是緩存不一致性問題。這會在多線程訪問共享變量時出現。

解決緩存不一致問題的方案:

通過總線鎖LOCK#方式。

通過緩存一致性協議。

如何解讀Java多線程與并發模型中的共享對象

圖2.1 :緩存不一致問題

圖2.1中提到的兩種內存一致性協議都是從計算機硬件層面上提供的保障。CPU一般是通過在總線上增加LOCK#鎖的方式,鎖住對內存的訪問來達到目的,也就是阻塞其他CPU對內存的訪問,從而使只有一個CPU能訪問該主內存。因此需要用總線進行內存鎖定,可以分析得到此種做法對CPU的吞吐率造成的損害很嚴重,效率低下。

隨著技術升級帶來了緩存一致性協議,市場占有率較大的Intel的CPU使用的是MESI協議,該協議可以保障各個高速緩存使用的共享變量的副本是一致的。其實現的核心思想是:當在多核心CPU中訪問的變量是共享變量時,某個線程在CPU中修改共享變量數據時,會通知其他也存儲了該變量副本的CPU將緩存置為無效狀態,因此其他CPU讀取該高速緩存中的變量時,發現該共享變量副本為無效狀態,會從主內存中重新加載。但當緩存一致性協議無法發揮作用時,CPU還是會降級使用總線鎖的方式進行鎖定處理。

一個小插曲:為什么volatile無法保障的原子性

我們看下圖2.2,CPU在主內存中讀取一個變量之后,拷貝副本到高速緩存,CPU在執行期間雖然識別了變量的“易變性”,但是只能保障最后一步store操作的原子性,在load,use期間并未實現其原子性操作。

如何解讀Java多線程與并發模型中的共享對象

圖2.2:數據加載和內存屏障

JVM為了使我們的代碼得到最優的執行體驗,在進行自我優化時,并不保障代碼的先后執行順序(滿足Happen-Before規則的除外),這就是“指令重排”,而上面提到的store操作保障了原子性,JVM是如何實現的呢?其原因是這里存在一個“內存屏障”的指令(以后我們會談到整個內容),這個是CPU支持的一個指令,該指令只能保障store時的原子性,但是不能保障整個操作的原子性。

從整個小插曲中,我們看到了volatile雖然有可見性的語義,但是并不能真正的保證線程安全。如果要保證并發線程的安全訪問,需要符合并發程序變量的訪問規則。

并發程序變量的訪問規則

1. 原子性

程序的原子性和數據庫事務的原子性有著同樣的意義,可以保障一次操作要么全部執行成功,要不全部都不執行。

2. 可見性

可見性是微妙的,因為最終的結果總是和我們的直覺大相徑庭,當多個線程共同修改一個共享變量的值時,由于存在高速緩存中的變量副本操作,不能及時將數據刷新到主內存,導致當前線程在CP中的操作結果對其他CPU是不可見狀態。

3. 有序性

有序性通俗的理解就是程序在JVM中是按照順序執行的,但是前面已經提到了JVM為了優化代碼的執行速度,會進行“指令重排”。在單線程中“指令重排”并不會帶來安全問題,但在并發程序中,由于程序的順序不能保障,運行過程中可能會出現不安全的線程訪問問題。

綜上,要想在并發編程環境中安全的運行程序,就必須滿足原子性、可見性和有序性。只要以上任何一點沒有保障,那程序運行就可能出現不可預知的錯誤。最后我們介紹一下Java并發的“殺手锏”,Happens-Before法則,符合該法則的情況下可以保障并發環境下變量的訪問規則。

happens-before語義

Java內存模型使用了各種操作來定義的,包括對變量的讀寫,監視器的獲取釋放等,JMM中使用了

happens-before

語義闡述了操作之間的內存可見性。如果想要保證執行操作B的線程看到操作A的結構(無論AB是否在同一線程),那么A,B必須滿足

happens-before

關系。如果兩個操作之間缺乏

happens-before

Happens-Before法則:

程序次序法則:線程中的每個動作A都Happens-Before于該線程中的每一個動作B,在程序中,所有的動作B都出現在動作A之后。

Lock法則:對于一個Lock的解鎖操作總是Happens-Before于每一個后續對該Lock的加鎖操作。

volatile變量法則:對于volatile變量的寫入操作Happens-Before于后續對同一個變量的讀操作。

線程啟動法則:在一個線程里,對Thread.start()函數的調用會Happens-Before于每一個啟動線程中的動作。

線程終結法則:線程中的任何動作都Happens-Before于其他線程檢測到這個線程已經終結或者從Thread.join()函數調用中成功返回或者Thread.isAlive()函數返回false。

中斷法則:一個線程調用另一個線程的interrupt總是Happens-Before于被中斷的線程發現中斷。

終結法則:一個對象的構造函數的結束總是Happens-Before于這個對象的finalizer(Java沒有直接的類似C的析構函數)的開始。

傳遞性法則:如果A事件Happens-Before于B事件,并且B事件Happens-Before于C事件,那么A事件Happens-Before于C事件。

當一個變量在多線程競爭中被讀取和存儲,如果并未按照Happens-Before的法則,那么他就會存在數據競爭關系。

總結

給大家關于Java的共享變量的內容就介紹到這里,現在你已經明白Java的volatile關鍵字的含義了,了解了為什么volatile不能保障原子性的原因了,了解了Happens-Before規則能讓我們的Java程序運行的更加安全。

在這里給大家提供一個學習交流的平臺,java架構師群1017599436

具有1-5工作經驗的,面對目前流行的技術不知從何下手,需要突破技術瓶頸的可以加群。

在公司待久了,過得很安逸,但跳槽時面試碰壁。需要在短時間內進修、跳槽拿高薪的可以加群。

如果沒有工作經驗,但基礎非常扎實,對java工作機制,常用設計思想,常用java開發框架掌握熟練的可以加群。

通過這節內容希望可以幫助你更深入的了解Java的并發概念中的內置鎖和共享變量。Java的并發內容還有很多,例如在某些場景下比synchronized效率要更高的Lock,阻塞隊列,同步器等。

上述內容就是如何解讀Java多線程與并發模型中的共享對象,你們學到知識或技能了嗎?如果還想學到更多技能或者豐富自己的知識儲備,歡迎關注億速云行業資訊頻道。

向AI問一下細節

免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。

AI

亚洲午夜精品一区二区_中文无码日韩欧免_久久香蕉精品视频_欧美主播一区二区三区美女