(一)学习JVM ——运行时数据区域
一、概述
JVM是整个Java平台的基石,是Java实现与硬件无关与操作系统无关的关键部分,是Java生成出极小体积的编译代码的运行平台,是保障用户机器免于恶意代码损害的屏障。——《Java虚拟机规范》
JVM在执行Java程序时,会把它所管理的内存氛围几个不同的区域。其中有一些区域会随着JVM启动而创建,随着JVM退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域随着线程的开始和结束而创建和销毁。
二、线程私有区域
2.1、程序计数器
程序计数器(Program Counter),也可成为PC寄存器,它是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
每一条JVM线程都有自己的PC寄存器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令的。
任意时刻,一条JVM线程只会执行一个方法的代码,这个正在被线程执行的方法称为该线程的当前方法(current method)。多个线程则是通过线程轮流切换并分配处理器执行时间的方式来实现的。
如果当前方法不是本地方法,那么PC寄存器中就保存JVM正在执行的字节码指令的地址,如果该方法是本地方法,那PC寄存器的值就是undefined。PC寄存器的容量至少应当能保存一个returnAddress类型的数据,或者保存一个与平台相关的本地指针的值。
该内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。
2.2、虚拟机栈
与程序计数器一样,JVM栈也是线程私有的,它的生命周期与JVM线程相同。每一条JVM线程都有自己私有的JVM栈(Java Virtual Machine stack),这个栈与线程同时创建,用于存储栈帧(Stack Frame)。
每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用知道执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
除了栈帧入栈和出栈,JVM栈不会在受其他因素的影响,所以栈帧可以在堆中分配,JVM栈所使用的内存不需要保证是连续的。
JVM规范既允许JVM栈被设置为固定大小,也允许根据计算动态扩展和收缩。如果采用固定大小,那么每一个线程的JVM栈容量可以在线程创建的时候独立选定。
JVM栈可能发生的异常情况有两种:如果线程请求分配的栈容量超过JVM栈允许的最大容量,会抛出:StackOverflowError异常。如果JVM栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的JVM栈,会抛出:OutOfMemoryError异常。
JVM提供了-Xss参数,对于设置JVM栈的最大值,下面的代码会产生StackOverflowError。
package lesson1.test;
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
// -verbose: -Xss128k
public static void main(String[] args) {
JavaVMStackSOF sof = new JavaVMStackSOF();
try {
sof.stackLeak();
} catch (Throwable e) {
System.out.println("stack length: " + sof.stackLength);
throw e;
}
}
}
上面代码设置-Xss的最大值为128k后,运行程序,结果返回:
tack length: 988
Exception in thread "main" java.lang.StackOverflowError
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:8)
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
at lesson1.test.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
……后续异常堆栈信息省略
实验结果表明:在单个线程下,无论是由于栈帧太大还是JVM栈容量太小,当内存无法分配时,会抛出StackOverflowError异常。
2.3、本地方法栈
本地方法栈(Native Method Stack)与JVM栈所发挥的作用是非常相似的,不同之处在于JVM栈执行Java方法,而本地方法栈执行Native方法。例如,当JVM虚拟机使用其他语言(比如C语言)来实现执行的解释器时,就可以使用本地方法栈。
JVM规范允许本地方法栈实现成固定大小或者根据计算动态扩展和收缩。如果采用固定大小,那么每一个线程的本地方法栈容量可以在创建栈的时候独立选定。
本地方法栈可能出现的异常情况有两种,如果线程请求分配的栈容量超过本地方法栈允许的最大容量,JVM会抛出一个StackOverflowError异常。如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线城时没有足够的内存区创建对应的本地方法栈,JVM会抛出一个OutOfMemoryError异常。
HotSpot虚拟机并不区分本地方法栈和JVM栈,因此,对于HotSpot来说,虽然-Xoss参数存在,但是无效,占容量只由-Xss参数设置。
三、线程共享区域
3.1、堆
堆(Heap)是可供各个线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。对于大多数应用来说,堆是JVM所管理的内存中最大的一块。堆在JVM启动的时候就被创建了,它存储了被自动内存管理系统(automatic storage management system),也就是垃圾回收器(GC)所管理的各种对象,这些受到管理的对象无需也无法显示地销毁。
堆可以处于物理上不连续的内存中,只要逻辑上是连续即可,就像磁盘空间一样。堆的容量可以是固定的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。
堆可能发生的异常情况是,如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那JVM将抛出一个OutOfMemoryError异常。
JVM提供了参数-Xmx设置堆的最大空间,-Xms设置堆的最小值。
import java.util.ArrayList;
import java.util.List;
public class HeapOOM {
static class OOMObject {
}
// -verbose: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid6252.hprof ...
Heap dump file created [27974147 bytes in 0.083 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at cn.net.bysoft.lesson1.HeapOOM.main(HeapOOM.java:15)
上面代码将堆的最小值Xms设置为20M,最大值Xmx也设置为20M,即为不扩展堆大小。通过参数-XX:HeapDumpOnOutOfMemoryError可以让JVM在内存溢出时Dump出当前的内存堆快照,以便分析。
可以使用Eclipse Memory Analyzer打开快照, http://archive.eclipse.org/mat/1.4/update-site/
分析如果是内存泄漏,可进一步通过工具查看泄露对象到GC Roots的引用链,如果不存在泄漏,就是内存中的对象都存活着,那就需要修改JVM的堆参数。或者从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况。
3.2、方法区
方法区(Method Area)是可供各个线程共享的运行时内存区域,它与传统语言中的编译代码存储区(storage area for compiled code)或者操作系统的正文段(text segment)的作用非常的类似,它存储了每一个类的结构信息,例如,运行时常量池(runtime constant pool)、字段和方法数据、构造函数和普通方法的字节码内容,还包括类、实例、接口初始化时用到的特殊方法。
方法区是堆的逻辑组成部分,它有一个别名叫做Non-Heap(非堆),目的是与堆区分开来。
方法区的容量可以是固定的,也可以随着程序执行的需求动态扩展,并在不需要过多空间的时候自动收缩。方法区在实际内存中可以是不连续的。
方法区可能发生一个异常,如果方法区的内存空间不能满足内存分配需求,那么JVM将抛出一个OutOfMemoryError异常。
JVM提供了参数-PermSize设置方法区的最小值,-MaxPermSize设置方法区的最大值。
import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
public class RuntimeConstantPoolOOM {
// -XX:PermSize=10M -XX:MaxPermSize=10M
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 arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
return arg3.invoke(arg0, arg2);
}
});
}
}
static class OOMObject {
}
}
上面代码借助CGLib直接操作字节码,运行时生成大量的动态类来使方法区OOM。
方法区用于存放Class的相关信息,如类名称、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的做法就是运行时产生大量的类去填满方法区,直到溢出。
上述代码在JDK1.8后就无效了,因为JDK1.8完全移除来永久带,取而代之的是Metaspace(元数据空间),同样的,它也提供了几个参数来设置其大小, -XX:MetaspaceSize 初始空间大小 , -XX:MaxMetaspaceSize 最大空间, -XX:MinMetaspaceFreeRatio 在GC之后,最小的Metaspace剩余空间容量的百分比 , -XX:MaxMetaspaceFreeRatio 在GC之后,最大的Metaspace剩余空间容量的百分比 。
JDK8测试Metaspace溢出的代码如下:
import java.util.ArrayList;
import java.util.List;
public class RuntimeConstantPoolOOM {
static String base = "string";
//-XX:MetaspaceSize=1M
//-XX:MaxMetaspaceSize=1M
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
String str = base + base;
base = str;
list.add(str.intern());
}
}
}
OutOfMemoryError: Metaspace
3.3、运行时常量池
运行时常量池(runtime constant pool)是方法区的一部分,是class文件中每一个类或接口的常量池表(constant_pool table)的运行时表示形式,它包括了若干种不同的常量,从编译期可知的数值字面量到必须在运行期解析后才能获得的方法或字段的引用。它类似于传统语言中的符号表(symbol table),不过它存储数据的范围更广泛。
每个运行时常量池都在JVM的方法区中分配,在加载类和接口到JVM后,就创建对应的运行时常量池。
创建运行时常量池时可能会发生一个异常,如果构造运行时常量池所需的内存超过了方法区所能提供的最大值,那么JVM将会抛出一个OutOfMemoryError异常。
4、堆中的数据
4.1、堆中创建对象
在语言层面上,创建对象(例如克隆,反序列化)通常仅仅通过一个new关键字而已,但在JVM中,对象的创建却更加细致。
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程(类加载需单独说明)。
在类加载检查通过后,JVM将为新对象分配内存。对象所需的内存大小在类加载后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从堆中划分出来。
划分内存的方式一般有两种:
指针碰撞(Bump the Pointer):假设堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把分界点指针往空闲空间端,移动一段与对象大小相等的距离。
空闲列表(Free List):如果堆中的内存并不是规整的,已使用的内存和空闲内存相互交错,JVM就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象,并更新列表上的记录。
选择哪种分配方式取决于堆内存是否规整,而堆内存是否规整由取决与采用的GC是否带有压缩整理功能。
除此之外,分配内存的过程也不是线程安全的,解决这个问题有两种方案:
一种是堆分配内存的空间的动作进行同步处理,实际上JVM采用CAS配上失败重试的方式保证更新操作的原子性;
另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在堆中预先分配一小块内存,成为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。
JVM是否采用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
分配好内存空间后,JVM要堆对象进行必要的设置,例如这个对象是那个类的实例、如何采用找到类的元数据信息、对象的HashCode、对象的GC分代年龄等信息。这些都在对象的Object Header中。
从JVM的视角看,一个新的对象已经产生了,但从Java程序的视角看,对象创建才刚刚开始,<init>方法还没有执行,所有的字段还是默认零值。
4.2、对象的内存布局
在HotSpot JVM中,对象在内存中存储的布局有3块区域:
对象头(Object Header);
实例数据(Instance Data);
对齐填充(Padding);
对象头(Object Header)
HotSpot JVM的对象头包括两部分信息:
第一部分用于存储对象自身的运行时数据,例如HashCode、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等。着部分数据的长度在32位和64位的JVM中分别为32bit和64bit,官方称之为Mark Word;
例如,在32位的JVM中,如果对象处于未锁定状态下,那么Mark Word的32bit中,25bit用于存储对象HashCode,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。
第二部分是类型指针,即对象指向它的类元数据的指针,JVM通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,那么在对象头中还必须有一块用于记录数组长度的数据,因为JVM可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定其大小。
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。着部分的存储顺序会受到JVM分配策略参数和字段在Java源码中定义顺序的影响。
HotSpot默认分配为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),宽度相同的字段总是被分配到一起。满足这个前提下,父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(default),那么子类之中较窄的变量也可以会加入到父类变量的空隙。
对齐填充(Padding)
第三部分对齐填充并不是必然的,也没有特别的含义,它仅仅起到占位符的作用。由于HotSpot自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象大小必须是8字节的倍数。而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
4.3、对象的访问定位
Java程序需要通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有两种:
句柄访问:堆中会划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含来对象实例数据与类型数据各自的具体地址信息;
直接指针访问:堆对象的布局中,必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址;
两种对象访问方式各有优势,句柄的好处就是reference中存储的是稳定的句柄地址,在对象被移动时只要改变句柄中的实例数据指针,二reference不需要改变。
使用直接指针访问的好处就是速度够快,节省来一次指针定位的时间开销,频繁的访问对象,指针定位时间积少成多后也是非常可观的。
5、栈中的数据
栈中是用栈帧(frame)来存储数据和部分过程结果的数据结构,同时也用来处理动态连接(dynamic linking)、方法返回值和异常分派(dispatch exception)。
它随着方法调用而创建,随着方法结束而销毁——无论方法是正常结束还是异常结束都算作方法结束。栈帧的存储空间由创建它的线程分配在JVM栈之中,每一个栈帧都有自己的本地变量表(local variable)、操作数栈(operand stack)和指向当前方法所属的类的运行时常量池的引用。
本地方法表和操作数栈的容量在编译期确定,并通过相关方法的code属性保存及提供给栈帧使用。
某条线程执行过程中的某个时间点上,只有目前正在执行的那个方法的栈帧的活动的,称为当前栈帧(current frame),对应的方法称为当前方法(current method),对应的类成为当前类(current class)。如果当前方法调用了其他方法,或者当前方法执行结束,那么这个方法的栈帧就不再是当前栈帧了。调用新方法,栈帧会随着创建,并成为新的当前栈帧。返回时,会回传该方法的执行结果给前一个栈帧,然后丢弃当前栈帧,是的前一个栈帧成为当前栈帧。
5.1、局部变量表
每个栈帧内部都包含一组称为局部变量表的变量列表。栈帧中局部变量表的长度由编译期决定,并且存储于类或接口的二进制表示之中。
一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference或returnAddress的数据。两个局部变量可以保存一个类型为long或double的数据。
5.2、操作数栈
每个栈帧内部都包含一个成为操作数栈的后进先出(LIFO)栈。栈帧中操作数栈的最大深度由编译期决定。
栈帧在刚刚创建时,操作数栈是空的。操作数栈在每一个位置上可以保存一个JVM中定义的任意数据类型的值,包括long和double数据,在操作数栈中的数据必须正确地操作。在任何时刻,操作数栈都会有一个栈深度,一个long或者double类型的数据会占用两个单位的栈深度,其他数据类型则占用一个。
5.3、动态连接
每个栈帧内部都包含一个指向当前方法所在类型的运行时常量池引用,以便对当前方法的代码实现动态连接。在class文件里,一个方法若要调用其他方法,或者访问成员变量,则需要通过符号引用来表示,动态连接的作用就是将这些以符号引用所表示的方法转换为对实际方法的直接引用。
由于对其他类型中的方法和变量进行了晚期绑定(late binding),所以即便那些类发生变化,也不会影响调用他们的方法。
5.4、方法调用正常完成
方法调用正常完成是指在方法的执行过程中,没有抛出任何异常——包括直接从JVM中抛出的异常以及在执行时通过throw语句显示抛出的异常。如果当前方法调用正常完成,它很可能会返回一个值给调用它的方法。
在这种场景下,当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器,以跳过刚才执行的方法调用指令等。
5.5、方法调用异常完成
方法调用异常完成是指在方法的执行过程中,某些指令导致了JVM会抛出异常,并且JVM抛出的异常在该方法中没有办法处理,或者在执行过程中遇到athrow字节码指令并显示抛出异常,同时在该方法内部没有捕获异常。如果方法是异常调用完成的,那一定不会有方法返回值返回给其调用者。
(一)学习JVM ——运行时数据区域