运行时数据区域
- 来看下面一张图片:
1.程序计数器(Program Counter Register) :由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的(一个时刻只有一个任务在执行),所以我们必须记录每个线程当前的执行位置,故其是线程私有的。
2. Java虚拟机栈(Virtual Machine Stack)和本地方法栈(Native Method Stack):虚拟机栈就是我们平常说的栈,它描述的是Java方法执行的内存模型(本地方法栈描述的是Native方法执行的内存模型)。每个方法执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。一个方法从执行到完毕,其实就对应了该栈帧的入栈和出栈。其中我们最熟悉的就是局部变量表,它用于存放编译期可知的各种基本数据类型、对象引用类型和returnAddress类型(字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的(个人觉得如果一个变量的创建是有条件的,例如if(false) {int i=1;}这个i也应该会被分配局部变量空间,只是运行的时候,可能永远不会被用到)。因为方法肯定是由某个线程去执行的,故栈也是线程独享的。如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常,比如一个错误的递归方法。如果栈可以动态扩展(大部分虚拟机都可以动态扩展,但也允许固定长度),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
3. Java堆(Java Heap):一般来说,它是Java虚拟机所管理的内存中最大的一块。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都会在这里分配内存。Java堆是垃圾收集器管理的主要区域。从内存回收的角度来看,如果采用的是分代收集算法,那么Java堆中还可以细分为新生代和老年代。再细致一点有Eden空间、From Survivor空间、To Survivor空间等。它是线程共享的,但是它也可以分配出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),作用自然不言而喻。如果堆中没有内存完成实例分配,并且堆也无法再扩展时(如果当前堆分配内存(初始为-Xms)没有达到 -Xmx 的值表示还可以扩展),将会抛出OutOfMemoryError异常。
4. 方法区(Method area):它也是线程共享的,用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它有一个别名叫做Non-Heap,目的是为了与堆区分开来。Java虚拟机对方法区的限制非常宽松,可以选择不实现垃圾收集。垃圾收集行为在这个区域上是较少出现的,因为回收常量池和对类型的卸载的条件都比较苛刻。版本较老的HotSpot虚拟机甚至把它归入了永久代(Permanent Generation),但由于会导致内存溢出,目前已把原本放在永久代的字符串常量池移出。它和堆类似,当无法满足内存分配要求时,将抛出OutOfMemoryError异常。
5. 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。Java语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,比如String类的intern()方法。它是方法区的一部分,当常量池无法申请到内存时也会抛出OutOfMemoryError异常。
6. 直接内存(Direct Memory),它并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4中新加入了NIO类,引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配对外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行赋值。直接内存分配不会受Java堆大小的限制,但如果直接内存分配的空间过大(其余物理内存会分配给虚拟机、操作系统等等),将会导致虚拟机动态扩展时出现OutOfMemoryError异常(本来是够的,但是有一部分被直接内存抢走了)。
HotSpot 虚拟机对象探秘
- 我们来了解一下对象是如何分配内存和使用。
对象的创建
- 在语言层面上,创建对象通常仅需要一个new关键字而已。那么在虚拟机中,对象是如何创建的呢(下面讨论普通对象,不包括数组和Class对象)?
- 当虚拟机遇到一条new指令时,首先检查这条指令的参数能否在常量池中定位到一个类的符号引用,并检查这个类是否已经被加载、解析和初始化过(Java编程思想(类型信息))。简单说就是加载Class对象。在第七章将会探讨这部分的细节。
- 类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,所以只需在堆中划出固定大小的内存空间即可。假设堆内存是绝对规整的,一边是已经使用的内存,一边是还未使用的内存,中间有一个指针作为分界点的指示器。那么分配内存就是把那个指针往空闲空间挪动一段与对象大小相等的距离,这种分配内存的方式成为指针碰撞(Bump the Pointer)。如果内存不是规整的,那虚拟机就必须维护一个列表,记录哪些内存块是可以使用的,在分配的时候我们只需在列表上找到一块足够大的空间划分给对象即可,这种分配方式称为空闲列表(Free List)。Java堆是否规整与采用的垃圾收集器是否带有压缩整理功能决定。
- 另外为对象分配内存是非常频繁的行为,而前面我们已经知道了堆是线程共享的,所以还得考虑线程安全的问题。一般来说有两种解决方案,一种是对分配内存空间的动作进行同步处理(实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性);另一种是把内存分配的动作按照线程划分在不同的空间进行,即每个线程在Java堆中预先分配一小块内存(TLAB,Thread Local Allocation Buffer)。当TLAB用完并分配新的TLAB时,才需要同步锁定(用空间换时间)。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
- 内存分配完成后,虚拟机需要将内存空间都初始化为零值(不包括对象头,关于对象结构后面会讲),如果使用了TLAB,可以在分配TLAB时进行。
- 接下来,虚拟机要对对象进行必要的设置,对象头(Object Header)中需要放入关于这个对象是那个类的实例、怎么找到类的元数据信息、对象的Hash码、对象的GC分代年龄等信息。
- 在上面工作完成之后,虚拟机的工作也就结束了。但从Java程序的视角来看,对象创建才刚刚开始。关于这一部分在Java编程思想(初始化与清理)中已经有学习过了。
对象的布局
- 对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头包括两部分信息,一部分用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁状态、线程持有的锁、偏向线程ID、偏向时间戳等。另外一部分是类型指针(Class信息),但不是所有的虚拟机实现都必须保留类型指针。如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据。
- 实例数据部分是对象真正存储的有效信息。父类继承下来的和子类中定义的都需要记录下来。HotSpot虚拟机默认的分配策略为longs/doubles(8bytes)、ints(4bytes)、shorts/chars(2bytes)、bytes/booleans(单独使用4bytes,数组1byte)、oops(Ordinary Object Pointers,4 or 8 bytes)。父类定义的变量会出现在子类之前。如果CompactFields参数值为true(默认),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。
- 对齐填充部分仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,对象的大小必须是8字节的整数倍。对象头已经是8字节的整数倍(32位虚拟机1倍,64位2倍),因此,当对象实例数据部分没有对齐时,就需要对象填充来补全。
对象的访问定位
- 我们通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象(这个对象不一定就是最终的对象)的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。
- 这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
- 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针对位的开销。就Sun HotSpot而已,它使用第二种方式进行对象访问。
实战:OutOfMemoryError异常
Java堆溢出
- 代码如下:
import java.util.ArrayList;
import java.util.List;
/**
* xms最小堆内存 ->(扩展) xmx最大堆内存
* VM Args: -Xms20m -Xmx20m
*/
public class Test {
public static void main(String []args){
List<Test> list = new ArrayList<Test>();
while (true) {
list.add(new Test());
}
}
}
//运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
虚拟机栈和本地方法栈溢出
- 这两个栈用的是同一块空间,可能抛出StackOverflowError和OutOfMemoryError。这两个异常从本质上来说产生的原因是相同的,都是无法继续对栈分配内存空间。测试发现单线程下都是抛出StackOverflowError(理解就是单线程的空间不够不能说明栈的总空间不够),先看一下单线程下的例子:
/**
* 设置线程独享的栈的空间大小
* VM Args: -Xss128k
*/
public class Test {
public static void main(String []args){
main(args);
}
}
//运行结果:
Exception in thread "main" java.lang.StackOverflowError
- 还有一个例子就是不断地开启新线程,使得分配给所有线程的栈空间不足以使用。可能是我电脑的内存比较大,加上很容易死机,我试了一次就没去尝试了。需要注意是的此时分配给栈的空间越大,越容易产生OOM,因为内存空间是有限的,分配给栈的空间越大,也就意味着只能创建更少的线程。
方法区和运行时常量池溢出
- 由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。首先是关于运行时常量池的例子:
/**
* 这个例子只能在JDK1.6下实现
* 设置方法区的大小:
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*
*/
public class Test {
public static void main(String []args) {
//使用List保持着常量池引用,避免Full GC回收常量池行为
List<String> list = new ArrayList<String>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
//运行结果
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
- 关于JDK1.7我也试过,的确不会马上溢出。原因是这样的,在JDK1.7中,intern()方法不一定会创建新的常量放入常量池。对于一个常量池中没有出现过的String对象,intern()只会复制这个字符串的引用放入常量池。所以即使我们在栈中握有对象的引用,而常量池中的引用并没有被使用,Full GC依然会正常回收这部分内存。要想等这个程序溢出,我想最可能的是堆溢出而不是方法区溢出。
- 方法区除了存放常量池以外,还会存放Class信息、静态变量等等。要想静态变量溢出太困难了,对于对象类型的(包括数组),其实只拥有一个引用而已。所以我们通过在运行时产生大量的类去填满方法区来产生溢出,这个例子需要用到CGLib(jar包下载,只需使用cglib-nodep-2.2.jar)。
import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
/**
* 设置方法区的大小:
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
*
*/
public class Test {
public static void main(String []args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Test.class);
enhancer.setUseCache(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();
}
}
}
//运行结果
Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
本机直接内存溢出
- 本机直接内存容量可通过-XX:MaxDirectMemorySize指定,如果不指定,默认与Java堆最大值(-Xmx)一样。
import java.lang.reflect.Field;
import sun.misc.Unsafe;
/**
* VM Args: -Xmx20M -Xms20M -XX:MaxDirectMemorySize=10M
*
*/
public class Test {
private static final int _1MB = 1024 * 1024;
public static void main(String []args) throws Exception {
//Unsafe 用的是饿汉单例模式,直接get因为有安全限制无法使用。
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);//静态变量
//直接使用会抛异常
//Unsafe unsafe = Unsafe.getUnsafe();
while (true) {
//申请分配内存
unsafe.allocateMemory(_1MB);
}
}
}
//运行结果:
Exception in thread "main" java.lang.SecurityException: Unsafe
at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
at test2.Test.main(Test.java:23)