Java7 HotSpot(TM) VM内存模型

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

JVM Architecture

JVM Architecture

我们先来了解一下JVM的整体架构体系,JVM主要分三大子系统:Class Loader SubSystem、Runtime Data Area、Execution 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 设置每个线程的堆栈大小。

内存分配查询

另外,我们可以使用如下命令查询各个操作系统默认分配的内存大小:

Ubuntu & RedHat

1
$ java -XX:+PrintFlagsFinal -version | grep -iE 'HeapSize|PermSize|ThreadStackSize'

Windows

1
> java -XX:+PrintFlagsFinal -version | findstr /i "HeapSize PermSize ThreadStackSize"

MacOS

1
$ java -XX:+PrintFlagsFinal -version | grep -iE 'heapsize|permsize|threadstacksize'

例如,我的Mac返回结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    uintx AdaptivePermSizeWeight                    = 20              {product}
intx CompilerThreadStackSize = 0 {pd product}
uintx ErgoHeapSizeLimit = 0 {product}
uintx HeapSizePerGCThread = 87241520 {product}
uintx InitialHeapSize := 134217728 {product}
uintx LargePageHeapSizeThreshold = 134217728 {product}
uintx MaxHeapSize := 2147483648 {product}
uintx MaxPermSize = 85983232 {pd product}
uintx PermSize = 21757952 {pd product}
intx ThreadStackSize = 1024 {pd product}
intx VMThreadStackSize = 1024 {pd product}
java version "1.7.0_72"
Java(TM) SE Runtime Environment (build 1.7.0_72-b14)
Java HotSpot(TM) 64-Bit Server VM (build 24.72-b04, mixed mode)

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
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
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

请我喝杯咖啡吧~