JVM内核及原理、诊断与优化-基础篇
1. 初识JVM
1.1. JVM的概念
JVM是Java Virtual Machine的简称。意为Java虚拟机
虚拟机
– 指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统
有哪些虚拟机
– VMWare
– Visual Box
– JVM
VMWare或者Visual Box都是使用软件模拟物理CPU的指令集
JVM使用软件模拟Java 字节码的指令集
1.2. JVM发展历史
1996年 SUN JDK 1.0 Classic VM
– 纯解释运行,使用外挂进行JIT
1997年 JDK1.1 发布
– AWT、内部类、JDBC、RMI、反射
1998年 JDK1.2 Solaris Exact VM(JDK1.2开始称为Java2 J2SE J2EE J2ME的出现并加入Swing Collections)
– JIT 解释器混合
– Accurate Memory Management 精确内存管理,数据类型敏感
– 提升的GC性能
2000年 JDK 1.3 Hotspot 作为默认虚拟机发布(加入JavaSound,支持声音的处理)
2002年 JDK 1.4 Classic VM退出历史舞台(Assert 正则表达式 NIO IPV6 日志API 加密类库)
2004年发布 JDK1.5 即 JDK5 、J2SE 5 、Java 5
– 泛型
– 注解
– 装箱
– 枚举
– 可变长的参数
– Foreach循环
JDK1.6 JDK6
– 脚本语言支持
– JDBC 4.0
– Java编译器 API
2011年 JDK7发布
– 延误项目推出到JDK8
– G1
– 动态语言增强
– 64位系统中的压缩指针
– NIO 2.0
2014年 JDK8发布
– Lambda表达式
– 语法增强 Java类型注解
2016年JDK9
– 模块化
1.3. JVM的历史-大事记
使用最为广泛的JVM为HotSpot
HotSpot 为Longview Technologies开发 被SUN收购
2006年 Java开源 并建立OpenJDK
– HotSpot 成为Sun JDK和OpenJDK中所带的虚拟机
2008 年 Oracle收购BEA
– 得到JRockit VM
2010年Oracle 收购 Sun
– 得到Hotspot
Oracle宣布在JDK8时整合JRockit和Hotspot,优势互补
– 在Hotspot基础上,移植JRockit优秀特性
1.4. JVM种类
KVM
– SUN发布
– IOS Android前,广泛用于手机系统
CDC/CLDC HotSpot
– 手机、电子书、PDA等设备上建立统一的Java编程接口
– J2ME的重要组成部分
JRockit
– BEA
IBM J9 VM
– IBM内部
Apache Harmony
– 兼容于JDK 1.5和JDK 1.6的Java程序运行平台
– 与Oracle关系恶劣 退出JCP ,Java社区的分裂
– OpenJDK出现后,受到挑战 2011年 退役
– 没有大规模商用经历
– 对Android的发展有积极作用
1.5. JAVA语言规范
包括:语法、变量、类型和文法。
语法定义
– IfThenStatement:
if ( Expression ) Statement
– ArgumentList:
Argument
ArgumentList , Argument
词法结构
– \u + 4个16进制数字 表示UTF-16
– 行终结符: CR, or LF, or CR LF.
– 空白符
• 空格 tab \t 换页 \f 行终结符
– 注释
– 标示符
– 关键字
词法结构
– Int
• 0 2 0372 0xDada_Cafe 1996 0x00_FF__00_FF
– Long
• 0l 0777L 0x100000000L 2_147_483_648L 0xC0B0L
– Float
• 1e1f 2.f .3f 0f 3.14f 6.022137e+23f
– Double
• 1e1 2. .3 0.0 3.14 1e-9d 1e137
– 操作
• += -= *= /= &= |= ^= %= <<= >>= >>>=
类型和变量
– 元类型
• byte short int long float char
– 变量初始值
• boolean false
• char \u0000
– 泛型
1.6. JVM规范
包括:Class文件类型、运行时数据、帧栈、虚拟机的启动、虚拟机的指令集。
Java语言规范定义了什么是Java语言
Java语言和JVM相对独立
– Groovy
– Clojure
– Scala
JVM主要定义二进制class文件和JVM指令集等
Class 文件格式
数字的内部表示和存储
– Byte -128 to 127 (-27 to 27 - 1)
returnAddress 数据类型定义
– 指向操作码的指针。不对应Java数据类型,不能在运行时修改。Finally实现需要
定义PC
堆
栈
方法区
Float的表示与定义
VM指令集
– 类型转化
• l2i
– 出栈入栈操作
• aload astore
– 运算
• iadd isub
– 流程控制
• ifeq ifne
– 函数调用
• invokevirtual invokeinterface invokespecial invokestatic
JVM需要对Java Library 提供以下支持:
– 反射 java.lang.reflect
– ClassLoader
– 初始化class和interface
– 安全相关 java.security
– 多线程
– 弱引用
1.7 JVM的编译
– 源码到JVM指令的对应格式
– javap
– JVM反汇编的格式
• <index> <opcode> [<operand1>[<operand2>...]][<comment>]
2. JVM运行机制
2.1. JVM启动流程
2.2. JVM基本结构
2.2.1. PC寄存器
(1)每一个线程拥有一个PC寄存器
(2)在线程创建时创建
(3)指向下一条指令的地址
(4)执行本地方法时,PC的值为undefined
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有“的内存。如果线程正在执行的是一个java方法,这个计数器记录的是正在执行虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在java虚拟机规范中没有规定OutOfMemoryError情况的区域。
2.2.2. 方法区
(1)保存装载的类信息
a. 类型的常量池,静态变量
b. 字段、方法信息
c. 方法字节码
(2)通常和永久区(Perm)关联在一起
- JDK6时,String等常量信息置于方法区。而JDK7时,已经移动到了堆。*
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Java虚拟机把方法区描述为堆的一个逻辑部分。
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展性外,还可以选择不实现垃圾收集。(但Sun HotSpot对内存回收扩展到了方法区。)
(3)运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放变异期间生成的各种字面量和符号引用,这部分内容将在类加载后存放的方法区的运行常量池中。Java语言并不要求常量一定只能在编译期产生,也就是并非预置入Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用比较多的便是String类的intern()方法。当 new 一个对象时,会检查这个区域是否有这个符号的引用。
(4)直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中引入了NIO类,引入了一种基于通道(Channel)与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则会受到本机总内存的大小及处理器寻址空间的限制。
在 JDK1.8 中已经移除了方法区(永久代),并使用了一个元数据区域保存类加载之后的类信息,字符串常量池也被移动到Java堆中。PermSize 和 MaxPermSize 已经不能使用了,在 JDK8 中配置这两个参数将会发出警告。
默认情况下元数据区域会根据使用情况动态调整,避免了在 1.7 中由于加载类过多从而出现 java.lang.OutOfMemoryError: PermGen。但也不能无线扩展,因此可以使用 -XX:MaxMetaspaceSize来控制最大内存。
2.2.3. Java堆
(1)和程序开发密切相关
(2)应用系统对象都保存在Java堆中
(3)所有线程共享Java堆
(4)对分代GC来说,堆也是分代的。比如永久区和新生代。
(5)GC的主要工作区间。
被所有线程共享。所有的对象实例和数组都要在堆上分配。Java堆可细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor 空间、To Survivor空间等。当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
2.2.4. 堆外直接内存
直接内存又称为 Direct Memory(堆外内存),它并不是由 JVM 虚拟机所管理的一块内存区域。
有使用过 Netty 的朋友应该对这块并内存不陌生,在 Netty 中所有的 IO(nio) 操作都会通过 Native 函数直接分配堆外内存。
它是通过在堆内存中的 DirectByteBuffer 对象操作的堆外内存,避免了堆内存和堆外内存来回复制交换复制,这样的高效操作也称为零拷贝。
既然是内存,那也得是可以被回收的。但由于堆外内存不直接受 JVM 管理,所以常规 GC 操作并不能回收堆外内存。它是借助于老年代产生的 fullGC 顺便进行回收。同时也可以显式调用 System.gc() 方法进行回收(前提是没有使用 -XX:+DisableExplicitGC 参数来禁止该方法)。
值得注意的是:由于堆外内存也是内存,是由操作系统管理。如果应用有使用堆外内存则需要平衡虚拟机的堆内存和堆外内存的使用占比。避免出现堆外内存溢出。
2.2.5. Java栈
(1)线程私有
(2)栈由一系列帧组成(因此Java栈也叫帧栈)
(3)帧保存一个方法的局部变量、操作数栈、常量池指针。
(4)每一次方法调用创建一个帧,并压栈。
与程序计数器一样,Java虚拟机桟也是线程私有,它的生命周期与线程相同。Java虚拟机桟也是线程私有的,它的生命周期与线程相同。虚拟机桟描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个桟帧(Stack Frame)用于存储局部变量表、操作桟、动态链接、方法出口等信息。每个方法被调用到执行完成,对应着一个桟帧在虚拟机桟中从入桟到出桟的过程。
经常说的Java内存区分为堆内存和桟内存。其中桟内存主要指的是虚拟机桟,或者指的虚拟机中的局部变量表部分。
局部变量存放了编译器可知的各种基本类型、对象引用和returnAddress类型。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间完全是确定的,在方法运行期间不会改变局部变量表的大小。这里存在两种异常:StackOverflowError和OutOfMemoryError。
本地方法桟与虚拟机所发挥的作用是非常相似的,本地方法桟则是为虚拟机使用到的Native 方法服务。有的虚拟机(Sun HotSpot)直接就把本地方法桟和虚拟机桟合二为一。
- Java栈 - 关于静态方法和普通方法的局部变量表
//静态方法
public static int runStatic(int i, long l, float f, Object o, byte b) {
return 0;
}
//普通方法
public int runInstanct(char c, short s, boolean b) {
return 0;
}
以上代码对应下图的栈中的局部变量关系表。
2. Java栈-函数调用组成帧栈
public static int runStatic(int i, long l, float f, Object o, byte b) {
return runStatic(i,lf,o,b);
}
3. Java栈-操作数栈
java没有寄存器,所有参数传递使用操作数栈。
4. Java栈-栈上分配
同C++的堆上分配不同,java几乎所有引用指针都在栈上,当栈帧调用完,垃圾回收器就可以将该栈帧及对应的堆变量回收。
以下示例是栈上分配的方法掉用是,如果开启逃逸分析(XX:+DoEscapeAnalysis)和不开启逃逸分析的运行时间。显然开启逃逸分析时能显著提高性能。
哪种情况下可以进行栈上分配?
(1)小对象(一般几十个bytes),在没有逃逸的情况下,可以直接分配在栈上。
(2)直接分配在栈上,可以自动回收,减轻GC压力。
(3)大对象或者逃逸对象(对象被其他方法使用)无法栈上分配。
如果出现方法递归调用出现死循环的话就会造成栈帧过多,最终会抛出 StackOverflowError。
若线程执行过程中栈帧大小超出虚拟机栈限制,则会抛出 StackOverflowError。
若虚拟机栈允许动态扩展,但在尝试扩展时内存不足,或者在为一个新线程初始化新的虚拟机栈时申请不到足够的内存,则会抛出 OutOfMemoryError。
2.3. 内存模型(JMM)
每一个线程有一个工作内存和主存独立,工作内存存放主存中变量的值的拷贝。当数据从主内存复制到工作存储时,必须出现两个动作:第一,由主内存执行的读(read)操作;第二,由工作内存执行的相应的load操作;当数据从工作内存拷贝到主内存时,也出现两个操作:第一个,由工作内存执行的存储(store)操作;第二,由主内存执行的相应的写(write)操作。这里有一个happens-before原则。
每一个操作都是原子的,即执行期间不会被中断。
对于普通变量,一个线程中更新的值,不能马上反应在其他变量中,如果需要在其他线程中立即可见,需要使用volatile关键字。
- volatile
没有volatile -server运行无法停止,当使用volatile就可以正常停止。volatile在jdk自身有很多地方用到,不能替代锁,但是一般认为volatile比锁性能好(不绝对)。选择使用volatile的条件是:语句是否满足应用。
public class VolatileStopThread extends Thread {
private volatile boolean stop = false;
public void stopMe() {
stop = true;
}
public void run() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("Stop thread");
}
public static void main(String[] arg) throws InterruptedException {
VolatileStopThread t = new VolatileStopThread();
t.start();
Thread.sleep(1000);
t.stopMe();
Thread.sleep(1000);
}
}
2.4. 编译和解释运行的概念
可见性
– 一个线程修改了变量,其他线程可以立即知道
保证可见性的方法
– volatile(先写主存)
– synchronized (unlock之前,写变量值回主存)
– final(一旦初始化完成,其他线程就可见)
有序性
– 在本线程内,操作都是有序的
– 在线程外观察,操作都是无序的。(指令重排 或 主内存同步延时)
指令重排
– 线程内串行语义
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。
以上语句不可重排
编译器不考虑多线程间的语义
可重排: a=1;b=2;
指令重排的基本原则:
(1)程序顺序原则:一个线程内保证语义的串行性。
(2)volatile规则:volatile变量的写,先发生于读。
(3)锁规则:解锁(unlock)必然发生在随后的加锁(lock)前。
(4)传递性:A先于B,B先于C 那么A必然先于C。
(5)线程的start方法先于它的每一个动作
(6)线程的所有操作先于线程的终结(Thread.join)。
(7)线程的中端(interrupt())先于被中断线程的代码。
(8)对象的构造函数执行结束先于finalize()方法。
解释运行:
(1)解释执行以解释方式运行字节码
(2)解释执行的意思是:读一句执行一句
编译运行(JIT)
(1)将字节码编译成机器码
(2)直接执行机器码
(3)运行时编译
(4)编译后性能有数量级的提升
3. 常用JVM配置参数
3.1. Trace跟踪参数
(1)-verbose:gc
(2)-XX:+printGC
(3)可以打印GC的简要信息
– [GC 4790K->374K(15872K), 0.0001606 secs]
– [GC 4790K->374K(15872K), 0.0001474 secs]
– [GC 4790K->374K(15872K), 0.0001563 secs]
– [GC 4790K->374K(15872K), 0.0001682 secs]
(4)-XX:+PrintGCDetails
– 打印GC详细信息
(5)-XX:+PrintGCTimeStamps
– 打印CG发生的时间戳
– [GC[DefNew: 4416K->0K(4928K), 0.0001897 secs] 4790K->374K(15872K), 0.0002232 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
(6)-Xloggc:log/gc.log
– 指定GC log的位置,以文件输出
– 帮助开发人员分析问题
(7)-XX:+PrintHeapAtGC
每次GC后,都打印堆信息。
{Heap before GC invocations=0 (full 0):
def new generation total 3072K, used 2752K [0x33c80000, 0x33fd0000, 0x33fd0000)
eden space 2752K, 100% used [0x33c80000, 0x33f30000, 0x33f30000)
from space 320K, 0% used [0x33f30000, 0x33f30000, 0x33f80000)
to space 320K, 0% used [0x33f80000, 0x33f80000, 0x33fd0000)
tenured generation total 6848K, used 0K [0x33fd0000, 0x34680000, 0x34680000)
the space 6848K, 0% used [0x33fd0000, 0x33fd0000, 0x33fd0200, 0x34680000)
compacting perm gen total 12288K, used 143K [0x34680000, 0x35280000, 0x38680000)
the space 12288K, 1% used [0x34680000, 0x346a3c58, 0x346a3e00, 0x35280000)
ro space 10240K, 44% used [0x38680000, 0x38af73f0, 0x38af7400, 0x39080000)
rw space 12288K, 52% used [0x39080000, 0x396cdd28, 0x396cde00, 0x39c80000)
[GC[DefNew: 2752K->320K(3072K), 0.0014296 secs] 2752K->377K(9920K), 0.0014604 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap after GC invocations=1 (full 0):
def new generation total 3072K, used 320K [0x33c80000, 0x33fd0000, 0x33fd0000)
eden space 2752K, 0% used [0x33c80000, 0x33c80000, 0x33f30000)
from space 320K, 100% used [0x33f80000, 0x33fd0000, 0x33fd0000)
to space 320K, 0% used [0x33f30000, 0x33f30000, 0x33f80000)
tenured generation total 6848K, used 57K [0x33fd0000, 0x34680000, 0x34680000)
the space 6848K, 0% used [0x33fd0000, 0x33fde458, 0x33fde600, 0x34680000)
compacting perm gen total 12288K, used 143K [0x34680000, 0x35280000, 0x38680000)
the space 12288K, 1% used [0x34680000, 0x346a3c58, 0x346a3e00, 0x35280000)
ro space 10240K, 44% used [0x38680000, 0x38af73f0, 0x38af7400, 0x39080000)
rw space 12288K, 52% used [0x39080000, 0x396cdd28, 0x396cde00, 0x39c80000]
(8)-XX:+TraceClassLoading - 监控类的加载
• [Loaded java.lang.Object from shared objects file]
• [Loaded java.io.Serializable from shared objects file]
• [Loaded java.lang.Comparable from shared objects file]
• [Loaded java.lang.CharSequence from shared objects file]
• [Loaded java.lang.String from shared objects file]
• [Loaded java.lang.reflect.GenericDeclaration from shared objects file]
• [Loaded java.lang.reflect.Type from shared objects file]
(9)-XX:+PrintClassHistogram
3.2. 堆的分配参数
-Xmx –Xms
- Xmx20m – Xms5m 运行代码:
-Xmx和-Xms应该保持一个什么关系,可以让系统的性能尽可能的好呢?
如果你要做一个Java的桌面产品,需要绑定JRE,但JRE又很大,如何给JRE瘦身呢?
3.3. 栈的分配参数
(1)-Xmn
- 设置新生代大小
(2)-XX:NewRatio
– 新生代(eden+2*s)和老年代(不包含永久区)的比值
– 4 表示 新生代:老年代=1:4,即年轻代占堆的1/5
(3)-XX:SurvivorRatio
– 设置两个Survivor区和eden的比
– 8表示 两个Survivor :eden=2:8,即一个Survivor占年轻代的1/10
案例
没有触发GC,全部分配在老年代。因为新生代太少。
没有触发GC,全部分配在eden,老年代没有使用。因为新生代分配的内存要大些。
实际占用空间大约10M 因为有系统级别的对象 如ClassLoader Thread等。
进行了2次新生代GC。S0 S1太小需要老年代担保
一共回收7M 剩余3M,S0 S1 太小无法周转。
进行了3次新生代GC s0 s1 增大
回收7M,剩余3M。
比例分配,新生代 老年代对半开 对象全部留在新生代
减少了s0 s1 GC数量变少,老年代未使用 空间使用率更高
- 当发生OOM的操作:-XX:OnOutOfMemoryError
(1)在OOM时,执行一个脚本。
(2)“-XX:OnOutOfMemoryError=D:/tools/jdk1.7_40/bin/printstack.bat %p”
(3)当程序OOM时,在D:/a.txt中将会生成线程的dump。
(4)可以在OOM时,发送邮件,甚至是重启程序。
推荐:
(1)根据实际事情调整新生代和幸存代的大小。
(2)官方推荐新生代占堆的3/8。
(3)幸存代占新生代的1/10。
(4)在OOM时,记得Dump出堆,确保可以排查现场问题。
3.4. 永久区分配参数
-XX:PermSize -XX:MaxPermSize
设置永久区的初始空间和最大空间
他们表示,一个系统可以容纳多少个类型
在使用CGLIB等库的时候,可能会产生大量的类,这些类,有可能会撑爆永久区导致OOM。
for(int i=0; i< 1000000; i++) {
// 不断产生新的类
CglibBean bean = new CglibBean("geym.jvm.bean"+i,new HashMap());
}
- 打开堆的Dump
– 堆空间实际占用非常少
– 但是永久区溢出 一样抛出OOM
-Xss
– 通常只有几百K
– 决定了函数调用的深度
– 每个线程都有独立的栈空间
– 局部变量、参数 分配在栈上
/**
* -Xss决定栈的深度
*/
public class TestXssParam {
private static int count = 0;
public static void recursion(long a, long b, long c) {
long e = 1, f = 2, g = 3, h = 4, i = 5, k = 6, q = 7, x = 8, y = 9, z = 10;
count++;
recursion(a, b, c);
}
public static void main(String[] args) {
try {
recursion(0L, 0L, 0L);
} catch (Throwable e) {
System.out.println("deep of calling = " + count);
e.printStackTrace();
}
}
}
当-Xss设置为128K时的栈的深度如下:
deep of calling = 306
java.lang.StackOverflowError
at com.xx.finance.credit.merchant.utils.TestXssParam.recursion(TestXssParam.java:10)
at com.xx.finance.credit.merchant.utils.TestXssParam.recursion(TestXssParam.java:12)
当调整为256K的栈的深度如下。
deep of calling = 1274
java.lang.StackOverflowError
at com.xx.finance.credit.merchant.utils.TestXssParam.recursion(TestXssParam.java:12)
at com.xx.finance.credit.merchant.utils.TestXssParam.recursion(TestXssParam.java:12)
3.5 常用参数
常见的如下:
(1)-Xms64m 最小堆内存 64m。
(2)-Xmx128m 最大堆内存 128m。
(3)-XX:NewSize=30m 新生代初始化大小为30m。
(4)-XX:MaxNewSize=40m 新生代最大大小为40m。
(5)-Xss=256k 线程栈大小。
(6)-XX:+PrintHeapAtGC 当发生 GC 时打印内存布局。
(7)-XX:+HeapDumpOnOutOfMemoryError 发送内存溢出时 dump 内存。
新生代和老年代的默认比例为 1:2,也就是说新生代占用 1/3的堆内存,而老年代占用 2/3 的堆内存。
可以通过参数 -XX:NewRatio=2 来设置老年代/新生代的比例。
由于 JVM 一直处在变化之中,所以一些参数的配置并不总是有效的。有时候你加入一个参数,“感觉上”运行速度加快了,但通过 -XX:+PrintFlagsFinal 来查看,却发现这个参数默认就是这样了。
所以,在不同的 JVM 版本上,不同的垃圾回收器上,要先看一下这个参数默认是什么,不要轻信别人的建议,命令行示例如下:
java -XX:+PrintFlagsFinal -XX:+UseG1GC 2>&1 | grep UseAdaptiveSizePolicy
还有一个与之类似的参数叫作 PrintCommandLineFlags,通过它,你能够查看当前所使用的垃圾回收器和一些默认的值。可以看到下面的 JVM 默认使用的就是并行收集器:
可以看到下面的 JVM 默认使用的就是并行收集器
# java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=127905216 -XX:MaxHeapSize=2046483456 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
openjdk version "1.8.0_41"
OpenJDK Runtime Environment (build 1.8.0_41-b04)
OpenJDK 64-Bit Server VM (build 25.40-b25, mixed mode)
JVM 的参数配置繁多,但大多数不需要我们去关心。
下面,我们通过对 ES 服务的 JVM 参数分析,来看一下常见的优化点。
ElasticSearch(简称 ES)是一个高性能的开源分布式搜索引擎。ES 是基于 Java 语言开发的,在它的 conf 目录下,有一个叫作jvm.options的文件,JVM 的配置就放在这里。
堆空间的配置
下面是 ES 对于堆空间大小的配置。
-Xms1g
-Xmx1g
通过 Xmx 可指定堆的最大值,通过 Xms 可指定堆的初始大小。我们通常把这两个参数,设置成一样大小的,可避免堆空间在动态扩容时的时间开销。
在配置文件中还有 AlwaysPreTouch 这个参数。
-XX:+AlwaysPreTouch
其实,通过 Xmx 指定了的堆内存,只有在 JVM 真正使用的时候,才会进行分配。这个参数,在 JVM 启动的时候,就把它所有的内存在操作系统分配了。在堆比较大的时候,会加大启动时间,但它能够减少内存动态分配的性能损耗,提高运行时的速度。
如下图,JVM 的内存,分为堆和堆外内存,其中堆的大小可以通过 Xmx 和 Xms 来配置。
但我们在配置 ES 的堆内存时,通常把堆的初始化大小,设置成物理内存的一半。这是因为 ES 是存储类型的服务,我们需要预留一半的内存给文件缓存(理论参见 “07 | 案例分析:无处不在的缓存,高并发系统的法宝”),等下次用到相同的文件时,就不用与磁盘进行频繁的交互。这一块区域一般叫作 PageCache,占用的空间很大。
对于计算型节点来说,比如我们普通的 Web 服务,通常会把堆内存设置为物理内存的 2/3,剩下的 1/3 就是给堆外内存使用的。
我们这张图,对堆外内存进行了非常细致的划分,解释如下:
元空间
参数 -XX:MaxMetaspaceSize 和 -XX:MetaspaceSize,分别指定了元空间的最大内存和初始化内存。因为元空间默认是没有上限的,所以极端情况下,元空间会一直挤占操作系统剩余内存。
JIT 编译后代码存放
-XX:ReservedCodeCacheSize。JIT 是 JVM 一个非常重要的特性,CodeCahe 存放的,就是即时编译器所生成的二进制代码。另外,JNI 的代码也是放在这里的。
本地内存
本地内存是一些其他 attch 在 JVM 进程上的内存区域的统称。比如网络连接占用的内存、线程创建占用的内存等。在高并发应用下,由于连接和线程都比较多,这部分内存累加起来还是比较可观的。
直接内存
这里要着重提一下直接内存,因为它是本地内存中唯一可以使用参数来限制大小的区域。使用参数 -XX:MaxDirectMemorySize,即可设定 ByteBuffer 类所申请的内存上限。
JNI 内存
上面谈到 CodeCache 存放的 JNI 代码,JNI 内存就是指的这部分代码所 malloc 的具体内存。很可惜的是,这部分内存的使用 JVM 是无法控制的,它依赖于具体的 JNI 代码实现。
日志参数配置
下面是 ES 的日志参数配置,由于 Java 8 和 Java 9 的参数配置已经完全不一样了,ES 在这里也分了两份。
8:-XX:+PrintGCDetails
8:-XX:+PrintGCDateStamps
8:-XX:+PrintTenuringDistribution
8:-XX:+PrintGCApplicationStoppedTime
8:-Xloggc:logs/gc.log
8:-XX:+UseGCLogFileRotation
8:-XX:NumberOfGCLogFiles=32
8:-XX:GCLogFileSize=64m
9-:-Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m
下面解释一下这些参数的意义,以 Java 8 为例。
PrintGCDetails 打印详细 GC 日志。
PrintGCDateStamps 打印当前系统时间,更加可读;与之对应的是PrintGCDateStamps 打印的是JVM启动后的相对时间,可读性较差。
PrintTenuringDistribution 打印对象年龄分布,对调优 MaxTenuringThreshold 参数帮助很大。
PrintGCApplicationStoppedTime 打印 STW 时间
下面几个日志参数是配置了类似于 Logback 的滚动日志,比较简单,不再详细介绍
从 Java 9 开始,JVM 移除了 40 多个 GC 日志相关的参数,具体参见 JEP 158。所以这部分的日志配置有很大的变化,GC 日志的打印方式,已经完全不一样了,比以前的日志参数规整了许多。
再来看下 ES 在异常情况下的配置参数:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=data
-XX:ErrorFile=logs/hs_err_pid%p.log
HeapDumpOnOutOfMemoryError、HeapDumpPath、ErrorFile 是每个 Java 应用都应该配置的参数。正常情况下,我们通过 jmap 获取应用程序的堆信息;异常情况下,比如发生了 OOM,通过这三个配置参数,即可在发生OOM的时候,自动 dump 一份堆信息到指定的目录中。
拿到了这份 dump 信息,我们就可以使用 MAT 等工具详细分析,找到具体的 OOM 原因。
垃圾回收器配置
ES 默认使用 CMS 垃圾回收器,它有以下三行主要的配置。
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
下面介绍一下这两个参数:
UseConcMarkSweepGC,表示年轻代使用 ParNew,老年代的用 CMS 垃圾回收器
-XX:CMSInitiatingOccupancyFraction 由于 CMS 在执行过程中,用户线程还需要运行,那就需要保证有充足的内存空间供用户使用。如果等到老年代空间快满了,再开启这个回收过程,用户线程可能会产生“Concurrent Mode Failure”的错误,这时会临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了(STW)。
这部分空间预留,一般在 30% 左右即可,那么能用的大概只有 70%。参数 -XX:CMSInitiatingOccupancyFraction 用来配置这个比例,但它首先必须配置 -XX:+UseCMSInitiatingOccupancyOnly 参数。
另外,对于 CMS 垃圾回收器,常用的还有下面的配置参数:
-XX:ExplicitGCInvokesConcurrent 当代码里显示的调用了 System.gc(),实际上是想让回收器进行FullGC,如果发生这种情况,则使用这个参数开始并行 FullGC。建议加上。
-XX:CMSFullGCsBeforeCompaction 默认为 0,就是每次FullGC都对老年代进行碎片整理压缩,建议保持默认。
-XX:CMSScavengeBeforeRemark 开启或关闭在 CMS 重新标记阶段之前的清除(YGC)尝试。可以降低 remark 时间,建议加上。
-XX:+ParallelRefProcEnabled 可以用来并行处理 Reference,以加快处理速度,缩短耗时。
CMS 垃圾回收器,已经在 Java14 中被移除,由于它的 GC 时间不可控,有条件应该尽量避免使用。
针对 Java10(普通 Java 应用在 Java 8 中即可开启 G1),ES 可采用 G1 垃圾回收器。我们在 “17 | 高级进阶:JVM 如何完成垃圾回收?” 介绍过 G1,它可以通过配置参数 MaxGCPauseMillis,指定一个期望的停顿时间,使用相对比较简单。
下面是主要的配置参数:
-XX:MaxGCPauseMillis 设置目标停顿时间,G1 会尽力达成。
-XX:G1HeapRegionSize 设置小堆区大小。这个值为 2 的次幂,不要太大,也不要太小。如果是在不知道如何设置,保持默认。
-XX:InitiatingHeapOccupancyPercent 当整个堆内存使用达到一定比例(默认是45%),并发标记阶段就会被启动。
-XX:ConcGCThreads 并发垃圾收集器使用的线程数量。默认值随 JVM 运行的平台不同而不同。不建议修改。
JVM 支持非常多的垃圾回收器,下面是最常用的几个,以及配置参数:
-XX:+UseSerialGC 年轻代和老年代都用串行收集器
-XX:+UseParallelGC 年轻代使用 ParallerGC,老年代使用 Serial Old
-XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
-XX:+UseG1GC 使用 G1 垃圾回收器
-XX:+UseZGC 使用 ZGC 垃圾回收器
额外配置
我们再来看下几个额外的配置。
-Xss1m
-Xss设置每个 Java 虚拟机栈的容量为 1MB。这个参数和 -XX:ThreadStackSize 是一样的,默认就是 1MB。
-XX:-OmitStackTraceInFastThrow
把 - 换成 +,可以减少异常栈的输出,进行合并。虽然会对调试有一定的困扰,但能在发生异常时显著增加性能。随之而来的就是异常信息不好排查,ES 为了找问题方便,就把错误合并给关掉了。
-Djava.awt.headless=true
Headless 模式是系统的一种配置模式,在该模式下,系统缺少了显示设备、键盘或鼠标。在服务器上一般是没这些设备的,这个参数是告诉虚拟机使用软件去模拟这些设备。
9-:-Djava.locale.providers=COMPAT
-Dfile.encoding=UTF-8
-Des.networkaddress.cache.ttl=60
-Des.networkaddress.cache.negative.ttl=10
-Dio.netty.noUnsafe=true
-Dio.netty.noKeySetOptimization=true
-Dio.netty.recycler.maxCapacityPerThread=0
-Dlog4j.shutdownHookEnabled=false
-Dlog4j2.disable.jmx=true
-Djava.io.tmpdir=${ES_TMPDIR}
-Djna.nosys=true
上面这些参数,通过 -D 参数,在启动一个 Java 程序时,设置系统属性值,也就是在 System 类中通过 getProperties() 得到的一串系统属性。
这部分自定义性比较强,不做过多介绍。
其他调优
以上就是 ES 默认的 JVM 参数配置,大多数还是比较基础的。在平常的应用服务中,我们希望得到更细粒度的控制,其中比较常用的就是调整各个分代之间的比例。
-Xmn 年轻代大小,默认年轻代占堆大小的 1/3。高并发快消亡场景可适当加大这个区域,对半或者更多都是可以的。但是在 G1 下,就不用再设置这个值了,它会自动调整;
-XX:SurvivorRatio 默认值为 8,表示伊甸区和幸存区的比例;
-XX:MaxTenuringThreshold 这个值在 CMS 下默认为 6,G1 下默认为 15。这个值和我们前面提到的对象提升有关,改动效果会比较明显。对象的年龄分布可以使用 -XX:+PrintTenuringDistribution 打印,如果后面几代的大小总是差不多,证明过了某个年龄后的对象总能晋升到老年代,就可以把晋升阈值设的小一些;
PretenureSizeThreshold 超过一定大小的对象,将直接在老年代分配,不过这个参数用得不是很多。