記憶體調優實戰

小纸条發表於2024-06-19

實戰篇

1、記憶體調優

1.1 記憶體溢位和記憶體洩漏

記憶體洩漏(memory leak:在Java中如果不再使用一個物件,但是該物件依然在GC ROOT的引用鏈上,這個物件就不會被垃圾回收器回收,這種情況就稱之為記憶體洩漏。

記憶體洩漏絕大多數情況都是由堆記憶體洩漏引起的,所以後續沒有特別說明則討論的都是堆記憶體洩漏。

比如圖中,如果學生物件1不再使用

可以選擇將ArrayList到學生物件1的引用刪除:

或者將物件AArrayList的引用刪除,這樣所有的學生物件包括ArrayList都可以回收:

但是如果不移除這兩個引用中的任何一個,學生物件1就屬於記憶體洩漏了。

少量的記憶體洩漏可以容忍,但是如果發生持續的記憶體洩漏,就像滾雪球雪球越滾越大,不管有多大的記憶體遲早會被消耗完,最終導致的結果就是記憶體溢位但是產生記憶體溢位並不是只有記憶體洩漏這一種原因

這些學生物件如果都不再使用,越積越多,就會導致超過堆記憶體的上限出現記憶體溢位。

正常情況的記憶體結構圖如下:

記憶體溢位出現時如下:

記憶體洩漏的物件和依然在GC ROOT引用鏈上需要使用的物件加起來佔滿了記憶體空間,無法為新的物件分配記憶體。

記憶體洩漏的常見場景:

1、記憶體洩漏導致溢位的常見場景是大型的Java後端應用中,在處理使用者的請求之後,沒有及時將使用者的資料刪除。隨著使用者請求數量越來越多,記憶體洩漏的物件佔滿了堆記憶體最終導致記憶體溢位。

這種產生的記憶體溢位會直接導致使用者請求無法處理,影響使用者的正常使用。重啟可以恢復應用使用,但是在執行一段時間之後依然會出現記憶體溢位。

程式碼:

Java
package com.itheima.jvmoptimize.controller;

import com.itheima.jvmoptimize.entity.UserEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/leak2")
public class LeakController2 {
private static Map<Long,Object> userCache = new HashMap<>();

/**
* 登入介面 放入hashmap中
*/
@PostMapping("/login")
public void login(String name,Long id){
userCache.put(id,new byte[1024 * 1024 * 300]);
}


/**
* 登出介面,刪除快取的使用者資訊
*/

@GetMapping("/logout")
public void logout(Long id){
userCache.remove(id);
}

}

設定虛擬機器引數,將最大堆記憶體設定為1g:

Postman中測試,登入id1的使用者:

呼叫logout介面,id1那麼資料會正常刪除:

連續呼叫login傳遞不同的id,但是不呼叫logout

呼叫幾次之後就會出現記憶體溢位:

2、第二種常見場景是分散式任務排程系統如Elastic-jobQuartz等進行任務排程時,被排程的Java應用在排程任務結束中出現了記憶體洩漏,最終導致多次排程之後記憶體溢位。

這種產生的記憶體溢位會導致應用執行下次的排程任務執行。同樣重啟可以恢復應用使用,但是在排程執行一段時間之後依然會出現記憶體溢位。

開啟定時任務:

定時任務程式碼:

Java
package com.itheima.jvmoptimize.task;

import com.itheima.jvmoptimize.leakdemo.demo4.Outer;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
public class LeakTask {

private int count = 0;
private List<Object> list = new ArrayList<>();

@Scheduled(fixedRate = 100L)
public void test(){
System.out.println("定時任務呼叫" + ++count);
list.add(new Outer().newList());
}
}

啟動程式之後很快就出現了記憶體溢位:

1.2 解決記憶體溢位的方法

首先要熟悉一些常用的監控工具:

1.2.1 常用監控工具

Top命令

top命令是linux下用來檢視系統資訊的一個命令,它提供給我們去實時地去檢視系統的資源,比如執行時的程序、執行緒和系統引數等資訊。程序使用的記憶體為RES(常駐記憶體)- SHR(共享記憶體)

優點:

  • 操作簡單
  • 無額外的軟體安裝

    缺點:

    只能檢視最基礎的程序資訊,無法檢視到每個部分的記憶體佔用(堆、方法區、堆外)

    VisualVM

    VisualVM是多功能合一的Java故障排除工具並且他是一款視覺化工具,整合了命令列 JDK 工具和輕量級分析功能,功能非常強大。這款軟體在Oracle JDK 6~8 中釋出,但是在 Oracle JDK 9 之後不在JDK安裝目錄下需要單獨下載。下載地址:https://visualvm.github.io/

    優點:

  • 功能豐富,實時監控CPU、記憶體、執行緒等詳細資訊
  • 支援Idea外掛,開發過程中也可以使用

    缺點:

    對大量叢集化部署的Java程序需要手動進行管理

    如果需要進行遠端監控,可以透過jmx方式進行連線。在啟動java程式時新增如下引數:

    Java
    -Djava.rmi.server.hostname=伺服器ip地址
    -Dcom.sun.management.jmxremote
    -Dcom.sun.management.jmxremote.port=9122
    -Dcom.sun.management.jmxremote.ssl=false
    -Dcom.sun.management.jmxremote.authenticate=false

    右鍵點選remote

    填寫伺服器的ip地址:

    右鍵新增JMX連線

    填寫ip地址和埠號,勾選不需要SSL安全驗證:

    雙擊成功連線。

    Arthas

    Arthas 是一款線上監控診斷產品,透過全域性視角實時檢視應用 load、記憶體、gc、執行緒的狀態資訊,並能在不修改應用程式碼的情況下,對業務問題進行診斷,包括檢視方法呼叫的出入參、異常,監測方法執行耗時,類載入資訊等,大大提升線上問題排查效率。

    優點:

  • 功能強大,不止於監控基礎的資訊,還能監控單個方法的執行耗時等細節內容。
  • 支援應用的叢集管理

    缺點:

    部分高階功能使用門檻較高

    使用阿里arthas tunnel管理所有的需要監控的程式

    背景:

    小李的團隊已經普及了arthas的使用,但是由於使用了微服務架構,生產環境上的應用數量非常多,使用arthas還得登入到每一臺伺服器上再去操作非常不方便。他看到官方文件上可以使用tunnel來管理所有需要監控的程式。

    步驟:

    Spring Boot程式中新增arthas的依賴(支援Spring Boot2),在配置檔案中新增tunnel服務端的地址,便於tunnel去監控所有的程式。

    2. tunnel服務端程式部署在某臺伺服器上並啟動。

    3. 啟動java程式

    4. 開啟tunnel的服務端頁面,檢視所有的程序列表,並選擇程序進行arthas的操作。

    pom.xml新增依賴:

    XML
    <dependency>
    <groupId>com.taobao.arthas</groupId>
    <artifactId>arthas-spring-boot-starter</artifactId>
    <version>3.7.1</version>
    </dependency>

    application.yml中新增配置:

    Properties
    arthas:
    #tunnel地址,目前是部署在同一臺伺服器,正式環境需要拆分
    tunnel-server: ws://localhost:7777/ws
    #tunnel顯示的應用名稱,直接使用應用名
    app-name: ${spring.application.name}
    #arthas http訪問的埠和遠端連線的埠
    http-port: 8888
    telnet-port: 9999

    在資料中找到arthas-tunnel-server.3.7.1-fatjar.jar上傳到伺服器,並使用

    nohup java -jar -Darthas.enable-detail-pages=true arthas-tunnel-server.3.7.1-fatjar.jar & 命令啟動該程式。-Darthas.enable-detail-pages=true引數作用是可以有一個頁面展示內容。透過伺服器ip地址:8080/apps.html開啟頁面,目前沒有註冊上來任何應用。

    啟動spring boot應用,如果在一臺伺服器上,注意區分埠。

    Properties
    -Dserver.port=tomcat埠號
    -Darthas.http-port=arthas的http埠號
    -Darthas.telnet-port=arthas的telnet埠號埠號

    最終就能看到兩個應用:

    單擊應用就可以進入操作arthas了。

    Prometheus+Grafana

    Prometheus+Grafana是企業中運維常用的監控方案,其中Prometheus用來採集系統或者應用的相關資料,同時具備告警功能。Grafana可以將Prometheus採集到的資料以視覺化的方式進行展示。

    Java程式設計師要學會如何讀懂Grafana展示的Java虛擬機器相關的引數。

    優點:

  • 支援系統級別和應用級別的監控,比如linux作業系統、RedisMySQLJava程序。
  • 支援告警並允許自定義告警指標,透過郵件、簡訊等方式儘早通知相關人員進行處理

    缺點:

    環境搭建較為複雜,一般由運維人員完成

    阿里雲環境搭建(瞭解即可)

    這一小節主要是為了讓同學們更好地去閱讀監控資料,所以提供一整套最簡單的環境搭建方式,覺得困難可以直接跳過。企業中環境搭建的工作由運維人員來完成。

    1、在pom檔案中新增依賴

    XML
    <dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    <scope>runtime</scope>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>

    <exclusions><!-- 去掉springboot預設配置 -->
    <exclusion>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-logging</artifactId>
    </exclusion>
    </exclusions>
    </dependency>

    2、新增配置項

    Properties
    management:
    endpoint:
    metrics:
    enabled: true #支援metrics
    prometheus:
    enabled: true #支援Prometheus
    metrics:
    export:
    prometheus:
    enabled: true
    tags:
    application: jvm-test #例項名採集
    endpoints:
    web:
    exposure:
    include: '*' #開放所有埠

    這兩步做完之後,啟動程式。

    3、透過地址:ip地址:埠號/actuator/prometheus訪問之後可以看到jvm相關的指標資料。

    4、建立阿里雲Prometheus例項

    5、選擇ECS服務

    6、在自己的ECS伺服器上找到網路和交換機

    7、選擇對應的網路:

    填寫內容,與ECS裡邊的網路設定保持一致

    8、選中新的例項,選擇MicroMeter

    9、給ECS新增標籤;

    10、填寫內容,注意ECS的標籤

    11、點選大盤就可以看到指標了

    12、指標內容:

    1.2.2 堆記憶體狀況的對比

  • 正常情況
    • 處理業務時會出現上下起伏,業務物件頻繁建立記憶體會升高,觸發MinorGC之後記憶體會降下來。
    • 手動執行FULL GC之後,記憶體大小會驟降,而且每次降完之後的大小是接近的。
    • 長時間觀察記憶體曲線應該是在一個範圍內。

    • 出現記憶體洩漏
      • 處於持續增長的情況,即使Minor GC也不能把大部分物件回收
      • 手動FULL GC之後的記憶體量每一次都在增長
      • 長時間觀察記憶體曲線持續增長

    1.2.3 產生記憶體溢位原因一:程式碼中的記憶體洩漏

    總結了6種產生記憶體洩漏的原因,均來自於java程式碼的不當處理:

  • equals()hashCode(),不正確的equals()hashCode()實現導致記憶體洩漏
  • ThreadLocal的使用,由於執行緒池中的執行緒不被回收導致的ThreadLocal記憶體洩漏
  • 內部類引用外部類,非靜態的內部類和匿名內部類的錯誤使用導致記憶體洩漏
  • Stringintern方法,由於JDK6中的字串常量池位於永久代,intern被大量呼叫並儲存產生的記憶體洩漏
  • 透過靜態欄位儲存物件,大量的資料在靜態變數中被引用,但是不再使用,成為了記憶體洩漏
  • 資源沒有正常關閉,由於資源沒有呼叫close方法正常關閉,導致的記憶體溢位

    案例1equals()hashCode()導致的記憶體洩漏

    問題:

    在定義新類時沒有重寫正確的equals()hashCode()方法。在使用HashMap的場景下,如果使用這個類物件作為keyHashMap在判斷key是否已經存在時會使用這些方法,如果重寫方式不正確,會導致相同的資料被儲存多份。

    正常情況:

    1、以JDK8為例,首先呼叫hash方法計算key的雜湊值,hash方法中會使用到keyhashcode方法。根據hash方法的結果決定存放的陣列中位置。

    2、如果沒有元素,直接放入。如果有元素,先判斷key是否相等,會用到equals方法,如果key相等,直接替換valuekey不相等,走連結串列或者紅黑樹查詢邏輯,其中也會使用equals比對是否相同。

    異常情況:

    1hashCode方法實現不正確,會導致相同id的學生物件計算出來的hash值不同,可能會被分到不同的槽中。

    2equals方法實現不正確,會導致key在比對時,即便學生物件的id是相同的,也被認為是不同的key

    3、長時間執行之後HashMap中會儲存大量相同id的學生資料。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo2;

    import org.apache.commons.lang3.builder.EqualsBuilder;
    import org.apache.commons.lang3.builder.HashCodeBuilder;

    import java.util.Objects;

    public class Student {
    private String name;
    private Integer id;
    private byte[] bytes = new byte[1024 * 1024];

    public String getName() {
    return name;
    }

    public void setName(String name) {
    this.name = name;
    }

    public Integer getId() {
    return id;
    }

    public void setId(Integer id) {
    this.id = id;
    }


    }

    Java
    package com.itheima.jvmoptimize.leakdemo.demo2;

    import java.util.HashMap;
    import java.util.Map;

    public class Demo2 {
    public static long count = 0;
    public static Map<Student,Long> map = new HashMap<>();
    public static void main(String[] args) throws InterruptedException {
    while (true){
    if(count++ % 100 == 0){
    Thread.sleep(10);
    }
    Student student = new Student();
    student.setId(1);
    student.setName("張三");
    map.put(student,1L);
    }
    }
    }

    執行之後透過visualvm觀察:

    出現記憶體洩漏的現象。

    解決方案:

    1、在定義新實體時,始終重寫equals()hashCode()方法。

    2、重寫時一定要確定使用了唯一標識去區分不同的物件,比如使用者的id等。

    3hashmap使用時儘量使用編號id等資料作為key,不要將整個實體類物件作為key存放。

    程式碼:

    Properties
    package com.itheima.jvmoptimize.leakdemo.demo2;

    import org.apache.commons.lang3.builder.EqualsBuilder;
    import org.apache.commons.lang3.builder.HashCodeBuilder;

    import java.util.Objects;

    public class Student {
    private String name;
    private Integer id;
    private byte[] bytes = new byte[1024 * 1024];

    public String getName() {
    return name;
    }

    public void setName(String name) {
    this.name = name;
    }

    public Integer getId() {
    return id;
    }

    public void setId(Integer id) {
    this.id = id;
    }

    @Override
    public boolean equals(Object o) {
    if (this == o) {
    return true;
    }

    if (o == null || getClass() != o.getClass()) {
    return false;
    }

    Student student = (Student) o;

    return new EqualsBuilder().append(id, student.id).isEquals();
    }

    @Override
    public int hashCode() {
    return new HashCodeBuilder(17, 37).append(id).toHashCode();
    }
    }

    案例2:內部類引用外部類

    問題:

    1、非靜態的內部類預設會持有外部類,儘管程式碼上不再使用外部類,所以如果有地方引用了這個非靜態內部類,會導致外部類也被引用,垃圾回收時無法回收這個外部類。

    2、匿名內部類物件如果在非靜態方法中被建立,會持有呼叫者物件,垃圾回收時無法回收呼叫者。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo3;

    import java.io.IOException;
    import java.util.ArrayList;

    public class Outer{
    private byte[] bytes = new byte[1024 * 1024]; //外部類持有資料
    private static String name = "測試";
    class Inner{
    private String name;
    public Inner() {
    this.name = Outer.name;
    }
    }

    public static void main(String[] args) throws IOException, InterruptedException {
    // System.in.read();
    int count = 0;
    ArrayList<Inner> inners = new ArrayList<>();
    while (true){
    if(count++ % 100 == 0){
    Thread.sleep(10);
    }
    inners.add(new Outer().new Inner());
    }
    }
    }

    Java
    package com.itheima.jvmoptimize.leakdemo.demo4;

    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.List;

    public class Outer {
    private byte[] bytes = new byte[1024 * 1024 * 10];
    public List<String> newList() {
    List<String> list = new ArrayList<String>() {{
    add("1");
    add("2");
    }};
    return list;
    }

    public static void main(String[] args) throws IOException {
    System.in.read();
    int count = 0;
    ArrayList<Object> objects = new ArrayList<>();
    while (true){
    System.out.println(++count);
    objects.add(new Outer().newList());
    }
    }
    }

    解決方案:

    1、這個案例中,使用內部類的原因是可以直接獲取到外部類中的成員變數值,簡化開發。如果不想持有外部類物件,應該使用靜態內部類。

    2、使用靜態方法,可以避免匿名內部類持有呼叫者物件。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo3;

    import java.io.IOException;
    import java.util.ArrayList;

    public class Outer{
    private byte[] bytes = new byte[1024 * 1024]; //外部類持有資料
    private static String name = "測試";
    static class Inner{
    private String name;
    public Inner() {
    this.name = Outer.name;
    }
    }

    public static void main(String[] args) throws IOException, InterruptedException {
    // System.in.read();
    int count = 0;
    ArrayList<Inner> inners = new ArrayList<>();
    while (true){
    if(count++ % 100 == 0){
    Thread.sleep(10);
    }
    inners.add(new Inner());
    }
    }
    }

    Java
    package com.itheima.jvmoptimize.leakdemo.demo4;

    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.List;

    public class Outer {
    private byte[] bytes = new byte[1024 * 1024 * 10];
    public static List<String> newList() {
    List<String> list = new ArrayList<String>() {{
    add("1");
    add("2");
    }};
    return list;
    }

    public static void main(String[] args) throws IOException {
    System.in.read();
    int count = 0;
    ArrayList<Object> objects = new ArrayList<>();
    while (true){
    System.out.println(++count);
    objects.add(newList());
    }
    }
    }

    案例3ThreadLocal的使用

    問題:

    如果僅僅使用手動建立的執行緒,就算沒有呼叫ThreadLocalremove方法清理資料,也不會產生記憶體洩漏。因為當執行緒被回收時,ThreadLocal也同樣被回收。但是如果使用執行緒池就不一定了。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo5;

    import java.util.concurrent.*;

    public class Demo5 {
    public static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(Integer.MAX_VALUE, Integer.MAX_VALUE,
    0, TimeUnit.DAYS, new SynchronousQueue<>());
    int count = 0;
    while (true) {
    System.out.println(++count);
    threadPoolExecutor.execute(() -> {
    threadLocal.set(new byte[1024 * 1024]);
    });
    Thread.sleep(10);
    }


    }
    }

    解決方案:

    執行緒方法執行完,一定要呼叫ThreadLocal中的remove方法清理物件。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo5;

    import java.util.concurrent.*;

    public class Demo5 {
    public static ThreadLocal<Object> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(Integer.MAX_VALUE, Integer.MAX_VALUE,
    0, TimeUnit.DAYS, new SynchronousQueue<>());
    int count = 0;
    while (true) {
    System.out.println(++count);
    threadPoolExecutor.execute(() -> {
    threadLocal.set(new byte[1024 * 1024]);
    threadLocal.remove();
    });
    Thread.sleep(10);
    }


    }
    }

    案例4Stringintern方法

    問題:

    JDK6中字串常量池位於堆記憶體中的Perm Gen永久代中,如果不同字串的intern方法被大量呼叫,字串常量池會不停的變大超過永久代記憶體上限之後就會產生記憶體溢位問題。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo6;

    import org.apache.commons.lang3.RandomStringUtils;

    import java.util.ArrayList;
    import java.util.List;

    public class Demo6 {
    public static void main(String[] args) {
    while (true){
    List<String> list = new ArrayList<String>();
    int i = 0;
    while (true) {
    //String.valueOf(i++).intern(); //JDK1.6 perm gen 不會溢位
    list.add(String.valueOf(i++).intern()); //溢位
    }
    }
    }
    }

    解決方案:

    1、注意程式碼中的邏輯,儘量不要將隨機生成的字串加入字串常量池

    2、增大永久代空間的大小,根據實際的測試/估算結果進行設定-XX:MaxPermSize=256M

    案例5:透過靜態欄位儲存物件

    問題:

    如果大量的資料在靜態變數中被長期引用,資料就不會被釋放,如果這些資料不再使用,就成為了記憶體洩漏。

    解決方案:

    1、儘量減少將物件長時間的儲存在靜態變數中,如果不再使用,必須將物件刪除(比如在集合中)或者將靜態變數設定為null

    2、使用單例模式時,儘量使用懶載入,而不是立即載入。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo7;

    import org.springframework.context.annotation.Lazy;
    import org.springframework.stereotype.Component;

    @Lazy //懶載入
    @Component
    public class TestLazy {
    private byte[] bytes = new byte[1024 * 1024 * 1024];
    }

    3SpringBean中不要長期存放大物件,如果是快取用於提升效能,儘量設定過期時間定期失效。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo7;

    import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;

    import java.time.Duration;

    public class CaffineDemo {
    public static void main(String[] args) throws InterruptedException {
    Cache<Object, Object> build = Caffeine.newBuilder()
    //設定100ms之後就過期
    .expireAfterWrite(Duration.ofMillis(100))
    .build();
    int count = 0;
    while (true){
    build.put(count++,new byte[1024 * 1024 * 10]);
    Thread.sleep(100L);
    }
    }
    }

    案例6:資源沒有正常關閉

    問題:

    連線和流這些資源會佔用記憶體,如果使用完之後沒有關閉,這部分記憶體不一定會出現記憶體洩漏,但是會導致close方法不被執行。

    Java
    package com.itheima.jvmoptimize.leakdemo.demo1;

    import java.io.FileInputStream;
    import java.io.IOException;
    import java.io.InputStream;
    import java.net.URL;
    import java.net.URLConnection;
    import java.sql.*;

    //-Xmx50m -Xms50m
    public class Demo1 {

    // JDBC driver name and database URL
    static final String JDBC_DRIVER = "com.mysql.cj.jdbc.Driver";
    static final String DB_URL = "jdbc:mysql:///bank1";

    // Database credentials
    static final String USER = "root";
    static final String PASS = "123456";

    public static void leak() throws SQLException {
    //Connection conn = null;
    Statement stmt = null;
    Connection conn = DriverManager.getConnection(DB_URL, USER, PASS);

    // executes a valid query
    stmt = conn.createStatement();
    String sql;
    sql = "SELECT id, account_name FROM account_info";
    ResultSet rs = stmt.executeQuery(sql);

    //STEP 4: Extract data from result set
    while (rs.next()) {
    //Retrieve by column name
    int id = rs.getInt("id");
    String name = rs.getString("account_name");

    //Display values
    System.out.print("ID: " + id);
    System.out.print(", Name: " + name + "\n");
    }

    }

    public static void main(String[] args) throws InterruptedException, SQLException {
    while (true) {
    leak();
    }
    }
    }

    同學們可以測試一下這段程式碼會不會產生記憶體洩漏,應該是不會的。但是這個結論不是確定的,所以建議程式設計時養成良好的習慣,儘量關閉不再使用的資源。

    解決方案:

    1、為了防止出現這類的資源物件洩漏問題,必須在finally塊中關閉不再使用的資源。

    2、從 Java 7 開始,使用try-with-resources語法可以用於自動關閉資源。

    1.2.4 產生記憶體溢位原因二併發請求問題

    透過傳送請求向Java應用獲取資料,正常情況下Java應用將資料返回之後,這部分資料就可以在記憶體中被釋放掉。

    接收到請求時建立物件:

    響應返回之後,物件就可以被回收掉:

    併發請求問題指的是由於使用者的併發請求量有可能很大,同時處理資料的時間很長,導致大量的資料存在於記憶體中,最終超過了記憶體的上限,導致記憶體溢位。這類問題的處理思路和記憶體洩漏類似,首先要定位到物件產生的根源。

    那麼怎麼模擬併發請求呢?

    使用Apache Jmeter軟體可以進行併發請求測試。

    Apache Jmeter是一款開源的測試軟體,使用Java語言編寫,最初是為了測試Web程式,目前已經發展成支援資料庫、訊息佇列、郵件協議等不同型別內容的測試工具。

    Apache Jmeter支援外掛擴充套件,生成多樣化的測試結果。

    使用Jmeter進行併發測試,發現記憶體溢位問題

    背景:

    小李的團隊發現有一個微服務在晚上8點左右使用者使用的高峰期會出現記憶體溢位的問題,於是他們希望在自己的開發環境能重現類似的問題。

    步驟:

    1、安裝Jmeter軟體,新增執行緒組。

    開啟資料中的Jmeter,找到bin目錄,雙擊jmeter.bat啟動程式。

    2. 線上程組中增加Http請求,新增隨機引數。

    新增執行緒組引數:

    新增Http請求:

    新增http引數:

    介面程式碼:

    Java
    /**
    * 大量資料 + 處理慢
    */
    @GetMapping("/test")
    public void test1() throws InterruptedException {
    byte[] bytes = new byte[1024 * 1024 * 100];//100m
    Thread.sleep(10 * 1000L);
    }

    3. 線上程組中新增監聽器聚合報告,用來展示最終結果。

    4. 啟動程式,執行執行緒組並觀察程式是否出現記憶體溢位。

    新增虛擬機器引數:

    點選執行:

    很快就出現了記憶體溢位:

    再來看一個案例:

    1、設定執行緒池引數:

    2、設定http介面引數

    3、程式碼:

    Java
    /**
    * 登入介面 傳遞名字和id,放入hashmap中
    */
    @PostMapping("/login")
    public void login(String name,Long id){
    userCache.put(id,new UserEntity(id,name));
    }

    4、我們想生成隨機的名字和id,選擇函式助手對話方塊

    5、選擇Random隨機數生成器

    6、讓隨機數生成器生效,值中直接ctrl + v就行,已經被複制到貼上板了。

    7、字串也是同理的設定方法:

    8、新增name欄位:

    9、點選測試,一段時間之後同樣出現了記憶體溢位:

    1.2.5 診斷

    記憶體快照

    當堆記憶體溢位時,需要在堆記憶體溢位時將整個堆記憶體儲存下來,生成記憶體快照(Heap Profile )檔案。

    使用MAT開啟hprof檔案,並選擇記憶體洩漏檢測功能,MAT會自行根據記憶體快照中儲存的資料分析記憶體洩漏的根源。

    生成記憶體快照的Java虛擬機器引數:

    -XX:+HeapDumpOnOutOfMemoryError:發生OutOfMemoryError錯誤時,自動生成hprof記憶體快照檔案。

    -XX:HeapDumpPath=<path>:指定hprof檔案的輸出路徑。

    使用MAT開啟hprof檔案,並選擇記憶體洩漏檢測功能,MAT會自行根據記憶體快照中儲存的資料分析記憶體洩漏的根源。

    在程式中新增jvm引數:

    Java
    -Xmx256m -Xms256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\jvm\dump\test1.hprof

    執行程式之後:

    使用MAT開啟hprof檔案(操作步驟見前文GC Root小節),首頁就展示了MAT檢測出來的記憶體洩漏問題原因。

    點選Details檢視詳情,這個執行緒持有了大量的位元組陣列:

    繼續往下來,還可以看到溢位時執行緒棧,透過棧資訊也可以懷疑下是否是因為這句程式碼建立了大量的物件:

    MAT記憶體洩漏檢測的原理

    MAT提供了稱為支配樹(Dominator Tree)的物件圖。支配樹展示的是物件例項間的支配關係。在物件引用圖中,所有指向物件B的路徑都經過物件A,則認為物件A支配物件B

    如下圖,A引用BCBC引用D, C引用EDE引用F,轉成支配樹之後。由於E只有C引用,所以E掛在C上。接下來BCDF都由其他至少1個物件引用,所以追溯上去,只有A滿足支配它們的條件。

    支配樹中物件本身佔用的空間稱之為淺堆(Shallow Heap)。

    支配樹中物件的子樹就是所有被該物件支配的內容,這些內容組成了物件的深堆(Retained Heap),也稱之為保留集( Retained Set 。深堆的大小表示該物件如果可以被回收,能釋放多大的記憶體空間。

    如下圖:C自身包含一個淺堆,而C底下掛了E,所以C+E佔用的空間大小代表C的深堆。

    需求:

    使用如下程式碼生成記憶體快照,並分析TestClass物件的深堆和淺堆。

    如何在不記憶體溢位情況下生成堆記憶體快照?-XX:+HeapDumpBeforeFullGC可以在FullGC之前就生成記憶體快照。

    Java
    package com.itheima.jvmoptimize.matdemo;

    import org.openjdk.jol.info.ClassLayout;

    import java.util.ArrayList;
    import java.util.List;

    //-XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=D:/jvm/dump/mattest.hprof
    public class HeapDemo {
    public static void main(String[] args) {
    TestClass a1 = new TestClass();
    TestClass a2 = new TestClass();
    TestClass a3 = new TestClass();
    String s1 = "itheima1";
    String s2 = "itheima2";
    String s3 = "itheima3";

    a1.list.add(s1);

    a2.list.add(s1);
    a2.list.add(s2);

    a3.list.add(s3);

    //System.out.print(ClassLayout.parseClass(TestClass.class).toPrintable());
    s1 = null;
    s2 = null;
    s3 = null;
    System.gc();
    }
    }

    class TestClass {
    public List<String> list = new ArrayList<>(10);
    }

    上面程式碼的引用鏈如下:

    轉換成支配樹,TestClass簡稱為tctc1 tc2 tc3都是直接掛在main執行緒物件上,itheima2 itheima3都只能透過tc2tc3訪問,所以直接掛上。itheima1不同,他可以由tc1 tc2訪問,所以他要掛載他們的上級也就是main執行緒物件上:

    使用mat來分析,新增虛擬機器引數:

    FullGC之後產生了記憶體快照檔案:

    直接檢視MAT的支配樹功能:

    輸入main進行搜尋:

    可以看到結構與之前分析的是一致的:

    同時可以看到字串的淺堆大小和深堆大小:

    為什麼字串物件的淺堆大小是24位元組,深堆大小是56位元組呢?首先字串物件引用了字元陣列,字元陣列的位元組大小底下有展示是32位元組,那我們只需要搞清楚淺堆大小也就是他自身為什麼是24位元組就可以了。使用jol框架列印下物件大小(原理篇會詳細展開講解,這裡先有個基本的認知)。

    新增依賴:

    XML
    <dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
    </dependency>

    使用程式碼列印:

    Java

    public class StringSize {
    public static void main(String[] args) {
    //使用JOL列印String物件
    System.out.print(ClassLayout.parseClass(String.class).toPrintable());
    }
    }

    結果如下:

    物件頭佔用了12位元組,value字元陣列的引用佔用了4位元組,int型別的hash欄位佔用4位元組,還有4位元組是物件填充,所以加起來是24位元組。至於物件填充、物件頭是做什麼用的,在《原理篇》中會詳細講解。

    MAT就是根據支配樹,從葉子節點向根節點遍歷,如果發現深堆的大小超過整個堆記憶體的一定比例閾值,就會將其標記成記憶體洩漏的"嫌疑物件"

    伺服器上的記憶體快照匯出和分析

    剛才我們都是在本地匯出記憶體快照的,並且是程式已經出現了記憶體溢位,接下來我們要做到防範於未然,一旦看到記憶體大量增長就去分析記憶體快照,那此時記憶體還沒溢位,怎麼樣去獲得記憶體快照檔案呢?

    背景:

    小李的團隊透過監控系統發現有一個服務記憶體在持續增長,希望儘快透過記憶體快照分析增長的原因,由於並未產生記憶體溢位所以不能透過HeapDumpOnOutOfMemoryError引數生成記憶體快照。

    思路:

    匯出執行中系統的記憶體快照,比較簡單的方式有兩種,注意只需要匯出標記為存活的物件:

    透過JDK自帶的jmap命令匯出,格式為:

    jmap -dump:live,format=b,file=檔案路徑和檔名程序ID

    透過arthasheapdump命令匯出,格式為:

    heapdump --live 檔案路徑和檔名

    先使用jps或者ps -ef檢視程序ID:

    透過jmap命令匯出記憶體快照檔案,live代表只儲存存活物件,format=b用二進位制方式儲存:

    也可以在arthas中輸出heapdump命令:

    接下來下載到本地分析即可。

    大檔案的處理

    在程式設計師開發用的機器記憶體範圍之內的快照檔案,直接使用MAT開啟分析即可。但是經常會遇到伺服器上的程式佔用的記憶體達到10G以上,開發機無法正常開啟此類記憶體快照,此時需要下載伺服器作業系統對應的MAT。下載地址:https://eclipse.dev/mat/downloads.php
    透過MAT中的指令碼生成分析報告:

    ./ParseHeapDump.sh 快照檔案路徑 org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components

    注意:預設MAT分析時只使用了1G的堆記憶體,如果快照檔案超過1G,需要修改MAT目錄下的MemoryAnalyzer.ini配置檔案調整最大堆記憶體。

    最終會生成報告檔案:

    將這些檔案下載到本地,解壓之後開啟index.html檔案:

    同樣可以看到類似的報告:

    案例1 - 分頁查詢文章介面的記憶體溢位:

    背景:

    小李負責的新聞資訊類專案採用了微服務架構,其中有一個文章微服務,這個微服務在業務高峰期出現了記憶體溢位的現象。

    解決思路:

    1、服務出現OOM記憶體溢位時,生成記憶體快照。

    2、使用MAT分析記憶體快照,找到記憶體溢位的物件。

    3、嘗試在開發環境中重現問題,分析程式碼中問題產生的原因。

    4、修改程式碼。

    5、測試並驗證結果。

    程式碼使用的是com.itheima.jvmoptimize.practice.oom.controller.DemoQueryController

    首先將專案打包,放到伺服器上,同時使用如下啟動命令啟動。設定了最大堆記憶體為512m,同時堆記憶體溢位時會生成hprof檔案:

    編寫JMeter指令碼進行壓測,size資料量一次性獲取10000條,執行緒150,每個執行緒執行10次方法呼叫:

    執行之後可以發現伺服器上已經生成了hprof檔案:

    將其下載到本地,透過MAT分析發現是Mysql返回的ResultSet存在大量的資料:

    透過支配樹,可以發現裡邊包含的資料,如果資料有一些特殊的標識,其實就可以判斷出來是哪個介面產生的資料:

    如果想知道每個執行緒在執行哪個方法,先找到springHandlerMethod物件:

    接著去找引用關係:

    透過描述資訊就可以看到介面:

    透過直方圖的查詢功能,也可以找到專案裡哪些物件比較多:

    問題根源:

    文章微服務中的分頁介面沒有限制最大單次訪問條數,並且單個文章物件佔用的記憶體量較大,在業務高峰期併發量較大時這部分從資料庫獲取到記憶體之後會佔用大量的記憶體空間。

    解決思路:

    1、與產品設計人員溝通,限制最大的單次訪問條數。

    以下程式碼,限制了每次訪問的最大條數為100

    2、分頁介面如果只是為了展示文章列表,不需要獲取文章內容,可以大大減少物件的大小。

    把文章內容去掉,減少物件大小:

    3、在高峰期對微服務進行限流保護。

    案例2 - Mybatis導致的記憶體溢位:

    背景:

    小李負責的文章微服務進行了升級,新增加了一個判斷id是否存在的介面,第二天業務高峰期再次出現了記憶體溢位,小李覺得應該和新增加的介面有關係。

    解決思路:

    1、服務出現OOM記憶體溢位時,生成記憶體快照。

    2、使用MAT分析記憶體快照,找到記憶體溢位的物件。

    3、嘗試在開發環境中重現問題,分析程式碼中問題產生的原因。

    4、修改程式碼。

    5、測試並驗證結果。

    透過分析hprof發現呼叫的方法,但是這個僅供參考:

    分析支配樹,找到大物件來源,是一些字串,裡邊還包含SQL

    透過SQL內容搜尋下可以找到對應的方法:

    發現裡邊用了foreach,如果迴圈內容很大,會產生特別大的一個SQL語句。

    直接開啟jmeter,開啟測試指令碼進行測試:

    本地測試之後,出現了記憶體溢位:

    問題根源:

    Mybatis在使用foreach進行sql拼接時,會在記憶體中建立物件,如果foreach處理的陣列或者集合元素個數過多,會佔用大量的記憶體空間。

    解決思路:

    1、限制引數中最大的id個數。

    2、將id快取到redis或者記憶體快取中,透過快取進行校驗。

    案例3 - 匯出大檔案記憶體溢位

    小李團隊使用的是k8s將管理系統部署到了容器中,所以這一次我們使用阿里雲的k8s環境還原場景,並解決問題。阿里雲的k8s整體規劃如下:

    K8S環境搭建(瞭解即可)

    1、建立映象倉庫

    2、專案中新增Dockerfile檔案

    Dockerfile
    FROM openjdk:8-jre

    MAINTAINER xiadong <xiadong@itcast.cn>

    RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

    ADD jvm-optimize-0.0.1-SNAPSHOT.jar /app/

    CMD ["java", "-Xmx512m", "-Xms512m", "-Dfile.encoding=UTF-8", "-XX:+HeapDumpOnOutOfMemoryError","-XX:HeapDumpPath=/opt/dump/heapdump.hprof","-jar", "/app/jvm-optimize-0.0.1-SNAPSHOT.jar"]

    EXPOSE 8881

    3、完全按照阿里雲的教程執行命令:

    4、推送成功之後,映象倉庫中已經出現了映象:

    5、透過映象構建k8s中的pod:

    6、選擇剛才的映象:

    7、在OSS中建立一個Bucket

    8、建立儲存宣告,選擇剛才的Bucket

    9、選擇這個儲存宣告,並新增hprof檔案生成的路徑對映,要和Dockerfile中虛擬機器引數裡的路徑相同:

    10、建立一個service,填寫配置,方便外網進行訪問。

    11、開啟jmeter檔案並測試:

    12OSS中出現了這個hprof檔案:

    13、從直方圖就可以看到是匯出檔案導致的記憶體溢位:

    問題根源:

    Excel檔案匯出如果使用POIXSSFWorkbook,在大資料量(幾十萬)的情況下會佔用大量的記憶體。

    程式碼:com.itheima.jvmoptimize.practice.oom.controller.Demo2ExcelController

    解決思路:

    1、使用poiSXSSFWorkbook

    2hutool提供的BigExcelWriter減少記憶體開銷。

    Dockerfile
    //http://www.hutool.cn/docs/#/poi/Excel%E5%A4%A7%E6%95%B0%E6%8D%AE%E7%94%9F%E6%88%90-BigExcelWriter
    @GetMapping("/export_hutool")
    public void export_hutool(int size, String path) throws IOException {


    List<List<?>> rows = new ArrayList<>();
    for (int i = 0; i < size; i++) {
    rows.add( CollUtil.newArrayList(RandomStringUtils.randomAlphabetic(1000)));
    }

    BigExcelWriter writer= ExcelUtil.getBigWriter(path + RandomStringUtils.randomAlphabetic(10) + ".xlsx");
    // 一次性寫出內容,使用預設樣式
    writer.write(rows);
    // 關閉writer,釋放記憶體
    writer.close();


    }

    3、使用easy excel,對記憶體進行了大量的最佳化。

    Dockerfile
    //https://easyexcel.opensource.alibaba.com/docs/current/quickstart/write#%E9%87%8D%E5%A4%8D%E5%A4%9A%E6%AC%A1%E5%86%99%E5%85%A5%E5%86%99%E5%88%B0%E5%8D%95%E4%B8%AA%E6%88%96%E8%80%85%E5%A4%9A%E4%B8%AAsheet
    @GetMapping("/export_easyexcel")
    public void export_easyexcel(int size, String path,int batch) throws IOException {

    // 方法1: 如果寫到同一個sheet
    String fileName = path + RandomStringUtils.randomAlphabetic(10) + ".xlsx";
    // 這裡注意 如果同一個sheet只要建立一次
    WriteSheet writeSheet = EasyExcel.writerSheet("測試").build();
    // 這裡 需要指定寫用哪個class去寫
    try (ExcelWriter excelWriter = EasyExcel.write(fileName, DemoData.class).build()) {
    // 分100次寫入
    for (int i = 0; i < batch; i++) {
    // 分頁去資料庫查詢資料 這裡可以去資料庫查詢每一頁的資料
    List<DemoData> datas = new ArrayList<>();
    for (int j = 0; j < size / batch; j++) {
    DemoData demoData = new DemoData();
    demoData.setString(RandomStringUtils.randomAlphabetic(1000));
    datas.add(demoData);
    }
    excelWriter.write(datas, writeSheet);
    //寫入之後datas資料就可以釋放了
    }
    }

    }

    案例4 – ThreadLocal使用時佔用大量記憶體

    背景:

    小李負責了一個微服務,但是他發現系統在沒有任何使用者使用時,也佔用了大量的記憶體。導致可以使用的記憶體大大減少。

    1、開啟jmeter測試指令碼

    2、記憶體有增長,但是沒溢位。所以透過jmap命令匯出hprof檔案

    3MAT分析之後發現每個執行緒中都包含了大量的物件:

    4、在支配樹中可以發現是ThreadLocalMap導致的記憶體增長:

    5ThreadLocalMap就是ThreadLocal物件儲存資料的地方,所以只要分析ThreadLocal程式碼即可。在攔截器中,ThreadLocal清理的程式碼被錯誤的放在postHandle中,如果介面發生了異常,這段程式碼不會呼叫到,這樣就產生了記憶體洩漏,將其移動到afterCompletion就可以了。

    問題根源和解決思路:

    很多微服務會選擇在攔截器preHandle方法中去解析請求頭中的資料,並放入一些資料到ThreadLocal中方便後續使用。在攔截器的afterCompletion方法中,必須要將ThreadLocal中的資料清理掉。

    案例5 – 文章內容稽核介面的記憶體問題

    背景:

    文章微服務中提供了文章稽核介面,會呼叫阿里雲的內容安全介面進行文章中文字和圖片的稽核,在自測過程中出現記憶體佔用較大的問題。

    設計1:使用SpringBoot中的@Async註解進行非同步的稽核。

    com.itheima.jvmoptimize.practice.oom.controller.Demo1ArticleController類中的article1方法

    1、開啟jmeter指令碼,已經準好了一段測試用的文字。

    2、執行測試,發現執行緒數一直在增加:

    3、發現是因為非同步執行緒池的最大執行緒數設定了Integer的最大值,所以只要沒到上限就一直建立執行緒:

    4、接下來修改為100,再次測試:

    5、這次執行緒數相對來說比較正常:

    存在問題:

    1、執行緒池引數設定不當,會導致大量執行緒的建立或者佇列中儲存大量的資料。

    2、任務沒有持久化,一旦走執行緒池的拒絕策略或者服務當機、伺服器掉電等情況很有可能會丟失任務。

    設計2:使用生產者和消費者模式進行處理,佇列資料可以實現持久化到資料庫。

    程式碼實現:article2方法

    1、測試之後發現,出現記憶體洩漏問題(其實並不是洩漏,而是記憶體中存放了太多的物件,但是從圖上看著像記憶體洩漏了)

    2、每次介面呼叫之後,都會將資料放入佇列中。

    3、而這個佇列沒有設定上限:

    4、調整一下上限設定為2000

    5、這次就沒有出現記憶體洩漏問題了:

    存在問題:

    1、佇列引數設定不正確,會儲存大量的資料。

    2、實現複雜,需要自行實現持久化的機制,否則資料會丟失。

    設計3:使用mq訊息佇列進行處理,由mq來儲存文章的資料。傳送訊息的服務和拉取訊息的服務可以是同一個,也可以不是同一個。

    程式碼方法:article3

    測試結果:

    記憶體沒有出現膨脹的情況

    問題根源和解決思路:

    在專案中如果要使用非同步進行業務處理,或者實現生產者消費者的模型,如果在Java程式碼中實現,會佔用大量的記憶體去儲存中間資料。

    儘量使用Mq訊息佇列,可以很好地將中間資料單獨進行儲存,不會佔用Java的記憶體。同時也可以將生產者和消費者拆分成不同的微服務。

    線上定位問題

    診斷問題有兩種方法,之前我們介紹的是第一種:

  • 生成記憶體快照並分析。

    優點:

    透過完整的記憶體快照準確地判斷出問題產生的原因

    缺點:

    記憶體較大時,生成記憶體快照較慢,這個過程中會影響使用者的使用

    透過MAT分析記憶體快照,至少要準備1.5 – 2倍大小的記憶體空間

  • 線上定位問題

    優點:

    無需生成記憶體快照,整個過程對使用者的影響較小

    缺點:

    無法檢視到詳細的記憶體資訊

    需要透過arthas或者btrace工具調測發現問題產生的原因,需要具備一定的經驗

    安裝Jmeter外掛

    為了監控響應時間RT、每秒事務數TPS等指標,需要在Jmeter上安裝gc外掛。

    1、開啟資料中的外掛包並解壓。

    2、按外掛包中的目錄,複製到jmeter安裝目錄的lib目錄下。

    3、重啟之後就可以在監聽器中看到三個選項,分別是活躍執行緒數、響應時間RT、每秒事務數TPS

    Arthas stack命令線上定位步驟

    1、使用jmap -histo:live 程序ID > 檔名命令將記憶體中存活物件以直方圖的形式儲存到檔案中,這個過程會影響使用者的時間,但是時間比較短暫。

    2、分析記憶體佔用最多的物件,一般這些物件就是造成記憶體洩
    開啟1.txt檔案,從圖中可以看到,有一個UserEntity物件佔用非常多的記憶體。

    漏的原因。

    3、使用arthasstack命令,追蹤物件建立的方法被呼叫的呼叫路徑,找到物件建立的根源。也可以使用btrace工具編寫指令碼追蹤方法執行的過程。

    接下來啟動jmeter指令碼,會發現有大量的方法呼叫這樣不利於觀察。

    加上-n 1 引數,限制只檢視一筆呼叫:

    這樣就定位到了是login介面中建立的物件:

    btrace線上定位問題步驟

    相比較arthasstack命令,btrace允許我們自己編寫程式碼獲取感興趣的內容,靈活性更高。

    BTrace 是一個在Java 平臺上執行的追蹤工具,可以有效地用於線上執行系統的方法追蹤,具有侵入性小、對效能的影響微乎其微等特點。
    專案中可以使用btrace工具,列印出方法被呼叫的棧資訊。
    使用方法:
    1
    、下載btrace工具,官方地址:https://github.com/btraceio/btrace/releases/latest

    在資料中也給出了:



    2
    、編寫btrace指令碼,通常是一個java檔案
    依賴:

    XML
    <dependencies>
    <dependency>
    <groupId>org.openjdk.btrace</groupId>
    <artifactId>btrace-agent</artifactId>
    <version>${btrace.version}</version>
    <scope>system</scope>
    <systemPath>D:\tools\btrace-v2.2.4-bin\libs\btrace-agent.jar</systemPath>
    </dependency>

    <dependency>
    <groupId>org.openjdk.btrace</groupId>
    <artifactId>btrace-boot</artifactId>
    <version>${btrace.version}</version>
    <scope>system</scope>
    <systemPath>D:\tools\btrace-v2.2.4-bin\libs\btrace-boot.jar</systemPath>
    </dependency>

    <dependency>
    <groupId>org.openjdk.btrace</groupId>
    <artifactId>btrace-client</artifactId>
    <version>${btrace.version}</version>
    <scope>system</scope>
    <systemPath>D:\tools\btrace-v2.2.4-bin\libs\btrace-client.jar</systemPath>
    </dependency>
    </dependencies>

    程式碼:

    程式碼非常簡單,就是列印出棧資訊。clazz指定類,method指定監控的方法。

    Java
    import org.openjdk.btrace.core.annotations.*;

    import static org.openjdk.btrace.core.BTraceUtils.jstack;
    import static org.openjdk.btrace.core.BTraceUtils.println;

    @BTrace
    public class TracingUserEntity {
    @OnMethod(
    clazz="com.itheima.jvmoptimize.entity.UserEntity",
    method="/.*/")
    public static void traceExecute(){
    jstack();
    }
    }




    3
    、將btrace工具和指令碼上傳到伺服器,在伺服器上執行btrace 程序ID 指令碼檔名

    配置btrace環境變數,與JDK配置方式基本相同:

    在伺服器上執行btrace 程序ID 指令碼檔名:


    4
    、觀察執行結果。
    啟動jmeter之後,同樣獲取到了棧資訊:

    2GC調優

    GC調優指的是對垃圾回收(Garbage Collection)進行調優。GC調優的主要目標是避免由垃圾回收引起程式效能下降。

    GC調優的核心分成三部分:

    1、通用Jvm引數的設定。

    2、特定垃圾回收器的Jvm引數的設定。

    3、解決由頻繁的FULLGC引起的程式效能問題。

    GC調優沒有沒有唯一的標準答案,如何調優與硬體、程式本身、使用情況均有關係,重點學習調優的工具和方法。

    2.1 GC調優的核心指標

    所以判斷GC是否需要調優,需要從三方面來考慮,與GC演算法的評判標準類似:

    1.吞吐量(Throughput) 吞吐量分為業務吞吐量和垃圾回收吞吐量

    業務吞吐量指的在一段時間內,程式需要完成的業務數量。比如企業中對於吞吐量的要求可能會是這樣的:

    支援使用者每天生成10000筆訂單

    在晚上8點到10點,支援使用者查詢50000條商品資訊

    保證高吞吐量的常規手段有兩條:

    1、最佳化業務執行效能,減少單次業務的執行時間

    2、最佳化垃圾回收吞吐量

    2.1.1 垃圾回收吞吐量

    垃圾回收吞吐量指的是 CPU 用於執行使用者程式碼的時間與 CPU 總執行時間的比值,即吞吐量 = 執行使用者程式碼時間 /(執行使用者程式碼時間 + GC時間)。吞吐量數值越高,垃圾回收的效率就越高,允許更多的CPU時間去處理使用者的業務,相應的業務吞吐量也就越高。

    2.1.2 延遲(Latency

    延遲指的是從使用者發起一個請求到收到響應這其中經歷的時間。比如企業中對於延遲的要求可能會是這樣的:

    所有的請求必須在5秒內返回給使用者結果

    延遲 = GC延遲 + 業務執行時間,所以如果GC時間過長,會影響到使用者的使用。

    2.1.3 記憶體使用量

    記憶體使用量指的是Java應用佔用系統記憶體的最大值,一般透過Jvm引數調整,在滿足上述兩個指標的前提下,這個值越小越好。

    2.2 GC調優的步驟

    2.2.1 發現問題 - 常用工具

    jstat工具

    Jstat工具是JDK自帶的一款監控工具,可以提供各種垃圾回收、類載入、編譯資訊

    等不同的資料。使用方法為:jstat -gc 程序ID 每次統計的間隔(毫秒) 統計次數

    C代表Capacity容量,U代表Used使用量

    S – 倖存者區,E – 伊甸園區,O – 老年代,M – 元空間

    YGCYGT:年輕代GC次數和GC耗時(單位:秒)

    FGCFGCTFull GC次數和Full GC耗時

    GCTGC總耗時

    優點:

    操作簡單

    無額外的軟體安裝

    缺點:

    無法精確到GC產生的時間,只能用於判斷GC是否存在問題

    Visualvm外掛

    VisualVm中提供了一款Visual GC外掛,實時監控Java程序的堆記憶體結構、堆記憶體變化趨勢以及垃圾回收時間的變化趨勢。同時還可以監控物件晉升的直方圖。

    優點:

    適合開發使用,能直觀的看到堆記憶體和GC的變化趨勢

    缺點:

    對程式執行效能有一定影響

    生產環境程式設計師一般沒有許可權進行操作

    安裝方法:

    1、開啟外掛頁面

    2、安裝Visual GC外掛

    3、選擇標籤就可以看到內容:

    Prometheus + Grafana

    Prometheus+Grafana是企業中運維常用的監控方案,其中Prometheus用來採系統或者應用的相關資料,同時具備告警功能。Grafana可以將Prometheus採集到的資料以視覺化的方式進行展示。

    Java程式設計師要學會如何讀懂Grafana展示的Java虛擬機器相關的引數。

    優點:

    支援系統級別和應用級別的監控,比如linux作業系統、RedisMySQLJava程序。

    支援告警並允許自定義告警指標,透過郵件、簡訊等方式儘早通知相關人員進行處理

    缺點:

    環境搭建較為複雜,一般由運維人員完成

    GC日誌

    透過GC日誌,可以更好的看到垃圾回收細節上的資料,同時也可以根據每款垃圾回收器的不同特點更好地發現存在的問題。

    使用方法(JDK 8及以下):-XX:+PrintGCDetails -Xloggc:檔名

    使用方法(JDK 9+):-Xlog:gc*:file=檔名

    1、新增虛擬機器引數:

    2、開啟日誌檔案就可以看到GC日誌

    3、分析GC日誌

    分析GC日誌 - GCViewer

    GCViewer是一個將GC日誌轉換成視覺化圖表的小工具,github地址: https://github.com/chewiebug/GCViewer
    使用方法:java -jar gcviewer_1.3.4.jar 日誌檔案.log

    右下角是基礎資訊,左邊是記憶體趨勢圖

    分析GC日誌 - GCEasy

    GCeasy是業界首款使用AI機器學習技術線上進行GC分析和診斷的工具。定位記憶體洩漏、GC延遲高的問題,提供JVM引數最佳化建議,支援線上的視覺化工具圖表展示。
    官方網站:https://gceasy.io/

    使用方法:

    1、選擇檔案,找到GC日誌並上傳

    2、點選Analyze分析就可以看到報告,每個賬號每個月能免費上傳5GC日誌。

    建議部分:

    記憶體情況:

    GC關鍵性指標:

    GC的趨勢圖:

    引發GC的原因:

    2.2.2 常見的GC模式

    根據記憶體的趨勢圖,我們可以將GC的情況分成幾種模式

    1、正常情況

    特點:呈現鋸齒狀,物件建立之後記憶體上升,一旦發生垃圾回收之後下降到底部,並且每次下降之後的記憶體大小接近,存留的物件較少。

    2、快取物件過多

    特點:呈現鋸齒狀,物件建立之後記憶體上升,一旦發生垃圾回收之後下降到底部,並且每次下降之後的記憶體大小接近,處於比較高的位置。

    問題產生原因:程式中儲存了大量的快取物件,導致GC之後無法釋放,可以使用MAT或者HeapHero等工具進行分析記憶體佔用的原因。

    3、記憶體洩漏

    特點:呈現鋸齒狀,每次垃圾回收之後下降到的記憶體位置越來越高,最後由於垃圾回收無法釋放空間導致物件無法分配產生OutOfMemory的錯誤。

    問題產生原因:程式中儲存了大量的記憶體洩漏物件,導致GC之後無法釋放,可以使用MAT或者HeapHero等工具進行分析是哪些物件產生了記憶體洩漏。

    4、持續的FullGC

    特點:在某個時間點產生多次Full GCCPU使用率同時飆高,使用者請求基本無法處理。一段時間之後恢復正常。

    問題產生原因:在該時間範圍請求量激增,程式開始生成更多物件,同時垃圾收集無法跟上物件建立速率,導致持續地在進行FULL GCGC分析報告

    比如如下報告就產生了持續的FULL GC

    整體的延遲就變得很長:

    原因就是老年代滿了:

    由於分配不了物件,導致頻繁的FULLGC

    5、元空間不足導致的FULLGC

    特點:堆記憶體的大小並不是特別大,但是持續發生FULLGC

    問題產生原因:元空間大小不足,導致持續FULLGC回收元空間的資料。GC分析報告

    元空間並不是滿了才觸發FULLGC,而是JVM自動會計算一個閾值,如下圖中元空間並沒有滿,但是頻繁產生了FULLGC

    停頓時間也比較長:

    非常頻繁的FULLGC:

    2.2.3 解決GC問題的手段

    解決GC問題的手段中,前三種是比較推薦的手段,第四種僅在前三種無法解決時選用:

  • 最佳化基礎JVM引數,基礎JVM引數的設定不當,會導致頻繁FULLGC的產生
  • 減少物件產生,大多數場景下的FULLGC是由於物件產生速度過快導致的,減少物件產生可以有效的緩解FULLGC的發生
  • 更換垃圾回收器,選擇適合當前業務場景的垃圾回收器,減少延遲、提高吞吐量
  • 最佳化垃圾回收器引數,最佳化垃圾回收器的引數,能在一定程度上提升GC效率

    最佳化基礎JVM引數

    引數1 -Xmx –Xms

    -Xmx引數設定的是最大堆記憶體,但是由於程式是執行在伺服器或者容器上,計算可用記憶體時,要將元空間、作業系統、其它軟體佔用的記憶體排除掉。

    案例:伺服器記憶體4G,作業系統+元空間最大值+其它軟體佔用1.5G-Xmx可以設定為2g

    最合理的設定方式應該是根據最大併發量估算伺服器的配置,然後再根據伺服器配置計算最大堆記憶體的值。

    引數1 -Xmx –Xms

    -Xms用來設定初始堆大小,建議將-Xms設定的和-Xmx一樣大,有以下幾點好處:

  • 執行時效能更好,堆的擴容是需要向作業系統申請記憶體的,這樣會導致程式效能短期下降。
  • 可用性問題,如果在擴容時其他程式正在使用大量記憶體,很容易因為作業系統記憶體不足分配失敗。
  • 啟動速度更快,Oracle官方文件的原話:如果初始堆太小,Java 應用程式啟動會變得很慢,因為 JVM 被迫頻繁執行垃圾收集,直到堆增長到更合理的大小。為了獲得最佳啟動效能,請將初始堆大小設定為與最大堆大小相同。

    引數2 -XX:MaxMetaspaceSize –XX:MetaspaceSize

    -XX:MaxMetaspaceSize=引數指的是最大元空間大小,預設值比較大,如果出現元空間記憶體洩漏會讓作業系統可用記憶體不可控,建議根據測試情況設定最大值,一般設定為256m

    -XX:MetaspaceSize=引數指的是到達這個值之後會觸發FULLGC(網上很多文章的初始元空間大小是錯誤的),後續什麼時候再觸發JVM會自行計算。如果設定為和MaxMetaspaceSize一樣大,就不會FULLGC,但是物件也無法回收。

    計算出來第一次因元空間觸發FULLGC的閾值:

    引數3 -Xss虛擬機器棧大小

    如果我們不指定棧的大小,JVM 將建立一個具有預設大小的棧。大小取決於作業系統和計算機的體系結構。

    比如Linux x86 64 1MB,如果不需要用到這麼大的棧記憶體,完全可以將此值調小節省記憶體空間,合理值為256k – 1m之間。

    使用:-Xss256k

    引數4 不建議手動設定的引數

    由於JVM底層設計極為複雜,一個引數的調整也許讓某個介面得益,但同樣有可能影響其他更多介面。

    -Xmn 年輕代的大小,預設值為整個堆的1/3,可以根據峰值流量計算最大的年輕代大小,儘量讓物件只存放在年輕代,不進入老年代。但是實際的場景中,介面的響應時間、建立物件的大小、程式內部還會有一些定時任務等不確定因素都會導致這個值的大小並不能僅憑計算得出,如果設定該值要進行大量的測試。G1垃圾回收器儘量不要設定該值,G1會動態調整年輕代的大小。

    ‐XX:SurvivorRatio 伊甸園區和倖存者區的大小比例,預設值為8

    ‐XX:MaxTenuringThreshold 最大晉升閾值,年齡大於此值之後,會進入老年代。另外JVM有動態年齡判斷機制:將年齡從小到大的物件佔據的空間加起來,如果大於survivor區域的50%,然後把等於或大於該年齡的物件,放入到老年代。

    比如下圖中,年齡1+年齡2+年齡3 = 55m已經超過了S區的50%,所以會將年齡3及以上的物件全部放入老年代。

    其他引數

    -XX:+DisableExplicitGC

    禁止在程式碼中使用System.gc() System.gc()可能會引起FULLGC,在程式碼中儘量不要使用。使用DisableExplicitGC引數可以禁止使用System.gc()方法呼叫。

    -XX:+HeapDumpOnOutOfMemoryError:發生OutOfMemoryError錯誤時,自動生成hprof記憶體快照檔案。

    -XX:HeapDumpPath=<path>:指定hprof檔案的輸出路徑。

    列印GC日誌

    JDK8及之前 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:檔案路徑

    JDK9及之後 -Xlog:gc*:file=檔案路徑

    JVM引數模板

    Java
    -Xms1g
    -Xmx1g
    -Xss256k
    -XX:MaxMetaspaceSize=512m
    -XX:+DisableExplicitGC-XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/opt/logs/my-service.hprof-XX:+PrintGCDetails
    -XX:+PrintGCDateStamps
    -Xloggc:檔案路徑

    注意:

    JDK9及之後gc日誌輸出修改為 -Xlog:gc*:file=檔名

    堆記憶體大小和棧記憶體大小根據實際情況靈活調整。

    垃圾回收器的選擇

    背景

    小李負責的程式在高峰期遇到了效能瓶頸,團隊從業務程式碼入手最佳化了多次也取得了不錯的效果,這次他希望能採用更合理的垃圾回收器最佳化效能。

    思路:

    編寫Jmeter指令碼對程式進行壓測,同時新增RT響應時間、每秒鐘的事務數

    等指標進行監控。

    選擇不同的垃圾回收器進行測試,併發量分別設定50100200,觀察

    資料的變化情況。

    3. JDK8 ParNew + CMS 組合 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC

    預設組合 PS + PO

    JDK8使用g1 : -XX:+UseG1GC JDK11 預設 g1

    測試用程式碼:

    com.itheima.jvmoptimize.fullgcdemo.Demo2Controller

    1、使用jmeter測試指令碼

    2、新增基礎JVM測試引數:

    Java
    -Xms8g -Xmx8g -Xss256k -XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:/test.hprof -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

    JDK8預設情況下測試的是PS+PO組合

    測試結果:

    垃圾回收器

    引數

    50併發(最大響應時間)

    100併發(最大響應時間)

    200併發(最大響應時間)

    PS+PO

    預設

    260ms

    474ms

    930ms

    CMS

    -XX:+UseParNewGC -XX:+UseConcMarkSweepGC

    157ms

    未測試

    833ms

    G1

    JDK11預設

    未測試

    未測試

    248ms

    由此可見使用了JDK11之後使用G1垃圾回收器,效能最佳化結果還是非常明顯的。其他測試資料同學們有興趣可以自行去測試一下。

    最佳化垃圾回收器的引數

    這部分最佳化效果未必出色,僅當前邊的一些手動無效時才考慮。

    一個最佳化的案例:

    CMS的併發模式失敗(concurrent mode failure)現象。由於CMS的垃圾清理執行緒和使用者執行緒是並行進行的,如果在併發清理的過程中老年代的空間不足以容納放入老年代的物件,會產生併發模式失敗。

    老年代已經滿了此時有一些物件要晉升到老年代:

    解決方案:

    1.減少物件的產生以及物件的晉升。

    2.增加堆記憶體大小

    3.最佳化垃圾回收器的引數,比如-XX:CMSInitiatingOccupancyFraction=值,當老年代大小到達該閾值時,會自動進行CMS垃圾回收,透過控制這個引數提前進行老年代的垃圾回收,減少其大小。

    JDK8中預設這個引數值為 -1,根據其他幾個引數計算出閾值:

    ((100 - MinHeapFreeRatio) + (double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)

    在我本機計算之後的結果是92

    該引數設定完是不會生效的,必須開啟-XX:+UseCMSInitiatingOccupancyOnly引數。

    調整前和調整之後的效果對比:

    很明顯FULLGC產生的次數下降了。

    2.2.4 案例實戰

    背景:

    小李負責的程式在高峰期經常會出現介面呼叫時間特別長的現象,他希望能最佳化程式的效能。

    思路:

    生成GC報告,透過Gceasy工具進行分析,判斷是否存在GC問題或者記憶體問題。

    存在記憶體問題,透過jmap或者arthas將堆記憶體快照儲存下來。

    透過MAT或者線上的heaphero工具分析記憶體問題的原因。

    修復問題,併發布上線進行測試。

    測試程式碼com.itheima.jvmoptimize.fullgcdemo.Practice

    JVM引數:

    Java
    -Xms1g -Xmx1g -Xss256k -XX:MaxMetaspaceSize=256m -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+DisableExplicitGC -Xloggc:D:/test.log

    1、開啟測試指令碼:

    2、發現有幾筆響應時間特別長的請求,懷疑是GC引起的:

    3、把GC日誌上傳到GCEasy之後發現記憶體佔用情況很嚴重:

    出現了幾次FULLGC,並且FULL GC之後,記憶體佔用也有160m左右:

    問題1

    發生了連續的FULL GC,堆記憶體1g如果沒有請求的情況下,記憶體大小在200-300mb之間。

    分析:

    沒有請求的情況下,記憶體大小並沒有處於很低的情況,滿足快取物件過多的情況,懷疑記憶體種快取了很多資料。需要將堆記憶體快照儲存下來進行分析。

    1、在本地測試,透過visualvmhprof檔案儲存下來:

    2、透過Heap Hero分析檔案,操作方式與GCEasy相同,上傳的是hprof檔案:

    但是我們發現,生成的檔案非常小,與接近200m大小不符:

    3、懷疑有些物件已經可以回收,所以沒有下載下來。使用jmap調整下引數,將live引數去掉,這樣即便是垃圾物件也能儲存下來:

    4、在MAT中分析,選擇不可達物件直方圖:

    5、大量的物件都是位元組陣列物件:

    6.那麼這些物件是如何產生的呢?繼續往下來,捕捉到有大量的執行緒物件,如果沒有發現這個點,只能去查程式碼看看哪裡建立了大量的位元組陣列了:

    問題2

    由於這些物件已經不在引用鏈上,無法透過支配樹等手段分析建立的位置。

    分析:

    在不可達物件列表中,除了發現大量的byte[]還發現了大量的執行緒,可以考慮跟蹤執行緒的棧資訊來判斷物件在哪裡建立。

    1、在VisualVM中使用取樣功能,對記憶體取樣:

    2、觀察到這個執行緒一直在發生變化,說明有執行緒頻繁建立銷燬:

    3、選擇執行緒功能,儲存執行緒棧:

    4、抓到了一個執行緒,執行緒後邊的ID很大,說明已經建立過很多執行緒了:

    5、透過棧資訊找到原始碼:

    這裡有個定時任務,每隔200ms就建立執行緒。

    問題產生原因:

    在定時任務中透過執行緒建立了大量的物件,導致堆記憶體一直處於比較高的位置。

    解決方案:

    暫時先將這段程式碼註釋掉,測試效果,由於這個服務本身的記憶體壓力比較大,將這段定時任務移動到別的服務中。

    問題3

    修復之後記憶體基本上處於100m左右,但是當請求發生時,依然有頻繁FULL GC的發生。

    分析:

    請求產生的記憶體大小比當前最大堆記憶體大,嘗試選擇配置更高的伺服器,將-Xmx-Xms引數調大一些。

    當前的堆記憶體大小無法支撐請求量,所以要不就將請求量降下來,比如限制tomcat執行緒數、限流,或者提升伺服器配置,增大堆記憶體。

    調整為4G之後的效果,FULLGC數量很少:

    案例總結:

    1、壓力比較大的服務中,儘量不要存放大量的快取或者定時任務,會影響到服務的記憶體使用。

    2、記憶體分析發現有大量執行緒建立時,可以使用匯出執行緒棧來檢視執行緒的執行情況。

    3、如果請求確實建立了大量的記憶體超過了記憶體上限,只能考慮減少請求時建立的物件,或者使用更大的記憶體。

    4、推薦使用g1垃圾回收器,並且使用較新的JDK可以獲得更好的效能。

    3、效能調優

    3.1 效能調優解決的問題

    應用程式在執行過程中經常會出現效能問題,比較常見的效能問題現象是:

    1、透過top命令檢視CPU佔用率高,接近100甚至多核CPU下超過100都是有可能的。

    2、請求單個服務處理時間特別長,多服務使用skywalking等監控系統來判斷是哪一個環節效能低下。

    3、程式啟動之後執行正常,但是在執行一段時間之後無法處理任何的請求(記憶體和GC正常)。

    3.2 效能調優的方法

    執行緒轉儲(Thread Dump)提供了對所有執行中的執行緒當前狀態的快照。執行緒轉儲可以透過jstackvisualvm等工具獲取。其中包含了執行緒名、優先順序、執行緒ID、執行緒狀態、執行緒棧資訊等等內容,可以用來解決CPU佔用率高、死鎖等問題。

    1、透過jps檢視程序ID

    2、透過jstack 程序ID檢視執行緒棧資訊:

    3、透過jstack 程序ID > 檔名匯出執行緒棧檔案

    執行緒轉儲(Thread Dump)中的幾個核心內容:
    名稱: 執行緒名稱,透過給執行緒設定合適的名稱更容易"見名知意"
    優先順序(prio):執行緒的優先順序
    Java ID
    tid):JVM中執行緒的唯一ID
    本地 ID (nid):作業系統分配給執行緒的唯一ID
    狀態:執行緒的狀態,分為:
    NEW –
    新建立的執行緒,尚未開始執行
    RUNNABLE –
    正在執行或準備執行
    BLOCKED –
    等待獲取監視器鎖以進入或重新進入同步塊/方法
    WAITING –
    等待其他執行緒執行特定操作,沒有時間限制
    TIMED_WAITING –
    等待其他執行緒在指定時間內執行特定操作
    TERMINATED –
    已完成執行

    棧追蹤: 顯示整個方法的棧幀資訊

    執行緒轉儲的視覺化線上分析平臺:
    1
    https://jstack.review/
    2
    https://fastthread.io/

    解決CPU佔用率高的問題

    應用程式在執行過程中經常會出現效能問題,比較常見的效能問題現象是:

    1、透過top命令檢視CPU佔用率高,接近100甚至多核CPU下超過100都是有可能的。

    2、請求單個服務處理時間特別長,多服務使用skywalking等監控系統來判斷是哪一個環節效能低下。

    3、程式啟動之後執行正常,但是在執行一段時間之後無法處理任何的請求(記憶體和GC正常)。

    問題:

    監控人員透過prometheus的告警發現CPU佔用率一直處於很高的情況,透過top命令看到是由於Java程式引起的,希望能快速定位到是哪一部分程式碼導致了效能問題。

    解決思路:

    1、透過top –c 命令找到CPU佔用率高的程序,獲取它的程序ID

    2、使用top -p 程序ID單獨監控某個程序,按H可以檢視到所有的執行緒以及執行緒對應的CPU使用率,找到CPU使用率特別高的執行緒。

    3、使用 jstack 程序ID 命令可以檢視到所有執行緒正在執行的棧資訊。使用 jstack 程序ID > 檔名儲存到檔案中方便檢視。

    4、找到nid執行緒ID相同的棧資訊,需要將之前記錄下的十進位制執行緒號轉換成16進位制。透過 printf '%x\n' 執行緒ID 命令直接獲得16進位制下的執行緒ID

    5、找到棧資訊對應的原始碼,並分析問題產生原因。

    在定位CPU佔用率高的問題時,比較需要關注的是狀態為RUNNABLE的執行緒。但實際上,有一些執行緒執行本地方法時並不會消耗CPU,而只是在等待。但 JVM 仍然會將它們標識成"RUNNABLE"狀態。

    3.3 案例實戰

    案例2:介面響應時間很長的問題

    問題:

    在程式執行過程中,發現有幾個介面的響應時間特別長,需要快速定位到是哪一個方法的程式碼執行過程中出現了效能問題。

    解決思路:

    已經確定是某個介面效能出現了問題,但是由於方法巢狀比較深,需要藉助於arthas定位到具體的方法。

    比如呼叫鏈是A方法 -> B方法 -> C方法 -> D方法,整體耗時較長。我們需要定位出來是C方法慢導致的問題。

    trace命令監控

    使用arthastrace命令,可以展示出整個方法的呼叫路徑以及每一個方法的執行耗時。

    命令:trace 類名 方法名

    新增--skipJDKMethod false引數可以輸出JDK核心包中的方法及耗時。

    新增 '#cost > 毫秒值' 引數,只會顯示耗時超過該毫秒值的呼叫。

    新增–n 數值引數,最多顯示該數值條數的資料。

    所有監控都結束之後,輸入stop結束監控,重置arthas增強的物件。

    測試方法:

    com.itheima.jvmoptimize.performance.PerformanceController.a()

    1、使用trace命令,監控方法的執行:

    2、發起一次請求呼叫:

    3、顯示出了方法呼叫的耗時佔比:

    4、新增--skipJDKMethod false引數可以輸出JDK核心包中的方法及耗時:

    5、新增 '#cost > 1000' 引數,只顯示耗時超過1秒的呼叫。

    6、新增–n 1引數,最多顯示1條資料,避免資料太多看起來不清晰。

    7、所有監控都結束之後,輸入stop結束監控,重置arthas增強的物件。避免對效能產生影響。

    watch命令監控

    在使用trace定位到效能較低的方法之後,使用watch命令監控該方法,可以獲得更為詳細的方法資訊。

    命令:

    watch 類名 方法名 '{params, returnObj}' '#cost>毫秒值' -x 2

    '{params, returnObj}'代表列印引數和返回值。

    -x代表列印的結果中如果有巢狀(比如物件裡有屬性),最多隻展開2層。允許設定的最大值為4

    測試方法:

    com.itheima.jvmoptimize.performance.PerformanceController.a()

    1、執行命令,發起一筆介面呼叫:

    2cost = 1565ms代表方法執行時間是1.56秒,result = 後邊是引數的內容,首先是一個集合(既可以獲取返回值,也可以獲取引數),第一個陣列就是引數,裡邊只有一個元素是一個整數值為1

    總結:

    1、透過arthastrace命令,首先找到效能較差的具體方法,如果訪問量比較大,建議設定最小的耗時,精確的找到耗時比較高的呼叫。

    2、透過watch命令,檢視此呼叫的引數和返回值,重點是引數,這樣就可以在開發環境或者測試環境模擬類似的現象,透過debug找到具體的問題根源。

    3、使用stop命令將所有增強的物件恢復。

    案例3:定位偏底層的效能問題

    問題:

    有一個介面中使用了for迴圈向ArrayList中新增資料,但是最終發現執行時間比較長,需要定位是由於什麼原因導致的效能低下。

    解決思路:

    Arthas提供了效能火焰圖的功能,可以非常直觀地顯示所有方法中哪些方法執行時間比較長。

    測試方法:

    com.itheima.jvmoptimize.performance.PerformanceController.test6()

    使用arthasprofile命令,生成效能監控的火焰圖。

    命令1 profiler start 開始監控方法執行效能

    命令2 profiler stop --format html HTML的方式生成火焰圖

    火焰圖中一般找綠色部分Java中棧頂上比較平的部分,很可能就是效能的瓶頸。

    1、使用命令開始監控:

    2、傳送請求測試:

    3、執行命令結束,並生成火焰圖的HTML

    4、觀察火焰圖的結果:

    火焰圖中重點關注左邊部分,是我們自己編寫的程式碼的執行效能,右邊是Java虛擬機器底層方法的效能。火焰圖中會展示出Java虛擬機器自身方法執行的時間。

    火焰圖中越寬的部分代表執行時間越長,比如:

    很明顯ArrayList類中的add方法呼叫花費了大量的時間,這其中可以發現一個copyOf方法,陣列的複製佔用時間較多。

    觀察原始碼可以知道,頻繁的擴容需要多次將老陣列中的元素複製到新陣列,浪費了大量的時間。

    ArrayList的構造方法中,設定一下最大容量,一開始就讓它具備這樣的大小,避免頻繁擴容帶來的影響:

    最終這部分開銷就沒有了,寬度變大是因為我放大了這張圖:

    總結:

    偏底層的效能問題,特別是由於JDK中某些方法被大量呼叫導致的效能低下,可以使用火焰圖非常直觀的找到原因。

    這個案例中是由於建立ArrayList時沒有手動指定容量,導致使用預設的容量而在新增物件過程中發生了多次的擴容,擴容需要將原來陣列中的元素複製到新的陣列中,消耗了大量的時間。透過火焰圖可以看到大量的呼叫,修復完之後節省了20% ~ 50%的時間。

    案例4:執行緒被耗盡問題

    問題:

    程式在啟動執行一段時間之後,就無法接受任何請求了。將程式重啟之後繼續執行,依然會出現相同的情況。

    解決思路:

    執行緒耗盡問題,一般是由於執行時間過長,分析方法分成兩步:

    1、檢測是否有死鎖產生,無法自動解除的死鎖會將執行緒永遠阻塞。

    2、如果沒有死鎖,再使用案例1的列印執行緒棧的方法檢測執行緒正在執行哪個方法,一般這些大量出現的方法就是慢方法。

    死鎖:兩個或以上的執行緒因為爭奪資源而造成互相等待的現象。

    死鎖問題,學習黑馬程式設計師《JUC併發程式設計》相關章節。
    地址 https://www.bilibili.com/video/BV16J411h7Rd?p=115

    解決方案:

    執行緒死鎖可以透過三種方法定位問題:

    測試方法:

    com.itheima.jvmoptimize.performance.PerformanceController.test6()

    com.itheima.jvmoptimize.performance.PerformanceController.test7()

    先呼叫deadlock1(test6)方法

    再呼叫deadlock2(test7)方法,就可以產生死鎖

    1 jstack -l 程序ID > 檔名將執行緒棧儲存到本地。

    在檔案中搜尋deadlock即可找到死鎖位置:

    2開發環境中使用visual vm或者Jconsole工具,都可以檢測出死鎖。使用執行緒快照生成工具就可以看到死鎖的根源。生產環境的服務一般不會允許使用這兩種工具連線。

    3、使用fastthread自動檢測執行緒問題。 https://fastthread.io/
    Fastthread
    Gceasy類似,是一款線上的AI自動執行緒問題檢測工具,可以提供執行緒分析報告。透過報告檢視是否存在死鎖問題。

    visualvm中儲存執行緒棧:

    選擇檔案並點選分析:

    死鎖分析報告:

    3.4 JMH基準測試框架

    面試中容易問到效能測試問題:

    Java程式在執行過程中,JIT即時編譯器會實時對程式碼進行效能最佳化,所以僅憑少量的測試是無法真實反應執行系統最終給使用者提供的效能。如下圖,隨著執行次數的增加,程式效能會逐漸最佳化。

    所以簡單地列印時間是不準確的,JIT有可能還沒有對程式進行效能最佳化,我們拿到的測試資料和終端使用者使用的資料是不一致的。

    OpenJDK中提供了一款叫JMHJava Microbenchmark Harness)的工具,可以準確地對Java程式碼進行基準測試,量化方法的執行效能。
    官網地址:https://github.com/openjdk/jmhc
    JMH
    會首先執行預熱過程,確保JIT對程式碼進行最佳化之後再進行真正的迭代測試,最後輸出測試的結果。

    JMH環境搭建:

    建立基準測試專案,在CMD視窗中,使用以下命令建立JMH環境專案:

    Shell
    mvn archetype:generate \
    -DinteractiveMode=false \
    -DarchetypeGroupId=org.openjdk.jmh \
    -DarchetypeArtifactId=jmh-java-benchmark-archetype \
    -DgroupId=org.sample \
    -DartifactId=test \
    -Dversion=1.0

    修改POM檔案中的JDK版本號和JMH版本號,JMH最新版本號參考Github

    編寫測試方法,幾個需要注意的點:

  • 死程式碼問題
  • 黑洞的用法

    初始程式碼:

    Java
    package org.sample;

    import org.openjdk.jmh.annotations.*;
    import org.openjdk.jmh.results.format.ResultFormatType;
    import org.openjdk.jmh.runner.Runner;
    import org.openjdk.jmh.runner.RunnerException;
    import org.openjdk.jmh.runner.options.Options;
    import org.openjdk.jmh.runner.options.OptionsBuilder;

    import java.text.SimpleDateFormat;
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.Date;
    import java.util.concurrent.TimeUnit;

    //執行5輪預熱,每次持續1秒
    @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    //執行一次測試
    @Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
    //顯示平均時間,單位納秒
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @State(Scope.Benchmark)
    public class HelloWorldBench {

    @Benchmark
    public int test1() {
    int i = 0;
    i++;
    return i;
    }

    public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
    .include(HelloWorldBench.class.getSimpleName())
    .resultFormat(ResultFormatType.JSON)
    .forks(1)
    .build();

    new Runner(opt).run();
    }
    }

    如果不將i返回,JIT會直接將這段程式碼去掉,因為它認為你不會使用i那麼我們對i進行的任何處理都是沒有意義的,這種程式碼無法執行的現象稱之為死程式碼

    我們可以將i返回,或者新增黑洞來消費這些變數,讓JIT無法消除這些程式碼:

    透過mavenverify命令,檢測程式碼問題並打包成jar包。透過
    java -jar target/benchmarks.jar
    命令執行基準測試。

    新增這行引數,可以生成JSON檔案,測試結果透過https://jmh.morethan.io/生成視覺化的結果。

    案例:日期格式化方法效能測試

    問題:

    JDK8中,可以使用Date進行日期的格式化,也可以使用LocalDateTime進行格式化,使用JMH對比這兩種格式化的效能。

    解決思路:

    1、搭建JMH測試環境。

    2、編寫JMH測試程式碼。

    3、進行測試。

    4、比對測試結果。

    Java
    package org.sample;

    import org.openjdk.jmh.annotations.*;
    import org.openjdk.jmh.results.format.ResultFormatType;
    import org.openjdk.jmh.runner.Runner;
    import org.openjdk.jmh.runner.RunnerException;
    import org.openjdk.jmh.runner.options.Options;
    import org.openjdk.jmh.runner.options.OptionsBuilder;

    import java.text.SimpleDateFormat;
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.Date;
    import java.util.concurrent.TimeUnit;

    //執行5輪預熱,每次持續1秒
    @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    //執行一次測試
    @Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
    //顯示平均時間,單位納秒
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @State(Scope.Thread)
    public class DateBench {


    private static String sDateFormatString = "yyyy-MM-dd HH:mm:ss";
    private Date date = new Date();
    private LocalDateTime localDateTime = LocalDateTime.now();
    private static ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal = new ThreadLocal();
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @Setup
    public void setUp() {

    SimpleDateFormat sdf = new SimpleDateFormat(sDateFormatString);
    simpleDateFormatThreadLocal.set(sdf);

    }

    @Benchmark
    public String date() {
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat(sDateFormatString);
    return simpleDateFormat.format(date);
    }

    @Benchmark
    public String localDateTime() {
    return localDateTime.format(formatter);
    }
    @Benchmark
    public String localDateTimeNotSave() {
    return localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    @Benchmark
    public String dateThreadLocal() {
    return simpleDateFormatThreadLocal.get().format(date);
    }


    public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
    .include(DateBench.class.getSimpleName())
    .resultFormat(ResultFormatType.JSON)
    .forks(1)
    .build();

    new Runner(opt).run();
    }
    }

    3.5 效能調優綜合案例

    問題:

    小李的專案中有一個獲取使用者資訊的介面效能比較差,他希望能對這個介面在程式碼中進行徹底的最佳化,提升效能。

    解決思路:

    1、使用trace分析效能瓶頸。

    2、最佳化程式碼,反覆使用trace測試效能提升的情況。

    3、使用JMHSpringBoot環境中進行測試。

    4、比對測試結果。

    Java
    package com.itheima.jvmoptimize.performance.practice.controller;

    import com.itheima.jvmoptimize.performance.practice.entity.User;
    import com.itheima.jvmoptimize.performance.practice.entity.UserDetails;
    import com.itheima.jvmoptimize.performance.practice.service.UserService;
    import com.itheima.jvmoptimize.performance.practice.vo.UserVO;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;

    import java.text.SimpleDateFormat;
    import java.time.format.DateTimeFormatter;
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.stream.Collectors;

    @RestController
    @RequestMapping("/puser")
    public class UserController {

    @Autowired
    private UserService userService;

    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    //初始程式碼
    public List<UserVO> user1(){
    //1.從資料庫獲取前端需要的詳情資料
    List<UserDetails> userDetails = userService.getUserDetails();

    //2.獲取快取中的使用者資料
    List<User> users = userService.getUsers();

    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    //3.遍歷詳情集合,從快取中獲取使用者名稱,生成VO進行填充
    ArrayList<UserVO> userVOS = new ArrayList<>();
    for (UserDetails userDetail : userDetails) {
    UserVO userVO = new UserVO();
    //可以使用BeanUtils物件複製
    userVO.setId(userDetail.getId());
    userVO.setRegister(simpleDateFormat.format(userDetail.getRegister2()));
    //填充name
    for (User user : users) {
    if(user.getId().equals(userDetail.getId())){
    userVO.setName(user.getName());
    }
    }
    //加入集合
    userVOS.add(userVO);
    }

    return userVOS;

    }


    //使用HasmMap存放使用者名稱字
    public List<UserVO> user2(){
    //1.從資料庫獲取前端需要的詳情資料
    List<UserDetails> userDetails = userService.getUserDetails();

    //2.獲取快取中的使用者資料
    List<User> users = userService.getUsers();
    //將list轉換成hashmap
    HashMap<Long, User> map = new HashMap<>();
    for (User user : users) {
    map.put(user.getId(),user);
    }

    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    //3.遍歷詳情集合,從快取中獲取使用者名稱,生成VO進行填充
    ArrayList<UserVO> userVOS = new ArrayList<>();
    for (UserDetails userDetail : userDetails) {
    UserVO userVO = new UserVO();
    //可以使用BeanUtils物件複製
    userVO.setId(userDetail.getId());
    userVO.setRegister(simpleDateFormat.format(userDetail.getRegister2()));
    //填充name
    userVO.setName(map.get(userDetail.getId()).getName());
    //加入集合
    userVOS.add(userVO);
    }

    return userVOS;

    }


    //最佳化日期格式化
    public List<UserVO> user3(){
    //1.從資料庫獲取前端需要的詳情資料
    List<UserDetails> userDetails = userService.getUserDetails();

    //2.獲取快取中的使用者資料
    List<User> users = userService.getUsers();
    //將list轉換成hashmap
    HashMap<Long, User> map = new HashMap<>();
    for (User user : users) {
    map.put(user.getId(),user);
    }

    //3.遍歷詳情集合,從快取中獲取使用者名稱,生成VO進行填充
    ArrayList<UserVO> userVOS = new ArrayList<>();
    for (UserDetails userDetail : userDetails) {
    UserVO userVO = new UserVO();
    //可以使用BeanUtils物件複製
    userVO.setId(userDetail.getId());
    userVO.setRegister(userDetail.getRegister().format(formatter));
    //填充name
    userVO.setName(map.get(userDetail.getId()).getName());
    //加入集合
    userVOS.add(userVO);
    }

    return userVOS;

    }

    @GetMapping
    //使用stream流改寫for迴圈
    public List<UserVO> user4(){
    //1.從資料庫獲取前端需要的詳情資料
    List<UserDetails> userDetails = userService.getUserDetails();

    //2.獲取快取中的使用者資料
    List<User> users = userService.getUsers();
    //將list轉換成hashmap
    Map<Long, User> map = users.stream().collect(Collectors.toMap(User::getId, o -> o));

    //3.遍歷詳情集合,從快取中獲取使用者名稱,生成VO進行填充
    return userDetails.stream().map(userDetail -> {
    UserVO userVO = new UserVO();
    //可以使用BeanUtils物件複製
    userVO.setId(userDetail.getId());
    userVO.setRegister(userDetail.getRegister().format(formatter));
    //填充name
    userVO.setName(map.get(userDetail.getId()).getName());
    return userVO;
    }).collect(Collectors.toList());

    }

    //使用並行流最佳化效能
    public List<UserVO> user5(){
    //1.從資料庫獲取前端需要的詳情資料
    List<UserDetails> userDetails = userService.getUserDetails();

    //2.獲取快取中的使用者資料
    List<User> users = userService.getUsers();
    //將list轉換成hashmap
    Map<Long, User> map = users.parallelStream().collect(Collectors.toMap(User::getId, o -> o));

    //3.遍歷詳情集合,從快取中獲取使用者名稱,生成VO進行填充
    return userDetails.parallelStream().map(userDetail -> {
    UserVO userVO = new UserVO();
    //可以使用BeanUtils物件複製
    userVO.setId(userDetail.getId());
    userVO.setRegister(userDetail.getRegister().format(formatter));
    //填充name
    userVO.setName(map.get(userDetail.getId()).getName());
    return userVO;
    }).collect(Collectors.toList());

    }
    }

    SpringBoot專案中整合JMH:

    1pom檔案中新增依賴:

    XML
    <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>${jmh.version}</version>
    <scope>test</scope>
    </dependency>
    <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>${jmh.version}</version>
    <scope>test</scope>
    </dependency>

    XML
    <properties>
    <java.version>8</java.version>
    <jmh.version>1.37</jmh.version>
    </properties>

    2、測試類中編寫:

    Java
    package com.itheima.jvmoptimize;

    import com.itheima.jvmoptimize.performance.practice.controller.UserController;
    import org.junit.jupiter.api.Test;
    import org.openjdk.jmh.annotations.*;
    import org.openjdk.jmh.infra.Blackhole;
    import org.openjdk.jmh.results.format.ResultFormatType;
    import org.openjdk.jmh.runner.Runner;
    import org.openjdk.jmh.runner.RunnerException;
    import org.openjdk.jmh.runner.options.OptionsBuilder;
    import org.springframework.boot.SpringApplication;
    import org.springframework.context.ApplicationContext;

    import java.io.IOException;
    import java.util.concurrent.TimeUnit;

    //執行5輪預熱,每次持續1秒
    @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    //執行一次測試
    @Fork(value = 1, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
    //顯示平均時間,單位納秒
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MILLISECONDS)
    @State(Scope.Benchmark)
    public class PracticeBenchmarkTest {

    private UserController userController;
    private ApplicationContext context;

    //初始化將springboot容器啟動 埠號隨機
    @Setup
    public void setup() {
    this.context = new SpringApplication(JvmOptimizeApplication.class).run();
    userController = this.context.getBean(UserController.class);
    }

    //啟動這個測試用例進行測試
    @Test
    public void executeJmhRunner() throws RunnerException, IOException {

    new Runner(new OptionsBuilder()
    .shouldDoGC(true)
    .forks(0)
    .resultFormat(ResultFormatType.JSON)
    .shouldFailOnError(true)
    .build()).run();
    }

    //用黑洞消費資料,避免JIT消除程式碼
    @Benchmark
    public void test1(final Blackhole bh) {

    bh.consume(userController.user1());
    }

    @Benchmark
    public void test2(final Blackhole bh) {

    bh.consume(userController.user2());
    }

    @Benchmark
    public void test3(final Blackhole bh) {

    bh.consume(userController.user3());
    }

    @Benchmark
    public void test4(final Blackhole bh) {

    bh.consume(userController.user4());
    }

    @Benchmark
    public void test5(final Blackhole bh) {

    bh.consume(userController.user5());
    }
    }

    總結:

    1、本案例中效能問題產生的原因是兩層for迴圈導致的迴圈次數過多,處理時間在迴圈次數變大的情況下變得非常長,考慮將一層迴圈拆出去,建立HashMap用來查詢提升效能。

    2、使用LocalDateTime替代SimpleDateFormat進行日期的格式化。

    3、使用stream流改造程式碼,這一步可能會導致效能下降,主要是為了第四次最佳化準備。

    4、使用並行流利用多核CPU的優勢並行執行提升效能。

相關文章