书是深入理解Java虚拟机:JVM高级特性与最佳实践(第2版) 周志明|第二版

代码在阿里云盘

image.png

2 Java内存区域与内存溢出

2.2 运行时数据区域

02-1_Java虚拟机运行时数据区.png

2.2.1 程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机栈的多线程是通过线程轮流切换,分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行一个的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器则为空(Undefined)。此内存区域是唯一一个没有任何OutOfMemoryError情况的区域。

2.2.2 Java虚拟机栈

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

在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可也动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.2.3 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
《Java虚拟机规范》对本地方法栈中使用的语言,使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据它需要自由实现它,甚至有的Java虚拟机(例如Hot-Sport)直接把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时会分别抛出StackOverflowError和OutOfMemoryError异常。

2.2.4 Java堆

对于java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存总最大的一块,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理内存的区域,因此一些资料中它也被称为GC堆。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以java堆中经常会出现“新生代”,“老年代”,“永久代”,“Eden空间”,“From Survivor空间”,“To Survivor空间”等名词

Java堆既可以被实成固定大小的,也可以是扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx -Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法在扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.2.5 方法区

方法区(Method Area)和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却是一个别名叫做“非堆”,目的是和Java堆区分开来。
说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK1.8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称为永久代,或将两者混为一谈。本质上这两者并不是等价的,因为因为是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如BEA JRockit IBM J9等来说,是不存在永久代的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题。

Java8中终于完全废弃了永久代的概念,改用与JRockit,J9一样在本地内存中实现的元空间来代替,把JDK1.7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非进入了方法区就如同永久代的名字一样“永久”了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。
根据《Java虚拟机规范》的规定,如果方法区无法满足新内存分配需求时,将抛出OutOfMemoryError异常。

2.2.6 运行时常量池

运行时常量池是方法区的一部分。class文件中除了有类的版本、字段、方法接口等描述信息外,还有一项就是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

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

2.3 HotSpot虚拟机对象探秘

以最常用的虚拟机HotSpot和最常用的内存区域Java堆为例,深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

2.3.1 对象的创建

Java是一门面向对象的编程语言,Java程序运行过程中每时每刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new 关键字而已,而在虚拟机中,对象(只讨论普通对象,不包括数组,Class对象等)。
当Java虚拟机遇到一条字节码new 指令时,首先去检查这个指令的参数是否能被爱在常量池中定位到一个类的符号引用,并且将去检查这个符号引用的类是否已被加载、解析和初始化。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过之后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。但是Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块时可用的,在分配的时候从列表中找出一块足够大的空间划分给对象实例,并更新列表上的记录。这种分配方式称为“空闲列表”。选择哪种分配方式是由Java堆是否规整决定,而Java堆是否规整又是由所采用的垃圾收集器是否带有空间压缩整理能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法时指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针分配内存的情况。解决这个问题有两种可选方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的本地缓存中分配,只有本地缓存区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB可以通过-XX:+/-UseTLAB参数来设定。
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前值TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋值就直接使用,使程序能够访问到这些字段类型所对应的零值。
接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何能找到类的元素信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode方法时才计算),对象的GC分代年龄等信息。这些信息是存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否偏启用偏向锁等,对象头会有不同的设置方式。
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从Java程序的角度来看,对象创建才刚刚开始——构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中的new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此。)。new指令之后会接着执行()方法,按照程序员的意愿对对象进程初始化,这样一个正真可用的对象才算完全被构造出来。

2.3.2 对象的内存布局

在HotSpot虚拟机里,对象在堆内存中存储布局可以划分为三个部分:对象头(header)、实例数据(Instacne Data)和对齐填充(Padding)。
HotSpot虚拟机对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈希码(HashCode),GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如果对象未被同步锁锁定的状态下,Mark Workd的32个比特存储空间中的25个比特用来存储对象哈希码,4个比特用来存储对象分代年龄,2个比特用来存储锁标志位,1个比特用来存储固定0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表。

对象头的另外一部分类型是指针,即对象指向的它的类型元数据的指针,Java虚拟机通过这个指针来确定对象是哪个类的实例。并不是所有虚拟机都必须在对象上保留指针类型。,换句话说,查找对象的元数据信息并不一定要经过对象本身。此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数据长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

接下来实例数据部分是对象真正存储的有效信息,即我们在程序中代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来,这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序是longs/doubles 、ints、shorts/char、bytes/booleans、opps ,从以上默认的分配策略可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类定义的变量会出现子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以省出一点点空间。
对象的第三部分是对其填充,这并不是必然存在的,也没有什么特别的含义,它仅仅是起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好8字节的倍数,因此,如果没有对其的话,就需要通过对其填充来补全。

2.3.3 对象的访问定位

创建对象自然是为了后续使用该对象,我们的java程序会通过栈上的reference数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位,访问到该堆中的具体位置,所以对方访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  • 如果使用句柄访问的话,Java堆中将可能划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄地址包含了对象实例数据与类型数据各自具有的地址信息。
  • 如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的haul,就不需要多一次间接访问的开销。

这两种对象的访问方式各有优势,使用句柄来访问最大的好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集器移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省一次指针定位的时间开销,由于对象在访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问。

2.4 实战:OutOfMemoryError异常

在《Java虚拟机规范》 的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能。

2.4.1 Java堆溢出

Java堆用于存储对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出。
代码清单中限制堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展)。参数-XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机出现内存溢出的时候Dump出当前内存堆转存储快照以便进行分析。

  1. /**
  2. * @author MI
  3. * @version 1.0
  4. * @date 2021/5/16 23:27
  5. * VM Args :-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:/tmp/dump_OOME.hprof
  6. */
  7. public class HeapOOM {
  8. static class OOMObject {
  9. }
  10. @Test
  11. void heapOOm(){
  12. final ArrayList<OOMObject> list = new ArrayList<>();
  13. while (true) {
  14. list.add(new OOMObject());
  15. }
  16. }
  17. }
  1. Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
  2. at com.study.jvm.char02.HeapOOM.heapOOm(HeapOOM.java:23)

image.png

要解决这个内存这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(例如Eclipse Memory Analyzer)对Dump出来的堆转存储为快照进行分析。第一步首先确认内存中导致OOM的对象时候是必要的,也要先分清楚到底是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
https://www.eclipse.org/mat/downloads.php
如果是内存泄漏,可以进一步通过工具查看泄漏对象到GC Roots的引用链。找到泄漏对象是通过怎样的引用路劲、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它们到GC Roots引用链的信息,一般可以比较准确地定位到这些对象穿件的位置,进而找出产生内存泄漏的代码的具体位置。
如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。在从代码上检查是否存在某些对象生命周期过长、持有状态时间长、存储设计不合理等情况,尽量减少程序运行期的内存消耗。

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

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

  1. 如果线程请求的栈深度大于虚拟机锁所允许的最大深度,将抛出StackOverflowError异常。
  2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。
为了验证这点,做两个实验,先将实验范围限制在单线程中操作,尝试下面两种行为是否能让HotSpot虚拟机产生OutOfMemoryError异常:

  1. 使用-Xss参数减少栈内存容量。

结果抛出StackOverflowError异常,异常出现时输出的栈深度相应减少。

  1. 定义了大量的本地变量,增大次方法栈中本地变量表的长度

结果抛出StackOverflowError异常,异常出现时输出的栈深度相应减少。

首先测试第一种情况:

  1. /**
  2. * @author study
  3. * @version 1.0
  4. * @date 2021/5/17 16:40
  5. * VM Args -Xss128k
  6. */
  7. public class JavaVMStackSOF {
  8. private int stackLength=1;
  9. public void stackLeak(){
  10. stackLength++;
  11. stackLeak();
  12. }
  13. @Test
  14. void test(){
  15. final JavaVMStackSOF oom = new JavaVMStackSOF();
  16. try {
  17. oom.stackLeak();
  18. } catch (Throwable e) {
  19. System.out.println("stack lentgh:"+oom.stackLength);
  20. e.printStackTrace();
  21. }
  22. }
  23. }
  1. stack lentgh:18758
  2. java.lang.StackOverflowError

对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这取决于操作系统内存分页大小。譬如上述方法中的参数-Xss128k可以正常用于32位Windows系统下的JDK6,但是如果用于64位Windows系统下的JDK11,则会提示栈容量最小不能低于180K,而Linux下这个值可能是228K,如果低于这个最小限制,HotSpot虚拟机启动时会给出提示

The Java thread stack size specified is too small. Specify at least 180k

  1. public class JavaVMStackSOF2 {
  2. private int stackLength = 0;
  3. public void test() {
  4. long unused1, unused2, unused3, unused4, unused5,
  5. unused6, unused7, unused8, unused9, unused10,
  6. unused11, unused12, unused13, unused14, unused15,
  7. unused16, unused17, unused18, unused19, unused20,
  8. unused21, unused22, unused23, unused24, unused25,
  9. unused26, unused27, unused28, unused29, unused30,
  10. unused31, unused32, unused33, unused34, unused35,
  11. unused36, unused37, unused38, unused39, unused40,
  12. unused41, unused42, unused43, unused44, unused45,
  13. unused46, unused47, unused48, unused49, unused50,
  14. unused51, unused52, unused53, unused54, unused55,
  15. unused56, unused57, unused58, unused59, unused60,
  16. unused61, unused62, unused63, unused64, unused65,
  17. unused66, unused67, unused68, unused69, unused70,
  18. unused71, unused72, unused73, unused74, unused75,
  19. unused76, unused77, unused78, unused79, unused80,
  20. unused81, unused82, unused83, unused84, unused85,
  21. unused86, unused87, unused88, unused89, unused90,
  22. unused91, unused92, unused93, unused94, unused95,
  23. unused96, unused97, unused98, unused99, unused100;
  24. stackLength++;
  25. test();
  26. unused1 = unused2 = unused3 = unused4 = unused5 =
  27. unused6 = unused7 = unused8 = unused9 = unused10 =
  28. unused11 = unused12 = unused13 = unused14 = unused15 =
  29. unused16 = unused17 = unused18 = unused19 = unused20 =
  30. unused21 = unused22 = unused23 = unused24 = unused25 =
  31. unused26 = unused27 = unused28 = unused29 = unused30 =
  32. unused31 = unused32 = unused33 = unused34 = unused35 =
  33. unused36 = unused37 = unused38 = unused39 = unused40 =
  34. unused41 = unused42 = unused43 = unused44 = unused45 =
  35. unused46 = unused47 = unused48 = unused49 = unused50 =
  36. unused51 = unused52 = unused53 = unused54 = unused55 =
  37. unused56 = unused57 = unused58 = unused59 = unused60 =
  38. unused61 = unused62 = unused63 = unused64 = unused65 =
  39. unused66 = unused67 = unused68 = unused69 = unused70 =
  40. unused71 = unused72 = unused73 = unused74 = unused75 =
  41. unused76 = unused77 = unused78 = unused79 = unused80 =
  42. unused81 = unused82 = unused83 = unused84 = unused85 =
  43. unused86 = unused87 = unused88 = unused89 = unused90 =
  44. unused91 = unused92 = unused93 = unused94 = unused95 =
  45. unused96 = unused97 = unused98 = unused99 = unused100 = 0;
  46. }
  47. @Test
  48. void runTest() {
  49. test();
  50. }
  51. }
  1. java.lang.StackOverflowError

实验结果表明:无论是由于栈帧太大还是虚拟机容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。可以如果在允许动态扩展容量大小的虚拟机上,相同代码则会导致不一样的情况。

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

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从JDK7开始逐步“去永久代”的计划,并在JDK8中完全使用元空间来实现方法区。
String::intern()是一个本地方法,它的作用是如果字符串常量池中已经波包含了一个等于次String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK6以及之前的HoTSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可见见限制其中常量池的容量。

  1. import java.util.HashSet;
  2. import java.util.Set;
  3. /**
  4. * @author study
  5. * @version 1.0
  6. * @date 2021/5/17 17:34
  7. * VM Args -XX:PermSize= 最小尺寸,初始分配
  8. * -XX:MaxPermSize= 最大允许分配尺寸,按需分配
  9. * -XX:PermSize=6M -XX:MaxPermSize=6M
  10. */
  11. public class RuntimeConstantPoolOOM {
  12. public static void main(String[] args) {
  13. //使用Set保持着常量池引用,避免Full GC回收
  14. Set<String> set = new HashSet<>();
  15. short i = 0;
  16. while (true) {
  17. set.add(String.valueOf(i++).intern());
  18. }
  19. }
  20. }

Java7或者更高版本的JDK来运行这段程序并不会和JDK6得到相同的结果,无论是在JDK7中继续使用-XX:MaxPermSize参数或者在JDK8及以上版本使用-XX:MaxMetaspaceSize参数把方法区容量同样限制在6MB,也都不会出现JDK6中的异常信息,循环将一直进行下去,永不停歇。出现这种情况,是因为JDK7起,原本存放在永久代的字符串常量池被移至Java堆之中,所以在JDK7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时使用-Xmx参数限制最大堆到6MB可以看到两种运行结果之一,

12 Java内存模型与线程