Java中的常见的锁及其内存语义
文章目錄
- 為什么會(huì)有鎖?
- JVM內(nèi)存模型
- 沒有鎖會(huì)怎么樣?
- happens-before 先行先發(fā)生原則
- Java中常見的鎖
- synchronized
- 內(nèi)存語義
- 實(shí)現(xiàn)原理
- volatile
- 內(nèi)存語義
- 實(shí)現(xiàn)原理
- Lock Api
- 隊(duì)列同步器
- 類初始化鎖
- 單例模式之懶漢式與靜態(tài)內(nèi)部類式
- 基于volatile的解決方案
- 基于類的初始化的解決方案——靜態(tài)內(nèi)部類
為什么會(huì)有鎖?
為什么Java會(huì)有鎖,這要從Java的內(nèi)存模型講起:
大家都知道Java是個(gè)多線程語言,這句話的意思是一個(gè)Java進(jìn)程可以創(chuàng)建多個(gè)線程來執(zhí)行指令。對于可并發(fā)的編程語言,要怎么做到線程之間是如何通信的,以及線程間是如何同步的?
-
在命令式編程中,通信機(jī)制一般有兩種,共享內(nèi)存和消息傳遞
共享內(nèi)存是一種隱式通信,通過訪問一個(gè)公共的內(nèi)存區(qū)域,來達(dá)到線程間數(shù)據(jù)交互的作用。
消息傳遞是一種顯式通信,線程之間必須通過發(fā)送接收消息才能進(jìn)行通信。 -
線程間的同步是指不同線程間的操作同步,在共享內(nèi)存模型中,同步是顯式的,需要開發(fā)人員主動(dòng)加鎖,而在消息傳遞的模型中,同步是隱式的,因?yàn)橄⒌陌l(fā)送必須在消息的接收之前,這就意味著數(shù)據(jù)同步。
JVM內(nèi)存模型
下面再來看看JVM的內(nèi)存模型(JMM)
如果線程AB要進(jìn)行通信,必須經(jīng)過兩個(gè)步驟,①線程A把本地內(nèi)存A的共享變量更新進(jìn)主內(nèi)存,②線程B從主內(nèi)存中讀取最新數(shù)據(jù)到本地內(nèi)存B
整體來看,線程A和線程B的通信必須經(jīng)過主內(nèi)存,JMM通過控制主內(nèi)存和每個(gè)線程本地內(nèi)存的交互,來提供內(nèi)存可見性,以此實(shí)現(xiàn)通信。
本地內(nèi)存的使用,使得每個(gè)線程不用都去頻繁地都去訪問主內(nèi)存,可也正是由于JVM本地內(nèi)存副本的實(shí)現(xiàn),導(dǎo)致了線程讀取的內(nèi)存數(shù)據(jù),可能不是最新的。
所以,對于存在并發(fā)競爭的數(shù)據(jù),我們得加鎖來保證數(shù)據(jù)一致。
沒有鎖會(huì)怎么樣?
public class A {// 定義一個(gè)靜態(tài)變量public static int a = 0; } public static void main(String[] args){CountDownLatch end = new CountDowmLatch(10);// 創(chuàng)建10個(gè)線程,每個(gè)線程都去給a加1,加10次for(int i=0; i < 10; i++){new Thread(() -> {for(int j = 0; j < 10; j++){A.a += 1;}end.countDown();}).start();}// 沒有執(zhí)行完線程前,阻塞end.await();System.out.println(A.a); }上面的代碼創(chuàng)建了10個(gè)線程并發(fā)地去給a變量加1,每個(gè)線程加10次。這里的最后打印的結(jié)果是小于10的。有就是說,如果不加鎖,預(yù)期結(jié)果和最終結(jié)果是不一致的。
happens-before 先行先發(fā)生原則
這個(gè)概念是后續(xù)鎖的內(nèi)存語義的基礎(chǔ),先了解一下JMM的happens-before規(guī)則,這幾點(diǎn)規(guī)則是JMM自己實(shí)現(xiàn)的。
比如看看下面這個(gè)案例:
假如線程A程序次序?yàn)椴襟E1,2,線程B的程序次序?yàn)椴襟E3,4且線程A比線程B先執(zhí)行,那么根據(jù)第3條規(guī)則,步驟22一定會(huì)比步驟3先發(fā)生,并且步驟3能“看到”步驟2的執(zhí)行,也就是步驟3讀到的已經(jīng)是步驟2修改過的數(shù)據(jù)了。那么再根據(jù)規(guī)則1和規(guī)則4的傳遞性,可以知道步驟1也先行發(fā)生于步驟4,所以線程B讀到的共享變量一定是線程A修改過后的變量。
Java中常見的鎖
synchronized
內(nèi)存語義
假設(shè)線程A執(zhí)行writer()方法,線程B執(zhí)行reader()方法,那么會(huì)發(fā)生如下3種happens-before關(guān)系
關(guān)系圖如下:
當(dāng)線程釋放鎖時(shí),JMM會(huì)把該線程對應(yīng)的本地內(nèi)存的共享變量刷新至主內(nèi)存中
當(dāng)線程獲取鎖時(shí),JMM會(huì)把該線程對應(yīng)本地內(nèi)存設(shè)置為不可用。
實(shí)現(xiàn)原理
synchronized都是將對象作為鎖
- 對于普通方法,是以當(dāng)前實(shí)例對象作為鎖
- 對于靜態(tài)方法,鎖的是字節(jié)碼對象
- 對于同步方法代碼塊,鎖的是synchronize括號內(nèi)的對象
synchronize在JVM的實(shí)現(xiàn)原理是基于進(jìn)入和退出Monitor對象來實(shí)現(xiàn)方法同步和代碼塊同步的,但是兩者的實(shí)現(xiàn)細(xì)節(jié)不一樣。
代碼塊同步是使用monitorenter和monitorexit指令實(shí)現(xiàn)的,而方法同步是使用另外一種方式實(shí)現(xiàn)的,但是大體也是可以通過這兩個(gè)來解析。
JVM保證monitorenter和monitorexit一定是成對出現(xiàn)的,在代碼編譯后,monitorenter在插入在同步代碼塊的開始位置,monitorexit則插入在結(jié)束位置或者異常位置,
當(dāng)執(zhí)行到指令monitorenter的時(shí)候,就會(huì)去獲取鎖對象,這個(gè)是在編譯成class字節(jié)碼文件時(shí)操作的
上面說到,synchronized是以對象作為鎖的,那么其實(shí)作為鎖的對象,是以對象頭部的一塊區(qū)域-MarkWord有關(guān)的。
在JDK1.6之后,synchronized進(jìn)行了優(yōu)化,引入了“偏向鎖”和“輕量鎖”,級別由低到高為無鎖狀態(tài)、偏向鎖狀態(tài)、輕量鎖狀態(tài)、重量鎖狀態(tài)
這幾個(gè)狀態(tài)會(huì)隨著鎖的競爭而升級,卻不會(huì)降級,不能降級目的是為了提高獲取鎖和釋放鎖的效率。
這里列舉了synchronized鎖的幾種狀態(tài)的優(yōu)缺點(diǎn)
這里不對偏向鎖,輕量鎖進(jìn)行冗述。可以直接看《Java并發(fā)編程的藝術(shù)》第二章第二節(jié)。
volatile
內(nèi)存語義
值得一提的是,我覺得volatile并不能稱為一種鎖,而是一種對內(nèi)存控制保持線程可見性的一個(gè)內(nèi)存特性。
上文的happens-before原則里用了volatile的案例,volatile只是對內(nèi)存操作,也就是數(shù)據(jù)的讀寫,進(jìn)行了加鎖同步。
鎖的happens-before規(guī)則能夠保證釋放鎖和獲取鎖的兩個(gè)線程的內(nèi)存可見性。這意味著對于volatile的變量,在讀volatile的時(shí)候,總是可以看到最后一個(gè)線程寫這個(gè)變量的數(shù)據(jù)。
volatile具有如下特性:
看下面的一個(gè)例子:
當(dāng)線程A先執(zhí)行writer(),然后線程B執(zhí)行reader()方法時(shí),根據(jù)happens-before原則,可以建立3種先行先發(fā)生關(guān)系
對于A線程來說,其執(zhí)行的是writer()方法,在把flag寫入緩存后,馬上就將本地內(nèi)存中被A更新過的兩個(gè)共享變量的值刷新至主存。而在讀一個(gè)volatile變量的時(shí)候,JMM會(huì)另本地工作內(nèi)存不可讀,直接從主內(nèi)存中獲取值。以保證數(shù)據(jù)同步
實(shí)現(xiàn)原理
了解一下CPU處理器的一些概念:處理器不直接和內(nèi)存進(jìn)行通信,而是先將系統(tǒng)內(nèi)存的數(shù)據(jù)讀到內(nèi)部緩存(L1,L2),然后再進(jìn)行操作,但是操作后的結(jié)果不知道什么時(shí)候回寫到內(nèi)存。
如果對變量進(jìn)行了volatile的修飾,在對變量進(jìn)行寫操作時(shí),JVM會(huì)向處理器發(fā)送一條Lock前綴的指令,將這個(gè)變量所在緩存行的緩存回寫到內(nèi)存。并且,為了保證每個(gè)處理器緩存都一致,
每個(gè)處理器通過嗅探在總線上的傳播數(shù)據(jù)來檢查自己的緩存是否過期,如果判斷修改,就會(huì)主動(dòng)過期無效自己的緩存,下一次操作就會(huì)從主內(nèi)存中獲取。
保證各個(gè)處理器緩存一致的行為稱為緩存一致性協(xié)議。
當(dāng)一段代碼對volatile的變量進(jìn)行寫操作,比如:
其轉(zhuǎn)為匯編的結(jié)果是:
lock前綴的指令如上所說,會(huì)讓處理器及時(shí)回寫內(nèi)存,并保持緩存一致性,總結(jié)為兩件事:
將當(dāng)前處理器的數(shù)據(jù)回寫系統(tǒng)內(nèi)存
這個(gè)回寫內(nèi)存操作會(huì)使其他cpu處理器的緩存了該內(nèi)存地址的數(shù)據(jù)無效
volatile還能夠設(shè)置內(nèi)存屏障,保證不會(huì)被指令重排
Lock Api
Lock是JDK1.5之后提供的API,相比synchronized的加鎖,Lock提供了更多的選擇
Lock相比synchronized來說,可以非阻塞的獲取鎖,可以中斷的獲取鎖,可以可超時(shí)的獲取鎖,也可以是公平或者非公平鎖。
Lock的實(shí)現(xiàn)大多都依靠AQS(AbstractQueuedSynchronizer)隊(duì)列同步器。
AQS提供的API是使用模板方法建造的,我們開發(fā)者可以通過重寫AQS的一些方法來實(shí)現(xiàn)我們自己的隊(duì)列同步器。
隊(duì)列同步器
同步器依賴于內(nèi)部的一個(gè)(FIFO)隊(duì)列實(shí)現(xiàn),隊(duì)列里面的內(nèi)容是封裝好的線程節(jié)點(diǎn)。當(dāng)前線程獲取同步器失敗時(shí),同步器會(huì)將當(dāng)前線程以及等待狀態(tài)信息構(gòu)造成一個(gè)節(jié)點(diǎn)(Node),并將其加入同步隊(duì)列中,尾插入。同時(shí)阻塞當(dāng)前線程。當(dāng)同步狀態(tài)釋放時(shí),會(huì)把首節(jié)點(diǎn)的線程喚醒,使其再次嘗試獲取同步狀態(tài)。
這是同步器隊(duì)列的基本結(jié)構(gòu),其中左邊是頭,右邊是尾。
插入同步器隊(duì)列一定是一個(gè)高并發(fā)的,有資源競爭場景,所以在插入節(jié)點(diǎn)的時(shí)候一定要保證同步,在同步隊(duì)列中,采用的是基于CAS的設(shè)置尾節(jié)點(diǎn)方法:
compareAndSetTail(Node expect, Node update),它需要傳遞當(dāng)前線程“認(rèn)為”的尾節(jié)點(diǎn)和當(dāng)前節(jié)點(diǎn),只有設(shè)置成功,當(dāng)前節(jié)點(diǎn)才能正式與之前的節(jié)點(diǎn)建立關(guān)聯(lián)。
而首節(jié)點(diǎn)設(shè)置是不需要同步的,因?yàn)槭坠?jié)點(diǎn)的線程釋放同步狀態(tài)時(shí),將會(huì)喚醒后繼節(jié)點(diǎn),而后繼節(jié)點(diǎn)將會(huì)在獲取同步狀態(tài)成功時(shí)將自己設(shè)置為首節(jié)點(diǎn),因?yàn)檫@個(gè)過程只涉及一個(gè)節(jié)點(diǎn)一個(gè)線程,所以不需要CAS來來保證,只需要將原首節(jié)點(diǎn)的后繼節(jié)點(diǎn)并斷開原首節(jié)點(diǎn)的next引用即可。
關(guān)于AQS更加詳細(xì)的內(nèi)容,也請各位讀者通過看書,看源碼的方式去詳細(xì)了解,這里不做太多描述
類初始化鎖
類的初始化只會(huì)執(zhí)行一次,而且這個(gè)是由Class對象的初始化鎖保證的
根據(jù)Java語言規(guī)范,在首次發(fā)生下列任意一種情況時(shí),一個(gè)類(抽象類也是類)或者接口類型T會(huì)被立即初始化,也就是會(huì)立即執(zhí)行類構(gòu)造器。
而執(zhí)行類構(gòu)造器是由編譯器保證全局只會(huì)執(zhí)行一次,通過class鎖保證同步。
單例模式之懶漢式與靜態(tài)內(nèi)部類式
說到單例模式,各位開發(fā)者應(yīng)該都很熟悉,而Java中常見的包括餓漢式,懶漢式等。這里不做介紹,先來列出一段代碼:
public class LazySingleInstance {private static Instance instance;public static Instance getInstance(){if(instance == null){sysnchronized (LazySingleInstance.class){if(instance == null){instance = new Instance();}}}return instance;} }這段代碼其實(shí)是有問題的
因?yàn)閚ew Instance() 是可以被重排序的,問題的根源是編譯器以單線程來考慮能否重排序。
一個(gè)創(chuàng)建對象并賦值的過程包括3步:分配對象的內(nèi)存空間,初始化對象,設(shè)置instance指向剛剛分配的空間
在Java語言規(guī)范中,所有線程在執(zhí)行操作時(shí),必須遵守intra-thread semantics,這個(gè)規(guī)則會(huì)保證重排序不影響單線程的結(jié)果,也就是說,如果對于單個(gè)線程允許重排序,那么為了優(yōu)化執(zhí)行效率,就會(huì)選擇重排序。
而上圖中的2,3剛好會(huì)被重排序,且對于單線程不影響,但是對多線程來說,這是不合法的,因?yàn)樵跈z查instance時(shí),只要內(nèi)存地址被賦值了,就會(huì)不為null,從而造成線程拿到的instance實(shí)例是未被正常初始化的。
基于volatile的解決方案
volatile是擁有禁止重排序語義的。只要將instance定義為volatile變量就沒問題了
這是JMM為了實(shí)現(xiàn)volatile的語義,會(huì)限制前面說的編譯器重排序和處理器重排序。上面的表格是針對編譯器制定的
如上的表格,總結(jié)為以下3點(diǎn):
- 對于讀一個(gè)volatile,后面的操作都不能重排序,也就是說,讀volatile后的數(shù)據(jù)操作都是在讀volatile之后
- 對于寫一個(gè)volatile,其前面的操作都不會(huì)重排序到寫volatile之后。
- 如果第一個(gè)操作是寫一個(gè)volatile數(shù)據(jù),那么后面的讀volatile數(shù)據(jù)是不會(huì)重排序到其之前。
實(shí)現(xiàn)這個(gè)重排序規(guī)則主要是JMM利用內(nèi)存屏障處理
基于類的初始化的解決方案——靜態(tài)內(nèi)部類
將代碼改造為:
public class LazySingleInstanceFactory {private static class InstanceHolder{public static Instance instance = new Instance();}public static Instance getSingleInstance(){return InstanceHolder.instance;}這里我們的實(shí)例對象是在類InstanceHolder中靜態(tài)賦值的,也就是在類構(gòu)造器中被賦值,根據(jù)上面類構(gòu)造器鎖中的執(zhí)行類初始化的觸發(fā)條件,第4點(diǎn):T中聲明的一個(gè)靜態(tài)字段被使用,且這個(gè)字段不是常量。
所以這里利用類構(gòu)造器鎖可以保證對象不會(huì)被重復(fù)創(chuàng)建。
參考資料:《Java并發(fā)編程的藝術(shù)》
總結(jié)
以上是生活随笔為你收集整理的Java中的常见的锁及其内存语义的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: springboot +mysql“友书
- 下一篇: Java常考面试题1--面向对象的特征