官术网_书友最值得收藏!

2.2 類的鏈接

類加載得到InstanceKlass后,此時的InstanceKlass雖然有了類的字段、字段個數、類名、父類名等信息,但是還不能使用,因為有些關鍵信息仍然缺失。HotSpot VM的執行模式是解釋器與JIT編譯器混合的模式,當一個Java方法/循環被探測到是“熱點”,即執行了很多次時,就可能使用JIT編譯器編譯它然后從解釋器切換到執行后的代碼再執行它。那么,如何讓方法同時具備可解釋執行、可執行編譯后的機器代碼的能力呢?HotSpot VM的實現是在方法中放置解釋器、編譯器的入口地址,需要哪種模式就進入哪種入口。

第二個問題,在哪里設置這些入口呢?結合類的實現過程,在前面的類加載中沒有提到,而后面的類初始化會執行代碼,說明在執行代碼時入口已設置,即它們是在類鏈接階段設置的。類鏈接源碼位于InstaceKlass::link_class_impl(),源碼很長,主要有5個步驟:

1)字節碼驗證(verify_code);

2)字節碼重寫(rewrite_class);

3)方法鏈接(link_method);

4)初始化vtable(虛表)和itable(接口表);

5)鏈接完成(set_init_state)。

2.2.1 字節碼驗證

字節碼驗證可以確保字節碼是結構性正確的。舉個例子,if_icmpeq字節碼判斷兩個整數是否相等并根據結果做跳轉,結構性正確就是指跳轉位置必須位于該方法內這一事實。又比如,一個方法返回boolean、byte、char、short、int中的任意一種類型,那么結構性正確要求該方法的返回字節碼必須是ireturn而不能是freturn、lreturn等。字節碼驗證的代碼位于classfile/verifier.cpp,它是一個對于程序安全運行很重要但是對于源碼分析又不必要的部分,感興趣的讀者請對照verifier源碼和Java虛擬機文檔4.9、4.10節(關于結構性正確的一些要求)閱讀。

2.2.2 字節碼重寫

字節碼重寫器(Rewritter)位于interpreter/rewriter.cpp,它實現了如下功能。

1. finalize方法重寫

本章最開始使用javap反編譯了類Foo的字節碼,其中包括Foo構造函數。Foo的構造函數默認調用Object構造函數,Object構造函數只有固定的三條字節碼:aload0, invokespecial,return。

當某個類重寫了Object.finalize()方法時,在運行時,最后一條字節碼return會被重寫器重寫為_return_register_finalizer。這是一條非標準的字節碼,在Java虛擬機規范中沒有要求,是虛擬機獨有的字節碼,如果虛擬機在執行時發現是非標準的_return_register_finalizer,則會額外執行很多代碼(代碼清單2-7):插入機器指令判斷當前方法是否重寫finalize,如果重寫,則經過一個很長的調用鏈,最終調用java.lang.ref.Finalizer的register()。

代碼清單2-7 重寫finalize額外需要執行的代碼


instanceOop InstanceKlass::register_finalizer(...) {
    instanceHandle h_i(THREAD, i);  JavaValue result(T_VOID);
    JavaCallArguments args(h_i);
    // 對應java.lang.ref.Finalizer的register方法(該類為package-private)
    methodHandle mh (THREAD, Universe::finalizer_register_method());
    JavaCalls::call(&result, mh, &args, CHECK_NULL);
    return h_i();
}

register()會將重寫了finalize()的對象放入一個鏈表,等待后面垃圾回收對鏈表每個對象執行finalize()方法。

2. switch重寫

重寫器還會優化switch語句的性能。它根據switch的case個數是否小于-XX:BinarySwitchThreshold[1](默認5)選擇線性搜索switch或者二分搜索switch。線性搜索可以在線性時間內定位到求值后的case,二分搜索則保證在最壞情況下,在O(logN)內定位到case。switch重寫使用的二分搜索算法如代碼清單2-8所示:

代碼清單2-8 二分搜索偽代碼(Edsger W. Dijkstra, W.H.J. Feijen)


int binary_search(int key, LookupswitchPair* array, int n) {
    int i = 0, j = n;
    while (i+1 < j) {
        int h = (i + j) >> 1;
        if (key < array[h].fast_match())
            j = h;
        else 
            i = h;
    }  
    return i;
}

2.2.3 方法鏈接

方法鏈接是鏈接階段乃至整個類可用機制中最重要的一步,它直接關系著方法能否被虛擬機執行。本節從方法在虛擬機中的表示開始,詳細描述方法鏈接過程。

1. Method數據結構

OpenJDK 8以后的版本是用Method這個數據結構,在JVM層表示Java方法,位于oops/method.cpp,里面包含了解釋器、編譯器的代碼入口和一些重要的用于統計方法性能的數據。

“HotSpot”的中文意思是“熱點”,指的是它能對字節碼中的方法和循環進行Profiling性能計數,找出熱點方法或循環,并對其進行不同程度的優化。這些Profiling數據就存放在MethodData和MethodCounter中。熱點探測與方法編譯是一個復雜又有趣的過程,虛擬機需要回答什么程度才算熱點、單個循環如何優化等,這些內容將在本書的第二部分詳細討論。

Method另一個重要的字段是_intrinsic_id。如果某方法的實現廣為人知,或者某方法另有高效算法實現,對于它們,即便使用JIT編譯性能也達不到最佳。為了追求極致的性能,可以將這些方法視作固有方法(Intrinsic Method)或者知名方法(Well-known Method),解放CPU指令集中所有支持的指令,由虛擬機工程師手寫它們的實現。_intrinsic_id表示固有方法的id,如果該id有效,即該方法是固有方法,即便方法有對應的Java實現,虛擬機也不會走普通的解釋執行或者編譯Java方法,而是直接跳到該方法對應的手寫的固有方法實現例程并執行。

所有固有方法都能在classfile/vmSymbols.hpp中找到,一個絕佳的例子是java.lang.Math。對于Math.sqrt(),用Java或者JNI均無法達到極致性能,這時可以將其置為固有方法,當虛擬機遇到它時只需要一條CPU指令fsqrt(代碼清單2-9),用硬件級實現碾壓軟件級算法:

代碼清單2-9 Math.sqrt固有方法實現


// 32位:使用x87的fsqrt
void Assembler::fsqrt() {
    emit_int8((unsigned char)0xD9);
    emit_int8((unsigned char)0xFA); 
}
// 64位:使用SSE2的sqrtsd
void Assembler::sqrtsd(XMMRegister dst, XMMRegister src) {
    ...
    int encode = simd_prefix_and_encode(...);
    emit_int8(0x51);
    emit_int8((unsigned char)(0xC0 | encode));
}

2. 編譯器、解釋器入口

Method的其他數據字段會在后面陸續提到,目前方法鏈接需要用到的數據只是圖2-2右側的各個入口地址,具體如下所示。

? _i2i_entry:定點解釋器入口。方法調用會通過它進入解釋器的世界,該字段一經設置后面不再改變。通過它一定能進入解釋器。

? _from_interpreter_entry:解釋器入口。最開始與_i2i_entry指向同一個地方,在字節碼經過JIT編譯成機器代碼后會改變,指向i2c適配器入口。

? _from_compiled_entry:編譯器入口。最開始指向c2i適配器入口,在字節碼經過編譯后會改變地址,指向編譯好的代碼。

? _code:代碼入口。當編譯器完成編譯后會指向編譯后的本地代碼。

圖2-2 Method結構

有了上面的知識,方法鏈接的源碼就很容易理解了。如代碼清單2-10所示,鏈接階段會將i2i_entry和_from_interpreter_entry都指向解釋器入口,另外還會生成c2i適配器,將_from_compiled_entry也適配到解釋器:

代碼清單2-10 方法鏈接實現


void Method::link_method(...) {
    // 如果是CDS(Class Data Sharing)方法
    if (is_shared()) {
        address entry = Interpreter::entry_for_cds_method(h_method);
        if (adapter() != NULL) {
            return;
        }
    } else if (_i2i_entry != NULL) {
        return;
    }
    // 方法鏈接時,該方法肯定沒有被編譯(因為沒有設置編譯器入口)
    if (!is_shared()) {
        // 設置_i2i_entry和_from_interpreted_entry都指向解釋器入口
        address entry = Interpreter::entry_for_method(h_method);
        set_interpreter_entry(entry);
    }
    ...
    // 設置_from_compiled_entry為c2i適配器入口
    (void) make_adapters(h_method, CHECK);
}

各種入口的地址不會是一成不變的,當編譯/解釋模式切換時,入口地址也會相應切換,如從解釋器切換到編譯器,編譯完成后會設置新的_code、_from_compiled_entry和_from_interpreter_entry入口;如果發生退優化(Deoptimization),從編譯模式回退到解釋模式,又會重置這些入口。關于入口設置的具體實現如代碼清單2-11所示:

代碼清單2-11 編譯器/解釋器入口的設置


void Method::set_code(...) {
    MutexLockerEx pl(Patching_lock, Mutex::_no_safepoint_check_flag);
    // 設置編譯好的機器代碼    
    mh->_code = code;   
    ...
    OrderAccess::storestore();
    // 設置解釋器入口點為編譯后的機器代碼
    mh->_from_compiled_entry = code->verified_entry_point();
    OrderAccess::storestore();
    if (!mh->is_method_handle_intrinsic())
            mh->_from_interpreted_entry = mh->get_i2c_entry();
}
void Method::clear_code(bool acquire_lock /* = true */) {
    MutexLockerEx pl(...);
    // 清除_from_interpreted_entry,使其再次指向c2i適配器
    if (adapter() == NULL) {
        _from_compiled_entry    = NULL;
    } else {
        _from_compiled_entry    = adapter()->get_c2i_entry();
    }
    OrderAccess::storestore();
    // 將_from_interpreted_entry再次指向解釋器入口
    _from_interpreted_entry = _i2i_entry;
    OrderAccess::storestore();
    // 取消指向機器代碼
    _code = NULL;
}

3. C2I/I2C適配器

在上述代碼中多次提到c2i、i2c適配器,如圖2-3所示。所謂c2i是指編譯模式到解釋模式(Compiler-to-Interpreter),i2c是指解釋模式到編譯模式(Interpreter-to-Compiler)。由于編譯產出的本地代碼可能用寄存器存放參數1,用棧存放參數2,而解釋器都用棧存放參數,需要一段代碼來消弭它們的不同,適配器應運而生。它是一段跳床(Trampoline)代碼,以i2c為例,可以形象地認為解釋器“跳入”這段代碼,將解釋器的參數傳遞到機器代碼要求的地方,這種要求即調用約定(Calling Convention),然后“跳出”到機器代碼繼續執行。

圖2-3 i2c,c2i適配器

如圖2-3所示,兩個適配器都是由SharedRuntime::generate_i2c2i_adapters生成的,該函數會在里面進一步調用geni2cadapter()生成i2c適配器。由于代碼較多,這里只以i2c適配器的生成為例(見代碼清單2-12),對c2i適配器感興趣的讀者可自行反向分析。

代碼清單2-12 i2c入口適配器生成


void SharedRuntime::gen_i2c_adapter(...) {
    // 將解釋器棧頂放入rax
    __ movptr(rax, Address(rsp, 0));
    ...
    // 保存當前解釋器棧頂到saved_sp
    __ movptr(r11, rsp);
    if (comp_args_on_stack) { ... }
    __ andptr(rsp, -16);
    // 將棧頂壓入棧作為返回值,本地代碼執行完畢后返回解釋模式,即使用這個地址
    __ push(rax);
    const Register saved_sp = rax;
    __ movptr(saved_sp, r11);
    // 獲取本地代碼入口放入r11,這是解釋執行到本地代碼執行的關鍵步驟
    __ movptr(r11,...Method::from_compiled_offset());
    // 從右向左逐個處理位于解釋器的方法參數
    for(int i = 0; i < total_args_passed; i++) {
        // 如果參數類型是VOID,就不將該參數從解釋器棧轉移到編譯后的代碼執行的棧
        if (sig_bt[i] == T_VOID) {
            continue;
        }
        // 獲取解釋器方法棧最右邊參數偏移到ld_off
        int ld_off = ...;
        // 獲取解釋器方法棧最右邊的前一個參數偏移到next_off
        int next_off = ld_off - Interpreter::stackElementSize;
        // r_1和r_2都表示32位,組合起來構成一個VMRegPair表示64位。如果是
        // 64位則r_2無效,所以下面代碼的r_2->is_valid()相當于判斷是否為64位
        VMReg r_1 = regs[i].first();
        VMReg r_2 = regs[i].second();
        if (!r_1->is_valid()) { continue; }
        // 如果本地代碼執行棧要求解釋器棧參數放到棧中
        if (r_1->is_stack()) {
            // 獲取本地代碼執行棧距棧頂偏移
            int st_off = ...;
            // 用r13做中轉,將解釋器棧參數放入r13,再移動到本地代碼執行棧
            if (!r_2->is_valid()) {
                __ movl(r13, Address(saved_sp, ld_off));
                __ movptr(Address(rsp, st_off), r13);
            } else {
                // 這里表示32位,一個槽放不下long和double
                …
            }
        }
        // 如果本地代碼執行棧要求解釋器棧參數放到通用寄存器中 
        else if (r_1->is_Register()) {
            Register r = r_1->as_Register();
            // 寄存器直接執行mov命令即可,不需要r13中轉
            if (r_2->is_valid()) {
                const int offset = ...;
                __ movq(r, Address(saved_sp, offset));
            } else {
                __ movl(r, Address(saved_sp, ld_off));
            }
        } 
        else { // 如果本地代碼執行棧要求解釋器棧參數放到XMM寄存器中 
            if (!r_2->is_valid()) {
                __ movflt(r_1->as_XMMRegister(),...);
            } else {
                __ movdbl(r_1->as_XMMRegister(), ...);
            }
        }
    }
    ...
    // r11保存了本地代碼入口,所以跳到r11執行本地代碼
    __ jmp(r11);
}

適配器的邏輯清晰,但是由于使用了類似匯編的代碼風格,看起來比較復雜。可以這樣理解適配器:想象有一個解釋器方法棧存放所有參數,然后有一個本地方法執行棧和寄存器,如圖2-4所示,適配器要做的就是將解釋器執行棧的參數傳遞到本地方法執行棧和寄存器中。

圖2-4 i2c適配器的工作方式

4. CDS

最后,方法鏈接還有個細節:在設置入口前,它會區分該方法是否是CDS(Class Data Sharing,類數據共享)方法,并據此設置不同的解釋器入口。

CDS是JDK5引入的特性,它把最常用的類從內存中導出形成一個歸檔文件,在下一次虛擬機啟動可使用mmap/MapViewOfFile等函數將該文件映射到內存中直接使用而不再加載解析這些類,以此加快Java程序啟動。如果有多個虛擬機運行,還可以共享該文件,減小內存消耗。

但是CDS只允許Bootstrap類加載器加載類共享文件,適用場景非常有限,所以JEP 310于Java 10引入了新的AppCDS(Application Class Data Sharing,應用類數據共享),讓Application類加載器和Platform類加載器甚至自定義類加載器也能擁有CDS。

AppCDS對于快速啟動、快速執行、立即關閉的應用程序有不錯的效果,使用代碼清單2-13的命令可以開啟AppCDS:

代碼清單2-13 使用AppCDS


$java -Xshare:off -XX:DumpLoadedClassList=class.lit HelloWorld
$java -Xshare:dump -XX:SharedClassListFile=class.list -XX:SharedArchiveFile=hello.jsa HelloWorld
$java -Xshare:on -XX:SharedArchiveFile=hello.jsa HelloWorld

AppCDS并不是故事的全部,它雖然可以導出更多類,但是使用比較麻煩,需要三步:

1)運行第一次,生成類列表;

2)運行第二次,根據類列表從內存中導出類到歸檔文件;

3)附帶著歸檔文件運行第三次。

為此,JEP 350于Java 13引入了DynamicCDS,它可以消除AppCDS的第一步,在第一次運行程序退出時將記錄了本次運行加載的CDS沒有涉及的類自動導出到歸檔文件,第二次直接附帶歸檔文件運行即可。

[1] HotSpot VM中的參數分為多種類型,詳細可參見runtime/globals.hpp。簡單來說,develop只在fastdebug/slowdebug虛擬機上有效;diagnostic用于VM調試診斷;experimental是實驗性質的一些特性,未來可能消失也可能進入產品;product是產品級特性,可以用來做VM調優、性能分析等;manageable可以通過JMI接口或者jconsole等工具在運行時修改它的值;使用-XX:+PrintFlagsFinal可以輸出所有product的參數。

主站蜘蛛池模板: 姚安县| 东乌珠穆沁旗| 乌拉特前旗| 浦县| 当涂县| 阿坝| 青田县| 察隅县| 多伦县| 新田县| 嘉峪关市| 大庆市| 阿合奇县| 阿鲁科尔沁旗| 焦作市| 新津县| 汉沽区| 涡阳县| 高青县| 广东省| 万全县| 沙湾县| 高雄市| 平阴县| 栖霞市| 鹿邑县| 民县| 宁阳县| 玉环县| 海门市| 宝清县| 高雄县| 弥渡县| 余庆县| 罗定市| 宁南县| 洪湖市| 扬中市| 响水县| 会理县| 临猗县|