第一章: 走近Java
第二章: Java内存区域与内存溢出异常
C、C++程序开发人员在内存管理领域,即拥有每一个的"所有权",又担负着每一个对象声明从开始到终结的维护责任。
Java程序开发人员在虚拟机自动内存管理机制帮助下,不需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题,但也因此一旦出现这方面问题如果不了解虚拟机怎样使用内存的,那排查错误、修正问题变得异常艰难。
Java虚拟机再执行Java程序的过程中会把管理的内存划分为若干不同的数据区域(《Java虚拟机规范》的规定)。这些区域有各自用途,以及创建和销毁的时间等,如图所示
程序计数器是一块较小的内存空间, 可以看做是当前线程所执行的字节码的行号指示器。即字节码解释器工作时就是改变这个计数器的值来选取下一条需要执行的字节码指令,是程序控制流的指示器,分支、循环、跳转、异常处理、线程处理等基础功能依赖该计数器完成。
线程私有的内存区域,每个线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储(这样设计的原因是因为在任何一个确定的时刻,一个处理器只会执行一条线程中的指令,为了线程切换后可以恢复到正确的执行位置)
此区域是唯一一个在虚拟机规范中没有规定任何OutMemoryError情况的区域
线程私有的内存区域,生命周期与线程相同
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,每个方法被调用直至执行完毕的过程就对应着一个栈帧在虚拟机中从出栈到入栈的过程。
局部变量表存放编译期间可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和rerurnAddress类型(指向一条字节码指令的地址),这些数据类型在局部变量表中的存储空间以变量槽(Slot)来表示(64位长度的double和long类型会占用两个变量槽,其余类型占用一个),局部变量表所需内存空间在编译期间完成分配,方法运行期间不会改变局局部变量表的大小(这里的大小指变量槽的数量)
线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
如果Java虚拟机栈容量支持动态扩展,当栈扩展时无法申请到足够内存时抛出OutOfMemoryError异常(Hotspot虚拟机不支持动态扩展)
与虚拟机栈发挥作用类型,区别在于虚拟机栈为虚拟机执行java方法(字节码)服务,本地虚拟机栈为虚拟机使用的Native方法服务。
同虚拟机栈,本地方法栈也会在栈深度溢出或栈扩展失败时分别抛出StackOverflowError异常和OutOfMemoryError异常
Java堆是虚拟机所管理内存中最大的一块,被所有线程共享,在虚拟机启动时创建。此区域存在的唯一目的就是存放对象实例(Java世界里几乎所有的对象实例及数组都应当在堆中分配,这里的几乎是从实现角度来看,未来可能会出现值类型的支持,以及现在即时编译器的一些类似于栈上分配、标量替换等优化手段)
Java堆是垃圾回收器管理的内存区域,从回收内存角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以经常出现“Java虚拟机的堆内存分为新生代、老年代、永久代。。。”这样的内容,这些区域仅是一部分垃圾收集器的共同特性或设计风格,并非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》对Java堆的进一步细致划分。
Java堆支持实现固定大小或可扩展。当前主流虚拟机可扩展实现(参数-Xmx和-Xms设定),如果堆中没有内存完成对象分配且无法扩展时,抛出OutOfMemoryError异常。
线程共享的内存区域。用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存数据等。《Java虚拟机规范》中描述方法区为堆的一个逻辑部分,但它有一个别名叫“非堆(Non-Heap)”,目的是与Java堆区分开。
JDK7及之前版本方法区的实现是永久代Permanent Generation(这里仅指Hotspot虚拟机,其他诸如BEA JRockit、IBM J9是不存在永久代概念的),永久代的设计使Hotspot的垃圾收集器能像管理堆一样管理这部分内存,也省去了专门为方法区编写内存管理代码的工作,但这样的设计也导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而JRockit和J9只要没有触碰到进程可用内存的上限,例如32位系统的4GB限制,就不会有问题),而且极少数的方法(例如String::intern())会因永久代的实现导致不同虚拟机下有不同表现。所以JDK7的HotSpot将存储在永久代的部分数据转移到了Java Heap堆内存或者是 Native Heap本地内存。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了Native Heap;字面量(interned strings)转移到了Java Heap;类的静态变量(class statics)转移到了Java Heap。
JDK8的Hotspot中彻底取消永久代,改用与JRockit、J9一样在本地内存中实现的元空间Meta- space来代替。
垃圾收集行为在方法区较少出现,它回收的目标主要是针对常量池的回收和对类型的卸载。若方法区无法满足新的内存分配时抛出OutOfMemoryError异常。
运行时常量池是方法区的一部分。在Class文件中除了类的版本号、方法、字段、接口等描述信息以外,还有一项常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。
Java虚拟机对Class文件的每一个部分(包含常量池)的格式有严格规定,如每一个字节用于存储那种数据类型 都必须符合规范上的要求才会被虚拟机认可、加载、执行,但对于运行时常量池《Java虚拟机规范》并没有坐任何细节要求。
运行时常量池相较于Class文件常量池的另一个重要特征是具备动态性,Java语言并不要求常量只有编译期生成,即并非只有预置Class文件中常量池的内容才能进入方法区的运行时常量池,运行期间也可以将新的常量加入池中(如String类的intern())
直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》定义的内存区域。
JDK1.4中新加入NIO类,引入一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,使用Native直接分配堆外内存,然后通过一个存储Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作,避免在Java堆和Native堆中来回复制拷贝数据,显著调高了性能。
直接内存不会受到Java堆内存的限制,但是会受到本机总内存(包括物理内存、SWAP分页或分页文件)大小及处理器寻址空间的限制,所以我们一般配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,导致动态扩展时出现OutOfMemoryError异常。
①、Java虚拟机遇到字节码new指令时,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的列是否已被加载、解析和初始化,如果没有则先执行相应的类加载过程。
②、类加载检查通过后虚拟机开始为对象分配内存。对象所需内存大小在类加载完成后就可以确定。为对象分配空间相当于把一块确定大小的内存块从Java堆中划分出来:
如何划分:
假设Java堆中内存是绝对规整的,使用过的内存在一边,空闲的内存在另一边,中间放着一个指针对位分界点的指示器,那分配内存时只需要将指针向空闲内存方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”;
假设Java堆中内存不规整,已使用内存和空闲内存相互交错,这时虚拟机就必须维护一个列表,记录那些内存块是可用的,分配的时候从列表中找到一块足够大的内存块划分给对象实例,并更新列表记录内容,这种方式称为“空闲列表”;
选择哪种内存分配方式由Java堆是否规整决定,而Java堆是否规整又由采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定,因此当使用Serial、ParNew等带有压缩整理过程的收集器时采用的指针碰撞,简单高效,而当使用CMS这种基于清除算法(Sweep)的收集器时,理论上就得用空闲列表来分配内存。
如何保证分配对象内存时并发线程安全(如出现正在对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况):
一种方案是对分配内存空间的动作同步处理(实际上虚拟机是采用CAS配上失败重试的方式保证每次更新操作的原子性);
还有一种方案是内存分配的动作按照线程划分在不同的空间中进行,即每个线程预先在Java堆中先分配一小块内存,称为本地线程缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程对应的本地线程缓冲区中进行分配,只有本地线程缓冲分配区用完,分配新的缓存区时才需要同步锁定(虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数设定)
③、内存分配完成后虚拟机必须将分配到内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时进行(清零操作是因为分配的内存可能还保留着上次分配给其他对象的数据,内存块虽然回收了,但是之前的数据没有被清除,会污染新对象,而且也能保证对象的实例字段在Java代码中不赋初始值就能直接使用),接下来Java虚拟机还会对对象的对象头信息进行一些设置(这个对象是那个类的实例、如何找到类的元数据信息、对象的哈希吗、对象的GC分代年龄等信息)
④、上面的工作从虚拟机的视角看一个对象已经产生了,但从Java程序的视角看对象创建才刚刚开始----构造函数,即Class文件中()方法还没有被执行,所有字段为默认的零值。一般来说(由字节码流中new指令后是否跟随invokespecial指令所决定),new指令之后会接着执行init()方法对对象初始化
Hotspot字节码解释器代码片段
// 确保常量池中存放的是已解释的类
if (!constants->tag_at(index).is_unresolved_klass())
{
// 断言确保是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 instanceKlass");
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);
retry:
}
if (result == NULL)
{
need_zero = true;
// 直接在eden中分配对象
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);
}
}
}
在Hotspot虚拟机中,对象在堆内存中由对象头(Header)、实例数据(Instance Data)和对齐填充组成。
对象头包含两类信息:
第一部分是对象自身运行时数据,如哈希码(HashCode)、GC分代年龄、锁标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据长度在32位和64位虚拟机中(未开启压缩指针)分别为32比特和64比特,官方称为“Mark Word”,由于对象要存储的运行时数据很多,所以Mark Word被设计成动态的数据结构,根据对象状态来复用自己的存储空间。
第二部分是类型指针,及对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(并不是所有虚拟机实现都必须在对象数据上保留类型指针),如果对象是一个数组,那么在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据信息确定对象的大小,但是如果数组长度是不确定的,将无法通过元数据推断数组大小。
实例数据是对象真正存储的有效信息,即我们在代码里定义的各种类型的字段内容。
对象填充并不是必然存在的,也没有特别的意义,仅仅是占位符的作用(由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即任何对象的大小都必须是8字节的整数倍,对象头部分已经被精心设计成8字节的倍数,因此对象实例数据部分没有对齐的话,就需要通过对其填充来补全)
Java程序通过栈上的referenc数据来操作堆上的具体对象。由于reference类型在《Java虚拟机规范》中只规定了它是一个指向对象的引用,并没有定义这个引用通过何种方式定位、访问到堆中对象的具体位置。所以对象访问方式是由虚拟机实现而定,主流的访问方式有两种:
句柄访问
Java堆中划分出一块内存作为句柄池,栈上的reference存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
直接指针访问
Java堆中对象内存布局自己考虑如何放置访问类型数据的相关信息,reference中存储的就直接是堆中对象的地址,如果访问对象的话,就不需要多一次间接访问的开销
两种访问方式各种优劣,句柄访问方式最大的好处是reference中存储的是稳定句柄地址,在对象被移动(GC垃圾收集)时只会改变句柄中的实例数据指针,而Reference本身不需要改变;直接指针访问最大好处是速度更快,节省一次指针定位的时间开销(Hotpsot虚拟机主要采用直接指针访问方式,Shenandoah收集器的话也会有一次额外的转发)
堆用于存放对象实例,只要不断地创造对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制回收这些对象,那么随着对象数量的不断增加,总容量触及最大堆容量限制后就会产生内存溢出异常。
如何模拟堆溢出异常:
限制Java堆的大小为20M且不可扩展(设置堆的最小值-Xms和最大值-Xmx参数设置一样为20M即可避免堆自动扩展),设置-XX: +HeapDumpOnOutOf-MemoryError让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照便于分析,即设置JVM启动参数-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
这里笔者使用JDK8自带的jvisualvm工具分析(本地安装jdk的bin目录下)
首先分析内存中导致OOM的对象是否是必须的,即判断是内存泄漏还是内存溢出。
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾回收器无法回收他们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以准确定位到对象创建的位置,从而找出内存泄漏的具体代码位置
如果是内存溢出,即内存中的对象都是必须存活的,那么就应该检查Java虚拟机的堆参数(-Xms堆最小值、-Xmx堆最大值),与机器的内存对比,分析是否还有向大调整的空间,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等,尽量减少程序运行期的内存消耗
Hostpost虚拟机中并不区分虚拟机栈和本地方法栈。JDK8栈容量可以通过-Xss参数设置
关于虚拟机栈和本地方法栈《Java虚拟机规范》描述了两种异常:
1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时将抛出OutOfMemoryError异常(Hotpost虚拟机不支持栈动态扩展)
单线程验证StackOverflowError异常:
还有一种是方式通过不断创建多线程在Hotpsot虚拟机上产生内存溢出异常,但这样产生的内存溢出和栈内存空间是否足够并不产生任何直接的关系,主要取决于操作系统本身的内存使用状态(操作系统给每个进程的内存是有限的,进程的内存减去最大堆容量减去最大方法区容量,程序计数器消耗内存小可以忽略不计,再减去直接内存和虚拟机本身耗费的内存,剩下的内存就由虚拟机栈和本地方法栈分配,因此每个线程分配的占内存越大,可以建立的线程数就越少,就越容易把剩下内存耗尽)
!!!在Window平台的虚拟机中,Java的线程是映射到操作系统的内核线程,无限制地创建线程可能会导致操作系统假死,谨慎!!!(所以我这里没有试验)
在这种不能减少线程数量的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程
使用String.intern()模拟运行时常量池溢出:intern()是本地Native方法,作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用,否则会将此String对象包含的字符串添加到常量池中,返回此String对象的引用。
直接内存的容量可以通过-XX:MaxDirectMemortSize参数指定,不指定默认与Java堆最大值(-Xmx)一致。
代码示例通过反射的方式越过DirectByteBuffer获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()
直接内存导致的内存溢出明显的一个特征就是Heap Dump文件中不会有什么明显的异常情况(如果日常中发现内存溢出后的Heap Dump文件很小,而程序中又直接或间接使用了DirectMemory(例NIO),那就需要检查一下直接内存方面的原因了)
为什么汇编语言不能越过操作系统操控硬件?
JVM源码分析之java对象头实现
JVM之创建对象源码分析
JVM之模板解释器
十种JVM内存溢出的情况,你碰到过几种?
JDK8官方JVM参数选项
一个Java对象到底占多少内存