深刻理解Java虚拟机-Java内存区域与内存溢出异常

本博客主要参考周志明老师的《深刻理解Java虚拟机》第二版css

读书是一种跟大神的交流。阅读《深刻理解Java虚拟机》受益不浅,对Java虚拟机有初步的认识。这里写博客主要出于如下三个目的:一方面是记录,方便往后阅读;一方面是加深对内容的理解;一方面是分享给你们,但愿对你们有帮助。java

《深刻理解Java虚拟机》全书总结以下:程序员

序号 内容 连接地址
1 深刻理解Java虚拟机-走近Java http://www.javashuo.com/article/p-wmquqpab-n.html
2 深刻理解Java虚拟机-Java内存区域与内存溢出异常 http://www.javashuo.com/article/p-qbxkmyli-a.html
3 深刻理解Java虚拟机-垃圾回收器与内存分配策略 http://www.javashuo.com/article/p-oivmnobw-ds.html
4 深刻理解Java虚拟机-虚拟机执行子系统 http://www.javashuo.com/article/p-qaeyzgca-a.html
5 深刻理解Java虚拟机-程序编译与代码优化 http://www.javashuo.com/article/p-gmrxlpja-o.html
6 深刻理解Java虚拟机-高效并发 http://www.javashuo.com/article/p-yuiduguo-b.html

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来。web

概述

对于从事C、C++程序开发的开发人员来讲,在内存管理领域,他们既是拥有最高权力的“皇帝”又是从事最基础工做的“劳动人民”——既拥有每个对象的“全部权”,又担负着每个对象生命开始到终结的维护责任。
对于Java程序员来讲,在虚拟机自动内存管理机制的帮助下,再也不须要为每个new操做去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存这一切看起来都很美好。不过,也正是由于Java程序员把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,若是不了解虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的工做。算法

运行时数据区域

在这里插入图片描述

JVM包含两个子系统和两个组件,两个子系统为Class loader(类装载)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)。编程

Class loader(类装载):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。windows

Execution engine(执行引擎):执行classes中的指令。数组

Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。安全

Runtime data area(运行时数据区域):这就是咱们常说的JVM的内存。多线程

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

在这里插入图片描述

程序计数器(线程私有)

程序计数器是一块较小的内存区域,能够看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工做时就是经过改变这个计数器的值来选取下一条须要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都须要依赖这个计数器来完成。「属于线程私有的内存区域」

附加:

  1. 当前线程所执行的字节码行号指示器
  2. 每一个线程都有一个本身的PC计数器。
  3. 线程私有的,生命周期与线程相同,随JVM启动而生,JVM关闭而死。
  4. 线程执行Java方法时,记录其正在执行的虚拟机字节码指令地址
  5. 线程执行Native方法时,计数器记录为(Undefined)。
  6. 惟一在Java虚拟机规范中没有规定任何OutOfMemoryError状况区域。

Java虚拟机栈(线程私有)

线程私有内存空间,它的生命周期和线程相同。线程执行期间,每一个方法被执行时,都会建立一个栈帧(Stack Frame)用于存储局部变量表、操做栈、动态连接、方法出口等信息。每一个方法从被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。「属于线程私有的内存区域」

注意:下面的内容为附加内容,对Java虚拟机栈进行详细说明,感兴趣的小伙伴能够有针对性的阅读

下面依次解释栈帧里的四种组成元素的具体结构和功能:

局部变量表

局部变量表局部变量表是 Java 虚拟机栈的一部分,是一组变量值的存储空间,用于存储方法参数局部变量。 在 Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量

局部变量表在编译期间分配内存空间,能够存放编译期的各类变量类型:

  1. 基本数据类型boolean, byte, char, short, int, float, long, double8种;
  2. 对象引用类型reference,指向对象起始地址引用指针;不等同于对象自己,根据不一样的虚拟机实现,它多是一个指向对象起始地址的引用指针,也多是指向一个表明对象的句柄或者其余与此对象相关的位置
  3. 返回地址类型returnAddress,返回地址的类型。指向了一条字节码指令的地址

变量槽(Variable Slot):

变量槽局部变量表最小单位,规定大小为32位。对于64位的longdouble变量而言,虚拟机会为其分配两个连续Slot空间。

操做数栈

操做数栈Operand Stack)也常称为操做栈,是一个后入先出栈。在 Class 文件的 Code 属性的 max_stacks 指定了执行过程当中最大的栈深度。Java虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的就是指-操做数栈

  1. 局部变量表同样,操做数栈也是一个以32字长为单位的数组。
  2. 虚拟机在操做数栈中可存储的数据类型intlongfloatdoublereferencereturnType等类型 (对于byteshort以及char类型的值在压入到操做数栈以前,也会被转换为int)。
  3. 局部变量表不一样的是,它不是经过索引来访问,而是经过标准的栈操做压栈出栈来访问。好比,若是某个指令把一个值压入到操做数栈中,稍后另外一个指令就能够弹出这个值来使用。

虚拟机把操做数栈做为它的工做区——大多数指令都要从这里弹出数据,执行运算,而后把结果压回操做数栈

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_0iload_1 将存储在局部变量表中索引为01的整数压入操做数栈中,其后iadd指令从操做数栈中弹出那两个整数相加,再将结果压入操做数栈。第四条指令istore_2则从操做数栈中弹出结果,并把它存储到局部变量表索引为2的位置。

下图详细表述了这个过程当中局部变量表操做数栈的状态变化(图中没有使用的局部变量表操做数栈区域以空白表示)。

在这里插入图片描述

动态连接

每一个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程当中的动态连接

Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:

  1. 静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如finalstatic域等),称为静态解析
  2. 动态解析:另外一部分将在每一次的运行期间转化为直接引用,称为动态连接
方法返回地址

当一个方法开始执行之后,只有两种方法能够退出当前方法:

  1. 正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),通常来讲,调用者的PC计数器能够做为返回地址。
  2. 异常返回:当执行遇到异常,而且当前方法体内没有获得处理,就会致使方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要经过异常处理器表来肯定。

当一个方法返回时,可能依次进行如下3个操做:

  1. 恢复上层方法局部变量表操做数栈
  2. 返回值压入调用者栈帧操做数栈
  3. PC计数器的值指向下一条方法指令位置。
小结

注意:在Java虚拟机规范中,对这个区域规定了两种异常。

其一:若是当前线程请求的栈深度大于虚拟机栈所容许的深度,将会抛出 StackOverflowError 异常(在虚拟机栈不容许动态扩展的状况下);

其二:若是扩展时没法申请到足够的内存空间,就会抛出 OutOfMemoryError 异常。

本地方法栈(线程私有)

本地方法栈Java虚拟机栈发挥的做用很是类似,主要区别是Java虚拟机栈执行的是Java方法服务,而本地方法栈执行Native方法服务(一般用C编写)。

有些虚拟机发行版本(譬如Sun HotSpot虚拟机)直接将本地方法栈Java虚拟机栈合二为一。与虚拟机栈同样,本地方法栈也会抛出StackOverflowErrorOutOfMemoryError异常。

Java堆(全局共享)

对大多数应用而言,Java 堆是虚拟机所管理的内存中最大的一块,是被全部线程共享的一块内存区域,在虚拟机启动时建立。此内存区域的惟一做用就是存放对象实例,几乎全部的对象实例都是在这里分配的(不绝对,在虚拟机的优化策略下,也会存在栈上分配、标量替换的状况,后面的章节会详细介绍)。

Java 堆是 GC 回收的主要区域,所以不少时候也被称为 GC 堆。

从内存回收的角度看,因为如今收集器基本都采用分代收集算法,因此在Java堆被划分红两个不一样的区域:新生代 (Young Generation) 、老年代 (Old Generation) 。新生代 (Young) 又被划分为三个区域:一个Eden区和两个Survivor区 - From Survivor区和To Survivor区。不过不管如何划分,都与存放内容无关,不管哪一个区域,存储的都仍然时对象实例,记你一步划分的目的是为了使JVM可以更好的管理堆内存中的对象,包括内存的分配以及回收。

简要概括:新的对象分配是首先放在年轻代 (Young Generation) 的Eden区,Survivor区做为Eden区和Old区的缓冲,在Survivor区的对象经历若干次收集仍然存活的,就会被转移到老年代Old中。

从内存回收的角度看,线程共享的 Java 堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。「属于线程共享的内存区域」

方法区(全局共享)

方法区和Java堆同样,为多个线程共享,它用于存储类信息常量静态常量即时编译后的代码等数据。Non-Heap(非堆)「属于线程共享的内存区域」

运行时常量池

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

下面信息为附加信息

  • HotSpot虚拟机中,将方法区称为“永久代”,本质上二者并不等价,仅仅是由于HotSpot虚拟机把GC分代收集扩展至方法区。
  • JDK 7的HotSpot中,已经将本来存放于永久代中的字符串常量池移出。
  • 根据虚拟机规范的规定,当方法区没法知足内存分配需求时,将会抛出OutOfMemoryError异常。当常量池没法再申请到内存时也会抛出OutOfMemoryError异常。
  • JDK 8的HotSpot中,已经将永久代废除,用元数据实现了方法区。元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的,须要配置参数。

在这里插入图片描述

直接内存

直接内存(Direct Memory)并非虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。Java 中的 NIO 可使用 Native 函数直接分配堆外内存,一般直接内存的速度会优于Java堆内存,而后经过一个存储在 Java 堆中的 DiectByteBuffer 对象做为这块内存的引用进行操做。这样能在一些场景显著提升性能,对于读写频繁、性能要求高的场景,能够考虑使用直接内存,由于避免了在 Java 堆和 Native 堆中来回复制数据。直接内存不受 Java 堆大小的限制。

HotSpot虚拟机对象探秘

对象的建立

说到对象的建立,首先让咱们看看 Java 中提供的几种对象建立方式:

Header 解释
使用new关键字 调用了构造函数
使用Class的newInstance方法 调用了构造函数
使用Constructor类的newInstance方法 调用了构造函数
使用clone方法 没有调用构造函数
使用反序列化 没有调用构造函数

下面是对象建立的主要流程:

在这里插入图片描述

虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,若是没有,必须先执行相应的类加载。类加载经过后,接下来分配内存。若Java堆中内存是绝对规整的,使用“指针碰撞“方式分配内存;若是不是规整的,就从空闲列表中分配,叫作”空闲列表“方式。划份内存时还须要考虑一个问题-并发,也有两种方式: CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。而后内存空间初始化操做,接着是作一些必要的对象设置(元信息、哈希码…),最后执行<init>方法。

下面内容是对象建立的详细过程

对象的建立一般是经过new关键字建立一个对象的,当虚拟机接收到一个new指令时,它会作以下的操做。

1.判断对象对应的类是否加载、连接、初始化

虚拟机接收到一条new指令时,首先会去检查这个指定的参数是否能在常量池中定位到一个类的符号引用,而且检查这个符号引用表明的类是否已被类加载器加载、连接和初始化过。若是没有则先执行相应的类加载过程。

在这里插入图片描述

2.为对象分配内存

类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:

  • 指针碰撞:若是Java堆的内存是规整,即全部用过的内存放在一边,而空闲的的放在另外一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工做。
  • 空闲列表:若是Java堆的内存不是规整的,则须要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候能够从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录。

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

在这里插入图片描述

3.处理并发安全问题

对象的建立在虚拟机中是一个很是频繁的行为,哪怕只是修改一个指针所指向的位置,在并发状况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的状况。解决这个问题有两种方案:

  • 对分配内存空间的动做进行同步处理(采用 CAS + 失败重试来保障更新操做的原子性);
  • 把内存分配的动做按照线程划分在不一样的空间之中进行,即每一个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪一个线程要分配内存,就在哪一个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才须要同步锁。经过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。

在这里插入图片描述

4.初始化分配到的内存空间

内存分配完后,虚拟机要将分配到的内存空间初始化为零值(不包括对象头)。若是使用了 TLAB,这一步会提早到 TLAB 分配时进行。这一步保证了对象的实例字段在 Java 代码中能够不赋初始值就直接使用。

5.设置对象的对象头

接下来设置对象头(Object Header)信息,包括对象的所属类、对象的HashCode和对象的GC分代年龄等数据存储在对象的对象头中。

6.执行init方法进行初始化

执行init方法,初始化对象的成员变量、调用类的构造方法,这样一个对象就被建立了出来。

对象的内存布局

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

在这里插入图片描述

对象头

HotSpot虚拟机中,对象头有两部分信息组成:运行时数据类型指针,若是是数组对象,还有一个保存数组长度的空间。

  • Mark Word(运行时数据):用于存储对象自身运行时的数据,如哈希码(hashCode)、GC分带年龄线程持有的锁偏向线程ID 等信息。在32位系统占4字节,在64位系统中占8字节;

    HotSpot虚拟机对象头Mark Word在其余状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容以下表所示:

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不须要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向
  • Class Pointer(类型指针):用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
  • Length:若是是数组对象,还有一个保存数组长度的空间,占4个字节;
实例数据

实例数据 是对象真正存储的有效信息,不管是从父类继承下来的仍是该类自身的,都须要记录下来,而这部分的存储顺序受虚拟机的分配策略定义的顺序的影响。

默认分配策略:

long/double -> int/float -> short/char -> byte/boolean -> reference

若是设置了-XX:FieldsAllocationStyle=0(默认是1),那么引用类型数据就会优先分配存储空间:

reference -> long/double -> int/float -> short/char -> byte/boolean

结论:

分配策略老是按照字节大小由大到小的顺序排列,相同字节大小的放在一块儿。

对齐填充

无特殊含义,不是必须存在的,仅做为占位符。

HotSpot虚拟机要求每一个对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(32位为1倍,64位为2倍),所以,当对象实例数据部分没有对齐的时候,就须要经过对齐填充来补全。

对象的访问定位

Java程序须要经过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄直接指针 两种方式。

指针: 指向对象,表明一个对象在内存中的起始地址。

句柄: 能够理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

句柄访问

Java堆中划分出一块内存来做为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据对象类型数据各自的具体地址信息,具体构造以下图所示:
在这里插入图片描述
优点:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很是广泛的行为)时只会改变句柄中实例数据指针,而引用自己不须要修改。

直接指针

若是使用直接指针访问,引用 中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。
在这里插入图片描述
优点:速度更,节省了一次指针定位的时间开销。因为对象的访问在Java中很是频繁,所以这类开销聚沙成塔后也是很是可观的执行成本。HotSpot 中采用的就是这种方式。

实战:OutOfMemoryError异常

内存异常是咱们工做当中常常会遇到问题,但若是仅仅会经过加大内存参数来解决问题显然是不够的,应该经过必定的手段定位问题,究竟是由于参数问题,仍是程序问题(无限建立,内存泄露)。定位问题后才能采起合适的解决方案,而不是一内存溢出就查找相关参数加大。

概念

内存泄露:代码中的某个对象本应该被虚拟机回收,但由于拥有GCRoot引用而没有被回收。

内存溢出:虚拟机因为堆中拥有太多不可回收对象没有回收,致使没法继续建立新对象。

在分析问题以前先给你们讲一讲排查内存溢出问题的方法,内存溢出时JVM虚拟机会退出,那么咱们怎么知道JVM运行时的各类信息呢,Dump机制会帮助咱们,能够经过加上VM参数-XX:+HeapDumpOnOutOfMemoryError让虚拟机在出现内存溢出异常时生成dump文件,而后经过外部工具(VisualVM)来具体分析异常的缘由。

除了程序计数器外,Java虚拟机的其余运行时区域都有可能发生OutOfMemoryError的异常,下面分别给出验证:

Java堆溢出

Java堆用来存储对象,所以只要不断建立对象,并保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象,那么当对象数量达到最大堆容量时就会产生 OOM。

/** * java堆内存溢出测试 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError */
public class HeapOOM {

    static class OOMObject{}

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

运行结果:

java.lang.OutOfMemoryError: Java heap space 
Dumping heap to java_pid7164.hprof … 
Heap dump file created [27880921 bytes in 0.193 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.jvm.oom.HeapOOM.main(HeapOOM.java:17)

堆内存 OOM 是常常会出现的问题,异常信息会进一步提示 Java heap space

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

在 HotSpot 虚拟机中不区分虚拟机栈和本地方法栈,栈容量只由 -Xss 参数设定。关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:

  • 若是线程请求的栈深度大于虚拟机所容许的最大深度,将抛出 StackOverflowError 异常。
  • 若是虚拟机在扩展栈时没法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
/** * 虚拟机栈和本地方法栈内存溢出测试,抛出stackoverflow exception * VM ARGS: -Xss128k 减小栈内存容量 */
public class JavaVMStackSOF {

    private int stackLength = 1;

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

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

    }

}

运行结果:

stack length = 11420 
Exception in thread “main” java.lang.StackOverflowError 
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12) 
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13) 
at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)

以上代码在单线程环境下,不管是因为栈帧太大仍是虚拟机栈容量过小,当内存没法分配时,抛出的都是 StackOverflowError 异常。

若是测试环境是多线程环境,经过不断创建线程的方式能够产生内存溢出异常,代码以下所示。可是这样产生的 OOM 与栈空间是否足够大不存在任何联系,在这种状况下,为每一个线程的栈分配的内存足够大,反而越容易产生OOM 异常。这点不难理解,每一个线程分配到的栈容量越大,能够创建的线程数就变少,创建多线程时就越容易把剩下的内存耗尽。这点在开发多线程的应用时要特别注意。若是创建过多线程致使内存溢出,在不能减小线程数或更换64位虚拟机的状况下,只能经过减小最大堆和减小栈容量来换取更多的线程。

/** * JVM 虚拟机栈内存溢出测试, 注意在windows平台运行时可能会致使操做系统假死 * VM Args: -Xss2M -XX:+HeapDumpOnOutOfMemoryError */

public class JVMStackOOM {

    private void dontStop() {
        while (true) {}
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {

                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) {
        JVMStackOOM oom = new JVMStackOOM();
        oom.stackLeakByThread();
    }
}

方法区和运行时常量池溢出

方法区用于存放Class的相关信息,对这个区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。使用CGLib实现。

方法区溢出也是一种常见的内存溢出异常,在常常生成大量Class的应用中,须要特别注意类的回收状况,这类场景除了使用了CGLib字节码加强和动态语言外,常见的还有JSP文件的应用(JSP第一次运行时要编译为Java类)、基于OSGI的应用等。

/** * 测试JVM方法区内存溢出 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M */
public class MethodAreaOOM {

    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 obj, Method method, Object[] args,
                        MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject{}
}

本机直接内存溢出

DirectMemory 容量可经过 -XX:MaxDirectMemorySize 指定,如不指定,则默认与Java堆最大值同样。测试代码使用了 Unsafe 实例进行内存分配。

由 DirectMemory 致使的内存溢出,一个明显的特征是在Heap Dump 文件中不会看见明显的异常,若是发现 OOM 以后 Dump 文件很小,而程序直接或间接使用了NIO,那就能够考虑检查一下是否是这方面的缘由。

/** * 测试本地直接内存溢出 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M */
public class DirectMemoryOOM {

    private static final int _1MB = 1024 * 1024;

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

本章小结

经过本章的学习,咱们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操做可能致使内存溢出异常。虽然Java有垃圾收集机制,但内存溢出异常离咱们仍然并不遥远,本章只是讲解了各个区域出现内存溢出异常的缘由。