前期准备
-
本项目均采用
VisualVM 2.1.10
进行dump文件的分析。JDK1.8及之前所在目录的bin
目录下有自带的VisualVM,JDK1.8以后需要自行手动安装下载。
下载地址:https://visualvm.github.io/download.html
IDEA插件配置:在Plugins
里搜索visualVM Launcher
即可。(也可以不用配置,直接下载客户端软件)后续只要在配置下载安装好的VisualVM程序地址即可,这样就能直接在IDEA中根据指定的类启动VisualVM了,不需要在独立的VisualVM里找指定路径装配。
-
项目采用
JDK1.8
对OOM溢出进行分析。在进行模拟时,需要配置一些参数,例如:
注意:
这里可以通过jmap指定打印他的内存快照dump文件,不过有的情况打印不了,我们会设置vm参数让程序自动生成dump文件。
-XX:+PrintGCDetails (让 JVM 在执行垃圾收集时输出详细的日志信息)
-XX:MetaspaceSize=64m
-XX:+HeapDumpOnOutOfMemoryError (打印出现OOM的异常信息dump文件)
-XX:HeapDumpPath=heap/heapdump3.hprof (打印的文件的具体位置)
-XX:SurvivorRatio=8 (设置年轻代中 Survivor 空间的比例)
-XX:+PrintGCDateStamps (使得 GC 日志中的每一条记录都会带上时间戳)
-Xms50M -Xmx50M
-Xloggc:log/gc-oomHeap.log (打印的GC日志文件的路径)
- 分析GC和dump文件:一般我们在命令中会设置JVM参数,执行启动类的时候会自动生成gc日志和dump文件。gc日志我们可以在
GC Easy
(https://gceasy.io/gc-dashboard.jsp)中分析即可。
接下来我们就要在VisualVM中装入我们要分析的dump文件即可。
案例一二三代码链接
https://pan.baidu.com/s/1C8IMG4ZXrqjQdYb4B-Z5gg 提取码: syhh
OOM案例一:堆溢出(Java heap space)
案例模拟
这是一个简单的SpringBoot项目,DemoApplication类
是整个当前模块项目的启动类,端口号为8848,如果端口号被占用,可以杀掉当前占用的进程(kill -9 PID),或者在application-dev.yml
中修改端口号:
我们模拟发送请求:http://localhost:8848/add
JVM参数配置
-XX:+PrintGCDetails
-XX:MetaspaceSize=64m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap/heapdump3.hprof
-XX:SurvivorRatio=8
-XX:+PrintGCDateStamps
-Xms50M -Xmx50M
-Xloggc:log/gc-oomHeap.log
运行结果
gc文件分析
在GC Easy
(https://gceasy.io/gc-dashboard.jsp)中分析即可:
通过下图可以看出,我们的程序进行了大量的Full GC操作,导致内存溢出了。
dump文件分析
首先我们在Summary中找到出现异常的线程,红色标记:
这时我们可以点击查看view all
:
找到出现异常线程的线程报告后,再找到我们代码中出现错误的行号(这点很重要)。另外,还可以通过对象分配来查看,是否存在大对象:
图片中标记为红色的区域显示了每个类的对象数量。例如,“com.atguigu.demo.bean.People”类有1,215,488个实例,这是所有对象中最多的。另一个红色的区域显示了每个类占用的内存大小。例如,“com.atguigu.demo.bean.People”类占用了大约39MB的内存空间。这表明该类可能存在内存泄漏或者过度分配的情况。
回到代码,果然:
原因及解决方法
原因
1、代码中可能存在大对象分配。
2、可能存在内存泄漏,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。
解决方法
1、检查是否存在大对象的分配,最有可能的是大数组分配。
2、如果没有找到明显的内存泄漏,使用 -Xmx 加大堆内存。
3、还有一点容易被忽略,检査是否有大量的自定义的 Finalizable 对象,也有可能是框架内部提供的,考虑其存在的必要性。
OOM案例二:元空间溢出(Metaspace)
案例模拟
这段代码使用了 CGLIB(Code Generation Library)框架中的 Enhancer 类来动态创建 People 类的子类实例。
JVM参数配置
-XX:+PrintGCDetails
-XX:MetaspaceSize=60m
-XX:MaxMetaspaceSize=60m
-Xss640K
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap/heapdumpMeta.hprof
-XX:SurvivorRatio=8
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-XX:+PrintGCDateStamps
-Xms60M -Xmx60M
-Xloggc:log/gc-oomMeta.log
运行结果
当我们正常启动程序时,使用VisualVM查看对应的类的元空间(Metaspace)。此时一切正常,最大元空间我们设置为60M,此时只使用了30M左右。
模拟一下,访问http://localhost:8848/metaSpaceOom,访问前记得先clear下控制台输出。
当我们访问http://localhost:8848/metaSpaceOom这个页面时,发现出现了OOM异常了。
gc文件分析
除了可以使用GC Easy
这个工具外呢,我们还可以使用控制台的jps
命令,列出正在运行的 JVM 进程的状态信息,包括进程 ID 和主类名称。然后使用jstat
命令统计对应进程ID的垃圾收集状况,每1秒钟执行一次。
可以看到元空间已使用的空间(MU)非常接近于上限了(MC),而且出现了大量的Full GC(829次)。
如果直接观察生成的gc日志,我们也不难看出:后期出现了大篇幅的Full GC。
dump文件分析
回到VisualVM,我们明显可以看到元空间占用和CPU明显飙升了,出现了满负载的情况,元空间也达到了我们所预设的最大上限。
老样子,在Summary
中找出现OOM的线程,定位到具体的代码行号。
原因及解决方法
JDK8后,元空间替换了永久代,元空间使用的是本地内存。
原因:
1.运行期间生成了大量的代理类,导致方法区被撑爆,无法卸载
2.应用长时间运行,没有重启
3.元空间内存设置过小
解决方法:
因为该 OOM 原因比较简单,解决方法有如下几种:
1.检查是否永久代空间或者元空间设置的过小
2.检查代码中是否存在大量的反射操作
3.dump之后通过VisualVM检査是否存在大量由于反射生成的代理类
当 setUseCache(true)
时,CGLIB 会尝试重用已存在的类定义,从而减少了类定义的数量,避免了由于频繁创建类而导致的内存问题。因此,设置为 true 可以有效防止因类定义过多而导致的内存溢出。
OOM案例三:GC overhead limit exceeded
案例模拟
注意模拟的时候,一定要分开执行,每次调整JVM的日志和dump输出的文件名,避免冲突!!!
Java源代码:
package com.atguigu.oom;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 案例3:测试 GC overhead limit exceeded
* @author shkstart
* @create 16:57
*/
public class OOMTest {
public static void main(String[] args) {
// 出现GC overhead limit exceeded异常
test1();
// 出现Java heap space异常
test2();
}
public static void test1() {
int i = 0;
List<String> list = new ArrayList<>();
try {
while (true) {
list.add(UUID.randomUUID().toString().intern());
i++;
}
} catch (Throwable e) {
System.out.println("************i: " + i);
e.printStackTrace();
throw e;
}
}
public static void test2() {
String str = "";
Integer i = 1;
try {
while (true) {
i++;
str += UUID.randomUUID();
}
} catch (Throwable e) {
System.out.println("************i: " + i);
e.printStackTrace();
throw e;
}
}
}
JVM参数配置
执行test1():
-XX:+PrintGCDetails
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap/dumpExceeded.hprof
-XX:+PrintGCDateStamps
-Xms5M
-Xmx5M
-Xloggc:log/gc-oomExceeded.log
执行test2():
-XX:+PrintGCDetails
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap/dumpExceeded1.hprof
-XX:+PrintGCDateStamps
-Xms5M
-Xmx5M
-Xloggc:log/gc-oomExceeded1.log
运行结果
执行test1():
执行test2():
gc文件分析
通过查看GC日志可以发现,系统在频繁性的做FULL GC,但是却没有回收掉多少空间,那么引起的原因可能是因为内存不足,也可能是存在内存泄漏的情况,接下来我们要根据堆dump文件来具体分析。
dump文件分析
test1():
test2():
原因及解决方法
根据业务来修改是否需要死循环。
原因:
这个是JDK6新加的错误类型,一般都是堆太小导致的。Sun 官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。 本质是一个预判性的异常,抛出该异常时系统没有真正的内存溢出。
第一段代码: 运行期间将内容放入常量池的典型案例intern()
方法。
- 如果字符串常量池里面已经包含了等于字符串X的字符串,那么就返回常量池中这个字符串的引用;
- 如果常量池中不存在,那么就会把当前字符串添加到常量池并返回这个字符串的引用
第二段代码: 不停的追加字符串str
你可能会疑惑,看似demo也没有差太多,为什么第二个没有报GCoverhead limit exceeded呢?以上两个demo的区别在于:
-
Java heap space的demo每次都能回收大部分的对象(中间产生的UUID),只不过有一个对象是无法回收的,慢慢长大,直到内存溢出。
-
GC overhead limit exceeded的demo由于每个字符串都在被list引用,所以无法回收,很快就用完内存,触发不断回收的机制。
解决方法:
1.检查项目中是否有大量的死循环或有使用大内存的代码,优化代码
2.添加参数-XX:-UseGcoverheadLimit
禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,最终出现 java.lang.OutOfMemoryError: Java heap space.
3. dump内存,检查是否存在内存泄漏,如果没有,加大内存。
OOM案例四:线程溢出(unable to create new native thread)
(一定不要在本地执行,当系统资源耗尽的时候,电脑直接挂机重启了)
案例模拟
在下面这个例子中,我们创建了一个无限循环,不断地创建并启动新线程:
package com.atguigu.oom;
import java.util.concurrent.CountDownLatch;
/**
* 案例4:线程溢出
* @author shkstart
* @create 17:45
*/
public class TestNativeOutOfMemoryError {
public static void main(String[] args) {
for (int i = 0; ; i++) {
System.out.println("i = " + i);
new Thread(new HoldThread()).start();
}
}
}
class HoldThread extends Thread {
CountDownLatch cdl = new CountDownLatch(1);
@Override
public void run() {
try {
cdl.await();
} catch (InterruptedException e) {
}
}
}
注意:在真实环境中,你应该谨慎使用这样的代码,因为它可能会耗尽系统资源。在生产环境中,你需要检查线程的生命周期管理,确保及时释放不再使用的线程资源。同时,你也应该关注系统的线程限制和线程栈大小设置,以确保它们满足应用程序的需求。
运行结果
如果你的机器上的可用线程数量达到系统的限制,或者剩余的线程栈空间不足以创建新的线程,就会抛出 java.lang.OutOfMemoryError: unable to create new native thread
异常。
原因及解决方法
出现这种异常大多都是创建了大量的线程导致的。
解决方向1:
- 通过 -Xss 设置每个线程栈大小的容量。
- JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。
- 正常情况下,在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
- 能创建的线程数的具体计算公式如下:
(MaxProcessMemory -JVMMemory - ReservedOsMemory) / (ThreadStackSize)= Number of threads
MaxProcessMemory 指的是进程可寻址的最大空间
JVMMemory:JVM内存
ReservedOsMemory:保留的操作系统内存
ThreadStackSize:线程栈的大小
- 当你创建一个线程的时候,虚拟机会在JVM内存创建一个Thread对象同时创建一个操作系统线程,而这个系统线程的内存用的不是JVMMemory,而是系统中剩下的内存(MaxProcessMemory -JVMMemory - ReservedOsMemory).
- 由公式得出结论:你给JVM内存越多,那么你能创建的线程越少,越容易发生
java.lang.OutOfMemoryError: unable to create new native thread
问题解决:
- 如果程序中有bug,导致创建大量不需要的线程或者线程没有及时回收,那么必须解决这个bug,修改参数是不能解决问题的。
- 如果程序确实需要大量的线程,现有的设置不能达到要求,那么可以通过修改MaxProcessMemory,JVMMemory,ThreadstackSize这三个因素,来增加能创建的线程数。
- MaxProcessMemory 使用64位操作系统
- JVMMemory 减少JVMMemory的分配
- ThreadStackSize 减小单个线程的栈大小
解决方向2:
线程总数也受到系统空闲内存和操作系统的限制,检查是否该系统下有此限制:
- /proc/sys/kernel/pid_max 系统最大PID值,在大型系统里可适当调大
- /proc/sys/kernel/threads-max 系统允许的最大线程数
- maxuserprocess(ulimit -u) 系统限制某用户下最多可以运行多少进程或线程
- /proc/sys/vm/max_map_count
max_map_count文件包含限制一个进程可以拥有的VMA(虚拟内存区域)的数量。虚拟内存区域是一个连续的虚拟地址空间区域。在进程的生命周期中,每当程序尝试在内存中映射文件,链接到共享内存段,或者分配堆空间的时候,这些区域将被创建。调优这个值将限制进程可拥有VMA的数量。限制一个进程拥有VMA的总数可能导致应用程序出错,因为当进程达到了VMA上限但又只能释放少量的内存给其他的内核进程使用时,操作系统会抛出内存不足的错误。如果你的操作系统在NORMAL区域仅占用少量的内存,那么调低这个值可以帮助释放内存给内核用。