鲁迅曾说过:Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进来,墙里面的人想出去。
一.虚拟机内存分布
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
1. 程序计数器(Program Counter Register)
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。称之为“线程私有”的内存。程序计数器内存区域是虚拟机中唯一没有规定OutOfMemoryError情况的区域。
2. Java虚拟机栈(Java Virtual Machine Stacks)
java虚拟机也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。局部变量表存放了编译期可知的各种基本数据类型(8个基本数据类型)、对象引用(地址指针)、returnAddress类型。
局部变量表所需的内存空间在编译期间完成分配。在运行期间不会改变局部变量表的大小。
这个区域规定了两种异常状态:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出*Error异常;如果虚拟机栈可以动态扩展,在扩展是无法申请到足够的内存,就会抛出OutOfMemoryError异常。
3. 本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈所发挥作用非常相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。本地方法栈也是抛出两个异常。
4. Java堆(Java Heap)
java堆是java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。
java堆是垃圾收集器管理的主要区域,因此也被成为“GC堆”(Garbage Collected Heap)。从内存回收角度来看java堆可分为:新生代和老生代(当然还有更细致的划分,在下一章会讲到)。从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。无论怎么划分,都与存放内容无关,无论哪个区域,存储的都是对象实例,进一步的划分都是为了更好的回收内存,或者更快的分配内存。
根据Java虚拟机规范的规定,java堆可以处于物理上不连续的内存空间中。当前主流的虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制)。如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
5. 方法区(Method Area)
方法区与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它有个别命叫Non-Heap(非堆)。当方法区无法满足内存分配需求时,抛出OutOfMemoryError异常。
6. 运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在加载后进入方法区的运行时常量池中存放。
7. 直接内存(Direct Memory)
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。但这部分区域也呗频繁使用,而且也可能导致OutOfMemoryError异常
在JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
二.HotSpot虚拟机对象探秘
了解Java虚拟机的运行时数据区之后,我们再深入探讨下HotSpot虚拟机(虚拟机的一种)在Java堆中对象分配、布局和访问的全过程。
1. 对象的创建
在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new关键字而已,看看虚拟机中对象(限于普通Java对象,不包括数组和Class对象等)的创建具体的过程。
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么必须执行相应的类加载过程,在之后章节将会详细探讨。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
分配内存有两种方式:当java堆中内存绝对规整时,使用过的内存放在一边,空闲的内存放在一边,中间放着一个指针作为分界点。这种方式叫“指针碰撞”(Bump The Pointer);还有一种是java堆内存不规整,使用内存与空闲内存交错,这时虚拟机会维护一个列表,记录哪些内存是可用的,需要分配时在列表中找到一块够大的内存分配出去,这种方式叫“空闲列表”(Free List)
内存分配完成后,虚拟机需要将分配到内存空间都初始化为零值(不包括对象头),如果使用TLAB(线程私有的分配缓冲区),这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用。
2. 对象的内存布局
对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头包括两部分信息:第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,官方称这部分为(Mark Word)。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录。存储位置:1.相同宽度的字段总是被分配到一起;2.父类中的定义在子类的之前。
对齐填充没有特别的含义,它仅仅起着占位符的作用。JVM要求对象的起始地址必须是8字节的整数倍。
3. 对象的访问定位
建立对象是为了使用对象,我们的java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种。如图:
两张访问方式有各有优势。使用句柄来访问最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例指针,而reference本身不需要修改。
使用直接指针访问方式最大的好处是速度更快,它节省了一次指针定位的时间开销,
三.实战:OutOfMemoryError异常
通过若干个实例来验证异常的发生场景,并且初步介绍几个与内存相关的最基本的虚拟机参数。
1. Java堆溢出
Java堆用于存储对象实例,我们只要不断创建对象,并保证GC Roots到对象之间有可达路径来避免垃圾回收机制清楚这些对象。
/**
* 堆内存最小 20m 最大20m,设置虚拟机在内存溢出时dump出当前快照
* 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_pid5536.hprof ...
Heap dump file created [ bytes in 0.138 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:)
at java.util.Arrays.copyOf(Arrays.java:)
at java.util.ArrayList.grow(ArrayList.java:)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:)
at java.util.ArrayList.add(ArrayList.java:)
at jvm.HeapOOM.main(HeapOOM.java:)
要解决这个区域的异常,一般都需要使用一些内存映射分析工具(如 Eclipse Memory Analyzer)
2. 虚拟机栈和本地方法栈溢出
如果线程请求深度大于虚拟机所允许的最大深度,将抛出*Error异常。
/**
* 设定栈容量
* -Xss128k
*/
public class StackSOF {
private int stackInt = ;
public void stackLeak() {
stackInt++;
stackLeak();
} public static void main(String[] args) {
StackSOF sof = new StackSOF();
try {
sof.stackLeak();
} catch (Throwable e) {
System.out.println("stackInt length:" + sof.stackInt);
throw e;
}
}
}
运行结果:
stackInt length:
Exception in thread "main" java.lang.*Error
at jvm.StackSOF.stackLeak(StackSOF.java:)
at jvm.StackSOF.stackLeak(StackSOF.java:)
实验结果表明:在单线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,虚拟机都是抛出*Error异常。
如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
/**
* 这时候不妨设置的大些 特别提示:如果你想尝试运行这段代码,记得保存当前工作,很可能电脑会卡死机
* -Xss4M
*/
public class StackOOM {
private void notStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
notStop();
}
});
thread.start();
}
}
public static void main(String[] args) {
StackOOM oom = new StackOOM();
oom.stackLeakByThread();
}
}
运行结果:
我在windows环境下,出现内存使用爆满,电脑卡死,只能*重启电脑。
3. 方法区和运行时常量池溢出
方法区用于存放Class相关信息,基本思路就是运行时产生大量的类去填满方法区,直到溢出。
/**
* 方法区内存10M 方法区最大内存10M
* -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class MethodAreaOOM { static class OOMObject {
} public static void main(String[] args) {
while (true) {
//使用了CFLIB这类字节码技术,有兴趣可以另外了解一下
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, objects);
}
});
enhancer.create();
}
}
}
运行结果:
书中介绍会抛出异常
caused by: java.OutOfMemoryError: PermGen space
.....
.....
我自己执行时出现内存使用率迅速增长,
我顾及电脑会再次卡死就手动关闭了程序
4. 本机直接内存溢出
使用Unsage类的allocateMemory()方法来真正申请分配内存。
/**
* 设置最大堆内存20M 最大直接内存10M
* -Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemoryOOM {
private static final int _1MB = *; public static void main(String[] args) throws Exception{
Field field = Unsafe.class.getDeclaredFields()[];
field.setAccessible(true);
Unsafe unsafe = (Unsafe)field.get(null);
while (true) {
unsafe.allocateMemory(_1MB);//分配内存
}
//执行后 360提示电脑虚拟内存不足
}
}
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at jvm.DirectMemoryOOM.main(DirectMemoryOOM.java:)
本章小结
通过这一章的学习,我们明白了虚拟机中的内存是怎么划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然Java有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,
本章只讲解了各个区域出现异常的原因,下一章会详细讲解Java垃圾收集机制的底层实现。