2024好事接龙,拉丁解牛祝愿所有有缘刷到的同学,好事发生,喜事连连。
开篇,先推荐一篇实用的文章《Java EasyExcel导出报表内存溢出全解析》,作者是【bug菌】。
这篇文章作者详细分享easyExcel内存优化最佳实践,尤其是针对大批量数据导出场景,在满足业务需求基础上如何优化解决OOM问题给出系统思路方法并提供代码示例分析,是一篇非常实用的经验分享,特此推荐给大家。同时今天我们也针对堆内存、非堆内存的OOM问题进行全面分析。
-----------------------------开启我们JVM系列正文----------------------
最近读书心得:在建立养成终身成长型思维过程,要重视熵减生活、工作。眼花缭乱的纷杂社会,很容易让我们进入盲目焦虑的状态。坚壁清野,唯清唯静。
JVM偏重实战经验的面试,面试官开局都喜欢问这个题目,这个题可以直接考察JVM内存模型基础以及候选人的实战经验,可谓一举两得。候选人也许内心万马奔腾,但是这个确实很考验基础,属于半开放的万能考题。根据候选人的履历,可考察很深入、也可以考察比较浅。能问出这样问题的面试官、以及答好这样的问题的候选人,都有一个共性,就是及其重视基础,韧性十足的扫地憎。能耐心答完这个问题,距离满意的offer将大幅接近。
我们本系列JVM调优目标,除了达成让系统服务运行更流畅、延时更低,也要达到避免OOM的目的。今天重点分析OOM的种类,并结合示例Demo分析总结OOM原因,帮助有缘刷到的同学巩固掌握OOM这块领域。「拉丁解牛说技术,实用至上,坚持用最简洁直白的文字+最少的代码示例分享干货。」
内存泄漏Memory Leak,通俗的讲,就是有一部分内存空间被无效的持续占用,导致这部分内存无法回收重复利用。比如在《JAVA并发编程系列(12)ThreadLocal就是这么简单》我们说过,ThreadLocal就可能会发生内存泄漏问题。比如内存只有10Mb,但是程序申请分配了4Mb内存空间,程序运行自始至终从没应用到这4Mb无效内存,GC永远回收不到它。导致内存泄漏了4Mb,内存最终可用空间只有6Mb。
内存溢出Out of Memory(简称OOM),是指超过可用内存大小=内存应用超标爆表。比方说,内存只有10Mb,如果要放11Mb数据,就超出了内存可用大小,发生OOM。
而今天的主角内存溢出,按之前《JVM进阶调优系列(2)JVM内存区域怎么划分,分别有什么用》说的那样,内存溢出会发生在heap堆内存、Metaspace元数据区、stack栈内存溢出、直接内存DirectMemory溢出四大种类。
按之前说过堆内存分年轻代+老年代,发生Out Of Memory Error异常,真实场景实质就是GC之后,仍然无法腾出足够可用内存空间分配给新对象。JVM被迫终止运行退出。
我们模拟内存溢出,通过设置10Mb堆内存,尝试创建6个2Mb的对象(实际5个都分配不了,就OOM)。
* JVM参数:-Xms10m -Xmx10m -Xmn5m -XX:SurvivorRatio=8 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./
* 参数说明:整个堆内存10Mb,其中老年代5Mb,年轻代5Mb,此外:
* -XX:+HeapDumpOnOutOfMemoryError ,内存溢出时导出整个堆信息,让JVM遇到OOM异常时能输出堆内信息;
* -XX:HeapDumpPath=./,内存异常堆数据导出到当前目录。
package lading.java.jvm;
public class Demo005OOM {
public static void main(String[] args) {
byte[] obj_2Mb_1 = new byte[2 * 1024 * 1024];
byte[] obj_2Mb_2 = new byte[2 * 1024 * 1024];
byte[] obj_2Mb_3 = new byte[2 * 1024 * 1024];
byte[] obj_2Mb_4 = new byte[2 * 1024 * 1024];
byte[] obj_2Mb_5 = new byte[2 * 1024 * 1024];
byte[] obj_2Mb_6 = new byte[2 * 1024 * 1024];
}
}
运行后,出现了Exception in thread "main" java.lang.OutOfMemoryError: Java heap space异常。
OOM的原因只有2个,一个是我们设置的JVM内存太小,不能满足业务系统合理要求。另一个是代码写的有问题,导致内存申请急速膨胀。
1、整个-Xms分配太小,系统正常运行没多久就OOM。
2、年轻代和老年代分配不合理,比如年轻代和老年代的比例是9:1,YGC后存活对象很多,老年代放不下,发生了OOM。
3、年轻代的S区过小,每次YGC后存活对象都进入老年代,而老年代的对象又无法回收,导致OOM。
1、一次加载过多无价值的数据,导致OOM。比如有个超大表,本次查询只需要其中2列,但是使用了select * from ,加载了很多没必要的数据导致堆内存急速膨胀。
2、代码bug,有无限循环递归。就出现无限在创建新对象导致OOM。
3、内存泄漏导致内存溢出。比如无效的对象,长期无法被回收,而且还在不断新增,日积月累就发生OOM。
此外,业务量暴增、或者发生雪崩,导致某个服务实例请求大幅上升,让之前评估合理的JVM配置无法适配当前高并发访问导致OOM。
在内存区域划分专栏里说过,每个线程都有自己的虚拟机栈,当线程执行一个方法时,会为该方法创建栈帧,用来存放方法里的局部变量引用、方法的出口、动态链接等信息。-Xss一般就是512k,或者1Mb。看系统并发能力以及内存情况来设置。方法执行存在递归死循环,或者方法里面有非常多的代码,比如几万行(这种不太可能),线程执行这种方法就一定会出现java.lang.StackOverflowError异常。
我们通过设置-Xss256k,让某方法递归调用自己,模拟栈内存溢出异常。
完整JVM参数:-Xms10m -Xmx10m -Xmn5m -Xss256k。
package lading.java.jvm;
public class Demo006StackOverError {
public static int count = 1;
/**
* 该方法将无限递归调用自己,类似while(true)
*/
public static void stackOverDemo() {
count++;
stackOverDemo();
}
public static void main(String[] args) {
stackOverDemo();
}
}
程序运行异常退出,发生了java.lang.StackOverflowError异常。
虚拟机栈出现异常,基本就是代码编写不当,导致方法执行被递归深度过大,甚至无限递归。所以代码的重试机制、while(条件)语句、for(条件)、还有方法里的递归,这些地方要多注意是否会出现Stack over flow error问题。
虚拟机栈的内存大小-Xss其实一般配置1M足够了。如果JVM内存不大和并发不高,设置256k,或者512k也足够用。特殊的数据计算或者涉及要递归,如果合理,可以提高到2Mb大小。
元数据区,也是多线程共享区域,但是和我们常说的堆内存不是同一个区域。元数据区主要存放的是,.class字节码、运行时常量池、JIT即时编译后的机器码。控制元数据区大小,主要有2个参数,第一个是 MetaspaceSize,可以设置元空间的初始大小。在JVM启动时,就从系统内存申请分配该大小的内存给元数据区。另一个MaxMetaspaceSize,可以设置元空间的最大大小,这个很有必要,因为默认情况下,元空间的大小可以无限申请达到最大物理内存,通过设置该参数来设置元数据空间上限,避免内存泄漏。
我们通过cglib Enhancer 多次动态加载LadingShare.class,并对它的startWork方法进行增强。模拟元数据区内存溢出场景。元数据可用空间限制仅提供10Mb,JVM参数:-Xms10m -Xmx10m -Xmn5m -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M。
package lading.java.jvm;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
public class Demo007MetaspaceOOM {
public static class LadingShare {
public LadingShare() {
}
/**
* 开始创作
*/
public void startWork() {
System.out.println("拉丁解牛说技术,技术分享开始创作ing");
}
}
static class MethodPlus implements MethodInterceptor {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("LadingShare.startWork()创作前,先构思思路。");
Object result = proxy.invokeSuper(obj, args);
System.out.println("LadingShare.startWork()创作后,到技术平台发布分享。");
return result;
}
}
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
System.out.println("当前加载" + ++i + "个LadingShare.class");
//创建Enhancer对象,动态代理LadingShare.class,并对它的startWork()方法新增而外逻辑
Enhancer enhancer = new Enhancer();
LadingShare share = new LadingShare();
enhancer.setSuperclass(share.getClass());
enhancer.setUseCache(false);//这里很关键,不能用cache,否则不会OutOfMemoryError-->Metaspace
enhancer.setCallback(new MethodPlus());
// 创建代理对象
LadingShare proxy = (LadingShare) enhancer.create();
// 调用代理对象的方法
proxy.startWork();
}
}
}
抛出了org.springframework.cglib.core.CodeGenerationException: java.lang.OutOfMemoryError-->Metaspace
以及Caused by: java.lang.OutOfMemoryError: Metaspace异常。
当我们设置的metaspace空间太小,而项目代码特别多,项目启动的时候就会报元数据内存溢出。另外一种情况就是demo我们示范的,项目运行过程,存在cglib、ByteBuddy等字节码增强或动态代理技术应用,会动态的生成加载类,需要注意是否存在元数据区OOM问题。
在JVM堆内存之外,除了有虚拟机栈内存、元数据区,还有一个DirectMemory直接内存区。这个区域的作用是什么呢?「拉丁解牛说技术,实用至上,坚持用最简洁直白的文字+最少的代码示例分享干货。」
直接内存的设置,主要是java NIO库的需要。NIO对数据读写要求高,且内存需求大,如果直接使用堆内存进行NIO频繁操作,JVM的堆内存很快就被打满,随后发生频繁的GC。而在堆内存之外的直接内存,有读写效率高、内存空间相对独立且容量够大的特点,非常适合NIO库的应用场景。
如果我们要用到直接内存,可以通过java.nio.ByteBuffer.allocateDirect()进行申请,也可以通过java.nio.DirectByteBuffer操作直接内存。直接内存我们是无法通过jmap查看,只能通过类似top 命令来看它的内存使用情况。
直接内存的最大可用空间大小,可以通过-XX:MaxDirectMemorySize来限制。当发生FGC后,这部分内存也会被GC回收。如果GC后,新申请的直接内存,大于直接内存可用空间,就会报直接内存OOM。
这里我们模拟,设置10Mb的直接内存空间,通过nio..ByteBuffer.allocateDirect()不断申请直接内存,最后导致OOM的简单案例。
JVM参数:主要设置10Mb的直接内存。
-Xms10m -Xmx10m -Xmn5m -XX:MaxDirectMemorySize=10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./
package lading.java.jvm;
import java.nio.ByteBuffer;
import java.util.LinkedList;
import java.util.List;
/**
* 模拟直接内存溢出场景
*/
public class Demo008DirectMemoryOOM {
public static void main(String[] args) {
List<ByteBuffer> directMemList = new LinkedList<>();
for (int i = 0; i < 10; i++) {
//每次申请2Mb 直接内存
System.out.println("尝试申请第:" + i + "个2Mb的直接内存");
ByteBuffer directMem2Mb = ByteBuffer.allocateDirect(2 * 1024 * 1024);
directMemList.add(directMem2Mb);
}
}
}
程序发生了java.lang.OutOfMemoryError: Direct buffer memory异常。
直接内存的应用,常见的就是NIO,比如Netty框架。出现了DM OOM,一个可能是研发预估不足,没有设置直接内存大小,或者分配的大小不合理。系统上线前,可以通过压测来合理设置-XX:MaxDirectMemorySize参数。
另一个是没有主动做好回收。研发人员需要注意手工回收这部分内存,比如可以通过DirectBuffer.cleaner().clean();进行回收。像Netty这种框架,他们会对直接内存进行主动充分管理。
之前《系列(7)JVM调优监控必备命令、工具集合》有详细分享通过jmap、jhat、GCeasy、Arthas等命令工具进行分析堆内存、GC情况。这个足以对堆内存溢出、虚拟机栈内存溢出问题、以及元数据区的溢出进行全面分析排查。
唯独直接内存溢出,如何排查分析呢?首先我们一定要设置 -XX:MaxDirectMemorySize参数,否则当程序申请过大直接内存后,会被Docker、系统悄无声息的干掉,将不会留下没有任何痕迹,就很难排查。
这里devops建设完善的公司,会对系统进行全面的监控,当超过阈值收到告警后,我们可以及时跟进分析服务状态。
另外,可以通过设置JVM参数-XX:NativeMemoryTracking=summary | detail参数来分析追踪JVM内存情况,这个参数由于导致5%-10%的额外性能开销,一般不启用。设置该参数,启动系统服务后,可以通过命令
jcmd 【jvm进程id】 VM.native_memory 查看实时内存分配情况。
此外,也可以再增加-XX:+PrintNMTStatistics、-XX:+UnlockDiagnosticVMOptions参数,当启用 NativeMemoryTracking 时,让JVM退出时打印内存使用情况。这样可以帮助排查发生直接内存溢出时的系统情况。
推荐阅读:
1、JVM进阶调优系列(5)CMS回收器通俗演义一文讲透FullGC
2、JVM进阶调优系列(4)年轻代和老年代采用什么GC算法回收?
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。