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变量。

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()和构造函数),从而减少了样板代码。

特点

  1. 不可变性 :Record类的字段默认是final的,因此 Record 类是不可变的。
  2. 简洁性 :Record类自动提供了构造函数、equals()hashCode()toString()方法,无需手动编写。
  3. 组件访问 :Record类的字段可以通过recordName.fieldName的方式直接访问。
  4. 模式匹配 :Record类支持模式匹配(Pattern Matching),可以与instanceofswitch表达式结合使用。

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增强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是不可变的,可用于发送多个请求。

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中,创建新进程通常使用ProcessBuilderRuntime.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年货】等关键词,可查看美团技术团队历年技术文章合集。

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

相关推荐
q***718524 分钟前
Spring Boot 集成 MyBatis 全面讲解
spring boot·后端·mybatis
大象席地抽烟30 分钟前
使用 Ollama 本地模型与 Spring AI Alibaba
后端
程序员小假33 分钟前
SQL 语句左连接右连接内连接如何使用,区别是什么?
java·后端
小坏讲微服务35 分钟前
Spring Cloud Alibaba Gateway 集成 Redis 限流的完整配置
数据库·redis·分布式·后端·spring cloud·架构·gateway
方圆想当图灵1 小时前
Nacos 源码深度畅游:Nacos 配置同步详解(下)
分布式·后端·github
方圆想当图灵1 小时前
Nacos 源码深度畅游:Nacos 配置同步详解(上)
分布式·后端·github
小羊失眠啦.2 小时前
用 Rust 实现高性能并发下载器:从原理到实战
开发语言·后端·rust
Filotimo_3 小时前
SpringBoot3入门
java·spring boot·后端
一 乐3 小时前
校园墙|校园社区|基于Java+vue的校园墙小程序系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·小程序
golang学习记3 小时前
🍵 Go Queryx 入门指南:让数据库操作像喝奶茶一样丝滑!
后端