JRockit權威指南深刻理解JVM

本文整理自:《JRockit權威指南深刻理解JVM》java

做者:Marcus Hirt , Marcus Lagergrengit

出版時間:2018-12-10程序員

起步

將應用程序遷移到JRockit

命令行選項

JRockit JVM中,主要有3類命令行選項,分別是系統屬性、標準選項(以-X開頭)和非標準選項(以-XX開頭)。github

一、系統屬性算法

設置JVM啓動參數的方式有多種。以-D開頭的參數會做爲系統屬性使用,這些屬性能夠爲Java類庫(如RMI等)提供相關的配置信息。例如,在啓動的時候,若是設置了-Dcom.Rockin.mc.debug=true參數,則JRockit Mission Control會打印出調試信息。不過,R28以後的JRockit JVM版本廢棄了不少以前使用過的系統屬性,轉而採用非標準選項和相似 HotSpot中虛擬機標誌(VM flag)的方式設置相關選項。數據庫

二、標準選項編程

以-X開頭的選項是大部分JVM廠商都支持的通用設置。例如,用於設置堆大小最大值的選項-Xmx在包括 JRockit在內的大部分JVM中都是相同的。固然,也存在例外,如JRockit中的選項-Xverbose會打印出可選的子模塊日誌信息,而在 HotSpot中,相似的(但實際上有更多的限制)選項是-verbose。數組

三、非標準選項緩存

以-XX開頭的命令行選項是各個JVM廠商本身定製的。這些選項可能會在未來的某個版本中被廢棄或修改。若是JVM的參數配置中包含了以-XX開頭的命令行選項,則在將Java應用程序從一種JVM遷移到另外一種時,應該在啓動M以前去除這些非標準選項肯定了新的VM選項後才能夠啓動Java應用程序。安全

自適應代碼生成

Java虛擬機

字節碼格式

Opcodes for the Java Virtual Machine

常量池

程序,包含數據和代碼兩部分,其中數據做爲操做數使用。對於字節碼程序來講,若是操做數很是小或者很經常使用(如常量0),則這些操做數是直接內嵌在字節碼指令中的。

較大塊的數據,例如常量字符串或比較大的數字,是存儲在class文件開始部分的常量池(constant pool)中的。當使用這類數據做爲操做數時,使用的是常量池中數據的索引位置,而不是實際數據自己。

此外,Java程序中的方法、屬性和類的元數據等也做爲clas文件的組成部分,存儲在常量池中。

自適應代碼生成

優化動態程序

在彙編代碼中,方法調用是經過call指令完成的。不一樣平臺上call指令的具體形式不盡相同,不一樣類型的call指令,其具體格式也不盡相同。

在面向對象的語言中,虛擬方法分派一般被編譯爲對分派表(dispatch table)中地址的間接調用(indirect call,即須要從內存中讀取真正的調用地址)。這是由於,根據不一樣的類繼承結構分派虛擬調用時可能會有多個接收者。每一個類中都有一個分派表,其中包含了其虛擬調用的接收者信息。靜態方法和確知只有一個接收者的虛擬方法能夠被編譯爲對固定調用地址的直接調用(direct call)。通常來講,這能夠大大加快執行速度。

假設應用程序是使用C++開發的,對代碼生成器來講,在編譯時已經能夠獲取到程序的全部結構性信息。例如,因爲在程序運行過程當中,代碼不會發生變化,因此在編譯時就能夠從代碼中判斷出,某個虛擬方法是否只有一種實現。正因如此,編譯器不只不須要由於廢棄代碼而記錄額外的信息,還能夠將那些只有一種實現的虛擬方法轉化爲靜態調用。

假如應用程序是使用Java開發的,起初某個虛擬方法可能只有一種實現,但Java容許在程序運行過程當中修改方法實現。當JIT編譯器須要編譯某個虛擬方法時,更喜歡的是那些永遠只存在一種實現的,這樣編譯器就能夠像前面提到的C++編譯器同樣作不少優化,例如將虛擬調用轉化爲直接調用。可是,因爲Java容許在程序運行期間修改代碼,若是某個方法沒有聲明final修飾符,那它就有可能在運行期間被修改,即便它看起來幾乎不可能有其餘實現,編譯器也不能將之優化爲直接調用。

在Java世界中,有一些場景如今看起來一切正常,編譯器能夠大力優化代碼,可是若是某天程序發生了改變的話,就須要將相關的優化所有撤銷。對於Java來講,爲了可以媲美C++程序的執行速度,就須要一些特殊的優化措施。

JVM使用的策略就是「賭」。JVM代碼生成策略的假設條件是,正在運行的代碼永遠不變。事實上,大部分時間裏確實如此。但若是正在運行的代碼發生了變化,違反了代碼優化的假設條件,就會觸發其簿記系統(bookkeeping system)的回調功能。此時,基於原先假設條件生成的代碼就須要被廢棄掉,從新生成,例如爲已經轉化爲直接調用的虛擬調用從新生成相關代碼。所以,「賭輸」的代價是很大的,但若是「賭贏」的機率很是高,則從中得到的性能提高就會很是大,值得一試。

通常來講,JVM和JIT編譯器所作的典型假設包括如下幾點:

  • 虛擬方法不會被覆蓋。因爲某個虛擬方法只存在一種實現,就能夠將之優化爲一個直接調用。
  • 浮點數的值永遠不會是NaN。大部分狀況下,能夠使用硬件指令來替換對本地浮點數函數庫的調用。
  • 某些try語句塊中幾乎不會拋出異常。所以,能夠將catch語句塊中的代碼做爲冷方法對待。
  • 對於大多數三角函數來講,硬件指令fsin都可以達到精度要求。若是真的達不到,就拋出異常,調用本地浮點數函數庫完成計算。
  • 鎖競爭並不會太激烈,初期能夠使用自旋鎖(spinlock)替代。
  • 鎖可能會週期性地被同一個線程獲取和釋放,因此,能夠將對鎖的重複獲取操做和重複釋放操做直接省略掉。

深刻JIT編譯器

優化字節碼

有些時候,對Java源代碼作優化會拔苗助長。絕大部分寫出可讀性不好的代碼的人都聲稱是爲了優化性能,其實就是照着一些基準測試報告的結論寫代碼,而這些性能測試每每只涉及了字節碼解釋執行,沒有通過JIT編譯器優化,因此並不能表明應用程序在運行時的真實表現。例如,某個服務器端應用程序中包含了大量對數組元素的迭代訪問操做,程序員參考了那些報告中的結論,沒有設置循環條件,而是寫一個無限for循環,置於try語句塊中,並在catch語句塊中捕獲ArrayIndexOutOfBoundsException異常。這種糟糕的寫法不只使代碼可讀性極差,並且一旦運行時對之優化編譯的話,其執行效率反而比普通循環方式低得多。緣由在於,JVM的基本假設之一就是「異常是不多發生的」。基於這種假設,JVM會作一些相關優化,因此當真的發生異常時,處理成本就很高。

代碼流水線

代碼生成概述

在生成優化代碼時,如何分配寄存器很是重要。編譯器教材上都將寄存器分配問題做爲圖的着色問題處理,這是由於同時用到的兩個變量不能共享同一個寄存器,從這點上講,與着色問題相同。同時使用的多個變量能夠用圖中相鏈接的節點來表示,這樣,寄存器分配問題就能夠被抽象爲「如何爲圖中的節點着色,才能使相連節點有不一樣的顏色」。這裏可用顏色的數量等於指定平臺上可用寄存器的數量。不過,遺憾的是,從計算複雜性上講,着色問題是NP-hard的,也就是說如今尚未一個高效的算法(指能夠在多項式時間內完成計算)能解決這個問題。可是,着色問題能夠在線性對數時間內給出近似解,所以大多數編譯器都使用着色算法的某個變種來處理寄存器分配問題。

自適應內存管理

堆管理基礎

對象的分配與釋放

通常來講,爲對象分配內存時,並不會直接在堆上劃份內存,而是先在線程局部緩衝(thread local buffer)或其餘相似的結構中找地方放置對象,而後隨着應用程序的運行、新對象的不斷分配,垃圾回收逐次執行,這些對象可能最終會被提高到堆中保存,也有可能會看成垃圾被釋放掉。

爲了可以在堆中給新建立的對象找一個合適的位置,內存管理系統必須知道堆中有哪些地方是空閒的,即尚未存活對象佔用。內存管理系統使用空閒列表(free list)—串聯起內存中可用內存塊的鏈表,來管理內存中可用的空閒區域,並按照某個維度的優先級排序。

在空閒列表中搜索足夠存儲新對象的空閒塊時,能夠選擇大小最適合的空閒塊,也能夠選擇第一個放得下的空閒塊。這其中會用到幾種不一樣的算法去實現,各有優劣,後文會詳細討論。

垃圾回收算法

在後文中,根集合(root set)專指上述搜索算法的初始輸入集合,即開始執行引用跟蹤時的存活對象集合。通常狀況下,根集合中包括了由於執行垃圾回收而暫停的應用程序的當前棧幀中全部的對象,包含了能夠從當前線程上下文的用戶棧和寄存器中能獲得的全部信息。此外,根集合中還包含全局數據,例如類的靜態屬性。簡單來講就是,根集合中包含了全部無須跟蹤引用就能夠獲得的對象。

Java使用的是準確式垃圾回收器(exact garbage collector),能夠將對象指針類型數據和其餘類型的數據區分開,只須要將元數據信息告知垃圾回收器便可,這些元數據信息,通常能夠從Java方法的代碼中獲得。

近些年,使用信號來暫停線程的方式受到頗多爭議。實踐發現,在某些操做系統上,尤以Linux爲例,應用程序對信號的使用和測試很不到位,還有一些第三方的本地庫不遵照信號約定,致使信號衝突等事件的發生。所以,與信號相關的外部依賴已經再也不可靠。

分代垃圾回收

事實上,將堆劃分爲兩個或多個稱爲代(generation)的空間,並分別存放具備不一樣長度生命週期的對象,能夠提高垃圾回收的執行效率。在JRockit中,新建立(young)的對象存放在稱爲新生代(nursery)的空間中,通常來講,它的大小會比老年代(old collections)小不少,隨着垃圾回收的重複執行,生命週期較長的對象會被提高(promote)到老年代中。所以,新生代垃圾回收和老年代垃圾回收兩種不一樣的垃圾回收方式應運而生,分別用於對各自空間中的對象執行垃圾回收。

新生代垃圾回收的速度比老年代快幾個數量級,即便新生代垃圾回收的頻率更高,執行效率也仍然比老年代垃圾回收強,這是由於大多數對象的生命週期都很短,根本無須提高到老年代。理想狀況下,新生代垃圾回收能夠大大提高系統的吞吐量,並消除潛在的內存碎片。

寫屏障

在實現分代式垃圾回收時,大部分JVM都是用名爲寫屏障(write barrier)的技術來記錄執行垃圾回收時須要遍歷堆的哪些部分。當對象A指向對象B時,即對象B成爲對象A的屬性的值時,就會觸發寫屏障,在完成屬性域賦值後執行一些輔助操做。

寫屏障的傳統實現方式是將堆劃分紅多個小的連續空間(例如每塊512字節),每塊空間稱爲卡片(card),因而,堆被映射爲一個粗粒度的卡表(card table)。當Java應用程序將某個對象賦值給對象引用時,會經過寫屏障設置髒標誌位(dirty bit),將該對象所在的卡片標記爲髒。

這樣,遍歷從老年代指向新生代的引用時間得以縮短,垃圾回收器在作新生代垃圾回收時只須要檢查老年代中被標記爲髒的卡片所對應的內存區域便可。

JRockit中的垃圾回收

老年代垃圾回收

JRockit不只將卡表應用於分代式垃圾回收,還用在併發標記階段結束時的清理工做,避免搜索整個存活對象圖。這是由於JRockit須要找出在執行併發標記操做時,應用程序又建立了哪些對象。修改引用關係時經過寫屏障能夠更新卡表,存活對象圖中的每一個區域使用卡表中的一個卡片表示,卡片的狀態能夠是乾淨或者髒,有新對象建立或者對象引用關係修改了的卡片會被標記爲髒。在併發標記階段結束時,垃圾回收器只須要檢查那些標記爲髒的卡片所對應的堆中區域便可,這樣就能夠找到在併發標記期間新建立的和被更新過引用關係的對象。

性能與伸縮性

線程局部分配

在JRockit中,使用了名爲線程局部分配(thread local allocation)的技術來大幅加速對象的分配過程。正常狀況下,在線程內的緩衝區中爲對象分配內存要比直接在須要同步操做的堆上分配內存快得多。垃圾回收器在堆上直接分配內存時是須要對整個堆加鎖的,對於多線程競爭激烈的應用程序來講,這將會是一場災難。所以,若是每一個Java線程可以有一塊局部對象緩衝區那麼絕大部分的對象分配操做只須要移動一下指針便可完成,在大多數硬件平臺上,只須要一條彙編指令就好了。這塊轉爲分配對象而保留的區域,就稱爲線程局部緩衝區(thread local area,TLA)。

爲了更好地利用緩存,達到更高的性能,通常狀況下,TLA的大小介於16KB到128KB之間,固然,也能夠經過命令行參數顯式指定。當TLA被填滿時,垃圾回收器會將TLA中的內容提高到堆中。所以,能夠將TLA看做是線程中的新生代內存空間

當Java源代碼中有new操做符,而且JIT編譯器對內存分配執行高級優化以後,內存分配的僞代碼以下所示:

object allocateNewobject(Class objectclass){
	Thread current getcurrentThread():
	int objectSize=alignedSize(objectclass)
	if(current.nextTLAOffset+objectSize> TLA_SIZE){
		current.promoteTLAToHeap();//慢,並且是同步操做
		current.nextTLAOffset=0;
	}
	Object ptr= current.TLAStart+current.nextTLAOffset:
	current.nextTLAOffset + objectSize;
	return ptr:	
}
複製代碼

爲了說明內存分配問題,在上面的僞代碼中省略了不少其餘關聯操做。例如若是待分配的對象很是大,超過了某個閾值,或對象太大致使沒法存放在TLA中,則會直接在堆中爲對象分配內存。

NUMA架構

NUMA(non-uniform memory access,非統一內存訪問模型)架構的出現爲垃圾回收帶來了更多挑戰。在NUMA架構下,不一樣的處理器核心一般訪問各自的內存地址空間,這是爲了不因多個CPU核心訪問同一內存地址形成的總線延遲。每一個CPU核心都配有專用的內存和總線,所以CPU核心在訪問其專有內存時速度很快,而要訪問相鄰CPU核心的內存時就會相對慢些,CPU核心相距越遠,訪問速度越慢(也依賴於具體配置)傳統上,多核CPU是按照UMA(uniform memory access,統一內存訪問模型)架構運行的,全部的CPU核心按照統一的模式無差異地訪問全部內存。

爲了更好地利用NUMA架構,垃圾回收器線程的組織結構應該作相應的調整。若是某個CPU核心正在運行標記線程,那麼該線程所要訪問的那部分堆內存最好可以放置在該CPU的專有內存中,這樣才能發揮NUMA架構的最大威力。在最壞狀況下,若是標記線程所要訪問的對象位於其餘NUMA節點的專有內存中,這時垃圾回收器一般須要一個啓發式對象移動算法。這是爲了保證使用時間上相近的對象在存儲位置上也能相近,若是這個算法可以正確工做,仍是能夠帶來不小的性能提高的。這裏所面臨的主要問題是如何避免對象在不一樣NUMA節點的專有內存中重複移動。理論上,自適應運行時系統應該能夠很好地處理這個問題。

大內存頁

內存分配是經過操做系統及其所使用的頁表完成的。操做系統將物理內存劃分紅多個頁來管理,從操做系統層面講,頁是實際分配內存的最小單位。傳統上,頁的大小是以4KB爲基本單位劃分的,頁操做對進程來講是透明的,進程所使用的是虛擬地址空間,並不是真正的物理地址。爲了便於將虛擬頁面轉換爲實際的物理內存地址,可以使用名爲旁路轉換緩衝(translation lookaside buffer,TLB)的緩存來加速地址的轉換操做。從實現上看,若是頁面的容量很是小的話,會致使頻繁出現旁路轉換緩衝丟失的狀況。

修復這個問題的一種方法就是將頁面的容量調大幾個數量級,例如以MB爲基本單位。現代操做系統廣泛傾向於支持這種大內存頁機制。

很明顯,當多個進程分別在各自的尋址空間中分配內存,而頁面的容量又比較大時,隨着使用的頁面數量愈來愈多,碎片化的問題就愈發嚴重,像進程要分配的內存比頁面容量稍微大一點的狀況,就會浪費不少存儲空間。對於在進程內本身管理內存分配回收、並有大量內存空間可用的運行時來講,這不算什麼問題,由於運行時能夠經過抽象出不一樣大小的虛擬頁面來解決。

一般狀況下,對於那些內存分配和回收頻繁的應用程序來講,使用大內存頁能夠使系統的總體性能至少提高10%。 JRockit對大內存頁有很好的支持。

近實時垃圾回收

JRockit Real Time

低延遲的代價是垃圾回收總體時間的延長。相比於並行垃圾回收,在程序運行的同時併發垃圾回收的難度更大,而頻繁中斷垃圾回收則可能帶來更多的麻煩。事實上,這並不是什麼大問題,由於大多數使用JRockit Real Time的用戶更關心繫統的可預測性,而不是減小垃圾回收的整體時間。大多數用戶認爲暫停時間的忽然增加比垃圾回收整體時間的延長更具危害性

軟實時的有效性

軟實時是JRockit Real Time的核心機制。但非肯定性系統如何提供指定程度的肯定性,例如像垃圾回收器這樣的系統如何保證應用程序的暫停時間不會超過某個閾值?嚴格來講,沒法提供這樣的保證,但因爲這樣的極端案例不多,因此也就可有可無了。

固然,沒有什麼萬全之策,確實存在沒法保證暫停時間的場景。但實踐證實,對於那些堆中存活對象約佔30%-50%的應用程序來講, JRockit Real Time的表現能夠知足服務須要,並且隨着JRockit Real Time各個版本的發行,30%-50%這個閾值在不斷提高,可支持的暫停時間閾值則不斷下降。

工做原理

  • 高效的並行執行
  • 細分垃圾回收過程,將之變成幾個可回滾、可中斷的子任務(work packet)
  • 高效的啓發式算法

事實上,實現低延遲的關鍵還是儘量多讓Java應用程序運行,保持堆的使用率和碎片化程度在一個較低的水平。在這一點上, JRockit Real Time使用的是貪心策略,即儘量推遲STW式的垃圾回收操做,但願問題可以由應用程序自身解決,或者可以減小不得不執行STW式操做的狀況,最好在具體執行的時候須要處理的對象也儘量少一些。

JRockit Real Time中,垃圾回收器的工做被劃分爲幾個子任務。若是在執行其中某個子任務時(例如整理堆中的某一部份內存),應用程序的暫停時間超過了閾值,那麼就放棄該子任務恢復應用程序的執行。用戶根據業務須要指定可用於完成垃圾回收的整體時間,有些時候,某些子任務已經完成,但沒有足夠的時間完成整個垃圾回收工做,這時爲了保證應用程序的運行,不得不廢棄還未完成的子任務,待到下次垃圾回收的時候再從新執行,指定的響應時間越短,則廢棄的子任務可能越多。

前面介紹過的標記階段的工做比較容易調整,能夠與應用程序併發執行。但清理和整理階段則須要暫停應用程序線程(STW)。幸運的是,標記階段會佔到垃圾回收整體時間的90%。若是暫停應用程序的時間過長,則不得不終止當前垃圾回收任務,從新併發執行,指望問題能夠自動解決。之因此將垃圾回收劃分爲幾個子任務就是爲了便於這一目標的實現。

內存操做相關API

析構方法

Java中的析構函數的設計就是一個失誤,應避免使用。

這不只僅是咱們的意見,也是Java社區的一致意見。

JVM的行爲差別

對於JVM來講,必定謹記,編程語言只能提醒垃圾回收器工做。就Java而言,在設計上它自己並不能精確控制內存系統。例如,假設兩個ⅣM廠商所實現軟引用在緩存中具備相同的存活時間,這本就是不切實際的。

另一個問題就是大量用戶對System.gc()方法的錯誤使用。System.gc()方法僅僅是提醒運行時「如今能夠作垃圾回收了」。在某些JVM實現中,頻繁調用該方法致使了頻繁的垃圾回收操做,而在某些JVM實現中,大部分時間忽略了該調用。

我過去任職爲性能顧問期間,屢次看到該方法被濫用。不少時候,只是去掉對 System.gc方法的幾回調用就能夠大幅提高性能,這也是 JRock中會有命令行參數-xx:AllowSystemGC=False來禁用System,gc方法的緣由。

陷阱與僞優化

部分開發人員在寫代碼時,有時會寫一些「通過優化的」的代碼,指望能夠幫助完成垃圾回收的工做,但實際上,這只是他們的錯覺。記住,過早優化是萬惡之源。就Java來講,很難在語言層面控制垃圾回收的行爲。這裏的主要問題時,開發人員誤覺得垃圾回收器有固定的運行模式,並妄圖去控制它。

除了垃圾回收外,對象池(object poll)也是Java中常見的僞優化(false optimization)。有人認爲,保留一個存活對象池來從新使用已建立的對象能夠提高垃圾回收的性能,但實際上,對象池不只增長了應用程序的複雜度,還很容易出錯。對於現代垃圾收集器來講,使用java.lang.ref.Reference系列類實現緩存,或者直接將無用對象的引用置爲null就行了,不用多操心。

事實上,基於現代VM,若是可以合理利用書本上的技巧,例如正確使用java.lang.ref.Reference系列類,注意Java的動態特性,徹底能夠寫出運行良好的應用程序。若是應用程序真的有實時性要求,那麼一開始就不應用Java編寫,而應該使用那些由程序員手動控制內存的靜態編程語言來實現應用程序。

JRockit中的內存管理

須要注意的是,花大力氣鼓搗JVM參數並不必定會使應用程序性能有多麼大的提高,並且反而可能會干擾JVM的正常運行。

線程與同步

基本概念

每一個對象都持有與同步操做相關的信息,例如當前對象是否做爲鎖使用,以及鎖的具體實現等。通常狀況下,爲了便於快速訪問,這些信息被保存在每一個對象的對象頭的鎖字(lock word)中。JRockit使用鎖字中的一些位來存儲垃圾回收狀態信息,雖然其中包含了垃圾回收信息,可是本書仍是稱之爲鎖字。

對象頭還包含了指向類型信息的指針,在 JRockit中,這稱爲類塊(class block)下圖是 JRockit中Java對象在不一樣的CPU平臺上的內存佈局。爲了節省內存,並加速解引用操做,對象頭中全部字的長度是32位。類塊是一個32位的指針,指向另外一個外部結構,該結構包含了當前對象的類型信息和虛分派表(virtual dispatch table)等信息。

就目前來看,在絕大部分JVM(包括JRockit)中,對象頭是使用兩個32位長的字來表示的。在JRockit中,偏移爲0的對象指針指向當前對象的類型信息,接下來是4字節的鎖字。在SPARC平臺上,對象頭的佈局恰好反過來,由於在使用原子指令操做指針時,若是沒有偏移的話,效率會更高。與鎖字不一樣,類塊並不爲原子操做所使用,所以在SPARC平臺上,類塊被放在鎖字後面。

原子操做(atomic operation)是指所有執行或所有不執行的本地指令。當原子指令所有執行時,其操做結果須要對全部潛在訪問者可見。

原子操做用於讀寫鎖字,具備排他性,這是實現JVM中同步塊的基礎。

難以調試

死鎖是指兩個線程都在等待對方釋放本身所需的資源,結果致使兩個線程都進入休眠狀態。很明顯,它們再也醒不過來了。活鎖的概念與死鎖相似,區別在於線程在竟爭時會採起主動操做,但沒法獲取鎖。這就像兩我的面對面前進,在一個很窄的走廊相遇,爲了能繼續前進,他們都向側面移動,但因爲移動的方向相反致使仍是沒法前進。

Java API

synchronized關鍵字

在Java中,關鍵字synchronized用於定義一個臨界區,既能夠是一段代碼塊,也能夠是個完整的方法,以下所示:

public synchronized void setGadget(Gadget g){
	this.gadget = g;
}
複製代碼

上面的方法定義中包含synchronized關鍵字,所以每次只能有一個線程修改給定對象的gadget域。

在同步方法中,監視器對象是隱式的,即當前對象this,而對靜態同步方法來講,監視器對象是當前對象的類對象。上面的示例代碼與下面的代碼是等效的:

public void setGadget(Gadget g){
	synchronized(this){
		this.gadget = g;
	}
}
複製代碼

java.lang.Thread類

Java中的線程也有優先級概念,可是否真的起做用取決於JVM的具體實現。setPriority方法用於設置線程的優先級,提示JVM該線程更加劇要或不怎麼重要。固然,對於大多數JVM來講,顯式地修改線程優先級沒什麼大幫助。當運行時「有更好的方案」時, JRockit JVM甚至會忽略Java線程的優先級。

正在運行的線程能夠經過調用yield方法主動放棄剩餘的時間片,以便其餘線程運行,自身休眠(調用wait方法)或等待其餘線程結束再運行(調用join方法)。

volatile 關鍵字

在多線程環境下,對某個屬性域或內存地址進行寫操做後,其餘正在運行的線程未必能當即看到這個結果。在某些場景中,要求全部線程在執行時須要得知某個屬性最新的值,爲此,Java提供了關鍵字volatile來解決此問題。

使用volatile修飾屬性後,能夠保證對該屬性域的寫操做會直接做用到內存中。本來,數據操做僅僅將數據寫到CPU緩存中,過一會再寫到內存中,正因如此,在同一個屬性域上,不一樣的線程可能看到不一樣的值。目前,JVM在實現volatile關鍵字時,是經過在寫屬性操做後插入內存屏障代碼來實現的,只不過這種方法有一點性能損耗。

人們經常難以理解「爲何不一樣的線程會在同一個屬性域上看到不一樣的值」。通常來講,目前的機器的內存模型已經足夠強,或者應用程序的自己結構就不容易使非volatile屬性出現這個問題。可是,考慮到JIT優化編譯器可能會對程序作較大改動,若是開發人員不留心的話,仍是會出現問題的。下面的示例代碼解釋了在Java程序中,爲何內存語義如此重要,尤爲是當問題還沒表現出來的時候。

public class My Thread extends Thread{
	private volatile boolean finished;
	public void run(){
		while(!finished){
   			//
		}
	}
	public void signalDone(){
		this.finished = true
	}
}
複製代碼

若是定義變量finished時沒有加上volatile關鍵字,那麼在理論上,JIT編譯器在優化時,可能會將之修改成只在循環開始前加載一次finished的值,但這就改變了代碼本來的含義若是finished的值是false,那麼程序就會陷入無限循環,即便其餘線程調用了signalDone方法也沒用。Java語言規範指明,若是編譯器認爲合適的話,能夠爲非 volatile變量在線程內建立副本以便後續使用。

因爲通常會使用內存屏障來實現volatile關鍵字的語義,會致使CPU緩存失效,下降應用程序總體性能,使用的時候要謹慎。

Java中線程與同步機制的實現

Java內存模型

如今CPU架構中,廣泛使用了數據緩存機制以大幅提高CPU對數據的讀寫速度,減輕處理器總線的競爭程度。正如全部的緩存系統同樣,這裏也存在一致性問題,對於多處理器系統來講尤爲重要,由於多個處理器有可能同時訪問內存中同一位置的數據內存模型定義了不一樣的CPU,在同時訪問內存中同一位置時,是否會看到相同的值的狀況。

強內存模型(例如x86平臺)是指,當某個CPU修改了某個內存位置的值後,其餘的CPU幾乎自動就能夠看到這個剛剛保存的值。在這種內存模型之下,內存寫操做的執行順序與代碼中的排列順序相同。弱內存模型(例如IA-64平臺)是指,當某個CPU修改了某個內存位置的值後其餘的CPU不必定能夠看到這個剛剛保存的值(除非CPU在執行寫操做時附有特殊的內存屏障類指令),更廣泛的說,全部由Java程序引發的內存訪問都應該對其餘全部CPU可見,但事實上卻不能保證當即可見。

同步的實現

原生機制

從計算機最底層CPU結構來講,同步是使用原子指令實現的,各個平臺的具體實現可能有所不一樣。以x86平臺爲例,它使用了專門的鎖前綴(lock prefix)來實現多處理器環境中指令的原子性。

在大多數CPU架構中,標準指令(例如加法和減法指令)均可以實現爲原子指令。

在微架構( micro- architecture)層面,原子指令的執行方式在各個平臺上不盡相同。通常狀況下,它會暫停CPU流水線的指令分派,直到全部已有的指令都完成執行,並將操做結果刷入到內存中。此外,該CPU還會阻止其餘CPU對相關緩存行的訪問,直到該原子指令結束執行。在現代x86硬件平臺上,若是屏障指令(fence instruction)中斷了比較複雜的指令執行,則該原子指令可能須要等上不少個時鐘週期才能完成執行。所以,不只是過多的臨界區會影響系統性能鎖的具體實現也會影響性能,當頻繁對較小的臨界區執行加鎖、解鎖操做時,性能損耗更是巨大。

同步在字節碼中的實現

Java字節碼中有兩條用於實現同步的指令,分別是monitorenter和monitorexit,它們都會從執行棧中彈出一個對象做爲其操做數。使用javac編譯源代碼時,若遇到顯式使用監視器對象的同步代碼,則爲之生成相應的monitorenter指令和monitorexit指令。

對於線程與同步的優化

鎖膨脹與鎖收縮

默認狀況下, JRockit使用一個小的自旋鎖來實現剛膨脹的胖鎖,只持續很短的時間。乍看之下,這不太符合常理,但這麼作確實是頗有益處的。若是鎖的竟爭確實很是激烈,而致使線程長時間自旋的話,能夠使用命令行參數-XX:UseFatSpin=false禁用此方式。做爲胖鎖的一部分,自旋鎖也能夠利用自適應運行時獲取到的反饋信息,這部分功能默認是禁用的,能夠使用命令行參數-XX:UseAdaptiveFatSpin=true來開啓。

延遲解鎖

如何分析不少線程局部的解鎖,以及從新加鎖的操做只會下降程序執行效率?這是不是程序運行的常態?運行時是否能夠假設每一個單獨的解鎖操做實際上都是沒必要要的?

若是某個鎖每次被釋放後又馬上都被同一個線程獲取,則運行時能夠作上述假設。但只要有另外某個線程試圖獲取這個看起來像是未被加鎖的監視器對象(這種狀況是符合語義的),這種假設就再也不成立了。這時爲了使這個監視器對象看起來像是一切正常,本來持有該監視器對象的線程須要強行釋放該鎖。這種實現方式稱爲延遲解鎖,在某些描述中也稱爲偏向鎖(biased locking)。

即便某個鎖徹底沒有競爭,執行加鎖和解鎖操做的開銷仍舊比什麼都不作要大。而使用原子指令會使該指令周圍的Java代碼都產生額外的執行開銷。

從以上能夠看出,假設大部分鎖都只在線程局部起做用而不會出現競爭狀況,是有道理的。在這種狀況下,使用延遲解鎖的優化方式能夠提高系統性能。固然,天下沒有免費的午飯,若是某個線程試圖獲取某個已經延遲解鎖優化的監視器對象,這時的執行開銷會被直接獲取普通監視器對象大得多,由於這個看似未加鎖的監視器對象必需要先被強行釋放掉所以,不能一直假設解鎖操做是沒必要要的,須要對不一樣的運行時行爲作針對性的優化。

1.實現

實現延遲解鎖的語義其實很簡單。

實現 monitorenter指令。

  • 若是對象是未鎖定的,則加鎖成功的線程將繼續持有該鎖,並標記該對象爲延遲加鎖的。
  • 若是對象已經被標記爲延遲加鎖的
    • 若是對象是被同一個線程加鎖的,則什麼也不作(大致上是一個遞歸鎖)
    • 若是對象是被另外一個線程加鎖的,則暫停該線程對鎖的持有狀態,檢查該對象真實的加鎖狀態,便是已加鎖的仍是未加鎖的,這一步操做代價高昂,須要遍歷調用棧。若是對象是已加鎖的,則將該鎖轉換爲瘦鎖,不然強制釋放該鎖,以即可以被新線程獲取到。

實現monitorexit指令:若是是延遲加鎖的對象,則什麼也不作,保留其已加鎖狀態,即執行延遲解鎖。

爲了能解除線程對鎖的持有狀態,必需要先暫停該線程的執行,這個操做有不小的開銷。在釋放鎖以後,鎖的實際狀態會經過檢查線程棧中的鎖符號來肯定。延遲解鎖使用本身的鎖符號,以表示「該對象是被延遲鎖定的」。

若是延遲鎖定的對象歷來也沒有被撤銷過,即全部的鎖都只在線程局部內發揮做用,那麼使用延遲鎖定就能夠大幅提高系統性能。但在實際應用中,若是咱們的假設不成立,運行時就不得不一遍又一遍地釋放已經被延遲加鎖的對象,這種性能消耗實在承受不起。所以,運行時須要記錄下監視器對象被不一樣線程獲取到的次數,這部分信息存儲在監視器對象的鎖字中,稱爲轉移位(transfer bit)。

若是監視器對象在不一樣的線程之間轉移的次數過多,那麼該對象、其類對象或者其類的全部實例均可能會被禁用延遲加鎖,只會使用標準的胖鎖和瘦鎖來處理加鎖或解鎖操做。

正如以前介紹過的,對象首先是未加鎖狀態的,而後線程T1執行monitorenter指令,使之進入延遲加鎖狀態。但若是線程T1在該對象上執行了monitorexit指令,這時系統會僞裝已經解鎖了,但實際上還是鎖定狀態,鎖對象的鎖字中仍記錄着線程T1的線程ID。在此以後線程T1若是再執行加鎖操做,就不用再執行相關操做了。

若是另外一個線程T2試圖獲取同一個鎖,則以前所作「該鎖絕大部分被線T1程使用」的假設再也不成立,會受到性能懲罰,將鎖字中的線程ID由線程T1的ID替換爲線程T2的。若是這狀況常常出現,那麼可能會禁用該對象做爲延遲鎖,並將該對象做爲普通的瘦鎖使用。

陷阱與僞優化

Thread.stop、Thread.resume和Thread.suspend

永遠不要使用Thread.stop方法、Thread.resume方法或Thread.suspend方法並當心處理使用這些方法的歷史遺留代碼。

廣泛建議使用wait方法、notify方法或volatile變量來作線程間的同步處理。

雙檢查鎖

若是對內存模型和CPU架構缺少理解的話,即便使用平遇到問題。如下面的代碼爲例,其目的是實現單例模式。

public class Gadget Holder{
	private Gadget theGadget;	
	public synchronized Gadget cetGadget(){
		if (this.theGadget == null){
			this.theGadget = new Gadget();
		}
		return this.theGadget;
	}
}

複製代碼

上面的代碼是線程安全的,由於getGadget方法是同步但當Gadget類的構造函數已經執行過一次以後,再執行同優化性能,將之改造爲下面的代碼。

public Gadget getGadget(){
	if (this.theGadget == null){
		synchronized(this){
			if(this.theGadget == null)){
				this.theGadget = new Gadget();
			}
		}
	}
	return this.theGadget;
}
複製代碼

上面的代碼使用了一個看起來很「聰明」的技巧,若是行同步操做,而是直接返回已有的對象;若是對象還未建立值。這樣能夠保證「線程安全」。

上述代碼就是所謂的雙檢查鎖(double checked locking),下面分析一下這段代碼的問題。假設某個線程通過內層的空值檢查,開始初始化theGadget字段的值,該線程須要爲新對象分配內存,並對theGadget字段賦值。但是,這一系列操做並非原子的,且執行順序沒法保證。若是在此時正好發生線程上下文切換,則另外一個線程看到的theGadget字段的值多是未經完整初始化的,有可能會致使外層的控制檢查失效,並返回這個未經完整初始化的對象。不只僅是建立對象可能會出問題,處理其餘類型數據時也要當心。例如,在32位平臺上,寫入一個long型數據一般須要執行兩次32位數據的寫操做,而寫入int數據則無此顧慮。

上述問題能夠經過將 theGadget字段聲明爲 volatile來解決(注意,只在新版本的內存模型下才有效),增長的執行開銷儘管比使用synchronized方法的小,但仍是有的。若是不肯定當前版本的內存模型是否實現正確,不要使用雙檢查鎖。網上有不少文章介紹了爲何不該該使用雙檢查鎖,不只限於Java,其餘語言也是。

雙檢查鎖的危險之處在於,在強內存模型下,它不多會使程序崩潰。Intel IA-64平臺就是個典型示例,其弱內存模型臭名遠揚,本來好好運行的Java應用程序卻出現故障。若是某個應用程序在x86平臺運行良好,在x64平臺卻出問題,人們很容易懷疑是JVM的bug,卻忽視了有多是Java應用程序自身的問題。

使用靜態屬性來實現單例模式能夠實現一樣的語義,而無須使用雙檢查鎖,以下所示:

public class GadgetMaker{
	public static Gadget theGadget= new Gadget();
}
複製代碼

Java語言保證類的初始化是原子操做, GadgetMaker類中沒有其餘的域,所以,在首次主動使用該類時會自動建立 Gadget類的實例。並賦值給theGadget字段。這種方法在新舊兩種內存模型下都可正常工做。

總之,使用Java作並行程序開發有不少須要當心的地方,若是可以正確理解Java內存模型那麼是能夠避開這些陷阱的。開發人員每每不太關心當前的硬件架構,但若是不能理解Java內存模型,早晚會搬起石頭砸本身的腳。

基準測試與性能調優

wait方法、notify方法與胖鎖

Java並不是萬能的

Java是一門強大的通用編程語言,因其友好的語義和內建的內的開發進度,但Java不是萬能的,這裏來談談不宜使用Java解決的場景:

  • 要開發一個有近實時性要求的電信應用程序,而且其中會有其中會有成千上萬的線程併發執行。
  • 應用程序的數據庫層所返回的數據常常是20MB的字節數組。
  • 應用程序性能和行爲的肯定性,徹底依賴於底層操做系統的調度器,即便調度器有微小變化也會對應用程序性能產生較大影響。
  • 開發設備驅動程序。
  • 使用 C/Fortran/COBOL等語言開發的歷史遺留代碼太多,目前團隊手中尚未好用的工具能夠將這些代碼轉換爲Java代碼。

除了上面的示例外,還有其餘不少場景不適宜使用Java。經過JvM對底層操做系統的抽象Java實現了「一次編寫,處處運行」,也所以受到了普遍關注。但誇大一點說,ANSI C也能作到這一點,只不過在編寫源代碼時,要花不少精力來應對可移植性問題。所以要結合實際場景選擇合適的工具。Java是好用,但也不要濫用。

博主

我的微信公衆號:

我的github:

github.com/jiankunking

我的博客:

jiankunking.com

相關文章
相關標籤/搜索