CH02-内存区域与溢出
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
2.1 概述
在 C/C++ 中,开发人员即拥有每一个对象的“所有权”,又负担着每个对象生命开始到终结的维护责任。
在 Java 中,在虚拟机自动内存管理机制的帮助下,开发人员不需要为每个 new 操作去编写与之配对的 delete/free 代码,不容易出现内存泄露和内存溢出问题。
2.2 运行时数据区域
JVM 在执行 Java 程序时会将它所管理的内存划分为不若干个不同的数据区域。这些区域都有各自的用途、创建和销毁实现,有的区域随着虚拟机进程的启动而存在,有些则依赖于用户线程的启动和结束而创建和销毁。
2.2.1 程序计数器
程序计数器是一块较小的内存空间,可以被看作是当前线程所执行字节码的“行号指示器”。在虚拟机的概念模型中,字节码解释器工作时就是通过改变该计数器的值来选取下一条要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于该计数器来完成。
由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说指一个核)都只会执行一个条线程中的指令。因此,为了线程切换后恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,该计数器记录的是正在执行的虚拟机字节码指令的地址:如果正在执行的是 Native 方法,该计数器值则为空(undefined)。此内存区域是唯一一个在 JVM 规范中没有规定任何 OOM 情况的区域。
2.2.2 JVM 栈
与程序计数器一样,JVM 栈(stack)也是线程私有的,其声明周期与线程相同。它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应这一个栈帧在 JVM 栈中入栈到出栈的过程。
经常有人把 Java 内存区域分为堆内存和栈内存,这种分类方式比较粗糙,Java 内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的区域是这两块。其中所指的“栈”就是这里所讲的 JVM 栈,或者说是局部变量表部分。
局部变量表存放了编译期可知的各种数据类型(8 种基本类型)、对象引用(引用类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与该对象相关的位置)、returnAddress 类型(指向一条字节码指令的地址)。
其中 64 位长度的 long 和 double 型数据会占用 2 个局部两两空间(slot),其余的数据类型仅占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在 JVM 规范中,对该区域规定了两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出栈溢出异常。
- 如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,将抛出内存溢出异常。
当前大部分虚拟机实现都可以动态扩展,只不过规范中也允许固定长度的虚拟机栈。
2.2.3 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈相似,区别在于:
- 虚拟机栈用于虚拟机执行 Java 方法,为字节码服务。
- 本地方法栈用于为虚拟机本身用到的 Native 方法服务。
虚拟机规范中没有强制规定本地方法栈中方法需要使用的语言、使用方式、数据结构,因此具体的虚拟机可以自由实现。甚至有虚拟机把直接把本地方法栈和虚拟机栈合二为一,如 Sun HotSpot 虚拟机。
可能会抛出栈溢出或内存溢出异常。
Java 堆
Java Heap 是 JVM 所管理内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例,几乎所有的对象实例都在这里分配内存。
规范中的描述为:所有的对象实例和数组都要在堆上分配。但是随着 JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换等优化技术将会带来一些微秒的变化,所有对象都在堆上分配也慢慢变得不那么“绝对”了。
堆是 GC 管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以堆可以被细分为新生代和老年代,再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的堆中可能划分出多个线程私有的分配缓冲区。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储依然都是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
根据虚拟机规范的规定,堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,这类似于磁盘空间。在实现时,既可以实现为固定大小的、也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展的方式来实现的(通过 -Xmx
和 -Xms
控制)。如果在堆中没有内存完成实例分配,并且堆也无法再进行扩展时,将抛出内存溢出异常。
2.2.5 方法区
方法区与堆一样,被各个线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然规范中将方法区描述为堆的一个逻辑部分,但是他却有一个别名叫做“非堆(Non-Heap)”,目的应该是为了与堆进行区分。
对于习惯于在 HotSpot 虚拟机实现上开发的程序员来说,很多人更愿意称方法区为“永久带”,本质上两者并不等价,仅仅是因为 HotSpot 的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久带来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理堆一样来管理这部分内存,这能够省去专门为方法区编写内存管理代码的工作。其他虚拟机(JRockit/J9)则不存在永久带的概念。原则上,如何实现方法区属于虚拟机的实现细节,不受规范的约束,但是用永久带来实现方法区,现在看来并非一个好主意,因为这样更容易遇到内存溢出问题,而且极少有方法会因为这个原因导致不同虚拟机下出现不同的表现。因此对于 HotSpot 来说,根据官方发布的路线图信息,目前也有放弃永久带并逐步改为采用 Native Memory 来实现方法区的规划了。在目前已经发布的 JDK 1.7 中,已经把原本放在永久带的字符串常量池移出。
规范对方法区的限制非常宽松,除了和堆一样不需要连续的内存、可以选择固定大小和可扩展之外,还可以选择实现垃圾收集机制。相对堆而言,垃圾收集行为在该区域较少出现,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。该区域的内存回收目标主要是针对常量池和类的卸载,一般来说,该区域的回收“成绩”比较难以令人满意,尤其是对类的卸载,条件相当苛刻,但是该区域的回收是确实必要的。
根据规范规定,当方法区无法满足内存分配需求时,将抛出内存溢出异常。
2.2.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项就是常量池,用于存放在编译期生成的各种字面量和符合引用,该部分内容将在类加载后被存放到方法区的运行时常量池。
JVM 对 Class 文件的各个部分的格式都有严格的规定,每个字节用于存放哪种数据都必须符合规范中的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,规范中没有任何细节的要求,不同的供应商实现的虚拟机可以按照自己的需求来实现该内存区域。但一般来说,除了保存 Class 文件中描述的符号引用之外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 并不要求常量只能在编译期产生,也就是并非预置入 Class 文件中常量池的内存才能进入方法区运行时常量池,运行区间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是 String 类的 intern()
方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池中无法申请到内存时将抛出内存溢出异常。
2.2.7 直接内存
直接内存并非虚拟机运行时数据的一部分,也不是 JVM 规范中定义的内存区域。但是这部分内存也被频繁的使用,而且可能导致内存溢出异常,因此有必要介绍。
在 JDK 1.4 中引入例如 NIO 类,引入了一种基于 Channel 和 Buffer 的 IO 方式,可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提升性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机内存的分配不会受到 Java 堆大小的限制,但是会收到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx
等参数信息,但经常会忽略直接内存,使得各个内存区域的总和大于实际的物理内存限制,从而导致扩展时出现内存溢出异常。
2.3 HotSpot 对象探秘
2.3.1 对象的创建
虚拟机遇到一条 new 指令时,首先回去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否已经被加载、解析且初始化。如果没有,必须首先执行对相关类的加载过程。
如果类加载检查通过,接下来虚拟机将为新生对象分配内存。对象所需要的内存大小在类加载时便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从堆中划分出来。假设堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,所分配的内存就仅仅是把该指针向空闲空间一侧挪动一段与新生对象的大小相等的距离,这种分配方式成为“指针碰撞”。如果堆中的内存是不规整的,已用内存与空闲内存相互交错,虚拟机就需要维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间来分配给新生对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。堆是否规整来决定了使用哪种分配方式,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能来决定。因此,在使用 Serial、ParNew 等带有 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 MarkSweep 算法的收集器时,通常会采用空闲列表方式。
除了如何划分可用空间之外,还有另外一个需要考虑的问题是,在虚拟机中对象创建是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决该问题有两种方案,一种是对内存分配动作进行同步——实际上虚拟机采用 CAS 加上失败重试的方式保证更新操作的原子性;另一种方式是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称之为本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步。虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB
参数来设定。
内存分配完成后,虚拟机需要将分配的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一过程也可以被提前至分配 TLAB 时进行。该操作保证了对象的实例字段在 Java 代码中可以不赋值初始值就能直接被使用,程序将访问到这些字段的类型所对应的零值。
接下来,虚拟机要对对象重新进行必要的设置,比如该对象是哪个类的实例、如何才能找到类的元信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁,对象头会有不同的设置方式。
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的角度来看,对象创建才刚刚开始——init
方法还没有执行,所有的字段都还为零值。所以,一般来说(根据字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 init
方法,把对象安装程序员的意愿进行初始化,这样一个真正可用的对象才算创建完成。
下面的代码清单是 HotSpot 中 butecodeInterpreter.cpp
的片段:
// 确保常量池中存放的是已解释的类
if(!constants->tag_at(index).is_unresolved_kclass()) {
// 断言确保是 klassOop 和 instanceKlassOop
oop entry = (klassOop)*constants->obj_at_addr(index);
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry=(klassOop)entry;
assert(k_entry->klass_part()->oop_is_instance(), "Should be instan
instanceKlass * ik=(instanceKlass*)k_entry->klass_part();
// 确保对象所属类型已经经过初始化阶段
if(ik->is_initialized()&&ik->can_be_fastpath_allocated()) {
//取对象长度
size_t obj_size=ik->size_helper();
oop result=NULL;
//记录是否需要将对象所有字段置零值
bool need_zero=!ZeroTLAB;
//是否在TLAB中分配对象
if(UseTLAB){
result=(oop)THREAD->tlab().allocate(obj_size);
}
if(result==NULL){
need_zero=true;
//直接在eden中分配对象
retry:
HeapWord * compare_to=*Universe:heap()->top_addr();
HeapWord * new_top=compare_to+obj_size;
/*cmpxchg是x86中的CAS指令, 这里是一个C++方法, 通过CAS方式分配空间, 如果并发失败, 转到retry中重试, 直至成功分配为止*/
if(new_top<=*Universe:heap()->end_addr()){
if(Atomic:cmpxchg_ptr(new_top,Universe:heap()->top_addr(), compare_to)!=compare_to){
goto retry;
}
result=(oop)compare_to;
}
}
if(result!=NULL){
//如果需要, 则为对象初始化零值
if(need_zero){
HeapWord * to_zero=(HeapWord*)result+sizeof(oopDesc)/oopSize;
obj_size-=sizeof(oopDesc)/oopSize;
if(obj_size>0){
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
//根据是否启用偏向锁来设置对象头信息
if(UseBiasedLocking){
result->set_mark(ik->prototype_header());
}else{
result->set_mark(markOopDesc:prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
//将对象引用入栈, 继续执行下一条指令
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
2.3.2 对象的内存布局
在 HotSpot 中,对象在内存中存储的布局可以分为三个区域:
- 对象头
- 实例数据
- 对齐填充
对象头包括两部分。第一部分用于存储对象自身的运行时数据,如哈希值、GC 年龄分代、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 位和 64 位,官方称其为 “Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。如,在 32 位的 HotSpot 中,如果对象处于未被锁定的状态下,那么 MarkWord 的 32 位存储空间中的 25 位用于存储对象哈希值,4位用于存储对象年龄分代,2位用于存储标志位。1位固定为0,而在其他状态(轻量级锁、重量级锁、GC 标记、可偏向)下对象的存储内容见下表:
对象的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针来确定这个对象的所属的类。并不是所有虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个数组,拿在对象头中还必须有一块用于记录数组长度的信息,因为虚拟机可以通过普通 Java 对象的元数据信息来确定 Java 的对象的大小,但是从数组的元数据中无法确定数组的大小。
代码清单 2-2 为 HotSpot 中 markOop.cpp
的代码片段,描述了 32 位下 MarkWord 的存储状态:
//Bit-format of an object header(most significant first,big endian layout below):
//32 bits:
//--------
//hash:25------------>|age:4 biased_lock:1 lock:2(normal object)
//JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2(biased object)
//size:32------------------------------------------>|(CMS free block)
//PromotedObject*:29---------->|promo_bits:3----->|(CMS promoted object)
接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承而来,而是在子类中定义的,都需要记录下来。这部分的存储顺序会收到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 代码中定义顺序的影响。HotSpot 默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。从分配策略可看出,相同宽度的字段总是会被分配在一起。在满足该前提条件的情况下,在父类中定义的字段会出现在子类定义的字段之前。如果 CompactFields 参数为真(默认为真),那么子类中较窄的字段也可能会插入到父类字段的空隙中。
第三部分对齐填充并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。由于 HotSpot 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,即对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节数的倍数,因此当对象实例数据部分没有对齐时,就需要对齐填充来补全。
2.3.3 对象的访问定位
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在虚拟机规范中国之规定了一个指向对象的引用,并没有定义该引用应该通过哪种方式去定位、访问堆中对象的具体位置,所以对象访问的方式也取决于具体虚拟机的实现。目前主流的方向是使用句柄或直接指针。
如果使用句柄来访问,那么堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如下图:
如果使用直接指针访问,那么堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。如下图:
这两种对象访问方式各有优势,句柄方式访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(如垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
直接指针的方式的最大好处就是速度快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的 HotSpot 而言,它使用直接指针的方式来访问对象,但从整体软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
2.4 实战内存溢出异常
2.4.1 堆溢出
堆用于存储对象实例,只要不断创建对象,并且保证 GC Roots 到对象之间的存在可达路径来避免 GC 清除这些对象,那么在对象数量达到堆的最大容量时就会产生堆内存溢出异常。
/**
*VM Args:-Xms20m-Xmx20m-XX:+HeapDumpOnOutOfMemoryError
*@author zzm
*/
public class HeapOOM{
static class OOMObject{
}
public static void main(String[]args){
List<OOMObject>list=new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}
要解决该区域的内存溢出异常,一般的手段是先通过内存映像分析工具(如 MAT)对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,即要分清楚到底是出现了内存泄露还是内存溢出。
如果是内存泄露,可以进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与 RC Roots 相关联并导致垃圾收集器无法将其回收。掌握了泄露对象的类型信息及其 GC Roots 引用链就能比较准确的定位出泄露代码的位置。
如果不存在泄露,就是内存中的对象确实都是需要存活的,那么就应当检查虚拟机的堆参数,与机器物理内存相比是否还能调大,从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
2.4.2 虚拟机栈和本地方法栈溢出
由于在 HotSpot 中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说虽然 -Xoss
参数(用于设置本地方法栈的大小)存在,但实际上是无效的,栈容量仅能通过 -Xss
参数来设定。关于虚拟机栈和本地方法栈,JVM 规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出栈溢出异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出内存溢出异常。
这里把异常分为两种情况看似更加严谨,但却存在着一些互相重叠的地方:当占空间无法继续分配时,到底是内存太小、还是已使用的栈深度太大,其本质上只是对同一件事情的两种描述而已。
在笔者的实验中,将实验范围限定于单线程的操作中,尝试了下面两种方式均无法然虚拟机产生内存溢出异常,尝试的结果都是栈溢出异常:
- 使用
-Xss
参数减少栈内存容量,结果抛出栈溢出异常,异常出现时输出的栈深度相应缩小。 - 定义了大量的本地变量,以增大此方法帧中本地变量表的长度。结果抛出栈溢出异常时输出的栈深度相应缩小。
/**
*VM Args:-Xss128k
*@author zzm
*/
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;
}
}
}
实验结果表明,在单个线程中,无论是由于栈帧太大还是虚拟机栈容量太小,当无法分配内存的时候,抛出的都是栈溢出异常。
如果测试时不限于单线程,通过不断创建线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间十分足够大并不存在任何联系,或者准确的说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
具体原因不难理解,操作系统分配给每个进程的内存是有限制的,比如 32 位的 Windows 限制为 2GB。虚拟机提供了参数来控制 Java 堆和方法区的内存最大值。剩余的内存为 2GB 减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小可以忽略。如果虚拟机进程本身消耗的内存不算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以创建的线程数量就越少,创建新的线程时就越容易把剩下的内存耗尽。
这一点读者需要在开发多线程的应用时特别注意,出现栈溢出异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。
代码清单 2-5 创建线程导致内存溢出异常:
/**
*VM Args:-Xss2M(这时候不妨设置大些)
*@author zzm
*/
public class JavaVMStackOOM{
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)throws Throwable{
JavaVMStackOOM oom=new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
2.4.3 方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前面提到JDK 1.7开始逐步“去永久代”的事情,在此就以测试代码观察一下这件事对程序的实际影响。
String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 -XX:PermSize
和 -XX:MaxPermSize
限制方法区大小,从而间接限制其中常量池的容量,如代码清单 2-6 所示。
/**
*VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M
*@author zzm
*/
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
//使用List保持着常量池引用, 避免Full GC回收常量池行为
List<String>list=new ArrayList<String>();
//10MB的PermSize在integer范围内足够产生OOM了
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
而使用 JDK 1.7 运行这段程序就不会得到相同的结果,while 循环将一直进行下去。关于这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,如代码清单 2-7 所示。
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
public static void main(String[]args){
String str1=new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern()==str1);
String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern()==str2);
}
}
}
这段代码在 JDK 1.6 中运行,会得到两个 false,而在 JDK 1.7 中运行,会得到一个 true 和一个 false。产生差异的原因是:在 JDK 1.6中,intern() 方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder 创建的字符串实例在 Java 堆上,所以必然不是同一个引用,将返回false。而 JDK 1.7(以及部分其他虚拟机,例如JRockit)的 intern() 实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此 intern() 返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对 str2 比较返回 false 是因为"java"这个字符串在执行 StringBuilder.toString() 之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回 true。
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用 Java SE API 也可以动态产生类(如反射时的 GeneratedConstructorAccessor 和动态代理等),但在本次实验中操作起来比较麻烦。在代码清单 2-8中,笔者借助 CGLib 直接操作字节码运行时生成了大量的动态类。
/**
*VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M
*@author zzm
*/
public class JavaMethodAreaOOM{
public static void main(String[]args){
while(true){
Enhancer enhancer=new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor(){
public Object intercept(Object obj,Method method,Object[]args,MethodProxy proxy)throws Throwable{
return proxy.invokeSuper(obj,args);
}
});
enhancer.create();
}
}
static class OOMObject{
}
}
值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如 Spring、Hibernate,在对类进行增强时,都会使用到 CGLib 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的 Class 可以加载入内存。另外,JVM 上的动态语言(例如Groovy等)通常都会持续创建类来实现语言的动态性,随着这类语言的流行,也越来越容易遇到与代码清单 2-8 相似的溢出场景。
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了 CGLib 字节码增强和动态语言之外,常见的还有:大量JSP 或动态产生 JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
2.4.4 本机直接内存溢出
DirectMemory 容量可通过 -XX:MaxDirectMemorySize
指定,如果不指定,则默认与 Java 堆最大值(-Xmx指定)一样,代码清单 2-9 越过了 DirectByteBuffer 类,直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe() 方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory()
。
/**
*VM Args:-Xmx20M-XX:MaxDirectMemorySize=10M
*@author zzm
*/
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);
}
}
}
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果读者发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,那就可以考虑检查一下是不是这方面的原因。
2.5 本章小结
通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然 Java 有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,第3章将详细讲解 Java 垃圾收集机制为了避免内存溢出异常的出现都做了哪些努力。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.