美团信息安全技术团队核心服务升级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变量。
ini
// JDK8
String str = "Hello world";
// JDK17
var str = "Hello world";
需要注意的是,var类型的局部变量仍然具有静态类型,一旦被推断出来,类型就会固定下来,并且不能重新赋值为不兼容的类型。
1.1.2 密封类
它允许我们将类或接口的继承限制为一组有限的子类。如果想将类或接口的继承限制为一组有限的子类时,这非常有用。在下面的示例中,可以看到我们如何使用sealed关键字将类的继承限制为一组有限的子类。我们可以通过在类的声明前加上sealed关键字来将该类声明为密封类。然后,可以使用permits关键字列出该密封类允许继承的子类。这些子类必须直接或间接地继承自密封类。这样,只有在这个预定义的子类中,才能继承该密封类。
scala
//使用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
关键字,并声明字段类型和名称即可。例如:
arduino
// 这里有一个包含两个字段的记录类
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
ini
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
sql
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字符串时非常有用。在下面的示例中,可以看到使用文本块时代码看起来更加简洁。
ini
// 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注解描述
python
// 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
ini
Object obj = ...;
if (obj instanceof String) {
String str = (String) obj;
int length = str.length();
System.out.println("字符串长度:" + length);
}
JDK 17
ini
Object obj = ...;
if (obj instanceof String str) {
int length = str.length();
System.out.println("字符串长度:" + length);
}
1.1.7 NullPointerExceptions的优化
对象空指针在日常开发中遇到的比较多,一般代码报错只能精确的某一行,如果该行的代码比较复杂,涉及到多个对象,往往不能直接确定是哪一个对象为空。
typescript
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行链式调用,如果某一个环节出现空指针,将会抛出空指针的异常:
php
Exception in thread "main" java.lang.NullPointerException
at NpeDemo.main(Main.java:6)
使用JDK 17
kotlin
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()。这些方法用于快速创建不可变集合,减少了代码量并提高了安全性。
typescript
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
是不可变的,可用于发送多个请求。
less
// 同步示例
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
提供了更强大的功能来管理这些进程。
ini
ProcessBuilder pb = new ProcessBuilder("echo", "Hello World!");
Process p = pb.start();
2.监控进程
csharp
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.获取进程信息
less
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影响的请求占比是:
<math xmlns="http://www.w3.org/1998/Math/MathML"> 受 G C 影响请求占比 = N ∗ ( G C 时间 + 接口响应时间 ) T = 6 ∗ ( 17 + 2.6 ) 60000 = 0.196 % 受GC影响请求占比 = \frac{N * \left ( GC时间 + 接口响应时间 \right ) }{T} = \frac{6 * \left ( 17 + 2.6 \right ) }{60000} = 0.196\% </math>受GC影响请求占比=TN∗(GC时间+接口响应时间)=600006∗(17+2.6)=0.196%
即有0.196%的请求收到GC时间0-17ms不等的影响。其中收到GC停顿完整影响的请求占比:
<math xmlns="http://www.w3.org/1998/Math/MathML"> 受 G C 完整影响请求占比 = N ∗ ( 接口响应时间 ) T = 6 ∗ 2.6 60000 = 0.026 % 受GC完整影响请求占比 = \frac{N * \left (接口响应时间 \right ) }{T} = \frac{6 * 2.6}{60000} = 0.026\% </math>受GC完整影响请求占比=TN∗(接口响应时间)=600006∗2.6=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
php
[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参数增加如下:
css
--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参数(还在调整中,仅供参考):
ruby
-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\] [语言特性](https://link.juejin.cn?target=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fjava%2Fjavase%2F17%2Flanguage%2Fjava-language-changes-summary.html "https://docs.oracle.com/en/java/javase/17/language/java-language-changes-summary.html")
-
3\] [进程相关API](https://link.juejin.cn?target=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fjava%2Fjavase%2F17%2Fcore%2Fprocess-api1.html "https://docs.oracle.com/en/java/javase/17/core/process-api1.html")
-
5\] TP999:指的是OctoService.TP999
阅读更多
| 关注「美团技术团队」微信公众号,在公众号菜单栏对话框回复【2024年货】、【2023年货】、【2022年货】、【2021年货】、【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明 "内容转载自美团技术团队"。本文未经许可,不得进行商业性转载或者使用。任何商用行为,请发送邮件至 [email protected] 申请授权。