概述
对于从事C、C++程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权力的“皇帝”又是从事最基础工作的“劳动人民”——既拥有每一个对象的“所有权”,又担负着每一个对象生命开始到终结的维护责任。
对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存这一切看起来都很美好。不过,也正是因为Java程序员把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的工作。
运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存区域划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有些区域随着虚拟机进程的启动而存在,有些区域则是依赖线程的启动和结束而建立和销毁。Java 虚拟机所管理的内存被划分为如下几个区域:
程序计数器(线程私有)
程序计数器是一块较小的内存区域,可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
1. 当前线程所执行的字节码行号指示器。
2. 每个线程都有一个自己的PC计数器。
3. 线程私有的,生命周期与线程相同,随JVM启动而生,JVM关闭而死。
4. 线程执行Java方法时,记录其正在执行的虚拟机字节码指令地址。
5. 线程执行Native方法时,计数器记录为空(Undefined)。
6. 唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。
Java虚拟机栈(VM Stack)
线程私有内存空间,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:线程执行期间,每个方法被执行时,都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每个方法从被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
下面依次解释栈帧里的四种组成元素的具体结构和功能:
局部变量表
局部变量表局部变量表是 Java 虚拟机栈的一部分,是一组变量值的存储空间,用于存储方法参数和局部变量。 在 Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。
局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:
- 基本数据类型:
boolean
,byte
,char
,short
,int
,float
,long
,double
等8种; - 对象引用类型 :reference,指向对象起始地址的引用指针;不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置
- 返回地址类型:
returnAddress
,返回地址的类型。指向了一条字节码指令的地址
变量槽(Variable Slot):
变量槽是局部变量表的最小单位,规定大小为32位。对于64位的long和double变量而言,虚拟机会为其分配两个变量槽,其余的数据类型只占用一个。
异常状况
- 如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryErrot异常
本地方法栈(Native Method Stack)
本地方法栈和Java虚拟机栈发挥的作用非常相似,主要区别是Java虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务
Java堆(Heap)
对大多数应用而言,Java 堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一作用就是存放对象实例,几乎所有的对象实例都是在这里分配的(不绝对,在虚拟机的优化策略下,也会存在栈上分配、标量替换的情况,后面的章节会详细介绍)
Java 堆是 GC 回收的主要区域,因此很多时候也被称为 GC 堆。
从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以在Java堆被划分成两个不同的区域:新生代 (Young Generation)
、老年代 (Old Generation)
。新生代 (Young) 又被划分为三个区域:一个Eden
区和两个Survivor区
- From Survivor区
和To Survivor区
。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然时对象实例,记你一步划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配以及回收。
如果从分配内存的的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB
),以提升对象分配时的效率。
Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数 -Xmx
和 -Xms
设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展,Java虚拟机将会抛出OutOfMemoryError
异常。
方法区(Method Area)
方法区和Java堆一样,为多个线程共享,它用于存储类信息、常量、静态常量和即时编译后的代码等数据。
如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError
异常
运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant Pool Table
),用于存放编译期生成的各种字面常量和符号引用,这部分内容会在类加载后进入方法区的运行时常量池。
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量池放入池中。(如String类的intern()
方法)
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryErro
r异常。
直接内存
直接内存(Direct Memory
)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。Java 中的 NIO 可以使用 Native 函数直接分配堆外内存,通常直接内存的速度会优于Java堆内存,然后通过一个存储在 Java 堆中的 DiectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景显著提高性能,对于读写频繁、性能要求高的场景,可以考虑使用直接内存,因为避免了在 Java 堆和 Native 堆中来回复制数据。直接内存不受 Java 堆大小的限制。
HotSpot虚拟机对象探秘
对象的创建
首先让我们看看 Java 中提供的几种对象创建方式:
Header | 解释 |
---|---|
使用new关键字 | 调用了构造函数 |
使用Class的newInstance方法 | 调用了构造函数 |
使用Constructor类的newInstance方法 | 调用了构造函数 |
使用clone方法 | 没有调用构造函数 |
使用反序列化 | 没有调用构造函数 |
下面是对象创建的主要流程:
虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载。类加载通过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;如果不是规整的,就从空闲列表中分配,叫做”空闲列表“方式。划分内存时还需要考虑一个问题-并发,也有两种方式: CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。然后内存空间初始化操作,接着是做一些必要的对象设置(元信息、哈希码…),最后执行方法。
对象的内存布局
HotSpot
虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header
)、实例数据(Instance Data
)和对齐填充(Padding
)。
对象头(Header)
在HotSpot
虚拟机中,对象头有两部分信息组成:运行时数据 和 类型指针,如果是数组对象,还有一个保存数组长度的空间。
- Mark Word(运行时数据):用于存储对象自身运行时的数据,如哈希码(hashCode)、GC分带年龄、线程持有的锁、偏向线程ID 等信息。在32位系统占4字节,在64位系统中占8字节;
HotSpot虚拟机对象头Mark Word在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示:
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
- Class Pointer(类型指针):用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
- Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;
实例数据(Instance Data)
实例数据 是对象真正存储的有效信息,无论是从父类继承下来的还是该类自身的,都需要记录下来,而这部分的存储顺序受虚拟机的分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义的顺序的影响。
默认分配策略:
long/double -> int/float -> short/char -> byte/boolean -> reference
如果设置了-XX:FieldsAllocationStyle=0(
默认是1),那么引用类型数据就会优先分配存储空间:
reference -> long/double -> int/float -> short/char -> byte/boolean
对齐填充
无特殊含义,不是必须存在的,仅作为占位符。
HotSpot
虚拟机要求每个对象的起始地址必须是8
字节的整数倍,也就是对象的大小必须是8
字节的整数倍。而对象头部分正好是8
字节的倍数(32
位为1
倍,64
位为2
倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
对象的访问定位
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄 和 直接指针 两种方式。
句柄访问
Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:
优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。
直接指针
如果使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。
优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。
实战:OutOfMemoryError异常
内存异常是我们工作当中经常会遇到问题,但如果仅仅会通过加大内存参数来解决问题显然是不够的,应该通过一定的手段定位问题,到底是因为参数问题,还是程序问题(无限创建,内存泄露)。定位问题后才能采取合适的解决方案,而不是一内存溢出就查找相关参数加大。
概念:
内存泄露:代码中的某个对象本应该被虚拟机回收,但因为拥有GCRoot引用而没有被回收。
内存溢出:虚拟机由于堆中拥有太多不可回收对象没有回收,导致无法继续创建新对象。
在分析问题之前先给大家讲一讲排查内存溢出问题的方法,内存溢出时JVM虚拟机会退出,那么我们怎么知道JVM运行时的各种信息呢,Dump
机制会帮助我们,可以通过加上VM参数-XX:+HeapDumpOnOutOfMemoryError
让虚拟机在出现内存溢出异常时生成dump文件,然后通过外部工具(VisualVM
)来具体分析异常的原因。
除了程序计数器外,Java虚拟机的其他运行时区域都有可能发生OutOfMemoryError
的异常,下面分别给出验证:
Java堆溢出
ava堆用来存储对象,因此只要不断创建对象,并保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么当对象数量达到最大堆容量时就会产生 OOM
。
/**
* java堆内存溢出测试
* VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject{}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
运行结果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid7164.hprof …
Heap dump file created [27880921 bytes in 0.193 secs]
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:2245)
at java.util.Arrays.copyOf(Arrays.java:2219)
at java.util.ArrayList.grow(ArrayList.java:242)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
at java.util.ArrayList.add(ArrayList.java:440)
at com.jvm.oom.HeapOOM.main(HeapOOM.java:17)
虚拟机栈和本地方法栈溢出
在 HotSpot 虚拟机中不区分虚拟机栈和本地方法栈,栈容量只由 -Xss
参数设定。关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出
StackOverflowError
异常。 - 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出
OutOfMemoryError
异常。
/**
* 虚拟机栈和本地方法栈内存溢出测试,抛出stackoverflow exception
* VM ARGS: -Xss128k 减少栈内存容量
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak () {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length = " + oom.stackLength);
throw e;
}
}
}
运行结果:
stack length = 11420
Exception in thread “main” java.lang.StackOverflowError
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
以上代码在单线程环境下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,抛出的都是 StackOverflowError 异常。
如果测试环境是多线程环境,通过不断建立线程的方式可以产生内存溢出异常,代码如下所示。但是这样产生的 OOM 与栈空间是否足够大不存在任何联系,在这种情况下,为每个线程的栈分配的内存足够大,反而越容易产生OOM 异常。这点不难理解,每个线程分配到的栈容量越大,可以建立的线程数就变少,建立多线程时就越容易把剩下的内存耗尽。这点在开发多线程的应用时要特别注意。如果建立过多线程导致内存溢出,在不能减少线程数或更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。
/**
* JVM 虚拟机栈内存溢出测试, 注意在windows平台运行时可能会导致操作系统假死
* VM Args: -Xss2M -XX:+HeapDumpOnOutOfMemoryError
*/
public class JVMStackOOM {
private void dontStop() {
while (true) {}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
});
thread.start();
}
}
public static void main(String[] args) {
JVMStackOOM oom = new JVMStackOOM();
oom.stackLeakByThread();
}
}
方法区和运行时常量池溢出
方法区用于存放Class的相关信息,对这个区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。使用CGLib实现。
方法区溢出也是一种常见的内存溢出异常,在经常生成大量Class的应用中,需要特别注意类的回收情况,这类场景除了使用了CGLib字节码增强和动态语言外,常见的还有JSP文件的应用(JSP第一次运行时要编译为Java类)、基于OSGI的应用等。
/**
* 测试JVM方法区内存溢出
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class MethodAreaOOM {
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();
}
}
static class OOMObject{}
}
本机直接内存溢出
DirectMemory
容量可通过 -XX:MaxDirectMemorySize
指定,如不指定,则默认与Java堆最大值一样。测试代码使用了 Unsafe
实例进行内存分配。
由 DirectMemory
导致的内存溢出,一个明显的特征是在Heap Dump
文件中不会看见明显的异常,如果发现 OOM 之后 Dump
文件很小,而程序直接或间接使用了NIO
,那就可以考虑检查一下是不是这方面的原因。
/**
* 测试本地直接内存溢出
* 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);
}
}
}