博客 / 詳情

返回

JDK高版本特性總結與ZGC實踐

美團信息安全技術團隊核心服務升級JDK 17後,性能與穩定性大幅提升,機器成本降低了10%。高版本JDK與ZGC技術令人驚豔,且Java AI SDK最低支持JDK 17。本文總結了JDK 17的主要特性,然後重點分享了JDK 17+ZGC在安全領域的一些實踐,希望能對大家有所幫助或啓發。

從一句調侃的話 “你發任你發,我用Java 8!” 可以看出,在開發新項目時,Java 8依然是大家的首選。美團Java 8服務佔比超過70%,可以説Java 8依然是絕對的主流。但是,我們在多個核心服務上遇到較多的性能問題,這些問題無法通過JVM參數微調來解決,為此我們對部分核心服務使用了 JDK 17,升級後服務性能和穩定性指標也得到巨大的飛躍,同時機器成本可以下降約10%,升級JDK版本收益十分明顯。另外,目前正處在AI時代的爆發期,Java AI SDK的最小支持版本為JDK 17,這讓升級JDK版本變得更具價值。接下來,期望跟大家一起探索JDK高版本和ZGC技術的奧秘,開啓優化Java應用的新徵程。

1. JDK 17的主要特性

包含JDK 9~17等中間版本的特性。

從 JDK 8 直接升級到 JDK 17,以下是需要重點關注的特性,這些特性對開發效率、代碼風格、性能優化和安全性都有顯著影響。

1.1 語言特性[1]

1.1.1 局部變量類型推斷

使用var關鍵字來聲明局部變量,而無需顯式指定變量的類型。在Java 17中,可以使用局部變量類型推斷的擴展來編寫更簡潔的代碼。其他語言如Golang很早就支持了var變量。

// JDK8
String str = "Hello world";

// JDK17
var str = "Hello world";
需要注意的是,var類型的局部變量仍然具有靜態類型,一旦被推斷出來,類型就會固定下來,並且不能重新賦值為不兼容的類型。

1.1.2 密封類

它允許我們將類或接口的繼承限制為一組有限的子類。如果想將類或接口的繼承限制為一組有限的子類時,這非常有用。在下面的示例中,可以看到我們如何使用sealed關鍵字將類的繼承限制為一組有限的子類。我們可以通過在類的聲明前加上sealed關鍵字來將該類聲明為密封類。然後,可以使用permits關鍵字列出該密封類允許繼承的子類。這些子類必須直接或間接地繼承自密封類。這樣,只有在這個預定義的子類中,才能繼承該密封類。

//使用permits關鍵字列出了允許繼承的子類Circle、Rectangle和Triangle
public sealed class Shape permits Circle, Rectangle, Triangle {
    // 省略實現
}

// 在與密封類相同的模塊或包中 定義以下三個允許的子類, Circle,Square和:Rectangle
public final class Circle extends Shape {
    public float radius;
}
 
public non-sealed class Square extends Shape {
   public double side;
}   
 
public sealed class Rectangle extends Shape permits FilledRectangle {
    public double length, width;
}

1.1.3 Record 類

Record 類的主要目的是提供一種更簡潔、更安全的方式來定義不可變的數據載體類。它自動實現了常見的方法(如equals()hashCode()toString()和構造函數),從而減少了樣板代碼。

特點

  1. 不可變性:Record類的字段默認是final的,因此 Record 類是不可變的。
  2. 簡潔性:Record類自動提供了構造函數、equals()hashCode()toString()方法,無需手動編寫。
  3. 組件訪問:Record類的字段可以通過recordName.fieldName的方式直接訪問。
  4. 模式匹配:Record類支持模式匹配(Pattern Matching),可以與instanceofswitch表達式結合使用。

Record類的定義非常簡單,只需要使用record關鍵字,並聲明字段類型和名稱即可。例如:

// 這裏有一個包含兩個字段的記錄類
record Rectangle(double length, double width) { }

// 這個簡潔的矩形聲明等同於以下普通類
public final class Rectangle {
    private final double length;
    private final double width;

    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }

    double length() { return this.length; }
    double width()  { return this.width; }

    // ...
    public boolean equals...
    public int hashCode...

    // ...
    public String toString() {...}
}

1.1.4 switch表達式優化

在Java 17中使用switch表達式時,不必使用關鍵字break來跳出switch語句,或return在每個switch case上使用關鍵字來返回值;相反,我們可以返回整個switch表達式。這種增強的switch表達式使整體代碼看起來更清晰,更易於閲讀。switch打印一週中某一天的字母數量的語句。

JDK 8

public enum Day { SUNDAY, MONDAY, TUESDAY,
    WEDNESDAY, THURSDAY, FRIDAY, SATURDAY; }

        // ...

    int numLetters = 0;
    Day day = Day.WEDNESDAY;
    switch (day) {
        case MONDAY:
        case FRIDAY:
        case SUNDAY:
            numLetters = 6;
            break;
        case TUESDAY:
            numLetters = 7;
            break;
        case THURSDAY:
        case SATURDAY:
            numLetters = 8;
            break;
        case WEDNESDAY:
            numLetters = 9;
            break;
        default:
            throw new IllegalStateException("Invalid day: " + day);
    }
    System.out.println(numLetters);

JDK 17

        Day day = Day.WEDNESDAY;    
    System.out.println(
        switch (day) {
            case MONDAY, FRIDAY, SUNDAY -> 6;
            case TUESDAY                -> 7;
            case THURSDAY, SATURDAY     -> 8;
            case WEDNESDAY              -> 9;
            default -> throw new IllegalStateException("Invalid day: " + day);
        }
    ); 

1.1.5 文本塊

在不使用轉義序列的情況下創建多行字符串。在創建SQL查詢或JSON字符串時非常有用。在下面的示例中,可以看到使用文本塊時代碼看起來更加簡潔。

// JDK8
String message = "'The time has come,' the Walrus said,\n" +
                 "'To talk of many things:\n" +
                 "Of shoes -- and ships -- and sealing-wax --\n" +
                 "Of cabbages -- and kings --\n" +
                 "And why the sea is boiling hot --\n" +
                 "And whether pigs have wings.'\n";

// 使用文本塊可以消除大部分混亂:
String message = """
    'The time has come,' the Walrus said,
    'To talk of many things:
    Of shoes -- and ships -- and sealing-wax --
    Of cabbages -- and kings --
    And why the sea is boiling hot --
    And whether pigs have wings.'
    """;

SQL註解描述

// JDK8    
@Select("select distinct ta.host_name from tb_agent_info tai, tb_agent ta where 1=1 " +
        "and ta.host_name=tai.host_name and ta.status=1 and ta.master=1 and tai.report_pid_count > 0")
Set<String> queryAllJavaHost();

// JDK17
@Select("""
    SELECT DISTINCT ta.host_name
    FROM tb_agent_info tai, tb_agent ta
    WHERE 1=1
      AND ta.host_name = tai.host_name
      AND ta.status = 1
      AND ta.master = 1
      AND tai.report_pid_count > 0
 """)
 Set<String> queryAllJavaHost2();
  • 可讀性更強:文本結構清晰可見,無需處理轉義字符或字符串連接。
  • 減少錯誤:不需要手動添加換行符(\n),降低了出錯的可能性。
  • 易於編輯:可以直接複製粘貼格式化好的JSON,而不需要額外的處理。
  • 保留縮進:文本塊會保留的縮進,使得其在Java代碼中的呈現更加美觀。

1.1.6 模式匹配instanceof優化

它允許將instanceof運算符用作返回已轉換對象的表達式。當我們使用嵌套的if-else語句時,這非常有用。在下面的示例中,可以看到我們如何使用instanceof運算符來捕獲對象,而不是進行顯式轉換。

JDK 8

Object obj = ...;

if (obj instanceof String) {
    String str = (String) obj;
    int length = str.length();
    System.out.println("字符串長度:" + length);
}

JDK 17

Object obj = ...;

if (obj instanceof String str) {
    int length = str.length();
    System.out.println("字符串長度:" + length);
}

1.1.7 NullPointerExceptions的優化

對象空指針在日常開發中遇到的比較多,一般代碼報錯只能精確的某一行,如果該行的代碼比較複雜,涉及到多個對象,往往不能直接確定是哪一個對象為空。

public class NpeDemo { 
  public static void main(String[] args) { 
    Address address=new Address();
    User user=new User();
    user.setAddress(address);
    log.info(user.getAddress().getCity().toLowerCase()); 
  }
}

上面代碼中的第6行鏈式調用,如果某一個環節出現空指針,將會拋出空指針的異常:

Exception in thread "main" java.lang.NullPointerException 
        at NpeDemo.main(Main.java:6)

使用JDK 17

Exception in thread "main" java.lang.NullPointerException: 
Cannot invoke "String.toLowerCase()" because the return value of "Address.getCity()" is null 
  at NpeDemo.main(Main.java:6)

1.1.8 集合、Stream和Optional的增強

Java 在集合(Collections)、Stream API 和 Optional類方面引入了許多增強功能。主要有:

集合增強:不可變集合: 引入了創建不可變集合的便捷方法,如List.of()、Set.of()和Map.of()。這些方法用於快速創建不可變集合,減少了代碼量並提高了安全性。

import java.util.*;

public class CollectionsDemo {
    public static void main(String[] args) {
        // 創建不可變list
        List<String> list = List.of("Java", "Golang", "Python");
        // 創建不可變set
        Set<String> set = Set.of("Java", "Golang", "Python");
        // 創建不可變map
        Map<String, Integer> map = Map.of("Java", 1, "Golang", 2, "Python", 3);
    }
}

集合工廠方法:Java 17還引入了集合工廠方法,如List.copyOf()、Set.copyOf() 和 Map.copyOf(),用於從現有集合創建不可變副本。

Stream API增強takeWhiledropWhile:基於條件截取或跳過元素;iterate:支持終止條件的迭代;ofNullable:將可能為null的值轉換為Stream。

Optional增強ifPresentOrElse:值存在時執行操作,否則執行另一個操作;or:在值不存在時提供替代值;stream:將Optional轉換為Stream。

1.2 新API和工具

1.2.1 新的HttpClient

可以使用HttpClient使用來發送請求並檢索其響應。 HttpClient可以通過builder來創建。該newBuilder方法返回一個構建器,用於創建默認HttpClient實現的實例。該構建器可用於配置每個客户端的狀態,例如:首選協議版本(HTTP/1.1 或 HTTP/2)、是否遵循重定向、代理、身份驗證器等。 構建完成後,HttpClient是不可變的,可用於發送多個請求。

// 同步示例
HttpClient client = HttpClient.newBuilder()
        .version(Version.HTTP_1_1)
        .followRedirects(Redirect.NORMAL)
        .connectTimeout(Duration.ofSeconds(20))
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 80)))
        .authenticator(Authenticator.getDefault())
        .build();
   HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
   System.out.println(response.statusCode());
   System.out.println(response.body());  

// 異步示例
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://foo.com/"))
        .timeout(Duration.ofMinutes(2))
        .header("Content-Type", "application/json")
        .POST(BodyPublishers.ofFile(Paths.get("file.json")))
        .build();
   client.sendAsync(request, BodyHandlers.ofString())
        .thenApply(HttpResponse::body)
        .thenAccept(System.out::println);  

如果不希望引入三方依賴(三方依賴漏洞和Bug等需要經常升級),可以使用JDK提供的原生的httpClient API,適用場景中間件

1.2.2 打包工具jpackage[2]

該工具將以Java應用程序和Java運行時鏡像作為輸入,生成包含所有必要依賴項的Java應用程序鏡像。它能夠生成特定平台格式的原生軟件包,例如Windows上的exe文件或macOS上的dmg 文件。每種格式都必須在其運行的平台上構建,不支持跨平台。該工具將提供一些選項,允許以各種方式定製打包的應用程序。該工具最大特點是無需單獨安裝JDK環境,例如用JDK17寫了一個MCP Server工具,直接打包為可執行文件安裝即可,減少環境依賴安裝。

1.2.3 進程相關API[3]

進程管理功能得到了顯著增強,ProcessHandle提供了更強大的功能來創建、監控和管理本地進程。這些改進使得Java程序能夠更靈活地與操作系統交互,同時提供了更詳細的進程信息和更強大的生命週期管理功能。

1.創建進程

在Java中,創建新進程通常使用ProcessBuilderRuntime.getRuntime().exec()。而Java 17上ProcessHandle提供了更強大的功能來管理這些進程。

ProcessBuilder pb = new ProcessBuilder("echo", "Hello World!");
Process p = pb.start();

2.監控進程

public class ProcessTest {

  // ...

  static public void startProcessesTest() throws IOException, InterruptedException {
    List<ProcessBuilder> greps = new ArrayList<>();
    greps.add(new ProcessBuilder("/bin/sh", "-c", "grep -c \"java\" *"));
    greps.add(new ProcessBuilder("/bin/sh", "-c", "grep -c \"Process\" *"));
    greps.add(new ProcessBuilder("/bin/sh", "-c", "grep -c \"onExit\" *"));
    ProcessTest.startSeveralProcesses (greps, ProcessTest::printGrepResults);      
    System.out.println("\nPress enter to continue ...\n");
    System.in.read();  
  }

  static void startSeveralProcesses (
    List<ProcessBuilder> pBList,
    Consumer<Process> onExitMethod)
    throws InterruptedException {
    System.out.println("Number of processes: " + pBList.size());
    pBList.stream().forEach(
      pb -> {
        try {
          Process p = pb.start();
          System.out.printf("Start %d, %s%n",
            p.pid(), p.info().commandLine().orElse("<na>"));
          p.onExit().thenAccept(onExitMethod);
        } catch (IOException e) {
          System.err.println("Exception caught");
          e.printStackTrace();
        }
      }
    );
  }
  
  static void printGrepResults(Process p) {
    System.out.printf("Exit %d, status %d%n%s%n%n",
      p.pid(), p.exitValue(), output(p.getInputStream()));
  }

  private static String output(InputStream inputStream) {
    String s = "";
    try (BufferedReader br = new BufferedReader(new InputStreamReader(inputStream))) {
      s = br.lines().collect(Collectors.joining(System.getProperty("line.separator")));
    } catch (IOException e) {
      System.err.println("Caught IOException");
      e.printStackTrace();
    }
    return s;
  }

  // ...
}

3.獲取進程信息

public static void getInfoTest() throws IOException {
        ProcessBuilder pb = new ProcessBuilder("echo", "Hello World!");
        String na = "<not available>";
        Process p = pb.start();
        ProcessHandle.Info info = p.info();
        System.out.printf("Process ID: %s%n", p.pid());
        System.out.printf("Command name: %s%n", info.command().orElse(na));
        System.out.printf("Command line: %s%n", info.commandLine().orElse(na));

        System.out.printf("Start time: %s%n",
            info.startInstant().map((Instant i) -> i
                .atZone(ZoneId.systemDefault()).toLocalDateTime().toString())
                .orElse(na));

        System.out.printf("Arguments: %s%n",
            info.arguments().map(
                (String[] a) -> Stream.of(a).collect(Collectors.joining(" ")))
                .orElse(na));

        System.out.printf("User: %s%n", info.user().orElse(na));
}

輸出
Process ID: 18761
Command name: /usr/bin/echo
Command line: echo Hello World!
Start time: 2017-05-30T18:52:15.577
Arguments: <not available>
User: administrator

1.2.4 AI工具最低版本為JDK17

最近火熱的AI大模型工具,JDK 8不再兼容,運行的最低版本為JDK 17,例如Spring AI工具。

1.3 性能優化與Bug修復

1.3.1 垃圾回收器改進ZGC

ZGC作為新一代的垃圾回收器,主要目標:

  • 支持TB級內存
  • 停頓時間控制在10ms之內
  • 對程序吞吐量影響小於15%

據官方測評數據,在內存為128GB的機器上,相比於G1來説,性能提高30%,停頓時間減少99%。

1.3.2 NIO 重寫與優化

  • 支持Unix-Domain套接字:在JDK8上如果想要使用UDS,一般使用Netty或者開源的Juds庫,JDK 17支持了該功能,無需使用第三方庫;
  • 文件通道的優化:可以將文件的某個區域直接映射到內存中,從而實現高效的讀寫操作。這種方式利用了操作系統的內存映射機制,減少了I/O操作的開銷;
  • 零拷貝支持:允許數據直接從磁盤的一個位置複製到另一個位置,而無需經過用户態內存。這減少了數據在用户態和內核態之間的拷貝次數,從而顯著提高了性能。

1.3.3 Java SDK模塊化設計

JVM的模塊化是Java 9引入的一個重要特性,通過Java Platform Module System (JPMS) 實現。這一特性旨在解決Java應用在可擴展性和維護上的問題,提供更高級別的封裝和依賴管理機制。

  • 減少環境資源開銷:在JDK 9之前,每次啓動JVM都要耗費至少30MB到60MB的內存空間,因為JVM需要加載整個rt.jar。模塊化允許JVM選擇性地加載必需的模塊,從而減少內存佔用。
  • 提升開發效率和運行速度:隨着代碼庫的複雜性增加,開發效率和運行速度會受到影響。模塊化通過規範化路徑和依賴關係,使系統更安全、更高效。
  • 規範化路徑及依賴關係:JDK 9之前,系統沒有對不同JAR之間的依賴或敏感路徑進行限制,導致所有JAR都可以被訪問,暴露了安全問題。模塊化通過管理模塊間的依賴關係,隱藏不必要的模塊,提高了安全性和空間利用率。

1.3.4 Java Agent機制的Attach Bug修復

Java Attach Socket文件被刪除後會導致Java Agent注入失敗,在JDK 8上只能通過重啓解決,而JDK 17會重新創建一個新的文件。

1.3.5 彈性元空間[4]

更及時地將未使用的元空間內存回收,減少元空間佔用的內存。

2. JDK17+ZGC在安全領域的實踐

2.1 美團JDK的現狀

在美團信息安全部,JDK8(Oracle JDK8u201)依然是主流版本,其次是Open JDK17,剩下為Open JDK 11。

2.2 ZGC適用場景

  • 服務器成本壓力大:服務器數量大於100台、單機配置大於16C16G、Java堆內存超過16G等。
  • 單機CPU高:峯值大約在50%

  • 性能火焰圖中GC佔比高
  • 高峯期故障雷達、監控大盤和服務日誌等告警頻繁

2.3 ZGC效果

2.3.1 性能壓測效果

在測試服務不同接口中,ZGC在高QPS場景中收益較大(服務的QPS超過1萬):

  • TP9999:下降220~380ms,下降幅度18%~74%。
  • TP999:下降60-125ms,下降幅度10%~63%。
  • TP99:下降 3ms-20ms,下降幅度0%-25%。

一些重度依賴外部的接口中性能優化不大,原因是這些服務的響應時間瓶頸不是GC,而是外部依賴的性能,在一些低QPS接口中對比不太明顯。

2.3.2 案例1:智能決策系統(JDK 11+ZGC 升級到JDK 17+ZGC)

峯值cpu.busy指標下降

升級前: 47.8565%

升級後: 41.4933%

系統長期運行時TP9999性能穩定

運行15天,JDK11機器長時間不重啓三九、四九線會逐漸升高,JDK 17機器較為穩定。

服務失敗率顯著降低

UGC集羣升級效果:錯誤數量由峯值6000下降到349。

JVM元空間使用降低

單機維度高峯期性能指標

2.3.3 案例2:內容安全核心服務 (JDK 8+CMS升級到JDK 17+ZGC)

該服務是內容安全的代理層,主要負責匹配請求的分發、輔助功能支撐(日誌、監控、熔斷)以及一些個性化業務需求。當前該服務GC是CMS,該服務線上的Young GC平均耗時是17ms,平均每分鐘GC次數是6次,該服務接口平均響應時間是2.6ms。

根據文章《從實際案例聊聊Java應用的GC優化》中提供的計算方式,受到Young GC影響的請求佔比是:

$$受GC影響請求佔比 = \frac{N * \left ( GC時間 + 接口響應時間 \right ) }{T} = \frac{6 * \left ( 17 + 2.6 \right ) }{60000} = 0.196\%$$

即有0.196%的請求收到GC時間0-17ms不等的影響。其中收到GC停頓完整影響的請求佔比:

$$受GC完整影響請求佔比 = \frac{N * \left (接口響應時間 \right ) }{T} = \frac{6 * 2.6}{60000} = 0.026\%$$

即其中有0.026%的請求受到完整的GC停頓時間影響,即耗時增加17ms,可以大致理解為請求響應的9999線會因GC停頓而導致17ms的上漲。

根據ZGC的STW的耗時在毫秒甚至亞毫秒級別,因此理論上升級後服務的9999線可以降低17ms左右。在實際生產中,還會有Full GC的影響,會帶來耗時的進一步提升,ZGC在該部分可以避免Full GC帶來的影響。

服務升級採用的是Tomcat 9+JDK 17的配置,錄製線上流量進行壓測,使用同樣的流量對先前採用CMS垃圾回收的以及採用ZGC垃圾回收方式的同時進行壓測。服務器配置均為8C16G,800QPS的壓測,通過2h左右的壓測,

分析接口耗時統計:可得到以下數據,發現耗時均有明顯下降,9999線的下降量低於理論的17ms,由於實際環境中其他因素的影響也基本符合預期。

分析CPU和JVM佔用情況:CPU和JVM佔用情況發現,CPU佔用在峯值處會提升10%左右,JVM佔用情況基本一致。

2.4 ZGC實現原理簡介

更多詳情,可參考《新一代垃圾回收器ZGC的探索與實踐》一文。

2.4.1 CMS與G1停頓時間瓶頸

在介紹ZGC之前,首先回顧一下CMS和G1的GC過程以及停頓時間的瓶頸。CMS新生代的Young GC、G1和ZGC都基於標記-複製算法,但算法具體實現的不同就導致了巨大的性能差異。

標記-複製算法應用在CMS新生代(ParNew是CMS默認的新生代垃圾回收器)和G1垃圾回收器中。標記-複製算法可以分為三個階段:

  • 標記階段,即從GC Roots集合開始,標記活躍對象;
  • 轉移階段,即把活躍對象複製到新的內存地址上;
  • 重定位階段,因為轉移導致對象的地址發生了變化,在重定位階段,所有指向對象舊地址的指針都要調整到對象新的地址上。

下面以G1為例,通過G1中標記-複製算法過程(G1的Young GC和Mixed GC均採用該算法),分析G1停頓耗時的主要瓶頸。G1垃圾回收週期如下圖所示:

G1的混合回收過程可以分為標記階段、清理階段和複製階段:

標記階段停頓分析

  • 初始標記階段:初始標記階段是指從根節點(GC Roots)出發標記全部直接子節點的過程,該階段是STW的。由於GC Roots數量不多,通常該階段耗時非常短。
  • 併發標記階段:併發標記階段是指從GC Roots開始對堆中對象進行可達性分析,找出存活對象。該階段是併發的,即應用線程和GC線程可以同時活動。併發標記耗時相對長很多,但因為不是STW,所以我們不太關心該階段耗時的長短。
  • 再標記階段:重新標記那些在併發標記階段發生變化的對象。該階段是STW的。

清理階段停頓分析

  • 清理階段清點出有存活對象的分區和沒有存活對象的分區,該階段不會清理垃圾對象,也不會執行存活對象的複製。該階段是STW的。

複製階段停頓分析

  • 複製算法中的轉移階段需要分配新內存和複製對象的成員變量。轉移階段是STW的,其中內存分配通常耗時非常短,但對象成員變量的複製耗時有可能較長,這是因為複製耗時與存活對象數量與對象複雜度成正比。對象越複雜,複製耗時越長。

四個STW過程中,初始標記因為只標記GC Roots,耗時較短。再標記因為對象數少,耗時也較短。清理階段因為內存分區數量少,耗時也較短。轉移階段要處理所有存活的對象,耗時會較長。因此,G1停頓時間的瓶頸主要是標記-複製中的轉移階段STW。為什麼轉移階段不能和標記階段一樣併發執行呢?主要是G1未能解決轉移過程中準確定位對象地址的問題。

2.4.2 ZGC原理

與CMS中的ParNew和G1類似,ZGC也採用標記-複製算法,不過ZGC對該算法做了重大改進:ZGC在標記、轉移和重定位階段幾乎都是併發的,這是ZGC實現停頓時間小於10ms目標的最關鍵原因。

ZGC垃圾回收週期如下圖所示:

ZGC只有三個STW階段:初始標記,再標記,初始轉移。其中,初始標記和初始轉移分別都只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,一般情況耗時非常短;再標記階段STW時間很短,最多1ms,超過1ms則再次進入併發標記階段。即,ZGC幾乎所有暫停都只依賴於GC Roots集合大小,停頓時間不會隨着堆的大小或者活躍對象的大小而增加。與ZGC對比,G1的轉移階段完全STW的,且停頓時間隨存活對象的大小增加而增加。

2.4.3 主要特點

  • 單代:ZGC沒有分代,基於“大部分對象朝生夕死”的假設,沒有Young GC的概念(這裏僅指JDK 17,JDK 21支持分代回收,性能更高)。
  • 基於Region: G1的每個Region大小是完全一樣的,而ZGC的Region更靈活,其中大型Region大小不固定,可以動態變化,也不會被重分配,因為複製一個大對象代價太高。
  • 部分壓縮: 基於Region,“標記-整理”,相對CMS壓縮時間更短。
  • 支持NUMA: 對應有UMA,每個CPU對應有一塊內存,每個CPU優先訪問這塊內存。

  • 染色指針

以前的垃圾回收器的GC信息都保存在對象頭中,ZGC將GC 信息保存在了染色指針上,無需進行對象訪問就可以獲得GC 信息。這就是ZGC在標記和轉移階段速度更快的原因。Marked0、Marked1和Remapped這三個虛擬內存作為ZGC的三個視圖空間,在同一個時間點內只能有一個有效。ZGC就是通過這三個視圖空間的切換,來完成併發的垃圾回收。

  • 讀屏障

讀屏障,在標記和移動對象的階段,每次從堆裏對象的引用類型中讀取一個指針的時候,都需要加上一個Load Barriers。用於確定對象的引用地址是否滿足條件,並作出相應動作。

3. JDK 17升級實踐過程

主要分為三個階段:安裝部署、解決兼容性問題、性能測試與參數優化。

如果公司的中間件大部分基於JDK 8,工程代碼編譯可以基於JDK 8,運行環境使用JDK 17。

3.1 安裝與兼容性問題

1.主要的問題舉例

JVM運行的報錯信息:module java.base does not "opens java.util.concurrent.locks" to unnamed module

[ERROR] main JsonUtil Json parse failed
java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.util.concurrent.locks.ReentrantReadWriteLock$ReadLock java.util.concurrent.locks.ReentrantReadWriteLock.readerLock accessible: module java.base does not "opens java.util.concurrent.locks" to unnamed module @1ba9117e
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
    at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:178)
    at java.base/java.lang.reflect.Field.setAccessible(Field.java:172)
    at com.fasterxml.jackson.databind.util.ClassUtil.checkAndFixAccess(ClassUtil.java:939)
    at com.fasterxml.jackson.databind.deser.impl.FieldProperty.fixAccess(FieldProperty.java:104)

2.原因:JDK9之後Java API使用了模塊化設計方案,用户模塊無法反射調用Java代碼,需要使用開啓對應模塊訪問權限(沒有引入新的安全問題,相當於沒有用模塊隔離的功能)。

3.解決方式: JVM參數增加如下:

--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED --add-opens java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens java.base/jdk.internal.access=ALL-UNNAMED --add-opens java.base/jdk.internal.misc=ALL-UNNAMED

其他軟件等兼容性問題,根據自身服務報錯,對應解決問題。

3.2 性能壓測

  • 基準: JDK 8+CMS
  • 壓測:實驗組和對照組壓測後重啓避免性能優化為結果影響並取平均值
  • 指標監控: 峯值CPU、平均CPU、TP9999、報錯數量、GC總時間和次數、JVM堆內存和元空間變化等
  • 其他:性能火焰圖

3.3 JVM參數

  • -Xmx18g -Xms18g 堆大小
  • -XX:MaxDirectMemorySize=2G 直接內存
  • -XX:+HeapDumpOnOutOfMemoryError 當JVM發生OOM時,自動生成DUMP文件。
  • -XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m 設置codecache大小 默認128m
  • -XX:+UseZGC 使用ZGC
  • -XX:ZAllocationSpikeTolerance=2 ZGC觸發自適應算法的修正係數,默認2,數值越大,越早的觸發ZGC
  • -XX:ZCollectionInterval=0 ZGC的週期。默認值為0,表示不需要觸發垃圾回收。固定週期垃圾回收。ZGC發生的最小時間間隔,單位秒
  • -XX:ConcGCThreads=4 併發階段的GC線程數,默認是總核數的12.5%
  • -XX:ZStatisticsInterval=10 控制統計信息輸出的間隔,默認10s
  • -XX:ParallelGCThreads=16 並行工作線程數據,STW階段使用線程數,默認是總核數的60%
  • -Xlog:safepoint,classhisto=trace,age,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m' 設置GC日誌中的內容、格式、位置以及每個日誌的大小

本服務prod機器16c,16g成功運行起來的JVM參數(還在調整中,僅供參考):

-server -Xmx12g -Xms12g -XX:+UnlockExperimentalVMOptions -XX:+UnlockDiagnosticVMOptions -XX:+UseZGC -XX:+UseDynamicNumberOfGCThreads -XX:ConcGCThreads=3 -XX:ParallelGCThreads=8 -XX:ZCollectionInterval=130 -XX:ZAllocationSpikeTolerance=1 -XX:MaxDirectMemorySize=460m -XX:MetaspaceSize=330m -XX:MaxMetaspaceSize=330m -XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m -XX:+UseCountedLoopSafepoints -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=500 -XX:GuaranteedSafepointInterval=0 -XX:+DisableExplicitGC -XX:+HeapDumpOnOutOfMemoryError -XX:ZStatisticsInterval=130 -XX:+PrintGCDetails -Xlog:safepoint,class+load=info,class+unload=info,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/java.util.concurrent.locks=ALL-UNNAMED --add-opens java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens java.base/jdk.internal.access=ALL-UNNAMED --add-opens java.base/jdk.internal.misc=ALL-UNNAMED --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED --add-opens java.base/jdk.internal.perf=ALL-UNNAMED --add-opens java.base/java.instrument=ALL-UNNAMED --add-opens jdk.attach/sun.tools.attach=ALL-UNNAMED 

4. 總結

  • ZGC作為新一代垃圾回收器,各項性能指標都比較突出,升級之後,機器成本和性能收益明顯;
  • Spring AI SDK支持的JDK版本最小為17,升級到JDK 17能更好地擁抱AI新技術;
  • 直接從JDK 8升級到JDK 17跨度較大,需要解決的兼容性問題較多,如果公司的基礎組件不支持JDK 17,可以考慮先升級到JDK 11做一個過渡;
  • 如果在升級與實踐的過程中遇到了一些問題,可以結合AI大模型來給出解決方案,幫助提高升級效率。

註釋

  • [1] 語言特性
  • [2] 打包工具jpackage
  • [3] 進程相關API
  • [4] 彈性元空間
  • [5] TP999:指的是OctoService.TP999
  • [6] TP9999

閲讀更多

| 關注「美團技術團隊」微信公眾號,在公眾號菜單欄對話框回覆【2024年貨】、【2023年貨】、【2022年貨】、【2021年貨】、【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明 "內容轉載自美團技術團隊"。本文未經許可,不得進行商業性轉載或者使用。任何商用行為,請發送郵件至 tech@meituan.com 申請授權。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.