JVM内存分配与对象创建
本文主要介绍JVM运行时内存划分,然后简述Java对象的创建过程与Java对象的内存模型以及如何访问一个Java对象,最后结合实际案例给出一个堆溢出的原因排查过程
JVM运行时数据区域划分与内存溢出异常
JVM运行时数据区可以从是否为线程私有分为两大部分,其中程序计数器、Java虚拟机栈、本地方法栈为线程私有;堆、方法区为线程共有。他们共同组成了JVM运行时内存,具体如图所示:
接下来逐一介绍:
程序计数器
可以看作是当前线程所执行的字节码的行号指示器。他记录了当前线程执行到了字节码的哪个位置、记录线程上下文切换的位置、程序中控制语句执行的位置、异常处理、线程恢复等等信息。这一片区域是《Java虚拟机规范》中规定不会发生任何内存溢出的区域
Java虚拟机栈
这个就是我们经常听到的JVM分为“堆”和“栈”的其中那个栈。Java虚拟机栈用来描述Java方法执行的线程内存模型。每当一个方法被调用时,都会在虚拟机栈中为这个方法创建一个栈帧,栈帧中会记录临时变量、对象引用、方法出口等信息。等方法执行完毕后,栈帧就会被销毁。该区域大小可以选择是否可以动态扩容,如果不能扩容,那么如果碰到无限递归的方法,就可能发生StackOverflowError异常;如果能够动态扩容,那么无限递归的方法会耗尽JVM内存,发生OutOfMemoryError异常
本地方法栈
本地方法栈和Java虚拟机栈类似,不同的是Java虚拟机栈记录的Java方法的调用情况,本地方法栈记录的是本地方法调用时的情况
Java堆
堆是JVM虚拟机管理的内存中的最大的一块。也是所有线程共享的区域。此内存区域的目的就是存放实例对象。几乎所有的对象实例和数组都存储在这里(由于即时编译的发展以及逃逸分析技术的发展导致有些对象实例也不一定在堆上,所以使用了几乎的描述)。这里需要注意会有人把堆又分为老年代、新生代等区域,但是这种区域的划分其实不是JVM规定的划分方式,而是有些虚拟机为了从对象回收的角度来更好的区分堆内存而进行的划分。如果堆的空间耗尽,会发生OOM异常
方法区
方法区中存着已经加载的类信息、常量、静态变量等信息。之前还被称呼为“永久代”,但同样的这也不是JVM的规范,而是在Java8之前hotspot使用永久代的方式实现了方法区,方便JVM管理内存。到Java8之后,已经不使用这种方式,方法区使用了元空间的方式去实现了,内存可以直接使用本地内存进行分配。但是方法区在逻辑概念上依然属于JVM运行时数据区的一部分。如果方法区的内存耗尽,会发生OOM异常。
运行时常量池
运行时常量池是方法区中的一部分。当Class被加载运行的时候,其中Class常量池中字符串、符号引用等内容就会被加载到运行时常量池中。还会把符号引用翻译出来的直接引用也存储在运行时常量池中。不同于Class文件的常量池。运行时常量池可以在程序动态运行的过程中加入常量,比如String.intern()的方法触发的字符串入池。常量池的空间耗尽会去找方法区继续申请空间,最终没有可申请的空间了也会出现OOM
直接内存
NIO会使用NATIVE函数库直接分配堆外内存,然后通过一个存储在堆中的对象作为这块内存的引用进行操作,这样在一些场景中能显著提升系统性能。这篇区域不属于JVM运行时数据区,但是由于他的工作方式很容易造成开发者对于JVM内存分配的错误估计,造成本机内存耗尽,最终发生OOM异常。
Java对象的创建、内存分配和访问
介绍完了内存空间的划分,那么一个对象又是怎么被创建出来如何被访问呢,我们从流程上先来看下这个过程。
注意:此后的所有内容都是基于hotspots虚拟机的实现来讲的
Java对象的创建过程
当执行了一个new命令背后的执行逻辑大致可以分为如下几步:
- 根据当前引用到字符串常量池中查看是否有相同引用并检查对应引用代表的类是否已经加载、解析和初始化,如果没有就进行类加载过程
- 类加载检查通过后,会去堆上为对象分配内存空间。
- 空间分配完成后进行空间的零值的初始化(不包括对象头),确保对象的实例字段在Java代码中可以不赋值就直接使用,使程序能够访问到这些字段的数据类型对应的零值
- 进行对象头的初始化,是否启用偏向锁、对象是哪个类的实例、如何找到类的元数据信息、对象哈希码等等
执行到第四步JVM的角度看对象的创建就完成了。但是此时从Java程序视角来看,对象的创建才刚刚开始
- 调用构造函数,Class文件中的
()方法,开始初始化实例中具体内容的值
至此,一个Java对象就被创建出来了
Java对象的内存布局
那么一个Java对象又是由哪些部分组成的呢?
对象头
也称为MarkWord,其中存储了对象的元数据信息,包括对象的hash码、线程持有的锁、锁偏向线程、GC分代年龄等等。还有一部分是类型指针,即对象指向他的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。需要注意如果对象是一个数组,那么对象头中还会有字段去记录数据的长度
实例数据部分
这部分是真正存储对象数据的部分,即我们在代码中定义的各种字段,也包括从父类继承下来的。变量记录的顺序依赖于虚拟机策略的设定。
对齐填充
这部分没有实际的意义,仅仅起着占位的作用。Hotspot虚拟机的自动内存管理要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍,因此,如果对象实例数据部分没有对齐的话,在这里就会被填充对齐内存大小
Java对象的访问
创建好对象自然就是为了访问,那么在JVM中是如何找到一个具体的对象呢。前面说到了Java虚拟机栈上会存对象的引用,找到具体的对象就是利用这个引用。常见的方式有两种,使用句柄访问和直接访问两种
句柄访问
句柄访问的方式会在堆中维护一个句柄池,池子中存放了对象实例地址。栈中引用对象的内容就是句柄在句柄池中的位置。每当需要找一个对象的时候,会从栈中找到句柄位置,然后根据句柄位置找到实例。
优点:这种方式的优点在于引用会十分的稳定,即时垃圾收集器挪动了对象实例的位置,但是引用指向的句柄地址是不会变的,实例位置变后句柄更新就好不需要改变引用。
直接访问
这种方式引用中会直接存储对象实例的地址,只需要根据引用中记录的堆中地址去访问对象实例就好。
优点:由于没有了句柄池这个中间步骤,访问的速度自然会快很多。
OutOfMemoryException排查
场景制造
这里写一段死循环创建对象的代码,最终大量的被引用对象会导致堆溢出。工程环境为普通的spring mvc架构的项目
代码:
1 | //controller层代码 |
服务jvm参数:
-XX:+HeapDumpOnOutOfMemoryError 堆溢出的时候打印堆快照文件
-XX:HeapDumpPath=/export/Logs/jvm/ 放置堆快照文件的目录
启动服务调用接口后不久出现OOM异常:
此时可以看到目标目录下出现了堆快照文件:
使用Jprofiler排查堆溢出
- 将dump文件导入应用,可以看到所有的对象集合,能够发现OOMObject对象占用了97%的内存空间
- 双击对象或者选择引用选项,选择传入引用,这时候就能够看到对象的引用链,点击详细更多还能看到具体的代码追踪,就可以找到是对应代码中的哪一行创建的这个对象了
上述例子是个非常简单的OOM溢出排查,实际业务中还要结合代码逻辑仔细分析哪些对象的内存比例异常,从而解决问题
JVM内存分配与对象创建