概述
多任務和高並發是衡量一台計算機處理器的能力重要指標之一。一般衡量一個服務器性能的高低好壞,使用每秒事務處理數(Transactions Per Second,TPS)這個指標比較能說明問題,它代表着一秒內服務器平均能響應的請求數,而TPS值與程序的並發能力有着非常密切的關系。在討論Java內存模型和線程之前,先簡單介紹一下硬件的效率與一致性。
硬件的效率與一致性
由於計算機的存儲設備與處理器的運算能力之間有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中沒這樣處理器就無需等待緩慢的內存讀寫了。
基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而他們又共享同一主存,如下圖所示:多個處理器運算任務都涉及同一塊主存,需要一種協議可以保障數據的一致性,這類協議有MSI、MESI、MOSI及Dragon Protocol等。Java虛擬機內存模型中定義的內存訪問操作與硬件的緩存訪問操作是具有可比性的,后續將介紹Java內存模型。

除此之外,為了使得處理器內部的運算單元能竟可能被充分利用,處理器可能會對輸入代碼進行亂起執行(Out-Of-Order Execution)優化,處理器會在計算之后將對亂序執行的代碼進行結果重組,保證結果准確性。與處理器的亂序執行優化類似,Java虛擬機的即時編譯器中也有類似的指令重排序(Instruction Recorder)優化。
Java內存模型(JMM)
定義Java內存模型並不是一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓Java的並發操作不會產生歧義。但是,也必須得足夠寬松,使得虛擬機的實現能有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存等)來獲取更好的執行速度。經過長時間的驗證和修補,在JDK1.5發布后,Java內存模型就已經成熟和完善起來了。JMM給我們一種規范,它描述了多線程程序如何與內存交互。
JMM大致描述:
JMM描述了線程如何與內存進行交互。Java虛擬機規范視圖定義一種Java內存模型,來屏蔽掉各種操作系統內存訪問的差異,以實現Java程序在各種平台下都能達到一致的訪問效果。
JMM描述了JVM如何與計算機的內存進行交互。
JMM都是圍繞着原子性,有序性和可見性進行展開的。
JMM的主要目標是定義程序中各個變量的訪問規則,虛擬機將變量存儲到內存和從內存取出變量這樣的底層細節。此處的變量指在堆中存儲的元素。
多線程的時候為什么容易出錯?
Java內存模型規定所有的共享變量都存儲在主內存中,而每條線程有自己的工作內存(本地內存),工作內存保存了共享變量的副本,而不同內存又無法訪問對方的工作內存,所以如果線程在工作內存中修改了變量副本,其它線程是無從得知的。
線程的傳值均需要通過主內存來完成


主內存與工作內存如何交互?
Java內存模型定義了8種操作來完成主內存與工作內存的交互細節,虛擬機必須保證這8種操作的每一個操作都是原子的,不可再分的。
lock: 作用於主內存的變量,把變量標識為線程獨占的狀態。
unlock: 與lock對應,把主內存中處於鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
read: 作用於主內存的變量,把一個變量的值從主內存傳輸到線程的工作內存,便於隨后的load使用。
load:作用於工作內存的變量,把read讀取到的變量放入工作內存副本。
use:作用於工作內存,把工作內存的變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
assign: 作用於工作內存,把執行引擎收到的值賦給工作內存的變量,虛擬機遇到賦值字節碼時候執行這個操作。
store:作用於工作內存,把變量的值傳輸到住內存中,以便隨后的write使用。
write:作用於主內存,把store操作從工作內存得到的值放入主內存的變量中。

Java內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:
- 不允許
read和load,store和write操作之一單獨出現。- 不允許一個線程丟棄它最近的
assign操作。即變量在工作內存中改變了賬號必須把變化同步回主內存。- 不允許一個線程無原因地(沒有發生過任何
assign操作)把數據從工作內存同步回主內存中。- 一個新的變量只允許在主內存中誕生,不允許工作內存直接使用未初始化的變量。
- 一個變量同一時刻只允許一條線程進行
lock操作,但同一線程可以lock多次,lock多次之后必須執行同樣次數的unlock操作。- 如果對一個變量執行
lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值。- 如果一個變量事先沒有被
lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。- 對一個變量執行
unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。
這8種操作定義相當嚴謹,實踐起來又比較麻煩,但是可以有助於我們理解多線程的工作原理。有一個與此8種操作相等的Happen-before原則。
Happen-before原則
這個是Java內存模型下無需任何同步器協助就已經存在,可以直接在編碼中使用。如果兩個操作之間的關系不在此列,並且無法從下列規則推導出來的話,它們的順序就沒有保障,虛擬機可以對他們進行任意的重排。
天然的happens-before:
程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在后面的操作;
鎖定規則:一個unlock操作先行發生於后面對同一個鎖的lock操作;
volatile變量規則:對一個變量的寫操作先行發生於后面對這個變量的讀操作;
傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
線程啟動規則:Thread對象的start()方法先行發生於此線程的每一個動作;
線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生;
線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行;
對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始。
這8條原則摘自《深入理解Java虛擬機》。
這8條規則中,前4條規則是比較重要的,后4條規則都是顯而易見的。
下面我們來解釋一下前4條規則:
對於程序次序規則來說,我的理解就是一段程序代碼的執行在單個線程中看起來是有序的。注意,雖然這條規則中提到“書寫在前面的操作先行發生於書寫在后面的操作”,這個應該是程序看起來執行的順序是按照代碼順序執行的,因為虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。
第二條規則也比較容易理解,也就是說無論在單線程中還是多線程中,同一個鎖如果出於被鎖定的狀態,那么必須先對鎖進行了釋放操作,后面才能繼續進行lock操作。
第三條規則是一條比較重要的規則。直觀地解釋就是,如果一個線程先去寫一個變量,然后一個線程去進行讀取,那么寫入操作肯定會先行發生於讀操作。
第四條規則實際上就是體現happens-before原則具備傳遞性。
Java運行時數據區
JVM定義了一些程序運行時會使用到的運行時數據區,其中一些會隨着虛擬機啟動而創建,隨着虛擬機退出而銷毀。另外一些是與現場一一對應的,這些線程對應的數據區會隨着線程的開始和結束而創建和銷毀。
這部分參考JVM規范
1. PC寄存器
可以支持多條線程同時允許,每一條Java虛擬機線程都有自己的PC寄存器。任意時刻,一條JVM線程之后執行一個方法的代碼,這個方法被稱為當前方法(current method)。
如果這個方法不是Native的,那么PC寄存器就保存JVM正在執行的字節碼指令地址。
如果是Native的,那么PC寄存器的值為undefined。
PC寄存器的容量至少能保證一個returnAddress類型的數據或者一個平台無關的本地指針的值。
2. JVM Stack(虛擬機棧)
每一個JVM線程都有自己的私有虛擬機棧,這個棧與線程同時創建,用於存儲棧幀(Frame)。
棧用來存儲局部變量與一些過程結果的地方。在方法調用和返回中也扮演了很重要的角色。
棧可以試固定分配的也可以動態調整。
如果請求線程分配的容量超過JVM棧允許的最大容量,拋出StackOverflowError異常。
如果JVM棧可以動態擴展,擴展的動作也已經嘗試過,但是沒有申請到足夠的內存,則拋出OutOfMemoryError異常。
3. Heap(堆)
堆是可以可供各個線程共享的運行時存儲區域,也是供所有類的實例和數組對象分配內存的區域。堆在JVM啟動的時候創建。
堆所存儲的就是被GC所管理的各種對象。
堆也是可以固定大小和動態調整的。
實際所需的堆超過的GC所提供的最大容量,那么JVM拋出OutOfMemoryError異常。
4. Method Area(方法區)
也是各個線程共享的運行時內存區,它存儲每一個類的實例信息,運行時常量池,字段和方法數據,構造函數和普通方法的字節碼等內容。還有一些特殊方法。
方法區是堆的邏輯組成部分,也在JVM啟動時創建,簡單的JVM可以不實現這個區域的垃圾收集。
方法區也可固定大小和動態分配與堆一樣,內存空間不夠,那么JVM拋出OutOfMemoryError異常。
5. Run-Time Constant Pool(運行時常量池)
在方法區中分配,在加載類和接口到虛擬機之后,就創建對應的運行時常量池。
它是class文件中每一個類或接口的常量池表的運行時表現形式。
存儲區域不夠用時候拋出OutOfMemoryError異常。
6. Native Method Stacks(原生方法棧或本地方法棧)
JDK中Native的方法,System類和Thread類中有很多。使用C語言編寫的方法,這個也通常叫做C stack。
可以不支持本地方法棧,但是如果支持的時候,這個棧一般會在線程創建的時候按線程分配。
與棧的錯誤一樣,StackOverFlowError和OutOfMemeoryError。
注意,具體 JVM 的內存結構,其實取決於其實現,不同廠商的JVM,或者同一廠商發布的不同版本,都有可能存在一定差異。
畫了一個簡單的內存結構圖,里面展示了前面提到的堆、線程棧等區域,並從數量上說明了什么是線程私有,例如,程序計數器、Java 棧等,以及什么是 Java 進程唯一。另外,還額外划分出了直接內存等區域。

里簡要介紹兩點區別:
直接內存(Direct Memory)區域,它就是 Direct Buffer 所直接分配的內存,也是個容易出現問題的地方。盡管,在 JVM 工程師的眼中,並不認為它是JVM 內部內存的一部分,也並未體現 JVM 內存模型中。
JVM 本身是個本地程序,還需要其他的內存去完成各種基本任務,比如,JIT Compiler 在運行時對熱點方法進行編譯,就會將編譯后的方法儲存在 Code Cache 里面;GC 等功能需要運行在本地線程之中,類似部分都需要占用內存空間。這些是實現 JVM JIT 等功能的需要,但規范中並不涉及。
如果深入到 JVM 的實現細節,你會發現一些結論似乎有些模棱兩可。比如:
Java 對象是不是都創建在堆上的呢?
我注意到有一些觀點,認為通過逃逸分析,JVM 會在棧上分配那些不會逃逸的對象,這在理論上是可行的,但是取決於 JVM 設計者的選擇。據我所知,Oracle Hotspot JVM 中並未這么做,這一點在逃逸分析相關的文檔里已經說明,所以可以明確所有的對象實例都是創建在堆上。
目前很多書籍還是基於 JDK 7 以前的版本,JDK 已經發生了很大變化,Intern 字符串的緩存和靜態變量曾經都被分配在永久代上,而永久代已經被元數據區取代。但是,Intern 字符串緩存和靜態變量並不是被轉移到元數據區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:對象實例都是分配在堆上。
接下來,我們來看看什么是 OOM 問題,它可能在哪些內存區域發生?
首先,OOM 如果通俗點兒說,就是 JVM 內存不夠用了,javadoc 中對OutOfMemoryError的解釋是,沒有空閑內存,並且垃圾收集器也無法提供更多內存。
這里面隱含着一層意思是,在拋出 OutOfMemoryError 之前,通常垃圾收集器會被觸發,盡其所能去清理出空間,例如:
JVM 會去嘗試回收軟引用指向的對象等。
在java.nio.BIts.reserveMemory() 方法中,我們能清楚的看到,System.gc() 會被調用,以清理空間,這也是為什么在大量使用 NIO 的 Direct Buffer 之類時,通常建議不要加下面的
參數,畢竟是個最后的嘗試,有可能避免一定的內存不足問題。
-XX:+DisableExplictGC
當然,也不是在任何情況下垃圾收集器都會被觸發的,比如,我們去分配一個超大對象,類似一個超大數組超過堆的最大值,JVM 可以判斷出垃圾收集並不能解決這個問題,所以直接拋出OutOfMemoryError。
從前面分析的數據區的角度,除了程序計數器,其他區域都有可能會因為可能的空間不足發生OutOfMemoryError,簡單總結如下:
-
堆內存不足是最常見的 OOM 原因之一,拋出的錯誤信息是“java.lang.OutOfMemoryError:Java heap space”,原因可能千奇百怪,例如,可能存在內存泄漏問題;也很有可能就是堆的大小不合理,比如我們要處理比較可觀的數據量,但是沒有顯式指定 JVM 堆大小或者指定數值偏小;或者出現 JVM 處理引用不及時,導致堆積起來,內存無法釋放等。
-
而對於 Java 虛擬機棧和本地方法棧,這里要稍微復雜一點。如果我們寫一段程序不斷的進行遞歸調用,而且沒有退出條件,就會導致不斷地進行壓棧。類似這種情況,JVM 實際會拋出StackOverFlowError;當然,如果 JVM 試圖去擴展棧空間的的時候失敗,則會拋出OutOfMemoryError。
-
對於老版本的 Oracle JDK,因為永久代的大小是有限的,並且 JVM 對永久代垃圾回收(如,常量池回收、卸載不再需要的類型)非常不積極,所以當我們不斷添加新類型的時候,永久代出現 OutOfMemoryError 也非常多見,尤其是在運行時存在大量動態類型生成的場合;類似 Intern 字符串緩存占用太多空間,也會導致 OOM 問題。對應的異常信息,會標記出來和永久代相關:“java.lang.OutOfMemoryError: PermGen space”。
-
隨着元數據區的引入,方法區內存已經不再那么窘迫,所以相應的 OOM 有所改觀,出現OOM,異常信息則變成了:“java.lang.OutOfMemoryError: Metaspace”。
-
直接內存不足,也會導致 OOM。
指令重排序
在執行程序時為了提高性能,編譯器和處理器經常會對指令進行重排序。重排序分成三種類型:
1、編譯器優化的重排序。編譯器在不改變單線程程序語義放入前提下,可以重新安排語句的執行順序。
2、指令級並行的重排序。現代處理器采用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
3、內存系統的重排序。由於處理器使用緩存和讀寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
從Java源代碼到最終實際執行的指令序列,會經過下面三種重排序:

為了保證內存的可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。Java內存模型把內存屏障分為LoadLoad、LoadStore、StoreLoad和StoreStore四種:

一個案例

- 一個本地變量可能是原始類型,在這種情況下,它總是“呆在”線程棧上。
- 一個本地變量也可能是指向一個對象的一個引用。在這種情況下,引用(這個本地變量)存放在線程棧上,但是對象本身存放在堆上。
- 一個對象可能包含方法,這些方法可能包含本地變量。這些本地變量任然存放在線程棧上,即使這些方法所屬的對象存放在堆上。
- 一個對象的成員變量可能隨着這個對象自身存放在堆上。不管這個成員變量是原始類型還是引用類型。
- 靜態成員變量跟隨着類定義一起也存放在堆上。
- 存放在堆上的對象可以被所有持有對這個對象引用的線程訪問。當一個線程可以訪問一個對象時,它也可以訪問這個對象的成員變量。如果兩個線程同時調用同一個對象上的同一個方法,它們將會都訪問這個對象的成員變量,但是每一個線程都擁有這個本地變量的私有拷貝。

