新四季網

深入理解jvm讓你輕鬆搞定這些(JRockit權威指南深入理解JVM)

2023-09-17 23:18:28 4

起步

將應用程式遷移到JRockit

命令行選項

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

1、系統屬性

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

2、標準選項

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

3、非標準選項

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

自適應代碼生成

Java虛擬機

假設應用程式是使用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是好用,但也不要濫用。

博主

,
同类文章
葬禮的夢想

葬禮的夢想

夢見葬禮,我得到了這個夢想,五個要素的五個要素,水火只好,主要名字在外面,職業生涯良好,一切都應該對待他人治療誠意,由於小,吉利的冬天夢想,秋天的夢是不吉利的
找到手機是什麼意思?

找到手機是什麼意思?

找到手機是什麼意思?五次選舉的五個要素是兩名士兵的跡象。與他溝通很好。這是非常財富,它擅長運作,職業是仙人的標誌。單身男人有這個夢想,主要生活可以有人幫忙
我不怎麼想?

我不怎麼想?

我做了什麼意味著看到米飯烹飪?我得到了這個夢想,五線的主要土壤,但是Tu Ke水是錢的跡象,職業生涯更加真誠。他真誠地誠實。這是豐富的,這是夏瑞的巨星
夢想你的意思是什麼?

夢想你的意思是什麼?

你是什​​麼意思夢想的夢想?夢想,主要木材的五個要素,水的跡象,主營業務,主營業務,案子應該抓住魅力,不能疏忽,春天夢想的吉利夢想夏天的夢想不幸。詢問學者夢想
拯救夢想

拯救夢想

拯救夢想什麼意思?你夢想著拯救人嗎?拯救人們的夢想有一個現實,也有夢想的主觀想像力,請參閱週宮官方網站拯救人民夢想的詳細解釋。夢想著敵人被拯救出來
2022愛方向和生日是在[質量個性]中

2022愛方向和生日是在[質量個性]中

[救生員]有人說,在出生88天之前,胎兒已經知道哪天的出生,如何有優質的個性,將走在什麼樣的愛情之旅,將與生活生活有什么生活。今天
夢想切割剪裁

夢想切割剪裁

夢想切割剪裁什麼意思?你夢想切你的手是好的嗎?夢想切割手工切割手有一個真正的影響和反應,也有夢想的主觀想像力。請參閱官方網站夢想的細節,以削減手
夢想著親人死了

夢想著親人死了

夢想著親人死了什麼意思?你夢想夢想你的親人死嗎?夢想有一個現實的影響和反應,還有夢想的主觀想像力,請參閱夢想世界夢想死亡的親屬的詳細解釋
夢想搶劫

夢想搶劫

夢想搶劫什麼意思?你夢想搶劫嗎?夢想著搶劫有一個現實的影響和反應,也有夢想的主觀想像力,請參閱週恭吉夢官方網站的詳細解釋。夢想搶劫
夢想缺乏缺乏紊亂

夢想缺乏缺乏紊亂

夢想缺乏缺乏紊亂什麼意思?你夢想缺乏異常藥物嗎?夢想缺乏現實世界的影響和現實,還有夢想的主觀想像,請看官方網站的夢想組織缺乏異常藥物。我覺得有些東西缺失了