zoukankan      html  css  js  c++  java
  • 《深入理解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,就有可能是这方面的原因。

  • 相关阅读:
    5285: [Hnoi2018]寻宝游戏
    CF 1117 E. Decypher the String
    4515: [Sdoi2016]游戏
    CF 1051 G. Distinctification
    4820: [Sdoi2017]硬币游戏
    HNOI2019游记
    最近公共祖先(LCT)
    [WC2006]水管局长(LCT)
    P4178 Tree(点分治)
    二维凸包
  • 原文地址:https://www.cnblogs.com/wfdespace/p/10449032.html
Copyright © 2011-2022 走看看