目 录CONTENT

文章目录

JVM内存和内存溢出原理

FatFish1
2025-03-26 / 0 评论 / 0 点赞 / 42 阅读 / 0 字 / 正在检测是否收录...

JVM运行时数据区域

JVM内存结构演进

从JDK1.6开始,JVM内存结构变化是比较大的

JDK1.6版本有方法区,且常量池在方法区中

JDK1.7版本从堆里面还划分出了方法区,但字符串已经被挪到堆内存了

JDK1.8版本取消了方法区,同时用元数据区代替了永久代,改放直接内存,即最大的区别就是元空间脱离虚拟机内存,直接使用本地内存。元空间的内存上限被大大提高,只受限于操作系统的内存大小

到JDK17版本就没有很多大的变化,而是重点在更新ZGC回收器上

程序计数器

  • 是程序的字节码行号指示器,控制分支、循环、跳转、异常处理等。执行JAVA方法时计数器记录正在执行的虚拟机内存指令地址,执行native方法时是空的

  • 多线程并发在JVM中实际是通过切换正在执行的线程进行的,依赖程序计数器

  • 程序计数器不会出现任何的内存溢出场景

虚拟机栈

描述java方法执行的内存模型,一个方法创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息

递归调用的方法会创建下一个栈帧,因此栈有一个深度。这个深度不一定可扩展

对于这个区域,可能会出现如下溢出场景:

  • 如果线程运行时请求栈深度超过虚拟机能分配的最大深度会出现StackOverflowError

  • 方法要求栈容量可扩展,但又超过了编译时分配的大小,会出现OutOfMemoryError

局部变量表

又叫本地变量表,存放编译期可知的各种java虚拟机基本数据类型(布尔、byte、char、short、int、float、long、double)、对象引用(不等于对象本身,可能是指向对象起始地址的指针)、returnAddress类型。

例如Person person = new Person();中,左边是在方法中创建的变量,存在局部变量表,右边是实际类的实例,存储在JAVA堆person在局部变量表中的内容就是那个实际实例的起始地址

局部变量表由slot(槽)组成,long和double类型是64位,占两个slot,其他数据类型均占1个slot

进入一个方法时,编译期就可以预知方法需要多少slot,因此局部变量表大小是事先分配好的,方法运行期间不会改变。这里的大小指的是局部变量表中slot的数量

本地方法栈

本地方法栈与虚拟机栈的结构、功能都差不多,二者的区别就是虚拟机栈是用来执行java方法,本地方法栈则是执行native方法

java堆

堆是占用内存最大的部分。作用是存放对象实例,也是JVM内存溢出的重点分析区域。如果java堆内存不够存放对象且堆再也无法扩展,会出现OutOfMemoryError。

java堆是JVM垃圾回收器的重点关照区域,JVM垃圾回收器都是基于分代理论实现的,包括JDK1.7的新生代、老年代、永久代,JDK1.8的eden区、survivor区(from区、to区)等概念,可以见后续垃圾回收部分补链接

JVM堆可以物理上不在连续空间内,但逻辑是连续的

java堆可以是固定也可以是可扩展的(通过-Xmx-Xms参数配置)

java1.8的堆分成了老年代和年轻代。年轻代分成了Eden区、Surviver区,Suerviver区分成了from区和to区。一般年轻代中E:S:T的结果是8:1:1。GC流程:

  • 内存首先使用Eden区

  • 一次YoungGC,Eden区存活对象进入From区

  • 第二次YoungGC,Eden区存活对象进入To区,From区根据经历GC次数判断进入To区还是老年代

  • 最后交换To区和From区,这样又获得了一个完全空置的To区

方法区和运行时常量池(JDK1.8之前)

方法区又叫非堆内存,存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区是基于永久代概念开发的,有上限(通过-XX:MaxPermSize设置,不指定也有上限),容易发生溢出

运行时常量池是方法区的一部分,存放编译期生成的类版本、字段、方法、接口等描述信息,还有常量池表,存放编译期生成的各种字面量、符合引用。常量池是动态的,运行时可以加载新的常量进常量池,如String.intern()方法。因此申请不到内存可能出现OutOfMemoryError

直接内存

直接内存不是JVM运行时数据区的一部分,不受堆内存影响,但跟堆内存相加不能大于物理内存,因此也有可能出现OutOfMemoryError。使用-MaxDirectMemorySize参数可以限制直接内存,减少溢出

NIO类使用native函数直接分配堆外内存,然后通过堆内的一个DirectByteBuffer对象作为这块内存的引用进行操作。很多客户端连接可能基于netty实现,例如redisTemplate。可以参考如下链接:

http://www.chymfatfish.cn/archives/niohttp://www.chymfatfish.cn/archives/netty

元数据区(JDK1.8以后)

主要用来保存被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。不容易变动。属于堆外内存。

可以使用参数-XX:MaxMetaspaceSize限制元数据区

HopSpot虚拟机

JVM对象创建原理

  1. 收到new指令时首先确认对应的类有没有加载、解析、初始化过,没有,先执行类的加载过程

  2. 类加载完成后,为对象分配内存空间(在类加载时就可以完全确定),从堆内存中划分出这个空间。

    1. 堆内存如果是绝对整齐的,即空闲内存和非空闲内存顺序排列,只需移动指针即可,即指针碰撞

    2. 如果不是绝对整齐,需要维护一个空闲内存表

    3. 多线程时,如果两个线程并发申请内存,可能冲突,这时可以使用本地线程分配缓存(TLAB),即预先按照线程把内存划分成不同空间,哪个线程创建对象就在对应空间进行。缓冲区分配完了再去堆内存申请时,会进行加锁操作。是否使用TLAB可以用参数-XX:+/-UseTLAB配置

  3. 分配内存后,JVM将分配到的内存空间初始化为0值。如果使用了TLAB,会在TLAB分配前执行这个流程。

  4. JVM对对象做必要设置,如元数据信息、哈希码、年代信息等

  5. 执行程序中的初始化方法,字节流中new指令后面会接着执行<init>()方法,完成程序初始化。创建字节码实例参考:

    http://www.chymfatfish.cn/archives/JVMknowledge#%E5%88%9B%E5%BB%BA%E5%AF%B9%E8%B1%A1

jvm对象内存布局

jvm中对象有三部分组成:对象头、实例数据、对齐填充

  • 对象头:对象头中有两类数据:

    • 存储对象自身运行时数据:例如哈希码、GC分代年龄、锁状态等,长度在32位和64位虚拟机中分别为32bit和64bit;

    • 指向对象的类型元数据的指针:以此来确定是哪个类的实例。如果对象是java数组,还会存有记录数组长度的数据。

  • 实例数据:存储父类中继承的和子类定义的数据。这部分的存储数据收到参数-XX:FieldsAllocationStyle参数影响。默认策略是将相同宽度的对象放到一起存。而参数-XX:CompactFields参数为true时(默认为true)允许长度短的对象在长的对象之间插空。

  • 对齐填充:无意义,填充使用

jvm对象访问定位

两种方式:使用句柄访问、直接使用指针访问

  • 使用句柄访问:jvm从堆内存中划分出一块作为句柄池,java栈本地变量reference中存储的是对象的句柄地址,句柄中包含对象的实例数据与类型数据各自的地址信息。好处是如果对象地址改变,只需要改变句柄池中的地址,reference中的数据始终都能够恒定不变。

  • 直接使用指针访问:java栈本地变量表中存储的是对象地址。好处时速度块,节省一次指针定位开销。但需要jvm堆能够布局如何访问到类型数据的相关信息。

jvm内存溢出场景分析

java堆溢出

模拟这个场景,只需要给小一点java堆,然后不断向列表中添加数据即可

/**
 * -Xms20m  -Xmx20m  -XX:+HeapDumpOnOutOfMemoryError
 */
public class OOMTest {
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

可以看到如下异常:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

当出现堆内存溢出,有两种思路去分析:

  • 内存泄露:即垃圾回收机制没有正确回收这些对象。这一点要从代码角度分析,看看哪里持有了对象不释放,或高频次生成大量无用对象。

  • 内存溢出:即这些对象确实应该存活,需要看下堆内存参数是否有扩展空间。这一点就得从JVM配置角度触发,调整不同区域占比,调整GC频次,达到内存占用、GC时长、STW时长的平衡。

可以使用idea自带的profile工具分析内存,链接如下:

http://www.chymfatfish.cn/archives/codetool#%E4%BD%BF%E7%94%A8profile%E5%B7%A5%E5%85%B7%E5%88%86%E6%9E%90%E5%86%85%E5%AD%98

java栈溢出

有两种异常:

  • 如果线程请求的栈深度大于虚拟机运行最大深度,抛出stackOverflowError

  • 如果栈内存运行动态扩展,当扩展栈容量无法申请足够内存,抛出OutOfMemoryError

/**
 * -Xss128K
 */
public class OOMTest {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        OOMTest oomTest = new OOMTest();
        try {
            oomTest.stackLeak();
        } catch (Throwable e) {
            System.out.println("stackLength:" + oomTest.stackLength);
            throw e;
        }
    }
}

通过不断递归自己触发虚拟机栈溢出

可以看到如下异常:Exception in thread "main" java.lang.StackOverflowError

有stackOverflowError异常时一般会有明确的错误堆栈可供分析

一般栈深度到达1000~2000是完全没有问题的,但是对于超高并发系统,建立过多线程导致内存溢出,在不能减少线程数量或更换64位虚拟机的情况下,可以考虑通过减少最大堆(-Xmx)和减少栈容量(-Xss)两个参数优化

这样做的原理是:减少堆内存可以使更多内存分配到虚拟机栈上,而-Xss参数决定虚拟机栈分配给单个线程的内存大小,减少-Xss大小可以使得允许创建线程数更多

有时anon区也和堆外内存相关,参考如下案例:

http://www.chymfatfish.cn/archives/k8squestion#%E9%97%AE%E9%A2%983-anon%E5%86%85%E5%AD%98%E8%BF%87%E5%A4%9A%E5%AF%BC%E8%87%B4oomkill%E7%9A%84%E5%9C%BA%E6%99%AF

方法区和运行时常量池内存溢出

jdk8目前实际上已经抛弃了永久代这一个概念,而改用元空间来实现方法区。而字符串常量池被移动到java堆中。

/**
 * -Xmx6M
 */
public class OOMTest {
    public static void main(String[] args) {
        // 使用set保持常量池引用,避免Full GC回收常量池
        Set<String> set = new HashSet<>();
        // 在short范围内足以让6MB的堆内存产生OOM
        short i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

可以看到如下异常:Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

此场景下JVM每次进行垃圾回收,却只能回收到很少的垃圾。默认情况下,如果Java进程花费98%以上的时间执行GC,并且每次只有不到2%的堆被恢复,则JVM抛出此错误,即耗尽了所有内存,却无法清理他们

除此之外,使用元空间的jdk8基本不会有方法区溢出的情况,但jvm也提供了一系列参数来进行防御,例如-XX:MaxMetaspaceSize

本机直接内存溢出

NIO底层调用Unsafe#getUnsafe方法申请直接内存。

/**
 * -Xmx20M  -XX:MaxDirectMemorySize=10M
 */
public class OOMTest {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

可以看到如下异常:Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory

这种场景下产生的Dump文件很小,程序中又间接使用了DirectMemroy(最典型就算NIO),那就可以重点检查下直接内存方面了。

0

评论区