JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事

简介: 本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置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溢出四大种类。

二、heap堆内存溢出

      按之前说过堆内存分年轻代+老年代,发生Out Of Memory Error异常,真实场景实质就是GC之后,仍然无法腾出足够可用内存空间分配给新对象。JVM被迫终止运行退出。

2.1 示例代码Demo

      我们模拟内存溢出,通过设置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];
    }
}

2.2 堆内存溢出日志

     运行后,出现了Exception in thread "main" java.lang.OutOfMemoryError: Java heap space异常。

2.3 堆内存溢出原因分析

     OOM的原因只有2个,一个是我们设置的JVM内存太小,不能满足业务系统合理要求。另一个是代码写的有问题,导致内存申请急速膨胀。

2.3.1 JVM内存分配不合理的几种表现

1、整个-Xms分配太小,系统正常运行没多久就OOM。

2、年轻代和老年代分配不合理,比如年轻代和老年代的比例是9:1,YGC后存活对象很多,老年代放不下,发生了OOM。

3、年轻代的S区过小,每次YGC后存活对象都进入老年代,而老年代的对象又无法回收,导致OOM。

2.3.2 代码问题导致OOM的几种表现

1、一次加载过多无价值的数据,导致OOM。比如有个超大表,本次查询只需要其中2列,但是使用了select * from ,加载了很多没必要的数据导致堆内存急速膨胀。

2、代码bug,有无限循环递归。就出现无限在创建新对象导致OOM。

3、内存泄漏导致内存溢出。比如无效的对象,长期无法被回收,而且还在不断新增,日积月累就发生OOM。

     此外,业务量暴增、或者发生雪崩,导致某个服务实例请求大幅上升,让之前评估合理的JVM配置无法适配当前高并发访问导致OOM。

三、stack栈内存溢出

    在内存区域划分专栏里说过,每个线程都有自己的虚拟机栈,当线程执行一个方法时,会为该方法创建栈帧,用来存放方法里的局部变量引用、方法的出口、动态链接等信息。-Xss一般就是512k,或者1Mb。看系统并发能力以及内存情况来设置。方法执行存在递归死循环,或者方法里面有非常多的代码,比如几万行(这种不太可能),线程执行这种方法就一定会出现java.lang.StackOverflowError异常。

3.1 示例代码Demo

    我们通过设置-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();
    }
}

3.2 栈内存溢出日志

     程序运行异常退出,发生了java.lang.StackOverflowError异常。

3.3 栈内存溢出原因分析

     虚拟机栈出现异常,基本就是代码编写不当,导致方法执行被递归深度过大,甚至无限递归。所以代码的重试机制、while(条件)语句、for(条件)、还有方法里的递归,这些地方要多注意是否会出现Stack over flow error问题。

     虚拟机栈的内存大小-Xss其实一般配置1M足够了。如果JVM内存不大和并发不高,设置256k,或者512k也足够用。特殊的数据计算或者涉及要递归,如果合理,可以提高到2Mb大小。

四、Metaspace元数据区溢出

      元数据区,也是多线程共享区域,但是和我们常说的堆内存不是同一个区域。元数据区主要存放的是,.class字节码、运行时常量池、JIT即时编译后的机器码。控制元数据区大小,主要有2个参数,第一个是 MetaspaceSize,可以设置元空间的初始大小。在JVM启动时,就从系统内存申请分配该大小的内存给元数据区。另一个MaxMetaspaceSize,可以设置元空间的最大大小,这个很有必要,因为默认情况下,元空间的大小可以无限申请达到最大物理内存,通过设置该参数来设置元数据空间上限,避免内存泄漏。

4.1 示例代码Demo

     我们通过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();
        }
    }
}

4.2 栈内存溢出日志

      抛出了org.springframework.cglib.core.CodeGenerationException: java.lang.OutOfMemoryError-->Metaspace

以及Caused by: java.lang.OutOfMemoryError: Metaspace异常。

4.3 栈内存溢出原因分析

      当我们设置的metaspace空间太小,而项目代码特别多,项目启动的时候就会报元数据内存溢出。另外一种情况就是demo我们示范的,项目运行过程,存在cglib、ByteBuddy等字节码增强或动态代理技术应用,会动态的生成加载类,需要注意是否存在元数据区OOM问题。

五、DirectMemory直接内存溢出

      在JVM堆内存之外,除了有虚拟机栈内存、元数据区,还有一个DirectMemory直接内存区。这个区域的作用是什么呢?「拉丁解牛说技术,实用至上,坚持用最简洁直白的文字+最少的代码示例分享干货。」

      直接内存的设置,主要是java NIO库的需要。NIO对数据读写要求高,且内存需求大,如果直接使用堆内存进行NIO频繁操作,JVM的堆内存很快就被打满,随后发生频繁的GC。而在堆内存之外的直接内存,有读写效率高、内存空间相对独立且容量够大的特点,非常适合NIO库的应用场景。

      如果我们要用到直接内存,可以通过java.nio.ByteBuffer.allocateDirect()进行申请,也可以通过java.nio.DirectByteBuffer操作直接内存。直接内存我们是无法通过jmap查看,只能通过类似top 命令来看它的内存使用情况。

      直接内存的最大可用空间大小,可以通过-XX:MaxDirectMemorySize来限制。当发生FGC后,这部分内存也会被GC回收。如果GC后,新申请的直接内存,大于直接内存可用空间,就会报直接内存OOM。

5.1 示例代码Demo

       这里我们模拟,设置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);
        }
    }
}

5.2 DirectMemory直接内存溢出日志

     程序发生了java.lang.OutOfMemoryError: Direct buffer memory异常。

5.3 DirectMemory直接内存溢出原因分析

      直接内存的应用,常见的就是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进阶调优系列(3)堆内存的对象什么时候被回收?

2、JVM进阶调优系列(2)字节面试:JVM内存区域怎么划分,分别有什么用?

3、JVM进阶调优系列(1)类加载器原理一文讲透

4、JAVA并发编程系列(13)Future、FutureTask异步小王子

相关文章
|
7月前
|
Arthas 存储 算法
深入理解JVM,包含字节码文件,内存结构,垃圾回收,类的声明周期,类加载器
JVM全称是Java Virtual Machine-Java虚拟机JVM作用:本质上是一个运行在计算机上的程序,职责是运行Java字节码文件,编译为机器码交由计算机运行类的生命周期概述:类的生命周期描述了一个类加载,使用,卸载的整个过类的生命周期阶段:类的声明周期主要分为五个阶段:加载->连接->初始化->使用->卸载,其中连接中分为三个小阶段验证->准备->解析类加载器的定义:JVM提供类加载器给Java程序去获取类和接口字节码数据类加载器的作用:类加载器接受字节码文件。
738 55
|
2月前
|
存储 缓存 Java
我们来说一说 JVM 的内存模型
我是小假 期待与你的下一次相遇 ~
281 5
|
2月前
|
存储 缓存 算法
深入理解JVM《JVM内存区域详解 - 世界的基石》
Java代码从编译到执行需经javac编译为.class字节码,再由JVM加载运行。JVM内存分为线程私有(程序计数器、虚拟机栈、本地方法栈)和线程共享(堆、方法区)区域,其中堆是GC主战场,方法区在JDK 8+演变为使用本地内存的元空间,直接内存则用于提升NIO性能,但可能引发OOM。
|
8月前
|
Arthas 监控 Java
Arthas memory(查看 JVM 内存信息)
Arthas memory(查看 JVM 内存信息)
729 6
|
11月前
|
存储 设计模式 监控
快速定位并优化CPU 与 JVM 内存性能瓶颈
本文介绍了 Java 应用常见的 CPU & JVM 内存热点原因及优化思路。
1104 166
|
9月前
|
存储 缓存 算法
JVM简介—1.Java内存区域
本文详细介绍了Java虚拟机运行时数据区的各个方面,包括其定义、类型(如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存)及其作用。文中还探讨了各版本内存区域的变化、直接内存的使用、从线程角度分析Java内存区域、堆与栈的区别、对象创建步骤、对象内存布局及访问定位,并通过实例说明了常见内存溢出问题的原因和表现形式。这些内容帮助开发者深入理解Java内存管理机制,优化应用程序性能并解决潜在的内存问题。
451 29
JVM简介—1.Java内存区域
|
8月前
|
存储 NoSQL Redis
阿里面试:Redis 为啥那么快?怎么实现的100W并发?说出了6大架构,面试官跪地: 纯内存 + 尖端结构 + 无锁架构 + EDA架构 + 异步日志 + 集群架构
阿里面试:Redis 为啥那么快?怎么实现的100W并发?说出了6大架构,面试官跪地: 纯内存 + 尖端结构 + 无锁架构 + EDA架构 + 异步日志 + 集群架构
阿里面试:Redis 为啥那么快?怎么实现的100W并发?说出了6大架构,面试官跪地: 纯内存 + 尖端结构 +  无锁架构 +  EDA架构  + 异步日志 + 集群架构
|
9月前
|
缓存 监控 算法
JVM简介—2.垃圾回收器和内存分配策略
本文介绍了Java垃圾回收机制的多个方面,包括垃圾回收概述、对象存活判断、引用类型介绍、垃圾收集算法、垃圾收集器设计、具体垃圾回收器详情、Stop The World现象、内存分配与回收策略、新生代配置演示、内存泄漏和溢出问题以及JDK提供的相关工具。
JVM简介—2.垃圾回收器和内存分配策略
|
9月前
|
存储 设计模式 监控
如何快速定位并优化CPU 与 JVM 内存性能瓶颈?
如何快速定位并优化CPU 与 JVM 内存性能瓶颈?
283 0
如何快速定位并优化CPU 与 JVM 内存性能瓶颈?
|
10月前
|
存储 算法 Java
JVM: 内存、类与垃圾
分代收集算法将内存分为新生代和老年代,分别使用不同的垃圾回收算法。新生代对象使用复制算法,老年代对象使用标记-清除或标记-整理算法。
164 6