美團信息安全技術團隊核心服務升級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()和構造函數),從而減少了樣板代碼。
特點
- 不可變性:Record類的字段默認是
final的,因此 Record 類是不可變的。 - 簡潔性:Record類自動提供了構造函數、
equals()、hashCode()和toString()方法,無需手動編寫。 - 組件訪問:Record類的字段可以通過
recordName.fieldName的方式直接訪問。 - 模式匹配:Record類支持模式匹配(Pattern Matching),可以與
instanceof和switch表達式結合使用。
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增強:takeWhile和dropWhile:基於條件截取或跳過元素;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中,創建新進程通常使用ProcessBuilder或Runtime.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 申請授權。