JVM
内存区域包括 PC计数器 、 Java虚拟机栈 、 本地方法栈 、 堆 、 方法区 、 运行时常量池 和 直接内存 。
本文主要介绍各个内存区域的作用和特性,同时分别阐述各个区域发生内存溢出的可能性和异常类型。
Java
虚拟机执行 Java
程序的过程中,会把所管理的内存划分为若干不同的 数据区域 。这些内存区域各有各的用途,以及创建和销毁时间。有的区域随着虚拟机进程的启动而存在,有的区域伴随着用户线程的启动和结束而创建和销毁。
JVM
内存区域 也称为 Java
运行时数据区域 。其中包括: 程序计数器 、 虚拟机栈 、 本地方法栈 、 堆 、 静态方法区 、 静态常量池 等。
注意:程序计数器、虚拟机栈、本地方法栈属于每个 线程私有的 ;堆和方法区属于 线程共享访问的 。
程序计数器( Program Counter Register
)是一块 较小 的内存空间,它的作用可以看做是 当前线程 所执行的 字节码行号指示器 。
PC
计数器。 JVM
启动而生, JVM
关闭而死。 Java
方法时,记录其正在执行的虚拟机 字节码指令地址 。 Native
方法时,计数器记录为 空 ( Undefined
)。 Java
虚拟机规范中没有规定任何 OutOfMemoryError
情况区域。 线程 私有 内存空间,它的生命周期和线程相同。线程执行期间,每个方法执行时都会创建一个 栈帧(Stack Frame) ,用于存储 局部变量表 、 操作数栈 、 动态链接 、 方法出口 等信息。
每一个方法从调用直到执行完成的过程,就对应着一个 栈帧 在虚拟机栈中的 入栈 和 出栈 的全过程。
下面依次解释 栈帧 里的四种组成元素的具体结构和功能:
局部变量表是一组 变量值 的存储空间,用于存储 方法参数 和 局部变量 。 在 Class
文件的方法表的 Code
属性的 max_locals
指定了该方法所需局部变量表的 最大容量 。
局部变量表在编译期间分配内存空间,可以存放 编译期 的各种变量类型:
boolean
, byte
, char
, short
, int
, float
, long
, double
等 8
种; reference
,指向对象 起始地址 的 引用指针 ; returnAddress
,返回地址的类型。 变量槽( Variable Slot
):
变量槽是 局部变量表 的 最小单位 ,规定大小为 32
位。对于 64
位的 long
和 double
变量而言,虚拟机会为其分配 两个连续 的 Slot
空间。
操作数栈( Operand Stack
)也常称为操作栈,是一个后入先出栈。在 Class
文件的 Code
属性的 max_stacks
指定了执行过程中最大的栈深度。 Java
虚拟机的 解释执行引擎 被称为 基于栈的执行引擎 ,其中所指的 栈 就是指- 操作数栈 。
32
字长 为单位的数组。 int
、 long
、 float
、 double
、 reference
和 returnType
等类型 (对于 byte
、 short
以及 char
类型的值在压入到操作数栈之前,也会被转换为 int
)。 begin iload_0 // push the int in local variable 0 onto the stack iload_1 // push the int in local variable 1 onto the stack iadd // pop two ints, add them, push result istore_2 // pop int, store into local variable 2 end 复制代码
在这个 字节码序列 里,前两个指令 iload_0
和 iload_1
将存储在 局部变量表 中索引为 0
和 1
的整数压入操作数栈中,其后 iadd
指令从操作数栈中弹出那两个整数相加,再将结果压入 操作数栈 。第四条指令 istore_2
则从 操作数栈 中弹出结果,并把它存储到 局部变量表 索引为 2
的位置。
下图详细表述了这个过程中 局部变量表 和 操作数栈 的状态变化(图中没有使用的 局部变量表 和 操作数栈 区域以空白表示)。
每个 栈帧 都包含一个指向运行时 常量池 中所属的 方法引用 ,持有这个引用是为了支持方法调用过程中的 动态链接 。
Class
文件的 常量池 中存在有大量的 符号引用 ,字节码中的 方法调用指令 就以常量池中指向方法的 符号引用 为参数。这些符号引用:
final
、 static
域等),称为 静态解析 , 当一个方法开始执行以后,只有两种方法可以退出当前方法:
Normal Method Invocation Completion
),一般来说,调用者的 PC
计数器 可以作为返回地址。 Abrupt Method Invocation Completion
),返回地址要通过 异常处理器 表来确定。 当一个方法返回时,可能依次进行以下 3
个操作:
PC
计数器 的值指向 下一条 方法指令位置。 注意:在Java虚拟机规范中,对这个区域规定了两种异常。 其一 :如果当前线程请求的栈深度大于虚拟机栈所允许的深度,将会抛出 StackOverflowError
异常(在虚拟机栈不允许动态扩展的情况下); 其二 :如果扩展时无法申请到足够的内存空间,就会抛出 OutOfMemoryError
异常。
本地方法栈和 Java
虚拟机栈 发挥的作用非常相似,主要区别是 Java
虚拟机栈 执行的是 Java
方法服务 ,而 本地方法栈 执行 Native
方法服务 (通常用C编写)。
有些虚拟机发行版本(譬如 Sun HotSpot
虚拟机)直接将 本地方法栈 和 Java
虚拟机 栈合二为一。与虚拟机栈一样,本地方法栈也会抛出 StackOverflowError
和 OutOfMemoryError
异常。
Java
堆是被所有 线程共享 的 最大 的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是 存放对象实例 ,几乎所有的对象实例都在这里分配内存。
在 Java
中,堆被划分成两个不同的区域: 新生代 ( Young Generation
) 、 老年代 ( Old Generation
) 。 新生代 ( Young
) 又被划分为三个区域: 一个 Eden
区和 两个 Survivor
区 - From Survivor
区和 To Survivor
区。
简要归纳:新的对象分配是首先放在年轻代 ( Young Generation
) 的 Eden
区, Survivor
区作为 Eden
区和 Old
区的缓冲,在 Survivor
区的对象经历若干次收集仍然存活的,就会被转移到老年代 Old
中。
这样划分的目的是为了使 JVM
能够更好的管理堆内存中的对象,包括内存的分配以及回收。
方法区和 Java
堆一样,为多个线程共享,它用于存储 类信息 、 常量 、 静态常量 和 即时编译后的代码 等数据。
运行时常量池是 方法区 的一部分, Class
文件中除了有 类的版本 、 字段 、 方法 和 接口 等描述信息外, 还有一类信息是 常量池 ,用于存储编译期间生成的各种 字面量 和 符号引用 。
直接内存不属于虚拟机运行时数据区的一部分,也不是 Java
虚拟机规范中定义的内存区域。 Java NIO
允许 Java
程序直接访问 直接内存 ,通常 直接内存 的速度会优于 Java堆内存 。因此,对于读写频繁、性能要求高的场景,可以考虑使用直接内存。
除了程序计数器外, Java
虚拟机的其他运行时区域都有可能发生 OutOfMemoryError
的异常,下面分别给出验证:
Java
堆能够存储对象实例。通过不断地创建对象,并保证 GC Roots
到对象有可达路径来避免垃圾回收机制清除这些对象。 当对象数量到达最大堆的容量限制时就会产生 OutOfMemoryError
异常。
设置 JVM
启动参数: -Xms20M
设置堆的 最小内存 为 20M
, -Xmx20M
设置堆的 最大内存 和 最小内存 一样,这样可以防止 Java
堆在内存不足时 自动扩容 。 -XX:+HeapDumpOnOutOfMemoryError
参数可以让虚拟机在出现内存溢出异常时 Dump
出 内存堆 运行时快照。
HeapOOM.java
/** * VM Args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError */ public class HeapOOM { public static class OOMObject { } public static void main(String[] args) { List<OOMObject> list = new ArrayList<>(); while (true) { list.add(new OOMObject()); } } } 复制代码
打开 Java VisualVM
导出 Heap
内存运行时的 dump
文件。
HeapOOM
对象不停地被创建,堆内存使用达到
99%
。
垃圾回收器
不断地尝试回收但都以失败告终。
分析:遇到这种情况,通常要考虑 内存泄露 和 内存溢出 两种可能性。
进一步使用 Java VisualVM
工具进行分析,查看 泄露对象 是通过怎样的 路径
与 GC Roots
关联而导致 垃圾回收器 无法回收的。
通过 Java VisualVM
工具分析,不存在泄露对象,也就是说 堆内存 中的对象必须得存活着。就要考虑如下措施:
-Xmx
与 -Xms
),对比机器的 物理内存 看是否还可以调大。 关于虚拟机栈和本地方法栈,分析内存异常类型可能存在以下两种:
StackOverflowError
异常。 OutOfMemoryError
异常。 可以划分为两类问题,当栈空间无法分配时,到底时栈内存 太小 ,还是 已使用 的栈内存 过大 。
-Xss
参数减少 栈内存 的容量,异常发生时打印 栈 的深度。 设置 JVM
启动参数: -Xss128k
设置 栈内存 的大小为 128k
。
JavaVMStackSOF.java
/** * VM Args: -Xss128k */ public class JavaVMStackSOF { private int stackLength = 1; private void stackLeak() { stackLength++; stackLeak(); } public static void main(String[] args) { JavaVMStackSOF oom = new JavaVMStackSOF(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("Stack length: " + oom.stackLength); throw e; } } } 复制代码
分析:在单个线程下,无论是 栈帧太大 还是 虚拟机栈容量太小 ,当无法分配内存的时候,虚拟机抛出的都是 StackOverflowError
异常。
JavaVMStackOOM.java
/** * VM Args: -Xss2M */ public class JavaVMStackOOM { private void running() { while (true) { } } public void stackLeakByThread() { while (true) { new Thread(new Runnable() { @Override public void run() { running(); } }).start(); } } public static void main(String[] args) { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } } 复制代码
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread 复制代码
上述测试代码运行时存在较大的风险,可能会导致操作系统假死,这里就不亲自测试了,引用作者的测试结果。
运行时常量和 字面量 都存放于 运行时常量池 中,常量池又是方法区的一部分,因此两个区域的测试是一样的。 这里采用 String.intern()
进行测试:
String.intern()是一个native方法,它的作用是:如果字符串常量池中存在一个String对象的字符串,那么直接返回常量池中的这个String对象; 否则,将此String对象包含的字符串放入常量池中,并且返回这个String对象的引用。
设置 JVM
启动参数:通过 -XX:PermSize=10M
和 -XX:MaxPermSize=10M
限制 方法区 的大小为 10M
,从而间接的限制其中 常量池 的容量。
RuntimeConstantPoolOOM.java
/** * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M */ public class RuntimeConstantPoolOOM { public static void main(String[] args) { // 使用List保持着常量池的引用,避免Full GC回收常量池 List<String> list = new ArrayList<>(); // 10MB的PermSize在Integer范围内足够产生OOM了 int i = 0; while (true) { list.add(String.valueOf(i++).intern()); } } } 复制代码
JDK1.6
版本运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space at java.lang.String.intern(Native Method) 复制代码
JDK1.6
版本运行结果显示 常量池 会溢出并抛出 永久带 的 OutOfMemoryError
异常。 而 JDK1.7
及以上的版本则不会得到相同的结果,它会一直循环下去。
方法区存放 Class
相关的信息,比如 类名 、 访问修饰符 、 常量池 、 字段描述 、 方法描述 等。 对于 方法区的内存溢出 的测试,基本思路是在运行时产生大量 类字节码 区填充 方法区 。
这里引入 Spring
框架的 CGLib
动态代理的 字节码技术 ,通过循环不断生成新的 代理类 ,达到 方法区 内存溢出的效果。
JavaMethodAreaOOM.java
/** * VM Args: -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(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } } private static class OOMObject { public OOMObject() { } } } 复制代码
JDK1.6
版本运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632) at java.lang.ClassLoader.defineClass(ClassLoader.java:616) 复制代码
测试结果分析:
JDK1.6
版本运行结果显示 常量池 会溢出并抛出 永久带 的 OutOfMemoryError
异常。 而 JDK1.7
及以上的版本则不会得到相同的结果,它会一直循环下去。
本机 直接内存 的容量可通过 -XX:MaxDirectMemorySize
指定,如果不指定,则默认与 Java
堆 最大值 (-Xmx指定)一样。
直接通过反射获取 Unsafe
实例,通过反射向操作系统申请分配内存:
设置 JVM
启动参数: -Xmx20M
指定 Java
堆的最大内存, -XX:MaxDirectMemorySize=10M
指定 直接内存 的大小。
DirectMemoryOOM.java
/** * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M */ public class DirectMemoryOOM { private static final int _1MB = 1024 * 1024; public static void main(String[] args) throws Exception { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { unsafe.allocateMemory(_1MB); } } } 复制代码
由 DirectMemory
导致的内存溢出,一个明显的特征是 Heap Dump
文件中不会看到明显的异常信息。 如果 OOM
发生后 Dump
文件很小,并且程序中直接或者间接地使用了 NIO
,那么就可以考虑一下这方面的问题。
欢迎关注技术公众号: 零壹技术栈
本帐号将持续分享后端技术干货,包括虚拟机基础,多线程编程,高性能框架,异步、缓存和消息中间件,分布式和微服务,架构学习和进阶等学习资料和文章。