熟悉 Java 语言特性的同学都知道,相比 C、C++ 等编程语言,Java 无需通过手动方式回收内存,内存中所有的对象都可以交给 Java 虚拟机来帮助自动回收;而像 C、C++ 等编程语言,需要开发者通过代码手动释放内存资源,否则会导致内存溢出。
尽管如此,如果编程不当,Java 应用程序也可能会出现内存溢出的现象,例如下面这个异常!
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:2760)
at java.util.Arrays.copyOf(Arrays.java:2734)
at java.util.ArrayList.ensureCapacity(ArrayList.java:167)
at java.util.ArrayList.add(ArrayList.java:351)
它表示当前服务已出现内存溢出,简单的说就是当服务出现了内存不足时,就会抛OutOfMemoryError
异常。
这种异常是怎么出现的呢?该如何解决呢?
熟悉 JVM 内存结构的同学,可能会很快看得出以上错误信息表示虚拟机堆内存空间不足,因此了解 JVM 内存结构对快速定位问题并解决问题有着非常重要的意义。今天我们一起来了解一下 JVM 内存结构。
本文以 JDK1.7 版本为例,不同的版本 JVM 内存布局可能稍有不同,但是所涉及的知识点基本大同小异。
Java 虚拟机在执行程序的过程中,会把所管理的内存划分成若干不同的数据区域。这些区域各有各有的用途,有的区域会随着虚拟机进程的启动而一直存在;有的区域会伴随着用户线程的启用和结束而创建和销毁。
其次,JVM 内存区域也称为运行时数据区域,这些数据区域包括:程序计数器、虚拟机栈、本地方法栈、堆、方法区等,可以用如下图来简要概括。
其中,运行时数据区的程序计数器、虚拟机栈、本地方法栈属于每个线程私有的区域;堆和方法区属于所有线程间共享的区域。
运行时数据区的线程间内存区域布局,可以用如下图来简要描述:
下面我们一起来看下每个区域的作用。
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,比如分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
我们知道 Java 是支持多线程的,其中虚拟机的多线程就是通过轮流切换线程并分配处理器执行时间的方式来实现的。在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,虚拟机为每个线程都设计了一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,属于线程私有的内存区域,生命周期与线程相同。
在 JVM 规范中,如果线程执行的是非native
方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native
方法,则程序计数器中的值是Undefined
,也就是空。
由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,此内存区域是唯一一个在 JVM 规范中没有规定任何OutOfMemoryError
情况的区域。
虚拟机栈(Java Virtual Machine Stacks)与程序计数器一样,也是线程私有的内存区域,它的生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行时的内存模型,每个方法执行的时候都会创建一个栈帧(Stack Frame), 用于存储局部变量表、操作数栈、动态链接、方法出口和一些额外的附加信息。每一个方法从被调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的全过程。
虚拟机栈内部结构,可以用如下图来简要描述。
下面简单看看栈帧里的四种组成元素的作用。
局部变量表是一组变量值的存储空间,用于存储方法参数和局部变量,例如:
通常,局部变量表的内存空间在编译器就会确定其大小,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是可以完全确定的,因此在程序执行期间局部变量表的大小是不会改变的。
其次,局部变量表的最小单位为 32 位的字长,对于 64 位的 long 和 double 变量而言,虚拟机会为其分配两个连续的局部变量空间。
操作数栈也常称为操作栈,是一个后入先出的栈。虚拟机会利用操作栈的压栈和出栈操作来执行指令运算。
比如下面的两个数据相加的计算示例。
begin
iload_0 // push the int in local variable 0 onto the stack
iload_1 // push the int in local variable 1 onto the stack
iadd // pop two ints, add them, push result
istore_2 // pop int, store into local variable 2
end
在这个字节码序列里,前两个指令iload_0
和iload_1
将存储在局部变量表中索引为0
和1
的整数压入操作数栈中;接着iadd
指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈;最后istore_2
指令从操作数栈中弹出结果,并把它存储到局部变量表索引为2
的位置,完成数据的计算。
每个栈帧都包含一个对当前方法类型的运行时常量池的引用,以支持方法调用过程中的动态链接。可以简单的理解成,当前栈帧与运行时常量池的方法引用建立链接。
比如方法 a 入栈后,栈帧中的动态链接会持有对当前方法所属类的常量池的引用,当方法 a 中调用了方法 b(符号引用),就可以通过运行时常量池查找到方法 b 具体的直接引用(方法地址),然后调用执行。
当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址,也称为方法出口。
在虚拟机栈中,只有两种方式可以退出当前方法:
当一个方法返回时,可能依次进行以下 3 个操作:
在 JVM 规范中,对这个内存区域规定了两种异常状况:
StackOverFlowError
异常(当前虚拟机栈不允许动态扩展的情况下)OutOfMemoryError
异常本地方法栈(Native Method Stacks)与虚拟机栈发挥的作用非常相似,主要区别在于:虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务;本地方法栈则是为虚拟机使用到的Native
方法服务(通常采用 C 编写)。
有些虚拟机发行版本,比如Sun HotSpot
虚拟机,直接将本地方法栈和 Java 虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowError
和OutOfMemoryError
异常。
Java 堆是被所有线程共享的最大的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存,也是出现OutOfMemoryError
异常最常见的区域。
在虚拟机中,堆被划分成两个不同的区域:年轻代 (Young Generation) 和老年代 (Old Generation),默认情况下按照1 : 2
的比例来分配空间。
其中年轻代又被划分为三个不同的区域:Eden 区、From Survivor 区、To Survivor 区,默认情况下按照8 : 1 : 1
的比例来分配空间。
整个堆内存的空间划分,可以用如下图来简要描述。
这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
新创建的对象分配会首先放在年轻代的 Eden 区,此区的对象回收频次会比较高,Survivor 区作为 Eden 区和 Old 区之间的缓冲区,在 Survivor 区的对象经历若干次收集仍然存活的,就会被转移到老年代 Old 区。
关于对象内存回收的相关知识,我们在后续的文章会再次进行介绍。
方法区在 JVM 中也是一个非常重要的区域,和 Java 堆一样,也是多个线程共享区域,它用于存储类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及即时编译后的代码等数据。
为了与 Java 堆区分,它还有一个别名 Non-Heap(非堆的意思)。相对而言,GC 对于这个区域的收集是很少出现的,但是也不意味着不会出现异常,当方法区无法满足内存分配需求时,也会抛出OutOfMemoryError
异常。
在 Java 7 及之前版本,大家也习惯称方法区它为“永久代”(Permanent Generation),更确切来说,应该是“HotSpot 使用永久代实现了方法区”!
运行时常量池是方法区的一部分。Class
文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池 (Constant pool table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入运行时常量池中存放。
运行时常量池的功能类似于传统编程语言的符号表,方便下游程序通过查表可找到对应的数据信息。
同时,运行时常量池相对于Class
文件常量池的另外一个特性是具备动态性,Java 语言并不要求常量一定只有编译器才产生,也就是说并非预置入Class
文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,其中String.intern()
方法就是这个特性的应用。
在之前的 Java NIO 文章中,我们提及到直接内存。直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域。
在 JDK1.4 中引入了 NIO 机制,它允许 Java 程序直接从操作系统中分配直接内存,这部分内存也被称为堆外内存,在某些场景下可以提高程序执行性能,因为避免了在 Java 堆和 Native 堆中来回复制数据的耗时。
Java NIO 创建堆外内存的简单示例。
// 创建直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
这部分内存如果出现资源不足,也可能导致OutOfMemoryError
异常出现。
所有内存溢出的问题,除了代码可能存在问题以外,更直观的问题是内存空间不足,如何通过参数来控制各区域的内存大小呢?
我们先来看一张图。
相关的常用控制参数介绍!
1)-Xms
设置堆的最小空间大小,此值必须是 1024 的倍数且大于 1 MB。附加字母 k 或 k 表示千字节,m 或 m 表示兆字节,g 或 g 表示千兆字节,其它命令参数同理。比如-Xms1024m
,表示堆的最小内存为1024M
,默认值为物理内存的1/64
。
2)-Xmx
设置堆的最大空间大小,此值必须是 1024 的倍数且大于 2 MB。比如-Xmx2048m
,表示堆最大内存为2G
,默认值为物理内存的1/4
。
对于服务器部署,-Xms
和-Xmx
通常建议设置为相同的值,以避免堆的内存空间频繁扩缩。
3)-XX:+HeapDumpOnOutOfMemoryError
表示可以让虚拟机在出现内存溢出异常时 Dump 出当前的堆内存转储快照
1)-XX:NewSize
设置年轻代的最小空间大小,比如-XX:NewSize=256m
,表示年轻代的最小内存为256M
。
GC 在这个区域比在其他区域执行的频率更高,如果年轻一代的设置太小,那么将进行大量的小频率 GCs。如果设置太大,那么会执行完整的GCs,这可能需要很长时间才能完成。Oracle 建议将年轻一代的大小保持在堆总大小的一半到四分之一之间。同时,该值需要小于-Xms
的值。
2)-XX:MaxNewSize
设置年轻代的最大空间大小,比如-XX:MaxNewSize=512m
,表示年轻代的最大内存为512M
。
3)-Xmn
设置年轻代堆的初始大小和最大大小,比如-Xmn128m
,表示年轻代的初始大小和最大大小为128M
。
这个参数是对-XX:newSize
、-XX:MaxnewSize
两个参数同时进行配置,虽然会很方便,但需要注意的是这个参数是在 JDK1.4 版本以后才加入的,低于此版本无法使用。
没有直接设置老年代的参数,但是可以设置堆空间大小和年轻代空间大小两个参数来间接控制,公式如下:
老年代空间大小 = 堆空间大小 - 年轻代空间大小
1)-XX:NewRatio
设置年轻代和老年代大小之间的比例,默认值是-XX:NewRatio=2
,表示Young : Old = 1 : 2
。
2)-XX:SurvivorRatio
设置 Eden 空间大小和 Survivor 空间大小之间的比例,默认值是-XX:SurvivorRatio=8
,表示Eden : from : to = 8 : 1 : 1
。
3)-XX:MinHeapFreeRatio
设置 GC 事件后允许的最小可用堆空间百分比(0到100),如果可用堆空间低于此值,则堆将被扩展。默认情况下,此参数为-XX:MinHeapFreeRatio=40
,表示40%
。
4)-XX:MaxHeapFreeRatio
设置 GC 事件后允许的最大可用堆空间百分比(0到100)。如果可用堆空间高于此值,则堆将被缩小。默认情况下,此参数为-XX:MaxHeapFreeRatio=70
,表示70%
。
1)-XX:PermSize
设置永久代的最小空间大小,比如-XX:PermSize=256m
,表示永久代的最小内存为256M
,默认值为物理内存的1/64
。
2)-XX:MaxPermSize
置永久代的最大空间大小,比如-XX:MaxPermSize=512m
,表示永久代的最大内存为512M
,默认值为物理内存的1/4
。
值得注意的是,-XX:PermSize
和-XX:MaxPermSize
这两个参数,在 JDK1.7 及以前的版本中有效,在 JDK1.8 中已经被弃用,被-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
两个参数取代。
1)-Xss
设置每个线程的栈大小,比如-Xss1024k
,表示每个线程的堆栈空间大小为1024KB
,通常不需要我们调整设置,默认值取决于平台:
2)-Xoss
设置每个线程中的本地方法栈大小,比如-Xoss128k
,表示每个线程中的本地方法栈大小为128KB
,不过 HotSpot 并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说这个参数是无效的。
1)-XX:MaxDirectMemorySize
此参数的含义是通过Direct ByteBuffer
方式分配的最大堆外内存大小。比如-XX:MaxDirectMemorySize=60m
,表示堆外最大内存不能超过60M
,如果没有设置,默认是 0,JVM 会自动申请内存的大小,最大大小受限于-Xmx
值。
在上文中,我们介绍了 JVM 内存结构以及可能会发生的异常状况,下面我们一起来复现一下几种常见的内存溢出现象。
堆溢出测试类如下。
/**
* 虚拟机参数: -Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOMTest {
public static void main(String[] args) {
List<HeapOOMTest> list = new ArrayList<>();
while (true){
list.add(new HeapOOMTest());
}
}
}
在 IDEA 中设置 JVM 相关的参数。
运行后输出结果如下:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid21886.hprof ...
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:265)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
at java.util.ArrayList.add(ArrayList.java:462)
at HeapOOMTest.main(HeapOOMTest.java:21)
Heap dump file created [12920047 bytes in 0.090 secs]
从报错的日志上可以清晰的看到,出现内存溢出的区域在Java heap space
,问题代码在HeapOOMTest.java:21
。生成的快照文件在当前工程目录下。
栈溢出测试类如下,JVM 相关的参数设置步骤同上。
/**
* 虚拟机参数: -Xss256k
*/
public class StackOOMTest {
private int stackLength = 1;
public static void main(String[] args) {
StackOOMTest stackOOMTest = new StackOOMTest();
try {
stackOOMTest.stackLeak();
} catch (Throwable e){
System.out.println("stack length:" + stackOOMTest.stackLength);
throw e;
}
}
private void stackLeak() {
stackLength++;
stackLeak();
}
}
运行后输出结果如下:
Exception in thread "main" java.lang.StackOverflowError
stack length:2326
at StackOOMTest.stackLeak(StackOOMTest.java:23)
at StackOOMTest.stackLeak(StackOOMTest.java:23)
at StackOOMTest.stackLeak(StackOOMTest.java:23)
at StackOOMTest.stackLeak(StackOOMTest.java:23)
at StackOOMTest.stackLeak(StackOOMTest.java:23)
......
在单个线程下,当栈帧的深度过大,也会超出虚拟机栈的最大容量,当无法分配内存的时候,虚拟机就会抛出StackOverflowError
异常。
我们在来看另一个例子。
/**
* 虚拟机参数: -Xss256k
*/
public class StackOOMTest2 {
public static void main(String[] args) {
StackOOMTest2 stackOOMTest = new StackOOMTest2();
stackOOMTest.stackLeakByThread();
}
private void stackLeakByThread() {
while (true) {
new Thread(new Runnable() {
@Override
public void run() {
running();
}
}).start();
}
}
private void running() {
while (true) {
}
}
}
运行后输出结果如下:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
在无限制的创建多个线程下,虚拟机栈也可能会出现OutOfMemoryError
异常,此时操作系统会出现假死, CPU 被完全跑满了,测试过程中发现操作系统下所有的应用无法正常操作,请谨慎测试。(以上报错内容,引入网上博主的测试结果)
运行时常量池属于方法区的一部分,这两个区域中我们抽取运行时常量池区域来测试内存溢出的现象。
针对这个区域,我们可以采用String.intern()
方法进行测试。String.intern()
是一个Native
方法,意思是如果常量池中有一个String
对象的字符串,就返回池中的这个字符串的String
对象;否则,将此String
对象包含的字符串添加到常量池中去,并且返回此String
对象的引用。
测试代码如下,JVM 相关的参数设置步骤同上。
/**
* 虚拟机参数:-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class RuntimeConstantPoolOOMTest {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
运行后输出结果如下:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
实际上,这个异常只会出现在 JDK1.6 及之前的版本中,在 JDK1.7 中是不会有这个异常的,它会一直while
循环下去。
在上文中我们介绍过,在 JDK1.7 及之前的版本中,方法区也被称为永久代,因此看到的是PermGen space
区域的OutOfMemoryError
异常信息。
但在 JDK1.8 及之后的版本中,没有-XX:PermSize
和-XX:MaxPermSize
这两个参数,取而代之的是-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
这两个参数,同时方法区被称为元空间,并划入到本地内存中。
直接内存溢出测试类如下,JVM 相关的参数设置步骤同上。
/**
* 虚拟机参数: -XX:MaxDirectMemorySize=2048k
*/
public class DirectMemoryTest {
public static void main(String[] args){
int i = 0;
List<ByteBuffer> buffers = new ArrayList<>();
while (true) {
ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024 * 1);
buffers.add(bb);
System.out.println(i++);
}
}
}
运行后输出结果如下:
0
1
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:694)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at DirectMemoryTest.main(DirectMemoryTest.java:22)
从日志上可以清晰的看到,OutOfMemoryError
的异常区域为Direct buffer memory
,也就是直接内存区域。
在上文中我们也提及到过,不同的版本 JVM 内存布局可能有所不同。最后,我们再一起来看下 JDK 1.6、1.7、1.8 的内存模型演变过程。
每一次的调整改动,都是为了更好的适应当下 CPU 性能,最大限度的提升 JVM 运行效率,各个版本的差异如下:
关于各个内存区域的变化,有些面试官会提出以下一些问题,我们一起来看下。
问题一:在 JDK 1.7 中,为什么要将字符串常量池移动到堆中?
这个问题的主要原因在于 GC 的回收效率上,在永久代中的数据, GC 回收效率非常低,只有在整堆收集 (Full GC) 的时候才会被执行 GC;而 Java 程序中通常会有大量的被创建的字符串需要等待回收,将字符串常量池放到堆中,能够更高效及时的回收字符串,释放内存。
问题二:JDK 1.8 为什么要废弃永久代,用元空间取而代之?
HotSpot 团队选择移除永久代,简单的说有两个因素:
-XX:PermSize
和-XX:MaxPermSize
这两个参数的限制,这两个参数在物理空间上又受到 JVM 设定的内存大小限制,这就会导致在使用中永久代可能出现内存溢出的问题,因此在 JDK 1.8 及之后的版本中彻底移除了永久代,用元空间来进行替代,其中元空间并不在虚拟机内存中而是使用本地内存,相比 JDK 1.7 而言,出现内存溢出的风险要小很多,但也不是完全不限制,其大小受操作系统可用内存大小的限制,也支持通过-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
这两个参数来配置如果想要在 JDK1.8 中测试元空间的内存溢出现象,可以通过 Cglib 动态代理框架来创建类,它会将类存放在元空间,测试示例如下。
/**
* 虚拟机参数: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
*/
public class MetaspaceOOMTest {
public static void main(String[] args) {
int i = 0;
try {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
// 创建一个动态代理类
enhancer.create();
i++;
}
} catch (Throwable e) {
System.out.println("第" + i + "次时发生异常");
e.printStackTrace();
}
}
private static class OOMObject {
public OOMObject() {
}
}
}
运行后输出结果如下:
第546次时发生异常
net.sf.cglib.core.CodeGenerationException: java.lang.reflect.InvocationTargetException-->null
at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:348)
at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:117)
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:294)
at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
at MetaspaceOOMTest.main(MetaspaceOOMTest.java:26)
Caused by: java.lang.reflect.InvocationTargetException
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:459)
at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:339)
... 6 more
Caused by: java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
... 11 more
从日志上可以清晰的看到,执行到第 546 次时出现OutOfMemoryError
,内存区域在Metaspace
。
通过以上的内容分析,相信大家对 JVM 内存结构以及相关的区域用途有了一些初步的了解。学习 JVM 内存模型以及相关的内存参数设置,对排查和解决服务器异常问题有着非常重要的作用,希望本篇文章的知识总结,对大家有所帮助,如果有描述不对的地方,欢迎大家留言支持,不胜感激。
1.https://zhuanlan.zhihu.com/p/43279292
2.http://www.ityouknow.com/jvm/2017/08/25/jvm-memory-structure.html
3.https://www.cnblogs.com/xrq730/p/4827590.html
4.https://www.cnblogs.com/aflyun/p/10575740.html
5.https://zhuanlan.zhihu.com/p/371778309