本文主要介绍JVM基本知识及JVM调优简介和实战案例
JVM基本知识
什么是 JVM
JVM 全称是 Java Virtual Machine,中文称为 Java 虚拟机。
JVM 是 Java 程序运行的底层平台,与 Java 支持库一起构成了 Java 程序的执行环境。
分为 JVM 规范和 JVM 实现两个部分。简单来说,Java 虚拟机就是指能执行标准 Java 字节码的虚拟计算机。
JVM相关知识
JDK 与 JVM 有什么区别?
现在的 JDK、JRE 和 JVM 一般是整套出现的。
- JDK = JRE + 开发调试诊断工具
- JRE = JVM + Java 标准库
JVM领域
从上图可以看到,JVM 可以划分为这些部分:
- 执行引擎,包括:GC、JIT 编译器
- 类加载子系统,这部分的问题,一般在开发过程中出现
- JNI 部分,这部分问题一般在 JVM 之外
- 运行时数据区;Java 将内存分为 2 大块:堆内存和栈内存
线上环境的JVM问题主要集中在 GC 和内存部分。而栈内存、线程分析等问题,主要是辅助诊断 Java 程序本身的问题。
JVM内存
JVM 的内存设置是最重要的参数设置,也是 GC 分析和调优的重点。
JVM 总内存=堆+栈+非堆+堆外内存。
相关的参数:
-Xmx
, 指定最大堆内存。 如-Xmx4g
. 这只是限制了 Heap 部分的最大值为 4g。这个内存不包括栈内存,也不包括堆外使用的内存。-Xms
, 指定堆内存空间的初始大小。 如-Xms4g
。 而且指定的内存大小,并不是操作系统实际分配的初始值,而是 GC 先规划好,用到才分配。 专用服务器上需要保持-Xms
和-Xmx
一致,否则应用刚启动可能就有好几个 FullGC。当两者配置不一致时,堆内存扩容可能会导致性能抖动。-Xmn
, 等价于-XX:NewSize
,使用 G1 垃圾收集器 不应该 设置该选项,在其他的某些业务场景下可以设置。官方建议设置为-Xmx
的1/2 ~ 1/4
。-XX:MaxPermSize=size
, 这是 JDK1.7 之前使用的。Java8 默认允许的 Meta 空间无限大,此参数无效。-XX:MaxMetaspaceSize=size
, Java8 默认不限制 Meta 空间, 一般不允许设置该选项。XX:MaxDirectMemorySize=size
,系统可以使用的最大堆外内存,这个参数跟-Dsun.nio.MaxDirectMemorySize
效果相同。-Xss
, 设置每个线程栈的字节数。 例如-Xss1m
指定线程栈为 1MB,与-XX:ThreadStackSize=1m
等价
这里要特别说一下堆外内存,也就是说不在堆上的内存,我们可以通过jconsole,jvisualvm 等工具查看。
“Direct Memory”,一般来说是 Java NIO 使用的 Direct-X-Buffer(例如 DirectByteBuffer)所分配的 native memory,这个地方如果我们使用 netty 之类的框架,会产生大量的堆外内存。
常用的 JVM 配置参数
1 | 设置堆内存 |
常用属性配置
1 | 指定默认的连接超时时间 |
排查问题常用shell操作
1 | 查看当前路径 |
配置多少 xmx 合适
从上面的分析可以看到,系统有大量的地方使用堆外内存,远比我们常说的 xmx 和 xms 包括的范围要广。所以我们需要在设置内存的时候留有余地
实际上,比较推荐配置系统或容器里可用内存的 70-80% 最好。比如说系统有 8G 物理内存,系统自己可能会用掉一点,大概还有 7.5G 可以用,那么建议配置
-Xmx6g 说明:xmx : 7.5G*0.8 = 6G,如果知道系统里有明确使用堆外内存的地方,还需要进一步降低这个值。
xmx 和 xms 是不是要配置成一致的
一般情况下,我们的服务器是专用的,就是一个机器(也可能是云主机或 docker 容器)只部署一个 Java 应用,这样的时候建议配置成一样的,好处是不会再动态去分配,如果内存不足(像上面的情况)上来就知道。
GC 日志相关的参数
在生产环境或性能压测环境里,我们用来分析和判断问题的重要数据来源之一就是 GC 日志,JVM 启动参数为我们提供了一些用于控制 GC 日志输出的选项。
-verbose:gc
:和其他 GC 参数组合使用, 在 GC 日志中输出详细的GC信息。 包括每次 GC 前后各个内存池的大小,堆内存的大小,提升到老年代的大小,以及消耗的时间。此参数支持在运行过程中动态开关。比如使用 jcmd, jinfo, 以及使用 JMX 技术的其他客户端。-XX:+PrintGCDetails
和-XX:+PrintGCTimeStamps
:打印 GC 细节与发生时间。请关注我们后续的 GC 课程章节。-Xloggc:file
:与-verbose:gc
功能类似,只是将每次 GC 事件的相关情况记录到一个文件中,文件的位置最好在本地,以避免网络的潜在问题。若与 verbose:gc 命令同时出现在命令行中,则以 -Xloggc 为准。
示例:
1 | export JAVA_OPTS="-Xms28g -Xmx28g -Xss1m \ |
指定垃圾收集器相关参数
垃圾回收器是 JVM 性能分析和调优的核心内容之一,也是近几个 JDK 版本大力发展和改进的地方。通过不同的 GC 算法和参数组合,配合其他调优手段,我们可以把系统精确校验到性能最佳状态。
以下参数指定具体的垃圾收集器,详细情况会在第二部分讲解:
-XX:+UseG1GC
:使用 G1 垃圾回收器-XX:+UseConcMarkSweepGC
:使用 CMS 垃圾回收器-XX:+UseSerialGC
:使用串行垃圾回收器-XX:+UseParallelGC
:使用并行垃圾回收器
JVM调优
运行一个 Java 应用程序,我们必须要先安装 JDK 或者 JRE 包。这是因为 Java 应用在编译后会变成字节码,然后通过字节码运行在 JVM 中,而 JVM 是 JRE 的核心组成部分。JVM 不仅承担了 Java 字节码的分析(JIT compiler)和执行(Runtime),同时也内置了自动内存分配管理机制。这个机制可以大大降低手动分配回收机制可能带来的内存泄露和内存溢出风险,使 Java 开发人员不需要关注每个对象的内存分配以及回收,从而更专注于业务本身。
JVM内存模型
JVM 自动内存分配管理机制的好处很多,但实则是把双刃剑。这个机制在提升 Java 开发效率的同时,也容易使 Java 开发人员过度依赖于自动化,弱化对内存的管理能力,这样系统就很容易发生 JVM 的堆内存异常,垃圾回收(GC)的方式不合适以及 GC 次数过于频繁等问题,这些都将直接影响到应用服务的性能。
因此,要进行 JVM 层面的调优,就需要深入了解 JVM 内存分配和回收原理,这样在遇到问题时,我们才能通过日志分析快速地定位问题;也能在系统遇到性能瓶颈时,通过分析 JVM 调优来优化系统性能。
JVM 内存模型的具体设计
在 Java 中,JVM 内存模型主要分为堆、程序计数器、方法区、虚拟机栈和本地方法栈。
堆(Heap)
堆是 JVM 内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。
在 Java6 版本中,永久代在非堆内存区;到了 Java7 版本,永久代的静态变量和运行时常量池被合并到了堆中;而到了 Java8,永久代被元空间取代了。 结构如下图所示:
程序计数器(Program Counter Register)
程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
方法区(Method Area)
很多开发者都习惯将方法区称为“永久代”,其实这两者并不是等价的。
HotSpot 虚拟机使用永久代来实现方法区,但在其它虚拟机中,例如,Oracle 的 JRockit、IBM 的 J9 就不存在永久代一说。因此,方法区只是 JVM 中规范的一部分,可以说,在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。
方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息。
JVM 在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。在加载类的时候,JVM 会先加载 class 文件,而在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。
字面量包括字符串(String a=“b”)、基本类型的常量(final 修饰的变量),符号引用则包括类和方法的全限定名(例如 String 这个类,它的全限定名就是 Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。
而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。
例如,类中的一个字符串常量在 class 文件中时,存放在 class 文件常量池中的;在 JVM 加载完类之后,JVM 会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class 文件中常量池多个相同的字符串在运行时常量池只会存在一份。
方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地
虚拟机栈(VM stack)
Java 虚拟机栈是线程私有的内存空间,它和 Java 线程一起创建。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用来保存方法的局部变量、操作数栈、动态链接方法和返回地址等信息,并参与方法的调用和返回。每一个方法的调用都伴随着栈帧的入栈操作,方法的返回则是栈帧的出栈操作。
本地方法栈(Native Method Stack)
本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的。
JVM 的运行原理
接下来,通过一个案例来了解下代码和对象是如何分配存储的,Java 代码又是如何在 JVM 中运行的。
1 |
|
当我们通过 Java 运行以上代码时,JVM 的整个处理过程如下:
JVM 向操作系统申请内存,JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配。
JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
class 文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值。
完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM 首先会执行构造器 方法,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 () 方法。
执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中
此时再次创建一个 JVMCase 对象,调用 sayHello 非静态方法,sayHello 方法属于对象 JVMCase,此时 sayHello 方法入栈,并通过栈中的 student 引用调用堆中的 Student 对象;之后,调用静态方法 print,print 静态方法属于 JVMCase 类,是从静态方法中获取,之后放入到栈中,也是通过 student 引用调用堆中的 student 对象。
了解完实际代码在 JVM 中分配的内存空间以及运行原理,相信会更加清楚内存模型中各个区域的职责分工。
可以使用不同的引用类型,改变一个对象的正常生命周期,从而提高 JVM 的回收效率,这也是 JVM 性能调优的一种方式
JVM调优案例
背景:老年代空间的使用量在缓慢上升,并没有下降,引发最大暂停时间的这个点并没有发生 FullGC。
从监控图可以看到老年代对应的内存池是 “ps_old_gen”,通过前面的学习,我们知道,ps 代表的是 ParallelGC 垃圾收集器。
JDK 8默认并行垃圾收集器, ParallelGC 为了最大的系统处理能力,即吞吐量,而牺牲掉了单次的暂停时间,导致暂停时间会比较长
为什么有怀疑呢,因为 Datadog 这个监控系统,默认 10s 上报一次数据。有可能在这 10s 内发生些什么事情但是被漏报了(当然,这是不可能的,如果上报失败会在日志系统中打印相关的错误)。
再分析上面这个图,可以看到老年代对应的内存池是 “ps_old_gen”,通过前面的学习,我们知道,ps 代表的是 ParallelGC 垃圾收集器。
JVM 启动参数
查看 JVM 的启动参数,发现是这样的:
1 | -Xmx4g -Xms4g |
我们使用的是 JDK 8,启动参数中没有指定 GC,确定这个服务使用了默认的并行垃圾收集器。
于是怀疑问题出在这款垃圾收集器上面,因为很多情况下 ParallelGC 为了最大的系统处理能力,即吞吐量,而牺牲掉了单次的暂停时间,导致暂停时间会比较长。
使用 G1 垃圾收集器
换成 G1,毕竟现在新版本的 JDK 8 中 G1 很稳定,而且性能不错。
然后换成了下面的启动参数:
1 | -Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50 |
运行一段时间后,最大 GC 暂停时间达到了 1300ms。
情况似乎更恶劣了。
进一步查看日志信息:
1 | { |
这次不是 FullGC,而是年轻代 GC,而且暂停时间达到了 1869ms。 一点道理都不讲,我认为这种情况不合理,而且观察 CPU 使用量也不高。
打印 GC 日志
修改打印 GC 日志启动参数如下:
1 | -Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50 |
重新启动,希望这次能排查出问题的原因。
运行一段时间,又发现了超长的暂停时间。
分析 GC 日志
因为不涉及敏感数据,那么我们把 GC 日志下载到本地进行分析。
定位到这次暂停时间超长的 GC 事件,关键的信息如下所示:
1 | Java HotSpot(TM) 64-Bit Server VM (25.162-b12) for linux-amd64 JRE (1.8.0_162-b12), |
前后的 GC 事件都很正常,也没发现 FullGC 或者并发标记周期,但找到了几个可疑的点:
physical 144145548k(58207948k free)
:JVM 启动时,物理内存 137GB,空闲内存 55GB。[Parallel Time: 1861.0 ms, GC Workers: 48]
:垃圾收集器工作线程 48 个。
分析 GC 日志:
user=1.67
:用户线程耗时 1.67s;sys=14.00
:系统调用和系统等待时间 14s;real=1.87 secs
:实际暂停时间 1.87s;- GC 之前,年轻代使用量 2GB,堆内存使用量 3.6GB,存活区 2MB,可推断出老年代使用量 1.6GB;
- GC 之后,年轻代使用量为 0,堆内存使用量 2GB,存活区 254MB,那么老年代大约 1.8GB,那么“内存提升量为 200MB 左右”。
这样分析之后,可以得出结论:
- 年轻代转移暂停,复制了 400MB 左右的对象,却消耗了 1.8s,系统调用和系统等待的时间达到了 14s。
- JVM 看到的物理内存 137GB。
- 推算出 JVM 看到的 CPU 内核数量 72个,因为 GC 工作线程
72* 5/8 ~= 48
个。
看到这么多的 GC 工作线程我就开始警惕了,毕竟堆内存才指定了 4GB。
按照一般的 CPU 和内存资源配比,常见的比例差不多是 4 核 4GB、4 核 8GB 这样的。
看看对应的 CPU 负载监控信息:
这个节点的配置被限制为 4 核 8GB。
这样一来,GC 暂停时间过长的原因就定位到了:
- K8S 的资源隔离和 JVM 未协调好,导致 JVM 看见了 72 个 CPU 内核,默认的并行 GC 线程设置为
72* 5/8 ~= 48 个
,但是 K8S 限制了这个 Pod 只能使用 4 个 CPU 内核的计算量,致使 GC 发生时,48 个线程在 4 个 CPU 核心上发生资源竞争,导致大量的上下文切换。
处置措施为:
- 限制 GC 的并行线程数量
事实证明,打印 GC 日志确实是一个很有用的排查分析方法。
限制 GC 的并行线程数量
下面是新的启动参数配置:
1 | -Xmx4g -Xms4g |
这里指定了 -XX:ParallelGCThreads=4
,为什么这么配呢?我们看看这个参数的说明。
1 | -XX:ParallelGCThreads=n |
设置 STW 阶段的并行 worker 线程数量。 如果逻辑处理器小于等于 8 个,则默认值 n 等于逻辑处理器的数量。
如果逻辑处理器大于 8 个,则默认值 n 大约等于处理器数量的 5/8。在大多数情况下都是个比较合理的值。如果是高配置的 SPARC 系统,则默认值 n 大约等于逻辑处理器数量的 5/16。
1 | -XX:ConcGCThreads=n |
设置并发标记的 GC 线程数量。默认值大约是 ParallelGCThreads 的四分之一。
一般来说不用指定并发标记的 GC 线程数量,只用指定并行的即可。