面試官:Java中實(shí)例對象存儲(chǔ)在哪?
本文轉(zhuǎn)載自微信公眾號「java寶典」,作者iTengyu。轉(zhuǎn)載本文請聯(lián)系java寶典公眾號。
目錄:
- 理解Java編譯流程
- 前端編譯(Front End)
- 后端編譯(Back End)
- 什么是JIT (Just in time)
- 編譯器和解釋器的優(yōu)缺點(diǎn)以及實(shí)用場景
- 熱點(diǎn)檢測算法
- 1)基于采樣的熱點(diǎn)探測
- 2) 基于計(jì)數(shù)器的熱點(diǎn)探測
- 對象棧上分配的優(yōu)化
- 逃逸分析
- 標(biāo)量替換
- 同步消除(鎖消除)
- 棧上分配
- 對象的內(nèi)存分配
- 解決堆內(nèi)存分配的并發(fā)問題
- CAS
- TLAB
- 總結(jié)
理解Java編譯流程
低級語言是計(jì)算機(jī)認(rèn)識的語言、高級語言是程序員認(rèn)識的語言。如何從高級語言轉(zhuǎn)換成低級語言呢?這個(gè)過程其實(shí)就是編譯。
不同的語言都有自己的編譯器,Java語言中負(fù)責(zé)編譯的編譯器是一個(gè)命令:javac
通過javac命令將Java程序的源代碼編譯成Java字節(jié)碼,即我們常說的.class文件。這也是我們所理解的編譯.
但是.class并不是計(jì)算機(jī)能夠識別的語言.要想讓機(jī)器能夠執(zhí)行,需要把字節(jié)碼再翻譯成機(jī)器指令,這個(gè)過程是JVM來完成的.這個(gè)過程也叫編譯.只是層次更深..
因此我們了解到,編譯器可劃分為前端(Front End)與后端(Back End)。
我們可以把將.java文件編譯成.class的編譯過程稱之為前端編譯。把將.class文件翻譯成機(jī)器指令的編譯過程稱之為后端編譯。
前端編譯(Front End)
前端編譯主要指與源語言有關(guān)但與目標(biāo)機(jī)無關(guān)的部分,包括詞法分析、語法分析、語義分析與中間代碼生成。
例如我們使用很多的IDE,如eclipse,idea等,都內(nèi)置了前端編譯器。主要功能就是把.java代碼轉(zhuǎn)換成`.class字節(jié)碼
后端編譯(Back End)
后端編譯主要指與目標(biāo)機(jī)有關(guān)的部分,包括代碼優(yōu)化和目標(biāo)代碼生成等。
在后端編譯中,通常都經(jīng)過前端編譯的處理,已經(jīng)加工成.class字節(jié)碼文件了 JVM通過解釋字節(jié)碼將其逐條讀入并翻譯為對應(yīng)機(jī)器指令,讀一條翻譯一條,勢必是分產(chǎn)生效率問題因此引入了JIT(just in time)
什么是JIT (Just in time)
當(dāng)JVM發(fā)現(xiàn)某個(gè)方法或代碼塊運(yùn)行特別頻繁的時(shí)候,就會(huì)認(rèn)為這是“熱點(diǎn)代碼”(Hot Spot Code)。JIT會(huì)把部分“熱點(diǎn)代碼”翻譯成本地機(jī)器相關(guān)的機(jī)器碼,并進(jìn)行優(yōu)化,然后緩存起來,以備下次使用
在HotSpot虛擬機(jī)中內(nèi)置了兩個(gè)JIT編譯器分別是:
- - Client complier [客戶端]
- - Server complier [服務(wù)端]
目前JVM中默認(rèn)都是采用: 解釋器+一個(gè)JIT編譯器 配合的方式進(jìn)行工作 即混合模式
下圖是我機(jī)器上安裝的JDK ,可以看出,使用的JIT是Server Complier, 解釋器和JIT的工作方式是mixed mode
面試題:為何HotSpot虛擬機(jī)要實(shí)現(xiàn)兩個(gè)不同的即時(shí)編譯器?
HotSpot虛擬機(jī)中內(nèi)置了兩個(gè)即時(shí)編譯器:Client Complier和Server Complier,簡稱為C1、C2編譯器,分別用在客戶端和服務(wù)端。目前主流的HotSpot虛擬機(jī)中默認(rèn)是采用解釋器與其中一個(gè)編譯器直接配合的方式工作。程序使用哪個(gè)編譯器,取決于虛擬機(jī)運(yùn)行的模式。HotSpot虛擬機(jī)會(huì)根據(jù)自身版本與宿主機(jī)器的硬件性能自動(dòng)選擇運(yùn)行模式,用戶也可以使用“-client”或“-server”參數(shù)去強(qiáng)制指定虛擬機(jī)運(yùn)行在Client模式或Server模式。
用Client Complier獲取更高的編譯速度,用Server Complier 來獲取更好的編譯質(zhì)量。和為什么提供多個(gè)垃圾收集器類似,都是為了適應(yīng)不同的應(yīng)用場景。
編譯器和解釋器的優(yōu)缺點(diǎn)以及實(shí)用場景
在JVM執(zhí)行代碼時(shí),它并不是馬上開始編譯代碼,當(dāng)一段經(jīng)常被執(zhí)行的代碼被編譯后,下次運(yùn)行就不用重復(fù)編譯,此時(shí)使用JIT是劃算的,但是它也不是萬能的,比如說一些極少執(zhí)行的代碼在編譯時(shí)花費(fèi)的時(shí)間比解釋器還久,這時(shí)就是得不償失了
所以,解釋器和JIT各有千秋:
解釋器與編譯器兩者各有優(yōu)勢:當(dāng)程序需要迅速啟動(dòng)和執(zhí)行的時(shí)候,解釋器可以首先發(fā)揮作用,省去編譯的時(shí)間,立即執(zhí)行。在程序運(yùn)行后,隨著時(shí)間的推移,編譯器逐漸發(fā)揮作用,把越來越多的代碼編譯成本地代碼之后,可以獲取更高的執(zhí)行效率。
當(dāng)極少執(zhí)行或者執(zhí)行次數(shù)較少的JAVA代碼使用解釋器最優(yōu).
當(dāng)重復(fù)執(zhí)行或者執(zhí)行次數(shù)較多的JAVA代碼使用JIT更劃算.
熱點(diǎn)檢測算法
要想觸發(fā)JIT,首先需要識別出熱點(diǎn)代碼。目前主要的熱點(diǎn)代碼識別方式是熱點(diǎn)探測(Hot Spot Detection),有以下兩種:
1)基于采樣的熱點(diǎn)探測
采用這種方法的虛擬機(jī)會(huì)周期性地檢查各個(gè)線程的棧頂,如果發(fā)現(xiàn)某些方法經(jīng)常出現(xiàn)在棧頂,那這個(gè)方法就是“熱點(diǎn)方法”。這種探測方法的好處是實(shí)現(xiàn)簡單高效,還可以很容易地獲取方法調(diào)用關(guān)系(將調(diào)用堆棧展開即可),缺點(diǎn)是很難精確地確認(rèn)一個(gè)方法的熱度,容易因?yàn)槭艿骄€程阻塞或別的外界因素的影響而擾亂熱點(diǎn)探測。
2) 基于計(jì)數(shù)器的熱點(diǎn)探測
采用這種方法的虛擬機(jī)會(huì)為每個(gè)方法(甚至是代碼塊)建立計(jì)數(shù)器,統(tǒng)計(jì)方法的執(zhí)行次數(shù),如果執(zhí)行次數(shù)超過一定的閥值,就認(rèn)為它是“熱點(diǎn)方法”。這種統(tǒng)計(jì)方法實(shí)現(xiàn)復(fù)雜一些,需要為每個(gè)方法建立并維護(hù)計(jì)數(shù)器,而且不能直接獲取到方法的調(diào)用關(guān)系,但是它的統(tǒng)計(jì)結(jié)果相對更加精確嚴(yán)謹(jǐn)。
那么在HotSpot虛擬機(jī)中使用的是哪個(gè)熱點(diǎn)檢測方式呢?
在HotSpot虛擬機(jī)中使用的是第二種,基于計(jì)數(shù)器的熱點(diǎn)探測方法,因此它為每個(gè)方法準(zhǔn)備了兩個(gè)計(jì)數(shù)器:
>1 方法調(diào)用計(jì)數(shù)器
顧名思義,就是記錄一個(gè)方法被調(diào)用次數(shù)的計(jì)數(shù)器。
>2 回邊計(jì)數(shù)器
是記錄方法中的for或者while的運(yùn)行次數(shù)的計(jì)數(shù)器。
在確定虛擬機(jī)運(yùn)行參數(shù)的前提下,這兩個(gè)計(jì)數(shù)器都有一個(gè)確定的閾值,當(dāng)計(jì)數(shù)器超過閾值溢出了,就會(huì)觸發(fā)JIT編譯。
對象棧上分配的優(yōu)化
逃逸分析逃逸分析是一種有效減少JAVA程序中 同步負(fù)載 和 堆內(nèi)存分配壓力 的分析算法.Hotspot編譯器能夠分析出一個(gè)新的對象的引用的使用范圍從而決定是否要將這個(gè)對象分配到棧上.
- public static StringBuffer method(String s1, String s2) {
- StringBuffer sb = new StringBuffer();
- sb.append("關(guān)注");
- sb.append("java寶典");
- return sb;
- //此時(shí)sb對象從method方法逃出..
- }
- public static String method(String s1, String s2) {
- StringBuffer sb = new StringBuffer();
- sb.append("關(guān)注");
- sb.append("java寶典");
- return sb.toString();
- //此時(shí)sb對象 沒有離開 作用域
- }
- public void globalVariableEscape(){
- globalVariableObject = new Object(); //靜態(tài)變量,外部線程可見,發(fā)生逃逸
- }
- public void instanceObjectEscape(){
- instanceObject = new Object(); //賦值給堆中實(shí)例字段,外部線程可見,發(fā)生逃逸
- }
public void globalVariableEscape(){ globalVariableObject = new Object(); //靜態(tài)變量,外部線程可見,發(fā)生逃逸 } public void instanceObjectEscape(){ instanceObject = new Object(); //賦值給堆中實(shí)例字段,外部線程可見,發(fā)生逃逸 }
在確定對象不會(huì)逃逸后,JIT將可以進(jìn)行以下優(yōu)化: 標(biāo)量替換 同步消除 棧上分配
第一段代碼中的sb就逃逸了,而第二段代碼中的sb就沒有逃逸。
在Java代碼運(yùn)行時(shí),通過JVM參數(shù)可指定是否開啟逃逸分析,
-XX:+DoEscapeAnalysis :表示開啟逃逸分析
-XX:-DoEscapeAnalysis :表示關(guān)閉逃逸分析
-XX:+PrintEscapeAnalysis 開啟打印逃逸分析篩選結(jié)果
從jdk 1.7開始已經(jīng)默認(rèn)開始逃逸分析
標(biāo)量替換
允許將對象打散分配在棧上,比如若一個(gè)對象擁有兩個(gè)字段,會(huì)將這兩個(gè)字段視作局部變量進(jìn)行分配。
逸分析只是棧上內(nèi)存分配的前提,還需要進(jìn)行標(biāo)量替換才能真正實(shí)現(xiàn)。例:
- public static void main(String[] args) throws Exception {
- long start = System.currentTimeMillis();
- for (int i = 0; i < 10000; i++) {
- allocate();
- }
- System.out.println((System.currentTimeMillis() - start) + " ms");
- Thread.sleep(10000);
- }
- public static void allocate() {
- MyObject myObject = new MyObject(2019, 2019.0);
- }
- public static class MyObject {
- int a;
- double b;
- MyObject(int a, double b) {
- this.a = a;
- this.b = b;
- }
- }
標(biāo)量,就是指JVM中無法再細(xì)分的數(shù)據(jù),比如int、long、reference等。相對地,能夠再細(xì)分的數(shù)據(jù)叫做聚合量
Java虛擬機(jī)中的原始數(shù)據(jù)類型(int,long等數(shù)值類型以及reference類型等)都不能再進(jìn)一步分解,它們就可以稱為標(biāo)量。相對的,如果一個(gè)數(shù)據(jù)可以繼續(xù)分解,那它稱為聚合量,Java中最典型的聚合量是對象
如果逃逸分析證明一個(gè)對象不會(huì)被外部訪問,并且這個(gè)對象是可分解的,那程序真正執(zhí)行的時(shí)候?qū)⒖赡懿粍?chuàng)建這個(gè)對象,而改為直接創(chuàng)建它的若干個(gè)被這個(gè)方法使用到的成員變量來代替。拆散后的變量便可以被單獨(dú)分析與優(yōu)化,可以各自分別在棧幀或寄存器上分配空間,原本的對象就無需整體分配空間了
仍然考慮上面的例子,MyObject就是一個(gè)聚合量,因?yàn)樗蓛蓚€(gè)標(biāo)量a、b組成。通過逃逸分析,JVM會(huì)發(fā)現(xiàn)myObject沒有逃逸出allocate()方法的作用域,標(biāo)量替換過程就會(huì)將myObject直接拆解成a和b,也就是變成了:
- static void allocate() {
- int a = 2019;
- double b = 2019.0;
- }
可見,對象的分配完全被消滅了,而int、double都是基本數(shù)據(jù)類型,直接在棧上分配就可以了。所以,在對象不逃逸出作用域并且能夠分解為純標(biāo)量表示時(shí),對象就可以在棧上分配
- 開啟標(biāo)量替換 (-XX:+EliminateAllocations)
標(biāo)量替換的作用是允許將對象根據(jù)屬性打散后分配在棧上,默認(rèn)該配置為開啟
同步消除(鎖消除)
如果同步塊所使用的鎖對象通過逃逸分析被證實(shí)只能夠被一個(gè)線程訪問,那么JIT編譯器在編譯這個(gè)同步塊的時(shí)候就會(huì)取消對這部分代碼的同步。這個(gè)取消同步的過程就叫同步省略,也叫鎖消除
例子:
- public void f() {
- Object java_bible = new Object();
- synchronized(java_bible) {
- System.out.println(java_bible);
- }
- }
在經(jīng)過逃逸分析后,JIT編譯階段會(huì)被優(yōu)化成:
- public void f() {
- Object java_bible = new Object();
- System.out.println(java_bible); //鎖被去掉了.
- }
如果JIT經(jīng)過逃逸分析之后發(fā)現(xiàn)并無線程安全問題的話,就會(huì)做鎖消除。
棧上分配
通過逃逸分析,我們發(fā)現(xiàn),許多對象的生命周期會(huì)隨著方法的調(diào)用開始而開始,方法的調(diào)用結(jié)束而結(jié)束,很多的對象的作用域都不會(huì)逃逸出方法外,對于此種對象,我們可以考慮使用棧上分配,而不是在堆中分配.
因?yàn)橐坏┓峙湓诙芽臻g中,當(dāng)方法調(diào)用結(jié)束,沒有了引用指向該對象,該對象就需要被gc回收,而如果存在大量的這種情況,對gc來說反而是一種負(fù)擔(dān)。
JVM提供了一種叫做棧上分配的概念,針對那些作用域不會(huì)逃逸出方法的對象,在分配內(nèi)存時(shí)不在將對象分配在堆內(nèi)存中,而是將對象屬性打散后分配在棧(線程私有的,屬于棧內(nèi)存,標(biāo)量替換)上,這樣,隨著方法的調(diào)用結(jié)束,??臻g的回收就會(huì)隨著將棧上分配的打散后的對象回收掉,不再給gc增加額外的無用負(fù)擔(dān),從而提升應(yīng)用程序整體的性能
那么問題來了,如果棧上分配失敗了怎么辦?
對象的內(nèi)存分配
創(chuàng)建個(gè)對象有多種方法: 比如 使用new , reflect , clone 不管使用哪種 ,我們都要先分配內(nèi)存
我們拿new 來舉個(gè)例子:
- T t = new T()
- class T{
- int m = 8;
- }
- //javap
- 0 new #2<T> //new作用在內(nèi)存申請開辟一塊空間 new完之后m的值為 0
- 3 dup
- 4 invokespecial #3 <T.<init>>
- 7 astore_1
- 8 return
那么它是怎么分配的呢?
當(dāng)我們使用new創(chuàng)建對象后代碼開始運(yùn)行后,虛擬機(jī)執(zhí)行到這條new指令的時(shí)候,會(huì)先檢查要new的對象對應(yīng)的類是否已被加載,如果沒有被加載則先進(jìn)行類加載,檢查通過之后,就需要給對象進(jìn)行內(nèi)存分配,分配的內(nèi)存主要用來存放對象的實(shí)例變量
為對象分配空間的任務(wù)等同于把一塊確定大小的內(nèi)存從Java堆中劃分出來
根據(jù)內(nèi)存連續(xù)和不連續(xù)的情況,JVM使用不同的分配方式.
- 連續(xù): 指針碰撞
- 不連續(xù):空閑列表
指針碰撞(Serial、ParNew等帶Compact過程的收集器) 假設(shè)Java堆中內(nèi)存是絕對規(guī)整的,所有用過的內(nèi)存都放在一邊,空閑的內(nèi)存放在另一邊,中間放著一個(gè)指針作為分界點(diǎn)的指示器,那所分配內(nèi)存就僅僅是把那個(gè)指針向空閑空間那邊挪動(dòng)一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”(Bump the Pointer)。
空閑列表(CMS這種基于Mark-Sweep算法的收集器) 如果Java堆中的內(nèi)存并不是規(guī)整的,已使用的內(nèi)存和空閑的內(nèi)存相互交錯(cuò),那就沒有辦法簡單地進(jìn)行指針碰撞了,虛擬機(jī)就必須維護(hù)一個(gè)列表,記錄上哪些內(nèi)存塊是可用的,在分配的時(shí)候從列表中找到一塊足夠大的空間劃分給對象實(shí)例,并更新列表上的記錄,這種分配方式稱為“空閑列表”(Free List)。
無論那種方式,最終都需要確定出一塊內(nèi)存區(qū)域,用于給新建對象分配內(nèi)存。對象的內(nèi)存分配過程中,主要是對象的引用指向這個(gè)內(nèi)存區(qū)域,然后進(jìn)行初始化操作,那么在并發(fā)場景之中,如果多線程并發(fā)去堆中獲取內(nèi)存區(qū)域,怎么保證內(nèi)存分配的線程安全性.
解決堆內(nèi)存分配的并發(fā)問題
保證分配過程中的線程安全有兩種方式:
- CAS
- TLAB
CAS
CAS:采用CAS機(jī)制,配合失敗重試的方式保證線程安全性
CAS對于內(nèi)存的控制是使用重試機(jī)制,因此效率比較低,目前JVM使用的是TLAB方式,我們著重介紹TLAB.
TLAB
TLAB:每個(gè)線程在Java堆中預(yù)先分配一小塊內(nèi)存,然后再給對象分配內(nèi)存的時(shí)候,直接在自己這塊"私有"內(nèi)存中分配,當(dāng)這部分區(qū)域用完之后,再分配新的"私有"內(nèi)存,注意這個(gè)私有對于創(chuàng)建對象時(shí)是私有的,但是對于讀取是共享的.
TLAB (Thread local allcation buffer ) 在“分配”這個(gè)動(dòng)作上是線程獨(dú)占的,至于在讀取、垃圾回收等動(dòng)作上都是線程共享的。在對象的創(chuàng)建時(shí),首先嘗試進(jìn)行棧上分配,如果分配失敗,會(huì)使用TLAB嘗試分配,如果失敗查看是否是大對象,如果是大對象直接進(jìn)入老年代,否則進(jìn)入新生代(Eden).這里我總結(jié)了一張流程圖,如下:
我們可以總結(jié)出: 創(chuàng)建大對象和創(chuàng)建多個(gè)小對象相比,多個(gè)小對象的效率更高
不知道大家有沒有注意到,TLAB分配空間,每個(gè)線程在Java堆中預(yù)先分配一小塊內(nèi)存,他們在堆中去搶地盤的時(shí)候,也會(huì)出現(xiàn)并發(fā)問題,但是對于TLAB的同步控制和我們直接在堆中分配相比效率高了不少(不至于因?yàn)橐峙湟粋€(gè)對象而鎖住整個(gè)堆了).
總結(jié)
為了保證Java對象的內(nèi)存分配的安全性,同時(shí)提升效率,每個(gè)線程在Java堆中可以預(yù)先分配一小塊內(nèi)存,這部分內(nèi)存稱之為TLAB(Thread Local Allocation Buffer),這塊內(nèi)存的分配時(shí)線程獨(dú)占的,讀取、使用、回收是線程共享的。
虛擬機(jī)是否使用TLAB 可以通過 -XX:+/-UseTLAB 參數(shù)指定