1.盡量使用final修飾符。
帶有final修飾符的類是不可派生的。在JAVA核心API中,有許多應用final的例子,例如java.lang.String。為String類指定final防止了使用者覆寫length()方法。另外,如果一個類是final的,則該類所有方法都是final的。java編譯器會尋找機會内聯(inline)所有的final方法(這和具體的編譯器實作有關)。此舉能夠使性能平均提高50%。
2.盡量重用對象。
特别是String對象的使用中,出現字元串連接配接情況時應使用StringBuffer代替,由于系統不僅要花時間生成對象,以後可能還需要花時間對這些對象進行垃圾回收和處理。是以生成過多的對象将會給程式的性能帶來很大的影響。
3.盡量使用局部變量。
調用方法時傳遞的參數以及在調用中建立的臨時變量都儲存在棧(Stack)中,速度較快。其他變量,如靜态變量,執行個體變量等,都在堆(Heap)中建立,速度較慢。
4.不要重複初始化變量。
預設情況下,調用類的構造函數時,java會把變量初始化成确定的值,所有的對象被設定成null,整數變量設定成0,float和double變量設定成0.0,邏輯值設定成false。當一個類從另一個類派生時,這一點尤其應該注意,因為用new關鍵字建立一個對象時,構造函數鍊中的所有構造函數都會被自動調用。
這裡有個注意,給成員變量設定初始值但需要調用其他方法的時候,最好放在一個方法比如initXXX()中,因為直接調用某方法指派可能會因為類尚未初始化而抛空指針異常,public int state = this.getState();
5.在java+Oracle的應用系統開發中,java中内嵌的SQL語言應盡量使用大寫形式,以減少Oracle解析器的解析負擔。
6.java程式設計過程中,進行資料庫連接配接,I/O流操作,在使用完畢後,及時關閉以釋放資源。因為對這些大對象的操作會造成系統大的開銷。
7.過分的建立對象會消耗系統的大量記憶體,嚴重時,會導緻記憶體洩漏,是以,保證過期的對象的及時回收具有重要意義。
JVM的GC并非十分智能,是以建議在對象使用完畢後,手動設定成null。
8.在使用同步機制時,應盡量使用方法同步代替代碼塊同步。
9.盡量減少對變量的重複計算。
比如
應修改為
10.采用在需要的時候才開始建立的政策。
例如:
應修改為:
11.慎用異常,異常對性能不利。
抛出異常首先要建立一個新的對象。Throwable接口的構造函數調用名為fillInStackTrace()的本地方法,fillInStackTrace()方法檢查棧,收集調用跟蹤資訊。隻要有異常被抛出,VM就必須調整調用棧,因為在處理過程中建立了一個新的對象。
異常隻能用于錯誤處理,不應該用來控制程式流程。
12.不要在循環中使用Try/Catch語句,應把Try/Catch放在循環最外層。
Error是擷取系統錯誤的類,或者說是虛拟機錯誤的類。不是所有的錯誤Exception都能擷取到的,虛拟機報錯Exception就擷取不到,必須用Error擷取。
13.通過StringBuffer的構造函數來設定他的初始化容量,可以明顯提升性能。
StringBuffer的預設容量為16,當StringBuffer的容量達到最大容量時,她會将自身容量增加到目前的2倍+2,也就是2*n+2。無論何時,隻要StringBuffer到達她的最大容量,她就不得不建立一個新的對象數組,然後複制舊的對象數組,這會浪費很多時間。是以給StringBuffer設定一個合理的初始化容量值,是很有必要的!
14.合理使用java.util.Vector。
Vector與StringBuffer類似,每次擴充容量時,所有現有元素都要指派到新的存儲空間中。Vector的預設存儲能力為10個元素,擴容加倍。
vector.add(index,obj) 這個方法可以将元素obj插入到index位置,但index以及之後的元素依次都要向下移動一個位置(将其索引加 1)。 除非必要,否則對性能不利。
同樣規則适用于remove(int index)方法,移除此向量中指定位置的元素。将所有後續元素左移(将其索引減 1)。傳回此向量中移除的元素。是以删除vector最後一個元素要比删除第1個元素開銷低很多。删除所有元素最好用removeAllElements()方法。
如果要删除vector裡的一個元素可以使用 vector.remove(obj);而不必自己檢索元素位置,再删除,如int index = indexOf(obj);vector.remove(index);
15.當複制大量資料時,使用System.arraycopy();
16.代碼重構,增加代碼的可讀性。
17.不用new關鍵字建立對象的執行個體。
用new關鍵詞建立類的執行個體時,構造函數鍊中的所有構造函數都會被自動調用。但如果一個對象實作了Cloneable接口,我們可以調用她的clone()方法。clone()方法不會調用任何類構造函數。
Factory模式
18.乘除法如果可以使用位移,應盡量使用位移,但最好加上注釋,因為位移操作不直覺,難于了解
19.不要将數組聲明為:public static final
20.HaspMap的周遊。
利用散列值取出相應的Entry做比較得到結果,取得entry的值之後直接取key和value。
21.array(數組)和ArrayList的使用。
array 數組效率最高,但容量固定,無法動态改變,ArrayList容量可以動态增長,但犧牲了效率。
22.單線程應盡量使用 HashMap, ArrayList,除非必要,否則不推薦使用HashTable,Vector,她們使用了同步機制,而降低了性能。
23.StringBuffer,StringBuilder的差別在于:java.lang.StringBuffer 線程安全的可變字元序列。一個類似于String的字元串緩沖區,但不能修改。StringBuilder與該類相比,通常應該優先使用StringBuilder類,因為她支援所有相同的操作,但由于她不執行同步,是以速度更快。為了獲得更好的性能,在構造StringBuffer或StringBuilder時應盡量指定她的容量。當然如果不超過16個字元時就不用了。
相同情況下,使用StringBuilder比使用StringBuffer僅能獲得10%~15%的性能提升,但卻要冒多線程不安全的風險。綜合考慮還是建議使用StringBuffer。
24.盡量使用基本資料類型代替對象。
25.用簡單的數值計算代替複雜的函數計算,比如查表方式解決三角函數問題。
26.使用具體類比使用接口效率高,但結構彈性降低了,但現代IDE都可以解決這個問題。
27.考慮使用靜态方法
如果你沒有必要去通路對象的外部,那麼就使你的方法成為靜态方法。她會被更快地調用,因為她不需要一個虛拟函數導向表。這同僚也是一個很好的實踐,因為她告訴你如何區分方法的性質,調用這個方法不會改變對象的狀态。
28.應盡可能避免使用内在的GET,SET方法。
android程式設計中,虛方法的調用會産生很多代價,比執行個體屬性查詢的代價還要多。我們應該在外包調用的時候才使用get,set方法,但在内部調用的時候,應該直接調用。
29.避免枚舉,浮點數的使用。
30.二維數組比一維數組占用更多的記憶體空間,大概是10倍計算。
31.SQLite資料庫讀取整張表的全部資料很快,但有條件的查詢就要耗時30-50MS,大家做這方面的時候要注意,盡量少用,尤其是嵌套查找!
4. 使用懶加載
懶加載 : 當要用的時候才建立該對象。
7.循環内盡量避免建立對象的引用。
尤其是循環量大的時候。
每次new Object()的時候,Object對象引用指向Object對象。
當循環次數多的時候,如第一種,JVM會建立1000個對象的引用,而第二種記憶體中隻有一份Object對象引用。這樣就大大節省了記憶體空間了。
8.不要随意使用static變量。
當對象被聲明為static的變量所引用時,此時,Java垃圾回收器不會清理這個對象所占用的堆記憶體。靜态變量所占用的堆記憶體直到該變量所在類所在程式結束才被釋放。 即靜态變量生命周期=類生命周期。
9.不要建立一些不使用的對象,不要導入一些不使用的類。
10.使用帶緩沖的I/O流:帶緩沖的I/O流可以極大提高I/O效率。BufferedWriter, BufferedReader, BufferedInputStream, BufferedOutputStream。
11.包裝類資料轉換為字元串使用: toString
Integer i = 1;
包裝類資料轉換為字元串方法速度排名 :
12.Map周遊效率 : entrySet > keySet
13.關于Iterator與forEach()的集合周遊舍取。
算法導論上說:算法是為了提高空間效率和時間效率。但往往時間和空間不能并存。
時間效率:Iterator > forEach()
代碼可讀性 : forEach() > Iterator
//Iterator
Set
但處理小資料的話,為了可讀性和後期維護還是使用forEach()。
兩者結合使用,都應該掌握。
1、及時清除不再使用的對象,設為null
2、盡可能使用final,static等關鍵字
3、盡可能使用buffered對象
如何優化代碼使JAVA源檔案及編譯後CLASS檔案更小
1 盡量使用繼承,繼承的方法越多,你要寫的代碼量也就越少
2 打開JAVA編譯器的優化選項: javac -O 這個選項将删除掉CLASS檔案中的行号,并能把
一些private, static,final的小段方法申明為inline方法調用
3 把公用的代碼提取出來 Util
4 不要初始化很大的數組,盡管初始化一個數組在JAVA代碼中隻是一行的代碼量,但編譯後的代碼是一行代碼插入一個數組的元素,是以如果你有大量的資料需要存在數組中的話,可以先把這些資料放在String中,然後在運作期把字元串解析到數組中
5 日期類型的對象會占用很大的空間,如果你要存儲大量的日期對象,可以考慮把它存儲為
long型,然後在使用的時候轉換為Date類型
6 類名,方法名和變量名盡量使用簡短的名字,可以考慮使用Hashjava, Jobe, Obfuscate and Jshrink等工具自動完成這個工作
7 将static final類型的變量定義到Interface中去
首先接口是一種高度抽象的”模版”,,而接口中的屬性也就是’模版’的成員,就應當是所有實作”模版”的實作類的共有特性,是以它是public static的 ,是所有實作類共有的 .假如可以是非static的話,因一個類可以繼承多個接口,出現重名的變量,如何區分呢?
其次,接口中如果可能定義非final的變量的話,而方法又都是abstract的,這就自相沖突了,有可變成員變量但對應的方法卻無法操作這些變量,雖然可以直接修改這些靜态成員變量的值,但所有實作類對應的值都被修改了,這跟抽象類有何差別? 又接口是一種更高層面的抽象,是一種規範、功能定義的聲明,所有可變的東西都應該歸屬到實作類中,這樣接口才能起到标準化、規範化的作用。是以接口中的屬性必然是final的。
最後,接口隻是對事物的屬性和行為更高層次的抽象 。對修改關閉,對擴充(不同的實作implements)開放,接口是對開閉原則(Open-Closed Principle )的一種展現。
8 算術運算 能用左移/右移的運算就不要用*和/運算,相同的運算不要運算多次
9 不要兩次初始化變量
Java通過調用獨特的類構造器預設地初始化變量為一個已知的值。所有的對象被設定成null,integers (byte, short, int, long)被設定成0,float和double設定成0.0,Boolean變量設定成false。這對那些擴充自其它類的類尤其重要,這跟使用一個新的關鍵詞建立一個對象時所有一連串的構造器被自動調用一樣。
10 在任何可能的地方讓類為Final
标記為final的類不能被擴充。在《核心Java API》中有大量這個技術的例子,諸如java.lang.String。将String類标記為final阻止了開發者建立他們自己實作的長度方法。
更深入點說,如果類是final的,所有類的方法也是final的。Java編譯器可能會内聯所有的方法(這依賴于編譯器的實作)。在我的測試裡,我已經看到性能平均增加了50%。
11 異常在需要抛出的地方抛出,try catch能整合就整合
12
更容易被編譯器優化
本文中,作者(Eva Andreasson)首先介紹了不同種類的編譯器,并對用戶端編譯,伺服器端編譯器和多層編譯的運作性能進行了對比。然後,在文章的最後介紹了幾種常見的JVM優化方法,如死代碼消除,代碼嵌入以及循環體優化。
Java最引以為豪的特性“平台獨立性”正是源于Java編譯器。軟體開發人員盡其所能寫出最好的java應用程式,緊接着背景運作的編譯器産生高效的基于目标平台的可執行代碼。不同的編譯器适用于不同的應用需求,因而也就産生不同的優化結果。是以,如果你能更好的了解編譯器的工作原理、了解更多種類的編譯器,那麼你就能更好的優化你的Java程式。
本篇文章突出強調和解釋了各種Java虛拟機編譯器之間的不同。同時,我也會探讨一些及時編譯器(JIT)常用的優化方案。
什麼是編譯器?
簡單來說,編譯器就是以某種程式設計語言程式作為輸入,然後以另一種可執行語言程式作為輸出。Javac是最常見的一種編譯器。它存在于所有的JDK裡面。Javac 以java代碼作為輸出,将其轉換成JVM可執行的代碼—位元組碼。這些位元組碼存儲在以.class結尾的檔案中,并在java程式啟動時裝載到java運作時環境。
位元組碼并不能直接被CPU讀取,它還需要被翻譯成目前平台所能了解的機器指令語言。JVM中還有另一個編譯器負責将位元組碼翻譯成目标平台可執行的指令。一些JVM編譯器需要經過幾個等級的位元組碼代碼階段。例如,一個編譯器在将位元組碼翻譯成機器指令之前可能還需要經曆幾種不同形式的中間階段。
從平台不可知論的角度出發,我們希望我們的代碼能夠盡可能的與平台無關。
為了達到這個目的,我們在最後一個等級的翻譯—從最低的位元組碼表示到真正的機器代碼—才真正将可執行代碼與一個特定平台的體系結構綁定。從最高的等級來劃分,我們可以将編譯器分為靜态編譯器和動态編譯器。 我們可以根據我們的目标執行環境、我們渴望的優化結果、以及我們需要滿足的資源限制條件來選擇合适的編譯器。在上一篇文章中我們簡單的讨論了一下靜态編譯器和動态編譯器,在接下來的部分我們将更加深入的解釋它們。
靜态編譯 VS 動态編譯
我們前面提到的javac就是一個靜态編譯的例子。對于靜态編譯器,輸入代碼被解釋一次,輸出即為程式将來被執行的形式。除非你更新源代碼并(通過編譯器)重新編譯,否則程式的執行結果将永遠不會改變:這是因為輸入是一個靜态的輸入并且編譯器是一個靜态的編譯器。
通過靜态編譯,下面的程式:
複制代碼 代碼如下:
staticint add7(int x ){ return x+7;}
将會轉換成類似下面的位元組碼:
iload0 bipush 7 iadd ireturn
動态編譯器動态的将一種語言編譯成另外一種語言,所謂動态的是指在程式運作的時候進行編譯—邊運作邊編譯!動态編譯和優化的好處就是可以處理應用程式加載時的一些變化。Java 運作時常常運作在不可預知甚至變化的環境上,是以動态編譯非常适用于Java 運作時。大部分的JVM 使用動态編譯器,如JIT編譯器。值得注意的是,動态編譯和代碼優化需要使用一些額外的資料結構、線程以及CPU資源。越進階的優化器或位元組碼上下文分析器,消耗越多的資源。但是這些花銷相對于顯著的性能提升來說是微不足道的。
JVM種類以及Java的平台獨立性
所有JVM的實作都有一個共同的特點就是将位元組碼編譯成機器指令。一些JVM在加載應用程式時對代碼進行解釋,并通過性能計數器來找出“熱”代碼;另一些JVM則通過編譯來實作。編譯的主要問題是集中需要大量的資源,但是它也能帶來更好的性能優化。
如果你是一個java新手,JVM的錯綜複雜肯定會搞得你暈頭轉向。但好消息是你并不需要将它搞得特别清楚!JVM将管理代碼的編譯和優化,你并不需要為機器指令以及采取什麼樣的方式寫代碼才能最佳的比對程式運作平台的體系結構而操心。
從java位元組碼到可執行
一旦将你的java代碼編譯成位元組碼,接下來的一步就是将位元組碼指令翻譯成機器代碼。這一步可以通過解釋器來實作,也可以通過編譯器來實作。
解釋
解釋是編譯位元組碼最簡單的方式。解釋器以查表的形式找到每條位元組碼指令對應的硬體指令,然後将它發送給CPU執行。
你可以将解釋器想象成查字典:每一個特定的單詞(位元組碼指令),都有一個具體的翻譯(機器代碼指令)與之對應。因為解釋器每讀一條指令就會馬上執行該指令,是以該方式無法對一組指令集進行優化。同時每調用一個位元組碼都要馬上對其進行解釋,是以解釋器運作速度是相當慢得。解釋器以一種非常準确的方式來執行代碼,但是由于沒有對輸出的指令集進行優化,是以它對目标平台的處理器來說可能不是最優的結果。
編譯
編譯器則是将所有将要執行的代碼全部裝載到運作時。這樣當它翻譯位元組碼時,就可以參考全部或部分的運作時上下文。它做出的決定都是基于對代碼圖分析的結果。如比較不同的執行分支以及參考運作時上下文資料。
在将位元組碼序列被翻譯成機器代碼指令集後,就可以基于這個機器代碼指令集進行優化。優化過的指令集存儲在一個叫代碼緩沖區的結構中。當再次執行這些位元組碼時,就可以直接從這個代碼緩沖區中取得優化過的代碼并執行。在有些情況下編譯器并不使用優化器來進行代碼優化,而是使用一種新的優化序列—“性能計數”。
使用代碼緩存器的優點是結果集指令可以被立即執行而不再需要重新解釋或編譯!
這可以大大的降低執行時間,尤其是對一個方法被多次調用的java應用程式。
優化
通過動态編譯的引入,我們就有機會來插入性能計數器。例如,編譯器插入性能計數器,每次位元組碼塊(對應某個具體的方法)被調用時對應的計數器就加一。編譯器通過這些計數器找到“熱塊”,進而就能确定哪些代碼塊的優化能對應用程式帶來最大的性能提升。運作時性能分析資料能夠幫助編譯器在聯機狀态下得到更多的優化決策,進而更進一步提升代碼執行效率。因為得到越多越精确的代碼性能分析資料,我們就可以找到更多的可優化點進而做出更好的優化決定,例如:怎樣更好的序列話指令、是否用更有效率的指令集來替代原有指令集,以及是否消除備援的操作等。
例如
考慮下面的java代碼
Javac 将靜态的将它翻譯成如下位元組碼:
iload0
bipush 7
iadd
ireturn
當該方法被調用時,該位元組碼将被動态的編譯成機器指令。當性能計數器(如果存在)達到指定的閥值時,該方法就可能被優化。優化後的結果可能類似下面的機器指令集:
lea rax,[rdx+7] ret
不同的編譯器适用于不同的應用
不同的應用程式擁有不同的需求。企業伺服器端應用通常需要長時間運作,是以通常希望對其進行更多的性能優化;而用戶端小程式可能希望更快的響應時間和更少的資源消耗。下面讓我們一起讨論三種不同的編譯器以及他們的優缺點。
用戶端編譯器(Client-side compilers)
C1是一種大家熟知的優化編譯器。當啟動JVM時,添加-client參數即可啟動該編譯器。通過它的名字我們即可發現C1是一種用戶端編譯器。它非常适用于那種系統可用資源很少或要求能快速啟動的用戶端應用程式。C1通過使用性能計數器來進行代碼優化。這是一種方式簡單,且對源代碼幹預較少的優化方式。
伺服器端編譯器(Server-side compilers)
對于那種長時間運作的應用程式(例如伺服器端企業級應用程式),使用用戶端編譯器可能遠遠不能夠滿足需求。這時我們應該選擇類似C2這樣的伺服器端編譯器。通過在JVM啟動行中加入 –server 即可啟動該優化器。因為大部分的伺服器端應用程式通常都是長時間運作的,與那些短時間運作、輕量級的用戶端應用相比,通過使用C2編譯器,你将能夠收集到更多的性能優化資料。是以你也将能夠應用更進階的優化技術和算法。
提示:預熱你的服務端編譯器
對于伺服器端的部署,編譯器可能需要一些時間來優化那些“熱點”代碼。是以伺服器端的部署常常需要一個“加熱”階段。是以當對伺服器端的部署進行性能測量時,務必確定你的應用程式已經達到了穩定狀态!給予編譯器充足的時間進行編譯将會給你的應用帶來很多好處。
伺服器端編譯器相比用戶端編譯器來說能夠得到更多的性能調優資料,這樣就可以進行更複雜的分支分析,進而找到性能更優的優化路徑。擁有越多的性能分析資料就能得到更優的應用程式分析結果。當然,進行大量的性能分析也就需要更多的編譯器資源。如JVM若使用C2編譯器,那麼它将需要使用更多的CPU周期,更大的代碼緩存區等等。
多層編譯
多層編譯混合了用戶端編譯和伺服器端編譯。Azul第一個在他的Zing JVM中實作了多層編譯。最近,這項技術已經被Oracle Java Hotspot JVM采用(Java SE7 之後)。多層編譯綜合了用戶端和伺服器端編譯器的優點。用戶端編譯器在以下兩種情況表現得比較活躍:應用啟動時;當性能計數器達到較低級别的門檻值時進行性能優化。用戶端編譯器也會插入性能計數器以及準備指令集以備接下來的進階優化—伺服器端編譯器—使用。多層編譯是一種資源使用率很高的性能分析方式。因為它可以在低影響編譯器活動時收集資料,而這些資料可以在後面更進階的優化中繼續使用。這種方式與使用解釋性代碼分析計數器相比可以提供更多的資訊。
圖1所描述的是解釋器、用戶端編譯、伺服器端編譯、多層編譯的性能比較。X軸是執行時間(時間機關),Y軸是性能(機關時間内的操作數)
圖1.編譯器性能比較
相對于純解釋性代碼,使用用戶端編譯器可以帶來5到10倍的性能提升。獲得性能提升的多少取決于編譯器的效率、可用的優化器種類以及應用程式的設計與目标平台的吻合程度。但對應程式開發人員來講最後一條往往可以忽略。
相對于用戶端編譯器,伺服器端編譯器往往能帶來30%到50%的性能提升。在大多數情況下,性能的提升往往是以資源的損耗為代價的。
多層編譯綜合了兩種編譯器的優點。用戶端編譯有更短的啟動時間以及可以進行快速優化;伺服器端編譯則可以在接下來的執行過程中進行更進階的優化操作。
一些常見的編譯器優化
到目前為止,我們已經讨論了優化代碼的意義以及怎樣、何時JVM會進行代碼優化。接下來我将以介紹一些編譯器實際用到的優化方式來結束本文。JVM優化實際發生在位元組碼階段(或者更底層的語言表示階段),但是這裡将使用java語言來說明這些優化方式。我們不可能在本節覆寫所有的JVM優化方式;當然啦,我希望通過這些介紹能激發你去學習數以百計的更進階的優化方式的興趣并在編譯器技術方面有所創新。
死代碼消除
死代碼消除,顧名思義就是消除那些永遠不會被執行到的代碼—即“死”代碼。
如果編譯器在運作過程中發現一些多餘指令,它将會将這些指令從執行指令集裡面移除。例如,在清單1裡面,其中一個變量在對其進行指派操作後永遠不會被用到,所有在執行階段可以完全地忽略該指派語句。對應到位元組碼級别的操作即是,永遠不需要将該變量值加載到寄存器中。不用加載意味着消耗更少的cpu時間,是以也就能加快代碼執行,最終導緻應用程式加快—如果該加載代碼每秒被調用好多次,那優化效果将更明顯。
清單1 用java 代碼列舉了一個對永遠不會被使用的變量指派的例子。
清單1. 死代碼
int timeToScaleMyApp(boolean endlessOfResources){
int reArchitect =24;
int patchByClustering =15;
int useZing =2;
if(endlessOfResources)
return reArchitect + useZing;
else
return useZing;
}
在位元組碼階段,如果一個變量被加載但是永遠不會被使用,編譯器可以檢測到并消除掉這些死代碼,如清單2所示。如果永遠不執行該加載操作則可以節約cpu時間進而改程序式的執行速度。
清單2. 優化後的代碼
int reArchitect =24; //unnecessary operation removed here…
備援消除是一種類似移除重複指令來改進應用性能的優化方式。
很多優化嘗試着消除機器指令級别的跳轉指令(如 x86體系結構中得JMP). 跳轉指令将改變指令指針寄存器,進而轉移程式執行流。這種跳轉指令相對其他ASSEMBLY指令來說是一種很耗資源的指令。這就是為什麼我們要減少或消除這種指令。代碼嵌入就是一種很實用、很有名的消除轉移指令的優化方式。因為執行跳轉指令代價很高,是以将一些被頻繁調用的小方法嵌入到函數體内将會帶來很多益處。清單3-5證明了内嵌的好處。
清單3. 調用方法
int whenToEvaluateZing(int y){ return daysLeft(y)+ daysLeft(0)+ daysLeft(y+1);}
清單4. 被調用方法
int daysLeft(int x){ if(x ==0) return0; else return x -1;}
清單5. 内嵌方法
int whenToEvaluateZing(int y){
int temp =0;
if(y ==0)
temp +=0;
temp += y -1;
if(0==0)
temp +=0-1;
if(y+1==0)
temp +=(y +1)-1;
return temp;
在清單3-5中我們可以看到,一個小方法在另一個方法體内被調用了三次,而我們想說明的是:将被調用方法直接内嵌到代碼中所花費的代價将小于執行三次跳轉指令所花費的代價。
内嵌一個不常被調用的方法可能并不會帶來太大的不同,但是如果内嵌一個所謂的“熱”方法(經常被調用的方法)則可以帶來很多的性能提升。内嵌後的代碼常常還可以進行更進一步的優化,如清單6所示。
清單6. 代碼内嵌後,更進一步的優化實作
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1; elsereturn y + y -1;}
循環優化
循環優化在降低執行循環體所帶來的額外消耗方面起着很重要的作用。這裡的額外消耗指的是昂貴的跳轉、大量的條件檢測,非優化管道(即,一系列無實際操作、消耗額外cpu周期的指令集)。這裡有很多種循環優化,接下來列舉一些比較流行的循環優化:
循環體合并:當兩個相鄰的循環體執行相同次數的循環時,編譯器将試圖合并這兩個循環體。如果兩個循環體互相之間是完全獨立的,則它們還可以被同時執行(并行)。
反演循環: 最基本的,你用一個do-while循環來替代一個while循環。這個do-while循環被放置在一個if語句中。這個替換将減少兩次跳轉操作;但增加了條件判斷,是以增加了代碼量。這種優化是以适當的增加資源消耗換來更有效的代碼的很棒的例子—編譯器對花費和收益進行衡量,在運作時動态的做出決定。
重組循環體: 重組循環體,使整個循環體能全部的存儲在緩存器中。
展開循環體: 減少循環條件的檢測次數和跳轉次數。你可以把這想象成将幾次疊代“内嵌”執行,而不必進行條件檢測。循環體展開也會帶來一定的風險,因為它可能因為影響流水線和大量的備援指令提取而降低性能。再一次,是否展開循環體由編譯器在運作時決定,如果能帶來更大的性能提升則值得展開。
以上就是對編譯器在位元組碼級别(或更低級别)如何改進應用程式在目标平台執行性能的一個概述。我們所讨論的都是些常見、流行的優化方式。由于篇幅有限我們隻舉了一些簡單的例子。我們的目的是希望通過上面簡單的讨論來激起你深入研究優化的興趣。
結論:反思點和重點
根據不同的目的,選擇不同的編譯器。
1.解釋器是将位元組碼翻譯成機器指令的最簡單形式。它的實作基于一個指令查詢表。
2.編譯器可以基于性能計數器進行優化,但是需要消耗一些額外的資源(代碼緩存,優化線程等)。
3.用戶端編譯器相對于解釋器可以帶來5到10倍的性能提升。
4.伺服器端編譯器相對于用戶端編譯器來說可以帶來30%到50%的性能提升,但需要消耗更多的資源。
5.多層編譯則綜合了兩者的優點。使用用戶端編譯來擷取更快的響應速度,接着使用伺服器端編譯器來優化那些被頻繁調用的代碼。
這裡有很多種可能的代碼優化方式。編譯器的一個重要工作就是分析所有可能的優化方式,然後對各種優化方式所付出的代價與最終得到的機器指令帶來的性能提升進行權衡。
Java應用程式是運作在JVM上的,但是你對JVM技術了解嗎?這篇文章(這個系列的第一部分)講述了經典Java虛拟機是怎麼樣工作的,例如:Java一次編寫的利弊,跨平台引擎,垃圾回收基礎知識,經典的GC算法和編譯優化。之後的文章會講JVM性能優化,包括最新的JVM設計——支援當今高并發Java應用的性能和擴充。
如果你是一個開發人員,你肯定遇到過這樣的特殊感覺,你突然靈光一現,所有的思路連接配接起來了,你能以一個新的視角來回想起你以前的想法。我個人很喜歡學習新知識帶來的這種感覺。我已經有過很多次這樣的經曆了,在我使用JVM技術工作時,特别是使用垃圾回收和JVM性能優化時。在這個新的Java世界中,我希望和你分享我的這些啟發。希望你能像我寫這篇文章一樣興奮的去了解JVM的性能。
這個系列文章,是為所有有興趣去學習更多JVM底層知識,和JVM實際做了什麼的Java開發人員所寫的。在更高層次,我将讨論垃圾回收和在不影響應用運作的情況下,對空閑記憶體安全和速度上的無止境追求。你将學到JVM的關鍵部分:垃圾回收和GC算法,編譯優化,和一些常用的優化。我同樣會讨論為什麼Java标記這樣難,提供建議什麼時候應該考慮測試性能。最後,我将講一些JVM和GC的新的創新,包括Azul’s Zing JVM, IBM JVM, 和Oracle’s Garbage First (G1) 垃圾回收中的重點。
我希望你讀完這個系列時對Java可擴充性限制的特點有更深的了解,同樣的這樣限制是如何強制我們以最優的方式建立一個Java部署。希望你會有一種豁然開朗的感受,并且能激發了一些好的Java靈感:停止接受那些限制,并去改變它!如果你現在還不是一個開源工作者,這個系列或許會鼓勵你往這方面發展。
JVM性能和“一次編譯,到處運作”的挑戰
我有新的消息告訴那些固執的認為Java平台本質上是緩慢的人。當Java剛剛做為企業級應用的時候,JVM被诟病的Java性能問題已經是十幾年前的事了,但這個結論,現在已經過時了。這是真的,如果你現在在不同的開發平台上運作簡單靜态和确定的任務時,你将很可能發現使用機器優化過的代碼比使用任何虛拟環境執行的要好,在相同的JVM下。但是,Java的性能在過去10年有了非常大的提升。Java産業的市場需求和增長,導緻了少量的垃圾回收算法、新的編譯創新、和大量的啟發式方法和優化,這些使JVM技術得到了進步。我将在以後的章節中介紹一些。
JVM的技術之美,同樣是它最大的挑戰:沒有什麼可以被認為是“一次編譯,到處運作”的應用。不是優化一個用例,一個應用,一個特定的使用者負載,JVM不斷的跟蹤Java應用現在在做什麼,并進行相應的優化。這種動态的運作導緻了一系列動态的問題。當設計創新時(至少不是在我們向生産環境要性能時),緻力于JVM的開發者不會依賴靜态編譯和可預測的配置設定率。
JVM性能的事業
在我早期的工作中我意識到垃圾回收是非常難“解決”的,我一直着迷于JVMs和中間件技術。我對JVMs的熱情開始于我在JRockit團隊中時,編碼一種新的方法用于自學,自己調試垃圾回收算法(參考 Resources)。這個項目(轉變為JRockit一個實驗性的特點,并成為Deterministic Garbage Collection算法的基礎)開啟了我JVM技術的旅程。我已經在BEA系統、Intel、Sun和Oracle(因為Oracle收購BEA系統,是以被Oracle短暫的工作過)工作過。之後我加入了在Azul Systems的團隊去管理Zing JVM,現在我為Cloudera工作。
機器優化的代碼可能會實作較好的性能(但這是以犧牲靈活性來做代價的),但對于動态裝載和功能快速變化的企業應用這并不是一個權衡選擇它的理由。大多數的企業為了Java的優點,更願意去犧牲機器優化代碼帶來的勉強完美的性能。
1.易于編碼和功能開發(意義是更短的時間去回應市場)
2.得到知識淵博的的程式員
3.用Java APIs和标準庫更快速的開發
4.可移植性——不用為新的平台去重新寫Java應用
從Java代碼到位元組碼
做為一個Java程式員,你可能對編碼、編譯和執行Java應用很熟悉。例子:我們假設你有一個程式(MyApp.java),現在你想讓它運作。去執行這個程式你需要先用javac(JDK内置的靜态Java語言到位元組碼編譯器)編譯。基于Java代碼,javac生成相應的可執行位元組碼,并儲存在相同名字的class檔案:MyApp.class中。在把Java代碼編譯成位元組碼後,你可以通過java指令(通過指令行或startup腳本,使用不使用startup選項都可以)來啟動可執行的class檔案,進而運作你的應用。這樣你的class被加載到運作時(意味着Java虛拟機的運作),程式開始執行。
這就是表面上每一個應用執行的場景,但是現在我們來探究下當你執行java指令時究竟發生了什麼。Java虛拟機是什麼?大多數開發人員通過持續調試來與JVM互動——aka selecting 和value-assigning啟動選項能讓你的Java程式跑的更快,同時避免了臭名昭著的”out of memory”錯誤。但是,你是否曾經想過,為什麼我們起初需要一個JVM來運作Java應用呢?
什麼是Java虛拟機?
簡單的說,一個JVM是一個軟體子產品,用于執行Java應用位元組碼并且把位元組碼轉化到硬體,作業系統特殊指令。通過這樣做,JVM允許Java程式在第一次編寫後可以在不同的環境中執行,并不需要更改原始的代碼。Java的可移植性是通往企業應用語言的關鍵:開發者并不需要為不同平台重寫應用代碼,因為JVM負責翻譯和平台優化。
一個JVM基本上是一個虛拟的執行環境,作為一個位元組碼指令機器,而用于配置設定執行任務和執行記憶體操作通過與底層的互動。
一個JVM同樣為運作的Java應用照看動态資源管理。這就意味着它掌握配置設定和釋放記憶體,在每個平台上保持一緻的線程模型,在應用執行的地方用一種适于CPU架構的方式組織可執行的指令。JVM把開發人員從跟蹤對象當中的引用,和它們需要在系統中存在多長時間中解放出來。同樣的它不用我們管理何時去釋放記憶體——一個像C語言那樣的非動态語言的痛點。
你可以把JVM當做是一個專門為Java運作的作業系統;它的工作是為Java應用管理運作環境。一個JVM基本上是一個虛拟的通過與底層的互動的執行環境,作為一個位元組碼指令機器,而用于配置設定執行任務和執行記憶體操作。
JVM元件概述
有很多寫JVM内部和性能優化的文章。作為這個系列的基礎,我将會總結概述下JVM元件。這個簡短的閱覽會為剛接觸JVM的開發者有特殊的幫助,會讓你更想了解之後更深入的讨論。
從一種語言到另一種——關于Java編譯器
編譯器是把一種語言輸入,然後輸出另一種可執行的語句。Java編譯器有兩個主要任務:
1. 讓Java語言更加輕便,不用在第一次寫的時候固定在特定的平台;
2. 確定對特定的平台産生有效的可執行的代碼。
編譯器可以是靜态也可以是動态。一個靜态編譯的例子是javac。它把Java代碼當做輸入,并轉化為位元組碼(一種在Java虛拟機執行的語言)。靜态編譯器一次解釋輸入的代碼,輸出可執行的形式,這個是在程式執行時将被用到。因為輸入是靜态的,你将總能看到結果相同。隻有當你修改原始代碼并重新編譯時,你才能看到不同的輸出。
動态編譯器,例如Just-In-Time (JIT)編譯器,把一種語言動态的轉化為另一種,這意味着它們做這些時把代碼被執行。JIT編譯器讓你收集或建立運作資料分析(通過插入性能計數的方式),用編譯器決定,用手邊的環境資料。動态的編譯器可以在編譯成語言的過程之中,實作更好的指令序列,把一系列的指令替換成更有效的,甚至消除多餘的操作。随着時間的增長你将收集更多的代碼配制資料,做更多更好的編譯決定;整個過程就是我們通常稱為的代碼優化和重編譯。
動态編譯給了你可以根據行為去調整動态的變化的優勢,或随着應用裝載次數的增加催生的新的優化。這就是為什麼動态編譯器非常适合Java運作。值得注意的是,動态編譯器請求外部資料結構,線程資源,CPU周期分析和優化。越深層次的優化,你将需要越多的資源。然而在大多數環境中,頂層對執行性能的提升幫助非常小——比你純粹的解釋要快5到10倍的性能。
配置設定會導緻垃圾回收
配置設定在每一個線程基于每個“Java程序配置設定記憶體位址空間”,或者叫Java堆,或者直接叫堆。在Java世界中單線程配置設定在用戶端應用程式中很常見。然而,單線程配置設定在企業應用和工作裝載服務端變的沒有任何益處,因為它并沒有使用現在多核環境的并行優勢。
并行應用設計同樣迫使JVM保證在同一時間,多線程不會配置設定同一個位址空間。你可以通過在整個配置設定空間中放把鎖來控制。但這種技術(通常叫做堆鎖)很消耗性能,持有或排隊線程會影響資源利用和應用優化的性能。多核系統好的一面是,它們創造了一個需求,為各種各樣的新的方法在資源配置設定的同時去阻止單線程的瓶頸,和序列化。
一個常用的方法是把堆分成幾部分,在對應用來說每個合式分區大小的地方——顯然它們需要調優,配置設定率和對象大小對不同應用來說有顯著的變化,同樣線程的數量也不同。線程本地配置設定緩存(Thread Local Allocation Buffer,簡寫:TLAB),或者有時,線程本地空間(Thread Local Area,簡寫:TLA),是一個專門的分區,在其中線程不用聲明一個全堆鎖就可以自由配置設定。當區域滿的時候,堆就滿了,表示堆上的空閑空間不夠用來放對象的,需要配置設定空間。當堆滿的時候,垃圾回收就會開始。
碎片
使用TLABs捕獲異常,是把堆碎片化來降低記憶體效率。如果一個應用在要配置設定對象時正巧不能增加或者不能完全配置設定一個TLAB空間,這将會有空間太小而不能生成新對象的風險。這樣的空閑空間被當做“碎片”。如果應用程式一直保持對象的引用,然後再用剩下的空間配置設定,最後這些空間會在很長一段時間内空閑。
碎片就是當碎片被分散在堆中的時候——通過一小段不用的記憶體空間來浪費堆空間。為你的應用配置設定 “錯誤的”TLAB空間(關于對象的大小、混合對象的大小和引用持有率)是導緻堆内碎片增多的原因。在随着應用的運作,碎片的數量會增加在堆中占有的空間。碎片導緻性能下降,系統不能給新應用配置設定足夠的線程和對象。垃圾回收器在随後會很難阻止out-of-memory異常。
TLAB浪費在工作中産生。一種方法可以完全或暫時避免碎片,那就是在每次基礎操作時優化TLAB空間。這種方法典型的作法是應用隻要有配置設定行為,就需要重新調優。通過複雜的JVM算法可以實作,另一種方法是組織堆分區實作更有效的記憶體配置設定。例如,JVM可以實作free-lists,它是連接配接起一串特定大小的空閑記憶體塊。一個連續的空閑記憶體塊和另一個相同大小的連續記憶體塊相連,這樣會建立少量的連結清單,每個都有自己的邊界。在有些情況下free-lists導緻更好的合适記憶體配置設定。線程可以對象配置設定在一個差不多大小的塊中,這樣比你隻依靠固定大小的TLAB,潛在的産生少的碎片。
GC瑣事
有一些早期的垃圾收集器擁有多個老年代,但是當超過兩個老年代的時候會導緻開銷超過價值。另一種優化配置設定減少碎片的方法,就是創造所謂的新生代,這是一個專門用于配置設定新對象的專用堆空間。剩餘的堆會成為所謂的老年代。老年代是用來配置設定長時間存在的對象的,被假定會存在很長時間的對象包括不被垃圾收集的對象或者大對象。為了更好的了解這種配置設定的方法,我們需要講一些垃圾收集的知識。
垃圾回收和應用性能
垃圾回收是JVM的垃圾回收器去釋放沒有引用的被占據的堆記憶體。當第一次觸發垃圾收集時,所有的對象引用還被儲存着,被以前的引用占據的空間被釋放或重新配置設定。當所有可回收的記憶體被收集後,空間等待被抓取和再次配置設定給新對象。
垃圾回收器永遠都不能重聲明一個引用對象,這樣做會破壞JVM的标準規範。這個規則的異常是一個可以捕獲的soft或weak引用 ,如果垃圾收集器将要将近耗盡記憶體。我強烈推薦你盡量避免weak引用,然而,因為Java規範的模糊導緻了錯誤的解釋和使用的錯誤。更何況,Java是被設計為動态記憶體管理,因為你不需要考慮什麼時候和什麼地方釋放記憶體。
垃圾收集器的一個挑戰是在配置設定記憶體時,需要盡量不影響運作着的應用。如果你不盡量垃圾收集,你的應用将耗近記憶體;如果你收集的太頻繁,你将損失吞吐量和響應時間,這将對運作的應用産生壞的影響。
GC算法
有許多不同的垃圾回收算法。稍後,在這個系列裡将深入讨論幾點。在最高層,垃圾收集兩個最主要的方法是引用計數和跟蹤收集器。
引用計數收集器會跟蹤一個對象指向多少個引用。當一個對象的引用為0時,記憶體将被立即回收,這是這種方法的優點之一。引用計數方法的難點在于環形資料結構和保持所有的引用即時更新。
跟蹤收集器對仍在引用的對象标記,用已經标記的對象,反複的跟随和标記所有的引用對象。當所有的仍然引用的對象被标記為“live”時,所有的不被标記的空間将被回收。這種方法管理環形資料結構,但是在很多情況下收集器應該等待直到所有标記完成,在重新回收不被引用的記憶體之前。
有不種的途徑來被上面的方法。最著名的算法是 marking 或copying 算法, parallel 或 concurrent算法。我将在稍後的文章中讨論這些。
通常來說垃圾回收的意義是緻力于在堆中給新對象和老對象配置設定位址空間。其中“老對象”是指在許多垃圾回收後幸存的對象。用新生代來給新對象配置設定,老年代給老對象,這樣能通過快速回收占據記憶體的短時間對象來減少碎片,同樣通過把長時間存在的對象聚合在一起,并把它們放到老年代位址空間中。所有這些在長時間對象和儲存堆記憶體不碎片化之間減少了碎片。新生代的一個積極作用是延遲了需要花費更大代價回收老年代對象的時間,你可以為短暫的對象重複利用相同的空間。(老空間的收集會花費更多,是因為長時間存在的對象們,會包含更多的引用,需要更多的周遊。)
最後值的一提的算法是compaction,這是管理記憶體碎片的方法。Compaction基本來說就是把對象移動到一起,從來釋放更大的連續記憶體空間。如果你熟悉磁盤碎片和處理它的工具,你會發現compaction跟它很像,不同的是這個運作在Java堆記憶體中。我将在系列中詳細讨論compaction。
總結:回顧和重點
JVM允許可移植(一次程式設計,到處運作)和動态的記憶體管理,所有Java平台的主要特性,都是它受歡迎和提高生産力的原因。
在第一篇JVM性能優化系統的文章中我解釋了一個編譯器怎麼把位元組碼轉化為目标平台的指令語言的,并幫助動态的優化Java程式的執行。不同的應用需要不同的編譯器。
我同樣簡述了記憶體配置設定和垃圾收集,和這些怎麼與Java應用性能相關的。基本上,你越快的填滿堆和頻繁的觸發垃圾收集,Java應用的占有率越高。垃圾收集器的一個挑戰是在配置設定記憶體時,需要盡量不影響運作着的應用,但要在應用耗盡記憶體之前。在以後的文章中我們會更詳細的讨論傳統的和新的垃圾回收和JVM性能優化。
Java平台的垃圾收集機制顯著提高了開發者的效率,但是一個實作糟糕的垃圾收集器可能過多地消耗應用程式的資源。在Java虛拟機性能優化系列的第三部分,Eva Andreasson向Java初學者介紹了Java平台的記憶體模型和垃圾收集機制。她解釋了為什麼碎片化(而不是垃圾收集)是Java應用程式性能的主要問題所在,以及為什麼分代垃圾收集和壓縮是目前處理Java應用程式碎片化的主要辦法(但不是最有新意的)。
垃圾收集(GC)的目的是釋放那些不再被任何活動對象引用的Java對象所占用的記憶體,它是Java虛拟機動态記憶體管理機制的核心部分。在一個典型的垃圾收集周期裡,所有仍然被引用的對象(是以是可達的)都将被保留,而那些不再被引用的對象将被釋放、其所占用的空間将被回收用來配置設定給新的對象。
為了了解垃圾收集機制和各種垃圾收集算法,首先需要知道關于Java平台記憶體模型的一些知識。
垃圾收集和Java平台記憶體模型
當用指令行啟動一個Java程式并指定啟動參數-Xmx時(例如:java -Xmx:2g MyApp),指定大小的記憶體就配置設定給了Java程序,這就是所謂的Java堆。這個專用的記憶體位址空間用于存儲Java程式(有時是JVM)所建立的對象。随着應用程式運作并不斷為新對象配置設定記憶體,Java堆(即專門的記憶體位址空間)就會慢慢被填滿。
最終Java堆會被填滿,也就是說記憶體配置設定線程找不到一塊足夠大的連續空間為新對象配置設定記憶體,這時JVM決定要通知垃圾收集器并啟動垃圾收集。垃圾收集也可以通過在程式中調用System.gc()來觸發,但使用System.gc()并不能確定垃圾收集一定被執行。在任何一次垃圾收集之前,垃圾收集機制都會首先判斷執行垃圾收集是否安全,當應用程式的所有活動線程都處于安全點時就可以開始執行一次垃圾收集。例如:當正在為對象配置設定記憶體時就不能執行垃圾收集,或者是正在優化CPU指令時也不能執行垃圾收集,因為這樣很可能會丢失上下文進而搞錯最終結果。
垃圾收集器不能回收任何一個有活動引用的對象,那将破壞Java虛拟機規範。也無需立即回收死對象,因為死對象最終還是會被後續的垃圾收集所回收。盡管有很多種垃圾收集的實作方法,但以上兩點對所有垃圾收集實作都是相同的。垃圾收集真正的挑戰在于如何識别對象是否存活以及如何在盡量不影響應用程式的情況下回收記憶體,是以垃圾收集器的目标有以下兩個:
1.迅速釋放沒有引用的記憶體以滿足應用程式的記憶體配置設定需要進而避免記憶體溢出。
2.回收記憶體時對正在運作的應用程式性能(延遲和吞吐量)的影響最小化。
兩類垃圾收集
在本系列的第一篇中,我介紹了兩種垃圾收集的方法,即引用計數和跟蹤收集。接下來我們進一步探讨這兩種方法,并介紹一些在生産環境中使用的跟蹤收集算法。
引用計數收集器
引用計數收集器記錄了指向每個Java對象的引用數,一旦指向某個對象的引用數為0,那麼就可以立即回收該對象。這種即時性是引用計數收集器的主要優點,而且維護那些沒有引用指向的記憶體幾乎沒有開銷,不過為每個對象記錄最新的引用數卻是代價高昂的。
引用計數收集器的主要難點在于如何保證引用計數的準确性,另外一個衆所周知的難點是如何處理循環引用的情況。如果兩個對象彼此引用,而且沒有被其他活動對象所引用,那麼這兩個對象的記憶體永遠都不會被回收,因為指向這兩個對象的引用數都不為0。對循環引用結構的記憶體回收需要major analysis(譯者注:Java堆上的全局分析),這将增加算法的複雜性,進而也給應用程式帶來額外的開銷。
跟蹤收集器
跟蹤收集器基于這樣的假設:所有的活動對象都可以通過一個已知的初始活動對象集合的疊代引用(引用以及引用的引用)找到。可以通過分析寄存器、全局對象和棧幀來确定初始活動對象集合(也被稱為根對象)。确定了初始對象集合後,跟蹤收集器順着這些對象的引用關系依次将引用所指向的對象标注為活動對象,就這樣已知的活動對象集合不斷擴大。這一過程持續進行直到所有被引用的對象都被标注為活動對象,而那些沒有被标注過的對象的記憶體就被回收。
跟蹤收集器不同于引用計數收集器主要在于它可以處理循環引用結構。多數的跟蹤收集器都是在标記階段發現那些循環引用結構中的無引用對象。
跟蹤收集器是動态語言中最常用的記憶體管理方式,也是目前Java中最常見的方式,同時在生産環境中也被驗證了很多年。下面我将從實作跟蹤收集的一些算法開始介紹跟蹤收集器。
跟蹤收集算法
複制垃圾收集器和标記-清除垃圾收集器并不是什麼新東西,但它們仍然是目前實作跟蹤收集的兩種最常見算法。
複制垃圾收集器
傳統的複制垃圾收集器使用堆中的兩個位址空間(即from空間和to空間),當執行垃圾收集時from空間的活動對象被複制到to空間,當from空間的所有活動對象都被移出(譯者注:複制到to空間或者老年代)後,就可以回收整個from空間了,當再次開始配置設定空間時将首先使用to空間(譯者注:即上一輪的to空間作為新一輪的from空間)。
在該算法的早期實作中,from空間和to空間不斷變換位置,也就是說當to空間滿了,觸發了垃圾收集,to空間就成為了from空間,如圖1所示。
圖1 傳統的複制垃圾收集順序
最新的複制算法允許堆内任意位址空間作為to空間和from空間。這樣它們不需要彼此交換位置,而隻是邏輯上變換了位置。
複制收集器的優點是在to空間被複制的對象緊湊排列,完全沒有碎片。而碎片化正是其他垃圾收集器所面臨的一個共同問題,也是我之後主要讨論的問題。
複制收集器的缺陷
通常來說複制收集器是stop-the-world的,也就是說隻要垃圾收集在進行,應用程式就無法執行。對于這種實作來說,你需要複制的東西越多,對應用程式性能的影響就越大。對于那些響應時間敏感的應用來說這是個缺點。使用複制收集器時,你還要考慮最壞的場景(即from空間中的所有對象都是活動對象),這時你需要為移動這些活動對象準備足夠大的空間,是以to空間必須大到可以裝下from空間的所有對象。由于這個限制,複制算法的記憶體使用率稍有不足(譯者注:在最壞的情況下to空間需要和from空間大小相同,是以隻有50%的使用率)。
标記-清除收集器
部署在企業生産環境上的大多數商業JVM采用的都是标記-清除(或者叫标記)收集器,因為它沒有複制垃圾收集器對應用程式性能的影響問題。其中最有名的标記收集器包括CMS、G1、GenPar和DeterministicGC。
标記-清除收集器跟蹤對象引用,并且用标志位将每個找到的對象标記為live。這個标志位通常對應堆上的一個位址或是一組位址。例如:活動位可以是對象頭的一個位(譯者注:bit)或是一個位向量、一個位圖。
在标記完成之後就進入了清除階段。清除階段通常都會再次周遊堆(不僅是标記為live的對象,而是整個堆),用來定位那些沒有标記的連續記憶體位址空間(沒有被标記的記憶體就是空閑并可回收的),然後收集器将它們整理為空閑清單。垃圾收集器可以有多個空閑清單(通常按照記憶體塊的大小劃分),有些JVM(例如:JRockit Real Time)的收集器甚至基于應用程式的性能分析和對象大小的統計結果來動态劃分空閑清單。
清除階段過後,應用程式就可以再次配置設定記憶體了。從空閑清單中為新對象配置設定記憶體時,新配置設定的記憶體塊需要符合新對象的大小,或是線程的平均對象大小,或是應用程式的TLAB大小。為新對象找到大小合适的記憶體塊有助于優化記憶體和減少碎片。
标記-清除收集器的缺陷
标記階段的執行時間依賴于堆中活動對象的數量,而清除階段的執行時間依賴于堆的大小。是以對于堆設定較大并且堆中活動對象較多的情況,标記-清除算法會有一定的暫停時間。
對于記憶體消耗很大的應用程式來說,你可以調整垃圾收集參數以适應各種應用程式的場景和需要。在很多情況下,這種調整至少推遲了标記階段/清除階段給應用程式或服務協定SLA(SLA這裡指應用程式要達到的響應時間)帶來的風險。但是調優僅僅對特定的負載和記憶體配置設定率有效,負載變化或是應用程式本身的修改都需要重新調優。
标記-清除收集器的實作
至少有兩種已經在商業上驗證的方法來實作标記-清除垃圾收集。一種是并行垃圾收集,另一種是并發(或者多數時間是并發)垃圾收集。
并行收集器
并行收集是指資源被垃圾收集線程并行使用。大多數并行收集的商業實作都是stop-the-world收集器,即所有的應用程式線程都暫停直到完成一次垃圾收集,因為垃圾收集器可以高效地使用資源,是以通常會在吞吐量的基準測試中得到高分,如SPECjbb。如果吞吐量對你的應用程式至關重要,那麼并行垃圾收集器是一個很好的選擇。
并行收集的主要代價(特别是對于生産環境)是應用程式線程在垃圾收集期間無法正常工作,就像複制收集器一樣。是以那些對于響應時間敏感的應用程式使用并行收集器會有很大的影響。特别是在堆空間中有很多複雜的活動對象結構時,有很多的對象引用需要跟蹤。(還記得嗎标記-清除收集器回收記憶體的時間取決于跟蹤活動對象集合的時間加上周遊整個堆的時間)對于并行方法來說,整個垃圾收集時間應用程式都會暫停。
并發收集器
并發垃圾收集器更适合那些對響應時間敏感的應用程式。并發意味着垃圾收集線程和應用程式線程并發執行。垃圾收集線程并不獨占所有資源,是以需要決定何時開始一次垃圾收集,需要有足夠的時間跟蹤活動對象集合并在應用程式記憶體溢出前回收記憶體。如果垃圾收集沒有及時完成,應用程式就會抛出記憶體溢出錯誤,另一方面又不希望垃圾收集執行時間太長因為那樣會消耗應用程式的資源進而影響吞吐量。保持這種平衡是需要技巧的,是以在确定開始垃圾收集的時機以及選擇垃圾收集優化的時機時都使用了啟發式算法。
另一個難點在于确定何時可以安全執行一些操作(需要完整準确的堆快照的操作),例如:需要知道何時标記階段完成,這樣就可以進入清理階段。對于stop-the-world的并行收集器來說這不成問題,因為世界已經暫停了(譯者注:應用程式線程暫停,垃圾收集線程獨占資源)。但對于并發收集器而言,從标記階段立刻切換到清理階段可能不安全。如果應用程式線程修改了一段記憶體,而這段記憶體已經被垃圾收集器跟蹤并标注過了,這就可能産生了新的沒有标注的引用。在一些并發收集實作中,這會使應用程式陷入長時間重複标注的循環,當應用程式需要這段記憶體時也無法獲得空閑記憶體。
通過到目前為止的讨論我們知道有很多的垃圾收集器和垃圾收集算法,分别适合特定的應用程式類型和不同的負載。不僅是不同的算法,還有不同的算法實作。是以在指定垃圾收集器錢最好了解應用程式的需求以及自身特點。接下來我們将介紹Java平台記憶體模型的一些陷阱,這裡陷阱的意思是,在動态變化的生産環境中Java程式員容易做出的一些使得應用程式性能變得更差的假設。
為什麼調優無法代替垃圾收集
多數的Java程式員都知道如果要優化Java程式可以有很多選擇。若幹個可選的JVM、垃圾收集器和性能調優參數讓開發者花費大量的時間在無休無盡的性能調優方面。這使有些人是以得出結論:垃圾收集是糟糕的,通過調優使垃圾收集較少發生或者持續時間較短是一個很好的變通辦法,不過這樣做是有風險的。
考慮一下針對具體應用程式的調優,多數的調優參數(例如記憶體配置設定率、對象大小、響應時間)都是基于目前測試的資料量對應用程式的記憶體配置設定率(譯者注:或者其他參數)調整。最終可能造成以下兩個結果:
1.在測試中通過的用例在生産環境中失敗。
2.資料量的變化或者應用程式的變化要求重新調優。
調優是需要反複的,特别是并發垃圾收集器可能需要很多調優(尤其在生産環境中)。需要啟發式方法來滿足應用程式的需要。為了要滿足最壞的情況,調優的結果可能是一個非常死闆的配置,這也導緻了大量的資源浪費。這種調優方法是一種堂吉诃德式的探索。事實上,你越是優化垃圾收集器來比對特定的負載,越是遠離了Java運作時的動态特性。畢竟有多少應用程式的負載是穩定的呢,你所預期的負載的可靠性又有多高呢?
那麼如果你不将注意力放在調優上,能夠做些什麼來防止記憶體溢出錯誤和提高響應時間呢?首要的事情就是找到影響Java應用程式性能的主要因素。
碎片化
影響Java應用程式性能的因素不是垃圾收集器,而是碎片化以及垃圾收集器如何處理碎片化。所謂碎片化是這樣一種狀态:堆空間中有空閑可用的空間,但并沒有足夠大的連續記憶體空間,以至于無法為新對象配置設定記憶體。正如在第一篇中提到的,記憶體碎片要麼是堆中殘留的一段空間TLAB,要麼是在長期存活對象中間被釋放的小對象所占用的空間。
随着時間的推移和應用程式的運作,這些碎片就會遍布在堆中。在某些情況下,使用了靜态化調優的參數可能會更糟,因為這些參數無法滿足應用程式的動态需要。應用程式無法有效利用這些碎片化的空間。如果不做任何事情,那麼将導緻接連不斷的垃圾收集,垃圾收集器嘗試釋放記憶體配置設定給新對象。在最壞的情況下,即使是接連不斷的垃圾收集也無法釋放更多的記憶體(碎片太多),然後JVM不得不抛出記憶體溢出的錯誤。你可以通過重新開機應用程式來解決碎片化,這樣Java堆就有連續的記憶體空間可以配置設定給新對象。重新開機程式導緻當機,而且一段時間後Java堆将再次充滿碎片,不得不再次重新開機。
記憶體溢出錯誤會挂起程序,日志顯示垃圾收集器正在超負荷工作,這些都顯示垃圾收集正試圖釋放記憶體,也表明堆中碎片很多。一些程式員會試圖通過再次優化垃圾收集器來解決碎片化問題。但我認為應該尋找更有新意的辦法解決這個問題。接下來的部分将重點讨論解決碎片化的兩個辦法:分代垃圾收集和壓縮。
分代垃圾收集
你可能聽過這樣的理論:在生産環境中絕大多數對象的存活時間都很短。分代垃圾收集正是由這一理論衍生出的一種垃圾收集政策。在分代垃圾收集中,我們将堆分為不同的空間(或者叫做代),每個空間中儲存着不同年齡的對象,所謂對象的年齡就是對象存活的垃圾收集周期數(也就是該對象多少個垃圾收集周期後仍然被引用)。
當新生代沒有剩餘空間可配置設定時,新生代的活動對象會被移動到老年代中(通常隻有兩個代。譯者注:隻有滿足一定年齡的對象才會被移動到老年代),分代垃圾收集常常使用單向的複制收集器,一些更現代的JVM新生代中使用的是并行收集器,當然也可以為新生代和老年代分别實作不同的垃圾收集算法。如果你使用并行收集器或複制收集器,那麼你的新生代收集器就是一個stop-the-world的收集器(參見之前的解釋)。
老年代配置設定給那些從新生代移出的對象,這些對象要麼是被引用很長一段時間,要麼是被一些新生代中對象集合所引用。偶爾也有大對象直接被配置設定到了老年代,因為移動大對象的成本相對較高。
分代垃圾收集技術
在分代垃圾收集中,老年代運作垃圾收集的頻率較低,而在新生代運作垃圾收集的頻率較高,而我們也希望在新生代中垃圾收集周期更短。在極少的情況下,新生代的垃圾收集可能會比老年代的垃圾收集更頻繁。如果你将新生代設定的太大時并且應用程式中的多數對象都存活較長時間,這種情況就可能會發生。在這種情況下,如果老年代設定的太小以至于無法容納所有的長時間存活的對象,老年代的垃圾收集也會掙紮于釋放空間給那些被移動進來的對象。不過通常來說分代垃圾收集可以使應用程式獲得更好的性能。
劃分出新生代的另一個好處是某種程度上解決了碎片化問題,或者說将最壞的情況推遲了。那些存活時間短的小對象本來可能産生碎片化問題,但都在新生代的垃圾收集中被清理了。由于存活時間長的對象被移到老年代時被更緊湊的配置設定空間,老年代也更加緊湊了。随着時間推移(如果你的應用運作時間足夠長),老年代也會産生碎片化,這時需要運作一次或是幾次完全垃圾收集,同時JVM也有可能抛出記憶體溢出錯誤。但是劃分出新生代推遲了出現最壞情況的時間,這對于很多應用程式來說已經足夠了。對于多數應用程式而言,它的确降低了stop-the-world垃圾收集的頻率和記憶體溢出錯誤的機會。
優化分代垃圾收集
正如之前提到的,使用分代垃圾收集帶來了重複的調優工作,例如調整新生代大小、提升率等。我無法針對具體應用運作時來強調怎樣做取舍:選擇固定的大小固然可以優化應用程式,但同時也減少了垃圾收集器應對動态變化的能力,而變化是不可避免的。
對于新生代首要原則就是在確定stop-the-world垃圾收集期間延遲時間前提下盡可能的加大,同時也要為那些長期存活的對象在堆中保留足夠大的空間。下面是在調整分代垃圾收集器時要考慮的一些額外因素:
1.新生代中多數都是stop-the-world垃圾收集器,新生代設定的越大,相應的暫停時間就越長。是以對于那些受垃圾收集暫停時間影響大的應用程式來說,要仔細考慮将新生代設定為多大合适。
2.可以在不同的代上使用不同的垃圾收集算法。例如在新生代中使用并行垃圾收集,在老年代中使用并發垃圾收集。
3.當發現頻繁的提升(譯者注:從新生代移動到老年代)失敗時說明老年代中碎片太多了,也就是說老年代中沒有足夠的空間來存放從新生代移出的對象。這時你可以調整一下提升率(即調整提升的年齡),或者確定老年代中的垃圾收集算法會進行壓縮(将在下一段讨論)并調整壓縮以适應應用程式的負載。也可以增加堆大小和各個代大小,但是這樣更會進一步延長老年代上的暫停時間。要知道碎片化是無法避免的。
4.分代垃圾收集最适合這樣的應用程式,他們有很多存活時間很短的小對象,很多對象在第一輪垃圾收集周期就被回收了。對于這種應用程式分代垃圾收集可以很好的減少碎片化,并将碎片化産生影響的時機推遲。
壓縮
盡管分代垃圾收集延遲了出現碎片化和記憶體溢出錯誤的時間,然而壓縮才是真正解決碎片化問題的唯一辦法。壓縮是指通過移動對象來釋放連續記憶體塊的垃圾收集政策,這樣通過壓縮為建立新對象釋放了足夠大的空間。
移動對象并更新對象引用是stop-the-world操作,會帶來一定的消耗(有一種情況例外,将在本系列的下一篇中讨論)。存活的對象越多,壓縮造成的暫停時間就越長。在剩餘空間很少并且碎片化嚴重的情況下(這通常是因為程式運作了很長的時間),壓縮存活對象較多的區域可能會有幾秒種的暫停時間,而當接近記憶體溢出時,壓縮整個堆甚至會花上幾十秒的時間。
壓縮的暫停時間取決于需要移動的記憶體大小和需要更新的引用數量。統計分析表明堆越大,需要移動的活動對象和更新的引用數量就越多。每移動1GB到2GB活動對象的暫停時間大約是1秒鐘,對于4GB大小的堆很可能有25%的活動對象,是以偶爾會有大約1秒的暫停。
壓縮和應用程式記憶體牆
應用程式記憶體牆是指在垃圾收集産生的暫停(例如:壓縮)前可以設定的堆大小。根據系統和應用的不同,大多數的Java應用程式記憶體牆都在4GB到20GB之間。這也是多數的企業應用都是部署在多個較小的JVM上,而不是少數較大的JVM上的原因。讓我們考慮一下這個問題:有多少現代企業的Java應用程式設計、部署是根據JVM的壓縮限制來定義的。在這種情況下,為了繞過整理堆碎片的暫停時間,我們接受了更耗費管理成本的多個執行個體部署方案。考慮到現在硬體的大容量存儲能力和企業級Java應用對增加記憶體的需求,這就有點奇怪了。為什麼為每個執行個體隻設定了幾個GB的記憶體。并發壓縮将會打破記憶體牆,這也是我下一篇文章的主題。
總結
本文是一篇關于垃圾收集的介紹性文章,幫助你了解有關垃圾收集的概念和機制,并希望能夠促使你進一步閱讀相關文章。這裡讨論的很多東西都已經存在了很久,在下一篇中将介紹一些新的概念。例如并發壓縮,目前是由Azul‘s Zing JVM實作的。它是一項新興的垃圾收集技術,甚至嘗試重新定義Java記憶體模型,特别是在今天記憶體和處理能力都不斷提高的情況下。
以下是我總結出的一些關于垃圾收集的要點:
1.不同的垃圾收集算法和實作适應不同的應用程式需要,跟蹤垃圾收集器是商業Java虛拟機中使用的最多的垃圾收集器。
2.并行垃圾收集在執行垃圾收集時并行使用所有資源。它通常是一個stop-the-world垃圾收集器,是以有更高的吞吐量,但是應用程式的工作線程必須等待垃圾收集線程完成,這對應用程式的響應時間有一定影響。
3.并發垃圾收集在執行收集時,應用程式工作線程仍然在運作。并發垃圾收集器需要在應用程式需要記憶體之前完成垃圾收集。
4.分代垃圾收集有助于延遲碎片化,但無法消除碎片化。分代垃圾收集将堆分為兩個空間,其中一個空間存放新對象,另一個空間存放老對象。分代垃圾收集适合有很多存活時間很短的小對象的應用程式。
5.壓縮是解決碎片化的唯一方法。多數的垃圾收集器都是以stop-the-world的方式執行壓縮的,程式運作時間越久,對象引用越是複雜,對象的大小越是分布不均勻都将導緻壓縮時間延長。堆的大小也會影響壓縮時間,因為可能有更多的活動對象和引用需要更新。
6.調優有助于延遲記憶體溢出錯誤。但是過度調優的結果是僵化的配置。在通過試錯的方式開始調優之前,要確定清楚生産環境的負載、應用程式的對象類型以及對象引用的特性。過于僵化的配置很可能無法應付動态負載,是以在設定非動态值時一定要了解這樣做的後果。
深入探讨C4(Concurrent Continuously Compacting Collector)垃圾收集算法