一、JVM 内存模型
1.1 堆
该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中(由于JDK8还没有真正支持【逃逸分析】优化,所以确实所有的对象和数组都是在堆上分配的)。堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成
在 Java6 版本中,永久代在非堆内存区;到了 Java7 版本,永久代的静态变量和运行时常量池被合并到了堆中;而到了 Java8,永久代被元空间取代了。 结构如下图所示:
1.2 程序计数器
主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器
1.3 方法区
方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息
1.4 虚拟机栈
虚拟机栈是线程私有的内存空间,它和 Java 线程一起创建。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用来保存方法的局部变量、操作数栈、动态链接方法和返回地址等信息,并参与方法的调用和返回。每一个方法的调用都伴随着栈帧的入栈操作,方法的返回则是栈帧的出栈操作
1.5 本地方法栈
本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的
二、JVM 运行原理
我们通过一个案例来了解下代码和对象是如何分配存储的,Java 代码又是如何在 JVM 中运行的
public class JVMCase {
// 常量
public final static String MAN_SEX_TYPE = "man";
// 静态变量
public static String WOMAN_SEX_TYPE = "woman";
public static void main(String[] args) {
Student stu = new Student();
stu.setName("nick");
stu.setSexType(MAN_SEX_TYPE);
stu.setAge(20);
JVMCase jvmcase = new JVMCase();
// 调用静态方法
print(stu);
// 调用非静态方法
jvmcase.sayHello(stu);
}
// 常规静态方法
public static void print(Student stu) {
System.out.println("name: " + stu.getName() + "; sex:" + stu.getSexType() + "; age:" + stu.getAge());
}
// 非静态方法
public void sayHello(Student stu) {
System.out.println(stu.getName() + "say: hello");
}
}
class Student{
String name;
String sexType;
int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSexType() {
return sexType;
}
public void setSexType(String sexType) {
this.sexType = sexType;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
当我们通过 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 对象
三、优化 Java 编译
最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中
在 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器,这两个编译器的编译过程是不一样的。
C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求。俗称 Client Compiler
C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。俗称 Server Compiler
3.1 热点探测
在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”
热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:
-
通过设置 JVM 参数来 减小热点阈值 或 增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存
-
方法调用计数器(默认阈值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次)
通过 -XX: CompileThreshold 来设定
-
回边计数器(默认阈值在 C1 默认为 13995,C2 默认为 10700)
通过 -XX: OnStackReplacePercentage=N 来设置
-
增加方法体阈值
经常执行的方法,默认方法体大小小于 325 字节的都会进行内联,我们可以 通过 -XX:MaxFreqInlineSize=N 来设置大小值
不经常执行的方法,默认方法大小小于 35 字节才会进行内联,我们也可以 通过 -XX:MaxInlineSize=N 来重置大小值
-
-
在编程中,避免在一个方法中写大量代码,习惯使用小方法体
-
尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查
3.2 逃逸分析
3.2.1 栈上分配
注意:HotSpot 中暂时没有实现这项优化!
在 Java 中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上
-XX:+DoEscapeAnalysis #开启逃逸分析(jdk1.8默认开启,其它版本未测试)
-XX:-DoEscapeAnalysis #关闭逃逸分析
3.2.2 锁消除
在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer。由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降
这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除
-XX:+EliminateLocks #开启锁消除(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateLocks #关闭锁消除
3.2.3 标量替换
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替
public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
...//to do something TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
...//to do something
}
逃逸分析后,代码会优化为:
public void foo() {
id = 1;
count = 99;
...//to do something
}
我们可以通过设置 JVM 参数来开关逃逸分析,还可以单独开关标量替换(锁消除亦如是),在 JDK1.8 中 JVM 是默认开启这些操作的
-XX:+EliminateAllocations #开启标量替换(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateAllocations #关闭就可以了
四、优化垃圾回收机制
4.1 GC 算法
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现
我们可以通过 JVM 工具查询当前 JVM 使用的垃圾收集器类型,首先通过 ps 命令查询出进程 ID,再通过 jmap -heap [pid] 查询出 JVM 的配置信息,其中就包括垃圾收集器的设置类型
4.2 查看 & 分析 GC 日志
我们需要通过 JVM 参数预先设置 GC 日志,通常有以下几种 JVM 参数设置:
-XX:+PrintGC #输出GC日志
-XX:+PrintGCDetails #输出GC的详细日志
-XX:+PrintGCTimeStamps #输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps #输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC #在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log #日志文件的输出路径
常用组合: -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log
可以通过 GCeasy 直观地分析 GC 日志。我们可以将日志文件压缩之后,上传到 GCeasy 官网 即可看到非常清楚的 GC 日志分析结果。也可以通过 GCViewer 工具查看日志
4.3 GC 调优策略
1 降低 Minor GC 频率
通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率
-Xmn512m
2 降低 Full GC 的频率
通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。优化方法有:
-
减少创建大对象
-
增大堆内存空间
-Xms1024m -Xmx1024m
4.4 查看 JVM 的 GC 情况
通过 jstat 命令查看 jvm 的 GC 情况,具体操作为:
- 通过 ps -ef | grep 命令拿到 java 进程号 vmid
- 然后执行 jstat [-命令选项] [vmid]
五、优化 JVM 内存分配
JVM 内存分配不合理最直接的表现就是频繁的 GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。因此,如果你在线上环境或性能测试时,发现频繁的 GC,且是正常的对象创建和回收,这个时候就需要考虑调整 JVM 内存分配了
5.1 对象在堆中的生存周期
当我们新建一个对象时,对象会被优先分配到新生代的 Eden 区中,这时虚拟机会给对象定义一个对象年龄计数器(通过参数 -XX:MaxTenuringThreshold
设置)
- 当 Eden 空间不足时,虚拟机将会执行一个新生代的垃圾回收(Minor GC)。这时 JVM 会把存活的对象转移到 Survivor 中,并给对象的年龄 +1
- 对象在 Survivor 中同样也会经历 MinorGC,每经过一次 MinorGC,对象的年龄将会 +1
内存空间也是有设置阈值的,可以 通过参数 -XX:PetenureSizeThreshold
设置直接被分配到老年代的最大对象,这时如果分配的对象超过了设置的阀值,对象就会直接被分配到老年代,这样做的 好处就是可以减少新生代的垃圾回收
5.2 查看 JVM 堆内存分配
java -XX:+PrintFlagsFinal -version | grep HeapSize
jmap -heap [pid]
在 JDK1.7 中,默认情况下年轻代和老年代的比例是 1:2,我们可以通过 –XX:NewRatio
重置该配置项。年轻代中的 Eden 和 To Survivor、From Survivor 的比例是 8:1:1,我们可以通过 -XX:SurvivorRatio
重置该配置项
在 JDK1.7 中如果开启了 -XX:+UseAdaptiveSizePolicy
配置项,JVM 将会动态调整 Java 堆中各个区域的大小以及进入老年代的年龄,–XX:NewRatio
和 -XX:SurvivorRatio
将会失效,而 JDK1.8 是默认开启 -XX:+UseAdaptiveSizePolicy
配置项的
5.3 JVM 内存分配的调优过程
1 调整堆内存空间减少 FullGC
-Xms -Xmx
2 调整年轻代空间减少 MinorGC
-Xmn
3 设置 Eden、Survivor 区比例
在 JDK1.8 中,默认是开启 AdaptiveSizePolicy 的,我们可以 通过 -XX:-UseAdaptiveSizePolicy
关闭该项配置,或显示运行 -XX:SurvivorRatio=8
将 Eden、Survivor 的比例设置为 8:2。大部分新对象都是在 Eden 区创建的,我们可以固定 Eden 区的占用比例,来调优 JVM 的内存分配性能
六、内存持续上升问题排查
6.1 常用的监控和诊断内存工具
6.1.1 Linux 命令工具之 top 命令
可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。还可以通过 top -Hp pid
查看具体线程使用系统资源情况
6.1.2 Linux 命令工具之 pidstat 命令
pidstat 是 Sysstat 中的一个组件,也是一款功能强大的性能监测工具,我们可以通过命令:yum install sysstat
安装该监控组件
6.1.3 JDK 工具之 jstat 命令
jstat
可以监测 Java 应用程序的实时运行情况,包括堆内存信息以及垃圾回收信息
-class:显示 ClassLoad 的相关信息;
-compiler:显示 JIT 编译的相关信息;
-gc:显示和 gc 相关的堆信息;
-gccapacity:显示各个代的容量以及使用情况;
-gcmetacapacity:显示 Metaspace 的大小;
-gcnew:显示新生代信息;
-gcnewcapacity:显示新生代大小和使用情况;
-gcold:显示老年代和永久代的信息;
-gcoldcapacity :显示老年代的大小;
-gcutil:显示垃圾收集信息;
-gccause:显示垃圾回收的相关信息(通 -gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
-printcompilation:输出 JIT 编译的方法信息;
6.1.4 JDK 工具之 jstack 命令
线程堆栈分析工具,最常用的功能就是使用 jstack pid
命令查看线程的堆栈信息,通常会结合 top -Hp pid
或 pidstat -p pid -t
一起查看具体线程的状态,也经常用来排查一些死锁的异常
每个线程堆栈的信息中,都可以查看到线程 ID、线程的状态(wait、sleep、running 等状态)以及是否持有锁等
6.1.5 JDK 工具之 jmap 命令
使用 jmap -histo[:live] pid
查看堆内存中的对象数目、大小统计直方图,如果带上 live 则只统计活对象
使用 jmap -dump:format=b,file=/tmp/heap.hprof [pid]
把堆内存的使用情况 dump 到文件中,然后使用 MAT 工具进行分析
6.2 线上问题基本排查流程
- 通过
top
命令查看进程在整个系统中内存的使用率和 CPU 使用率 - 通过
top -Hp pid
查看具体线程占用系统资源情况 - 通过
jstack [pid]
查看具体线程的堆栈信息,可以发现该线程一直处于 TIMED_WAITING 状态,此时根据 CPU 使用率和负载有无异常判断是否有死锁或 I/O 阻塞,跳转到情况二 - 通过
jmap -heap [pid]
查看堆内存的使用情况,看一下新生代和老年代的使用率,若使用率过高,则有可能是内存溢出或内存泄漏,跳转到情况一
情况一:如果定位为内存异常
- 通过
jmap -histo[:live] pid
查看存活对象数量 - 通过 dump 文件,使用 MAT 工具定位异常对象是被那个对象引用的,由此可定位问题代码
情况二:如果定位为 CPU 异常
通过 printf "%x\n" [pid]
将线程 ID 转化为 16 进制的线程 ID,假设 16 进制的 PID 为 3faa
通过 JVM 的 jstack [pid] | grep "3faa" -A 30
查看进程信息,定位问题