JVM - 浅谈JVM运行时内存结构(2)
一、堆(Heap)
1. 什么是堆
堆是用于存放对象的内存区域。因此,它是垃圾收集器(GC)管理的主要目标。其具有以下特点:
- 堆在逻辑上划分为新生代和老年代。由于 Java 对象大部分是朝生夕灭,还有一小部分能够长期驻留在内存中,为了对这两种对象进行这里是引用
- 堆占用的内存并不要求物理连续,只需要逻辑连续即可
- 堆一般实现成可扩展内存大小,使用 -Xms 与 -Xmx 控制最小与最大内存,扩展动作交由虚拟机执行。但由于该行为比较消耗性能,因此通常将堆的最大最小内存设为相等
- 堆是所有****的内存区域,因此每个线程都可以拿到堆上的同一个对象
- 堆的生命周期是随着虚拟机的启动而创建
一切new出来的东西,都在堆内存中
2. 堆异常
当堆无法分配对象且内存无法继续扩展时,会抛出 OutOfMemoryError 异常。
一般来说,堆无法分配对象时会进行一次 GC,如果 GC 后仍然无法分配对象,才会报内存耗尽的错误。可以通过不断生成新的对象,但不释放引用来模拟这种情形。
/**
* java堆溢出demo
* JVM参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
//不断创建新对象,使得Heap溢出
while (true) {
list.add(new OOMObject());
}
}
}
上述代码中对象不断的被创建而不进行引用释放,导致 GC 无法回收堆内存,最终 OutOfMemoryError,错误信息:
java.lang.OutOfMemoryError: Java heap space // 堆空间OOM
二、方法区(Method Area)
1. 什么是方法区
方法区也是一个线程共享的内存区域。其中主要存储加载的类字节码、class/method/field 等元数据对象、static-final常量、static变量,以及JIT编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域:运行时常量池。他们的关系如下图所示:
- 加载的类字节码:要使用一个类,首先需要将其字节码加载到JVM的内存中。类的字节码来源包括很多种,如 .class文件、网络传输、cglib字节码框架直接生成
- class/method/field 等元数据对象:类字节码加载后,JVM根据其中的内容为这个类生成 Class/Method/Field 等对象,他们用于描述一个类,通常在反射中用的比较多。不同于存储在堆中的Java实例对象,这两种对象储存在方法区中
- static-final常量、static变量:对于这两种类型的类成员,JVM会在方法区为他们创建一份数据,因此同一个类的static修饰的类成员只有一份
- JIT编译器的编译结果:以 HotSpot 虚拟机为例,其在运行时会使用JIT对热点代码进行优化,优化方式为将字节码编译成机器码。通常情况下,JVM使用解释执行的方式执行字节码,即JVM在读取到一个字节码的指令时,会将其按照预先定好的规则执行栈操作,而栈操作会进一步映射为底层的机器操作;通过JIT编译后,执行的机器码会直接和底层机器打交道。如下图所示:
2. 运行时常量池
类的字节码在加载时会被解析并生成不同的内容存入方法区。这些内容包含了类的版本、字段、方法、接口等描述信息,以及字面量(如字符串字面量)和符号引用。其中,在类加载时,所有的字面量和符号引用将被存放在常量池中。
运行时常量池时方法区种一个比较特殊的部分,具备动态性。也就是说,除了在类加载时可以向常量池存放内容,Java程序运行期间也能够对常量池执行写入操作。
//使用StringBuilder在堆上创建字符串abc,再使用intern将其放入运行时常量池
String str = new StringBuilder("abc");
str.intern();
//直接使用字符串字面量xyz,其被放入运行时常量池
String str2 = "xyz";
3. 方法区的实现
JVM规范中并没有明确指定方法区的实现,目前有 2 种比较主流的实现方式:
- HotSpot虚拟机1.7-:在JDK1.6及之前的版本,HotSpot使用永久代(Permanent Generation)的概念作为实现。即,将GC分代收集至方法区。这种实现比较取巧,可以不必为方法区编写专门的内存管理,但带来的后果是容易碰到内存溢出的问题(因为永久代有-XX:MaxPermSize的上限)。在JDK1.7+之后,HotSpot逐渐改变方法区的实现方式,如1.7版本移除了方法区中的字符串常量池。
- HotSpot虚拟机1.8+:1.8版本中移除了永久代并使用了元数据空间(metaspace)作为替代实现。metaspace占用系统内存,也就是说,只要不触碰到系统内存上限,方法区会有足够的内存空间。但这并不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。
方法区指的是JVM内存结构规范中的一部分,永久代和元空间分别是方法区不同的实现方式。其中,前者对应JDK1.7-,后者对应JDK1.8+
思考两个问题:
一、为什么使用永久代并将 GC 分代收集扩展至方法区这种实现方式不好/容易导致 OOM ?
- 首先,要明白方法区的内存回收目标是什么:方法区储存了类的元数据信息和各种常量,它的内存回收目标理应是对这些类型的卸载和常量的回收。
- 但是,由于这些数据被类的实例引用,卸载条件变得复杂且严格,回收不当会导致堆中的类实例丢失元数据信息和常量信息。因此,回收方法区内存并不是一件简单高效的事情,往往 GC 是在做无用功。
- 而且,随着应用规模不断扩大,各种框架的引入,尤其是使用了字节码生成技术的框架……这些因素往往会导致方法区内存占用越来越大,最终导致 OOM。
二、对比永久代,元空间的优势体现在哪里?
- 不会出现“java.lang.OutOfMemoryError: PremGen space”异常;
- 字符串常量池迁移到堆中,避免溢出;
- 解决了因永久代对GC造成的额外的复杂度,提高回收效率;
- 可以通过类加载器控制垃圾回收。
4. 方法区异常
方法区的两种实现方式都有最大值上限,因此,如果方法区(包括运行时常量池)占用内存达到上限,且无法在申请内存时,便会抛出OutOfMemoryError。
下面的例子使用 cglib 字节码生成框架不断生成新的类,最终使方法区内存占满,从而抛出 OutOfMemoryError。
/**
* java方法区溢出OutOfMemoryError(JVM参数适用于JDK1.6之前,借助CGLIB)
* JVM参数:-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(objects, args));
enhancer.create();
}
}
static class OOMObject {
}
}
报错信息为:
Caused by: java.lang.OutOfMemoryError: PermGen space // 永久代OOM
at java.lang.ClassLoader.defineClass1(Native Method)
···
此外,在日常开发中,不仅仅使 cglib 字节码生成框架会产生大量的 class 信息,动态语言、JSP、基于 OSI 的应用都会在方法区额外产生大量的类信息。
三、直接内存(Direct Memory)
1. 什么是直接内存
JVM内存结构中并不包含直接内存,它并不是JVM运行时数据区的一部分,但它却被频繁地使用,原因是NIO这个包。
NIO(New input/output)是JDK1.4中新加入的类,引入了一种基于通道(channel)和缓冲区(buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过堆上的 DirectByteBuffer 对象堆这块内存进行引用和操作。如下图所示:
可以看出,直接内存的大小并不受到 Java 堆大小的限制,甚至不受到 JVM 进程内存大小的限制。它只受到本机内存(RAM 及 SWAP 区或者分页文件)容量以及处理器寻址空间的限制(最常见的就是32位/64位 CPU 的最大寻址空间限制不同)。
2. 直接内存的 OutOfMemoryError
直接内存出现 OutOfMemoryError 的原因是对该区域进行内存分配时,其内存与其它内存加起来超过最大物理内存限制(包括物理的和操作系统级的限制),从而导致异常出现。
另外,若通过参数 -XX:MaxDirectMemorySize 指定了直接内存的最大值,其超过指定的最大值时,也会抛出内存溢出异常。
/**
* jvm直接内存溢出
* JVM参数:-Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws IllegalAccessException {
//通过反射获取Unsafe类并通过其分配直接内存
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
结果如下,可以看出,其抛出的内存溢出异常并没有指定是JVM那一块数据区域:
Exception in thread "main" java.lang.OutOfMemoryError // 无指定内存区域
at sun.misc.Unsafe.allocateMemory(Native Method)
at com.manayi.study.jvm.chapter2._07_DirectMemoryOOM.main(_07_DirectMemoryOOM.java:22)
上面的例子有点特殊,因为我们使用到了 Unsafe 这个类,它的allocateMemory 方法能够直接从堆外内存中申请内存(类比于 C 的 malloc 函数)。不同于 DirectByteBuffer 的内存分配方式(先计算是否有足够的可用内存再决定是手动抛异常还是向操作系统申请分配内存),Unsafe 是直接向操作系统申请分配内存,若未申请到则抛异常。