常见的内存问题分析思路
一 系统内存不足
Java 应用一般都有单机或者集群的内存水位监控,如果单机的内存利用率大于 95%,或者集群的内存利用率大于80%,就说明可能存在潜在的内存问题(注:这里的内存水位是系统内存)。
除了一些较极端的情况,一般系统内存不足,大概率是由 Java 应用引起的。使用 top 命令时,我们可以看到 Java 应用进程的实际内存占用,其中 RES 表示进程的常驻内存使用,VIRT 表示进程的虚拟内存占用,内存大小的关系为:VIRT > RES > Java 应用实际使用的堆大小。除了堆内存,Java 进程整体的内存占用,还有方法区/元空间、JIT 缓存等,主要组成如下:
Java 应用内存占用 = Heap(堆区)+ Code Cache(代码缓存区) + Metaspace(元空间)+ Symbol tables(符号表)+ Thread stacks(线程栈区)+ Direct buffers(堆外内存)+ JVM structures(其他的一些 JVM 自身占用)+ Mapped files(内存映射文件)+ Native Libraries(本地库)+ ...
Java 进程的内存占用,可以使用 jstat -gc 命令查看,输出的指标中可以得到当前堆内存各分区、元空间的使用情况。堆外内存的统计和使用情况,可以利用 NMT(Native Memory Tracking,HotSpot VM Java8 引入)获取。线程栈使用的内存空间很容易被忽略,虽然线程栈内存采用的是懒加载的模式,不会直接使用 +Xss 的大小来分配内存,但是过多的线程也会导致不必要的内存占用,可以使用 jstackmem 这个脚本统计整体的线程占用。
系统内存不足的排查思路:
- 首先使用 free 查看当前内存的可用空间大小,然后使用 vmstat 查看具体的内存使用情况及内存增长趋势,这个阶段一般能定位占用内存最多的进程;
- 分析缓存 / 缓冲区的内存使用。如果这个数值在一段时间变化不大,可以忽略。如果观察到缓存 / 缓冲区的大小在持续升高,则可以使用 pcstat、cachetop、slabtop 等工具,分析缓存 / 缓冲区的具体占用;
- 排除掉缓存 / 缓冲区对系统内存的影响后,如果发现内存还在不断增长,说明很有可能存在内存泄漏,具体分析过程见2.3 节。
二 Java 内存溢出
内存溢出是指应用新建一个对象实例时,所需的内存空间大于堆的可用空间。内存溢出的种类较多,一般会在报错日志里看到 OutOfMemoryError 关键字。常见内存溢出种类及分析思路如下:
- java.lang.OutOfMemoryError: Java heap space。原因:堆中(新生代和老年代)无法继续分配对象了、某些对象的引用长期被持有没有被释放,垃圾回收器无法回收、使用了大量的 Finalizer 对象,这些对象并不在 GC 的回收周期内等。一般堆溢出都是由于内存泄漏引起的,如果确认没有内存泄漏,可以适当通过增大堆内存。
- java.lang.OutOfMemoryError:GC overhead limit exceeded。原因:垃圾回收器超过98%的时间用来垃圾回收,但回收不到2%的堆内存,一般是因为存在内存泄漏或堆空间过小。
- java.lang.OutOfMemoryError: Metaspace或java.lang.OutOfMemoryError: PermGen space。排查思路:检查是否有动态的类加载但没有及时卸载,是否有大量的字符串常量池化,永久代/元空间是否设置过小等。
- java.lang.OutOfMemoryError : unable to create new native Thread。原因:虚拟机在拓展栈空间时,无法申请到足够的内存空间。可适当降低每个线程栈的大小以及应用整体的线程个数。此外,系统里总体的进程/线程创建总数也受到系统空闲内存和操作系统的限制,请仔细检查。注:这种栈溢出,和 StackOverflowError 不同,后者是由于方法调用层次太深,分配的栈内存不够新建栈帧导致。
此外,还有 Swap 分区溢出、本地方法栈溢出、数组分配溢出等 OutOfMemoryError 类型,由于不是很常见,就不一一介绍了。
三 Java 内存泄漏
Java 内存泄漏可以说是开发人员的噩梦,内存泄漏与内存溢出不同则,后者简单粗暴,现场也比较好找。内存泄漏的表现是:应用运行一段时间后,内存利用率越来越高,响应越来越慢,直到最终出现进程「假死」。
Java 内存泄漏可能会造成系统可用内存不足、进程假死、OOM 等,排查思路却不外乎下面两种:
- 通过 jmap 定期输出堆内对象统计,定位数量和大小持续增长的对象;
- 使用 Profiler 工具对应用进行Profiling,寻找内存分配热点。
此外,在堆内存持续增长时,建议 dump 一份堆内存的快照,后面可以基于快照做一些分析。快照虽然是瞬时值,但也是有一定的意义的。
四 垃圾回收相关
GC(垃圾回收,下同)的各项指标,是衡量 Java 进程内存使用是否健康的重要标尺。垃圾回收最核心指标:GC Pause(包括 MinorGC 和 MajorGC) 的频率和次数,以及每次回收的内存详情,前者可以通过 jstat 工具直接得到,后者需要分析 GC 日志。需要注意的是,jstat 输出列中的 FGC/FGCT 表示的是一次老年代垃圾回收中,出现 GC Pause (即 Stop-the-World)的次数,譬如对于 CMS 垃圾回收器,每次老年代垃圾回收这个值会增加2(初始标记和重新标记着两个 Stop-the-World 的阶段,这个统计值会是 2。
什么时候需要进行 GC 调优?这取决于应用的具体情况,譬如对响应时间的要求、对吞吐量的要求、系统资源限制等。一些经验:GC 频率和耗时大幅上升、GC Pause 平均耗时超过 500ms、Full GC 执行频率小于1分钟等,如果 GC 满足上述的一些特征,说明需要进行 GC 调优了。
由于垃圾回收器种类繁多,针对不同的应用,调优策略也有所区别,因此下面介绍几种通用的的 GC 调优策略。
1.选择合适的 GC 回收器。根据应用对延迟、吞吐的要求,结合各垃圾回收器的特点,合理选用。推荐使用 G1 替换 CMS 垃圾回收器,G1 的性能是在逐步优化的,在 8GB 内存及以下的机器上,其各方面的表现也在赶上甚至有超越之势。G1 调参较方便,而 CMS 垃圾回收器参数太过复杂、容易造成空间碎片化、对 CPU 消耗较高等弊端,也使其目前处于废弃状态。Java 11 里新引入的 ZGC 垃圾回收器,基本可用做到全阶段并发标记和回收,值得期待。
2.合理的堆内存大小设置。堆大小不要设置过大,建议不要超过系统内存的 75%,避免出现系统内存耗尽。最大堆大小和初始化堆的大小保持一致,避免堆震荡。新生代的大小设置比较关键,我们调整 GC 的频率和耗时,很多时候就是在调整新生代的大小,包括新生代和老年代的占比、新生代中 Eden 区和 Survivor 区的比例等,这些比例的设置还需要考虑各代中对象的晋升年龄,整个过程需要考虑的东西还是比较多的。如果使用 G1 垃圾回收器,新生代大小这一块需要考虑的东西就少很多了,自适应的策略会决定每一次的回收集合(CSet)。新生代的调整是 GC 调优的核心,非常依赖经验,但是一般来说,Young GC 频率高,意味着新生代太小(或 Eden 区和 Survivor 配置不合理),Young GC 时间长,意味着新生代过大,这两个方向大体不差。
3.降低 Full GC 的频率。如果出现了频繁的 Full GC 或者 老年代 GC,很有可能是存在内存泄漏,导致对象被长期持有,通过 dump 内存快照进行分析,一般能较快地定位问题。除此之外,新生代和老年代的比例不合适,导致对象频频被直接分配到老年代,也有可能会造成 Full GC,这个时候需要结合业务代码和内存快照综合分析。
此外,通过配置 GC 参数,可以帮助我们获取很多 GC 调优所需的关键信息,如配置-XX:+PrintGCApplicationStoppedTime-XX:+PrintSafepointStatistics-XX:+PrintTenuringDistribution,分别可以获取 GC Pause 分布、安全点耗时统计、对象晋升年龄分布的信息,加上 -XX:+PrintFlagsFinal 可以让我们了解最终生效的 GC 参数等。