本文我们来学习一下 Java7 HotSpot (TM) VM 内存模型。

JVM Architecture

JVM Architecture

我们先来了解一下 JVM 的整体架构体系,JVM 主要分三大子系统:Class Loader SubSystemRuntime Data AreaExecution Engine

Class Loader SubSystem

Java 的动态类加载功能由类加载器子系统处理。 它在运行时第一次引用类时加载,链接和初始化类,而不是在编译时引用。 它执行三个主要功能,如加载,链接和初始化。

Execution Engine

被分配给运行时数据区域中的字节码将被执行引擎来执行。 执行引擎读取字节代码并挨个挨个执行。

本篇将重点介绍 Rumtime Date Area,至于 Class Loader SubSystem 和 Execution Engine 将放在后期的文章中去讲解。

Runtime Data Area

Java 虚拟机定义了在程序执行期间使用的各种运行时数据区域。 其中一些数据区域是随着 Java 虚拟机启动而创建,随着 Java 虚拟机退出而销毁。其他数据区域则是依附于每个单独的线程,它们随着线程创建而创建,随着线程 的退出而销毁。

运行时数据区,是 JVM 内存管理的主要区域。Java 虚拟机在程序执行的过程中,会把它所管理的内存划分为若干个不同的数据区域。主要分为:

  • Program Counter Register:程序计数器
  • Java Virtual Machine Stacks:Java 虚拟机栈
  • Native Method Stack:本地方法栈
  • Java Heap:Java 堆
  • Method Area:方法区

Runtime Data Area

控制参数

每个区域的内存大小可以通过以下参数进行分配

Runtime data area allocation

控制参数:

  • -Xms 设置堆的最小空间大小。
  • -Xmx 设置堆的最大空间大小。
  • -XX:NewSize 设置新生代最小空间大小。
  • -XX:MaxNewSize 设置新生代最大空间大小。
  • -XX:PermSize 设置永久代最小空间大小。
  • -XX:MaxPermSize 设置永久代最大空间大小。
  • -Xss 设置每个线程的堆栈大小。

Program Counter Register

  • 当前线程所执行的字节码的行号解释器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 此为线程的私有内存区域。任何一个确定的时刻,一个处理器或者内核,都只会执行一条线程中的指令,为了确保线程切换后还能回到正确的执行位置,每一个线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。
  • 此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java Virtual Machine Stack

概念

  • 线程私有,生命周期与线程的一致。
  • 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
  • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

默认大小

Java 虚拟机栈的默认大小与运行的操作系统有关:

Platform Default
Windows IA32 64 KB
Linux IA32 128 KB
Windows x86_64 128 KB
Linux x86_64 256 KB
Windows IA64 320 KB
Linux IA64 1024 KB (1 MB)
Solaris Sparc 512 KB

我们可以通过 -Xss<size> 来设置 Java 虚拟机栈大小,例如:-Xss128k,则大小为 128k。

异常

StackOverflowError

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。

如下例子,通过限制虚拟机栈的大小,并产生大量的局部变量,以此来增加此方法帧中的本地变量表长度。

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
package com.thinkingjava;

/**
* VM args: -Xss:200k
*/
public class JavaVMStackSOF {

private int stackLength = 1;

public void stackLeak() {
stackLength++;
stackLeak();
}

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

// 内容输出:
Exception in thread "main" java.lang.StackOverflowError
stack length:1239
at com.thinkingjava.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:11)
at com.thinkingjava.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)

...

在单线程的情况下,无论是栈容量太大,还是虚拟机内存太小,当内存无法分配时,都会抛出 StackOverflowError 异常。

OutOfMemoryError

如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

1
栈内存大小 = 进程的内存大小 - 堆内存大小(-Xmx) - 方法区内存大小(-XX:MaxPermSize)

由于操作系统给每一个进程的内存是有限制,除去 Java 堆内存大小(Xmx)和方法区的内存大小(MaxPermSize),剩余的内容就归 Java 虚拟机栈和本地方法栈分配了,每个线程可分配的栈容量越大,那么所能够产生的线程数则越少。建立线程越多,则越容易把内存消耗掉。

如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

如下例子:

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
package com.thinkingjava;

/**
*
*/
public class JavaVMStackOOM {

public static void main(String[] args) {
int count = 0;
while (true) {
System.out.println(++count);
new Thread(new Runnable() {
public void run() {
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
}
}
}).start();
}
}
}

// 输出
1
2

...

2032

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:714)
at com.thinkingjava.JavaVMStackOOM.main(JavaVMStackOOM.java:12)

Native Method Stack

  • 本地方法栈与 Java 虚拟机栈类似,区别在于 Java 虚拟机栈为 Java 方法服务,而本地方法栈为 Native 方法服务。
  • 与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

Java Heap

Java 堆(Java Heap)主要用于所有对象实例与数组的分配,是 Java 虚拟机所管理的内存当中最大的一块。

随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么 “绝对” 了。

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做 “GC 堆”(Garbage Collected Heap)。

Java 堆空间可以分为如下几个部分:

  • Young Generation(新生代):是所有新对象分配和老化的地方
    • Eden Space(伊甸区):最初为大多数对象分配内存的地方
    • Survivor Space(幸存者区):Eden Space 进行 Minor GC 后幸存下来的对象存放的地方
  • Tenured/Old Generation(老年代):Survivor Space 进行 Major GC 后幸存下来的对象存放的地方

Java_Heap

异常

如果在堆中没有内存完成实例分配时,并且堆再也无法扩展时,将会抛出 OOM 异常。

将堆的最小值 - Xms 参数与最大值 - Xmx 参数设置为一样即可避免堆自动扩展

如下例子,不断地创造对象,最终导致 Java 堆内存溢出。

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
package com.thinkingjava;

import java.util.ArrayList;
import java.util.List;

/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {

static class HeapObject {

}

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

// 日志输出:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid22301.hprof ...
Heap dump file created [27600189 bytes in 0.203 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 com.thinkingjava.HeapOOM.main(HeapOOM.java:18)

Process finished with exit code 1

Method Area(Non-Heap)

由于该区域使用的是永久代 GC 收集方法来实现,所以也称为” 永久代”(Permanent Generation),用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。

垃圾回收在这个区域较少出现,但这个区域的数据并非永久存在,这个区域的内存回收主要目标是针对常量池的回收和对类型的卸载。

异常

当方法区无法满足内存分配的需求时,将会抛出 OutOfMemoryError 异常。下面例子,运用 cblib 动态代理技术,产生大量的类型,导致 OOM 异常:

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
package one.wangwei.java;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

public class JavaMethodOOM {

static class OOMObject {

}

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 o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invoke(o, objects);
}
});
enhancer.create();
}
}
}

// 异常输出:
Exception in thread "main"
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载进方法区后进入常量池存放。

运行时常量池相对于 Class 文件常量池的另一个重要特征,是具备动态性。Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern () 方法。

字符串常量池

字符串常量池 在 JDK 1.7 的 HotSpot 中已经从运行时常量池转移到 Heap 中。参见

Area: HotSpot

Synopsis: In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application. This change will result in more data residing in the main Java heap, and less data in the permanent generation, and thus may require heap sizes to be adjusted. Most applications will see only relatively small differences in heap usage due to this change, but larger applications that load many classes or make heavy use of the String.intern() method will see more significant differences. RFE: 6962931

异常

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

String.intern () 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 - XX:PermSize 和 - XX:MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package one.wangwei.java;

import java.util.ArrayList;
import java.util.List;

/**
* JVM VM: -XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class ConstantPoolOOM {
public static void main(String[] args) {
List<String> stringList = new ArrayList<String>();
int i = 0;
while (true) {
i++;
stringList.add(String.valueOf(i).intern());
}
}
}

// 输出
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at one.wangwei.java.App.main(App.java:15)

Direct Memory

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

异常

本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 - Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定,下面的代码越过了 DirectByteBuffer 类,直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe () 方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory ()。

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
package one.wangwei.java;

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
* VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class DirectMemory {

private static final int ALLOCATE_MEMORY = 10 * 1024 * 1024;

public static void main(String[] args) {
try {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(ALLOCATE_MEMORY);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}


// 输出:

java(45961,0x70000a70c000) malloc: *** mach_vm_map(size=10485760) failed (error code=3)
*** error: can't allocate region
*** set a breakpoint in malloc_error_break to debug
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at one.wangwei.java.DirectMemory.main(DirectMemory.java:20)

参考资料

https://press.one/file/v?s=6f3d70c29a9ccc4fd57898e95ca6b76ff82541dffa7198d4e05a6cf172f09627e9cd2261f63d7a9ccd73e1e829e03ed53363f67b313ae4b340728ad060650dd91&h=4b82cb88322a4072133c5388a10f69a6a10b5b85faf4dcd993b62d4549a94463&a=23fe9bfd7ceef4b44c2ce44dcac8e4a49caf8026&f=P1&v=2