本文主要是对《深入理解java虚拟机 第二版》第二章部分做的总结,文章中大部分内容都来自这章内容,之所以记录到博客,是想通过这个过程加深自己的理解,并且方便以后遇到相关问题之后进行查阅。

JVM

Java 虚拟机屏蔽了与具体操作系统平台相关的信息,使得 Java 语言编译程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java 虚拟机在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

Java 虚拟机所管理的内存包括以下几个运行时数据区域,如下图所示。

jvm

  • 线程间共享区域:方法区和堆;
  • 线程间私有区域:虚拟机栈、本地方法栈和程序计数器。

程序计数器

特点:

  1. 它是一块较小的内存空间,可以看出当前线程所执行的字节码的行号指示器;
  2. 字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复都要靠它完成;
  3. 每个线程都有一个自己的计数器,线程之间的计数器互不影响;
  4. JVM多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的;
  5. 执行Native方法时,计数器不起作用,职位空缺(null);
  6. 此区域是唯一没有规定OOM的区域。

Java虚拟机栈

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frmae)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表

局部变量表存放了编译期可知的基本数据类型(boolean、byte、char、shot、int、float、long、double)、对象引用(reference 类型,他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

其中,long、double因为长度为64bit,会占用两个Slot,其余的数据类型只占用一个。由此可知局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

异常情况

虚拟机栈中可能会出现两种异常情况:

  1. StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度.
  2. OutOfMemoryError:虚拟机栈动态扩展内存时,无法申请到足够的内存.

本地方法栈

与虚拟机栈作用很相似,区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowErrorOutOfMemoryError 异常。

Java堆

Java Heap 是 jvm 所管理的内存中最大的区域。Java Heap 是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存。

Java Heap 是垃圾收集器管理的主要区域,也叫做 GC 堆。其可细分为新生代和老年代,而新生代又可分为Eden 空间、From Survivor 空间和 To Survivor 空间。

根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑是连续的即可。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx-Xms控制)。但如果在堆中没有内存完成实例分配,并且也无法再扩展时,会抛出 OutOfMemoryError 异常。

其中,-Xmn 用来控制新生代内存的大小。

方法区

特点

  1. 线程间共享的内存区域;
  2. 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
  3. 当方法区无法满足内存的分配需求时,将抛出 OutOfMemoryError 异常;

运行时常量池 Runtime Constant Pool

特点

  1. 属于方法区的一部分,
  2. 保存Class文件中描述的符号引用和各种字面量.
  3. 因为是方法区的一部分,所以会受到方法区内存的限制,当常量池无法再申请到内存时,抛出 OutOfMemoryError 异常。

直接内存 Direct Memory

  1. 直接内存不是 JVM 运行时数据区的一部分,也不是 JVM 规范中定义的内存区域,但是这部分内存也可能会出现 OutOfMemoryError 异常;
  2. 在 JDK 1.4 中新加入了 NIO(New Input/Output),引入 ChannelBuffer 的 I/O 方式,它可以用 native 方法申请堆外内存,然后通过 JVM 堆中的 DirectByteBuffer 对象操作这块内存,在一些场景下可以显著提高性能(零拷贝);
  3. 虽然本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。开发者在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个区域总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError 异常。

HotSpot虚拟机对象解密

在了解了JVM 内存的概况之后,这里再介绍一下这些虚拟机内存中的数据的其它细节,譬如它们是如何创建、如何布局以及如何访问的。对于这样设计细节的问题,必须把讨论范围限定在具体的虚拟机和集中在某一个内存区域上才有意义。这里我们以常用的虚拟机 HotSpot 和常用的内存区域 Java 堆为例,深入学习 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

对象的创建

Java 是一门面向对象的编程语言,在 Java 中无时无刻都有对象被创建出来。在语言层次上,创建对象(例如克隆、反序列化)通常只是一个 new 关键字而已,本小节主要讨论一下对与一个普通的 Java 对象(不包括数组和 Class 对象等)创建的过程是怎样的?

创建过程

当虚拟机遇到一条 new 指令时,虚拟机会进行以下步骤创建对象。

  1. 将先去检查这个指令的参数是否能够在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行响应的类加载过程(这部分本文暂时不涉及);
  2. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来;
  3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(这步操作保证了对象的实例字段在 Java 代码中可以不赋初值就可以直接使用);
  4. 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息,这些信息存放在对象的对象头(Object Header)之中。

这些步骤结束之后,对于虚拟机来说,一个新的对象已经产生了,但是从 Java 角度来看,对象创建才刚刚开始,还没有对对象进行初始化操作。

堆内存分配方法

从上节的第二步中可以看到,虚拟机为新生对象分配内存,相当于把一块固定大小的内存从 Java 堆中划分出来。

  1. 我们假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式叫做指针碰撞(Bump the Pointer);
  2. 如果 Java 堆中的内存不是完整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表(Free List)。

选择哪种分配方式由 Java 堆是否完整决定,而 Java 堆是否完整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  • 在使用 Serial、ParNew 等带有 Compact 过程的收集器时,系统采用的分配算法是指针碰撞;
  • 而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用的是空闲列表。

线程安全

在JVM 中,对象创建是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现在给对象 A 分配内存时,指针还没来得及修改,对象 B 又同时使用原来的指针分配内存的情况。对于这个问题,有两种解决方案:

  1. 对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式更新操作的原子性;
  2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数来设定。

对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头

HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别是32bit 和64bit,官方称为”Mark Word”。

注:对象需要存储的运行时数据很多,其实已经超出了32位、64位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,因此,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通的 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中却无法确定数组的大小。

实例数据

实例数据才是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略和字段在 Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFilds 参数值为 true(默认为 true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

对齐填充

对齐填充仅仅起着占位符的作用,由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补充。

对象的访问定位

建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。

句柄访问

Java 堆会先划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

getobject1

通过句柄访问的最大好处 reference 中存储的是稳定的句柄地址,在对象被移动(gc 时移动对象非常普遍)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

直接地址访问

使用直接地址访问时,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址。

getobject2

使用直接指针访问方式的最大好处就是速度更快,节省了一次指针定位的开销,HotSpot 也是这是使用这种方式实现的。

OOM 调试

根据前面的介绍,我们知道在 JVM 中,除了程序计数器之后,虚拟机内存的其他几个区域都有发生 OOM 异常的可能,本节会通过一些示例来验证异常发生的场景以及讲述一下如何进行调试。

Java 堆溢出

Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常。

示例

下面的例子中,我们限制 Java 堆的大小为5MB,不可扩展(将堆的最小值 -Xms 参数与最大值 -Xmx 参数设置为一样即可)。JVM 参数设置为 -Xms5m -Xmx5m -XX:+HeapDumpOnOutOfMemoryError,程序代码如下(HeapOOMTest)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class HeapOOMTest {
private static class Person {
private final static String DEFAULT_NAME = "matt";
private final static int DEFAULT_AGE = 18;
private String name;
private int age;

public Person() {
this.name = DEFAULT_NAME;
this.age = DEFAULT_AGE;
}

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}

public static void main(String[] args) {
List<Person> persons = new ArrayList<Person>();
while (true) {
persons.add(new Person());
}
}
}

运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid9479.hprof ...
Heap dump file created [11824236 bytes in 0.092 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 heap.HeapOOMTest.main(HeapOOMTest.java:47)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:606)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)

示例异常分析

Java 堆内存的 OOM 异常是实际应用中常见的内存异常情况。当出现 Java 堆内存溢出时,会报错误信息 java.lang.OutOfMemoryError,会跟着进一步提示 Java heap space

出现这个异常之后,首先需要确定内存中的数据是否是必要,也就是要先分清楚是出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)。

  1. 如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的,这样就就可以定位出泄露代码的位置;
  2. 如果不是内存泄露,需要检查一下虚拟机的参数(-Xmx-Xms),与物理机内存对比看是否还可以调大,然后再检查一下代码,是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

虚拟机栈和本地方法栈溢出

由于在 HotSpot 虚拟机中并不区分虚拟机栈而本地方法栈。因此,对于 HotSpot 来说,虽然 -Xoss 参数(设置本地方法栈大小)存在,但实际上无效的,栈容量只由 -Xss 参数设定。对于虚拟机栈和本地方法栈,在 Java 虚拟机中描述了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常;
  2. 如果虚拟机在扩展栈时无法申请到足够的内存空间,则将抛出 OutOfMemoryError 异常。

注:这里有一个问题,当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是针对同一件事情两种描述。

示例

对单线程的情况,尝试一下两种方法都是获得 StackOverflowError 异常(StackTest1

  • 使用-Xss参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的栈深度相应减少;
  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出 StackOverflowError 异常时输出的堆栈深度相应减少。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* VM Args: -Xss256k
*/
public class StackTest1 {
private int stackLength=1;
public void stackLeak(){
stackLength++;
stackLeak();
}

public static void main(String[] args) throws Throwable{
StackTest1 stackTest1=new StackTest1();
try {
stackTest1.stackLeak();
}catch (Throwable e){
System.out.println("stack length: "+stackTest1.stackLength);
throw e;
}
}
}

输出结果

1
2
3
4
5
6
Exception in thread "main" java.lang.StackOverflowError
at stack.StackTest1.stackLeak(StackTest1.java:9)
stack length: 1868
at stack.StackTest1.stackLeak(StackTest1.java:10)
at stack.StackTest1.stackLeak(StackTest1.java:10)
....

示例分析

根据上面的测试结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。

方法区和运行时常量池异常

运行时常量池是方法区的一部分。可以通过 -XX:PermSize-XX:MaxPermSize 来限制方法区的大小,从而间接限制其中常量池的容量。下面的例子主要讲述一下方法区异常的示例。

示例

方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,我们的思路是运行时产生大量的类去填满方法区,知道溢出。在我们的示例中,我们借助 CGLib 直接操作字节码运行时生成了大量的动态类(JavaMethodAreaOOM)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M -XX:+HeapDumpOnOutOfMemoryError
*/
public class JavaMethodAreaOOM {
static class OOMObject {
}

public static void main(final String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, args);
}
});
enhancer.create();
}
}
}

运行结果

1
2
3
4
5
java.lang.OutOfMemoryError: PermGen space
Dumping heap to java_pid10480.hprof ...
Heap dump file created [10685765 bytes in 0.086 secs]
Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

方法区溢出

  • 方法区存储类信息,当类过多时,就会导致方法区溢出.
  • 实际应用中,主流框架如Spring、Hibernate(CGLIB)、JSP、OSGi等会动态生成大量Class;而类被回收的判定条件是非常苛刻的.

参考