運行時優化
方法內聯
方法內聯,是指 JVM在運行時將調用次數達到一定閾值的方法調用替換為方法體本身 ,從而消除調用成本,并為接下來進一步的代碼性能優化提供基礎,是JVM的一個重要優化手段之一。
注:
- C++的inline屬于編譯后內聯,但是java是運行時內聯
簡單通俗的講就是把方法內部調用的其它方法的邏輯,嵌入到自身的方法中去,變成自身的一部分,之后不再調用該方法,從而節省調用函數帶來的額外開支。
為什么會出現方法內聯呢?
之所以出現方法內聯是因為(方法調用)函數調用除了執行自身邏輯的開銷外,還有一些不為人知的額外開銷。 這部分額外的開銷主要來自方法棧幀的生成、參數字段的壓入、棧幀的彈出、還有指令執行地址的跳轉 。比如有下面這樣代碼:
public static void function_A(int a, int b){//do somethingfunction_B(a,b);}public static void function_B(int c, int d){//do something}public static void main(String[] args){function_A(1,2);}
則代碼的執行過程如下:
所以如果java中方法調用嵌套過多或者方法過多,這種額外的開銷就越多。
試想一下想get/set這種方法調用:
public int getI() {return i;}public void setI(int i) {this.i = i;}
很可能自身執行邏輯的開銷還比不上為了調用這個方法的額外開鎖。如果類似的方法被頻繁的調用,則真正相對執行效率就會很低,雖然這類方法的執行時間很短。這也是為什么jvm會在熱點代碼中執行方法內聯的原因,這樣的話就可以省去調用調用函數帶來的額外開支。
這里舉個內聯的可能形式:
public int add(int a, int b , int c, int d){return add(a, b) + add(c, d);}public int add(int a, int b){return a + b;}
內聯之后:
public int add(int a, int b , int c, int d){return a + b + c + d;}
內聯條件
一個方法如果滿足以下條件就很可能被jvm內聯。
- 熱點代碼。 如果一個方法的執行頻率很高就表示優化的潛在價值就越大。那代碼執行多少次才能確定為熱點代碼?這是根據編譯器的編譯模式來決定的。如果是客戶端編譯模式則次數是1500,服務端編譯模式是10000。次數的大小可以通過-XX:CompileThreshold來調整。
- 方法體不能太大。jvm中被內聯的方法會編譯成機器碼放在code cache中。如果方法體太大,則能緩存熱點方法就少,反而會影響性能。熱點方法小于325字節的時候,非熱點代碼35字節以下才會使用這種方式
- 如果希望方法被內聯, 盡量用private、static、final修飾 ,這樣jvm可以直接內聯。如果是public、protected修飾方法jvm則需要進行類型判斷,因為這些方法可以被子類繼承和覆蓋,jvm需要判斷內聯究竟內聯是父類還是其中某個子類的方法。
所以了解jvm方法內聯機制之后,會有助于我們工作中寫出能讓jvm更容易優化的代碼,有助于提升程序的性能。
逃逸分析
什么是“對象逃逸”?
對象逃逸的本質是對象指針的逃逸。
在計算機語言編譯器優化原理中,逃逸分析是指分析指針動態范圍的方法,它同編譯器優化原理的指針分析和外形分析相關聯。當變量(或者對象)在方法中分配后,其指針有可能被返回或者被全局引用,這樣就會被其他方法或者線程所引用,這種現象稱作指針(或者引用)的逃逸(Escape)。通俗點講,如果一個對象的指針被多個方法或者線程引用時,那么我們就稱這個對象的指針(或對象)的逃逸(Escape)。
什么是逃逸分析?
逃逸分析,是一種可以有效減少Java 程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法。通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的對象的引用的使用范圍從而決定是否要將這個對象分配到堆上。 逃逸分析(Escape Analysis)算是目前Java虛擬機中比較前沿的優化技術了。
注意:逃逸分析不是直接的優化手段,而是代碼分析手段。
對象逃逸案例:
Xpublic User doSomething1() {User user1 = new User ();user1 .setId(1);user1 .setDesc("xxxxxxxx");// ......return user1 ;
}
對象未逃逸:
public void doSomething2() {User user2 = new User ();user2 .setId(2);user2 .setDesc("xxxxxxxx");// ......
}
基于逃逸分析的優化
當判斷出對象不發生逃逸時,編譯器可以使用逃逸分析的結果作一些代碼優化
- 棧上分配:將堆分配轉化為棧分配。如果某個對象在子程序中被分配,并且指向該對象的指針永遠不會逃逸,該對象就可以在分配在棧上,而不是在堆上。在的垃圾收集的語言中,這種優化可以降低垃圾收集器運行的頻率。
- 同步消除:如果發現某個對象只能從一個線程可訪問,那么在這個對象上的操作可以不需要同步。
- 分離對象或標量替換。如果某個對象的訪問方式不要求該對象是一個連續的內存結構,那么對象的部分(或全部)可以不存儲在內存,而是存儲在CPU寄存器中。
標量替換
**標量:**不可被進一步分解的量,而JAVA的基本數據類型就是標量(比如int,long等基本數據類型) 。
聚合量: 標量的對立就是可以被進一步分解的量,稱之為聚合量。 在JAVA中對象就是可以被進一步分解的聚合量。
**標量替換:**通過逃逸分析確定該對象不會被外部訪問,并且對象可以被進一步分解時,JVM不會創建該對象,而是將該對象成員變量分解若干個被這個方法使用的成員變量所代替,這些代替的成員變量在棧幀或寄存器上分配空間,這樣就不會因為沒有一大塊連續空間導致對象內存不夠分配。
棧上分配案例:
虛擬機參數:
-XX:+PrintGC -Xms5M -Xmn5M -XX:+DoEscapeAnalysis
-XX:+DoEscapeAnalysis表示開啟逃逸分析,JDK8是默認開啟的
-XX:+PrintGC 表示打印GC信息
-Xms5M -Xmn5M 設置JVM內存大小是5M
public static void main(String[] args){for(int i = 0; i < 5_000_000; i++){createObject();}}public static void createObject(){new Object();}
運行結果是沒有GC。
把虛擬機參數改成 -XX:+PrintGC -Xms5M -Xmn5M -XX:-DoEscapeAnalysis。關閉逃逸分析得到結果的部分截圖是,說明了進行了GC,并且次數還不少。
這說明了JVM在逃逸分析之后,將對象分配在了方法createObject()方法棧上。方法棧上的對象在方法執行完之后,棧楨彈出,對象就會自動回收。這樣的話就不需要等內存滿時再觸發內存回收。這樣的好處是程序內存回收效率高,并且GC頻率也會減少,程序的性能就提高了。
同步鎖消除
如果發現某個對象只能從一個線程可訪問,那么在這個對象上的操作可以不需要同步 。
虛擬機配置參數:-XX:+PrintGC -Xms500M -Xmn500M -XX:+DoEscapeAnalysis。配置500M是保證不觸發GC。
public static void main(String[] args){long start = System.currentTimeMillis();for(int i = 0; i < 5_000_000; i++){createObject();}System.out.println("cost = " + (System.currentTimeMillis() - start) + "ms");}public static void createObject(){synchronized (new Object()){}}
運行結果
cost = 6ms
把逃逸分析關掉:-XX:+PrintGC -Xms500M -Xmn500M -XX:-DoEscapeAnalysis
運行結果
cost = 270ms
說明了逃逸分析把鎖消除了,并在性能上得到了很大的提升。這里說明一下Java的逃逸分析是方法級別的,因為JIT ( just in time )即時編譯器的即時編譯是方法級別。
什么條件下會觸發逃逸分析?
對象會先嘗試棧上分配,如果不能成功分配,那么就去TLAB,如果還不行,就判定當前的垃圾收集器悲觀策略,可不可以直接進入老年代,最后才會進入Eden。
Java的逃逸分析只發在JIT的即時編譯中,因為在啟動前已經通過各種條件判斷出來是否滿足逃逸,通過上面的流程圖也可以得知對象分配不一定在堆上,所以可知滿足逃逸的條件如下,只要滿足以下任何一種都會判斷為逃逸。
一、對象被賦值給堆中對象的字段和類的靜態變量。
二、對象被傳進了不確定的代碼中去運行。
對象逃逸的范圍有:全局逃逸、參數逃逸、沒有逃逸;
TLAB前面的內容講過,在當前場景下做一個補充:
TLAB(Thread Local Allocation Buffer)
即線程本地分配緩存區,這是一個線程專用的內存分配區域。
由于對象一般會分配在堆上,而堆是全局共享的。因此在同一時間,可能會有多個線程在堆上申請空間。因此,每次對象分配都必須要進行同步(虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性),而在競爭激烈的場合分配的效率又會進一步下降。JVM使用TLAB來避免多線程沖突,在給對象分配內存時,每個線程使用自己的TLAB,這樣可以避免線程同步,提高了對象分配的效率。
每個線程會從Eden分配一大塊空間,例如說100KB,作為自己的TLAB。這個start是TLAB的起始地址,end是TLAB的末尾,然后top是當前的分配指針。顯然start <= top < end。
當一個Java線程在自己的TLAB中分配到盡頭之后,再要分配就會出發一次“TLAB refill”,也就是說之前自己的TLAB就“不管了”(所有權交回給共享的Eden),然后重新從Eden里分配一塊空間作為新的TLAB。所謂“不管了”并不是說就讓舊TLAB里的對象直接死掉,而是把那塊空間的控制權歸還給普通的Eden,里面的對象該怎樣還是怎樣。通常情況下,在TLAB中分配多次才會填滿TLAB、觸發TLAB refill,這樣使用TLAB分配就比直接從共享部分的Eden分配要均攤(amortized)了同步開銷,于是提高了性能。其實很多關注多線程性能的malloc庫實現也會使用類似的做法,例如TCMalloc。
到觸發GC的時候,無論是minor GC還是full GC,要收集Eden的時候里面的空間無論是屬于某個線程的TLAB還是不屬于任何TLAB都一視同仁,把Eden當作一個整體來收集里面的對象——把活的對象拷貝到survivor space(或者直接晉升到Old Gen)。在GC結束之后,每個Java線程又會重新從Eden分配自己的TLAB。周而復始。
TLAB分配的對象可以共享嗎?
答:只要是Heap上的對象,所有線程都是可以共享的,就看你有沒有本事訪問到了。在GC的時候只從root sets來掃描對象,而不管你到底在哪個TLAB中。
4.1 內存優化
4.1.1 內存分配
正常情況下不需要設置,那如果是促銷或者秒殺的場景呢?
每臺機器配置2c4G,以每秒3000筆訂單為例,整個過程持續60秒
4.1.2 內存溢出(OOM)
一般會有兩個原因:
(1)大并發情況下
(2)內存泄露導致內存溢出
4.1.2.1 大并發[秒殺]
瀏覽器緩存、本地緩存、驗證碼
CDN靜態資源服務器
集群+負載均衡
動靜態資源分離、限流[基于令牌桶、漏桶算法]
應用級別緩存、接口防刷限流、隊列、Tomcat性能優化
異步消息中間件
Redis熱點數據對象緩存
分布式鎖、數據庫鎖
5分鐘之內沒有支付,取消訂單、恢復庫存等
4.1.2.2 內存泄露導致內存溢出
ThreadLocal引起的內存泄露,最終導致內存溢出
public class TLController { @RequestMapping(value = "/tl") public String tl(HttpServletRequest request) {ThreadLocal<Byte[]> tl = new ThreadLocal<Byte[]>();// 1MBtl.set(new Byte[1024*1024]);return "ok"; } }
(1)上傳到阿里云服務器
jvm-case-0.0.1-SNAPSHOT.jar
(2)啟動
java -jar -Xms1000M -Xmx1000M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=jvm.hprof jvm-case-0.0.1-SNAPSHOT.jar
(3)使用jmeter模擬10000次并發
39.100.39.63:8080/tl
(4)top命令查看
top
top -Hp PID
(5)jstack查看線程情況,發現沒有死鎖或者IO阻塞的情況
jstack PID
java -jar arthas.jar ---> thread
(6)查看堆內存的使用,發現堆內存的使用率已經高達88.95%
jmap -heap PID
java -jar arthas.jar ---> dashboard
(7)此時可以大體判斷出來,發生了內存泄露從而導致的內存溢出,那怎么排查呢?
jmap -histo:live PID | more
獲取到jvm.hprof文件,上傳到指定的工具分析,比如heaphero.io