从 JDK 9 到 19,我们帮您提炼了和云原生场景有关的能力列表(上)

语言: CN / TW / HK

作者:孤戈

在 JDK 9 之前,Java 基本上平均每三年出一个版本。但是自从 2017 年 9 月分推出 JDK9 到现在,Java 开始了疯狂更新的模式,基本上保持了每年两个大版本的节奏。从 2017 年至今,已经发布了 十一个版本到了 JDK 19。其中包括了两个 LTS 版本(JDK11 与 JDK17)。除了版本更新节奏明显加快之外,JDK 也围绕着云原生场景的能力,推出并增强了一系列诸如容器内资源动态感知、无停顿 GC(ZGC、Shenandoah)、原生的运维能力等等。这篇文章是 EDAS 团队的同学在服务客户的过程中,从云原生的角度将相关的功能进行整理和提炼而来。希望能和给大家一起认识一个新的 Java 形态。

云原生场景定义

云原生的内在推动力之一是让我们的业务工作负载最大化的利用云所带来的技术红利,云带来最大的技术红利就是通过弹性等相关技术,带来我们资源的高效交付和利用,从而降低最终的资源的经济成本。所以如何最大化的利用资源的弹性能力是很多技术产品所追求的其中一个目标。

同时,另外一个内在推动力是如何去避免云厂商技术的锁定,实现手段就是推动各个领域的标准的建立。自从云原生诞生以来, 随着 Kubernetes 的大获成功,以开源为主要形态的技术产品,持续从各个领域中形成既定的规范和标准,是技术产品追求的另外一个目标。

有了最终的目标,通过不断优化的标准,那么如何在这个新场景下利用上相关的标准能力,是很多产品不断往前演进的方向。以上两点,我们自己的产品如此,Java 亦如此。

Java 针对性能力

针对 Java 的近十个版本更新,我们将从运维、编程模型与运行时、内存三个场景进行解读。其中运维部分主要是如何利用现有的容器技术获取运维指标以及在这个场景下的一些原生能力的支持。同时 Java 也在字符串和新的 IO 模型与能力。另外一个最大的变化来自于内存部分,除了在容器场景下对于 CGroup 有更好的支持之外,还提供了令人期待的 ZGC 和 Shenandoah GC 两款无停顿的垃圾回收器,除了提供低时延的 STW 之外,还具备归还部分内存给操作系统,最大限度的提供了应用在云原生场景下利用硬件资源的能力。

整个解读分为上下两篇,除了内存会使用一个单独的文章进行解读之外,剩下的内容主要在这章讲解。

更原生的运维场景

1、OperatingSystemMXBean

容器的其中一个能力是进程级别的隔离,默认情况下,容器内的 Java 程序如果基于 JMX 中提供的 OperatingSystemMXBean 中的方法进行访问,会返回所在宿主机的所有资源数据。在 JDK 14 的版本之后,在容器或其他虚拟化操作环境中执行时,OperatingSystemMXBean 方法将返回容器特定信息(如:系统可用内存、Swap 、Cpu、Load 等),这项能力在基于 JMX 开发的很多能力(如:监控、系统限流等)是一一项特别友好的能力。JDK 14 中涉及到的改造的 API 如下:

``` // Returns the amount of free memory in bytes long getFreeMemorySize();

// Returns the total amount of memory in bytes. long getTotalMemorySize();

// Returns the amount of free swap space in bytes. long getFreeSwapSpaceSize();

// Returns the total amount of swap space in bytes long getTotalSwapSpaceSize();

// Returns the "recent cpu usage" for the operating environment. double getCpuLoad(); ```

2、Single File

我们熟知的 Java 语言程序的执行过程一般情况都需要经过两步:

  1. 首先,使用编译工具将源代码编译成静态的字节码文件,如:执行 javac App.java 执行后会生成一个 App.class 文件。
  2. 然后,再通过使用 java启动命令,配合加上相关的类路径并设置启动的主程序之后开始执行应用程序,如:使用 java -cp . App 的方式执行刚刚编译好的字节码程序。

很多其他的静态语言程序,是直接编译生成一个可执行文件,如:c++/go 等。而对于其他的动态脚本语言,Linux 也提供了 #shebang 这种方式,配合文件的可执行权限,达到简化执行方式的目的。

很显然,Java 的执行方式稍微繁琐,这对于一些习惯使用脚本方式进行运维的同学就不是特别便利,所以长久以来 Java 语言都和运维没有太大的联系。而到了云原生场景下之后,受到 Code Base 和 Admin processes 理念的影响,很多的一次性任务都习惯性的通过 Job/CronJob + Single-file 的方式执行。JDK 11 中发布的 JEP 330 定义了这种能力,补齐了 Java 从源码执行的方式,即如果通过 java App.java 执行,相当于以下两行命令执行的结果:

$ javac App.java $ java -cp . App

同时也支持 Linux 的 shebang 文件,即在脚本文件头中指定文件的执行引擎 ,并给予文件可执行权限后,就能直接执行的脚本的内容,相关脚本方式解释如下:

``` $ cat helloJava

!/path/to/java --source version

// Java Source Code

$ chmod +x helloJava $ ./hellJava ```

3、JDK_JAVA_OPTIONS

在容器环境中,一旦镜像确定,程序行为就只能通过配置的方式进行改变了。这也是符合云原生的要素 Config 的一种设计。但是对于 JVM 程序启动时,由于我们有很多的配置需要通过启动参数进行配置(比如:对内存设置,-D设置系统参数等等)。除非我们在 Dockerfile 编写阶段就支持 JVM 启动命令手动传入相关的环境变量来改变 JVM 的行为,否则这种设计对于 Java 而言就很不友好。好在 JVM 提供了一个系统的环境变量 JAVA_TOOL_OPTIONS,来支持通过读取这个环境变量的值来设置的启动参数的默认值。可是这个参数存在以下的问题:

  1. 不仅针对 java 命令生效:其他的管控命令如:jar, jstack, jmap等也一样会生效。而容器内的进程默认都会读取外部传入的环境变量的值,即一旦设置,这个值会被容器内所有的进程共享,意味着当我们想进入到容器进行一些 java 程序的排查工作时,默认都会受到 JAVA_TOOL_OPTIONS 这个变量的“污染”而得不到预期的结果。
  2. 环境变量的长度限制:无论是在 Linux Shell 内部还是在 Kubernetes 编排的 yaml 中,针对环境变量的长度都不会是无限的,而 JVM 启动参数通常都会很长。所以很多时候会遇到因为 JAVA_TOOL_OPTIONS 的值过长而引起不可预知的行为。

在 JDK 9 中,提供了一个新的环境变量 JDK_JAVA_OPTIONS,它只会支持影响到 java启动命令,不会污染其他命令;同时还支持了通过 export JDK_JAVA_OPTIONS='@file' 的方式从指定的文件读取的相关的内容;从而很好的规避了以上两个问题。

4、ExitOnOutOfMemoryError

OutOfMemoryError 是 Java 程序员最不想遇到的一个场景,因为见到它可能意味着系统中存在一定程度的内存泄露。而且内存泄露的问题一般都需要很繁琐的步骤加上大量精力的进行分析查出来。从发现问题,到定位到这个问题,往往需要耗费的大量的时间和精力。为了保证业务的连续性,如何在发生错误时及时的恢复以止损是我们处理故障时的首要原则;如果系统发生了 OutOfMemoryError,我们往往会选择快速重启进行恢复。

在 Kubernetes 中定义了 Liveness存活探针,让程序员有机会根据业务的健康程度来决定是否需要进行快速重启。因为常见的OutOfMemoryError 常常会伴随着大量的 FullGC,随着 FullGC 引发 CPU/Load 飙高而引发请求时间过长,我们可以根据这一特性,选择合适的业务 API 进行应用健康存活的探测。然而这个方案存在以下一些问题:

  1. 首先,所选择的 API 存在误判的可能性,API 超时可能因为很多的原因引起,内存只是其中一种。
  2. 其次,发生 OutOfMemoryError 错误时不一定全是业务使用的堆内存的问题,如:元数据空间溢出、栈空间溢出、无法创建系统线程等都会有这个错误出现。
  3. 第三,从发生问题到最后探活失败,通常需要经历连续多长时间的重复失败探测才会导致最终的失败。这个过程会有一定的时延。

这个问题在 JDK9 中有了更好的解法,这个版本中引入了额外的系统参数:

  • ExitOnOutOfMemoryError:即遇到 OutOfMemoryError时,JVM 马上退出。
  • CrashOnOutOfMemoryError:除了继承了 ExitOnOutOfMemoryError 的语义之外,同时还会生成 JVM Crash 的日志文件,让程序可以在退出前进行现场的基本保留。
  • OnOutOfMemoryError:可以在此参数后加入一个脚本,配合此脚本,可以在退出前进行一些状态的清理。

以上三个参数在云原生所推崇的 "Fail Fast" 理念中特别的有价值,尤其是在无状态的微服务应用场景(如在 EDAS 中)中,在退出前结合 OnOutOfMemoryError 的脚本做很多优雅下线的工作,同时可以将 JVM Crash 的文件输出到云盘(如:NAS)中。最大限度保障我们的业务因为内存而受到干扰,同时还能保存当时的现场。

5、CDS

云原生应用所践行另外一个理念是应用的快速启动,在 Serverless 的推动下,云厂商都在为应用的冷启动指标努力,Java 应用一直因为初始化时间过长而饱受锆病,在最近的 EDAS 2022 的年度报告中,EDAS 中托管应用 70% 的启动时间要 30 秒以上。如果我们仔细分析,Java 应用启动时间除了应用程序本身的初始化之外,还有 JVM 的初始化过程,而 JVM 的初始化过程中中最长的要数 Class 文件的查找和加载。CDS技术就是为加速 Class 文件启动速度而生,它为 Class-Data Sharing 的简称,即为应用间共享 Class-Data 数据信息的一种技术,原理是利用 Class 文件不会被轻易改变的特点,可以将其中一个进程中产生的 Class 元数据信息直接 dump ,在新启动的实例中进行共享复用。省去每个新实例都需要从 0 开始初始化的开销。

CDS 从 JDK 5 开始就有介绍,不过第一个版本只支持 Bootrap Class Loader 的 Class 共享能力。

到 JDK 10 引入 AppCDS,允许加载应用级别的 Class ;JDK 13 中的 引入了两个 JVM 参数(-XX:ArchiveClassesAtExit=foo.jsa与 -XX:ShareArchiveFile=foo.jsa),结合这个两个参数的使用,可以在程序退出前进行共享文件的动态 dump,在启动时加载;而在 JDK 19 中又简化了运维操作,通过 -XX:+AutoCreateSharedArchive这个参数做到了运行时无需检测共享文件的幂等性,进一步的提升了这项技术的易用性。

更友好的运行时能力

1、Compact Strings

在 Java 内部,我们所有的字符存储都是使用 char 类型 2 个字节(16个字节)来进行存储,官方从很多不同的线上 Java 应用中曾经分析过,JVM 内部的堆的消耗主要是字符串的使用。然而大部分的字符串仅仅存储了一个拉丁字符,即 1 个字节就能完整表示。所以理论上,绝大多数的字符串只需要一半的空间就能完成存储和表示。

从 JDK9 开始,JDK 中关于字符串的默认实现(java.lang.String, AbstractStringBuilder, StringBuilder, StringBuffer)的内部实现上,默认集成了这种机制,这个机制根据字符串的内容,自动编码成 一个字节的 ISO-8859-1/Latin-1或 两个字节的 UTF-16,从而大幅减少堆内存的使用量。更小的堆使用同时也减少了 GC 次数,从而系统性的提升了整个系统的性能。

字符串压缩 JDK 从 1.6 就开始探索,当时在 JVM 参数层面提供了一个非开源 UseCompressedStrings的开关来实现,打开之后它将通过改变存储结构(byte[]或 char[])来达到压缩的目的,由于这种方式只修改了 String类的实现,没有系统性的梳理其他字符串使用的场景,在实验的过程中引发了一些不可预知的问题,后来在 JDK7 中被抹除。

2、Active Processor Count

Active Processor Count 是指获取 JVM 进程能利用上的 CPU 核数,对应 JDK 中的 API 是 Runtime.getRuntime().availableProcessors(),常见于一些系统线程和 I/O(如:JVM 内默认的 GC 线程数、JIT 编译线程数、某些框架的 I/O 、 ForJoinPool 等)的场景中,我们会习惯性的将的线程个数设置成 JVM 能获取到的这个数。然而一开始的默认实现是通过读取 /proc/cpuinfo文件系统下的 CPU 数据来设置。容器场景中如果不做特殊默认读取到的是宿主机的 CPU 信息。而容器场景下,通过 cgroup 的隔离机制,我们其实可以给容器设置一个远小于所在机器的真实核数。比如如果我们在一台 4 核的机器上,在一个只设置了 2 个核的容器跑一个 JVM 程序的话,它获得的数据是 4,而不是期望的 2。

容器内的资源感知不仅仅是 CPU 这一项,比较著名的版本是 JDK 8u191,这个版本中除了 CPU 之外,还增加了对于内存最大值的获取、宿主机上对于容器内 JVM 进程的 attach (jstack/jcmd 命令等) 的优化等。在 CPU 的改进点上,主要是做了以下两点增强:

  1. 首先:新增了一个启动参数 -XX:ActiveProcessorCount,可以显示的指定处理器的数量。
  2. 其次:根据 CGroup 文件系统进行自动的探测,其中自动探测的相关变量有 3 个,1)CPU Set(直接以绑核的方式进行 CPU 分配);2)cpu.shares ;3)cfs_quota+ cfs_period。其中的在 Kubernetes 场景下,默认优先级是 1) > 2) > 3)。

这里大家可能会有一个疑问,为什么在 Kubernetes 场景中会带来问题?比如我们通过以下的配置来设置一个 POD 的资源使用情况:

resources: limits: cpu: "4" requests: cpu: "2"

以上的配置表示这个 POD 最多能用 4 个核,而向系统申请的资源则是 2 个核。在 Kubernetes 内部,CPU limit 部分最终是使用 CFS (quota + period) 的方式进行表示,而 CPU request 部分最终是通过 cpu.shares来设置(具体 kubernetes 是如何进行的 cgroup 映射,不再本篇的叙述范围)。则此时场景下,默认通过Runtime.getRuntime().availableProcessors()能获取到的核数就是 2。而不是我们预期中的 4。

如何避免这个问题?第一个最为简单的方式,就是默认通过 -XX:ActiveProcessorCount显示进行 CPU 的传递,当然这里带来一点点需要重写启动命令上的运维动作。JVM 在 JDK19 中,默认去掉了根据 cpu.shares 来进行计算的逻辑,同时新增了一个启动参数 -XX:+UseContainerCpuShares来兼容之前的行为。

3、JEP 380: Unix domain sockets

Unix domain socket (简称:UDS)是一种在 Unix 系列的系统之下解决同一台机器中进程间(IPC)通信的一种方式。在很多方面,他的使用方式和 TCP/IP 类似,如:针对 Socket 的读写行为、链接的接收与建立等。但是也有诸多的不同,比如它没有实际的 IP 和端口,他不需要走一个 TCP/IP 的全栈解析和转发。同时相比较直接使用 127.0.0.1的方式进行传输,还有以下两个显而易见的优点:

  1. 安全:UDS 是一种严格在本机内进程间进行通信的设计,它不能接受任何远程访问,所以它从设计上久避免了非本机进程的干扰。同时它的权限控制也能直接使用到 Unix 中基于文件的权限访问控制,从而从系统角度大大增强安全性。
  2. 性能:虽然通过 127.0.0.1 进行 Loopback 的访问方式在协议栈上做了很多优化,但是从本质上它还是一种 Socket 的通信方式,即他还是需要进行三次握手、协议栈的拆包解包、受系统缓冲区的制约等。而 UDS 的链接建立无需那么复杂,且数据传输上也不需要经过内核层面的多次拷贝,传输数据的逻辑逻辑简化到:1)寻找对方的 Socket 。2)直接将数据放给对方的收消息的缓冲区。这样简练的设计,相比 Loopback 在小数据量发送的场景下效率高了一倍以上。

在 Java 中,一直没有支持对 UDS 的支持,但是到了 JDK 16 这一局面将迎来改观,但是为什么 Java 到现在才加入对 UDS 的支持呢?原因我觉得还是云原生场景的冲击。在 Kubnernetes 的场景下,在一个 POD 内编排多个容器一起使用的方式(sidecar 模式)将会变的越来越流行,在同一个 POD 内部的多个容器中进行数据传输时,因为默认都是在同一命名空间的文件系统下,UDS 的加入会大大提升同一个  POD 内容器间数据传输的效率。

结语

本篇主要从运维和运行时上进行解读,下一篇我们来讲讲内存。如果有感兴趣的内容,欢迎留言或加入钉群:21958624 与我们进行沟通与交流;预祝大家新春快乐、阖家幸福、“兔”飞猛进!