深入理解 Java 虚拟机(一):Java 内存区域与内存溢出异常

概述

JVM 的内存模型是 Java 语言绕不开的一个话题。对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个 new 操作去写配对的 delete/free 代码,不容易出现内存泄漏问题,由虚拟机管理内存这一切看起来都很美好。不过,也正因为 Java 程序员把内存控制的权利交给了 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将成为一项异常艰难的工作。即要进行 Java 的性能调优,首先就要了解其内存模型;同时,在诸多的面试笔试中,这也是很多面试官会考察的内容。

本篇是我学习 JVM 系列的开篇,同时也是我阅读周志明老师《深入理解 Java 虚拟机》一书的学习笔记,主要内容讲述 JVM 的基本概念,然后从概念上介绍 Java 虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题,最后讨论一下常见的内存泄漏和溢出的问题。

JVM 基本概念

JDK 与 JRE 区别

JDK:把 Java 程序设计语言、Java 虚拟机、Java API 类库这三部分统称为 JDK(Java Development Kit),JDK 是用于支持 Java 程序开发的最小环境,是 Java 语言的软件开发工具包(SDK)。

JRE:把 Java API 类库中的 Java SE API 子集和 Java 虚拟机这两部分统称为 JRE(Java Runtime Environment),JRE 是支持 Java 程序运行的标准环境。

JVM 是什么?

JVM 是 Java Virtual Machine(Java 虚拟机)的缩写,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

Java 语言的一个非常重要的特点就是与平台的无关性,而使用 Java 虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入 Java 虚拟机后,Java 语言在不同平台上运行时不需要重新编译。Java 语言使用 Java 虚拟机屏蔽了与具体平台相关的信息,使得 Java 语言编译程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java 虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是 Java 的能够 “一次编译,到处运行” 的原因。

Sun 公司开发了 Java 语言,但任何人都可以在遵循 JVM 规范的前提下开发和提供 JVM 实现。所以目前业界有多种不同的 JVM 实现,包括 Oracle Hotspot、Oracle JRockit、IBM J9、MRJ(MacOS Runtime for Java),它们都实现了 Java 虚拟机规范,但内存管理机制的实现方式各异。在平常的学习和工作中,我们接触的最多的就是 Oracle Hotspot,它是 SunJDK 和 OpenJDK[1] 中所带的虚拟机,也是目前使用范围最广的 Java 虚拟机。

OpenJDK 与 SunJDK 区别

怎么区分 SunJDK 和 OpenJDK

查看 Java 版本,显示 OpenJDK 则表示 OpenJDK

1
2
3
4
[root@VM_24_98_centos ~]# java -version
openjdk version "1.8.0_232"
OpenJDK Runtime Environment (build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM (build 25.232-b09, mixed mode)

查看 Java 版本,显示 Java(TM) SE 则表示 SunJDK

1
2
3
4
[root@iZbp11s2rh7xf6zphn2z5tZ ~]# java -version
java version "1.8.0_161"
Java(TM) SE Runtime Environment (build 1.8.0_161-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.161-b12, mixed mode)

运行时数据区域

Java 运行时数据区域中堆内存与非堆内存

运行时数据区是 JVM 程序运行时在操作系统上分配的内存区域。Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java 虚拟机规范(Java SE 7 版)》的规定,Java 虚拟机所管理的内存将包括一下几个运行时数据区域:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。

Java 运行时数据区域

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的节字码的行号指示器。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为 “线程私有” 的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefind)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame - 是方法运行时的基础数据结构),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

经常有人把 Java 内存分为堆内存(Heap)和栈内存(Stack),这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的栈就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。

局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的地址)和 returnAddress 类型(指向了一条节字码指令的地址)。

其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间(Slot),其余的数据类型只占用 1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在 Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

本地方法栈

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

Java 堆

对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。这一点在 Java 虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配,但是随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么 “绝对” 了。

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC 堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

根据 Java 虚拟机的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError。

方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆分开来。

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

Java 虚拟机规范中堆方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样 “永久” 存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收 “成绩” 比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。

根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池

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

Java 虚拟机对 Class 文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java 虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需求来实现这个内存区域。不过,一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储运行时常量池中。

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

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

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。

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

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

直接内存与堆内存

Java 可以通过 java.nio.ByteBuffer.allocateDirect(capacity) 直接使用 non java heap(Java堆外)的内存 。

  • 优点:1. 堆外内存不影响 JVM GC,程序会减少 Full GC 次数;2. IO 操作使用堆外内存比堆内存快。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后在发送。
  • 缺点:1. 堆外内存难以控制,如果内存泄漏,那么很难排查;2. 堆外内存只能通过序列化和反序列化来存储,保存对象速度比堆内存慢,不适合存储很复杂的对象。

OutOfMemoryError 异常

JVM 内存参数设置

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

Java 运行时数据区域 JVM 参数设置

Java 堆溢出

Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大推的容量限制后就会产生内存溢出异常。

例如 JVM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
将堆的最小值 - Xms 参数与最大值 - Xmx 参数设置为一样即可避免堆自动扩展;通过参数 -XX:+HeapDumpOnOutOfMemoryError 即可让虚拟机在出现内存溢出时 Dump 出当前内存堆转储快照以便事后进行分析。

Java 堆内存溢出异常测试

Java 堆内存的 OOM 异常是实际应用中最常见的内存溢出异常情况。当出现 Java 堆内存溢出时,异常堆栈信息 “java.lang.OutOfMemoryError” 会跟着进一步提示“Java heap space”。

要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如 Eclipse Memory Analyzer)对 Dump 出来的转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息以及 GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。

如果不存在泄漏,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 - Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

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

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

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

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

若将实现范围限制于单线程中的操作。

  • 使用 - Xss 参数减少栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。

虚拟机栈和本地栈 OOM 测试

实现结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。

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

由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。

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

运行时常量池导致的内存溢出异常

从运行结果中可以看到,运行时常量池溢出,在 OutOfMemoryError 后面跟随的提示信息是“PermGen space”,说明运行时常量池属于方法区(HotSpot 虚拟机中的永久代)的一部分。

而使用 JDK 1.7 运行这段程序就不会得到相同的结果,while 循环将一直进行下去。
String.intern() 返回引用的测试

这段代码在 JDK 1.6 中运行,会得到两个 false,而在 JDK 1.7 中运行,会得到一个 true 和一个 false。产生差异的原因是:在 JDK 1.6 中,intern() 方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由 StringBuilder 创建的字符串实例在 Java 堆上,所以必然不是同一个引用,将返回 false。而 JDK 1.7(以及部分其他虚拟机,例如 JRockit)的 intern() 返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对于 str2 比较返回 false 是因为 “java” 这个字符串在执行 StringBuilder.toString() 之前已经出现过,字符串常量池中已经有它的引用了,不符合 “首次出现” 的原则,而 “计算机软件” 这个字符串则是首次首次出现的,因此返回 true。

方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状态。这类场景除了程序使用了 CGLib 字节码增强和动态语言之外,常见的还有:大量 JSP 或动态产生 JSP 文件的应用(JSP 第一次运行时需要编译为 Java 类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器也会视为不同的类)等。

本机直接内存溢出

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

使用 unsafe 分配本机内存

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

延伸阅读

再见永久代 PermGen,你好元空间 Metaspace

永久代:对于习惯在 HotSpot 虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。(通过 -XX:PermSize 设置永久代初始大小;通过 -XX:MaxPermSize 设置永久代最大大小)

元空间:JDK 1.8 中 HotSpot 虚拟机设计团队使用元空间(Metaspace)代替永久代(PermGen),使用本地内存来存储类元数据,被称为 Metaspace。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。(通过 -XX:MetaspaceSize 设置元空间初始大小;通过 -XX:MaxMetaspaceSize 设置元空间最大大小,默认没有限制)

JDK 1.8 运行时数据区发生的变化

为什么废弃永久代(PermGen)
  • 移除永久代是为融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代。
  • Permgen 空间的具体多大很难预测。指定小了会造成 java.lang.OutOfMemoryError: Permgen size 错误,设置多了又造成浪费。
  • 当使用元空间时,可以加载多少类元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

PermGen 与 Metaspace

PermGen 到 Metaspace 数据转移

从 JDK 1.7 开始永久代的移除工作,贮存在永久代的一部分数据已经转移到了 Java Heap 或者是 Native Heap。但永久代仍然存在于 JDK 1.7,并没有完全的移除:符号引用(Symbols)转移到了 Native Heap;字面量(interned strings)转移到了 Java Heap;类的静态变量(class statics)转移到了 Java Heap。因此,升级到 JDK 1.8 之后,会发现 Java 堆空间有所增长。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7 及 JDK 1.8 的区别,以字符串常量为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.paddx.test.memory;

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

public class StringOomMock {
static String base = "string";
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
String str = base + base;
base = str;
list.add(str.intern());
}
}
}

我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:

JDK 1.6 运行结果

JDK 1.7 运行结果

JDK 1.8 运行结果

从上述结果可以看出,JDK 1.6 下,会出现 “PermGen Space” 的内存溢出,而在 JDK 1.7 和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8 中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。

逃逸分析

前面说到 Java 堆对象分配时候提到:随着 JIT 编译器的发展以及逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化大声,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。那么什么是逃逸分析呢?

逃逸分析(Escape Analusis)是目前 Java 虚拟机中比较前言的优化技术,是为其他优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还可能被外部线程访问到,譬如赋值给类变量或者可以在其他线程中访问的实例变量,称为线程逃逸。如果能证明一个对象不会逃逸到方法或者线程之外,也就是别的方法或者线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化。

栈上分配(Stack Allocation)

Java 虚拟机中,在 Java 堆上分配创建对象的内存空间几乎是 Java 程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但是回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。

同步消除(Synchronization Elimination)

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。

标量替换(Scalar Replacement)

标量(Scalar)是指一个数据已经无法在分解成更小的数据来表示了,Java 虚拟机中的基本数据类型以及 reference 类型都不能再进一步分解,它们就可以成为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java 中的对象就是最典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。

本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

虚拟机为新生对象分配内存的分配方式

我们知道虚拟机为新生对象分配内存的任务等同于把一块确定大小的内存从 Java 堆中划分出来,其分配方法主要分为以下两种:假设 Java 堆中的内存是绝对规整的,所有用过的内存都放在一边,空间的内存放在另外一边,中间放着一个指定作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲那边挪动一段与对象大小相等的距离,这种分配方法称为“指针膨胀”(Bump the Pointer);如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那么虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空间列表”(Free List)。选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的的垃圾收集器是否带有压缩整理功能决定。因此,在使用 Serial、ParNer 等带 Compact 过程的收集器时,系统才用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。

TLAB 与对象的创建

除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小怀内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数来决定。

对象分配流程


参考博文

[1]. OpenJDK 和 Sun/OracleJDK 区别 与联系
[2].《深入理解 Java 虚拟机:JVM 高级特效与最佳实现》,第 2 章
[3]. Java 8: 从永久代(PermGen)到元空间(Metaspace)
[4]. Java对象分配简要流程


注脚

[1]. SunJDK 和 OpenJDK: 在 2006 年的 Java One 大会上,Sun 公司宣布最终会把 Java 开源,并在随后的一年,陆续将 JDK 的各个部分(其中当然也包括了 HotSpot VM)在 GPL 协议下公开了源码,并在此基础上建立了 OpenJDK。这样,HotSpot VM 便成为了 SunJDK 和 Open JDK 两个实现极度接近的 JDK 项目的共同虚拟机。


深入理解 Java 虚拟机系列


谢谢你长得那么好看,还打赏我!😘
0%