上文 GC 理论颇受你们好评,学习了以后,相信你们对 GC 的工做原理有了比较深入的认识,这一篇咱们继续趁热打铁,来学习下 GC 的实战内容,主要包括如下几点java
JVM 参数简介算法
发生 OOM 的主要几种场景及相应解决方案数组
OOM 问题排查的一些经常使用工具缓存
GC 日志格式怎么看数据结构
jstat 与可视化 APM 工具构建多线程
再谈 JVM 参数设置并发
在开始实践以前咱们有必要先简单了解一下 JVM 参数配置,由于本文以后的实验中提到的 JVM 中的栈,堆大小,使用的垃圾收集器等都须要经过 JVM 参数来设置less
先来看下如何运行一个 Java 程序jvm
public class Test {
public static void main(String[] args) {
System.out.println("test");
}
}
首先咱们经过 javac Test.java 将其转成字节码ide
其次咱们每每会输入 java Test 这样的命令来启动 JVM 进程来执行此程序,其实咱们在启动 JVM 进程的时候,能够指定相应的 JVM 的参数,以下蓝色部分
指定这些 JVM 参数咱们就能够指定启动 JVM 进程以哪一种模式(server 或 client),运行时分配的堆大小,栈大小,用什么垃圾收集器等等,JVM 参数主要分如下三类
一、 标准参数(-),全部的 JVM 实现都必须实现这些参数的功能,并且向后兼容;例如 -verbose:gc(输出每次GC的相关状况)
二、 非标准参数(-X),默认 JVM 实现这些参数的功能,可是并不保证全部 JVM 实现都知足,且不保证向后兼容,栈,堆大小的设置都是经过这个参数来配置的,用得最多的以下
参数示例 | 表示意义 |
---|---|
-Xms512m | JVM 启动时设置的初始堆大小为 512M |
-Xmx512m | JVM 可分配的最大堆大小为 512M |
-Xmn200m | 设置的年轻代大小为 200M |
-Xss128k | 设置每一个线程的栈大小为 128k |
三、非Stable参数(-XX),此类参数各个 jvm 实现会有所不一样,未来可能会随时取消,须要慎重使用, -XX:-option 表明关闭 option 参数,-XX:+option 表明要关闭 option 参数,例如要启用串行 GC,对应的 JVM 参数即为 -XX:+UseSerialGC。非 Stable 参数主要有三大类
行为参数(Behavioral Options):用于改变 JVM 的一些基础行为,如启用串行/并行 GC
参数示例 | 表示意义 |
---|---|
-XX:-DisableExplicitGC | 禁止调用System.gc();但jvm的gc仍然有效 |
-XX:-UseConcMarkSweepGC | 对老生代采用并发标记交换算法进行GC |
-XX:-UseParallelGC | 启用并行GC |
-XX:-UseParallelOldGC | 对Full GC启用并行,当-XX:-UseParallelGC启用时该项自动启用 |
-XX:-UseSerialGC | 启用串行GC |
性能调优(Performance Tuning):用于 jvm 的性能调优,如设置新老生代内存容量比例
参数示例 | 表示意义 |
---|---|
-XX:MaxHeapFreeRatio=70 | GC后java堆中空闲量占的最大比例 |
-XX:NewRatio=2 | 新生代内存容量与老生代内存容量的比例 |
-XX:NewSize=2.125m | 新生代对象生成时占用内存的默认值 |
-XX:ReservedCodeCacheSize=32m | 保留代码占用的内存容量 |
-XX:ThreadStackSize=512 | 设置线程栈大小,若为0则使用系统默认值 |
调试参数(Debugging Options):通常用于打开跟踪、打印、输出等 JVM 参数,用于显示 JVM 更加详细的信息
参数示例 | 表示意义 |
---|---|
-XX:HeapDumpPath=./java_pid.hprof | 指定导出堆信息时的路径或文件名 |
-XX:-HeapDumpOnOutOfMemoryError | 当首次遭遇OOM时导出此时堆中相关信息 |
-XX:-PrintGC | 每次GC时打印相关信息 |
-XX:-PrintGC Details | 每次GC时打印详细信息 |
画外音:以上只是列出了比较经常使用的 JVM 参数,更多的 JVM 参数介绍请查看文末的参考资料
明白了 JVM 参数是干啥用的,接下来咱们进入实战演练,下文中全部程序运行时对应的 JVM 参数都以 VM Args 的形式写在开头的注释里,读者若是在执行程序时记得要把这些 JVM 参数给带上哦
有些人可能会以为奇怪, GC 不是会自动帮咱们清理垃圾以腾出使用空间吗,怎么还会发生 OOM, 咱们先来看下有哪些场景会发生 OOM
一、Java 虚拟机规范中描述在栈上主要会发生如下两种异常
StackOverflowError 异常
这种状况主要是由于单个线程请求栈深度大于虚拟机所容许的最大深度(如经常使用的递归调用层级过深等),再好比单个线程定义了大量的本地变量,致使方法帧中本地变量表长度过大等也会致使 StackOverflowError 异常, 一句话:在单线程下,当栈桢太大或虚拟机容量过小致使内存没法分配时,都会发生 StackOverflowError 异常。
虚拟机在扩展栈时没法申请到足够的内存空间,会抛出 OOM 异常
在刨根问底---一次 OOM 试验形成的电脑雪崩引起的思考 一文中咱们已经详细地剖析了此例子,再来看看
/**
* VM Args:-Xss160k
*/
public class Test {
private void dontStop() {
while(true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
});
thread.start();
}
}
public static void main(String[] args) {
Test oom = new Test();
oom.stackLeakByThread();
}
}
运行以上代码会抛出「java.lang.OutOfMemoryError: unable to create new native thread」的异常,缘由不难理解,操做系统给每一个进程分配的内存是有限制的,好比 32 位的 Windows 限制为 2G,虚拟机提供了参数来控制 Java 堆和方法的这两部内存的最大值,剩余的内存为 「2G - Xmx(最大堆容量)= 线程数 * 每一个线程分配的虚拟机栈(-Xss)+本地方法栈 」(程序计数器消耗内存不多,可忽略),每一个线程都会被分配对应的虚拟机栈大小,因此总可建立的线程数确定是固定的, 像以上代码这样不断地建立线程固然会形成最终没法分配,不过这也给咱们提供了一个新思路,若是是由于创建过多的线程致使的内存溢出,而咱们又想多建立线程,能够经过减小最大堆(-Xms)和减小虚拟机栈大小(-Xss)来实现。
二、堆溢出 (java.lang.OutOfMemoryError:Java heap space)
主要缘由有两点
1.大对象的分配,最有可能的是大数组分配
示例以下:
/**
* VM Args:-Xmx12m
*/
class OOM {
static final int SIZE=2*1024*1024;
public static void main(String[] a) {
int[] i = new int[SIZE];
}
}
咱们指定了堆大小为 12M,执行 「java -Xmx12m OOM」命令就发生了 OOM 异常,若是指定 13M 则以上程序就能正常执行,因此对于因为大对象分配致使的堆溢出这种 OOM,咱们通常采用增大堆内存的方式来解决
画外音:有人可能会说分配的数组大小不是只有 2 * 1024 * 1024 * 4(一个 int 元素占 4 个字节)= 8M, 怎么分配 12 M 还不够,由于 JVM 进程除了分配数组大小,还有指向类(数组中元素对应的类)信息的指针、锁信息等,实际须要的堆空间是可能超过 12M 的, 12M 也只是尝试出来的值,不一样的机器可能不同
2.内存泄漏
咱们知道在 Java 中,开发者建立和销毁对象是不须要本身开辟空间的,JVM 会自动帮咱们完成,在应用程序整个生命周期中,JVM 会定时检查哪些对象可用,哪些再也不使用,若是对象再也不使用的话理论上这块内存会被回收再利用(即GC),若是没法回收就会发生内存泄漏
/**
* VM Args:-Xmx4m
*/
public class KeylessEntry {
static class Key {
Integer id;
Key(Integer id) {
this.id = id;
}
@Override
public int hashCode() {
return id.hashCode();
}
}
public static void main(String[] args) {
Map<Key,String> m = new HashMap<Key,String>();
while(true) {
for(int i=0;i<10000;i++) {
if(!m.containsKey(new Key(i))) {
m.put(new Key(i), "Number:" + i);
}
}
}
}
}
执行以上代码就会发生内存泄漏,第一次循环,map 里存有 10000 个 key value,但以后的每次循环都会新增 10000 个元素,由于 Key 这个 class 漏写了 equals 方法,致使对于每个新建立的 new Key(i) 对象,即便 i 相同也会被认定为属于两个不一样的对象,这样 m.containsKey(new Key(i)) 结果均为 false,结果就是 HashMap 中的元素将一直增长,解决方式也很简单,为 Key 添加 equals 方法便可,以下
@Override
public boolean equals(Object o) {
boolean response = false;
if (o instanceof Key) {
response = (((Key)o).id).equals(this.id);
}
return response;
}
对于这种内存泄漏致使的 OOM, 单纯地增大堆大小是没法解决根本问题的,只不过是延缓了 OOM 的发生,最根本的解决方式仍是要经过 heap dump analyzer 等方式来找出内存泄漏的代码来修复解决,后文会给出一个例子来分析
三、java.lang.OutOfMemoryError:GC overhead limit exceeded
Sun 官方对此的定义:超过98%的时间用来作 GC 而且回收了不到 2% 的堆内存时会抛出此异常
致使的后果就是因为通过几个 GC 后只回收了不到 2% 的内存,堆很快又会被填满,而后又频繁发生 GC,致使 CPU 负载很快就达到 100%,另外咱们知道 GC 会引发 「Stop The World 」的问题,阻塞工做线程,因此会致使严重的性能问题,产生这种 OOM 的缘由与「java.lang.OutOfMemoryError:Java heap space」相似,主要是因为分配大内存数组或内存泄漏致使的, 解决方案以下:
检查项目中是否有大量的死循环或有使用大内存的代码,优化代码。
dump 内存(后文会讲述如何 dump 出内存),检查是否存在内存泄露,若是没有,可考虑经过 -Xmx 参数设置加大内存。
四、java.lang.OutOfMemoryError:Permgen space
在 Java 8 之前有永久代(实际上是用永久代实现了方法区的功能)的概念,存放了被虚拟机加载的类,常量,静态变量,JIT 编译后的代码等信息,因此若是错误地频繁地使用 String.intern() 方法或运行期间生成了大量的代理类都有可能致使永久代溢出,解决方案以下
是否永久代设置的太小,若是能够,适应调大一点
检查代码是否有大量的反射操做
dump 以后经过 mat 检查是否存在大量因为反射生成的代码类
五、java.lang.OutOfMemoryError:Requested array size exceeds VM limit
该错误由 JVM 中的 native code 抛出。JVM 在为数组分配内存以前,会执行基于所在平台的检查:分配的数据结构是否在此平台中是可寻址的,平台通常容许分配的数据大小在 1 到 21 亿之间,若是超过了这个数就会抛出这种异常
碰到这种异常通常咱们只要检查代码中是否有建立超大数组的地方便可。
六、java.lang.OutOfMemoryError: Out of swap space
Java 应用启动的时候分被分配必定的内存空间(经过 -Xmx 及其余参数来指定), 若是 JVM 要求的总内存空间大小大于可用的本机内存,则操做系统会将内存中的部分数据交换到硬盘上
七、Out of memory:Kill process or sacrifice child
为了理解这个异常,咱们须要知识一些操做系统的知识,咱们知道,在操做系统中执行的程序,都是以进程的方式运行的,而进程是由内核调度的,在内核的调度任务中,有一个「Out of memory killer」的调度器,它会在系统可用内存不足时被激活,而后选择一个进程把它干掉,哪一个进程会被干掉呢,简单地说会优先干掉占用内存大的应用型进程
解决这种 OOM 最直接简单的方法就是升级内存,或者调整 OOM Killer 的优先级,减小应用的没必要要的内存使用等等
看了以上的各类 OOM 产生的状况,能够看出:GC 和是否发生 OOM 没有必然的因果关系! GC 主要发生在堆上,而 从以上列出的几种发生 OOM 的场景能够看出,空间不足没法再建立线程,或者存在死循环一直在分配对象致使 GC 没法回收对象或者一次分配大内存数组(超过堆的大小)等均可能致使 OOM, 因此 OOM 与 GC 并无必然的因果关系
接下来咱们来看下如何排查形成 OOM 的缘由,内存泄漏是最多见的形成 OOM 的一种缘由,因此接下来咱们以来看看怎么使用工具来排查这种问题,使用到的工具主要有两大类
一、使用 mat(Eclipse Memory Analyzer) 来分析 dump(堆转储快照) 文件
主要步骤以下
运行 Java 时添加 「-XX:+HeapDumpOnOutOfMemoryError」 参数来导出内存溢出时的堆信息,生成 hrof 文件, 添加 「-XX:HeapDumpPath」能够指定 hrof 文件的生成路径,若是不指定则 hrof 文件生成在与字节码文件相同的目录下
使用 MAT(Eclipse Memory Analyzer)来分析 hrof 文件,查出内存泄漏的缘由
接下来咱们就来看看如何用以上的工具查看以下内存泄漏案例
/**
* VM Args:-Xmx10m
*/
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
while (true) {
list.add("OutOfMemoryError soon");
}
}
}
为了让以上程序快速产生 OOM, 我把堆大小设置成了 10M, 这样执行 「java -Xmx10m -XX:+HeapDumpOnOutOfMemoryError Main」后很快就发生了 OOM,此时咱们就拿到了 hrof 文件,下载 MAT 工具,打开 hrof,进行分析,打开以后选择 「Leak Suspects Report」进行分析,能够看到发生 OOM 的线程的堆栈信息,明肯定位到是哪一行形成的
二、使用 jvisualvm 来分析
用第一种方式必须等 OOM 后才能 dump 出 hprof 文件,但若是咱们想在运行中观察堆的使用状况以便查出可能的内存泄漏代码就无能为力了,这时咱们能够借助 jvisualvm 这款工具, jvisualvm 的功能强大,除了能够实时监控堆内存的使用状况,还能够跟踪垃圾回收,运行中 dump 中堆内存使用状况、cpu分析,线程分析等,是查找分析问题的利器,更骚的是它不光能分析本地的 Java 程序,还能够分析线上的 Java 程序运行状况, 自己这款工具也是随 JDK 发布的,是官方力推的一款运行监视,故障处理的神器。咱们来看看如何用 jvisualvm 来分析上文所述的存在内存泄漏的以下代码
import java.util.Map;
import java.util.HashMap;
public class KeylessEntry {
static class Key {
Integer id;
Key(Integer id) {
this.id = id;
}
@Override
public int hashCode() {
return id.hashCode();
}
}
public static void main(String[] args) {
Map<Key,String> m = new HashMap<Key,String>();
while(true) {
for(int i=0;i<10000;i++) {
if(!m.containsKey(new Key(i))) {
m.put(new Key(i), "Number:" + i);
}
}
}
}
}
打开 jvisualvm (终端输入 jvisualvm 执行便可),打开后,将堆大小设置为 500M,执行命令 java Xms500m -Xmx500m KeylessEntry,此时能够观察到左边出现了对应的应用 KeylessEntry,双击点击 open
打开以后能够看到展现了 CPU,堆内存使用,加载类及线程的状况
注意看堆(Heap)的使用状况,一直在上涨
此时咱们再点击 「Heap Dump」
过一下子便可看到内存中对象的使用状况
能够看到相关的 TreeNode 有 291w 个,远超正常状况下的 10000 个!说明 HashMap 一直在增加,自此咱们能够定位出问题代码所在!
三、使用 jps + jmap 来获取 dump 文件
jps 能够列出正在运行的虚拟机进程,并显示执行虚拟机主类及这些进程的本地虚拟机惟一 ID,如图示
拿到进程的 pid 后,咱们就能够用 jmap 来 dump 出堆转储文件了,执行命令以下
jmap -dump:format=b,file=heapdump.phrof pid
拿到 dump 文件后咱们就能够用 MAT 工具来分析了。
但这个命令在生产上必定要慎用!由于JVM 会将整个 heap 的信息 dump 写入到一个文件,heap 比较大的话会致使这个过程比较耗时,而且执行过程当中为了保证 dump 的信息是可靠的,会暂停应用!
接下来咱们看看 GC 日志怎么看,日志能够有效地帮助咱们定位问题,因此搞清楚 GC 日志的格式很是重要,来看下以下例子
/**
* VM Args:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseSerialGC -XX:SurvivorRatio=8
*/
public class TestGC {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; // 这里会出现一次 Minor GC
}
}
执行以上代码,会输出以下 GC 日志信息
10.080: 2[GC 3(Allocation Failure) 0.080: 4[DefNew: 56815K->280K(9216K),6 0.0043690 secs] 76815K->6424K(19456K), 80.0044111 secs]9 [Times: user=0.00 sys=0.01, real=0.01 secs]
以上是发生 Minor GC 的 GC 是日志,若是发生 Full GC 呢,格式以下
10.088: 2[Full GC 3(Allocation Failure) 0.088: 4[Tenured: 50K->210K(10240K), 60.0009420 secs] 74603K->210K(19456K), [Metaspace: 2630K->2630K(1056768K)], 80.0009700 secs]9 [Times: user=0.01 sys=0.00, real=0.02 secs]
二者格式其实差很少,一块儿来看看,主要以本例触发的 Minor GC 来说解, 以上日志中标的每个数字与如下序号一一对应
开头的 0.080,0.088 表明了 GC 发生的时间,这个数字的含义是从 Java 虚拟机启动以来通过的秒数
[GC 或者 [Full GC 说明了此次垃圾收集的停顿类型,注意不是用来区分新生代 GC 仍是老年化 GC 的,若是有 Full,说明此次 GC 是发生了 Stop The World 的,若是是调用 System.gc() 所触发的收集,这里会显示 [Full GC(System)
以后的 Allocation Failure 表明了触发 GC 的缘由,在这个程序中咱们设置了新生代的大小为 10M(-Xmn10M),Eden:S0:S1 = 8:1:1(-XX:SurvivorRatio=8),也就是说 Eden 区占了 8M, 当分配 allocation4 时,因为将要分配的总大小为 10M,超过了 Eden 区,因此此时会发生 GC
接下来的 [DefNew,[Tenured,[Metaspace 表示 GC 发生的区域,这里显示的区域名与使用的 GC 收集器是密切相关的,在此例中因为新生代咱们使用了 Serial 收集器,此收集器新生代名为「Default New Generation」,因此显示的是 [DefNew,若是是 ParNew 收集器,新生代名称就会变为 [ParNew`,意为 「Parallel New Generation」,若是采用 「Parallel Scavenge」收集器,则配套的新生代名称为「PSYoungGen」,老年代与新生代同样,名称也是由收集器决定的
再日后 6815K->280K(9216K) 表示 「GC 前该内存区域已使用容量 -> GC 后该内存区域已使用容量(该内存区域总容量)」
0.0043690 secs 表示该块内存区域 GC 所占用的时间,单位是秒
6815K->6424K(19456K) 表示「GC 前 Java 堆已使用容量 -> GC 后 Java 堆已使用容量(java 堆总容量)」。
0.0044111 secs 表示整个 GC 执行时间,注意和 6 中 0.0043690 secs 的区别,后者专指相关区域所花的 GC 时间,而前者指的 GC 的总体堆内存变化所花时间(新生代与老生代的的内存整理),因此前者是确定大于后者的!
最后一个 [Times: user=0.01 sys=0.00, real=0.02 secs] 这里的 user, sys 和 real 与Linux 的 time 命令所输出的时间一致,分别表明用户态消耗的 CPU 时间,内核态消耗的 CPU 时间,和操做从开始到结束所通过的墙钟时间,墙钟时间包括各类非运算的等待耗时,例如等待磁盘 I/O,等待线程阻塞,而 CPU 时间不包括这些耗时,但当系统有多 CPU 或者多核的话,多线程操做会叠加这些 CPU 时间,因此 user 或 sys 时间是可能超过 real 时间的。
知道了 GC 日志怎么看,咱们就能够根据 GC 日志有效定位问题了,如咱们发现 Full GC 发生时间过长,则结合咱们上文应用中打印的 OOM 日志可能能够快速定位到问题
jstat 是用于监视虚拟机各类运行状态信息的命令行工具,能够显示本地或者远程虚拟机进程中的类加载,内存,垃圾收集,JIT 编译等运行数据,jstat 支持定时查询相应的指标,以下
jstat -gc 2764 250 22
定时针对 2764 进程输出堆的垃圾收集状况的统计,能够显示 gc 的信息,查看gc的次数及时间,利用这些指标,把它们可视化,对分析问题会有很大的帮助,如图示,下图就是我司根据 jstat 作的一部分 gc 的可视化报表,能快速定位发生问题的问题点,若是你们须要作 APM 可视化工具,建议配合使用 jstat 来完成。
通过前面对 JVM 参数的介绍及相关例子的实验,相信你们对 JVM 的参数有了比较深入的理解,接下来咱们再谈谈如何设置 JVM 参数
一、首先 Oracle 官方推荐堆的初始化大小与堆可设置的最大值通常是相等的,即 Xms = Xmx,由于起始堆内存过小(Xms),会致使启动初期频繁 GC,起始堆内存较大(Xmx)有助于减小 GC 次数
二、调试的时候设置一些打印参数,如-XX:+PrintClassHistogram -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC -Xloggc:log/gc.log,这样能够从gc.log里看出一些端倪出来
三、系统停顿时间过长多是 GC 的问题也多是程序的问题,多用 jmap 和 jstack 查看,或者killall -3 Java,而后查看 Java 控制台日志,能看出不少问题
四、 采用并发回收时,年轻代小一点,年老代要大,由于年老大用的是并发回收,即便时间长点也不会影响其余程序继续运行,网站不会停顿
五、仔细了解本身的应用,若是用了缓存,那么年老代应该大一些,缓存的 HashMap 不该该无限制长,建议采用 LRU 算法的 Map 作缓存,LRUMap 的最大长度也要根据实际状况设定
要设置好各类 JVM 参数,还能够对 server 进行压测, 预估本身的业务量,设定好一些 JVM 参数进行压测看下这些设置好的 JVM 参数是否能知足要求
本文经过详细介绍了 JVM 参数及 GC 日志, OOM 发生的缘由及相应的调试工具,相信读者应该掌握了基本的 MAT,jvisualvm 这些工具排查问题的技巧,不过这些工具的介绍本文只是提到了一些皮毛,你们能够在再深刻了解相应工具的一些进阶技能,这能对本身排查问题等大有裨益!文中的例子你们能够去试验一下,修改一下参数看下会发生哪些神奇的现象,亲自动手作一遍能对排查问题的思路更加清晰哦
欢迎关注公众号与笔者共同交流哦^_^
http://songkun.me/2018/12/19/2018-12-19-java-why-xmx-xms-should-be-same/
https://plumbr.io/outofmemoryerror
https://yq.aliyun.com/articles/512832
想知道更多?长按/扫码关注我吧↓↓↓