关于java:云原生时代的Java应用优化实践

5次阅读

共计 8863 个字符,预计需要花费 23 分钟才能阅读完成。

Java 从诞生至今曾经走过了 26 年,在这 26 年的工夫里,Java 利用从未停下脚步,从最开始的单机版到 web 利用再到当初的微服务利用,依附其弱小的生态,它依然占据着当今语言之争的“天下第一”的宝座。但在现在的云原生 serverless 时代,Java 利用却遭逢到了前所未有的挑战。

在云原生时代,云原生技术利用各种私有云、公有云和混合云等新型动静环境,构建和运行可弹性扩大的利用。而咱们利用也越来越呈现出以下特点:

  • 基于容器镜像构建

Java 诞生之初,靠着“一次编译,到处运行”的口号,以语言层虚拟化的形式,在那个操作系统平台尚不对立的年代,建设起了劣势。但现在步入云原生时代,以 Docker 为首的容器技术同样提出了“一次构建,到处运行”的口号,通过操作系统虚拟化的形式,为应用程序提供了环境兼容性和平台无关性。因而,在云原生时代的明天,Java“一次编译,到处运行”的劣势,曾经被容器技术大幅度地减弱,不再是大多数服务端开发者技术选型的次要思考因素了。此外,因为是基于镜像,云原生时代对镜像大小能够说是非常敏感,而蕴含了 JDK 的 Java 利用动辄几百兆的镜像大小,无疑是越来越不合乎时代的要求。

  • 生命周期缩短,并常常须要弹性扩缩容

灵便和弹性能够说是云原生利用的一个显著个性,而这也意味着利用须要具备更短的冷启动工夫,以应答灵便弹性的要求。Java 利用往往面向长时间大规模程序而设计,JVM 的 JIT 和分层编译优化技术,会使得 Java 利用在一直的运行中进行自我优化,并在一段时间后达到性能高峰。但与运行性能相同,Java 利用往往有着迟缓的启动工夫。风行的框架(例如 Spring)中大量的类加载、字节码加强和初始化逻辑,更是减轻了这一问题。这无疑是与云原生时代的理念是相悖的。

  • 对计算资源用量敏感

进入私有云时代,利用往往是按用量付费,这意味着利用所须要的计算资源就变的非常重要。Java 利用固有的内存占用多的劣势,在云原生时代被放大,绝对于其余语言,应用起来变得更加“低廉”。

由此可见,在云原生时代,Java 利用的劣势正在一直被鲸吞,而劣势却在一直的被放大。因而,如何让咱们的利用更加顺应时代的倒退,使 Java 语言能在云原生时代施展更大的价值,就成了一个值得探讨的话题。为此,笔者将尝试跳出语言比照的固有思路,为大家从一个更全局的角度,来看看在云原生利用公布的全流程中,咱们都可能做哪些优化。

镜像构建优化

Dockerfile

从 Dockerfile 说起是因为它是最根底的,也是最简略的优化,它能够简略的放慢咱们的利用构建镜像和拉取镜像的工夫。

以一个 Springboot 利用为例,咱们通常会看到这种样子的 Dockerfile:

FROM openjdk:8-jdk-alpine
COPY app.jar /
ENTRYPOINT ["java","-jar","/app.jar"]

足够简略清晰,但很显然,这并不是一个很好的 Dockerfile,因为它没有利用到 Image layer 去进行效率更高的缓存。

咱们都晓得,Docker 领有足够高效的缓存机制,但如果不好好的利用这一个性,而是简略的将 Jar 包打成繁多 layer 镜像,就会导致,即便利用只改变一行代码,咱们也须要从新构建整个 Springboot Jar 包,而这其中 Spring 的宏大依赖类库其实都没有产生过更改,这无疑是一种得失相当的做法。因而,将利用的所有依赖库作为一个独自的 layer 显然是一个更好的计划。

因而,一个更正当的 Dockerfile 应该长这个样子:

FROM openjdk:8-jdk-alpine
ARG DEPENDENCY=target/dependency
COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY ${DEPENDENCY}/META-INF /app/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","HelloApplication"]

这样,咱们就能够充分利用 Image layer cache 来放慢构建镜像和拉取镜像的工夫。

构建组件

在 Docker 占有镜像构建的相对话语权的明天,咱们在理论开发过程中,往往会漠视构建组件的抉择,但事实上,抉择一个高效的构建组件,往往能使咱们的构建效率事倍功半。

传统的 docker build 存在哪些问题?

在 Docker v18.06 之前的 docker build 会存在一些问题:

  • 扭转 Dockerfile 中的任意一行,就会使之后的所有行的缓存生效
# 假如只扭转此 Dockerfile 中的 EXPOSE 端口号
# 那么接下来的 RUN 命令的缓存就会生效
FROM debian
EXPOSE 80
RUN apt update && apt install –y HEAVY-PACKAGES
  • 多阶段并行构建效率不佳
# 即便 stage0 和 stage1 之间并没有依赖
# docker 也无奈并行构建,而是抉择串行
FROM openjdk:8-jdk AS stage0
RUN ./gradlew clean build

FROM openjdk:8-jdk AS stage1
RUN ./gradlew clean build

FROM openjdk:8-jdk-alpine
COPY --from=stage0 /app-0.jar /
COPY --from=stage1 /app-1.jar /
  • 无奈提供编译历史缓存
# 单纯的 RUN 命令无奈提供编译历史缓存
# 而 RUN --mount 的新语法在旧版本 docker 下无奈反对
RUN ./gradlew build
# since Docker v18.06
# syntax = docker/dockerfile:1.1-experimental
RUN --mount=type=cache,target=/.cache ./gradlew build
  • 镜像 push 和 pull 的过程中存在压缩和解压的固有耗时

如上图所示,在传统的 docker pull push 阶段,存在着 pack 和 unpack 的耗时,而这一部分并非必须的,针对这些固有的弊病,业界也始终在踊跃的探讨,并诞生了一些能够适应新时代的构建工具。

新一代构建组件:

在最佳的新一代构建工具抉择上,是一个没有银弹的话题,但通过一些简略的比照,咱们仍能选出一个最适宜的构建工具,咱们认为,一个适宜云原生平台的构建工具应该至多具备以下几个特点:

  • 可能反对残缺的 Dockerfile 语法,以便利用平顺迁徙;
  • 可能补救上述传统 Docker 构建的毛病;
  • 可能在非 root privilege 模式下执行(在基于 Kubernetes 的 CICD 环境中显得尤为重要)。

因而,Buildkit 就怀才不遇,这个由 Docker 公司开发,目前由社区和 Docker 公司正当保护的“含着金钥匙出世”的新一代构建工具,领有良好的扩展性、极大地提高了构建速度,并提供了更好的安全性。Buildkit 反对全副的 Dockerfile 语法,能更高效的命中构建缓存,增量的转发 build context,多并发间接推送镜像层至镜像仓库。


(Buildkit 与其余构建组件的比照)


(Buildkit 的构建效率)

镜像大小

为了在拉取和推送镜像过程中更高的管制耗时,咱们通常会尽可能的缩小镜像的大小。

Alpine Linux 是许多 Docker 容器首选的根底镜像,因为它只有 5 MB 大小,比起其余 Cent OS、Debain 等动辄一百多 MB 的发行版来说,更适宜用于容器环境。不过 Alpine Linux 为了尽量瘦身,默认是用 musl 作为 C 规范库的,而非传统的 glibc(GNU C library),因而要以 Alpine Linux 为根底制作 OpenJDK 镜像,必须先装置 glibc,此时根底镜像大概有 12 MB。

在 JEP 386 中,OpenJDK 将上游代码移植到 musl,并通过兼容性测试。这一个性曾经在 Java 16 中公布。这样制作进去的镜像仅有 41MB,不仅远低于 Cent OS 的 OpenJDK(大概 396 MB),也要比官网的 slim 版(约 200MB)要小得多。

利用启动减速

让咱们首先来看一下,一个 Java 利用在启动过程中,会有哪些阶段

这个图代表了 Java 运行时各个阶段的生命周期,能够看到它要通过五个阶段,首先是 VM init 虚拟机的初始化阶段,而后是 App init 利用的初始化阶段,再通过 App active(warmup)的利用预热期间,在预热一段时间后进入 App active(steady)达到性能巅峰期,最初利用完结实现整个生命周期。

应用 AppCDS

从下面的图中,咱们不难发现,蓝色的 CL(ClassLoad)局部,理论长占用了 Java 利用启动的阶段的一大部分工夫。而 Java 也始终在致力于缩小利用启动的 ClassLoad 工夫。

从 JDK 1.5 开始,HotSpot 就提供了 CDS(Class Data Sharing)性能,很长一段时间以来,它的性能都十分无限,并且只有局部商业化。晚期的 CDS 致力于,在同一主机上的 JVM 实例之间“共享”同样须要加载一次的类,然而遗憾的是晚期的 CDS 不能解决由 AppClassloader 加载的类,这使得它在理论开发实际中,显得比拟“鸡肋”。

但在从 OpenJDK 10 (2018) 开始,AppCDS【JEP 310】在 CDS 的根底上,退出了对 AppClassloader 的适配,它的呈现,使得 CDS 技术变得宽泛可用并且更加实用。尤其是对于动辄须要加载数千个类的 Spring Boot 程序,因为 JVM 不须要在每个实例的每次启动时加载(解析和验证)这些类,因而,启动应该变得更快并且内存占用应该更小。看起来,AppCDS 的所有都很美妙,但理论应用也的确如此吗?

当咱们试图应用 AppCDS 时,它应该蕴含以下几个步骤:

  • 应用 -XX:DumpLoadedClassList 参数来获取咱们心愿在应用程序实例之间共享的类;
  • 应用 -Xshare:dump 参数将类存储到适宜内存映射的存档 (.jsa 文件) 中;
  • 应用 -Xshare:on 参数在启动时将存档附加到每个应用程序实例。

乍一看,应用 AppCDS 仿佛很容易,只需 3 个简略的步骤。然而,在理论应用过程中,你会发现每一步都可能变成一次带有特定 JVM Options 的利用启动,咱们无奈简略的通过一次启动来取得可重复使用的类加载存档文件。只管在 JDK 13 中,提供了新的动静 CDS【JEP 350】,来将上述步骤 1 和步骤 2 合并为一步。但在目前风行的 JDK 11 中,咱们依然逃不开上述三个步骤(三次启动)。因而,应用 AppCDS 往往意味着对利用的启动过程进行简单的革新,并随同着更为漫长的首次编译和启动工夫。

同时须要留神的是,在应用 AppCDS 时,许多利用的类门路将会变得更加凌乱:它们既位于原来的地位(JAR 包)中,同时又位于新的共享存档(.jsa 文件)中。在咱们利用开发的过程中,咱们会一直更改、删除原来的类,而 JVM 会从新的类中进行解析。这种状况所带来的危险是不言而喻的:如果类归档文件放弃不变,那么类不匹配是迟早的事,咱们会遇到典型的“Classpath Hell”问题。

JVM 无奈阻止类的变动,但它至多应该可能在适当的时候检测到类不匹配。然而,在 JVM 的实现中,并没有检测每一个独自的类,而是抉择去比拟整个类门路,因而,在 AppCDS 的官网形容中,咱们能够找到这样一句话:

The classpath used with -Xshare:dump must be the same as, or be a prefix of, the classpath used with -Xshare:on. Otherwise, the JVM will print an error message

即第二部步归档文件创立时应用的类门路必须与运行时应用的类门路雷同(或前者是后者的前缀)。

但这是一个相当含混的陈说,因为类门路可能以几种不同的形式造成,例如:

  • 从带有 Jar 包的目录中间接加载.class 文件,例如java com.example.Main
  • 应用通配符,扫描带有 Jar 包的目录,例如java -cp mydir/* com.example.Main
  • 应用明确的 Jar 包门路,例如java -cp lib1.jar:lib2.jar com.example.Main

在这些形式中,AppCDS 惟一反对的形式只有第三种,即是显式列出 Jar 包门路。这使得那些应用了大规模 Jar 包依赖的利用的启动语句变得非常繁琐。

同时,咱们也要必须留神到,这种显式列出 Jar 包门路的形式并不会进行递归查找,即它只会在蕴含所有 class 文件的 FatJar 中失效。这意味着应用 SpringBoot 框架的嵌套 Jar 包构造,将很难利用 AppCDS 技术所带来的便当。

因而,SpringBoot 如果想在云原生环境中应用 AppCDS,就必须进行利用侵入性的革新,不去应用 SpringBoot 默认的嵌套 Jar 启动构造,而是用相似 maven shade plugin 从新打 FatJar,并在程序中显示的申明能让程序天然敞开的接口或参数,通过 Volume 挂载或者 Dockerfile 革新的形式,来存储和加载类的归档文件。这里给出一个革新过的 Dockerfile 的示例:

# 这里假如咱们曾经做过 FatJar 革新,并且 Jar 包中蕴含利用运行所需的全副 class 文件
FROM eclipse-temurin:11-jre as APPCDS

COPY target/helloworld.jar /helloworld.jar

# 运行利用,同时设置一个 '--appcds' 参数使程序在运行后可能进行
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# 应用上一步失去的 class 列表来生成类归档文件
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM eclipse-temurin:11-jre

# 同时复制 Jar 包和类归档文件
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# 应用 -Xshare:on 参数来启动利用
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

由此可见,应用 AppCDS 还是要付出相当多的学习和革新老本的,并且许多革新都会对咱们的利用产生入侵。

JVM 优化

除了构建阶段和启动阶段,咱们还能够从 JVM 自身动手,依据云原生环境的特点,进行针对性的优化。

应用能够感知容器内存资源的 JDK

在虚拟机和物理机中,对于 CPU 和内存调配,JVM 会从常见地位(例如,Linux 中的 /proc/cpuinfo/proc/meminfo)查找其能够应用的 CPU 和内存。然而,在容器中运行时,CPU 和内存限度条件存储在 /proc/cgroups/... 中。较旧版本的 JDK 会持续在/proc(而不是/proc/cgroups)中查找,这可能会导致 CPU 和内存用量超出调配的下限,并因而引发多种重大的问题:

  • 线程过多,因为线程池大小由 Runtime.availableProcessors() 配置
  • JVM 的对内存应用超出容器内存下限。并导致容器被 OOMKilled。

JDK 8u131 首先实现了 UseCGroupMemoryLimitForHeap 的参数。但这个参数存在缺点,为利用增加 UnlockExperimentalVMOptionsUseCGroupMemoryLimitForHeap参数后,JVM 的确能够感知到容器内存,并管制利用的理论堆大小。然而这并没有充分利用咱们为容器调配的内存。

因而 JVM 提供 -XX:MaxRAMFraction 标记来帮忙更好的计算堆大小,MaxRAMFraction默认值是 4(即除以 4),但它是一个分数,而不是一个百分比,因而很难设置一个能无效利用可用内存的值。

JDK 10 附带了对容器环境的更好反对。如果在 Linux 容器中运行 Java 应用程序,JVM 将应用 UseContainerSupport 选项自动检测内存限度。而后,通过 InitialRAMPercentageMaxRAMPercentageMinRAMPercentage来进行对内存管制。这时,咱们应用的是百分比而不是分数,这将更加精确。

默认状况下,UseContainerSupport参数是激活的,MaxRAMPercentage是 25%,MinRAMPercentage是 50%。

须要留神的是,这里 MinRAMPercentage 并不是用来设置堆大小的最小值,而是仅当物理服务器(或容器)中的总可用内存小于 250MB 时,JVM 将用此参数来限度堆的大小。

同理,MaxRAMPercentage是当物理服务器(或容器)中的总可用内存大小超过 250MB 时,JVM 将用此参数来限度堆的大小。

这几个参数曾经向下移植到 JDK 8u191。UseContainerSupport 默认状况下是激活的。咱们能够设置 -XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=80.0 来 JVM 感知并充分利用容器的可用内存。须要留神的是,在指定 -Xms -Xmx 时,InitialRAMPercentageMaxRAMPercentage 将会生效。

敞开优化编译器

默认状况下,JVM 有多个阶段的 JIT 编译。尽管这些阶段能够逐步进步利用的效率,但它们也会减少内存应用的开销,并减少启动工夫。

对于短期运行的云原生利用,能够思考应用以下参数来敞开优化阶段,以就义长期运行效率来换取更短的启动工夫。

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

敞开类验证

当 JVM 将类加载到内存中以供执行时,它会验证该类未被篡改并且没有歹意批改或损坏。但在云原生环境,CI/CD 流水线通常也由云原生平台提供,这示意咱们的利用的编译和部署是可信的,因而咱们应该思考应用以下参数敞开验证。如果在启动时加载大量类,则敞开验证可能会进步启动速度。

JAVA_TOOL_OPTIONS="-noverify"

减小线程栈大小

大多数 Java Web 利用都是基于每个连贯一个线程的模式。每个 Java 线程都会耗费本机内存(而不是堆内存)。这称为线程栈,并且每个线程默认为 1 MB。如果您的利用解决 100 个并发申请,则它可能至多有 100 个线程,这相当于应用了 100MB 的线程栈空间。该内存不计入堆大小。咱们能够应用以下参数来减小线程栈大小。

JAVA_TOOL_OPTIONS="-Xss256k"

须要留神如果减小得太多,则将呈现java.lang.StackOverflowError。您能够对利用进行剖析,并找到要配置的最佳线程栈大小。

应用 TEM 进行零革新的 Java 利用云原生优化

通过下面的剖析,咱们能够看出,如果想要让咱们的 Java 利用能在云原生时代施展出最大实力,是须要付出许多侵入性的革新和优化操作的。那么有没有一种形式可能帮忙咱们零革新的发展 Java 利用云原生优化?

腾讯云的 TEM 弹性微服务就为宽广 Java 开发者提供了一种利用零革新的最佳实际,帮忙您的 Java 利用以最优姿势疾速上云。应用 TEM 您能够享受的以下劣势:

  • 零构建部署。间接抉择应用 Jar 包 /War 包交付,无需自行构建镜像。TEM 默认提供能充分利用构建缓存的构建流程,应用新一代构建利器 Buildkit 进行高速构建,构建速度优化 50% 以上,并且整个构建流程可追溯,构建日志可查,简略高效。


(间接应用 Jar 包部署)


(构建日志可查)


(构建速度比照)

  • 零革新减速。间接应用 KONA Jdk 11/Open Jdk 11 进行利用减速,并且默认反对 SpringBoot 利用零革新减速。您无需革新原有的 SpringBoot 嵌套 Jar 包构造,TEM 将间接提供 Java 利用减速的最佳实际,实例扩容时的启动工夫将缩短至 10%~40%。


(不应用利用减速,规格 1c2g)


(应用利用减速,规格 1c2g)


(利用启动速度比照,以 spring petclinic 为例,规格 1c2g)

  • 零运维监控。应用 SkyWalking 为您的 Java 利用进行利用级别的监控,您能够直观的查看 JVM 堆内存,GC 次数 / 耗时,接口 RT/QPS 等要害参数,帮忙您即便找到利用性能瓶颈。


(利用 JVM 监控)

  • 极致弹性。TEM 默认提供使用率较高的定时弹性策略和基于资源的弹性策略,为您的利用提供秒级的弹性性能,帮忙您应答流量洪峰,并能在实例闲置时及时节俭资源。


(指标弹性策略)


(定时弹性策略)

总结

工欲善其事,必先利其器。在步入云原生时代的明天,如何让您的 Java 利用的部署效率和运行性能最大化,这对所有开发者都是一个挑战。而 TEM 作为一款面向微服务利用的 Serverless PaaS 平台,将成为您手中的“云端利器”,TEM 将致力于为企业和开发者服务,帮忙您的业务以最疾速、便捷、省心的姿势,无忧上云,享受云原生时代的便当。

参考文档

http://seanthefish.com/2020/1…
https://www.infoq.cn/article/…
https://medium.com/@toparvion…
https://events19.linuxfoundat…
https://static.sched.com/host…

正文完
 0