流行問題:哪種語言的原始分配性能更快,Java 語言還是 C/C++?答案可能令人驚訝 —— 現代 JVM 中的分配比執行得最好的 malloc 實現還要快得多。HotSpot 1.4.2 之後虛擬機中的 new Object() 常見代碼路徑最多 10 條機器指令(Sun 提供的數據;請參閱 參考資料),而用 C 語言實現的執行得最好的 malloc 實現,每個調用平均要求的指令在 60 到 100 條之間(Detlefs 等;請參閱 參考資料)。而且分配性能在整體性能中不是一個微不足道的部分,測評顯示:對於許多實際的 C 和 C++ 程序(例如 Perl 和 Ghostscript),整體執行時間中的 20% 到 30% 都花在 malloc 和 free 上,遠遠多於健康的 Java 應用程序在分配和垃圾收集上的開銷(Zorn;請參閱 參考資料)。
繼續,弄得一團糟
沒有必要搜索眾多的 blog 或 Slashdot 貼子,去尋找像“垃圾收集永遠不會像直接內存管理一樣有效”這樣能夠說服人的陳述。而且,從某個方面來說,這些話說的是對的 —— 動態內存管理並不一樣快 —— 而是快得多。malloc/free 技術一次處理一個內存塊,而垃圾收集機制則采用大批量方式處理內存管理,從而形成更多的優化機會(以一些可以預見到的損失為代價)。
這條“聽起來有理的意見” (以大批量清理垃圾要比一天到晚一點點兒清理垃圾更容易)得到了數據的證實。一項研究(Zorn; 請參閱 參考資料)測量了在許多常見 C++ 應用程序中,用保守的 Boehm-Demers-Weiser(BDW)替換 malloc 的效果,結果是:許多程序在采用垃圾收集而不是傳統的分配器運行時,表現出了速度提升。(BDW 是個保守的、不移動的垃圾收集器,嚴重地限制了對分配和回收進行優化的能力,也限制了改善內存位置的能力;像 JVM 中使用的那些精確的浮動收集器可以做得更好。)
在 JVM 中的分配並不總是這麼快,早期 JVM 的分配和垃圾收集性能實際上很差,這當然就是 JVM 分配慢這一說法的起源。在非常早的時候,我們看到過許多“分配慢”的意見 —— 因為就像早期 JVM 中的一切一樣,它確實慢 —— 而性能顧問提供了許多避免分配的技巧,例如對象池。(公共服務聲明:除了對最重量的對象之外,對象池現在對於所有對象都是嚴重的性能損失,而且要在不造成並發瓶頸的情況下使用對象池也很需要技巧。)但是,從 JDK 1.0 開始已經發生了許多變化;JDK 1.2 中引入的分代收集器(generational collector)支持簡單得多的分配方式,可以極大地提高性能。
分代垃圾收集
分代垃圾收集器把堆分成多代;多數 JVM 使用兩代,“年輕代”和“年老代”。對象在年輕代中分配;如果它們在一定數量的垃圾收集之後仍然存在,就被當作是”長壽的“,並晉升到年老代。
HotSpot 提供了使用三個年輕代收集器的選擇(串行拷貝、並行拷貝和並行清理),它們都采用“拷貝”收集器的形式,有幾個重要的公共特征。拷貝收集器把內存空間從中間分成兩半,每次只使用一半。開始時,使用中的一半構成了可用內存的一個大塊;分配器滿足分配請求時,返回它沒有使用的空間的前 N 個字節,並把指針(分隔“使用”部分)從“自由”部分移動過來,如清單 1 的偽代碼所示。當使用的那一半用滿時,垃圾收集器把所有活動對象(不是垃圾的那些對象)拷貝到另一半的底部(把堆壓縮成連續的),然後從另一半開始分配。
清單 1. 在存在拷貝收集器的情況下,分配器的行為
void *malloc(int n) {
if (heapTop - heapStart < n)
doGarbageCollection();
void *wasStart = heapStart;
heapStart += n;
return wasStart;
}
從這個偽代碼可以看出為什麼拷貝收集器可以實現這麼快的分配 —— 分配新對象只是檢查在堆中是否還有足夠的剩余空間,如果還有,就移動指針。不需要搜索自由列表、最佳匹配、第一匹配、lookaside 列表 ,只要從堆中取出前 N 個字節,就成功了。
如何回收?
但是分配僅僅是內存管理的一半,回收是另一半。對於多數對象來說,直接垃圾收集的成本為零。這是因為,拷貝收集器不需要訪問或拷貝死對象,只處理活動對象。所以在分配之後很快就變成垃圾的對象,不會造成收集周期的工作量。
在典型的面向對象程序中,絕大多數對象(根據不同的研究,在 92% 到 98% 之間)“死於年輕”,這意味著它們在分配之後,通常在下一次垃圾收集之前,很快就變成垃圾。(這個屬性叫作 分代假設,對於許多面向對象語言已經得到實際測試,證明為真。)所以,不僅分配要快,對於多數對象來說,回收也要自由。
線程本地分配
如果分配器完全像 清單 1 所示的那樣實現,那麼共享的 heapStart 字段會迅速變成顯著的並發瓶頸,因為每個分配都要取得保護這個字段的鎖。為了避免這個問題,多數 JVM 采用了 線程本地分配塊,這時每個線程都從堆中分配一個更大的內存塊,然後順序地用這個線程本地塊為小的分配請求提供服務。所以,線程花在獲得共享堆鎖的大量時間被大大減少,從而提高了並發性。(在傳統的 malloc 實現的情況下要解決這個問題更困難,成本更高;把線程支持和垃圾收集都構建進平台促進了這類協作。)
回頁首
堆棧分配
C++ 向程序員提供了在堆或堆棧中分配對象的選擇。基於堆棧的分配更有效:分配更便宜,回收成本真正為零,而且語言提供了隔離對象生命周期的幫助,減少了忘記釋放對象的風險。另一方面,在 C++ 中,在發布或共享基於堆棧的對象的引用時,必須非常小心,因為在堆棧幀整理時,基於堆棧的對象會被自動釋放,從而造成孤懸的指針。
基於堆棧的分配的另一個優勢是它對高速緩存更加友好。在現代的處理器上,緩存遺漏的成本非常顯著,所以如果語言和運行時能夠幫助程序實現更好的數據位置,就會提高性能。堆棧的頂部通常在高速緩存中是“熱”的,而堆的頂部通常是“冷”的(因為從這部分內存使用之後可能過了很長時間)。所以,在堆上分配對象,比起在堆棧上分配對象,會帶來更多緩存遺漏。
更糟的是,在堆上分配對象時,緩存遺漏還有一個特別討厭的內存交互。在從堆中分配內存時,不管上次使用內存之後留下了什麼內容,內存中的內容都被當作垃圾。如果在堆的頂部分配的內存塊不在緩存中,執行會在內存內容裝入緩存的過程中出現延遲。然後,還要用 0 或其他初始值覆蓋掉剛剛費時費力裝入緩存的那些值,從而造成大量內存活動的浪費。(有些處理器,例如 Azul 的 Vega,包含加速堆分配的硬件支持。)
escape 分析
Java 語句沒有提供任何明確地在堆棧上分配對象的方式,但是這個事實並不影響 JVM 仍然可以在適當的地方使用堆棧分配。JVM 可以使用叫作 escape 分析 的技術,通過這項技術,JVM 可以發現某些對象在它們的整個生命周期中都限制在單一線程內,還會發現這個生命周期綁定到指定堆棧幀的生命周期上。這樣的對象可以安全地在堆棧上而不是在堆上分配。更好的是,對於小型對象,JVM 可以把分配工作完全優化掉,只把對象的字段放入寄存器。
清單 2 顯示了一個可以用 escape 分析把堆分配優化掉的示例。Component.getLocation() 方法對組件的位置做了一個保護性的拷貝,這樣調用者就無法在不經意間改變組件的實際位置。先調用 getDistanceFrom() 得到另一個組件的位置,其中包括對象的分配,然後用 getLocation() 返回的 Point 的 x 和 y 字段計算兩個組件之間的距離。
清單 2. 返回復合值的典型的保護性拷貝方式
public class Point {
private int x, y;
public Point(int x, int y) {
this.x = x; this.y = y;
}
public Point(Point p) { this(p.x, p.y); }
public int getX() { return x; }
public int getY() { return y; }
}
public class Component {
private Point location;
public Point getLocation() { return new Point(location); }
public double getDistanceFrom(Component other) {
Point otherLocation = other.getLocation();
int deltaX = otherLocation.getX() - location.getX();
int deltaY = otherLocation.getY() - location.getY();
return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
}
}
getLocation() 方法不知道它的調用者要如何處理它返回的 Point;有可能得到一個指向 Point 的引用,比如把它放在集合中,所以 getLocation() 采用了保護性的編碼方式。但是,在這個示例中,getDistanceFrom() 並不會這麼做,它只會使用 Point 很短的時間,然後釋放它,這看起來像是對完美對象的浪費。
聰明的 JVM 會看出將要進行的工作,並把保護性拷貝的分配優化掉。首先,對 getLocation() 的調用會變成內聯的,對 getX() 和 getY() 的調用也同樣處理,從而導致 getDistanceFrom() 的表現會像清單 3 一樣有效。
清單 3. 偽代碼描述了把內聯優化應用到 getDistanceFrom() 的結果
public double getDistanceFrom(Component other) {
Point otherLocation = new Point(other.x, other.y);
int deltaX = otherLocation.x - location.x;
int deltaY = otherLocation.y - location.y;
return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
}
在這一點上,escape 分析可以顯示在第一行分配的對象永遠不會脫離它的基本塊,而 getDistanceFrom() 也永遠不會修改 other 組件的狀態。(escape 指的是對象引用沒有保存到堆中,或者傳遞給可能保留一份拷貝的未知代碼。)如果 Point 真的是線程本地的,而且也清楚它的生命周期限制在分配它的基本塊內,那麼它既可以進行堆棧分配,也可以完全優化掉,如清單 4 所示。
清單 4. 偽代碼描述了從 getDistanceFrom() 優化掉分配後的結果
public double getDistanceFrom(Component other) {
int tempX = other.x, tempY = other.y;
int deltaX = tempX - location.x;
int deltaY = tempY - location.y;
return Math.sqrt(deltaX*deltaX + deltaY*deltaY);
}
結果就是得到了與所有字段都是 public 時能夠得到的相同的性能,同時保持了封裝和保護性拷貝(在其他安全編碼技術之中)提供的安全性。
Mustang 中的 escape 分析
escape 分析是一項被議論了很久的優化,它最後終於出現了:Mustang(Java SE 6)的當前構建中可以做 escape 分析,並在適當的地方把堆分配轉換成堆棧分析(或者不分配)。用 escape 分析清除一些分配,會帶來更快的平均分配時間,簡化的內存工作,更少的緩存遺漏。而且,優化掉一些分配,可以降低垃圾收集器的壓力,從而讓收集運行得更少。
即使在源代碼中進行堆棧分配不太現實的地方,即使語言提供了分配的選項,escape 分析也能找到堆棧分配的機會,因為特定的分配是否會被優化掉,是根據特定代碼路徑中實際上如何使用對象返回方法的結果而決定的。getLocation() 返回的 Point 可能不是在所有情況下都適合進行堆棧分配,但是一旦 JVM 內聯了 getLocation(),它就可以自由而且獨立地優化每個調用,從而在兩方面都提供了最好的結果:最優的性能,最少的時間花在進行低級的性能調整決策上。
結束語
JVM 擅長發現我們一直以為只有開發人員才能知道的事情,這令人震驚。讓 JVM 根據具體情況在堆棧分配和堆分配之間進行選擇,我們就能得到堆棧分配的性能好處,卻不必讓程序員在進行堆棧分配還是進行堆分配上費腦筋。