CPU、內存、緩存的關系
要理解JMM,要先從計算機底層開始,下面是一份大佬的研究報告
計算機在做一些我們平時的基本操作時,需要的響應時間是不一樣的!如果我們計算一次a+b所需要的的時間:
- CPU讀取內存獲得a,100納秒
- CPU讀取內存獲得b,100納秒
- CPU執行一條指令 a+b ,0.6納秒
也就是說99%的時間花費在CPU讀取內存上了,那如何解決速度不均衡問題?
早期計算機中cpu和內存的速度是差不多的,但在現代計算機中cpu的指令速度遠超內存的存取速度,由于計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了
CPU緩存
什么是CPU緩存
在計算機系統中,CPU高速緩存(英語:CPU Cache,在本文中簡稱緩存)是用于減少處理器訪問內存所需平均時間的部件。在金字塔式存儲體系中它位于自頂向下的第二層,僅次于CPU寄存器。其容量遠小于內存,但速度卻可以接近處理器的頻率。當處理器發出內存訪問請求時,會先查看緩存內是否有請求數據。如果存在(命中),則不經訪問內存直接返回該數據;如果不存在(失效),則要先把內存中的相應數據載入緩存,再將其返回處理器。
下圖是一個典型的存儲器層次結構,我們可以看到一共使用了三級緩存:
為什么要有多級CPU Cache
在計算機系統中,寄存器劃是L0級緩存,接著依次是L1,L2,L3(接下來是內存,本地磁盤,遠程存儲)。越往上的緩存存儲空間越小,速度越快,成本也更高;越往下的存儲空間越大,速度更慢,成本也更低。從上至下,每一層都可以看做是更下一層的緩存,即:L0寄存器是L1一級緩存的緩存,L1是L2的緩存,依次類推;每一層的數據都是來至它的下一層,所以每一層的數據是下一層的數據的子集
下圖是我電腦的三級緩存,可以看到層級越小容量越小。速度越快價格越高!!
在現代CPU上,一般來說L0, L1,L2,L3都集成在CPU內部,而L1還分為一級數據緩存(Data Cache,D-Cache,L1d)和一級指令緩存(Instruction Cache,I-Cache,L1i),分別用于存放數據和執行數據的指令解碼。每個核心擁有獨立的運算處理單元、控制器、寄存器、L1、L2緩存,然后一個CPU的多個核心共享最后一層CPU緩存L3。
為了充分利用 CPU Cache,Java提出了內存模型這個概念
Java內存模型(Java Memory Model,JMM)
從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存、寫緩沖區、寄存器以及其他的硬件和編譯器優化。
程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的工作內存(Local Memory),工作內存中存儲了該線程以讀/寫共享變量的副本。
舉個栗子:多個線程去修改主內存中的變量a。線程不能直接修改主內存中的數據,先把數據拷貝到工作內存,線程對私有的工作內存修改然后再同步到主內存。那這樣做會帶來什么問題呢?
JMM導致的并發安全問題
從JMM角度看,如果兩個線程同時調用 a=a+1這個函數(假設a的初始值是0),A、B線程同時從主內存中拷貝a=0,然后修改寫回,最后主內存為a=1,咋搞?
如下是代碼栗子
public class MainTest { private long count = 0; public void incCount() { count += 1; } public static void main(String[] args) throws InterruptedException { MainTest test = new MainTest(); Count count = new Count(test); Count count1 = new Count(test); count.start(); count1.start(); Thread.sleep(5); System.out.println("result is :" + test.count); } private static class Count extends Thread{ private MainTest m; public Count(MainTest m){ this.m = m; } @Override public void run() { for (int i = 0; i < 10000; i++) { m.incCount(); } } } }
執行結果
// 第一次執行
> Task :lib-test:MainTest.main()
result is :11861// 第二次執行
> Task :lib-test:MainTest.main()
result is :10535
可見性
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值
由于線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量,那么對于共享變量a,它們首先是在自己的工作內存,之后再同步到主內存。可是并不會及時的刷到主存中,而是會有一定時間差。很明顯,這個時候線程 A 對變量 a 的操作對于線程 B 而言就不具備可見性了 。
要解決共享對象可見性這個問題,我們可以使用volatile關鍵字或者是加鎖
原子性
即一個操作或者多個操作 要么全部執行并且執行的過程不會被任何因素打斷,要么就都不執行
我們都知道CPU資源的分配都是以線程為單位的,并且是分時調用,操作系統允許某個進程執行一小段時間,例如 50 毫秒,過了 50 毫秒操作系統就會重新選擇一個進程來執行(我們稱為“任務切換”),這個 50 毫秒稱為“時間片”。而任務的切換大多數是在時間片段結束以后。
那么線程切換為什么會帶來bug呢?因為操作系統做任務切換,可以發生在任何一條CPU 指令執行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高級語言里的一條語句。比如count++,在java里就是一句話,但高級語言里一條語句往往需要多條 CPU 指令完成。其實count++包含了三個CPU指令
有序性
即程序執行的順序按照代碼的先后順序執行。
在Java內存模型中,為了效率是允許編譯器和處理器對指令進行重排序,當然重排序不會影響單線程的運行結果,但是對多線程會有影響。Java提供volatile來保證一定的有序性。最著名的例子就是單例模式里面的DCL(雙重檢查鎖)。另外,可以通過synchronized和Lock來保證有序性,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼,自然就保證了有序性。
在單線程的情況下,CPU執行語句并不是按照順序來的,為了更高的執行效率可能會重新排序,單線程下是可以提高執行效率且保證正確。但在多線程下反而變成了安全問題,Java提供volatile來保證一定的有序性。此處不做深入!
volatile
volatile特性
- 可見性:對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最后的寫入
- 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似于volatile++這種復合操作不具有原子性
【面試題】為什么volatile不能保證a++的線程安全問題
:線程執行a++要經歷讀取主內存-加載-使用-賦值-寫內存-寫回主內存幾個階段,而且a++不是原子操作,至少可以分為三步執行。線程A、B同時從主內存讀取a的值,A線程執行到加載階段切換上下文交出CPU使用權,B線程完成整個操作并刷新了主內存中a的值。此時A線程繼續賦值等其他操作,已經造成了安全問題。可見性是保證線程每次讀取時必須讀取主內存的值,對后續的操作沒有限制,不會因為主內存中的值改變而中斷了操作。如果是原子性則可以,synchronized可以保證原子性。
volatile 的實現原理
有volatile修飾的共享變量進行寫操作的時候會使用CPU提供的Lock前綴指令
- 將當前處理器緩存的數據寫回到系統內存
- 這個寫回內存的操作會使其他CPU里緩存了該地址的數據無效
單例模式的雙重鎖為什么要加volatile
public class TestInstance{ private volatile static TestInstance instance; public static TestInstance getInstance(){ //1 if(instance == null){ //2 synchronized(TestInstance.class){ //3 if(instance == null){ //4 instance = new TestInstance(); //5 } } } return instance; //6 } }
需要volatile關鍵字的原因是,在并發情況下,如果沒有volatile關鍵字,在第5行會出現問題。
instance = new TestInstance()可以分解為3行偽代碼
a. memory = allocate() //分配內存 b. ctorInstanc(memory) //初始化對象 c. instance = memory //設置instance指向剛分配的地址
上面的代碼在編譯運行時,可能會出現重排序從a-b-c排序為a-c-b。在多線程的情況下會出現以下問題。當線程A在執行第5行代碼時,B線程進來執行到第2行代碼。假設此時A執行的過程中發生了指令重排序,即先執行了a和c,沒有執行b。那么由于A線程執行了c導致instance指向了一段地址,所以B線程判斷instance不為null,會直接跳到第6行并返回一個未初始化的對象
總結
因為CPU與內存的速度差距越來越大,為了彌補速度差距引入了CPU緩存,又因為緩存導致線程安全問題,從前到后縷出一條線來就很容易理解了。如果只是單線程完全不擔心什么指令重排,想要更高的執行效率必然付出安全風險。知其然,知其所以然!
到此這篇關于java 多線程與并發之volatile詳解分析的文章就介紹到這了,更多相關Java volatile內容請搜索服務器之家以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持服務器之家!
原文鏈接:https://blog.csdn.net/xihuailu3244/article/details/115454622