日日操夜夜添-日日操影院-日日草夜夜操-日日干干-精品一区二区三区波多野结衣-精品一区二区三区高清免费不卡

公告:魔扣目錄網(wǎng)為廣大站長提供免費(fèi)收錄網(wǎng)站服務(wù),提交前請做好本站友鏈:【 網(wǎng)站目錄:http://www.ylptlb.cn 】, 免友鏈快審服務(wù)(50元/站),

點(diǎn)擊這里在線咨詢客服
新站提交
  • 網(wǎng)站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會員:747

一、現(xiàn)代計(jì)算機(jī)理論模型與工作方式

現(xiàn)代計(jì)算機(jī)模型是基于-馮諾依曼計(jì)算機(jī)模型。計(jì)算機(jī)在運(yùn)行時(shí),先從內(nèi)存中取出第一條指令,通過控制器的譯碼,按指令的要求,從存儲器中取出數(shù)據(jù)進(jìn)行指定的運(yùn)算和邏輯操作等加工,然后再按地址把結(jié)果送到內(nèi)存中去。接下來,再取出第二條指令,在控制器的指揮下完成規(guī)定操作。依此進(jìn)行下去,直至遇到停止指令。

程序與數(shù)據(jù)一樣存儲,按程序編排的順序,一步一步地取出指令,自動地完成指令規(guī)定的操作是計(jì)算機(jī)最基本的工作模型。這一原理最初是由美籍匈牙利數(shù)學(xué)家馮.諾依曼于1945年提出來的,故稱為馮.諾依曼計(jì)算機(jī)模型。

多線程編程?聊聊并發(fā)的背后知識

 

1.1、計(jì)算機(jī)五大核心組成部分

  1. 控制器(Control):是整個(gè)計(jì)算機(jī)的中樞神經(jīng),其功能是對程序規(guī)定的控制信息進(jìn)行解釋,根據(jù)其要求進(jìn)行控制,調(diào)度程序、數(shù)據(jù)、地址,協(xié)調(diào)計(jì)算機(jī)各部分工作及內(nèi)存與外設(shè)的訪問等。
  2. 運(yùn)算器(Datapath):運(yùn)算器的功能是對數(shù)據(jù)進(jìn)行各種算術(shù)運(yùn)算和邏輯運(yùn)算,即對數(shù)據(jù)進(jìn)行加工處理。
  3. 存儲器(Memory):存儲器的功能是存儲程序、數(shù)據(jù)和各種信號、命令等信息,并在需要時(shí)提供這些信息。
  4. 輸入(Input system):輸入設(shè)備是計(jì)算機(jī)的重要組成部分,輸入設(shè)備與輸出設(shè)備合為外部設(shè)備,簡稱外設(shè),輸入設(shè)備的作用是將程序、原始數(shù)據(jù)、文字、字符、控制命令或現(xiàn)場采集的數(shù)據(jù)等信息輸入到計(jì)算機(jī)。常見的輸入設(shè)備有鍵盤、鼠標(biāo)器、光電輸入機(jī)、磁帶機(jī)、磁盤機(jī)、光盤機(jī)等。
  5. 輸出(Output system):輸出設(shè)備與輸入設(shè)備同樣是計(jì)算機(jī)的重要組成部分,它把外算機(jī)的中間結(jié)果或最后結(jié)果、機(jī)內(nèi)的各種數(shù)據(jù)符號及文字或各種控制信號等信息輸出出來。計(jì)算機(jī)常用的輸出設(shè)備有顯示終端、打印機(jī)、激光印字機(jī)、繪圖儀及磁帶、光盤機(jī)等。

 

1.2、計(jì)算機(jī)多CPU架構(gòu)

多線程編程?聊聊并發(fā)的背后知識

 

多CPU

一個(gè)現(xiàn)代計(jì)算機(jī)通常由兩個(gè)或者多個(gè)CPU,如果要運(yùn)行多個(gè)程序(進(jìn)程)的話,假如只有一個(gè)CPU的話,就意味著要經(jīng)常進(jìn)行進(jìn)程上下文切換,因?yàn)閱蜟PU即便是多核的,也只是多個(gè)處理器核心,其他設(shè)備都是共用的,所以多個(gè)進(jìn)程就必然要經(jīng)常進(jìn)行進(jìn)程上下文切換,這個(gè)代價(jià)是很高的

CPU多核

一個(gè)現(xiàn)代CPU除了處理器核心之外還包括寄存器、L1L2L3緩存這些存儲設(shè)備、浮點(diǎn)運(yùn)算單元、整數(shù)運(yùn)算單元等一些輔助運(yùn)算設(shè)備以及內(nèi)部總線等。一個(gè)多核的CPU也就是一個(gè)CPU上有多個(gè)處理器核心,這樣有什么好處呢?比如說現(xiàn)在我們要在一臺計(jì)算機(jī)上跑一個(gè)多線程的程序,因?yàn)槭且粋€(gè)進(jìn)程里的線程,所以需要一些共享一些存儲變量,如果這臺計(jì)算機(jī)都是單核單線程CPU的話,就意味著這個(gè)程序的不同線程需要經(jīng)常在CPU之間的外部總線上通信,同時(shí)還要處理不同CPU之間不同緩存導(dǎo)致數(shù)據(jù)不一致的問題,所以在這種場景下多核單CPU的架構(gòu)就能發(fā)揮很大的優(yōu)勢,通信都在內(nèi)部總線,共用同一個(gè)緩存。

CPU寄存器

每個(gè)CPU都包含一系列的寄存器,它們是CPU內(nèi)內(nèi)存的基礎(chǔ)。CPU在寄存器上執(zhí)行操作的速度遠(yuǎn)大于在主存上執(zhí)行的速度。這是因?yàn)镃PU訪問寄存器的速度遠(yuǎn)大于主存。

CPU緩存

即高速緩沖存儲器,是位于CPU與主內(nèi)存間的一種容量較小但速度很高的存儲器。由于CPU的速度遠(yuǎn)高于主內(nèi)存,CPU直接從內(nèi)存中存取數(shù)據(jù)要等待一定時(shí)間周期,Cache中保存著CPU剛用過或循環(huán)使用的一部分?jǐn)?shù)據(jù),當(dāng)CPU再次使用該部分?jǐn)?shù)據(jù)時(shí)可從Cache中直接調(diào)用,減少CPU的等待時(shí)間,提高了系統(tǒng)的效率,目前主流CPU緩存有:

一級Cache(L1 Cache)

二級Cache(L2 Cache)

三級Cache(L3 Cache)

1.3、CPU讀取存儲器數(shù)據(jù)過程

  1. CPU取寄存器XX的值:只需要一步:直接讀取。
  2. CPU取L1 cache的某個(gè)值:需要1-3步(或者更多):把cache行鎖住,把某個(gè)數(shù)據(jù)拿來,解鎖。
  3. CPU取L2 cache的某個(gè)值:先要到L1 cache里取,L1當(dāng)中不存在,在L2里,L2開始加鎖,加鎖以后,把L2里的數(shù)據(jù)復(fù)制到L1,再執(zhí)行讀L1的過程,再解鎖。
  4. CPU取L3 cache的某個(gè)值:和讀取L2 cache一樣,只不過先由L3復(fù)制到L2,從L2復(fù)制到L1,從L1到CPU。
  5. CPU取內(nèi)存則最復(fù)雜:通知內(nèi)存控制器占用總線帶寬,通知內(nèi)存加鎖,發(fā)起內(nèi)存讀請求,等待回應(yīng),回應(yīng)數(shù)據(jù)保存到L3(如果沒有就到L2),再從L3/2到L1,再從L1到CPU,之后解除總線鎖定。
  6. 1.4、多線程環(huán)境下存在的問題

緩存一致性問題

在多處理器系統(tǒng)中,每個(gè)處理器都有自己的高速緩存,而它們又共享同一主內(nèi)存(MainMemory)。基于高速緩存的存儲交互很好地解決了處理器與內(nèi)存的速度矛盾,但是也引入了新的問題:緩存一致性(CacheCoherence)。當(dāng)多個(gè)處理器的運(yùn)算任務(wù)都涉及同一塊主內(nèi)存區(qū)域時(shí),將可能導(dǎo)致各自的緩存數(shù)據(jù)不一致的情況,如果真的發(fā)生這種情況,那同步回到主內(nèi)存時(shí)以誰的緩存數(shù)據(jù)為準(zhǔn)呢?為了解決一致性的問題,需要各個(gè)處理器訪問緩存時(shí)都遵循一些協(xié)議,在讀寫時(shí)要根據(jù)協(xié)議來進(jìn)行操作,這類協(xié)議有MSIMESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等等。

多線程編程?聊聊并發(fā)的背后知識

 

指令重排序問題

為了使得處理器內(nèi)部的運(yùn)算單元能盡量被充分利用,處理器可能會對輸入代碼進(jìn)行亂序執(zhí)行(Out-Of-Order Execution)優(yōu)化,處理器會在計(jì)算之后將亂序執(zhí)行的結(jié)果重組,保證該結(jié)果與順序執(zhí)行的結(jié)果是一致的,但并不保證程序中各個(gè)語句計(jì)算的先后順序與輸入代碼中的順序一致。因此,如果存在一個(gè)計(jì)算任務(wù)依賴另一個(gè)計(jì)算任務(wù)的中間結(jié)果,那么其順序性并不能靠代碼的先后順序來保證。與處理器的亂序執(zhí)行優(yōu)化類似,JAVA虛擬機(jī)的即時(shí)編譯器中也有類似的指令重排序(Instruction Reorder)優(yōu)化。

二、什么是線程

現(xiàn)代操作系統(tǒng)在運(yùn)行一個(gè)程序時(shí),會為其創(chuàng)建一個(gè)進(jìn)程。例如,啟動一個(gè)Java程序,操作系統(tǒng)就會創(chuàng)建一個(gè)Java進(jìn)程。現(xiàn)代操作系統(tǒng)調(diào)度CPU的最小單元是線程,也叫輕量級進(jìn)程(Light Weight Process),在一個(gè)進(jìn)程里可以創(chuàng)建多個(gè)線程,這些線程都擁有各自的計(jì)數(shù)器、堆棧和局部變量等屬性,并且能夠訪問共享的內(nèi)存變量。處理器在這些線程上高速切換,讓使用者感覺到這些線程在同時(shí)執(zhí)行。

線程的實(shí)現(xiàn)可以分為兩類:

1、用戶級線程(User-Level Thread)

2、內(nèi)核線線程(Kernel-Level Thread)

在理解線程分類之前我們需要先了解系統(tǒng)的用戶空間與內(nèi)核空間兩個(gè)概念,以4G大小的內(nèi)存空間為例:linux為內(nèi)核代碼和數(shù)據(jù)結(jié)構(gòu)預(yù)留了幾個(gè)頁框,這些頁永遠(yuǎn)不會被轉(zhuǎn)出到磁盤上。從0x00000000 到 0xc0000000(PAGE_OFFSET) 的線性地址可由用戶代碼 和 內(nèi)核代碼進(jìn)行引用(即用戶空間)。從0xc0000000(PAGE_OFFSET)到 0xFFFFFFFFF的線性地址只能由內(nèi)核代碼進(jìn)行訪問(即內(nèi)核空間)。內(nèi)核代碼及其數(shù)據(jù)結(jié)構(gòu)都必須位于這 1 GB的地址空間中,但是對于此地址空間而言,更大的消費(fèi)者是物理地址的虛擬映射。

這意味著在 4 GB 的內(nèi)存空間中,只有 3 GB 可以用于用戶應(yīng)用程序。一個(gè)進(jìn)程只能運(yùn)行在用戶方式(usermode)或內(nèi)核方式(kernelmode)下。用戶程序運(yùn)行在用戶方式下,而系統(tǒng)調(diào)用運(yùn)行在內(nèi)核方式下。在這兩種方式下所用的堆棧不一樣:用戶方式下用的是一般的堆棧,而內(nèi)核方式下用的是固定大小的堆棧(一般為一個(gè)內(nèi)存頁的大?。?,每個(gè)進(jìn)程都有自己的 3 G 用戶空間,它們共享1GB的內(nèi)核空間。當(dāng)一個(gè)進(jìn)程從用戶空間進(jìn)入內(nèi)核空間時(shí),它就不再有自己的進(jìn)程空間了。這也就是為什么我們經(jīng)常說線程上下文切換會涉及到用戶態(tài)到內(nèi)核態(tài)的切換原因所在。

2.1、用戶線程

指不需要內(nèi)核支持而在用戶程序中實(shí)現(xiàn)的線程,其不依賴于操作系統(tǒng)核心,應(yīng)用進(jìn)程利用線程庫提供創(chuàng)建、同步、調(diào)度和管理線程的函數(shù)來控制用戶線程。另外,用戶線程是由應(yīng)用進(jìn)程利用線程庫創(chuàng)建和管理,不依賴于操作系統(tǒng)核心。不需要用戶態(tài)/核心態(tài)切換,速度快。操作系統(tǒng)內(nèi)核不知道多線程的存在,因此一個(gè)線程阻塞將使得整個(gè)進(jìn)程(包括它的所有線程)阻塞。由于這里的處理器時(shí)間片分配是以進(jìn)程為基本單位,所以每個(gè)線程執(zhí)行的時(shí)間相對減少。

2.2、內(nèi)核線程

線程的所有管理操作都是由操作系統(tǒng)內(nèi)核完成的。內(nèi)核保存線程的狀態(tài)和上下文信息,當(dāng)一個(gè)線程執(zhí)行了引起阻塞的系統(tǒng)調(diào)用時(shí),內(nèi)核可以調(diào)度該進(jìn)程的其他線程執(zhí)行。在多處理器系統(tǒng)上,內(nèi)核可以分派屬于同一進(jìn)程的多個(gè)線程在多個(gè)處理器上運(yùn)行,提高進(jìn)程執(zhí)行的并行度。由于需要內(nèi)核完成線程的創(chuàng)建、調(diào)度和管理,所以和用戶級線程相比這些操作要慢得多,但是仍然比進(jìn)程的創(chuàng)建和管理操作要快。大多數(shù)市場上的操作系統(tǒng),如windows,Linux等都支持內(nèi)核級線程。

原理區(qū)別如下圖所示:

多線程編程?聊聊并發(fā)的背后知識

 

2.3、Java線程與系統(tǒng)內(nèi)核線程關(guān)系

Java目前創(chuàng)建線程會直接申請內(nèi)核空間的內(nèi)核線程,所以線程的創(chuàng)建、切換、調(diào)度都得經(jīng)過內(nèi)核空間

多線程編程?聊聊并發(fā)的背后知識

 

2.4、Java線程的生命周期

多線程編程?聊聊并發(fā)的背后知識

 

Java中線程有六種狀態(tài),6種狀態(tài)切換圖上圖所示,下面我們詳細(xì)講講六種狀態(tài):

  1. NEW : 表示線程被創(chuàng)建但尚未啟動的狀態(tài):當(dāng)我們用 new Thread() 新建一個(gè)線程時(shí),如果線程沒有開始運(yùn)行 start() 方法,那么線程也就沒有開始執(zhí)行 run() 方法里面的代碼,那么此時(shí)它的狀態(tài)就是 New。而一旦線程調(diào)用了 start(),它的狀態(tài)就會從 New 變成 Runnable。
  2. RUNNABLE : Java 中的 Runnable 狀態(tài)對應(yīng)操作系統(tǒng)線程狀態(tài)中的兩種狀態(tài),分別是 RUNNING 和 READY,也就是說,Java 中處于 Runnable 狀態(tài)的線程有可能正在執(zhí)行,也有可能沒有正在執(zhí)行,正在等待被分配 CPU 資源。 RUNNING:正在被執(zhí)行的狀態(tài) READY:已經(jīng)準(zhǔn)備就緒,等待被執(zhí)行的狀態(tài)
  3. BLOCKED:這是一個(gè)相對簡單的狀態(tài),我們可以通過下面的圖示看到,從 RUNNABLE 狀態(tài)進(jìn)入到 BLOCKED 狀態(tài)只有一種途徑,那么就是當(dāng)進(jìn)入到 synchronized 代碼塊中時(shí)未能獲得相應(yīng)的 monitor 鎖,為 BLOCKED 狀態(tài);當(dāng)獲取到 monitor 鎖后,此時(shí)線程就會進(jìn)入 RUNNABLE 狀體中參與 CPU 資源的搶奪。
  4. WAITING:對于 WAITING 狀態(tài)的線程進(jìn)入有四種情況,如上圖中所示,分別為: 當(dāng)線程中調(diào)用了沒有設(shè)置 Timeout 參數(shù)的 Object.wait() 方法 當(dāng)線程調(diào)用了沒有設(shè)置 Timeout 參數(shù)的 Thread.join() 方法 當(dāng)線程調(diào)用了 LockSupport.park() 方法 當(dāng)線程調(diào)用了 Lock.lock() 方法
  5. TIMED WAITING:它與 WAITING 狀態(tài)非常相似,其中的區(qū)別只在于是否有時(shí)間的限制,在 TIMED WAITING 狀態(tài)時(shí)會等待超時(shí),之后由系統(tǒng)喚醒,或者也可以提前被通知喚醒如 notify。
  6. TERMINATED:線程執(zhí)行完畢,或者線程拋出未捕獲異常提前終止。

三、并發(fā)編程

3.1、為什么要用并發(fā)

并發(fā)編程的本質(zhì)其實(shí)就是利用多線程技術(shù),在現(xiàn)代多核的CPU的背景下,催生了并發(fā)編程的趨勢,通過并發(fā)編程的形式可以將多核CPU的計(jì)算能力發(fā)揮到極致,性能得到提升。除此之外,面對復(fù)雜業(yè)務(wù)模型,并行程序會比串行程序更適應(yīng)業(yè)務(wù)需求,而并發(fā)編程更能吻合這種業(yè)務(wù)拆分 。即使是單核處理器也支持多線程執(zhí)行代碼,CPU通過給每個(gè)線程分配CPU時(shí)間片來實(shí)現(xiàn)這個(gè)機(jī)制。時(shí)間片是CPU分配給各個(gè)線程的時(shí)間,因?yàn)闀r(shí)間片非常短,所以CPU通過不停地切換線程執(zhí)行,讓我們感覺多個(gè)線程是同時(shí)執(zhí)行的,時(shí)間片一般是幾十毫秒(ms)。并發(fā)不等于并行:并發(fā)指的是多個(gè)任務(wù)交替進(jìn)行,而并行則是指真正意義上的“同時(shí)進(jìn)行”。實(shí)際上,如果系統(tǒng)內(nèi)只有一個(gè)CPU,而使用多線程時(shí),那么真實(shí)系統(tǒng)環(huán)境下不能并行,只能通過切換時(shí)間片的方式交替進(jìn)行,而成為并發(fā)執(zhí)行任務(wù)。真正的并行也只能出現(xiàn)在擁有多 個(gè)CPU的系統(tǒng)中。

3.2、并發(fā)的優(yōu)點(diǎn)

  1. 充分利用多核CPU的計(jì)算能力;
  2. 方便進(jìn)行業(yè)務(wù)拆分,提升應(yīng)用性能。

3.3、并發(fā)產(chǎn)生的問題

  1. 高并發(fā)場景下,導(dǎo)致頻繁的上下文切換
  2. 臨界區(qū)線程安全問題,容易出現(xiàn)死鎖的,產(chǎn)生死鎖就會造成系統(tǒng)功能不可用

CPU通過時(shí)間片分配算法來循環(huán)執(zhí)行任務(wù),當(dāng)前任務(wù)執(zhí)行一個(gè)時(shí)間片后會切換到下一個(gè)任務(wù)。但是,在切換前會保存上一個(gè)任務(wù)的狀態(tài),以便下次切換回這個(gè)任務(wù)時(shí),可以再加載這個(gè)任務(wù)的狀態(tài)。所以任務(wù)從保存到再加載的過程就是一次上下文切換。

多線程編程?聊聊并發(fā)的背后知識

 

四、JMM模型

4.1、什么是JMM模型

Java內(nèi)存模型(Java Memory Model簡稱JMM)是一種抽象的概念,并不真實(shí)存在,它描述的是一組規(guī)則或規(guī)范,通過這組規(guī)范定義了程序中各個(gè)變量(包括實(shí)例字段,靜態(tài)字段和構(gòu)成數(shù)組對象的元素)的訪問方式。JVM運(yùn)行程序的實(shí)體是線程,而每個(gè)線程創(chuàng)建時(shí)JVM都會為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為??臻g),用于存儲線程私有的數(shù)據(jù),而Java內(nèi)存模型中規(guī)定所有變量都存儲在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內(nèi)存中進(jìn)行,首先要將變量從主內(nèi)存拷貝的自己的工作內(nèi)存空間,然后對變量進(jìn)行操作,操作完成后再將變量寫回主內(nèi)存,不能直接操作主內(nèi)存中的變量,工作內(nèi)存中存儲著主內(nèi)存中的變量副本拷貝,前面說過,工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù)區(qū)域,因此不同的線程間無法訪問對方的工作內(nèi)存,線程間的通信(傳值)必須通過主內(nèi)存來完成。

線程,工作內(nèi)存,主內(nèi)存工作交互圖(基于JMM規(guī)范):

多線程編程?聊聊并發(fā)的背后知識

 

主內(nèi)存

主要存儲的是Java實(shí)例對象,所有線程創(chuàng)建的實(shí)例對象都存放在主內(nèi)存中,不管該實(shí)例對象是成員變量還是方法中的本地變量(也稱局部變量),當(dāng)然也包括了共享的類信息、常量、靜態(tài)變量。由于是共享數(shù)據(jù)區(qū)域,多條線程對同一個(gè)變量進(jìn)行訪問可能會發(fā)生線程安全問題。

工作內(nèi)存

主要存儲當(dāng)前方法的所有本地變量信息(工作內(nèi)存中存儲著主內(nèi)存中的變量副本拷貝),每個(gè)線程只能訪問自己的工作內(nèi)存,即線程中的本地變量對其它線程是不可見的,就算是兩個(gè)線程執(zhí)行的是同一段代碼,它們也會各自在自己的工作內(nèi)存中創(chuàng)建屬于當(dāng)前線程的本地變量,當(dāng)然也包括了字節(jié)碼行號指示器、相關(guān)Native方法的信息。注意由于工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù),線程間無法相互訪問工作內(nèi)存,因此存儲在工作內(nèi)存的數(shù)據(jù)不存在線程安全問題。

根據(jù)JVM虛擬機(jī)規(guī)范主內(nèi)存與工作內(nèi)存的數(shù)據(jù)存儲類型以及操作方式,對于一個(gè)實(shí)例對象中的成員方法而言,如果方法中包含本地變量是基本數(shù)據(jù)類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工作內(nèi)存的幀棧結(jié)構(gòu)中,但倘若本地變量是引用類型,那么該變量的引用會存儲在功能內(nèi)存的幀棧中,而對象實(shí)例將存儲在主內(nèi)存(共享數(shù)據(jù)區(qū)域,堆)中。但對于實(shí)例對象的成員變量,不管它是基本數(shù)據(jù)類型或者包裝類型(Integer、Double等)還是引用類型,都會被存儲到堆區(qū)。至于static變量以及類本身相關(guān)信息將會存儲在主內(nèi)存中。需要注意的是,在主內(nèi)存中的實(shí)例對象可以被多線程共享,倘若兩個(gè)線程同時(shí)調(diào)用了同一個(gè)對象的同一個(gè)方法,那么兩條線程會將要操作的數(shù)據(jù)拷貝一份到自己的工作內(nèi)存中,執(zhí)行完成操作后才刷新到主內(nèi)存模型如下圖所示:

多線程編程?聊聊并發(fā)的背后知識

 

Java內(nèi)存模型與硬件內(nèi)存架構(gòu)的關(guān)系

通過對前面的硬件內(nèi)存架構(gòu)、Java內(nèi)存模型以及Java多線程的實(shí)現(xiàn)原理的了解,我們應(yīng)該已經(jīng)意識到,多線程的執(zhí)行最終都會映射到硬件處理器上進(jìn)行執(zhí)行,但Java內(nèi)存模型和硬件內(nèi)存架構(gòu)并不完全一致。對于硬件內(nèi)存來說只有寄存器、緩存內(nèi)存、主內(nèi)存的概念,并沒有工作內(nèi)存(線程私有數(shù)據(jù)區(qū)域)和主內(nèi)存(堆內(nèi)存)之分,也就是說Java內(nèi)存模型對內(nèi)存的劃分對硬件內(nèi)存并沒有任何影響,因?yàn)镴MM只是一種抽象的概念,是一組規(guī)則,并不實(shí)際存在,不管是工作內(nèi)存的數(shù)據(jù)還是主內(nèi)存的數(shù)據(jù),對于計(jì)算機(jī)硬件來說都會存儲在計(jì)算機(jī)主內(nèi)存中,當(dāng)然也有可能存儲到CPU緩存或者寄存器中,因此總體上來說,Java內(nèi)存模型和計(jì)算機(jī)硬件內(nèi)存架構(gòu)是一個(gè)相互交叉的關(guān)系,是一種抽象概念劃分與真實(shí)物理硬件的交叉。(注意對于Java內(nèi)存區(qū)域劃分也是同樣的道理)

多線程編程?聊聊并發(fā)的背后知識

 

4.2、JMM存在的必要性

在明白了Java內(nèi)存區(qū)域劃分、硬件內(nèi)存架構(gòu)、Java多線程的實(shí)現(xiàn)原理與Java內(nèi)存模型的具體關(guān)系后,接著來談?wù)凧ava內(nèi)存模型存在的必要性。由于JVM運(yùn)行程序的實(shí)體是線程,而每個(gè)線程創(chuàng)建時(shí)JVM都會為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為棧空間),用于存儲線程私有的數(shù)據(jù),線程與主內(nèi)存中的變量操作必須通過工作內(nèi)存間接完成,主要過程是將變量從主內(nèi)存拷貝的每個(gè)線程各自的工作內(nèi)存空間,然后對變量進(jìn)行操作,操作完成后再將變量寫回主內(nèi)存,如果存在兩個(gè)線程同時(shí)對一個(gè)主內(nèi)存中的實(shí)例對象的變量進(jìn)行操作就有可能誘發(fā)線程安全問題。

假設(shè)主內(nèi)存中存在一個(gè)共享變量x,現(xiàn)在有A和B兩條線程分別對該變量x=1進(jìn)行操作,A/B線程各自的工作內(nèi)存中存在共享變量副本x。假設(shè)現(xiàn)在A線程想要修改x的值為2,而B線程卻想要讀取x的值,那么B線程讀取到的值是A線程更新后的值2還是更新前的值1呢?答案是,不確定,即B線程有可能讀取到A線程更新前的值1,也有可能讀取到A線程更新后的值2,這是因?yàn)楣ぷ鲀?nèi)存是每個(gè)線程私有的數(shù)據(jù)區(qū)域,而線程A變量x時(shí),首先是將變量從主內(nèi)存拷貝到A線程的工作內(nèi)存中,然后對變量進(jìn)行操作,操作完成后再將變量x寫回主內(nèi),而對于B線程的也是類似的,這樣就有可能造成主內(nèi)存與工作內(nèi)存間數(shù)據(jù)存在一致性問題,假如A線程修改完后正在將數(shù)據(jù)寫回主內(nèi)存,而B線程此時(shí)正在讀取主內(nèi)存,即將x=1拷貝到自己的工作內(nèi)存中,這樣B線程讀取到的值就是x=1,但如果A線程已將x=2寫回主內(nèi)存后,B線程才開始讀取的話,那么此時(shí)B線程讀取到的就是x=2,但到底是哪種情況先發(fā)生呢?

如以下示例圖所示案例:

多線程編程?聊聊并發(fā)的背后知識

 

以上關(guān)于主內(nèi)存與工作內(nèi)存之間的具體交互協(xié)議,即一個(gè)變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步到主內(nèi)存之間的實(shí)現(xiàn)細(xì)節(jié),Java內(nèi)存模型定義了以下八種操作來完成。

4.3、JMM-同步八種操作介紹

  1. lock(鎖定):作用于主內(nèi)存的變量,把一個(gè)變量標(biāo)記為一條線程獨(dú)占狀態(tài)
  2. unlock(解鎖):作用于主內(nèi)存的變量,把一個(gè)處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定
  3. read(讀取):作用于主內(nèi)存的變量,把一個(gè)變量值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用
  4. load(載入):作用于工作內(nèi)存的變量,它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中
  5. use(使用):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個(gè)變量值傳遞給執(zhí)行引擎
  6. assign(賦值):作用于工作內(nèi)存的變量,它把一個(gè)從執(zhí)行引擎接收到的值賦給工作內(nèi)存的變量
  7. store(存儲):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個(gè)變量的值傳送到主內(nèi)存中,以便隨后的write的操作
  8. write(寫入):作用于工作內(nèi)存的變量,它把store操作從工作內(nèi)存中的一個(gè)變量的值傳送到主內(nèi)存的變量中

如果要把一個(gè)變量從主內(nèi)存中復(fù)制到工作內(nèi)存中,就需要按順序地執(zhí)行read和load操作,如果把變量從工作內(nèi)存中同步到主內(nèi)存中,就需要按順序地執(zhí)行store和write操作。但Java內(nèi)存模型只要求上述操作必須按順序執(zhí)行,而沒有保證必須是連續(xù)執(zhí)行。

多線程編程?聊聊并發(fā)的背后知識

 

同步規(guī)則分析

  1. 不允許一個(gè)線程無原因地(沒有發(fā)生過任何assign操作)把數(shù)據(jù)從工作內(nèi)存同步回主內(nèi)存中
  2. 一個(gè)新的變量只能在主內(nèi)存中誕生,不允許在工作內(nèi)存中直接使用一個(gè)未被初始化(load或者assign)的變量。即就是對一個(gè)變量實(shí)施use和store操作之前,必須先自行assign和load操作。
  3. 一個(gè)變量在同一時(shí)刻只允許一條線程對其進(jìn)行l(wèi)ock操作,但lock操作可以被同一線程重復(fù)執(zhí)行多次,多次執(zhí)行l(wèi)ock后,只有執(zhí)行相同次數(shù)的unlock操作,變量才會被解鎖。lock和unlock必須成對出現(xiàn)。
  4. 如果對一個(gè)變量執(zhí)行l(wèi)ock操作,將會清空工作內(nèi)存中此變量的值,在執(zhí)行引擎使用這個(gè)變量之前需要重新執(zhí)行l(wèi)oad或assign操作初始化變量的值。
  5. 如果一個(gè)變量事先沒有被lock操作鎖定,則不允許對它執(zhí)行unlock操作;也不允許去unlock一個(gè)被其他線程鎖定的變量。
  6. 對一個(gè)變量執(zhí)行unlock操作之前,必須先把此變量同步到主內(nèi)存中(執(zhí)行store和write操作)

4.4、并發(fā)編程三要素

原子性

原子性指的是一個(gè)操作是不可中斷的,即使是在多線程環(huán)境下,一個(gè)操作一旦開始就不會被其他線程影響。

在java中,對基本數(shù)據(jù)類型的變量的讀取和賦值操作是原子性操作有點(diǎn)要注意的是,對于32位系統(tǒng)的來說,long類型數(shù)據(jù)和double類型數(shù)據(jù)(對于基本數(shù)據(jù)類型,byte,short,int,float,boolean,char讀寫是原子操作),它們的讀寫并非原子性的,也就是說如果存在兩條線程同時(shí)對long類型或者double類型的數(shù)據(jù)進(jìn)行讀寫是存在相互干擾的,因?yàn)閷τ?2位虛擬機(jī)來說,每次原子讀寫是32位的,而long和double則是64位的存儲單元,這樣會導(dǎo)致一個(gè)線程在寫時(shí),操作完前32位的原子操作后,輪到B線程讀取時(shí),恰好只讀取到了后32位的數(shù)據(jù),這樣可能會讀取到一個(gè)既非原值又不是線程修改值的變量,它可能是“半個(gè)變量”的數(shù)值,即64位數(shù)據(jù)被兩個(gè)線程分成了兩次讀取。但也不必太擔(dān)心,因?yàn)樽x取到“半個(gè)變量”的情況比較少見,至少在目前的商用的虛擬機(jī)中,幾乎都把64位的數(shù)據(jù)的讀寫操作作為原子操作來執(zhí)行,因此對于這個(gè)問題不必太在意。

  • X=10:原子性(簡單的讀取、將數(shù)字賦值給變量)
  • Y = x:變量之間的相互賦值,不是原子操作
  • X++:對變量進(jìn)行計(jì)算操作,不是原子性操作
  • X = x+1:對變量進(jìn)行計(jì)算并賦值操作,不是原子性操作

可見性

理解了指令重排現(xiàn)象后,可見性容易了,可見性指的是當(dāng)一個(gè)線程修改了某個(gè)共享變量的值,其他線程是否能夠馬上得知這個(gè)修改的值。對于串行程序來說,可見性是不存在的,因?yàn)槲覀冊谌魏我粋€(gè)操作中修改了某個(gè)變量的值,后續(xù)的操作中都能讀取這個(gè)變量值,并且是修改過的新值。

但在多線程環(huán)境中可就不一定了,前面我們分析過,由于線程對共享變量的操作都是線程拷貝到各自的工作內(nèi)存進(jìn)行操作后才寫回到主內(nèi)存中的,這就可能存在一個(gè)線程A修改了共享變量x的值,還未寫回主內(nèi)存時(shí),另外一個(gè)線程B又對主內(nèi)存中同一個(gè)共享變量x進(jìn)行操作,但此時(shí)A線程工作內(nèi)存中共享變量x對線程B來說并不可見,這種工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象就造成了可見性問題,另外指令重排以及編譯器優(yōu)化也可能導(dǎo)致可見性問題,通過前面的分析,我們知道無論是編譯器優(yōu)化還是處理器優(yōu)化的重排現(xiàn)象,在多線程環(huán)境下,確實(shí)會導(dǎo)致程序輪序執(zhí)行的問題,從而也就導(dǎo)致可見性問題。

有序性

有序性是指對于單線程的執(zhí)行代碼,我們總是認(rèn)為代碼的執(zhí)行是按順序依次執(zhí)行的,這樣的理解并沒有毛病,畢竟對于單線程而言確實(shí)如此,但對于多線程環(huán)境,則可能出現(xiàn)亂序現(xiàn)象,因?yàn)槌绦蚓幾g成機(jī)器碼指令后可能會出現(xiàn)指令重排現(xiàn)象,重排后的指令與原指令的順序未必一致,要明白的是,在Java程序中,倘若在本線程內(nèi),所有操作都視為有序行為,如果是多線程環(huán)境下,一個(gè)線程中觀察另外一個(gè)線程,所有操作都是無序的,前半句指的是單線程內(nèi)保證串行語義執(zhí)行的一致性,后半句則指指令重排現(xiàn)象和工作內(nèi)存與主內(nèi)存同步延遲現(xiàn)象。

五、Java如何保證并發(fā)編程三要素

5.1、原子性問題

除了JVM自身提供的對基本數(shù)據(jù)類型讀寫操作的原子性外,可以通過 synchronized和Lock實(shí)現(xiàn)原子性。因?yàn)閟ynchronized和Lock能夠保證任一時(shí)刻只有一個(gè)線程訪問該代碼塊。

5.2、可見性問題

volatile關(guān)鍵字保證可見性。當(dāng)一個(gè)共享變量被volatile修飾時(shí),它會保證修改的值立即被其他的線程看到,即修改的值立即更新到主存中,當(dāng)其他線程需要讀取時(shí),它會去內(nèi)存中讀取新值。synchronized和Lock也可以保證可見性,因?yàn)樗鼈兛梢员WC任一時(shí)刻只有一個(gè)線程能訪問共享資源,并在其釋放鎖之前將修改的變量刷新到內(nèi)存中。

5.3、有序性問題

在Java里面,可以通過volatile關(guān)鍵字來保證一定的“有序性”,另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個(gè)時(shí)刻是有一個(gè)線程執(zhí)行同步代碼,相當(dāng)于是讓線程順序執(zhí)行同步代碼,自然就保證了有序性。

Java內(nèi)存模型

每個(gè)線程都有自己的工作內(nèi)存(類似于前面的高速緩存)。線程對變量的所有操作都必須在工作內(nèi)存中進(jìn)行,而不能直接對主存進(jìn)行操作。并且每個(gè)線程不能訪問其他線程的工作內(nèi)存。Java內(nèi)存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個(gè)通常也稱為hAppens-before 原則。如果兩個(gè)操作的執(zhí)行次序無法從happens-before原則推導(dǎo)出來,那么它們就不能保證它們的有序性,虛擬機(jī)可以隨意地對它們進(jìn)行重排序。

指令重排序

java語言規(guī)范規(guī)定JVM線程內(nèi)部維持順序化語義。即只要程序的最終結(jié)果與它順序化情況的結(jié)果相等,那么指令的執(zhí)行順序可以與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什么?JVM能根據(jù)處理器特性(CPU多級緩存系統(tǒng)、多核處理器等)適當(dāng)?shù)膶C(jī)器指令進(jìn)行重排序,使機(jī)器指令能更符合CPU的執(zhí)行特性,最大限度的發(fā)揮機(jī)器性能。

下圖為從源碼到最終執(zhí)行的指令序列示意圖

多線程編程?聊聊并發(fā)的背后知識

 

as-if-serial語義

as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結(jié)果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關(guān)系的操作做重排序,因?yàn)檫@種重排序會改變執(zhí)行結(jié)果。但是,如果操作之間不存在數(shù)據(jù)依賴關(guān)系,這些操作就可能被編譯器和處理器重排序。

happens-before 原則

只靠sychronized和volatile關(guān)鍵字來保證原子性、可見性以及有序性,那么編寫并發(fā)程序可能會顯得十分麻煩,幸運(yùn)的是,從JDK 5開始,Java使用新的JSR-133內(nèi)存模型,提供了 happens-before 原則來輔助保證程序執(zhí)行的原子性、可見性以及有序性的問題,它是判斷數(shù)據(jù)是否存在競爭、線程是否安全的依據(jù),happens-before 原則內(nèi)容如下:

  1. 程序順序原則:即在一個(gè)線程內(nèi)必須保證語義串行性,也就是說按照代碼順序執(zhí)行。
  2. 鎖規(guī)則:解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個(gè)鎖的加鎖(lock)之前,也就是說,如果對于一個(gè)鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個(gè)鎖)。
  3. volatile規(guī)則:volatile變量的寫,先發(fā)生于讀,這保證了volatile變量的可見性,簡單的理解就是,volatile變量在每次被線程訪問時(shí),都強(qiáng)迫從主內(nèi)存中讀該變量的值,而當(dāng)該變量發(fā)生變化時(shí),又會強(qiáng)迫將最新的值刷新到主內(nèi)存,任何時(shí)刻,不同的線程總是能夠看到該變量的最新值。
  4. 線程啟動規(guī)則:線程的start()方法先于它的每一個(gè)動作,即如果線程A在執(zhí)行線程B的start方法之前修改了共享變量的值,那么當(dāng)線程B執(zhí)行start方法時(shí),線程A對共享變量的修改對線程B可見
  5. 傳遞性:A先于B,B先于C 那么A必然先于C線程終止規(guī)則 線程的所有操作先于線程的終結(jié),Thread.join()方法的作用是等待當(dāng)前執(zhí)行的線程終止。假設(shè)在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回后,線程B對共享變量的修改將對線程A可見。
  6. 線程中斷規(guī)則:對線程 interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通過Thread.interrupted()方法檢測線程是否中斷。
  7. 對象終結(jié)規(guī)則:對象的構(gòu)造函數(shù)執(zhí)行,結(jié)束先于finalize()方法

5.4、volatile

volatile是Java虛擬機(jī)提供的輕量級的同步機(jī)制,volatile關(guān)鍵字有如下兩個(gè)作用:

  1. 保證被volatile修飾的共享變量對所有線程總數(shù)可見的,也就是當(dāng)一個(gè)線程修改了一個(gè)被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。
  2. 禁止指令重排序優(yōu)化。

volatile保證可見性

關(guān)于volatile的可見性作用,我們必須意識到被volatile修飾的變量對所有線程總是立即可見的,對volatile變量的所有寫操作總是能立刻反應(yīng)到其他線程中。

示例1:running變量不加volatile,main線程改變r(jià)unning屬性之后,線程Thread1感知不到。

public class VolatileDemo1 {
    private static boolean running = true;

    private static class Thread1 extends Thread {
        @Override
        public void run() {
            System.out.println("Thread1 start!");
            while (running) {

            }
            System.out.println("Thread1 end!");
        }
    }

    public static void main(String[] args) throws IOException, InterruptedException {
        System.out.println("main method is start!");
        // 啟動線程 thread1
        Thread1 thread1 = new Thread1();
        thread1.start();

        // 主線程暫停2秒,確保thread1已經(jīng)啟動
        TimeUnit.SECONDS.sleep(2);

        running = false;

        // 主線程暫停5秒,為了更直觀的觀察 thread1 狀態(tài)
        TimeUnit.SECONDS.sleep(5);
        System.out.println("main method is end!");
    }
}

執(zhí)行結(jié)果如下,可以看到 Thread1 線程始終沒有結(jié)束,證明Thread1沒有感知到running的改變:

main method is start!
Thread1 start!
main method is end!

示例2:running變量加上volatile,main線程改變r(jià)unning屬性之后,線程Thread1會立即感知。

public class VolatileDemo1 {
    private static volatile boolean running = true;

    private static class Thread1 extends Thread {
        @Override
        public void run() {
            System.out.println("Thread1 start!");
            while (running) {

            }
            System.out.println("Thread1 end!");
        }
    }

    public static void main(String[] args) throws IOException, InterruptedException {
        System.out.println("main method is start!");

        // 啟動線程 thread1
        Thread1 thread1 = new Thread1();
        thread1.start();

        // 主線程暫停2秒,確保thread1已經(jīng)啟動
        TimeUnit.SECONDS.sleep(2);

        running = false;

        // 主線程暫停5秒,為了更直觀的觀察 thread1 狀態(tài)
        TimeUnit.SECONDS.sleep(5);
        System.out.println("main method is end!");
    }
}

執(zhí)行結(jié)果如下,main線程改變r(jià)unning屬性之后,線程Thread1立即終止。

main method is start!
Thread1 start!
Thread1 end!
main method is end!

volatile無法保證原子性

示例:創(chuàng)建100個(gè)線程,每個(gè)線程執(zhí)行 1000000 次 n++ 操作,n被volatile修飾

public class VolatileDemo2 {
    private static volatile long n = 0L;

    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[100];
        CountDownLatch latch = new CountDownLatch(threads.length);

        // 創(chuàng)建100個(gè)線程,每個(gè)線程執(zhí)行 1000000 次 n++ 操作
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000000; j++) {
                    n++;
                }
                latch.countDown();
            });
        }

        // 啟動100個(gè)線程
        for (Thread t : threads) {
            t.start();
        }

        // 等待所有線程執(zhí)行結(jié)束,并輸出n的值
        latch.await();
        System.out.println(n);
    }
}

輸出結(jié)果如下,n應(yīng)該是100000000,輸出的結(jié)果和預(yù)期值相差甚遠(yuǎn):

4120418

在并發(fā)場景下,n變量的任何改變都會立馬反應(yīng)到其他線程中,但是如此存在多條線程同時(shí)對n進(jìn)行n++操作,就會出現(xiàn)線程安全問題,畢竟n++操作并不具備原子性,該操作是先讀取值,然后寫回一個(gè)新值,相當(dāng)于原來的值加上1,分兩步完成:

step1:n = 1,線程1 和 線程2 同時(shí)讀取了n;

step2:線程1 和 線程2 都已經(jīng)計(jì)算完 n++,此時(shí)線程1 將 n = 2 寫回主存;

step3:Volatile保證可見性的情況下,線程2感知到n值已經(jīng)發(fā)生改動,需要重新讀取主存里的n值,因此本次 線程2 的 n++ 操作被廢棄。

這也就造成了線程不安全,因此對于n++操作必須使 用synchronized修飾,以便保證線程安全,需要注意的是一旦使用synchronized修飾方法后,由于synchronized本身也具備與volatile相同的特性,即可見性,因此在這樣種情況下就完全可以省去volatile修飾變量。

volatile禁止指令重排

volatile關(guān)鍵字另一個(gè)作用就是禁止指令重排優(yōu)化,從而避免多線程環(huán)境下程序出現(xiàn)亂序執(zhí)行的現(xiàn)象,關(guān)于指令重排優(yōu)化前面已詳細(xì)分析過,這里主要簡單說明一下volatile是如何實(shí)現(xiàn)禁止指令重排優(yōu)化的。先了解一個(gè)概念,內(nèi)存屏障(Memory Barrier)。內(nèi)存屏障,又稱內(nèi)存柵欄,是一個(gè)CPU指令,它的作用有兩個(gè),一是保證特定操作的執(zhí)行順序,二是保證某些變量的內(nèi)存可見性(利用該特性實(shí)現(xiàn)volatile的內(nèi)存可見性)。由于編譯器和處理器都能執(zhí)行指令重排優(yōu)化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說通過插入內(nèi)存屏障禁止在內(nèi)存屏障前后的指令執(zhí)行重排序優(yōu)化。Memory Barrier的另外一個(gè)作用是強(qiáng)制刷出各種CPU的緩存數(shù)據(jù),因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本。總之,volatile變量正是通過內(nèi)存屏障實(shí)現(xiàn)其在內(nèi)存中的語義,即可見性和禁止重排優(yōu)化。下面看一個(gè)非常典型的禁止重排優(yōu)化的例子DCL,如下:

public class VolatileReOrderSample {
    private static int a = 0, b = 0, x = 0, y = 0;
    private static long i = 0;

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            i++;
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            CountDownLatch countDownLatch = new CountDownLatch(2);
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 等待100ms,確保兩個(gè)線程公平競爭CPU資源
                        TimeUnit.MICROSECONDS.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    a = 1;
                    x = b;

                    countDownLatch.countDown();
                }
            });

            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 等待100ms,確保兩個(gè)線程公平競爭CPU資源
                        TimeUnit.MICROSECONDS.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    b = 1;
                    y = a;

                    countDownLatch.countDown();
                }
            });

            thread1.start();
            thread2.start();
            countDownLatch.await();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            System.out.println(result);

            // 如果出現(xiàn) x == 0 && y == 0,終止程序
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

上述代碼是經(jīng)典的驗(yàn)證指令重排的代碼,我們先來分析下,正常邏輯下,這段代碼輸出結(jié)果會出現(xiàn)哪些情況呢,如下圖,在不發(fā)生指令重排的情況下,無論如何也不會出現(xiàn) x = 0 并且 y = 0 的結(jié)果。

多線程編程?聊聊并發(fā)的背后知識

 

我們看下執(zhí)行結(jié)果,竟然出現(xiàn)了 x = 0 并且 y = 0 的結(jié)果:

多線程編程?聊聊并發(fā)的背后知識

 

那么什么情況下才會出現(xiàn) x = 0 并且 y = 0 的情況,只有當(dāng)執(zhí)行發(fā)生指令重排的時(shí)候,才可能出現(xiàn)這種情況,如下圖所示:

多線程編程?聊聊并發(fā)的背后知識

 

上述代碼是經(jīng)典的驗(yàn)證指令重排的代碼,我們也看到了執(zhí)行結(jié)果,顯而易見會出現(xiàn)指令重排,從而導(dǎo)致線程不安全,當(dāng)然解決方法就是加Volatile,或者加鎖。

volatile內(nèi)存語義的實(shí)現(xiàn)

前面提到過重排序分為編譯器重排序和處理器重排序。為了實(shí)現(xiàn)volatile內(nèi)存語義,JMM會分別限制這兩種類型的重排序類型,下圖是JMM針對編譯器制定的volatile重排序規(guī)則表。

多線程編程?聊聊并發(fā)的背后知識

 

舉例來說,第三行最后一個(gè)單元格的意思是:在程序中,當(dāng)?shù)谝粋€(gè)操作為普通變量的讀或?qū)憰r(shí),如果第二個(gè)操作為volatile寫,則編譯器不能重排序這兩個(gè)操作。

從上圖可以看出:當(dāng)?shù)诙€(gè)操作是volatile寫時(shí),不管第一個(gè)操作是什么,都不能重排序。這個(gè)規(guī)則確保 volatile寫之前的操作不會被編譯器重排序到volatile寫之后。

  • 當(dāng)?shù)谝粋€(gè)操作是volatile讀時(shí),不管第二個(gè)操作是什么,都不能重排序。這個(gè)規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
  • 當(dāng)?shù)谝粋€(gè)操作是volatile寫,第二個(gè)操作是volatile讀時(shí),不能重排序。

為了實(shí)現(xiàn)volatile的內(nèi)存語義,編譯器在生成字節(jié)碼時(shí),會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個(gè)最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內(nèi)存屏障插入策略。

  • 在每個(gè)volatile寫操作的前面插入一個(gè)StoreStore屏障。
  • 在每個(gè)volatile寫操作的后面插入一個(gè)StoreLoad屏障。
  • 在每個(gè)volatile讀操作的后面插入一個(gè)LoadLoad屏障。
  • 在每個(gè)volatile讀操作的后面插入一個(gè)LoadStore屏障。

上述內(nèi)存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內(nèi)存語義。 下面是保守策略下,volatile寫插入內(nèi)存屏障后生成的指令序列示意圖:

多線程編程?聊聊并發(fā)的背后知識

 

上圖中StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經(jīng)對任意處理器可見了。這是因?yàn)镾toreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內(nèi)存。

這里比較有意思的是,volatile寫后面的StoreLoad屏障。此屏障的作用是避免volatile寫與 后面可能有的volatile讀/寫操作重排序。因?yàn)榫幾g器常常無法準(zhǔn)確判斷在一個(gè)volatile寫的后面 是否需要插入一個(gè)StoreLoad屏障(比如,一個(gè)volatile寫之后方法立即return)。為了保證能正確 實(shí)現(xiàn)volatile的內(nèi)存語義,JMM在采取了保守策略:在每個(gè)volatile寫的后面,或者在每個(gè)volatile 讀的前面插入一個(gè)StoreLoad屏障。從整體執(zhí)行效率的角度考慮,JMM最終選擇了在每個(gè) volatile寫的后面插入一個(gè)StoreLoad屏障。因?yàn)関olatile寫-讀內(nèi)存語義的常見使用模式是:一個(gè) 寫線程寫volatile變量,多個(gè)讀線程讀同一個(gè)volatile變量。當(dāng)讀線程的數(shù)量大大超過寫線程時(shí),選擇在volatile寫之后插入StoreLoad屏障將帶來可觀的執(zhí)行效率的提升。從這里可以看到JMM 在實(shí)現(xiàn)上的一個(gè)特點(diǎn):首先確保正確性,然后再去追求執(zhí)行效率。

下圖是在保守策略下,volatile讀插入內(nèi)存屏障后生成的指令序列示意圖:

多線程編程?聊聊并發(fā)的背后知識

 

上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。上述volatile寫和volatile讀的內(nèi)存屏障插入策略非常保守。在實(shí)際執(zhí)行時(shí),只要不改變 volatile寫-讀的內(nèi)存語義,編譯器可以根據(jù)具體情況省略不必要的屏障。下面通過具體的示例代碼進(jìn)行說明。

public class VolatileBarrier { 
    int a;volatile int v1 = 1;
    volatile int v2 = 2;
    void readAndWrite() {
        int i = v1; // 第一個(gè)volatile讀 
        int j = v2; // 第二個(gè)volatile讀 
        a = i + j;  // 普通寫 
        v1 = i + 1; // 第一個(gè)volatile寫 
        v2 = j * 2; // 第二個(gè) volatile寫 
    }
}

針對readAndWrite()方法,編譯器在生成字節(jié)碼時(shí)可以做如下的優(yōu)化:

多線程編程?聊聊并發(fā)的背后知識

 

注意,最后的StoreLoad屏障不能省略。因?yàn)榈诙€(gè)volatile寫之后,方法立即return。此時(shí)編 譯器可能無法準(zhǔn)確斷定后面是否會有volatile讀或?qū)懀瑸榱税踩鹨?,編譯器通常會在這里插 入一個(gè)StoreLoad屏障。

上面的優(yōu)化針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器內(nèi)存模 型,內(nèi)存屏障的插入還可以根據(jù)具體的處理器內(nèi)存模型繼續(xù)優(yōu)化。以X86處理器為例,除最后的StoreLoad屏障外,其他的屏障都會被省略。

前面保守策略下的volatile讀和寫,在X86處理器平臺可以優(yōu)化成如下圖所示。前文提到過,X86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀、讀-寫和寫-寫操作做重排序,因此在X86處理器中會省略掉這3種操作類型對應(yīng)的內(nèi)存屏障。在X86中,JMM僅需 在volatile寫后面插入一個(gè)StoreLoad屏障即可正確實(shí)現(xiàn)volatile寫-讀的內(nèi)存語義。這意味著在 X86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因?yàn)閳?zhí)行StoreLoad屏障開銷會比較大)。

多線程編程?聊聊并發(fā)的背后知識

 

5.5、Synchronized

synchronized 內(nèi)置鎖是一種對象鎖(鎖的是對象而非引用),作用粒度是對象,可以用來實(shí)現(xiàn)對臨界資源的同步互斥訪問,是可重入的。

synchronized 加鎖的方式:

  1. 同步實(shí)例方法,鎖是當(dāng)前實(shí)例對象
  2. 同步類方法,鎖是當(dāng)前類對象同步代碼塊,鎖是括號里面的對象
  3. 同步代碼塊,鎖是括號里面的對象

synchronized底層原理

synchronized是基于JVM內(nèi)置鎖實(shí)現(xiàn),通過內(nèi)部對象Monitor(監(jiān)視器鎖)實(shí)現(xiàn),基于進(jìn)入與退出Monitor對象實(shí)現(xiàn)方法與代碼塊同步,監(jiān)視器鎖的實(shí)現(xiàn)依賴底層操作系統(tǒng)的Mutex lock(互斥鎖)實(shí)現(xiàn),它是一個(gè)重量級鎖性能較低。當(dāng)然,JVM內(nèi)置鎖在1.5之后版本做了重大的優(yōu)化,如鎖粗化(Lock Coarsening)、鎖消除(Lock Elimination)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)、適應(yīng)性自旋(Adaptive Spinning)等技術(shù)來減少鎖操作的開銷,,內(nèi)置鎖的并發(fā)性能已經(jīng)基本與Lock持平。

synchronized關(guān)鍵字被編譯成字節(jié)碼后會被翻譯成 monitorenter 和 monitorexit 兩條指令分別在同步塊邏輯代碼的起始位置與結(jié)束位置。

多線程編程?聊聊并發(fā)的背后知識

 

每個(gè)同步對象都有一個(gè)自己的Monitor(監(jiān)視器鎖),加鎖過程如下圖所示:

多線程編程?聊聊并發(fā)的背后知識

 

那么有個(gè)問題來了,我們知道synchronized加鎖加在對象上,對象是如何記錄鎖狀態(tài)的呢?答案是鎖狀態(tài)是被記錄在每個(gè)對象的對象頭(Mark word)中,下面我們一起認(rèn)識一下對象的內(nèi)存布局對象的內(nèi)存布局。

對象的內(nèi)存布局

HotSpot虛擬機(jī)中,對象在內(nèi)存中存儲的布局可以分為三塊區(qū)域:對象頭(Header)、實(shí)例數(shù)據(jù)(Instance Data)和對齊填充(Padding)。

  1. 對象頭:比如 hash碼,對象所屬的年代,對象鎖,鎖狀態(tài)標(biāo)志,偏向鎖(線程)ID,偏向時(shí)間,數(shù)組長度(數(shù)組對象)等
  2. 實(shí)例數(shù)據(jù):即創(chuàng)建對象時(shí),對象中成員變量,方法等
  3. 對齊填充:對象的大小必須是8字節(jié)的整數(shù)倍
多線程編程?聊聊并發(fā)的背后知識

 

對象頭

HotSpot虛擬機(jī)的對象頭包括兩部分信息,第一部分是“Mark Word”,用于存儲對象自身的運(yùn)行時(shí)數(shù)據(jù), 如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時(shí)間戳等等,這部分?jǐn)?shù)據(jù)的長度在32位和64位的虛擬機(jī)(暫 不考慮開啟壓縮指針的場景)中分別為32個(gè)和64個(gè)Bits,官方稱它為“Mark Word”。對象需要存儲的運(yùn)行時(shí)數(shù)據(jù)很多,其實(shí)已經(jīng)超出了32、64位Bitmap結(jié)構(gòu)所能記錄的限度,但是對象頭信息是與對象自身定義的數(shù)據(jù)無關(guān)的額外存儲成本,考慮到虛擬機(jī)的空間效率,Mark Word被設(shè)計(jì)成一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存儲盡量多的信息。

對象頭信息是與對象自身定義的數(shù)據(jù)無關(guān)的額外存儲成本,但是考慮到虛擬機(jī)的空間效率,Mark Word被設(shè)計(jì)成一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存存儲盡量多的數(shù)據(jù),它會根據(jù)對象的狀態(tài)復(fù)用自己的存儲空間,也就是說,Mark Word會隨著程序的運(yùn)行發(fā)生變化,變化狀態(tài)如下(64位虛擬機(jī)):

多線程編程?聊聊并發(fā)的背后知識

 

鎖的膨脹升級過程

鎖的狀態(tài)總共有四種,無鎖狀態(tài)、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現(xiàn)鎖的降級。下圖為Synchronize鎖的升級過程:

多線程編程?聊聊并發(fā)的背后知識

 

偏向鎖

偏向鎖是Java 6之后加入的新鎖,它是一種針對加鎖操作的優(yōu)化手段,經(jīng)過研究發(fā)現(xiàn),在大多數(shù)情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,因此為了減少同一線程獲取鎖(會涉及到一些CAS操作,耗時(shí))的代價(jià)而引入偏向鎖。偏向鎖的核心思想是,如果一個(gè)線程獲得了鎖,那么鎖就進(jìn)入偏向模式,此時(shí)Mark Word 的結(jié)構(gòu)也變?yōu)槠蜴i結(jié)構(gòu),當(dāng)這個(gè)線程再次請求鎖時(shí),無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關(guān)鎖申請的操作,從而也就提供程序的性能。所以,對于沒有鎖競爭的場合,偏向鎖有很好的優(yōu)化效果,畢竟極有可能連續(xù)多次是同一個(gè)線程申請相同的鎖。但是對于鎖競爭比較激烈的場合,偏向鎖就失效了,因?yàn)檫@樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應(yīng)該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗后,并不會立即膨脹為重量級鎖,而是先升級為輕量級鎖。下面我們接著了解輕量級鎖。

輕量級鎖

輕量級鎖又叫CAS、自旋鎖等,具體原理如下圖:每次更新值時(shí),先判斷該值有沒有被其他線程改過,如果沒被改過,直接賦值;如果改過,則重新計(jì)算,重新比較;CAS底層其實(shí)底層實(shí)現(xiàn)是用了匯編指令:cmpxchg,如果是多核CPU或者是多個(gè)CPU,則用匯編指令:lock cmpxchg,因?yàn)閏mpxchg在多個(gè)CPU狀態(tài)下線程不安全;另外CAS有一個(gè)典型的問題:ABA問題。解決ABA問題的方法也很簡單,就是加版本號。

多線程編程?聊聊并發(fā)的背后知識

 

需要注意的一點(diǎn):倘若偏向鎖失敗,虛擬機(jī)并不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優(yōu)化手段(1.6之后加入的),此時(shí)Mark Word 的結(jié)構(gòu)也變?yōu)檩p量級鎖的結(jié)構(gòu)。輕量級鎖能夠提升程序性能的依據(jù)是“對絕大部分的鎖,在整個(gè)同步周期內(nèi)都不存在競爭”,注意這是經(jīng)驗(yàn)數(shù)據(jù)。需要了解的是,輕量級鎖所適應(yīng)的場景是線程交替執(zhí)行同步塊的場合,如果存在同一時(shí)間訪問同一鎖的場合,就會導(dǎo)致輕量級鎖膨脹為重量級鎖。自旋鎖輕量級鎖失敗后,虛擬機(jī)為了避免線程真實(shí)地在操作系統(tǒng)層面掛起,還會進(jìn)行一項(xiàng)稱為自旋鎖的優(yōu)化手段。這是基于在大多數(shù)情況下,線程持有鎖的時(shí)間都不會太長,如果直接掛起操作系統(tǒng)層面的線程可能會得不償失,畢竟操作系統(tǒng)實(shí)現(xiàn)線程之間的切換時(shí)需要從用戶態(tài)轉(zhuǎn)換到核心態(tài),這個(gè)狀態(tài)之間的轉(zhuǎn)換需要相對比較長的時(shí)間,時(shí)間成本相對較高,因此自旋鎖會假設(shè)在不久將來,當(dāng)前的線程可以獲得鎖,因此虛擬機(jī)會讓當(dāng)前想要獲取鎖的線程做幾個(gè)空循環(huán)(這也是稱為自旋的原因),一般不會太久,可能是50個(gè)循環(huán)或100循環(huán),在經(jīng)過若干次循環(huán)后,如果得到鎖,就順利進(jìn)入臨界區(qū)。如果還不能獲得鎖,那就會將線程在操作系統(tǒng)層面掛起,這就是自旋鎖的優(yōu)化方式,這種方式確實(shí)也是可以提升效率的。最后沒辦法也就只能升級為重量級鎖了。

重量級鎖

重量級鎖是需要從操作系統(tǒng)內(nèi)核申請鎖,擁有這把鎖的線程可以執(zhí)行,其他線程都得回到等待隊(duì)列等待鎖,相當(dāng)于是單線程執(zhí)行,因此效率較低。

分享到:
標(biāo)簽:多線程
用戶無頭像

網(wǎng)友整理

注冊時(shí)間:

網(wǎng)站:5 個(gè)   小程序:0 個(gè)  文章:12 篇

  • 51998

    網(wǎng)站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會員

趕快注冊賬號,推廣您的網(wǎng)站吧!
最新入駐小程序

數(shù)獨(dú)大挑戰(zhàn)2018-06-03

數(shù)獨(dú)一種數(shù)學(xué)游戲,玩家需要根據(jù)9

答題星2018-06-03

您可以通過答題星輕松地創(chuàng)建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學(xué)四六

運(yùn)動步數(shù)有氧達(dá)人2018-06-03

記錄運(yùn)動步數(shù),積累氧氣值。還可偷

每日養(yǎng)生app2018-06-03

每日養(yǎng)生,天天健康

體育訓(xùn)練成績評定2018-06-03

通用課目體育訓(xùn)練成績評定