天天看點

七、JVM調優實戰及常量池詳解

作者:Java架構之路

文章較長,請耐心閱讀,希望對您有所幫助。文末有視訊教程擷取方式,内容更詳盡,免費分享!!!

阿裡巴巴Arthas詳解

Arthas 是 Alibaba 在 2018 年 9 月開源的 Java 診斷工具。支援 JDK6+, 采用指令行互動模式,可以友善的定位和診斷線上程式運作問題。Arthas 官方文檔十分詳細,詳見:

Arthas使用場景

得益于 Arthas 強大且豐富的功能,讓 Arthas 能做的事情超乎想象。下面僅僅列舉幾項常見的使用情況,更多的使用場景可以在熟悉了 Arthas 之後自行探索。

  1. 是否有一個全局視角來檢視系統的運作狀況?
  2. 為什麼 CPU 又升高了,到底是哪裡占用了 CPU ?
  3. 運作的多線程有死鎖嗎?有阻塞嗎?
  4. 程式運作耗時很長,是哪裡耗時比較長呢?如何監測呢?
  5. 這個類從哪個 jar 包加載的?為什麼會報各種類相關的 Exception?
  6. 我改的代碼為什麼沒有執行到?難道是我沒 commit?分支搞錯了?
  7. 遇到問題無法線上上 debug,難道隻能通過加日志再重新釋出嗎?
  8. 有什麼辦法可以監控到 JVM 的實時運作狀态?

Arthas使用

# github下載下傳arthas
wget https://alibaba.github.io/arthas/arthas-boot.jar
# 或者 Gitee 下載下傳
wget https://arthas.gitee.io/arthas-boot.jar           

用java -jar運作即可,可以識别機器上所有Java程序(我們這裡之前已經運作了一個Arthas測試程式,代碼見下方)

七、JVM調優實戰及常量池詳解
import java.util.HashSet;

public class Arthas {

    private static HashSet hashSet = new HashSet();

    public static void main(String[] args) {
        // 模拟 CPU 過高
        cpuHigh();
        // 模拟線程死鎖
        deadThread();
        // 不斷的向 hashSet 集合增加資料
        addHashSetThread();
    }

    /**
     * 不斷的向 hashSet 集合添加資料
     */
    public static void addHashSetThread() {
        // 初始化常量
        new Thread(() -> {
            int count = 0;
            while (true) {
                try {
                    hashSet.add("count" + count);
                    Thread.sleep(1000);
                    count++;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    public static void cpuHigh() {
        new Thread(() -> {
            while (true) {

            }
        }).start();
    }

    /**
     * 死鎖
     */
    private static void deadThread() {
        /** 建立資源 */
        Object resourceA = new Object();
        Object resourceB = new Object();
        // 建立線程
        Thread threadA = new Thread(() -> {
            synchronized (resourceA) {
                System.out.println(Thread.currentThread() + " get ResourceA");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resourceB");
                synchronized (resourceB) {
                    System.out.println(Thread.currentThread() + " get resourceB");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (resourceB) {
                System.out.println(Thread.currentThread() + " get ResourceB");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resourceA");
                synchronized (resourceA) {
                    System.out.println(Thread.currentThread() + " get resourceA");
                }
            }
        });
        threadA.start();
        threadB.start();
    }
}           

選擇程序式号1,進入程序資訊操作

七、JVM調優實戰及常量池詳解

輸入dashboard可以檢視整個程序的運作情況,線程、記憶體、GC、運作環境資訊:

七、JVM調優實戰及常量池詳解

輸入thread可以檢視線程詳細情況

七、JVM調優實戰及常量池詳解

輸入 thread加上線程ID 可以檢視線程堆棧

七、JVM調優實戰及常量池詳解

輸入 thread -b 可以檢視線程死鎖

七、JVM調優實戰及常量池詳解

輸入 jad加類的全名 可以反編譯,這樣可以友善我們檢視線上代碼是否是正确的版本

七、JVM調優實戰及常量池詳解

使用 ognl 指令可以

七、JVM調優實戰及常量池詳解
七、JVM調優實戰及常量池詳解

更多指令使用可以用help指令檢視,或檢視文檔:

GC日志詳解

對于java應用我們可以通過一些配置把程式運作過程中的gc日志全部列印出來,然後分析gc日志得到關鍵性名額,分析GC原因,調優JVM參數。

列印GC日志方法,在JVM參數裡增加參數,%t 代表時間

-Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps  -XX:+PrintGCTimeStamps -XX:+PrintGCCause  
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M           

Tomcat則直接加在JAVA_OPTS變量裡。

如何分析GC日志

運作程式加上對應gc日志

java -jar -Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps  -XX:+PrintGCTimeStamps -XX:+PrintGCCause  
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M microservice-eureka-server.jar           

下圖中是我截取的JVM剛啟動的一部分GC日志

七、JVM調優實戰及常量池詳解

我們可以看到圖中第一行紅框,是項目的配置參數。這裡不僅配置了列印GC日志,還有相關的VM記憶體參數。

第二行紅框中的是在這個GC時間點發生GC之後相關GC情況。

1、對于2.909: 這是從jvm啟動開始計算到這次GC經過的時間,前面還有具體的發生時間日期。

2、Full GC(Metadata GC Threshold)指這是一次full gc,括号裡是gc的原因, PSYoungGen是年輕代的GC,ParOldGen是老年代的GC,Metaspace是元空間的GC

3、 6160K->0K(141824K),這三個數字分别對應GC之前占用年輕代的大小,GC之後年輕代占用,以及整個年輕代的大小。

4、112K->6056K(95744K),這三個數字分别對應GC之前占用老年代的大小,GC之後老年代占用,以及整個老年代的大小。

5、6272K->6056K(237568K),這三個數字分别對應GC之前占用堆記憶體的大小,GC之後堆記憶體占用,以及整個堆記憶體的大小。

6、20516K->20516K(1069056K),這三個數字分别對應GC之前占用元空間記憶體的大小,GC之後元空間記憶體占用,以及整個元空間記憶體的大小。

7、0.0209707是該時間點GC總耗費時間。

從日志可以發現幾次fullgc都是由于元空間不夠導緻的,是以我們可以将元空間調大點

java -jar -Xloggc:./gc-adjust-%t.log -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps  
-XX:+PrintGCTimeStamps -XX:+PrintGCCause  -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M 
microservice-eureka-server.jar           

調整完我們再看下gc日志發現已經沒有因為元空間不夠導緻的fullgc了

對于CMS和G1收集器的日志會有一點不一樣,也可以試着列印下對應的gc日志分析下,可以發現gc日志裡面的gc步驟跟我們之前講過的步驟是類似的

public class HeapTest {

    byte[] a = new byte[1024 * 100];  //100KB

    public static void main(String[] args) throws InterruptedException {
        ArrayList<HeapTest> heapTests = new ArrayList<>();
        while (true) {
            heapTests.add(new HeapTest());
            Thread.sleep(10);
        }
    }
}           

CMS

-Xloggc:d:/gc-cms-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps  
 -XX:+PrintGCTimeStamps -XX:+PrintGCCause  -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M 
 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC              

G1

-Xloggc:d:/gc-g1-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps  
 -XX:+PrintGCTimeStamps -XX:+PrintGCCause  -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -XX:+UseG1GC            

上面的這些參數,能夠幫我們檢視分析GC的垃圾收集情況。但是如果GC日志很多很多,成千上萬行。就算你一目十行,看完了,腦子也是一片空白。是以我們可以借助一些功能來幫助我們分析,這裡推薦一個gceasy(),可以上傳gc檔案,然後他會利用可視化的界面來展現GC情況。具體下圖所示

上圖我們可以看到年輕代,老年代,以及永久代的記憶體配置設定,和最大使用情況。

七、JVM調優實戰及常量池詳解

上圖我們可以看到堆記憶體在GC之前和之後的變化,以及其他資訊。

這個工具還提供基于機器學習的JVM智能優化建議,當然現在這個功能需要付費

七、JVM調優實戰及常量池詳解
七、JVM調優實戰及常量池詳解

JVM參數彙總檢視指令

java -XX:+PrintFlagsInitial 表示列印出所有參數選項的預設值

java -XX:+PrintFlagsFinal 表示列印出所有參數選項在運作程式時生效的值

Class常量池與運作時常量池

Class常量池可以了解為是Class檔案中的資源倉庫。 Class檔案中除了包含類的版本、字段、方法、接口等描述資訊外,還有一項資訊就是常量池(constant pool table),用于存放編譯期生成的各種字面量(Literal)和符号引用(Symbolic References)。

一個class檔案的16進制大體結構如下圖:

七、JVM調優實戰及常量池詳解

對應的含義如下,細節可以查下oracle官方文檔

七、JVM調優實戰及常量池詳解

當然我們一般不會去人工解析這種16進制的位元組碼檔案,我們一般可以通過javap指令生成更可讀的JVM位元組碼指令檔案:

javap -v Math.class

七、JVM調優實戰及常量池詳解

紅框标出的就是class常量池資訊,常量池中主要存放兩大類常量:字面量和符号引用。

字面量

字面量就是指由字母、數字等構成的字元串或者數值常量

字面量隻可以右值出現,所謂右值是指等号右邊的值,如:int a=1 這裡的a為左值,1為右值。在這個例子中1就是字面量。

int a = 1;
int b = 2;
int c = "abcdefg";
int d = "abcdefg";           

符号引用

符号引用是編譯原理中的概念,是相對于直接引用來說的。主要包括了以下三類常量:

  • 類和接口的全限定名
  • 字段的名稱和描述符
  • 方法的名稱和描述符

上面的a,b就是字段名稱,就是一種符号引用,還有Math類常量池裡的 Lcom/tuling/jvm/Math 是類的全限定名,main和compute是方法名稱,()是一種UTF8格式的描述符,這些都是符号引用。

這些常量池現在是靜态資訊,隻有到運作時被加載到記憶體後,這些符号才有對應的記憶體位址資訊,這些常量池一旦被裝入記憶體就變成運作時常量池,對應的符号引用在程式加載或運作時會被轉變為被加載到記憶體區域的代碼的直接引用,也就是我們說的動态連結了。例如,compute()這個符号引用在運作時就會被轉變為compute()方法具體代碼在記憶體中的位址,主要通過對象頭裡的類型指針去轉換直接引用。

字元串常量池

字元串常量池的設計思想

  1. 字元串的配置設定,和其他的對象配置設定一樣,耗費高昂的時間與空間代價,作為最基礎的資料類型,大量頻繁的建立字元串,極大程度地影響程式的性能
  2. JVM為了提高性能和減少記憶體開銷,在執行個體化字元串常量的時候進行了一些優化
  • 為字元串開辟一個字元串常量池,類似于緩存區
  • 建立字元串常量時,首先查詢字元串常量池是否存在該字元串
  • 存在該字元串,傳回引用執行個體,不存在,執行個體化該字元串并放入池中

三種字元串操作(Jdk1.7 及以上版本)

  • 直接指派字元串
String s = "XXXXX"; // s指向常量池中的引用           

這種方式建立的字元串對象,隻會在常量池中。

因為有"XXXXX"這個字面量,建立對象s的時候,JVM會先去常量池中通過 equals(key) 方法,判斷是否有相同的對象

如果有,則直接傳回該對象在常量池中的引用;

如果沒有,則會在常量池中建立一個新對象,再傳回引用。

  • new String();
String s1 = new String("XXXXX"); // s1指向記憶體中的對象引用           

這種方式會保證字元串常量池和堆中都有這個對象,沒有就建立,最後傳回堆記憶體中的對象引用。

步驟大緻如下:

因為有"XXXXX"這個字面量,是以會先檢查字元串常量池中是否存在字元串"zhuge"

不存在,先在字元串常量池裡建立一個字元串對象;再去記憶體中建立一個字元串對象"zhuge";

存在的話,就直接去堆記憶體中建立一個字元串對象"zhuge";

最後,将記憶體中的引用傳回。

  • intern方法
String s1 = new String("XXXXX");   
String s2 = s1.intern();

System.out.println(s1 == s2);  //false           

String中的intern方法是一個 native 的方法,當調用 intern方法時,如果池已經包含一個等于此String對象的字元串(用equals(oject)方法确定),則傳回池中的字元串。否則,将intern傳回的引用指向目前字元串 s1(jdk1.6版本需要将 s1 複制到字元串常量池裡)。

字元串常量池位置

Jdk1.6及之前: 有永久代, 運作時常量池在永久代,運作時常量池包含字元串常量池

Jdk1.7:有永久代,但已經逐漸“去永久代”,字元串常量池從永久代裡的運作時常量池分離到堆裡

Jdk1.8及之後: 無永久代,運作時常量池在元空間,字元串常量池裡依然在堆裡

用一個程式證明下字元串常量池在哪裡:

/**
 * jdk6:-Xms6M -Xmx6M -XX:PermSize=6M -XX:MaxPermSize=6M  
 * jdk8:-Xms6M -Xmx6M -XX:MetaspaceSize=6M -XX:MaxMetaspaceSize=6M
 */
public class RuntimeConstantPoolOOM{
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        for (int i = 0; i < 10000000; i++) {
            String str = String.valueOf(i).intern();
            list.add(str);
        }
    }
}

運作結果:
jdk7及以上:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
jdk6:Exception in thread "main" java.lang.OutOfMemoryError: PermGen space           

字元串常量池設計原理

  字元串常量池底層是hotspot的C++實作的,底層類似一個 HashTable, 儲存的本質上是字元串對象的引用。

看一道比較常見的面試題,下面的代碼建立了多少個 String 對象?

String s1 = new String("he") + new String("llo");
String s2 = s1.intern();
 
System.out.println(s1 == s2);
// 在 JDK 1.6 下輸出是 false,建立了 6 個對象
// 在 JDK 1.7 及以上的版本輸出是 true,建立了 5 個對象
// 當然我們這裡沒有考慮GC,但這些對象确實存在或存在過           

  為什麼輸出會有這些變化呢?主要還是字元串池從永久代中脫離、移入堆區的原因, intern() 方法也相應發生了變化:

1、在 JDK 1.6 中,調用 intern() 首先會在字元串池中尋找 equal() 相等的字元串,假如字元串存在就傳回該字元串在字元串池中的引用;假如字元串不存在,虛拟機會重新在永久代上建立一個執行個體,将 StringTable 的一個表項指向這個新建立的執行個體。

七、JVM調優實戰及常量池詳解

2、在 JDK 1.7 (及以上版本)中,由于字元串池不在永久代了,intern() 做了一些修改,更友善地利用堆中的對象。字元串存在時和 JDK 1.6一樣,但是字元串不存在時不再需要重新建立執行個體,可以直接指向堆上的執行個體。

七、JVM調優實戰及常量池詳解

  由上面兩個圖,也不難了解為什麼 JDK 1.6 字元串池溢出會抛出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 及以上版本抛出 OutOfMemoryError: Java heap space 。

String常量池問題的幾個例子

示例1:

String s0="12345";
String s1="12345";
String s2="123" + "45";
System.out.println( s0==s1 ); //true
System.out.println( s0==s2 ); //true           

分析:因為例子中的 s0和s1中的”zhuge”都是字元串常量,它們在編譯期就被确定了,是以s0==s1為true;而”zhu”和”ge”也都是字元串常量,當一個字 符串由多個字元串常量連接配接而成時,它自己肯定也是字元串常量,是以s2也同樣在編譯期就被優化為一個字元串常量"zhuge",是以s2也是常量池中” zhuge”的一個引用。是以我們得出s0==s1==s2;

示例2:

String s0="12345";
String s1=new String("12345");
String s2="123" + new String("45");
System.out.println( s0==s1 );  // false
System.out.println( s0==s2 );  // false
System.out.println( s1==s2 );  // false           

分析:用new String() 建立的字元串不是常量,不能在編譯期就确定,是以new String() 建立的字元串不放入常量池中,它們有自己的位址空間。

s0還是常量池 中"zhuge”的引用,s1因為無法在編譯期确定,是以是運作時建立的新對象”12345”的引用,s2因為有後半部分 new String(”45”)是以也無法在編譯期确定,是以也是一個新建立對象”12345”的引用;明白了這些也就知道為何得出此結果了。

示例3:

String a = "a1";
  String b = "a" + 1;
  System.out.println(a == b); // true 
  
  String a = "atrue";
  String b = "a" + "true";
  System.out.println(a == b); // true 
  
  String a = "a3.4";
  String b = "a" + 3.4;
  System.out.println(a == b); // true           

分析:JVM對于字元串常量的"+"号連接配接,将在程式編譯期,JVM就将常量字元串的"+"連接配接優化為連接配接後的值,拿"a" + 1來說,經編譯器優化後在class中就已經是a1。在編譯期其字元串常量的值就确定下來,故上面程式最終的結果都為true。

示例4:

String a = "ab";
String bb = "b";
String b = "a" + bb;

System.out.println(a == b); // false           

分析:JVM對于字元串引用,由于在字元串的"+"連接配接中,有字元串引用存在,而引用的值在程式編譯期是無法确定的,即"a" + bb無法被編譯器優化,隻有在程式運作期來動态配置設定并将連接配接後的新位址賦給b。是以上面程式的結果也就為false。

示例5:

String a = "ab";
final String bb = "b";
String b = "a" + bb;

System.out.println(a == b); // true           

分析:和示例4中唯一不同的是bb字元串加了final修飾,對于final修飾的變量,它在編譯時被解析為常量值的一個本地拷貝存儲到自己的常量池中或嵌入到它的位元組碼流中。是以此時的"a" + bb和"a" + "b"效果是一樣的。故上面程式的結果為true。

示例6:

String a = "ab";
final String bb = getBB();
String b = "a" + bb;

System.out.println(a == b); // false

private static String getBB() 
{  
    return "b";  
 }           

分析:JVM對于字元串引用bb,它的值在編譯期無法确定,隻有在程式運作期調用方法後,将方法的傳回值和"a"來動态連接配接并配置設定位址為b,故上面 程式的結果為false。

關于String是不可變的

通過上面例子可以得出得知:

String  s  =  "a" + "b" + "c";  //就等價于String s = "abc";
String  a  =  "a";
String  b  =  "b";
String  c  =  "c";
String  s1  =   a  +  b  +  c;           

  s1 這個就不一樣了,可以通過觀察其JVM指令碼發現s1的"+"操作會變成如下操作:

StringBuilder temp = new StringBuilder();
temp.append(a).append(b).append(c);
String s = temp.toString();           

最後再看一個例子:

//字元串常量池:"計算機"和"技術"     堆記憶體:str1引用的對象"計算機技術"  
//堆記憶體中還有個StringBuilder的對象,但是會被gc回收,StringBuilder的toString方法會new String(),這個String才是真正傳回的對象引用
String str2 = new StringBuilder("計算機").append("技術").toString();   //沒有出現"計算機技術"字面量,是以不會在常量池裡生成"計算機技術"對象
System.out.println(str2 == str2.intern());  //true
//"計算機技術" 在池中沒有,但是在heap中存在,則intern時,會直接傳回該heap中的引用

//字元串常量池:"ja"和"va"     堆記憶體:str1引用的對象"java"  
//堆記憶體中還有個StringBuilder的對象,但是會被gc回收,StringBuilder的toString方法會new String(),這個String才是真正傳回的對象引用
String str1 = new StringBuilder("ja").append("va").toString();    //沒有出現"java"字面量,是以不會在常量池裡生成"java"對象
System.out.println(str1 == str1.intern());  //false
//java是關鍵字,在JVM初始化的相關類裡肯定早就放進字元串常量池了

String s1=new String("test");  
System.out.println(s1==s1.intern());   //false
//"test"作為字面量,放入了池中,而new時s1指向的是heap中新生成的string對象,s1.intern()指向的是"test"字面量之前在池中生成的字元串對象

String s2=new StringBuilder("abc").toString();
System.out.println(s2==s2.intern());  //false
//同上           

八種基本類型的包裝類和對象池

java中基本類型的包裝類的大部分都實作了常量池技術(嚴格來說應該叫對象池,在堆上),這些類是Byte,Short,Integer,Long,Character,Boolean,另外兩種浮點數類型的包裝類則沒有實作。另外Byte,Short,Integer,Long,Character這5種整型的包裝類也隻是在對應值小于等于127時才可使用對象池,也即對象不負責建立和管理大于127的這些類的對象。因為一般這種比較小的數用到的機率相對較大。

public class Test {

    public static void main(String[] args) {
        //5種整形的包裝類Byte,Short,Integer,Long,Character的對象,  
        //在值小于127時可以使用對象池  
        Integer i1 = 127;  //這種調用底層實際是執行的Integer.valueOf(127),裡面用到了IntegerCache對象池
        Integer i2 = 127;
        System.out.println(i1 == i2);//輸出true  

        //值大于127時,不會從對象池中取對象  
        Integer i3 = 128;
        Integer i4 = 128;
        System.out.println(i3 == i4);//輸出false  
        
        //用new關鍵詞新生成對象不會使用對象池
        Integer i5 = new Integer(127);  
        Integer i6 = new Integer(127);
        System.out.println(i5 == i6);//輸出false 

        //Boolean類也實作了對象池技術  
        Boolean bool1 = true;
        Boolean bool2 = true;
        System.out.println(bool1 == bool2);//輸出true  

        //浮點類型的包裝類沒有實作對象池技術  
        Double d1 = 1.0;
        Double d2 = 1.0;
        System.out.println(d1 == d2);//輸出false  
    }
}            

感謝您的耐心閱讀,私信擷取視訊教程,内容更詳盡,免費分享!!!