Java對象竟然會在棧上分配內存?
1 逃逸分析
JVM中高深的優(yōu)化技術,如同類繼承關系分析,該技術并非直接去優(yōu)化代碼,而是一種為其他優(yōu)化措施提供依據(jù)的分析技術。
分析對象的動態(tài)作用域,當某對象在方法里被定義后,它可能
- 方法逃逸
被外部方法引用,例如作為參數(shù)傳遞給其他方法
- 線程逃逸
被外部線程訪問,例如賦值給可以在其他線程中訪問的實例變量
所以 Java 對象由低到高的逃逸程度即為:
- 不逃逸 =》
- 方法逃逸 =》
- 線程逃逸
若能確定一個對象
- 不會逃逸到方法或線程外(即其它方法、線程無法訪問到該對象)
- 或逃逸程度較低(只逃逸出方法而不逃逸出線程)
則可為該對象實例采取不同程度的優(yōu)化方案。
2 優(yōu)化方案
2.1 棧上分配(Stack Allocations)
由于復雜度等原因,HotSpot中目前暫時還沒有做這項優(yōu)化,但一些其他的虛擬機(如Excelsior JET)使用了該優(yōu)化。
JVM的GC模塊會回收堆中不再使用的對象,但如下回收動作
- 標記篩選出可回收對象
- 回收和整理內存
都需耗費大量資源。
若確定一個對象不會逃逸出線程,那讓該對象在棧上分配內存就是個不錯主意,對象所占用內存空間就可隨棧幀出棧而銷毀。
在一般應用中,完全不會逃逸的局部對象和不會逃逸出線程的對象所占比例很大,若能使用棧上分配,則大量對象就會隨方法結束而自動銷毀,GC系統(tǒng)壓力會下降很多。
棧上分配可支持方法逃逸,但不能支持線程逃逸。
2.2 標量替換(Scalar Replacement)
2.2.1 標量
若一個數(shù)據(jù)已經無法再分解成更小數(shù)據(jù),JVM中的原始數(shù)據(jù)類型(如 int、long 等數(shù)值類型及 reference 類型)都不能再進一步分解,這些數(shù)據(jù)即為標量。
2.2.2 聚合量
若一個數(shù)據(jù)可繼續(xù)分解,則稱為聚合量(Aggregate),比如 Java 對象就是聚合量。
2.2.3 標量替換
把一個Java對象拆散,根據(jù)程序訪問情況,將其用到的成員變量恢復為原始類型來訪問。
假如逃逸分析能證明一個對象不會被方法外部訪問,并且該對象可被分解,那么程序真正執(zhí)行時將可能不去創(chuàng)建該對象,而改為直接創(chuàng)建它的若干個被這方法使用的成員變量。
將對象拆分后:
- 可讓對象的成員變量在棧上 (棧上存儲的數(shù)據(jù),很大概率會被JVM分配至物理機器的高速寄存器中存儲)分配和讀寫
- 為后續(xù)進步優(yōu)化創(chuàng)建條件
2.2.4 適用場景
標量替換可視為棧上分配一種特例,實現(xiàn)更簡單(不用考慮對象完整結構的分配),但對逃逸程度的要求更高,它不允許對象逃逸出方法范圍內。
2.3 同步消除(Synchronization Elimination)
線程同步是個相對耗時的過程,若逃逸分析能確定一個變量不會逃逸出線程,即不會被其他線程訪問,則該變量的讀寫肯定不會有線程競爭, 也可安全消除對該變量實施的同步措施。
逃逸分析的論文在1999年就已發(fā)表,但到JDK 6,HotSpot才開始初步支持逃逸分析,至今該也尚未成熟,主要因為逃逸分析的計算成本高到無法保證帶來的性能收益會高于它的消耗。要百分百準確判斷一個對象是否會逃逸,需進行一系列復雜數(shù)據(jù)流敏感的過程間分析,才能確定程序各個分支執(zhí)行時對此對象的影響。過程間分析這種大壓力的分析算法正是即時編譯的弱項。試想,若逃逸分析完畢后發(fā)現(xiàn)幾乎找不到幾個不逃逸的對象, 那這些運行期耗用的時間就白費了,所以目前JVM只能采用不那么準確,但時間壓力相對較小的算法來完成分析。
C和C++原生支持棧上分配(不使用new即可),靈活運用棧內存方面,Java的確是弱勢群體。
在現(xiàn)在仍處于實驗階段的Valhalla項目,設計了新的inline關鍵字用于定義Java的內聯(lián)類型, 對標C#的值類型。有了該標識與約束,以后逃逸分析做起來就會簡單很多。
3 代碼實戰(zhàn)驗證
3.1 全無優(yōu)化的代碼
- public int test(int x) {
- int xx = x + 2;
- Point p = new Point(xx, 42);
- return p.getX();
- }
3.2 優(yōu)化step1:內聯(lián)構造器和getX()方法
- public int test(int x) {
- int xx = x + 2;
- // 在堆中分配P對象
- Point p = point_memory_alloc();
- // Point構造器被內聯(lián)后
- p.x = xx;
- p.y = 42;
- // Point::getX()被內聯(lián)后
- return p.x;
- }
優(yōu)化step2:標量替換
逃逸分析后,發(fā)現(xiàn)在整個test()方法的范圍內Point對象實例不會發(fā)生任何程度逃逸, 便可對它進行標量替換:把其內部的x和y直接置換出來,分解為test()方法內的局部變量,從而避免了Point對象實例的創(chuàng)建
- public int test(int x) {
- int xx = x + 2;
- int px = xx;
- int py = 42
- return px;
- }
step3:無效代碼消除
數(shù)據(jù)流分析,發(fā)現(xiàn)py的值其實對方法不會造成任何影響,那就可以放心地去做無效代碼消除得到最終優(yōu)化結果,如下所示:
- public int test(int x) {
- return x + 2;
- }
觀察測試結果,實施逃逸分析后的程序在MicroBenchmarks中往往能得到不錯的成績,但在實際應用程序中,尤其是大型程序中反而發(fā)現(xiàn)實施逃逸分析可能出現(xiàn)效果不穩(wěn)定,或分析過程耗時但卻無法有效判別出非逃逸對象而導致性能(即時編譯的收益)下降,所以曾經在很長的一段時間,即使是服務端編譯器,也默認不開啟逃逸分析(從JDK 6 Update 23開始,服務端編譯器中開始才默認開啟逃逸分析。),甚至在某些版本(如JDK 6 Update 18)中還曾完全禁止這項優(yōu)化,一直到JDK 7時這項優(yōu)化才成為服務端編譯器默認開啟的選項。
若有需要或確認對程序有益,可使用參數(shù):
- -XX:+DoEscapeAnalysis 手動開啟逃逸分析
開啟后可通過參數(shù):
- -XX:+PrintEscapeAnalysis 查看分析結果
有逃逸分析支持后,用戶可使用如下參數(shù):
- -XX:+EliminateAllocations 開啟標量替換
- +XX:+EliminateLocks 開啟同步消除
- -XX:+PrintEliminateAllocations 查看標量的替換情況
讓我們一起期待該JIT優(yōu)化技術之逃逸分析的發(fā)展。
參考
《深入理解 Java 虛擬機》
本文轉載自微信公眾號「JavaEdge」,可以通過以下二維碼關注。轉載本文請聯(lián)系JavaEdge公眾號。