之前我們文章提到過反射,說的比較淺顯,我們這裏來理解JVM。
一個標準的JVM是這樣的
JVM負責裝載class文件並執行,我們首先來了解類加載和執行的機制。
類加載機制
JVM將.class文件加載到JVM,並形成Class對象,之後就可以對Class對象進行實例化並調用。
該過程分爲三個步驟:
- 裝載。
- 鏈接。
- 初始化。
- 裝載
負責找到二進制字節碼並加載到JVM中。
JVM通過類的全限定名以及類加載器完成類的加載。
比如Object[] o=new Object[10]
,o的全限定名:[Ljava.lang.Object
,並由數組型中的元素類型所在的ClassLoader進行加載。
- 鏈接
鏈接過程負責對二進制字節碼進行校驗、初始化裝載類中的靜態變量以及解析類中調用的接口、類。
- 初始化
初始化過程即執行類中的靜態初始化代碼,構造器代碼以及靜態屬性的初始化。
類執行機制
在完成將class文件信息加載到JVM併產生Class對象後,就可執行Class對象的靜態方法或實例化對象進行調用了。在源碼編譯階段,將源碼編譯爲JVM字節碼,JVM字節碼是一種中間代碼的方式,要由JVM在運行期間對其進行解釋並執行。這種方式稱爲:字節碼解釋執行方式。
字節碼解釋執行
由於採用JVM字節碼,也就是說JVM有一套自己的指令來執行中間碼:
- invokestatic
調用static方法 - invokevirtual
調用對象實例的方法 - invokeinterface
調用接口 - invokespecial
調用private方法和<init>對象初始化方法
比如下面這一段代碼:
public class Demo{
public void execute(){
A.execute();
A a=new A();
a.bar();
IFoo b=new B();
b.bar();
}
}
class A{
public static int execute(){
return 1+2;
}
public int bar(){
return 1+2;
}
}
class B implements IFoo{
public int bar(){
return 1+2;
}
}
public interface IFoo{
public int bar();
}
通過javac 編譯上面的代碼後,使用javap -c Demo
查看其execute方法的字節碼:
Compiled from "Demo.java"
public class Demo {
public Demo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void execute();
Code:
0: invokestatic #2 // Method A.execute:()I
3: pop
4: new #3 // class A
7: dup
8: invokespecial #4 // Method A."<init>":()V
11: astore_1
12: aload_1
13: invokevirtual #5 // Method A.bar:()I
16: pop
17: new #6 // class B
20: dup
21: invokespecial #7 // Method B."<init>":()V
24: astore_2
25: aload_2
26: invokeinterface #8, 1 // InterfaceMethod IFoo.bar:()I
31: pop
32: return
}
從上面的栗子可以看出,四種指令對應調用方法的情況。
Sun JDK基於棧的體系結構來執行字節碼,基於棧方式的好處就是代碼緊湊,體積小。
線程在創建後,都會產生程序計數器(PC或者稱爲PC registers)和棧(Stack);PC存放了下一條要執行的指令在方法內的偏移量;棧中存放了棧幀(StackFrame),每個方法每次調用都會產生棧幀,棧幀主要分爲局部變量區和操作數棧兩個部分,局部變量區用於存放方法體中的局部變量和參數,操作數棧中用於存放方法執行過程中產生的中間結果,棧幀中還有一些其他空間,例如只想方法已解析的常量池的引用、其他一些VM內部需要的數據等,具體結構如下圖所示:
下面來看一個方法執行時過程的栗子:
public class Demo2{
public static void foo(){
int a=1;
int b=2;
int c=(a+b)*5;
}
}
同樣的方法獲得JVM字節碼:
public class Demo2 {
public Demo2();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void foo();
Code:
0: iconst_1
1: istore_0
2: iconst_2
3: istore_1
4: iload_0
5: iload_1
6: iadd
7: iconst_5
8: imul
9: istore_2
10: return
}
每條字節碼及其對應的解釋如下:
對於方法的指令解釋執行,執行方式爲經典馮諾伊曼體系中的FDX循環方式,即獲取下一條指令,解碼並分派,然後執行。在實現FDX循環時有switch-threading、token-threading、direct-threading等多種方式。
第一種swith-threading,代碼大致如下:
while(true){
int code=fetchNextCode();//下一條指令
switch(code){
case IADD: //do add
case ...: //do sth
}
}
每次執行完都得重新回到循環開始點,然後重新獲取下一條指令,並繼續switch,這導致了大部分時間都花在了跳轉和獲取下一條指令上,真的的業務邏輯代碼非常短。
token-threading在上面的基礎上稍微有所修改:
IADD:{
//do add
fetchNextCode();//下一條指令
dispatch();
}
ICONST_0:{
push(0);
fetchNextCode();下一條指令
dispatch();
}
...
該方法相對第一種switch-threading而言,冗餘了fetch next code和dispatch,相對比較消耗內存,但是由於去除了switch,因此性能會稍微好一些。
其他的xxx-threading做了更多的優化,在此不做贅述。Sun JDK的重點爲編譯成機器碼,並沒有在解釋器上做太複雜的處理,因此採用了token-threading方法,爲了讓解釋執行能夠更加高效,Sun JDK還做了一些其他的優化,主要是:棧頂緩存(top-of-stack caching)和部分棧幀共享。
棧頂緩存
在方法執行過程中,可以看到有很多操作要將值放入操作數棧,這導致了寄存器和內存要不斷的交換數據,Sun JDK採用了一個棧頂緩存,即將本來位於操作數棧頂的值直接緩存到寄存器上,可直接在寄存器計算,然後放回操作數棧。
部分棧幀共享
當一個方法調用另一個方法時,通常傳入另一個方法的參數爲已存放在操作數棧的數據,Sun JDK採用:當調用方法時,後一個方法將前一個方法的操作數棧作爲當前方法的局部變量,從而節省數據copy帶來的消耗。
(運行時)編譯執行
由於解釋執行的效率太低,Sun JDK提供將字節碼編譯爲機器碼,在執行過程中,對執行頻率高的代碼進行編譯執行,對執行不頻繁的代碼採用解釋執行,因此Sun JDK也稱爲Hotspot VM,在編譯上Sun JDK提供了兩種模式,client compiler(-client) 和 server compiler(-server)。
- client compiler
client compiler比較輕量級,製作少量性能開銷比較高的優化,它佔用內存較少,適合於桌面交互式應用,主要的優化有:方法內聯、去虛擬化、冗餘消除等。
1.方法內聯
例如這樣一段代碼:
public void bar(){
...
bar2();
...
}
public void bar2(){
//bar2執行代碼
}
當編譯時,如果bar2代碼編譯後的字節數小雨等於35個字節(可以通過啓動參數-XX:MaxInlineSize=35來控制),那麼會演變稱爲這樣的結構:
public void bar(){
...
//bar2執行代碼
...
}
可在debug版本的JDK的啓動參數上加上-XX:+PrintInlining來查看方法內聯信息。
2.去虛擬化
去虛擬化是指在裝載class文件後,進行類層次的分析,如發現類中的方法只提供一個實現類,那麼對於調用了此方法的代碼,也可進行方法內聯,從而提升執行的性能。
例如這樣的代碼:
public interface IFoo{
public void bar();
}
public class Foo implements IFoo{
public void bar(){
//Foo bar method
}
}
public class Demo{
public void execute(IFoo foo){
foo.bar();
}
}
當整個JVM只有Foo實現了IFoo接口,Demo execute方法被編譯的時候,就會演變成類似這樣的結構:
public void execute(){
//Foo bar method
}
3.冗餘消除
冗餘消除是指在編譯時,根據運行時狀況進行摺疊或消除代碼。
比如:
private static final Log=log.LogFactory.getLog("BLUEDAVY");
private static final boolean isDebug=log.isDebugEnabled();
public void execute(){
if(isDebug){
log.debug(xxx);
}
//do something else
}
如果boolean值是false那麼會演變爲如下的結構:
public void execute(){
//do something else
}
- server compiler
server compiler較爲重量級,採用了大量傳統編譯優化技巧,佔用內存相對較多,適合服務端的應用,下面介紹幾個優化:
1.標量替換
例如:
Point p=new Point(1,2);
sout("point.x="+p.x+";point.y="+p.y);
當p對象在後面沒用到的時候,會演變成下面的結構:
int x=1;
int y=2;
sout("point.x="+x+";point.y="+y);
2.同步消除
如果發現同步的對象沒必要,那麼會直接去掉:
Point p=new Point();
sysnchronized(p){
//do something
}
演變爲:
Point p=new Point();
//do somehing
從上面兩種重量級和輕量級的編譯來看,它們做了很多努力來優化。爲什麼不再一開始就編譯稱爲機器碼呢?
主要有下面幾方面的原因:
- 靜態編譯並不能根據程序的運行狀況來優化執行的代碼,server compiler收集運行數據越長,編譯出來的代碼會越優化。
- 解釋執行比編譯執行更省內存。
- 啓動時解釋執行的啓動速度比編譯後再啓動更快。
那麼什麼時候就需要編譯呢?這需要一個權衡值,Sun JDK有兩個計數器來計算閾值:
- CompileThreshold
當方法被調用多少次後,編譯爲機器碼。通過-XX:CompileThreshold=10000來設置該值。client默認1500次,server默認10000; - OnStackReplacePercentage
棧上替換的百分比,該值用於/參與計算是否觸發OSR編譯的閾值,通過-XX:OnStackReplacePercentage=140來設置。在client模式下,計算規則爲:CompileThreshold * (OnStackReplacePercentage/100);在server模式下,計算規則爲:(OnStackReplacePercentage – InterpreterProfilePercentage))/100
反射執行
反射執行是基於反射來動態調用某對象實例中對應的方法,訪問查看對象的屬性等等,之前的文章寫的很清楚。
Java中通過如下的方法調用:
Class actionClass=Class.forName(外部實現類);
Method method=actionClass.getMethod(“execute”,null);
Object action=actionClass.newInstance();
method.invoke(action,null);
這樣在創建對象過程和方法調用過程是動態的,具有很高的靈活性。
內存回收
內存空間
Sun JDK在實現時,將內存空間劃分爲方法區、堆、本地方法棧、PC寄存器以及JVM方法棧。如下圖所示:
- 方法區
方法區存放了要加載的類的信息、靜態變量、final類型常量等信息。方法區是全局共享的。
通過-XX:PermSize和-XX:MaxPermSize來指定最小最大的值,保證方法區內存大小。
- 堆
堆用於存儲對象實例及數組值,可以認爲Java中所有new的對象都在此分配。
- 本地方法棧
用於支持native方法的執行。在Sun JDK的實現中本地方法棧和JVM方法棧是同一個。
- PC寄存器和JVM方法棧
每個線程單獨創建自己的PC寄存器和JVM方法棧(私有的)。當方法運行完畢時,其對應的棧幀所用內存也會自動釋放。
收集器
JVM通過GC來回收堆和方法區中的內存,GC的基本原理是首先找到程序中不再被使用的對象,然後回收這些對象所佔用的內存。
主要的收集器有引用計數收集器和跟蹤收集器。
- 引用計數收集器
顧名思義,通過計數器記錄對象引用數目,當引用數目爲0時回收對象。 -
跟蹤收集器
跟蹤收集器採用的爲集中式的管理方式,全局記錄數據的引用狀態。基於一定條件觸發(例如定時觸發或者空間不足時觸發),執行時需要從根集合來掃描對象的引用關係,這可能會造成應用程序暫停,主要有複製、標記-清除、標記-壓縮三種實現算法。
(其實就是清理內存的算法,計算機原理也學過)
複製:從根集合中掃描存活的對象,複製到未使用的空間中。
標記清除:從根集合中掃描,對存活的對象標記,然後再掃描整個空間中未標記的對象,進行回收。
標記壓縮:和標記清除一樣也要進行標記,不過第二步在回收不存活的對象的內存後,會將對象左移壓縮。
Sun JDK可用的GC
以上三種跟蹤收集器各有優缺點,Sun JDK認爲:程序中大部分對象存活時間都是較短的,只有少部分是長期存活的。根據這一分析,JVM被劃分爲新生代和舊生代,根據兩代generation有不同的GC實現。
新生代中對象存活期短,因此選用複製算法進行回收。由於在複製的時候,需要一塊未使用的空間來存放存活的對象(和固態硬盤一樣,也是要預留空間),所以新生代又被分爲Eden、S0、S1三塊空間。
Eden Space存放新創建的對象,S0或S1其中一塊作爲複製的目標空間(輪流):當一塊作爲複製的目標空間,另一塊被清空。因此S0和S1也被稱爲:From Space和To Space。
Sun JDK提供了串行GC、並行回收GC和並行GC三種方式來回收,在此不做贅述。
舊生代與新生代不同,對象存活的時間比較長,比較穩定,因此採用標記(Mark)算法來進行回收,所謂標記就是掃描出存活的對象,然後再進行回收未被標記的對象,回收後對用空出的空間要麼進行合併,要麼標記出來便於下次進行分配,總之就是要減少內存碎片帶來的效率損耗。在執行機制上JVM提供了串行 GC(SerialMSC)、並行GC(parallelMSC)和併發GC(CMS),具體算法細節還有待進一步深入研究。