JVM详解1.Java内存模型

26次阅读

共计 14372 个字符,预计需要花费 36 分钟才能阅读完成。

博客地址:https://spiderlucas.github.io 备用地址:http://spiderlucas.coding.me
1.1 基础知识
1.1.1 一些基本概念
JDK(Java Development Kit):Java 语言、Java 虚拟机、Java API 类库 JRE(Java Runtime Environment):Java 虚拟机、Java API 类库 JIT(Just In Time):Java 虚拟机内置 JIT 编译器,将字节码编译成本机机器代码。OpenJDK:OpenJDK 是基于 Oracle JDK 基础上的 JDK 的开源版本,但由于历史原因缺少了部分(不太重要)的代码。Sun JDK > SCSL > JRL > OpenJDKJCP 组织(Java Community Process):由 Java 开发者以及被授权者组成,负责维护和发展技术规范、参考实现(RI)、技术兼容包。
1.1.2 编译 JDK
参见《深入理解 Java 虚拟机》1.6 节走进 JVM 之一 自己编译 openjdk 源码
1.2 Java 内存模型
1.2.1 运行时数据区域
根据 Java 虚拟机规范(Java SE7)的规定,JVM 的内存包括以下几个运运行时数据区域:
程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,他可以看作是当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
程序计数器是线程私有的,每条线程都有一个独立的独立的程序计数器,各条线程之间计数器互不影响。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

Java 虚拟机栈是线程私有的,他的生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于包含局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成这个过程,就对应这一个栈帧在虚拟机栈中的入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型和对象引用(reference 类型,他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
其中 64 位长度的 long 和 double 类型会占用 2 个局部变量空间,其余的数据类型只会占用 1 个局部变量空间。局部变量表所需的内存空间在编译期间完成内存分配。当进入一个方法时,这个方法需要在帧中分配多大的内存空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在 Java 虚拟机规范中,对这个区域规定了两种异常状态:如果线程请求的栈的深度大于虚拟机允许的深度,将抛出 StackOverFlowError 异常(栈溢出);如果虚拟机栈可以动态扩展(现在大部分 Java 虚拟机都可以动态扩展,只不过 Java 虚拟机规范中也允许固定长度的 java 虚拟机栈),如果扩展时无法申请到足够的内存空间,就会抛出 OutOfMemoryError 异常(没有足够的内存)。

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,他们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地 Native 方法服务。
在虚拟机规范中对本地方法栈中的使用方法、语言、数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(例如 Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
本地方法栈也会抛出 StackOverFlowError 和 OutOfmMemoryError 异常。

Java 堆

Java 堆(Java Heap)是 Java 虚拟机管理内存中的最大一块。
Java 堆是所有线程共享的一块内存管理区域。

此内存区域唯一目的就是存放对象的实例,几乎所有对象实例都在堆中分配内存。这一点在 Java 虚拟机规范中的描述是:所有对象实例以及数组都要在堆上分配,但是随着 JIT 编译器的发展与逃逸技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也不是变的那么“绝对”了。
Java 堆是垃圾回收器管理的主要区域,因此很多时候也被称为 GC 堆(Garbage Collected Heap)。
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和年老代。再在细致一点的划分可以分为:Eden 空间、From Survivor 空间、To Survivor 空间等。
从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区。
不过无论如何划分,都与存放内容无关,无论哪个区域存放的都是对象实例。进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
Java 堆可以处在物理上不连续的内存空间,只要逻辑上是连续的即可。在实现上既可以实现成固定大小,也可以是可扩展的大小,不过当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。
如果在堆中没有内存实例完成分配,并且堆也无法在扩展时将会抛出 OutOfMemoryError 异常。

方法区

方法区是各个线程共享的内存区域
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然 Java 虚拟机规范把方法区描述为堆的一部分,但是他还有个别名叫做 Non-heap(非堆),目的应该是与 Java 堆区分开来。
根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样永久存在了。这区域的内存回收目标重要是针对常量池的回收和类型的卸载,一般来说这个内存区域的回收‘成绩’比较难以令人满意。尤其是类型的卸载条件非常苛刻,但是这部分的回收确实是必要的。在 Sun 公司的 bug 列表中,曾出现过的若干个严重的 bug 就是由于低版本的 HotSpot 虚拟机对此区域未完成回收导致的内存溢出。

注意:方法区与永久代

对于习惯在 HotSpot 虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价。
仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。
对于其他虚拟机(如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。
对于 HotSpot 虚拟机,根据官方发布的路线图信息,现在也有放弃永久代并逐步改为采用 Native Memory 来实现方法区的规划了,在目前已经发布的 JDK1.7 的 HotSpot 中,已经把原本放在永久代的字符串常量池移出。

运行时常量池
(见 1.2.2)
直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制。但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xms 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

1.2.2 常量池
Class 常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用。
字面量(Literal):文本字符串(如 String str = “SpiderLucas” 中 SpiderLucas 就是字面量)、八种基本类型的值(如 int i = 0 中 0 就是字面量)、被声明为 final 的常量等;
符号引用(Symbolic References):类和方法的全限定名、字段的名称和描述符、方法的名称和描述符。
每个 class 文件都有一个 class 常量池。

字符串常量池

参考资料来源:Java 中的常量池、彻底弄懂字符串常量池等相关问题、Java 中 String 字符串常量池

字符串常量池中的字符串只存在一份。
字符串常量池(String Constant Pool)是存储 Java String 的一块内存区域,在 JDK 6 之前是放置于方法区的,在 JDK 7 之后放置于堆中。
在 HotSpot 中实现的字符串常量池功能的是一个 StringTable 类,它是一个 Hash 表,默认值大小长度是 1009;这个 StringTable 在每个 HotSpot VM 的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了 StringTable 上。

StringTable 的长度:在 JDK 6 中,StringTable 的长度是固定的,因此如果放入 String Pool 中的 String 非常多,就会造成 hash 冲突,导致链表过长,当调用 String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;在 JDK 7 中,StringTable 的长度可以通过参数指定:-XX:StringTableSize=1024。

字符串常量池中存放的内容:在 JDK 6 及之前版本中,String Pool 里放的都是字符串常量;JDK 7 中,由于 String#intern()发生了改变,因此 String Pool 中也可以存放放于堆内的字符串对象的引用。

intern() 函数

在 JDK 6 中,intern()的处理是先判断字符串常量是否在字符串常量池中,如果存在直接返回该常量;如果没有找到,则将该字符串常量加入到字符串常量区,也就是在字符串常量区建立该常量。
在 JDK 7 中,intern()的处理是先判断字符串常量是否在字符串常量池中,如果存在直接返回该常量,如果没有找到,说明该字符串常量在堆中,则处理是把堆区该对象的引用加入到字符串常量池中,以后别人拿到的是该字符串常量的引用,实际存在堆中。

字符串常量池案例
String s1 = new String(“Spider”); // s1 -> 堆
// 该行代码创建了几个对象
// 两个对象(不考虑对象内部的对象):首先创建了一个字符串常量池的对象,然后创建了堆里的对象
s1.intern(); // 字符串常量池中存在 ”Spider”,直接返回该常量
String s2 = “Spider”; // s2 -> 字符串字符串常量池
System.out.println(s1 == s2); // false

String s3 = new String(“Str”) + new String(“ing”); // s3 -> 堆
// 该行代码创建了几个对象?
// 反编译后的代码:String s3 = (new StringBuilder()).append(new String(“Str”)).append(new String(“ing”)).toString();
// 六个对象(不考虑对象内部的对象):两个字符串常量池的对象 ”Str” 和 ”ing”,两个堆的对象 ”Str” 和 ”ing”,一个 StringBuilder,一个 toString 方法创建的 new String 对象
s3.intern(); // 字符串常量池中没有,在 JDK 7 中以后会把堆中该对象的引用放在字符串常量池中(JDK 6 中创建一个 jdk1.6 中会在字符串常量池中建立该常量)
String s4 = “String”; // s4 -> 堆(JDK 6:s4 -> 字符串字符串常量池)
System.out.println(s3 == s4); // true(JDK6 false)

String s5 = “AAA”;
String s6 = “BBB”;
String s7 = “AAABBB”; // s7 -> 字符串常量池
String s8 = s5 + s6; // s8 -> 堆(原因就是如上字符串 + 的重载)
String s9 = “AAA” + “BBB”; // JVM 会对此代码进行优化,直接创建字符串常量池
System.out.println(s7 == s8); // false
System.out.println(s7 == s9); // true(都指向字符串常量池)
方法区与运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

Class 常量池的内容将在类加载后进入方法区的运行时常量池中存放。
Java 虚拟机对 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java 虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置如 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern()方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

1.3 HotSpot 中的对象
1.3.1 对象的创建
new 一个对象的全部流程

从常量池中查找该类的符号引用,并且检查该符号引用代表的类是否被加载、解析、初始化。如果类已经被加载,则跳转至 3;否则跳转至 2。
执行类的加载过程。
为新对象分配内存空间:由于对象所需要内存大小在类加载完成时可以确定,所以可以直接从 Java 堆中划分一块确定大小的内存。
把分配的内存空间都初始化为零值(不包括对象头),如果使用 TLAB 则该操作可以提前至 TLAB 中,这是为了保证对象的字段都被初始为默认值。
执行 init 方法,按照程序员的意愿进行初始化。

对象分配内存空间详解

指针碰撞:如果堆内存是规整,已经分配和为分配的内存有一个指针作为分界点,那么只需要将指针向空闲内存移动即可。

空闲列表:如果内存是不规整的,虚拟机需要维护一个列表,记录那些内存块是可用的。在分配的时候从足够大的空间划分给对象,并更新该列表。
Java 堆是否规整取决于 GC 是否会压缩整理,Serial、ParNew 等带 Compact 过程的收集器,分配算法是指针碰撞;是用 CMS 这种基于 Mark-Sweep 算法的收集器时,分配算法是空闲列表。

分配内存的并发问题
无论是指针碰撞还是空闲列表,都有可能因为并发而产生问题,解决方法有两种:

对分配内存空间的动作进行同步处理——实际上 JVM 采用 CAS(Compare And Swap)配上失败重试的方式保证更新操作的原子性。
把内存分配的动作按照线程划分在不同的空间,每个线程在 Java 堆中预先分配一小块内存,成为本地缓冲内存(Tread Local Allocation Buffer,TLAB)。哪个线程需要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完了,才需要同步锁定。可以通过 -XX:+/-UseTLAB 参数来设定。

CAS 原理一个 CAS 方法包含三个参数 CAS(V,E,N)。V 表示要更新的变量,E 表示预期的值,N 表示新值。只有当 V 的值等于 E 时,才会将 V 的值修改为 N。如果 V 的值不等于 E,说明已经被其他线程修改了,当前线程可以放弃此操作,也可以再次尝试次操作直至修改成功。基于这样的算法,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰(临界区值的修改),并进行恰当的处理。
1.3.2 对象的内存布局
在 HotSpot 虚拟机中,对象在内存中的存储布局可以分为 3 部分:对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头第一部分

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,这部分数据的长度在 32 位和 64 位的虚拟机(暂不考虑开启压缩指针的场景)中分别为 32 个和 64 个 Bits,官方称它为“Mark Word”。
对象需要存储的运行时数据很多,其实已经超出了 32、64 位所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,原理是它会根据对象的状态复用自己的存储空间。
例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25Bits 用于存储对象哈希码(HashCode),4Bits 用于存储对象分代年龄,2Bits 用于存储锁标志位,1Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下表所示。

对象头第二部分

对象头的第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息不一定要经过对象本身。

如果对象是一个数组:对象头中还需要一块用于记录数组长度的数据。

实例数据

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

对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。对象头正好是 8 字节的倍数(1 倍或者 2 倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
1.3.3 对象的访问定位
建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。
对象的两种访问定位方式
主流的访问方式有使用句柄和直接指针两种。

句柄访问:Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息,如下图所示。
直接指针:Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如下图所示。

两种方式比较

句柄访问的优势:reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。

直接指针的优势:最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问的在 Java 中非常频繁,因此这类开销积小成多也是一项非常可观的执行成本。
HotSpot 虚拟机:它是使用第二种方式进行对象访问。
但在整个软件开发的范围来看,各种语言、框架中使用句柄来访问的情况也十分常见。

1.4 OOM 异常分类
1.4.1 堆溢出
Java 堆用于存储对象实例,只要不断创建对象,并且保证 GC Roots 到对象之间有可达路径来避免 GC,那么在对象数量到达最大堆容量限制之后便会产生堆溢出。
/**
* VM args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* 1. 将堆的最小值 -Xms 与最大值 -Xmx 参数设置为一样可以避免堆自动扩展
* 2. 通过参数 -XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机出现内存异常时 Dump 当前堆内存堆转储快照
* 3. 快照位置默认为 user.dir
*/
public class HeapOOM {
static class OOMObject {}
public static void main(String[] args) {
// 保留引用,防止 GC
List<OOMObject> list = new ArrayList<>();
for (;;) {
list.add(new OOMObject());
}
}
}
// 运行结果
// java.lang.OutOfMemoryError: Java heap space
// Dumping heap to java_pid72861.hprof …
// Heap dump file created [27888072 bytes in 0.086 secs]
堆转储快照
以下是 JProfiler 对转储快照的分析
内存泄漏与内存溢出

重点:确认内存中的对象是否是必要的,也就是分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
内存泄漏:是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
内存溢出:是指程序在申请内存时,没有足够的内存空间供其使用。内存泄漏的堆积最终会导致内存溢出。

内存泄漏的分类(按发生方式来分类)

常发性内存泄漏:发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
偶发性内存泄漏:发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
一次性内存泄漏:发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。
隐式内存泄漏:程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。

处理方式

如果是内存泄漏:需要找到泄漏对象的类型信息,和对象与 GC Roots 的引用链的信息,分析 GC 无法自动回收它们的原因。
如果不存在内存泄漏,即内存中的对象的确必须存活:那就应当检查 JVM 的参数能否调大;从代码上检查是否某些对象生命周期过长、持有状态时间过长,尝试减少程序运行期的内存消耗。

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

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

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。
StackOverflowError
/**
* VM args: -Xss256k
* 1. 设置 -Xss 参数减小栈内存
* 2. 死递归增大此方法栈中本地变量表的长度
*/
public class SOF {
int stackLength = 1;

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

public static void main(String[] args) {
SOF sof = new SOF();
try {
sof.stackLeak();
} catch (Throwable e) {
System.out.println(“Stack Length:” + sof.stackLength);
throw e;
}
}
}
// Stack Length:2006
// Exception in thread “main” java.lang.StackOverflowError
// at s1.SOF.stackLeak(SOF.java:13)
// at s1.SOF.stackLeak(SOF.java:13)
多线程导致栈 OOM 异常
/**
* VM Args: -Xss20M
* 通过不断创建线程的方式产生 OOM
*/
public class StackOOM {
private void dontStop() {
for (;;) {

}
}

private void stackLeakByThread() {
for (;;) {
Thread thread = new Thread(this::dontStop);
thread.start();
}
}

public static void main(String[] args) {
new StackOOM().stackLeakByThread();
}
}
通过不断创建线程的方式产生 OOM 异常,但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系。或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。原因:操作系统分配给每个进程的内存是有限制的,假设操作系统的内存为 2GB,剩余的内存为 2GB(操作系统限制)减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。所以每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。解决方法:如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过“减少内存”的手段来解决内存溢出——减少最大堆和减少栈容量来换取更多的线程。
1.4.3 方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,所以对于动态生成类的情况比较容易出现永久代的内存溢出。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。
/**
* (JDK 8)VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10m
* (JDK 7 之前)VM Args: -XX:PermSize=10M -XX:MaxPermSize=10m
*/
public class MethodAreaOOM {
static class OOMClass {}

public static void main(final String[] args) {
for (;;) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMClass.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, objects);
}
});
enhancer.create();
}
}
}
// Exception in thread “main” java.lang.OutOfMemoryError: Metaspace
// at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:237)
// at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
// at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
// at com.ankeetc.commons.Main.main(Main.java:28)
方法区溢出场景
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类场景主要有:

使用了 CGLib 字节码增强,当前的很多主流框架,如 Spring、Hibernate,在对类进行增强时,都会使用到 CGLib 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的 Class 可以加载入内存。
大量 JSP 或动态产生 JSP 文件的应用(JSP 第一次运行时需要编译为 Java 类)
基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等
JVM 上的动态语言(例如 Groovy 等)通常都会持续创建类来实现语言的动态性

1.4.4 本机直接内存溢出
下面代码越过了 DirectByteBuffer 类,直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory()。
/**
* VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
* DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定
* 如果不指定,则默认与 Java 堆最大值(-Xmx 指定)一样。
*/
public class Main {

private static final long _1024MB = 1024 * 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(_1024MB);
}
}
}
// Exception in thread “main” java.lang.OutOfMemoryError
// at sun.misc.Unsafe.allocateMemory(Native Method)
// at com.ankeetc.commons.Main.main(Main.java:25)
DirectMemory 特征

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

1.5 不同版本的 JDK
参考资料

Java8 内存模型—永久代 (PermGen) 和元空间(Metaspace)
JDK8- 废弃永久代(PermGen)迎来元空间(Metaspace)

关于永久代和方法区

在 HotSpot VM 中“PermGen Space”其实指的就是方法区
“PermGen Space”和方法区有本质的区别。前者是 JVM 规范的一种实现(HotSpot),后者是 JVM 的规范。
只有 HotSpot 才有“PermGen Space”,而对于其他类型的虚拟机,如 JRockit、J9 并没有“PermGen Space”。

不同版本 JDK 总结

JDK 7 之后将字符串常量池由永久代转移到堆中
JDK 8 中,HotSpot 已经没有“PermGen space”这个区间了,取而代之是一个叫做 Metaspace(元空间)的东西。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

-XX:MetaspaceSize:初始空间大小,达到该值就会触发垃圾收集进行类型卸载。同时 GC 会对该值进行调整——如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 MaxMetaspaceSize 时,适当提高该值。

-XX:MaxMetaspaceSize:最大空间,默认是没有限制的。

-XX:MinMetaspaceFreeRatio:在 GC 之后,最小的 Metaspace 剩余空间容量的百分比,减少为分配空间所导致的垃圾收集

-XX:MaxMetaspaceFreeRatio:在 GC 之后,最大的 Metaspace 剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

PermSize、MaxPermSize 参数已移除

正文完
 0