美文网首页java进阶
对Java对象的深入认识(三)

对Java对象的深入认识(三)

作者: Jack_Ou | 来源:发表于2021-01-22 16:41 被阅读0次

    1. Java对象

    1.1 Java对象的组成

    ​ 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

    • 对象头包括两部分信息: 对象自身的运行时数据和类型指针

    • 对齐填充不是必然存在的,因为虚拟机在创建对象分配内存的时候,都是分配8个字节的整数倍,如果对象大小不是8字节的整数倍,就需要填充占位符来使内存对齐。

    对象组成.png

    1.2 对象的创建

    ​ 当虚拟机遇到一条new指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。

    类加载就是把class加载到JVM的运行时数据区的过程。

    对象创建.png

    1.2.1 检查加载

    ​ 检查这个指令的参数是否能在常量池中定位到一个类的符号引用(符号引用 符号引用以一组符号来描述所引用的目标),并且检查类是否已经被加载、解析和初始化过。

    1.2.2 分配内存

    ​ 虚拟机会为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从运行时数据区的堆中划分出来。

    • 指针碰撞的方式:对于规整的内存区域使用指针碰撞的形式分配内存,在内存已使用和未使用的区域之间存放一个指示器,需要分配内存时,直接在该指针后分配指定大小的空间即可。
    • 空闲列表:对于不规整的内存区域使用空闲列表的方式分配内存。虚拟机会维护一张列表(相当于空闲内存索引),表中标记哪些内存已经被分配,哪些没有,当需要分配内存时,只需要在表中找到符合需求大小的位置分配即可,然后更新列表。

    (1) 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。如果是Serial、ParNew等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。如果是使用CMS这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。

    (2) 在虚拟机中创建对象的操作是非常频繁的,仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

    如何保证在创建对象过程中的线程安全呢?

    CAS机制:虚拟机采用CAS失败重试的方式保证更新操作的原子性;

    分配缓存:在Eden区分配一块缓存区的方式创建对象,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),此操作有点像Threadlocal。可使用-XX:+UseTLAB来启动此选项。

    1.2.3 内存空间初始化

    ​ 虚拟机需要将分配到的内存空间都初始化为零值(如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

    1.2.4 设置对象头

    ​ 虚拟机会对对象头进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes在Java hotspot VM内部表示为类元数据)、对象的哈希码、对象的GC分代年龄等信息。

    1.2.5 对象初始化

    ​ 调用对象的构造方法,按照程序员的意愿初始化对象。

    1.3 对对象的访问方式

    ​ 当对象创建出来之后,Java程序需要通过栈上引用的方式对堆上的具体对象进行操作。目前主流的访问方式有使用句柄和直接指针两种。

    • 句柄:如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

    • 直接指针:如果使用直接指针访问, reference中存储的直接就是对象地址。

    1.3.1 两种对象访问方式的区别

    ​ 使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾回收时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

    ​ 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

    对Sun HotSpot而言,它是使用直接指针访问方式进行对象访问。《可视化JVM理解运行时数据区》中可以看到验证过程

    1.4 对象的存活判断

    ​ 在运行时数据区的堆中几乎保存着所有的对象实例,当内存不够的时候,垃圾回收器会根据对象实例的“存活”,对实例进行回收。根据不同的虚拟机,判断方式有所不同,主流的有两种方式:引用计数法和可达性分析法

    1.4.1 引用计数法

    ​ 在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1,当引用失效时,计数器减1。如果计数器为0就可以回收对象,但是这种方式存在循环引用导致对象无法被回收的情况,需要引入其他机制来保证循环引用对象的回收。

    1.4.2 可达性分析

    1.4.2.1 对实例对象的回收

    ​ 垃圾回收器从一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,称此对象不可达。

    ​ 可以被作为“GC Roots”的对象有以下几种:

    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性。
    • 方法区中常量。
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象。
    • JVM的内部引用(class对象、异常对象NullPointException、OutofMemoryError,系统类加载器)。
    • 所有被同步锁(synchronized关键字)持有的对象。
    • JVM内部的JMXBean、JVMTI中注册的回调、本地代码缓存等
    • JVM实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代时)

    以上是通过可达性分析对实例对象的回收。

    public Object instance =null;
    //占据内存,便于判断分析GC
    private byte[] bigSize = new byte[50*1024*1024];
    
    public static void main(String[] args) {
        GcTest objectA = new GcTest();//objectA 会放入局部变量表作为GCRoots
        GcTest objectB = new GcTest();//objectB 会放入局部变量表作为GCRoots
        //相互引用
        objectA.instance = objectB;
        objectB.instance = objectA;
        //切断可达
        objectA =null;
        objectB =null;
        //强制垃圾回收
        System.gc();
    }
    
    // 输出结果:虚拟机对objectA和objectB引用的对象进行回收
    [GC (System.gc())  106345K->52064K(251392K), 0.0011209 secs]
    [Full GC (System.gc())  52064K->708K(251392K), 0.0054685 secs]
    

    1.4.2.2 对Class对象的回收

    ​ 要想对方法区中的Class对象进行回收,条件就比较苛刻了:

    • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
    • 参数控制:-Xnoclassgc 禁止垃圾回收器对Class对象的回收。

    2. 对象的引用方式

    2.1 强引用

    ​ Object obj = new Object() ,引用通过new关键字创建的对象都属于强引用。在任何情况下,只要有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。

    2.2 软引用

    ​ 通过SoftReference<GcTest> test = new SoftReference<>(objectA);创建软引用对象,软引用对象只有当系统发生OOM之前才会被回收。

    //虚拟机参数:-Xms10m  -Xmx10m -XX:+PrintGC    配置10M堆内存
    public static void main(String[] args) {
            Student jackOu = new Student("JackOu", "man", 18);
            SoftReference<Student> soft = new SoftReference<>(jackOu); // 创建软引用对象
            jackOu = null;//切掉强引用
            System.out.println("Gc前,我还在?  ->" + (soft.get() != null));
            System.gc();
            System.out.println("Gc后,我还在?  ->" + (soft.get() != null));
    
            List<byte[]> list = new ArrayList<>(); // 制造oom
            try {
                for (int i = 0; i < 10; i++) {
                    System.out.println("我还活着么? ->" + (soft.get() != null));
                    list.add(new byte[2 * 1024 * 1024]);
                    Thread.sleep(10);
                }
            } catch (Throwable t) {
                System.out.println("OOM了,我还活着么?-> " + (soft.get() != null));
            }
        }
    
    //输出结果
    Gc前,我还在?  ->true
    [GC (System.gc())  844K->804K(9728K), 0.0005400 secs]
    [Full GC (System.gc())  804K->697K(9728K), 0.0138756 secs]
    Gc后,我还在?  ->true   //GC后没被回收
    我还活着么? ->true
    我还活着么? ->true
    我还活着么? ->true   // 打印了4次,说明OOM之前没被回收
    我还活着么? ->true   // 第5次内存分配失败发生OOM就被回收了
    [GC (Allocation Failure)  7002K->7033K(9728K), 0.0021776 secs]
    [Full GC (Ergonomics)  7033K->6874K(9728K), 0.0065646 secs]
    [GC (Allocation Failure)  6874K->6874K(9728K), 0.0003435 secs]
    [Full GC (Allocation Failure)  6874K->6856K(9728K), 0.0084790 secs]
    OOM了,我还活着么?-> false  // OOM之后被回收
    
    2.3 弱引用

    ​ 通过WeakReference<GcTest> test = new WeakReference<>(objectA);创建弱引用对象,弱引用对象从程度比软引用更低,弱引用对象只能活到下一次GC之前,不管内存足不足GC的过程中就会被回收。

    public static void main(String[] args) {
        Student jackOu = new Student("JackOu", "man", 18);
        WeakReference<Student> weak = new WeakReference<>(jackOu);
        jackOu = null;
        System.out.println("GC前,我还活着么? ->" + (weak.get() != null));
        System.gc();
        System.out.println("GC前,我还活着么? ->" + (weak.get() != null));
    }
    // 输出结果
    GC前,我还活着么? ->true
    [GC (System.gc())  3945K->896K(251392K), 0.0008463 secs]
    [Full GC (System.gc())  896K->706K(251392K), 0.0040099 secs]
    GC前,我还活着么? ->false  //gc之后对象被回收了
    
    2.4 虚引用

    程度最弱,随时可能被回收,垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。

    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<String>();
        PhantomReference<String> pr = new PhantomReference<String>(new String("Hello World!"), queue);
        System.out.println(pr.get());
    }
    
    // 输出结果
    null   //一创建出来就被回收了
    

    3. 对象分配原则

    ​ JVM创建对象会满足以下几个原则:

    • 对象优先在Eden区分配
    • 空间分配担保原则
    • 大对象直接进入老年代
    • 长期存活对象进入老年代
    • 动态对象年龄判定

    其中JVM对创建对象也做了一些优化:栈中创建对象和在堆中使用TLAB技术创建对象

    3.1.1 栈上创建对象

    ​ Java几乎所有对象都在堆中分配,但是也有例外,可以在栈中分配的可能。如果对象没有逃逸,就会在栈中分配。

    逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用,比如:调用参数传递到其他方法中,这种称之为方法逃逸,甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸

    ​ 从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

    总结:不逃逸的对象在栈中分配,逃逸的对象(方法逃逸和线程逃逸)在堆中分配。

    对象分配原则.png

    3.1.2 验证栈中分配优势

    ​ 从测试代码中可以看到myObject对象的创建是在allocate()方法中,在其他地方没有引用,所以myObject没有发送逃逸,如果JVM开启逃逸分析的话,JVM会在栈中创建myObject对象。
    ​ 如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。如果没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢。
    ​ 在虚拟机中默认是开启了栈上分配内存优化的,即默认开启了逃逸分析的,那么我们来验证一下:

    // -XX:-DoEscapeAnalysis 关闭逃逸分析
    // -XX:+DoEscapeAnalysis 开启逃逸分析
    // -XX:+PrintGC  打印GC日志
    public class EscapeAnalysisTest {
        public static void main(String[] args) throws Exception {
            long start = System.currentTimeMillis();
            for (int i = 0; i < 50000000; i++) {//5千万的对象,为什么不会垃圾回收
                allocate();
            }
            System.out.println((System.currentTimeMillis() - start) + " ms");
            Thread.sleep(600000);
        }
    
        static void allocate() {//满足逃逸分析(不会逃逸出方法)
            MyObject myObject = new MyObject(2020, 2020.6);
        }
    
        static class MyObject {
            int a;
            double b;
    
            MyObject(int a, double b) {
                this.a = a;
                this.b = b;
            }
        }
    }
    [GC (Allocation Failure)  65536K->952K(251392K), 0.0006760 secs]
    [GC (Allocation Failure)  66488K->888K(251392K), 0.0007227 secs]
    [GC (Allocation Failure)  66424K->904K(251392K), 0.0006020 secs]
    [GC (Allocation Failure)  66440K->920K(316928K), 0.0007311 secs]
    [GC (Allocation Failure)  131992K->920K(316928K), 0.0007891 secs]
    [GC (Allocation Failure)  131992K->936K(438272K), 0.0006538 secs]
    [GC (Allocation Failure)  263080K->775K(438272K), 0.0016288 secs]
    [GC (Allocation Failure)  262919K->775K(700928K), 0.0002895 secs]
    232 ms
    //关闭逃逸分析,对象分配耗时:232 ms ! 发生了8次gc
        
    6 ms
    //开启逃逸分析,对象分配耗时:6ms!没有发生gc
    

    3.2 对象优先在Eden区分配

    ​ 从上图中可以看到,在堆中分配的情况,如果满足TLAB条件,就在Eden的TLAB区分配,如果不满足,也不是大对象,就Eden区非TLAB区分配。

    ​ 当Eden区没有足够空间分配时,虚拟机将发起一次Minor GC。

    3.3 大对象直接进入老年代

    ​ 最典型的大对象是很长的字符串以及数组。这样做的目的:1.避免大量内存复制;2.避免提前进行垃圾回收,明明内存有空间进行分配。

    3.4 长期存活对象进入老年区

    ​ 如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor(from区或者to区)容纳的话,将被移动到Survivor空间中,并将对象年龄设为1,对象在Survivor区中每熬过一次 Minor GC,年龄就增加1,当它的年龄增加到一定程度(并发的垃圾回收器默认为15),CMS是6时,就会被晋升到老年代中。

    3.5 对象年龄动态判定

    ​ 为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

    3.6 空间分配担保

    ​ 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的,如果担保失败则会进行一次Full GC;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

    相关文章

      网友评论

        本文标题:对Java对象的深入认识(三)

        本文链接:https://www.haomeiwen.com/subject/iyfezktx.html