一个volatile跟面试官扯了半个小时
前言
volatile 應(yīng)該算是Java 后端面試的必考題,因為多線程編程基本繞不開它,很適合作為并發(fā)編程的入門題。
開場
面試官:你先自我介紹一下吧!
安琪拉: ? 我是安琪拉,草叢三婊之一,最強中單(鐘馗不服)!哦,不對,串場了,我是**,目前在–公司做–系統(tǒng)開發(fā)。
面試官: ? 看你簡歷上寫熟悉并發(fā)編程,volatile 用過的吧?
安琪拉: ? 用過的。(還是熟悉的味道)
面試官: ? 那你跟我講講什么時候會用到 volatile ?
安琪拉: ? 如果需要保證多線程共享變量的可見性時,可以使用volatile 來修飾變量。
面試官: ? 什么是共享變量的可見性?
安琪拉: ? 多線程并發(fā)編程中主要圍繞著三個特性實現(xiàn)。可見性是其中一種!
可見性
可見性是指當(dāng)多個線程訪問同一個共享變量時,一個線程修改了這個變量的值,其他線程能夠立即看到修改后的值。
原子性
原子性指的一個操作或一組操作要么全部執(zhí)行,要么全部不執(zhí)行。
有序性
有序性是指程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。
面試官: ? volatile 除了解決共享變量的可見性,還有別的作用嗎?
安琪拉: ? volatile 除了讓共享變量具有可見性,還具有有序性(禁止指令重排序)。
面試官: ? 你先跟我舉幾個實際volatile 實際項目中的例子?
安琪拉: ? 可以的。有個特別常見的例子:
狀態(tài)標(biāo)志
比如我們工程中經(jīng)常用一個變量標(biāo)識程序是否啟動、初始化完成、是否停止等,如下:
volatile修飾狀態(tài)標(biāo)志
volatile 很適合只有一個線程修改,其他線程讀取的情況。volatile 變量被修改之后,對其他線程立即可見。
面試官: ?現(xiàn)在我們來看一下你的例子,如果不加volatile 修飾,會有什么后果?
安琪拉: ?比如這是一個帶前端交互的系統(tǒng),有A、 B二個線程,用戶點了停止應(yīng)用按鈕,A 線程調(diào)用shutdown() 方法,讓變量shutdown 從false 變成 true,但是因為沒有使用volatile 修飾, B 線程可能感知不到shutdown 的變化,而繼續(xù)執(zhí)行 doWork 內(nèi)的循環(huán),這樣違背了程序的意愿:當(dāng)shutdown 變量為true 時,代表應(yīng)用該停下了,doWork函數(shù)應(yīng)該跳出循環(huán),不再執(zhí)行。
面試官: ? volatile還有別的應(yīng)用場景嗎?
安琪拉: ? 懶漢式單例模式,我們常用的 double-check 的單例模式,如下所示:
懶漢式單例模式
使用volatile 修飾保證 singleton 的實例化能夠?qū)λ芯€程立即可見。
面試官: ? 我們再來看你的單例模式的例子,我有三個問題:
為什么使用volatile 修飾了singleton 引用還用synchronized 鎖?
第一次檢查singleton 為空后為什么內(nèi)部還需要進行第二次檢查?
volatile 除了內(nèi)存可見性,還有別的作用嗎?
安琪拉: ?【心里炸了,舉單例模式例子簡直給自己挖坑】這三個問題,我來一個個回答:
為什么使用volatile 修飾了singleton 引用還用synchronized 鎖?
volatile 只保證了共享變量 singleton 的可見性,但是 singleton = new Singleton(); 這個操作不是原子的,可以分為三步:
步驟1:在堆內(nèi)存申請一塊內(nèi)存空間;
步驟2:初始化申請好的內(nèi)存空間;
步驟3:將內(nèi)存空間的地址賦值給 singleton;
所以singleton = new Singleton(); 是一個由三步操作組成的復(fù)合操作,多線程環(huán)境下A 線程執(zhí)行了第一步、第二步之后發(fā)生線程切換,B 線程開始執(zhí)行第一步、第二步、第三步(因為A 線程singleton 是還沒有賦值的),所以為了保障這三個步驟不可中斷,可以使用synchronized 在這段代碼塊上加鎖。(synchronized 原理參考《安琪拉與面試官二三事》系列第二篇文章)
第一次檢查singleton 為空后為什么內(nèi)部還進行第二次檢查?
A 線程進行判空檢查之后開始執(zhí)行synchronized代碼塊時發(fā)生線程切換(線程切換可能發(fā)生在任何時候),B 線程也進行判空檢查,B線程檢查 singleton == null 結(jié)果為true,也開始執(zhí)行synchronized代碼塊,雖然synchronized 會讓二個線程串行執(zhí)行,如果synchronized代碼塊內(nèi)部不進行二次判空檢查,singleton 可能會初始化二次。
volatile 除了內(nèi)存可見性,還有別的作用嗎?
volatile 修飾的變量除了可見性,還能防止指令重排序。
指令重排序 是編譯器和處理器為了優(yōu)化程序執(zhí)行的性能而對指令序列進行重排的一種手段。現(xiàn)象就是CPU 執(zhí)行指令的順序可能和程序代碼的順序不一致,例如 a = 1; b = 2; 可能 CPU 先執(zhí)行b=2; 后執(zhí)行a=1;
singleton = new Singleton(); 由三步操作組合而成,如果不使用volatile 修飾,可能發(fā)生指令重排序。步驟3 在步驟2 之前執(zhí)行,singleton 引用的是還沒有被初始化的內(nèi)存空間,別的線程調(diào)用單例的方法就會引發(fā)未被初始化的錯誤。
指令重排序也遵循一定的規(guī)則:
重排序不會對存在依賴關(guān)系的操作進行重排
指令重排
重排序目的是優(yōu)化性能,不管怎樣重排,單線程下的程序執(zhí)行結(jié)果不會變
as-if-serial
因此volatile 還有禁止指令重排序的作用。
面試官: ? 那為什么不加volatile ,A 線程對共享變量的修改,其他線程不可見呢?你知道volatile的底層原理嗎?
安琪拉: ? 果然該來的還是來了,我要放大招了,您坐穩(wěn)咯!
面試官: ? 我靠在椅子上,穩(wěn)的很,請開始你的表演!
安琪拉: ? ?先說結(jié)論,我們知道volatile可以實現(xiàn)內(nèi)存的可見性和防止指令重排序,但是volatile 不保證操作的原子性。那么volatile是怎么實現(xiàn)可見性和有序性的呢?其實volatile的這些內(nèi)存語意是通過內(nèi)存屏障技術(shù)實現(xiàn)的。
面試官: ? 那你跟我講講內(nèi)存屏障。
安琪拉: ? 講內(nèi)存屏障的話,這塊內(nèi)容會比較深,我以下面的順序講,這個整個知識成體系,不散:
現(xiàn)代CPU 架構(gòu)的形成
Java 內(nèi)存模型(JMM)
Java 通過 Java 內(nèi)存模型(JMM )實現(xiàn) volatile 平臺無關(guān)
現(xiàn)代CPU 架構(gòu)的形成
安琪拉: ?一切要從盤古開天辟地說起,女媧補天!咳咳,不好意思,扯遠了!一切從馮洛伊曼計算機體系開始說起!
面試官: ?扯的是不是有點遠!
安琪拉: ?你就說要不要聽?要聽別打斷我!
面試官: ? 得嘞!您請講!
安琪拉: ?下圖就是經(jīng)典的 馮洛伊曼體系結(jié)構(gòu),基本把計算機的組成模塊都定義好了,現(xiàn)在的計算機都是以這個體系弄的,其中最核心的就是由運算器和控制器組成的中央處理器,就是我們常說的CPU。
面試官: ? 這個跟 volatile 有什么關(guān)系?
安琪拉: ?不要著急嘛!理解技術(shù)不要死盯著技術(shù)的細枝末節(jié),要思考這個技術(shù)產(chǎn)生的歷史背景和原因,思考發(fā)明這個技術(shù)的人當(dāng)時是遇到了什么問題?而發(fā)明這個技術(shù)的。這樣即理解深刻,也讓自己思考問題更宏觀,更有深度!這叫從歷史的角度看問題,站在巨人的肩膀上!
面試官: ?來來來,今天你教我做人!
安琪拉: ?剛才說到馮洛伊曼體系中的CPU,你應(yīng)該聽過摩爾定律吧!就是英特爾創(chuàng)始人戈登·摩爾講的:
集成電路上可容納的晶體管數(shù)目,約每隔18個月便會增加一倍,性能也將提升一倍。
面試官: ?聽過的,然后呢?
安琪拉:所以你看到我們電腦CPU 的性能越來越強勁,英特爾CPU 從Intel Core 一直到 Intel Core i7,前些年單核CPU 的晶體管數(shù)量確實符合摩爾定律,看下面這張圖。
橫軸為新CPU發(fā)明的年份,縱軸為可容納晶體管的對數(shù)。所有的點近似成一條直線,這意味著晶體管數(shù)目隨年份呈指數(shù)變化,大概每兩年翻一番。
面試官: ? 后來呢?這和今天說的 volatile,以及內(nèi)存屏障有什么關(guān)系?
安琪拉:別著急啊!后來摩爾定律越來越撐不住了,但是更新?lián)Q代的程序?qū)﹄娔X性能的期望和要求還在不斷上漲,就出現(xiàn)了下面的劇情。
他為其Pentium 4新一代芯片取消上市而道歉, 近幾年來,英特爾不斷地在增加其處理器的運行速度。當(dāng)前最快的一款,其速度已達3.4GHz,雖然強化處理器的運行速度,也增強了芯片運作效能,但速度提升卻使得芯片的能源消耗量增加,并衍生出冷卻芯片的問題。
因此,英特爾摒棄將心力集中在提升運行速度的做法,在未來幾年,將其芯片轉(zhuǎn)為以多模核心(multi-core)的方式設(shè)計等其他方式,來提升芯片的表現(xiàn)。多模核心的設(shè)計法是將多模核心置入單一芯片中。如此一來,這些核心芯片即能以較緩慢的速度運轉(zhuǎn),除了可減少運轉(zhuǎn)消耗的能量,也能減少運轉(zhuǎn)生成的熱量。此外,集眾核心芯片之力,可提供較單一核心芯片更大的處理能力。?—《經(jīng)濟學(xué)人》
安琪拉:當(dāng)然上面貝瑞特當(dāng)然只是在開玩笑,眼看摩爾定律撐不住了,后來怎么處理的呢?一顆CPU 不行,我們多來幾顆嘛!這就是現(xiàn)在我們常見的多核CPU,四核8G 聽著熟悉不熟悉?當(dāng)然完全依據(jù)馮洛伊曼體系設(shè)計的計算機也是有缺陷的!
面試官: ? 什么缺陷?說說看。
安琪拉:CPU 運算器的運算速度遠比內(nèi)存讀寫速度快,所以CPU 大部分時間都在等數(shù)據(jù)從內(nèi)存讀取,運算完數(shù)據(jù)寫回內(nèi)存。
面試官: ? 那怎么解決?
安琪拉:因為CPU 運行速度實在太快,主存(就是內(nèi)存)的數(shù)據(jù)讀取速度和CPU 運算速度差了有幾個數(shù)量級,因此現(xiàn)代計算機系統(tǒng)通過在CPU 和主存之前加了一層讀寫速度盡可能接近CPU 運行速度的高速緩存來做數(shù)據(jù)緩沖,這樣緩存提前從主存獲取數(shù)據(jù),CPU 不再從主存取數(shù)據(jù),而是從緩存取數(shù)據(jù)。這樣就緩解由于主存速度太慢導(dǎo)致的CPU 饑餓的問題。同時CPU 內(nèi)還有寄存器,一些計算的中間結(jié)果臨時放在寄存器內(nèi)。
面試官: ? 既然你提到緩存,那我問你一個問題,CPU 從緩存讀取數(shù)據(jù)和從內(nèi)存讀取數(shù)據(jù)除了讀取速度的差異?有什么本質(zhì)的區(qū)別嗎?不都是讀數(shù)據(jù)寫數(shù)據(jù),而且加緩存會讓整個體系結(jié)構(gòu)變得更加復(fù)雜。
安琪拉:緩存和主存不僅僅是讀取寫入數(shù)據(jù)速度上的差異,還有另外更大的區(qū)別:研究人員發(fā)現(xiàn)了程序80%的時間在運行20% 的代碼,所以緩存本質(zhì)上只要把20%的常用數(shù)據(jù)和指令放進來就可以了(是不是和Redis 存放熱點數(shù)據(jù)很像),另外CPU 訪問主存數(shù)據(jù)時存在二個局部性現(xiàn)象:
時間局部性現(xiàn)象
如果一個主存數(shù)據(jù)正在被訪問,那么在近期它被再次訪問的概率非常大。想想你程序大部分時間是不是在運行主流程。
空間局部性現(xiàn)象
CPU使用到某塊內(nèi)存區(qū)域數(shù)據(jù),這塊內(nèi)存區(qū)域后面臨近的數(shù)據(jù)很大概率立即會被使用到。這個很好解釋,我們程序經(jīng)常用的數(shù)組、集合(本質(zhì)也是數(shù)組)經(jīng)常會順序訪問(內(nèi)存地址連續(xù)或鄰近)。
因為這二個局部性現(xiàn)象的存在使得緩存的存在可以很大程度上緩解CPU 饑餓的問題。
面試官: ? 講的是那么回事,那能給我畫一下現(xiàn)在CPU、緩存、主存的關(guān)系圖嗎?
安琪拉:可以。我們來看下現(xiàn)在主流的多核CPU的硬件架構(gòu),如下圖所示。
多核心CPU架構(gòu)
安琪拉:現(xiàn)代操作系統(tǒng)一般會有多級緩存(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的電腦緩存信息,一共4核,三級緩存,L1 緩存(在CPU核心內(nèi))這里沒有顯示出來,這里L(fēng)2 緩存后面括號標(biāo)識了是每個核都有L2 緩存,而L3 緩存沒有標(biāo)識,是因為L3 緩存是4個核共享的緩存:
安琪拉的電腦緩存
面試官: ? 那你能跟我簡單講講程序運行時,數(shù)據(jù)是怎么在主存、緩存、CPU寄存器之間流轉(zhuǎn)的嗎?
安琪拉:可以。比如以 i = i + 2; 為例, 當(dāng)線程執(zhí)行到這條語句時,會先從主存中讀取i 的值,然后復(fù)制一份到緩存中,CPU 讀取緩存數(shù)據(jù)(取數(shù)指令),進行 i + 2 操作(中間數(shù)據(jù)放寄存器),然后把結(jié)果寫入緩存,最后將緩存中i最新的值刷新到主存當(dāng)中(寫回時間不確定)。
面試官: ? ?這個數(shù)據(jù)操作邏輯在單線程環(huán)境和多線程環(huán)境下有什么區(qū)別?
安琪拉:?比如i 如果是共享變量(例如對象的成員變量),單線程運行沒有任何問題,但是多線程中運行就有可能出問題。例如:有A、B二個線程,在不同的CPU 上運行,因為每個線程運行的CPU 都有自己的緩存,A 線程從內(nèi)存讀取i 的值存入緩存,B 線程此時也讀取i 的值存入自己的緩存,A 線程對i 進行+1操作,i變成了1,B線程緩存中的變量 i 還是0,B線程也對i 進行+1操作,最后A、B線程先后將緩存數(shù)據(jù)寫入內(nèi)存,內(nèi)存預(yù)期正確的結(jié)果應(yīng)該是2,但是實際是1。這個就是非常著名的緩存一致性問題。
說明:單核CPU 的多線程也會出現(xiàn)上面的線程不安全的問題,只是產(chǎn)生原因不是多核CPU緩存不一致的問題導(dǎo)致,而是CPU調(diào)度線程切換,多線程局部變量不同步引起的。
執(zhí)行過程如下圖:
緩存不一致
面試官: ? 那CPU 怎么解決緩存一致性問題呢?
安琪拉:早期的一些CPU 設(shè)計中,是通過鎖總線(總線訪問加Lock# 鎖)的方式解決的。看下CPU 體系結(jié)構(gòu)圖,如下:
CPU內(nèi)體系結(jié)構(gòu)
因為CPU 都是通過總線來讀取主存中的數(shù)據(jù),因此對總線加Lock# 鎖的話,其他CPU 訪問主存就被阻塞了,這樣防止了對共享變量的競爭。但是鎖總線對CPU的性能損耗非常大,把多核CPU 并行的優(yōu)勢直接給干沒了!
后面研究人員就搞出了一套協(xié)議:緩存一致性協(xié)議。協(xié)議的類型很多(MSI、MESI、MOSI、Synapse、Firefly),最常見的就是Intel 的MESI 協(xié)議。緩存一致性協(xié)議主要規(guī)范了CPU 讀寫主存、管理緩存數(shù)據(jù)的一系列規(guī)范,如下圖所示。
緩存一致性協(xié)議
面試官: ? 那講講 **MESI **協(xié)議唄!
安琪拉: ? (MESI這部分內(nèi)容可以只了解大概思想,不用深究,因為東西多到可以單獨成一篇文章了)
MESI 協(xié)議的核心思想:
定義了緩存中的數(shù)據(jù)狀態(tài)只有四種,MESI 是四種狀態(tài)的首字母。
當(dāng)CPU寫數(shù)據(jù)時,如果寫的變量是共享變量,即在其他CPU中也存在該變量的副本,會發(fā)出信號通知其他CPU將該變量的緩存行置為無效狀態(tài);
當(dāng)CPU讀取共享變量時,發(fā)現(xiàn)自己緩存的該變量的緩存行是無效的,那么它就會從內(nèi)存中重新讀取。
緩存中數(shù)據(jù)都是以緩存行(Cache Line)為單位存儲;MESI 各個狀態(tài)描述如下表所示:
面試官: ? 那我問你MESI 協(xié)議和volatile實現(xiàn)的內(nèi)存可見性時什么關(guān)系?
安琪拉: ? volatile 和MESI 中間差了好幾層抽象,中間會經(jīng)歷java編譯器,java虛擬機和JIT,操作系統(tǒng),CPU核心。
volatile 是Java 中標(biāo)識變量可見性的關(guān)鍵字,說直接點:使用volatile 修飾的變量是有內(nèi)存可見性的,這是Java 語法定的,Java 不關(guān)心你底層操作系統(tǒng)、硬件CPU 是如何實現(xiàn)內(nèi)存可見的,我的語法規(guī)定就是volatile 修飾的變量必須是具有可見性的。
CPU 有X86(復(fù)雜指令集)、ARM(精簡指令集)等體系架構(gòu),版本類型也有很多種,CPU 可能通過鎖總線、MESI 協(xié)議實現(xiàn)多核心緩存的一致性。因為有硬件的差異以及編譯器和處理器的指令重排優(yōu)化的存在,所以Java 需要一種協(xié)議來規(guī)避硬件平臺的差異,保障同一段代表在所有平臺運行效果一致,這個協(xié)議叫做Java 內(nèi)存模型(Java Memory Model)。
Java 內(nèi)存模型(JMM)
面試官: ? 你能詳細講講Java 內(nèi)存模型嗎?
安琪拉: ? JMM 全稱 Java Memory Model, 是 Java 中非常重要的一個概念,是Java 并發(fā)編程的核心和基礎(chǔ)。JMM 是Java 定義的一套協(xié)議,用來屏蔽各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,讓Java 程序在各種平臺都能有一致的運行效果。
協(xié)議這個詞都不會陌生,HTTP 協(xié)議、TCP 協(xié)議等。JMM 協(xié)議就是一套規(guī)范,具體的內(nèi)容為:
所有的變量都存儲在主內(nèi)存中,每個線程還有自己的工作內(nèi)存,線程的工作內(nèi)存中保存了該線程使用到的變量(主內(nèi)存的拷貝),線程對變量的所有操作(讀取、賦值)都必須在工作內(nèi)存中進行,而不能直接讀寫主內(nèi)存中的變量。不同線程之間無法直接訪問對方工作內(nèi)存中的變量,線程間變量值的傳遞均需要在主內(nèi)存來完成。
面試官: ? 你剛才提到每個線程都有自己的工作內(nèi)存,問個深入一點的問題,線程的工作內(nèi)存在主存還是緩存中?
安琪拉: ? 這個問題非常棒!JMM 中定義的每個線程私有的工作內(nèi)存是抽象的規(guī)范,實際上工作內(nèi)存和真實的CPU 內(nèi)存架構(gòu)如下所示,Java 內(nèi)存模型和真實硬件內(nèi)存架構(gòu)是不同的:
JMM與真實內(nèi)存架構(gòu)
JMM 是內(nèi)存模型,是抽象的協(xié)議。首先真實的內(nèi)存架構(gòu)是沒有區(qū)分堆和棧的,這個Java 的JVM 來做的劃分,另外線程私有的本地內(nèi)存線程棧可能包括CPU 寄存器、緩存和主存。堆亦是如此!
面試官: ?能具體講講JMM 內(nèi)存模型規(guī)范嗎?
安琪拉: ? 可以。前面已經(jīng)講了線程本地內(nèi)存和物理真實內(nèi)存之間的關(guān)系,說的詳細些:
初始變量首先存儲在主內(nèi)存中;
線程操作變量需要從主內(nèi)存拷貝到線程本地內(nèi)存中;
線程的本地工作內(nèi)存是一個抽象概念,包括了緩存、store buffer(后面會講到)、寄存器等。
JMM
面試官: ? 那JMM 模型中多線程如何通過共享變量通信呢?
安琪拉: ?線程間通信必須要經(jīng)過主內(nèi)存。
線程A與線程B之間要通信的話,必須要經(jīng)歷下面2個步驟:
1)線程A把本地內(nèi)存A中更新過的共享變量刷新到主內(nèi)存中去。
2)線程B到主內(nèi)存中去讀取線程A之前已更新過的共享變量。
線程間通信
關(guān)于主內(nèi)存與工作內(nèi)存之間的具體交互協(xié)議,即一個變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步到主內(nèi)存之間的實現(xiàn)細節(jié),Java內(nèi)存模型定義了以下八種操作(單一操作都是原子的)來完成:
lock(鎖定):作用于主內(nèi)存的變量,把一個變量標(biāo)識為一條線程獨占狀態(tài)。
unlock(解鎖):作用于主內(nèi)存變量,把一個處于鎖定狀態(tài)的變量解除鎖定,解除鎖定后的變量才可以被其他線程鎖定。
read(讀取):作用于主內(nèi)存變量,把一個變量值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用
load(載入):作用于工作內(nèi)存的變量,它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中。
use(使用):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個變量值傳遞給執(zhí)行引擎,每當(dāng)虛擬機遇到一個需要使用變量的值的字節(jié)碼指令時將會執(zhí)行這個操作。
assign(賦值):作用于工作內(nèi)存的變量,它把一個從執(zhí)行引擎接收到的值賦值給工作內(nèi)存的變量,每當(dāng)虛擬機遇到一個給變量賦值的字節(jié)碼指令時執(zhí)行這個操作。
store(有的指令是save/存儲):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個變量的值傳送到主內(nèi)存中,以便隨后的write的操作。
write(寫入):作用于主內(nèi)存的變量,它把store操作從工作內(nèi)存中一個變量的值傳送到主內(nèi)存的變量中。
我們編譯一段Java code 看一下。
代碼和字節(jié)碼指令分別為:
指令演示源代碼
指令演示
Java內(nèi)存模型還規(guī)定了在執(zhí)行上述八種基本操作時,必須滿足如下規(guī)則:
如果要把一個變量從主內(nèi)存中復(fù)制到工作內(nèi)存,需要順序執(zhí)行read 和load 操作, 如果把變量從工作內(nèi)存中同步回主內(nèi)存中,就要按順序地執(zhí)行store 和write 操作。但Java內(nèi)存模型只要求上述操作必須按順序執(zhí)行,而沒有保證必須是連續(xù)執(zhí)行,也就是操作不是原子的,一組操作可以中斷。
不允許read和load、store和write操作之一單獨出現(xiàn),必須成對出現(xiàn)。
不允許一個線程丟棄它的最近assign的操作,即變量在工作內(nèi)存中改變了之后必須同步到主內(nèi)存中。
不允許一個線程無原因地(沒有發(fā)生過任何assign操作)把數(shù)據(jù)從工作內(nèi)存同步回主內(nèi)存中。
一個新的變量只能在主內(nèi)存中誕生,不允許在工作內(nèi)存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執(zhí)行過了assign和load操作。
一個變量在同一時刻只允許一條線程對其進行l(wèi)ock操作,但lock操作可以被同一條線程重復(fù)執(zhí)行多次,多次執(zhí)行l(wèi)ock后,只有執(zhí)行相同次數(shù)的unlock操作,變量才會被解鎖。lock和unlock必須成對出現(xiàn)
如果對一個變量執(zhí)行l(wèi)ock操作,將會清空工作內(nèi)存中此變量的值,在執(zhí)行引擎使用這個變量前需要重新執(zhí)行l(wèi)oad或assign操作初始化變量的值
如果一個變量事先沒有被lock操作鎖定,則不允許對它執(zhí)行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。
對一個變量執(zhí)行unlock操作之前,必須先把此變量同步到主內(nèi)存中(執(zhí)行store和write操作)。
面試官: ? 聽下來 Java 內(nèi)存模型真的內(nèi)容很多,那Java 內(nèi)存模型是如何保障你上面說的這些規(guī)則的呢?
安琪拉: ? 這就是接下來要說的底層實現(xiàn)原理了,上面叨逼叨說了一堆概念和規(guī)范,需要慢慢消化。
Java 通過 Java 內(nèi)存模型(JMM )實現(xiàn) volatile 平臺無關(guān)
安琪拉: ? 我們前面說 并發(fā)編程實際就是圍繞三個特性的實現(xiàn)展開的:
可見性
有序性
原子性
面試官: ?對的。前面已經(jīng)說過了。我怎么感覺我想是捧哏。????
安琪拉: ?前面我們已經(jīng)說過共享變量不可見的問題,講完Java 內(nèi)存模型,理解的應(yīng)該更深刻了,如下圖所示:
1. 可見性問題:如果對象obj 沒有使用volatile 修飾,A 線程在將對象count讀取到本地內(nèi)存,從1修改為2,B 線程也把obj 讀取到本地內(nèi)存,因為A 線程的修改對B 線程不可見,這是從Java 內(nèi)存模型層面看可見性問題(前面從物理內(nèi)存結(jié)構(gòu)分析的)。
2. 有序性問題:重排序發(fā)生的地方有很多,編譯器優(yōu)化、CPU 因為指令流水批處理而重排序、內(nèi)存因為緩存以及store buffer 而顯得亂序執(zhí)行。如下圖所示:
附一張帶store buffer (寫緩沖)的CPU 架構(gòu)圖,希望詳細了解store buffer 可以看文章最后面的擴展閱讀。
每個處理器上的Store Buffer(寫緩沖區(qū)),僅僅對它所在的處理器可見。這會導(dǎo)致處理器執(zhí)行內(nèi)存操作的順序可能會與內(nèi)存實際的操作執(zhí)行順序不一致。由于現(xiàn)代的處理器都會使用寫緩沖區(qū),因此現(xiàn)代的處理器都會允許對寫-讀操作進行重排序:
下圖是各種CPU 架構(gòu)允許的指令重排序的情況。
3. ?原子性問題:例如多線程并發(fā)執(zhí)行 i = i +1。i 是共享變量,看完Java 內(nèi)存模型,知道這個操作不是原子的,可以分為+1 操作和賦值操作。因此多線程并發(fā)訪問時,可能發(fā)生線程切換,造成不是預(yù)期結(jié)果。
針對上面的三個問題,Java 中提供了一些關(guān)鍵字來解決。
可見性 & 有序性 問題解決
volatile 可以讓共享變量實現(xiàn)可見性,同時禁止共享變量的指令重排,保障可見性。從JSR-333 規(guī)范 和 實現(xiàn)原理講:
JSR-333 規(guī)范:JDK 5定義的內(nèi)存模型規(guī)范,
在JMM中,如果一個操作執(zhí)行的結(jié)果需要對另一個操作可見,那么這兩個操作之間必須存在happens-before關(guān)系。
1. 如果一個操作happens-before另一個操作,那么第一個操作的執(zhí)行結(jié)果將對第二個操作可見,而且第一個操作的執(zhí)行順序排在第二個操作之前。
2. 兩個操作之間存在happens-before關(guān)系,并不意味著一定要按照happens-before原則制定的順序來執(zhí)行。如果重排序之后的執(zhí)行結(jié)果與按照happens-before關(guān)系來執(zhí)行的結(jié)果一致,那么這種重排序并不非法。
程序次序規(guī)則:一個線程內(nèi),按照代碼順序,書寫在前面的操作先行發(fā)生于書寫在后面的操作;
鎖定規(guī)則:一個unLock操作先行發(fā)生于后面對同一個鎖額lock操作;
volatile變量規(guī)則:對一個變量的寫操作先行發(fā)生于后面對這個變量的讀操作;
傳遞規(guī)則:如果操作A先行發(fā)生于操作B,而操作B又先行發(fā)生于操作C,則可以得出操作A先行發(fā)生于操作C;
線程啟動規(guī)則:Thread對象的start()方法先行發(fā)生于此線程的每個一個動作;
線程中斷規(guī)則:對線程interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生;
線程終結(jié)規(guī)則:線程中所有的操作都先行發(fā)生于線程的終止檢測,我們可以通過Thread.join()方法結(jié)束、Thread.isAlive()的返回值手段檢測到線程已經(jīng)終止執(zhí)行;
對象終結(jié)規(guī)則:一個對象的初始化完成先行發(fā)生于他的finalize()方法的開始;
實現(xiàn)原理:上面說的happens-before原則保障可見性,禁止指令重排保證有序性,如何實現(xiàn)的呢?
Java編譯器在生成指令序列的適當(dāng)位置會插入內(nèi)存屏障指令來禁止特定類型的處理器重排序,保證共享變量操作的有序性。
內(nèi)存屏障指令:寫操作的會讓線程本地的共享內(nèi)存變量寫完強制刷新到主存。讀操作讓本地線程變量無效,強制從主內(nèi)存讀取,保證了共享內(nèi)存變量的可見性。
JVM中提供了四類內(nèi)存屏障指令:
JSR-133 定義的相應(yīng)的內(nèi)存屏障,在第一步操作(列)和第二步操作(行)之間需要的內(nèi)存屏障指令如下:
Java ?volatile 例子:
以下是區(qū)分各個CPU體系支持的內(nèi)存屏障(也叫內(nèi)存柵欄),由JVM 實現(xiàn)平臺無關(guān)(volatile所有平臺表現(xiàn)一致)
synchronized 也可以實現(xiàn)有序性和可見性,但是是通過鎖讓并發(fā)串行化實現(xiàn)有序,內(nèi)存屏障實現(xiàn)可見。原理可以看《安琪拉與面試官二三事》系列的synchronized 篇。
一個線程寫入變量a后,任何線程訪問該變量都會拿到最新值。
在寫入變量a之前的寫入操作,其更新的數(shù)據(jù)對于其他線程也是可見的。因為Memory Barrier會刷出cache中的所有先前的寫入。
原子性問題解決
原子性主要通過JUC Atomic***包實現(xiàn),如下圖所示,內(nèi)部使用CAS 指令實現(xiàn)原子性,各個CPU架構(gòu)有些區(qū)別。
擴展閱讀
Java如何實現(xiàn)跨平臺
作為Java 程序員的我們只需要寫一堆 ***.java 文件,編譯器把 .java 文件編譯成 .class 字節(jié)碼文件,后面的事就都交給Java 虛擬機(JVM)做了。如下圖所示, Java虛擬機是區(qū)分平臺的,虛擬機來進行 .class 字節(jié)碼指令翻譯成平臺相關(guān)的機器碼。
所以 Java 是跨平臺的,Java 虛擬機(JVM)不是跨平臺的,JVM 是平臺相關(guān)的。大家可以看 Hostpot1.8 源碼文件夾,JVM 每個系統(tǒng)都有單獨的實現(xiàn),如下圖所示:
As-if-serial
As-if-serial語義的意思是,所有的動作(Action)都可以為了優(yōu)化而被重排序,但是必須保證它們重排序后的結(jié)果和程序代碼本身的應(yīng)有結(jié)果是一致的。Java編譯器、運行時和處理器都會保證單線程下的as-if-serial語義。
并發(fā)&并行
現(xiàn)代操作系統(tǒng),現(xiàn)代操作系統(tǒng)都是按時間片調(diào)度執(zhí)行的,最小的調(diào)度執(zhí)行單元是線程,多任務(wù)和并行處理能力是衡量一臺計算機處理器的非常重要的指標(biāo)。這里有個概念要說一下:
并發(fā):多個程序可能同時運行的現(xiàn)象,例如刷微博和聽歌同時進行,可能你電腦只有一顆CPU,但是通過時間片輪轉(zhuǎn)的方式讓你感覺在同時進行。
并行:多核CPU,每個CPU 內(nèi)運行自己的線程,是真正的同時進行的,叫并行。
內(nèi)存屏障
JSR-133 對應(yīng)規(guī)則需要的規(guī)則
另外 final 關(guān)鍵字需要 StoreStore 屏障
x.finalField = v; StoreStore; sharedRef = x;
MESI 協(xié)議運作模式
MESI 協(xié)議運作的具體流程,舉個實例
第一列是操作序列號,第二列是執(zhí)行操作的CPU,第三列是具體執(zhí)行哪一種操作,第四列描述了各個cpu local cache中的cacheline的狀態(tài)(用meory address/狀態(tài)表示),最后一列描述了內(nèi)存在0地址和8地址的數(shù)據(jù)內(nèi)容的狀態(tài):V表示是最新的,和cache一致,I表示不是最新的內(nèi)容,最新的內(nèi)容保存在cache中。
總結(jié)篇
Java內(nèi)存模型
Java 內(nèi)存模型(JSR-133)屏蔽了硬件、操作系統(tǒng)的差異,實現(xiàn)讓Java程序在各種平臺下都能達到一致的并發(fā)效果,規(guī)定了一個線程如何和何時可以看到由其他線程修改過后的共享變量的值,以及在必須時如何同步的訪問共享變量,JMM使用內(nèi)存屏障提供了java程序運行時統(tǒng)一的內(nèi)存模型。
volatile的實現(xiàn)原理
volatile可以實現(xiàn)內(nèi)存的可見性和防止指令重排序。
通過內(nèi)存屏障技術(shù)實現(xiàn)的。
為了實現(xiàn)volatile的內(nèi)存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障指令,內(nèi)存屏障效果有:
禁止volatile 修飾變量指令的重排序
寫入數(shù)據(jù)強制刷新到主存
讀取數(shù)據(jù)強制從主存讀取
volatile使用總結(jié)
volatile 是Java 提供的一種輕量級同步機制,可以保證共享變量的可見性和有序性(禁止指令重排),常用于
狀態(tài)標(biāo)志、雙重檢查的單例等場景。使用原則:
對變量的寫操作不依賴于當(dāng)前值。例如 i++ 這種就不適用。
該變量沒有包含在具有其他變量的不變式中。
volatile的使用場景不是很多,使用時需要仔細考慮下是否適用volatile,注意滿足上面的二個原則。
單個的共享變量的讀/寫(比如a=1)具有原子性,但是像num++或者a=b+1;這種復(fù)合操作,volatile無法保證其原子性;
有道無術(shù),術(shù)可成;有術(shù)無道,止于術(shù)
歡迎大家關(guān)注Java之道公眾號
好文章,我在看??
總結(jié)
以上是生活随笔為你收集整理的一个volatile跟面试官扯了半个小时的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 阿里巴巴Java开发手册建议创建Hash
- 下一篇: 新公司要上监控,我决定用Promethe