Java性能调优(十一)JVM调优(2)

1,322 阅读27分钟

三、优化垃圾回收机制

垃圾回收机制

掌握 GC 算法之前,我们需要先弄清楚 3 个问题。第一,回收发生在哪里?第二,对象在什么时候可以被回收?第三,如何回收这些对象?

1. 回收发生在哪里?

JVM 的内存区域中,程序计数器、虚拟机栈和本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁;栈中的栈帧随着方法的进入和退出进行入栈和出栈操作,每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的,因此这三个区域的内存分配和回收都具有确定性。

那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。

2. 对象在什么时候可以被回收?

那 JVM 又是怎样判断一个对象是可以被回收的呢?一般一个对象不再被引用,就代表该对象可以被回收。目前有以下两种算法可以判断该对象是否可以被回收。

引用计数算法:这种算法是通过一个对象的引用计数器来判断该对象是否被引用了。每当对象被引用,引用计数器就会加 1;每当引用失效,计数器就会减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。这里强调一点,虽然引用计数算法的实现简单,判断效率也很高,但它存在着对象之间相互循环引用的问题。

可达性分析算法:GC Roots 是该算法的基础,GC Roots 是所有对象的根对象,在 JVM 加载时,会创建一些普通对象引用正常对象。这些对象作为正常对象的起始点,在垃圾回收时,会从这些 GC Roots 开始向下搜索,当一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可用的。目前 HotSpot 虚拟机采用的就是这种算法。

以上两种算法都是通过引用来判断对象是否可以被回收。在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种:

3. 如何回收这些对象?

了解完 Java 程序中对象的回收条件,那么垃圾回收线程又是如何回收这些对象的呢?JVM 垃圾回收遵循以下两个特性。

自动性:Java 提供了一个系统级的线程来跟踪每一块分配出去的内存空间,当 JVM 处于空闲循环时,垃圾收集器线程会自动检查每一块分配出去的内存空间,然后自动回收每一块空闲的内存块。

不可预期性:一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的。我们很难确定一个没有被引用的对象是不是会被立刻回收掉,因为有可能当程序结束后,这个对象仍在内存中。

垃圾回收线程在 JVM 中是自动执行的,Java 程序无法强制执行。我们唯一能做的就是通过调用 System.gc 方法来"建议"执行垃圾收集器,但是否可执行,什么时候执行?仍然不可预期。

GC 算法

JVM 提供了不同的回收算法来实现这一套回收机制,通常垃圾收集器的回收算法可以分为以下几种: 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,JDK1.7 update14 之后 Hotspot 虚拟机所有的回收器整理如下(以下为服务端垃圾收集器): 其实在 JVM 规范中并没有明确 GC 的运作方式,各个厂商可以采用不同的方式实现垃圾收集器。我们可以通过 JVM 工具查询当前 JVM 使用的垃圾收集器类型,首先通过 ps 命令查询出经常 ID,再通过 jmap -heap ID 查询出 JVM 的配置信息,其中就包括垃圾收集器的设置类型。

GC 性能衡量指标

一个垃圾收集器在不同场景下表现出的性能也不一样,那么如何评价一个垃圾收集器的性能好坏呢?我们可以借助一些指标。

吞吐量:这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于 95%。

停顿时间:指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。

垃圾回收频率:多久发生一次指垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。

查看 & 分析 GC 日志

已知了性能衡量指标,现在我们需要通过工具查询 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:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs

打印后的日志为: 上图是运行很短时间的 GC 日志,如果是长时间的 GC 日志,我们很难通过文本形式去查看整体的 GC 性能。此时,我们可以通过GCView工具打开日志文件,图形化界面查看整体的 GC 性能,如下图所示: 通过工具,我们可以看到吞吐量、停顿时间以及 GC 的频率,从而可以非常直观地了解到 GC 的性能情况。

这里我再推荐一个比较好用的 GC 日志分析工具,GCeasy是一款非常直观的 GC 日志分析工具,我们可以将日志文件压缩之后,上传到 GCeasy 官网即可看到非常清楚的 GC 日志分析结果:

GC 调优策略

找出问题后,就可以进行调优了,下面介绍几种常用的 GC 调优策略。

1. 降低 Minor GC 频率

通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。

可能你会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?如果单次 Minor GC 的时间增加,那也很难达到我们期待的优化效果呀。

我们知道,单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2。

当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1。

可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。

如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。

2. 降低 Full GC 的频率

通常情况下,由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。我们可以使用哪些方法来降低 Full GC 的频率呢?

减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC。

我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。

增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。

选择合适的 GC 回收器

假设我们有这样一个需求,要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 回收器,CMS(Concurrent Mark Sweep)回收器和 G1 回收器都是不错的选择。

而当我们的需求对系统吞吐量有要求时,就可以选择 Parallel Scavenge 回收器来提高系统的吞吐量。

四、优化JVM内存分配

JVM 内存分配性能问题

谈到 JVM 内存表现出的性能问题时,你可能会想到一些线上的 JVM 内存溢出事故。但这方面的事故往往是应用程序创建对象导致的内存回收对象难,一般属于代码编程问题。

但其实很多时候,在应用服务的特定场景下,JVM 内存分配不合理带来的性能表现并不会像内存溢出问题这么突出。可以说如果你没有深入到各项性能指标中去,是很难发现其中隐藏的性能损耗。

JVM 内存分配不合理最直接的表现就是频繁的 GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。因此,如果你在线上环境或性能测试时,发现频繁的 GC,且是正常的对象创建和回收,这个时候就需要考虑调整 JVM 内存分配了,从而减少 GC 所带来的性能开销。

对象在堆中的生存周期

了解了性能问题,那需要做的势必就是调优了。但先别急,在了解 JVM 内存分配的调优过程之前,我们先来看看一个新创建的对象在堆内存中的生存周期,为后面的学习打下基础。

我们知道,在 JVM 内存模型的堆中,堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 区和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。

当我们新建一个对象时,对象会被优先分配到新生代的 Eden 区中,这时虚拟机会给对象定义一个对象年龄计数器(通过参数 -XX:MaxTenuringThreshold 设置)。

同时,也有另外一种情况,当 Eden 空间不足时,虚拟机将会执行一个新生代的垃圾回收(Minor GC)。这时 JVM 会把存活的对象转移到 Survivor 中,并给对象的年龄 +1。对象在 Survivor 中同样也会经历 MinorGC,每经过一次 MinorGC,对象的年龄将会 +1。

当然了,内存空间也是有设置阈值的,可以通过参数 -XX:PetenureSizeThreshold 设置直接被分配到老年代的最大对象,这时如果分配的对象超过了设置的阀值,对象就会直接被分配到老年代,这样做的好处就是可以减少新生代的垃圾回收。

查看 JVM 堆内存分配 我们知道了一个对象从创建至回收到堆中的过程,接下来我们再来了解下 JVM 堆内存是如何分配的。在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。我们可以通过以下命令来查看堆内存配置的默认值:

java -XX:+PrintFlagsFinal -version | grep HeapSize 
jmap -heap 17284

通过命令,我们可以获得在这台机器上启动的 JVM 默认最大堆内存为 1953MB,初始化大小为 124MB。

在 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 配置项的。

还有,在 JDK1.8 中,不要随便关闭 UseAdaptiveSizePolicy 配置项,除非你已经对初始化堆内存 / 最大堆内存、年轻代 / 老年代以及 Eden 区 /Survivor 区有非常明确的规划了。否则 JVM 将会分配最小堆内存,年轻代和老年代按照默认比例 1:2 进行分配,年轻代中的 Eden 和 Survivor 则按照默认比例 8:2 进行分配。这个内存分配未必是应用服务的最佳配置,因此可能会给应用服务带来严重的性能问题。

JVM 内存分配的调优过程

我们先使用 JVM 的默认配置,观察应用服务的运行情况,下面我将结合一个实际案例来讲述。现模拟一个抢购接口,假设需要满足一个 5W 的并发请求,且每次请求会产生 20KB 对象,我们可以通过千级并发创建一个 1MB 对象的接口来模拟万级并发请求产生大量对象的场景,具体代码如下:

	@RequestMapping(value = "/test1")
	public String test1(HttpServletRequest request) {
		List<Byte[]> temp = new ArrayList<Byte[]>();
		
		Byte[] b = new Byte[1024*1024];
		temp.add(b);
		
		return "success";
	}

AB 压测

分别对应用服务进行压力测试,以下是请求接口的吞吐量和响应时间在不同并发用户数下的变化情况:

可以看到,当并发数量到了一定值时,吞吐量就上不去了,响应时间也迅速增加。那么,在 JVM 内部运行又是怎样的呢?

分析 GC 日志

此时我们可以通过 GC 日志查看具体的回收日志。我们可以通过设置 VM 配置参数,将运行期间的 GC 日志 dump 下来,具体配置参数如下:

 -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log

以下是各个配置项的说明:

  • -XX:PrintGCTimeStamps:打印 GC 具体时间;
  • -XX:PrintGCDetails :打印出 GC 详细日志;
  • -Xloggc: path:GC 日志生成路径。

收集到 GC 日志后,我们就可以使用第 22 讲中介绍过的 GCViewer 工具打开它,进而查看到具体的 GC 日志如下: 主页面显示 FullGC 发生了 13 次,右下角显示年轻代和老年代的内存使用率几乎达到了 100%。而 FullGC 会导致 stop-the-world 的发生,从而严重影响到应用服务的性能。此时,我们需要调整堆内存的大小来减少 FullGC 的发生。

参考指标

我们可以将某些指标的预期值作为参考指标,上面的 GC 频率就是其中之一,那么还有哪些指标可以为我们提供一些具体的调优方向呢?

GC 频率:高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。

内存:这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加 FullGC,严重的将导致 CPU 持续爆满,影响系统性能。

吞吐量:频繁的 FullGC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。

延时:JVM 的 GC 持续时间也会影响到每次请求的响应时间。

具体调优方法

调整堆内存空间减少 FullGC:通过日志分析,堆内存基本被用完了,而且存在大量 FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间。

java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar

以下是各个配置项的说明:

-Xms:堆初始大小;
-Xmx:堆最大值。

调大堆内存之后,我们再来测试下性能情况,发现吞吐量提高了 40% 左右,响应时间也降低了将近 50%。 再查看 GC 日志,发现 FullGC 频率降低了,老年代的使用率只有 16% 了。

调整年轻代减少 MinorGC:通过调整堆内存大小,我们已经提升了整体的吞吐量,降低了响应时间。那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些 MinorGC(第 22 讲有通过降低 Minor GC 频率来提高系统性能的详解)。

java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar

再进行 AB 压测,发现吞吐量上去了。 再查看 GC 日志,发现 MinorGC 也明显降低了,GC 花费的总时间也减少了。

设置 Eden、Survivor 区比例:在 JVM 中,如果开启 AdaptiveSizePolicy,则每次 GC 后都会重新计算 Eden、From Survivor 和 To Survivor 区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候 SurvivorRatio 默认设置的比例会失效。

在 JDK1.8 中,默认是开启 AdaptiveSizePolicy 的,我们可以通过 -XX:-UseAdaptiveSizePolicy 关闭该项配置,或显示运行 -XX:SurvivorRatio=8 将 Eden、Survivor 的比例设置为 8:2。大部分新对象都是在 Eden 区创建的,我们可以固定 Eden 区的占用比例,来调优 JVM 的内存分配性能。

再进行 AB 性能测试,我们可以看到吞吐量提升了,响应时间降低了。

五、内存持续上升,该如何排查问题?

常用的监控和诊断内存工具

Linux 命令行工具之 top 命令

top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。 top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。 除了简单的 top 之外,我们还可以通过 top -Hp pid 查看具体线程使用系统资源情况:

Linux 命令行工具之 vmstat 命令

vmstat 是一款指定采样周期和次数的功能性监测工具,我们可以看到,它不仅可以统计内存的使用情况,还可以观测到 CPU 的使用率、swap 的使用情况。但 vmstat 一般很少用来查看内存的使用情况,而是经常被用来观察进程的上下文切换。

  • r:等待运行的进程数;
  • b:处于非中断睡眠状态的进程数;
  • swpd:虚拟内存使用情况;
  • free:空闲的内存;
  • buff:用来作为缓冲的内存数;
  • si:从磁盘交换到内存的交换页数量;
  • so:从内存交换到磁盘的交换页数量;
  • bi:发送到块设备的块数;
  • bo:从块设备接收到的块数;
  • in:每秒中断数;
  • cs:每秒上下文切换次数;
  • us:用户 CPU 使用时间;
  • sy:内核 CPU 系统使用时间;
  • id:空闲时间;
  • wa:等待 I/O 时间;
  • st:运行虚拟机窃取的时间。

Linux 命令行工具之 pidstat 命令

pidstat 是 Sysstat 中的一个组件,也是一款功能强大的性能监测工具,我们可以通过命令:yum install sysstat 安装该监控组件。之前的 top 和 vmstat 两个命令都是监测进程的内存、CPU 以及 I/O 使用情况,而 pidstat 命令则是深入到线程级别。

通过 pidstat -help 命令,我们可以查看到有以下几个常用的参数来监测线程的性能: 常用参数:

  • -u:默认的参数,显示各个进程的 cpu 使用情况;
  • -r:显示各个进程的内存使用情况;
  • -d:显示各个进程的 I/O 使用情况;
  • -w:显示每个进程的上下文切换情况;
  • -p:指定进程号;
  • -t:显示进程中线程的统计信息。

我们可以通过相关命令(例如 ps 或 jps)查询到相关进程 ID,再运行以下命令来监测该进程的内存使用情况:

其中 pidstat 的参数 -p 用于指定进程 ID,-r 表示监控内存的使用情况,1 表示每秒的意思,3 则表示采样次数。

其中显示的几个关键指标的含义是:

  • Minflt/s:任务每秒发生的次要错误,不需要从磁盘中加载页;
  • Majflt/s:任务每秒发生的主要错误,需要从磁盘中加载页;
  • VSZ:虚拟地址大小,虚拟内存使用 KB;
  • RSS:常驻集合大小,非交换区内存使用 KB。

如果我们需要继续查看该进程下的线程内存使用率,则在后面添加 -t 指令即可:

我们知道,Java 是基于 JVM 上运行的,大部分内存都是在 JVM 的用户内存中创建的,所以除了通过以上 Linux 命令来监控整个服务器内存的使用情况之外,我们更需要知道 JVM 中的内存使用情况。JDK 中就自带了很多命令工具可以监测到 JVM 的内存分配以及使用情况。

JDK 工具之 jstat 命令

jstat 可以监测 Java 应用程序的实时运行情况,包括堆内存信息以及垃圾回收信息。我们可以运行 jstat -help 查看一些关键参数信息: 再通过 jstat -option 查看 jstat 有哪些操作:

  • -class:显示 ClassLoad 的相关信息;
  • -compiler:显示 JIT 编译的相关信息;
  • -gc:显示和 gc 相关的堆信息;
  • -gccapacity:显示各个代的容量以及使用情况;
  • -gcmetacapacity:显示 Metaspace 的大小;
  • -gcnew:显示新生代信息;
  • -gcnewcapacity:显示新生代大小和使用情况;
  • -gcold:显示老年代和永久代的信息;
  • -gcoldcapacity :显示老年代的大小;
  • -gcutil:显示垃圾收集信息;
  • -gccause:显示垃圾回收的相关信息(通 -gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
  • -printcompilation:输出 JIT 编译的方法信息。

它的功能比较多,在这里我例举一个常用功能,如何使用 jstat 查看堆内存的使用情况。我们可以用 jstat -gc pid 查看:

  • S0C:年轻代中 To Survivor 的容量(单位 KB);
  • S1C:年轻代中 From Survivor 的容量(单位 KB);
  • S0U:年轻代中 To Survivor 目前已使用空间(单位 KB);
  • S1U:年轻代中 From Survivor 目前已使用空间(单位 KB);
  • EC:年轻代中 Eden 的容量(单位 KB);
  • EU:年轻代中 Eden 目前已使用空间(单位 KB);
  • OC:Old 代的容量(单位 KB);
  • OU:Old 代目前已使用空间(单位 KB);
  • MC:Metaspace 的容量(单位 KB);
  • MU:Metaspace 目前已使用空间(单位 KB);
  • YGC:从应用程序启动到采样时年轻代中 gc 次数;
  • YGCT:从应用程序启动到采样时年轻代中 gc 所用时间 (s);
  • FGC:从应用程序启动到采样时 old 代(全 gc)gc 次数;
  • FGCT:从应用程序启动到采样时 old 代(全 gc)gc 所用时间 (s);
  • GCT:从应用程序启动到采样时 gc 用的总时间 (s)。

JDK 工具之 jstack 命令

它是一种线程堆栈分析工具,最常用的功能就是使用 jstack pid 命令查看线程的堆栈信息,通常会结合 top -Hp pid 或 pidstat -p pid -t 一起查看具体线程的状态,也经常用来排查一些死锁的异常。 每个线程堆栈的信息中,都可以查看到线程 ID、线程的状态(wait、sleep、running 等状态)以及是否持有锁等。

JDK 工具之 jmap 命令

jmap 可以查看堆内存初始化配置信息以及堆内存的使用情况。那么除了这个功能,我们其实还可以使用 jmap 输出堆内存中的对象信息,包括产生了哪些对象,对象数量多少等。

我们可以用 jmap 来查看堆内存初始化配置信息以及堆内存的使用情况: 我们可以使用 jmap -histo[:live] pid 查看堆内存中的对象数目、大小统计直方图,如果带上 live 则只统计活对象: 我们可以通过 jmap 命令把堆内存的使用情况 dump 到文件中: 我们可以将文件下载下来,使用 MAT 工具打开文件进行分析。

实战演练

我们平时遇到的内存溢出问题一般分为两种,一种是由于大峰值下没有限流,瞬间创建大量对象而导致的内存溢出;另一种则是由于内存泄漏而导致的内存溢出。

使用限流,我们一般就可以解决第一种内存溢出问题,但其实很多时候,内存溢出往往是内存泄漏导致的,这种问题就是程序的 BUG,我们需要及时找到问题代码。

下面我模拟了一个内存泄漏导致的内存溢出案例,我们来实践一下。

我们知道,ThreadLocal 的作用是提供线程的私有变量,这种变量可以在一个线程的整个生命周期中传递,可以减少一个线程在多个函数或类中创建公共变量来传递信息,避免了复杂度。但在使用时,如果 ThreadLocal 使用不恰当,就可能导致内存泄漏。

这个案例的场景就是 ThreadLocal,下面我们创建 100 个线程。运行以下代码,系统一会儿就发送了内存溢出异常:

final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(100, 100, 1, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>());// 创建线程池,通过线程池,保证创建的线程存活
	
	final static ThreadLocal<Byte[]> localVariable = new ThreadLocal<Byte[]>();// 声明本地变量
	
	@RequestMapping(value = "/test0")
	public String test0(HttpServletRequest request) {
		  poolExecutor.execute(new Runnable() {
              public void run() {
          		  Byte[] c = new Byte[4096*1024];
                  localVariable.set(c);// 为线程添加变量
 
              }
          });
		return "success";
	}
	
	@RequestMapping(value = "/test1")
	public String test1(HttpServletRequest request) {
		List<Byte[]> temp1 = new ArrayList<Byte[]>();
		
		Byte[] b = new Byte[1024*20];
		temp1.add(b);// 添加局部变量
		
		return "success";
	}

在启动应用程序之前,我们可以通过 HeapDumpOnOutOfMemoryError 和 HeapDumpPath 这两个参数开启堆内存异常日志,通过以下命令启动应用程序:

java -jar -Xms1000m -Xmx4000m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -Xms1g -Xmx1g -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/heapTest.log heapTest-0.0.1-SNAPSHOT.jar

首先,请求 test0 链接 10000 次,之后再请求 test1 链接 10000 次,这个时候我们请求 test1 的接口报异常了。 通过日志,我们很好分辨这是一个内存溢出异常。我们首先通过 Linux 系统命令查看进程在整个系统中内存的使用率是多少,最简单就是 top 命令了。 从 top 命令查看进程的内存使用情况,可以发现在机器只有 8G 内存且只分配了 4G 内存给 Java 进程的情况下,Java 进程内存使用率已经达到了 55%,再通过 top -Hp pid 查看具体线程占用系统资源情况。 再通过 jstack pid 查看具体线程的堆栈信息,可以发现该线程一直处于 TIMED_WAITING 状态,此时 CPU 使用率和负载并没有出现异常,我们可以排除死锁或 I/O 阻塞的异常问题了。 我们再通过 jmap 查看堆内存的使用情况,可以发现,老年代的使用率几乎快占满了,而且内存一直得不到释放: 通过以上堆内存的情况,我们基本可以判断系统发生了内存泄漏。下面我们就需要找到具体是什么对象一直无法回收,什么原因导致了内存泄漏。

我们需要查看具体的堆内存对象,看看是哪个对象占用了堆内存,可以通过 jstat 查看存活对象的数量: Byte 对象占用内存明显异常,说明代码中 Byte 对象存在内存泄漏,我们在启动时,已经设置了 dump 文件,通过 MAT 打开 dump 的内存日志文件,我们可以发现 MAT 已经提示了 byte 内存异常: 再点击进入到 Histogram 页面,可以查看到对象数量排序,我们可以看到 Byte[] 数组排在了第一位,选中对象后右击选择 with incomming reference 功能,可以查看到具体哪个对象引用了这个对象。 在这里我们就可以很明显地查看到是 ThreadLocal 这块的代码出现了问题。



在一些比较简单的业务场景下,排查系统性能问题相对来说简单,且容易找到具体原因。但在一些复杂的业务场景下,或是一些开源框架下的源码问题,相对来说就很难排查了,有时候通过工具只能猜测到可能是某些地方出现了问题,而实际排查则要结合源码做具体分析