《深入理解jvm》day3:自动内存管理机制之jvm中数据细节
1.3.hotSpot VM对象布局细节
在了解完jvm的内存情况后,即了解内存中存放了些什么内容。
接下来,想了解下内存中数据的具体细节,如:如何创建对象、如何布局以及如何访问。
这些细节问题,需要把范围限定在Hotspot虚拟机和java堆中。
1.3.1.对象创建
在java语言中,通过new关键字,表示创建一个对象。对应到VM中,对象创建是一个什么样的过程呢?
jvm遇到一个new指令,首先会去检查指令的参数,是否能在常量池中定位到一个类的符号引用,并检查符号引用代表的类是否已被加载、解析和初始化过。
如果没有,必须要先执行相应的类加载过程。
在类加载检查通过后,jvm将为新生对象分配内存。加载完成后,为对象分配内存空间,从java堆中划分出来一块区域。如果java堆中内存是绝对规整的(用过内存和空闲内存各在一边,中间放指针作为分界点指示器),分配内存就是把指针往空闲空间挪动出和对象大小的空间,这种分配方式称“指针碰撞”。
如果java堆中内存并不规整(已使用内存和空闲内存相互交错),jvm就需要维护一个列表(记录可用内存块),为对象分配内存时,从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式叫做“空闲列表”。选择哪种分配方式----->由java堆是否规整决定----->又由所采用的垃圾收集器是否带有压缩整理功能决定。
如:使用Seria、ParNew等带Compact过程的收集器时,采用指针碰撞方式;而使用CMS这种基于Mark-Sweep算法的收集器,通常采用空闲列表。
除了考虑如何划分可用空间外,因为jvm中创建对象是很频繁的,即使只修改一个指针所指向的位置,在并发情况下也可能出现线程安全问题。比如:可能出现正在给对象A分配内存,指针还未来得及修改,对象B又同时使用原来的指针分配内存的情况。有两种方案解决:
一是对分配内存空间的动作进行同步处理-----实际上jvm采用CAS配上失败重试的方式保证更新操作的原子性。
二是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。只需要在对应线程TLAB上分配内存,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
jvm是否使用TLAB,可以通过-XX:+/-UseTLAB参数来决定。
内存分配完成后,VM会将分配到的内存空间都初始化为零值(不包括对象头),也就是保证对象的字段无需赋初值。根据对应的数据类型初始化零值。
接下来,jvm需要对对象进行必要设置。如:这个对象属哪个类的实例,如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等。这些信息存储在对象的对象头(Object Header)之中。
从jvm的角度看,一个新的对象已经产生。但是,从java程序角度看,对象创建才刚开始。下面会执行<init>方法,程序员对字段重新初始化。
下面的代码片段,用于了解HotSpot的运作过程。
// 确保常量池中存放的是已解释的类
if (!constants->tag_at(index).is_unresolved_klass()) {
// 断言确保是klassOop和instanceKlassOop(这部分下一节介绍)
oop entry = (klassOop) *contants->obj_at_addr(index);
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry = (klassOop) entry;
assert(k_entry->kalss_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);
}
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);
}
}
}
1.3.2.对象的内存布局
在 HotSpot VM中,对象在内存中存储布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包含两部分信息:一是用来存储对象自身的运行时数据,如:哈希码(hashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、编向时间戳等。这部分数据的长度在32位和64位VM中分别为32bit和64bit。官方称它为“Mark Word"。
第二部分,是类型指针。即对象指向它的类元数据的指针。VM通过这个指针来确定对象是哪个类的实例。
如果对象是一个java数据,对象头中还必须有一块用于记录数组长度的数据。
实例数据部分,是对象真正存储的有效信息。也就是程序代码中定义的各种类型的字段内容。
存储顺序决定于VM分配策略参数(FieldsAllocationStyle)和字段在java源码中定义顺序。HotSpot VM默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),相同宽度的字段被分配在一起。
在这个前提下,在父类定义的字段会出现在子类之前。
对齐填充部分,并不一定存在。
HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍。而对象头部分下好是8字节的整数倍,当对象实例部分没有对齐,就需要通过对齐填充补全。
1.3.3.对象访问定位
是什么?怎么用?
在java内存中,栈是用来存储变量或对象引用的,而堆是用来存储具体对象。
java程序通过对象引用来操作堆上的这个具体对象,也就是如何通过对象引用来访问,以何种方式访问具体对象的?
jvm中并没有规定对象的访问方式,取决于虚拟机的实现。目前主流的访问方式有两种:句柄和直接指针。
如果使用句柄,java堆中就会划分一块内存来作为句柄池,java栈中对象引用reference存储的是对象的句柄地址。句柄中包含对象实例数据与类型数据各自的具体地址信息。如下图所示:
如果使用直接指针访问,这种方式reference存储的直接就是对象地址。如果下图所示:
两种方式各有优势:
使用句柄,最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时,移动对象较普遍)时,只会改变句柄中的实例数据指针,而reference本身不需要改变。
而使用直接指针访问,好处是速度快,减少了一次指针定位。
Sun HotSpot使用直接指针方式访问的。
1.3.4.实战:OutOfMemoryError异常
在jvm规范中,除了程序计数器,其它几个内存区域都可能发OOM异常。
通过以下的代码示例,能够明白jvm中各个运行时区域存储的内容。
如果在实际工作中,出现了OOM异常,能够快速定位出是哪个区域出现OOM?知道什么样的代码导致这些区域内存溢出,以及出现异常后如何解决?
可以通过设置jvm参数,来设置内存区域。-verbose:gc -Xms20M -Xmx20M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8
如果使用Eclipse IDE,设置jvm启动参数的方法如下图所示:
1.java堆溢出
java堆用于存储对象实例,只要不断创建对象,当对象数据达到最大堆的容量限制后就会产生溢出异常。
限制java堆大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让jvm出现OOM异常时Dump出当前的内存堆转储快照(方便分析)。
package com.jvm; import java.util.ArrayList; import java.util.List; /** * 测试heap内存溢出 * @author wangfei * -Xms20M -Xmx20M 堆初始大小20M 堆最大大小20M *-verbose:gc -Xms20M -Xmx20M -Xmn20M -XX:+HeapDumpOnOutOfMemoryError */ public class HeapTest { public static void main(String[] args) { List<HeapTest> list = new ArrayList<HeapTest>(); int count = 0; try { while(true) { count ++; list.add(new HeapTest());//不断创建对象实例 } }catch(Throwable e) { System.out.println("创建实例个数:" + count); e.printStackTrace(); } } }
运行结果:
java堆内存的OOM异常是开发中常见的内存溢出异常情况。当出现java堆内存溢出时,异常堆栈信息java.lang.OutOfMemoryError: Java heap space
要解决这个区域的异常:
一般方法是先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。重点确认内存中对象是必要的,也就是先分清到底是内存泄漏(Memory Leak) 还是内存溢出(Memory Overflow)
下面显示Eclipse Memory Analyzer打开的堆转储快照文件, 在上面的console中可以看到生成了java_pid1828.hprof 文件,默认是在该项目的根目录下。刷新后,就可以显示出来。如下图所示:
我们双击打开该文件,发现文件显示乱码。我们需要使用专门的分析工具打开,当打开Eclipse中打开文件后,会提示安装插件来打开文件,如下所示:
下面就点击确定,匹配相应版本的插件,点击install进行安装。
下面就可以对该文件进行分析了。
如果是内存泄露,可进一步通过工具查看泄露对象到GC roots的引用链。就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。通过泄露对象的类型信息及GC Roots引用链的信息,就可以准确地定位出泄露代码的位置。
如果不存在泄露,也就是说内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xms与-Xmx),与机器物理内存对比,看jvm堆内存可否调大。从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
2.虚拟机栈和本地方法栈
HotSpot虚拟机并区分虚拟机栈和本地方法区栈。虽然可以通过-Xoss参数(设置本地方法栈大小),实际上是没意义的。栈容量只由-Xss参数设定。
这个区域可能抛出两种异常:
如果线程请求的栈深度大于VM所允许的最大深度,将抛出StackOverFlowError异常。
如果VM在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
这里限于单个线程,使用-Xss减少栈内存容量,代码如下:
package com.jvm; /** * * @author wangfei * 测试jvm抛出异常 *-Xss128k,虚拟机栈大小为128k */ public class Test { private static Integer count = 0; public static void main(String[] args) { Test test = new Test(); test.test(); } /** * 递归方法:该方法没有递归结束条件 */ private void test() { try { count ++; test(); }catch(Throwable e) { System.out.println("递归调用次数" + count); e.printStackTrace(); } } }
consolse中打印结果:
结果表明:
在单个线程下,无论是栈帧太大,还是jvm栈容量太小,当内存无法分配时,VM抛出的都是java.lang.StackOverflowError异常。
package com.jvm; /** * VM args: -Xss2M(可以设置大一些) * @author wangfei * created on 2019/2/28 */ 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) { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } }
上面的代码,是用于测试OOM异常发生情形的,但是,如果是在window平台运行程序,java的线程是映射到OS的内核线程上的。可能会导致OS假死。
3.本机直接内存溢出
DirectMemory容量可以通过-XX:MaxDirectMemorySize来设定。如果不指定,则默认与java堆最大值(-Xmx指定)一样大,通过如下的代码演示OOM异常情况:
package com.jvm; import java.lang.reflect.Field; import sun.misc.Unsafe; /** * VM args:-Xmx20M -XX:MaxDirectMemorySize=10M * @author wf */ 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); } } }
该代码越过DirectByteBuffer类,直接通过反射获取Unsafe实例进行分配内存(Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。因为,DirectByteBuffer分配内存也会抛出OOM异常,但它抛出异常时并没有真正向OS申请内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。
运行结果如下图所示:
总结:
由DirectMemory导致的OOM异常,一个明显的特点是在Heap Dump文件中不会看见明显的异常。
如果发现OOM之后Dump文件很小,而程序又直接或间接使用NIO,就有可能是这方面的原因。