2.1 概述
对于c/c++程序员来说,每一个new操作后面要配对delete/free操作来释放内存,很繁琐。
而Java将内存控制权交给了Java虚拟机,使得程序员不需要去关心内存的内在机制,而知专注于代码本身。而这一章将介绍虚拟机是如何使用内存的。
2.2 运行时数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区 域。
- 方法区
- 堆
- 虚拟机栈
- 本地方法栈
- 程序计数器
2.2.1 程序计数器
简单来说程序计数器就是用于程序选取的,每一个线程都有自己独立的程序计数器(线程私有)。通过程序计数器可以选取下一条需要执行的指令,由它指示到字节码的行号,也就是记录指令的地址。
2.2.2 Java虚拟机栈
虚拟机栈也是平常我们所说的栈区域,它也是线程私有的,也就是生命周期与线程一致,简单来说它就是用来方法执行的,每个方法在执行的时候,会创建一个栈帧,用于存储于 存储局部变量表 、操作数栈、动态链接、方法出口 等信息,每一个方法从调用到执行完成,都有一个栈帧从入栈到出栈。
2.2.3 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间 的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚 拟机使用到的Native方法(非Java代码)服务
2.2.4 Java堆
Java堆是内存中最大的一块区域,它是线程共享的,虚拟机启动时创建,而它的唯一目的就是存放对象实例,Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。
2.2.5 方法区
方法区也是线程共享的,它用于存储已被虚 拟机加载的 类信息、常量、静态变量、即时编译器_编译后的代码_等数据。
2.2.6 运行时常量池
它是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
2.2.7 直接内存
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓 冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储 在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著 提高性能,因为避免了在Java堆和Native堆中来回复制数据。
2.3 HotSpot虚拟机对象
笔者以常用的虚拟机HotSpot和常用的内存区域Java堆为例,深入探讨HotSpot虚拟机在Java堆中对象分 配、布局和访问的全过程。
2.3.1 对象的创建
- new指令: 开始创建对象
- 类加载检查: 首先检查指令参数能否在常量池中定位到一个类的符号,检查是否加载过,如果没有需要类加载过程
- 分配内存: 内存大小确定后,分区指针向空闲区移动内存大小的距离(指针碰撞),如果空闲区和已使用区域交错,则虚拟机维护一个列表,记录空闲的内存块,并为对象从列表中分配一块足够大的空间(空闲列表)。
- 对象信息设置: 例如对象是哪个类的实例,对象的哈希码,对象的GC年代划分等等,这些信息放在对象的对象头(Object Header) 当中,虚拟机视角来看,当前对象已经创建完成。
- 方法: 从Java程序角度来看,执行方法后,这个对象才算完全的生产出来
2.3.2 对象的内存布局
在Hospot虚拟机中,对象在内存中布局可分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
- 对象头:
包括两部分信息,一部分存储运行时信息,例如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过指针来确定对象是哪个类的实例,但是并不一定,查找对象元数据信息不一定要通过对象本身。 - 实例数据: 这部分存储真正有效的数据信息,也是在程序代码中所定义的各种类型的字段内容。
- 对齐填充: 这部分不是必然存在的,仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象大小必须是8的倍数,因此当实例数据部分没有对齐时,通过这部分来进行填充对齐。
2.3.3 对象的定位访问
Java程序需要通过栈上的reference数据来操作堆上的具体对象。而这个数据只规定了一个指向对象的引用,没有定义用何种方式去定位访问,目前主流的访问方式有使用句柄和直接指针两种方式
-
使用句柄访问
划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,优势是稳定的句柄地址,对象被移动时只会改变句柄中的示例数据指针。
-
直接指针访问
reference中存储的直接是对象的地址,优势是访问速度快,节省一次定位指针的开销。
2.4 实战:OutOfMemoryError异常
Java虚拟机中除了程序计数器以外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能。首先设置debug的参数显示详细信息,通过代码进行各种溢出的尝试。
2.4.1 Java堆溢出
Java堆用于存储对象实例,不断地创建对象,并且保证GC ROOTS到对象之间有可达路径来避免垃圾回收清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出
public class Main {
static class OOMObject{
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}
2.4.2虚拟机栈和本地方法栈溢出
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
Java虚拟机把异常分为这两种情况,但却有重叠的的地方,当栈空间无法继续分配时,是内存太小还是已使用栈空间太大,本质是相同的。
实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。
每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
2.4.3 方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。
- 常量池: String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等 于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包 含的字符串添加到常量池中,并且返回此String对象的引用。
/*
* VM Options
* -XX:PermSize=10M
* -XX:MaxPermSize=10M
* 限制常量区大小
* */
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
int i = 0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
- 方法区: 测试思路是运行时产生大量的类去填满方法区,直到溢出
/*
* VM Options
* -XX:PermSize=10M
* -XX:MaxPermSize=10M
* 限制常量区大小
* */
public class Main {
public static void main(String[] args) {
while(true){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OMMObject.class);
enhancer.setUseCatch(false);
enhancer.setCallback(new MethodInterceptor)(
public Object intercept(Object obj,Method method,Object args[], MethodProxy proxy)throws Throwable{
return proxy.invokeSuper(obj,args);
}
);
enhancer.create();
}
}
static class OMMObject{}
}
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是 比较苛刻的。
2.4.4 本机直接溢出
DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java 堆最大值(-Xmx指定)一样
由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显 的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就 可以考虑检查一下是不是这方面的原因