如何应对Android面试官->JVM对象回收与逃逸分析

前言

本节主要围绕下面三个方向进行知识点的讲解,带你从架构师角度认识内存溢出

虚拟机中对象的创建过程

当 JVM 遇到一条字节码 new 指令的时候,它首先会进行:

检查加载

检查对应的类有没有加载进来,如果没有加载进来,则要重新进行类加载,直到类加载成功,成功之后继续进行检查加载,具体检查什么呢?

Object obj = new Object();

例如上面这段代码,它会检查通过设置的 new 的参数(new Object)是否能在方法区的常量池中找到这个类的符号引用,

什么是类的符号引用?

用一组符号来描述你所引用的对象,例如 NBA 球员 James,这个 James 就是一个符号引用,假设 James 不来中国,那么你是看不到他的,你只看到了这个符号;那么对于类来说,这个类的前面加了 com.american.James,同时它要检查这个 James 类有没有被加载过;

分配内存

检查加载成功之后,就要开始分配内存,那么 JVM 是如何划分内存的呢?对象申请内存空间流程是怎样的呢?

划分内存有两种方式,一种是指针碰撞、一种是空闲列表

指针碰撞

假设我们的堆内存比较规整,红色代表已经分配了内存,白色代表未分配的内存,这个堆内存比较规整,我们可以使用一个指针指向堆内存中的最后一个对象的偏移量,当我们给一个对象申请内存空间的时候,这个指针就会根据这个对象的 size 挪动到指定的位置来放下创建的这个对象,这个就叫做指针碰撞(移动一个对象大小的距离);另外这种指针碰撞只能在堆空间比较规整的情况下,但是经过垃圾回收之后,就会变成零散的,不规整的,那么指针碰撞在这种情况下就不合适了,这种时候,JVM 就会维护一个空闲列表;

空闲列表

JVM 用空闲列表来标记对应的位置是否有对象存在,在分配位置的时候,假设对象需要一个位置大小,就分配到 1 的位置,如果需要三个大小,就分配到 3-5 这个位置;

上面两种划分方式都是因为:在 JVM 中对象要占据的内存一定要是连续的;

JVM 用哪种方式来划分,取决于堆的规整程度;堆空间的规整度 又是由垃圾回收器决定的,垃圾回收器是否带有整理功能;

不管使用 指针碰撞 还是 空闲列表,JVM 为了提高效率,同样使用了多线程,那么就会带来多线程安全问题,那么 JVM 是如何解决并发安全的问题呢?

CAS加失败重试

A B 两个分配内存的时候,都会去抢同一块内存,会进行查询操作,查询下这块空间是不是空的,A B 两个线程拿到的都是空的,就会进行 CAS 操作,因为 CAS 操作是由 CPU 保证线程的执行顺序,假设 A  比 B 先执行,当 A 进行 CAS 操作的时候,判断这块空间是空的,就进行交换占据这块内存,当 B 进行 CAS 操作的时候,比较发现这块区域不空了,就会进行重试,直到找到一块为空的区域,然后进行交换操作;

CAS原理可以查看之前的讲解:如何应对Android面试官->CAS基本原理

本地线程分配缓冲

CAS 比较并且交换,比较和交换,必定耗费性能,所以 JVM 提供了第二种方式:本地线程分配缓冲(Thread Local Allocation Buffer)简称 TLAB;

本地线程分配缓冲类似 ThreadLocal,堆中的 eden 预先给每个线程划分单独的一块区域,当线程执行的时候,直接分配,就不需要采取安全措施,这就是本地线程分配缓冲,但是 TLAB 比较小,只占用 eden 区的 1%;

什么时候 CAS,什么时候分配缓冲?

分配缓冲默认开启,如果要禁用,可以使用下面的配置选项

-XX:-UseTLAB 

内存空间初始化

内存空间的初始化不是构造方法,而是在内存分配之后,划分了一块区域,但是这块区域是空的,需要把里面的一些数据设置为 零值,这一步确保了对象在分配完内存后,在代码里面不需要赋值就可以直接使用,程序越早使用对象,它的效率就越高,这就是内存空间初始化;

什么是零值?

比如 int 类型,那么它的零值就是 0,boolean 类型,它的零值就是 false,

设置

对象属于哪个实例,需要设置一下,以及设置对象头;

对象的初始化

调用构造方法进行对象的初始化;

以上过程,针对的是 一般的对象(也就是我们编写的程序),因为 Java 中万物介对象;

虚拟机中对象的布局

HotSpot 中对象可以分为三块:对象头、实际数据、对齐补充

对象头

Mark Word(存储对象自身的运行时数据)

哈希码、GC分代年龄、锁状态标记、线程持有的锁、偏向线程ID、偏向时间戳;

类型指针

指向类对象信息的指针;

Person p = new Person(); 

p 是一个引用对象,存在栈中(java虚拟机栈中的栈桢),new Person() 存在于堆中, 假设有一个 A 类,A 类中有一个 Person 对象,这个对象存储于堆区,那么它就会指向方法区的这个 A类(方法区存储类的描述信息),指向的过程就是这个 Class Pointer;

若为对象数组,还应有记录数组长度的数据

lenght 数据长度,只针对数组对象;

实例数据

包含对象所有成员变量,根据变量类型决定大小;

对齐填充

为了让对象的大小为8字节的整数倍;

为什么要对齐填充?

因为在 HotSpot 中,它对管理的对象的大小是有要求的,必须是 8 字节的整数,但是 对象头和实例数据是没有办法控制的,假设对象头 + 实例数据刚好 38 字节,那么对齐填充就会填充 2 个字节,如果对象头和实例数据加起来刚好是 8 的整数倍,那么这个对齐填充就不需要了;填充的话随便用一些值填充就可以了;

虚拟机中对象的访问定位

所有的虚拟机中(包括 HosSpot)对象的访问定位都有两种方式:使用句柄、直接指针

使用句柄

什么是句柄?

句柄就是在堆空间划一块区域,叫作句柄池,句柄池中存放的是什么呢?对象的访问通过 reference ,这个 reference 中就不会存放对象的地址了,而是存放一个叫作对象实例的指针,句柄其实就是做了一次中转,通过句柄池找到真实的对象实例数据,这样做的好处就是:如果对象进行了移动,句柄池不需要修改,还是可以通过句柄池找到对应的对象实例;

例如:句柄池中存放的是 Kobe 的对象实例指针,实例池中存放的是 Kobe 的实例,但是 Kobe 离开之后,句柄池的这个指针不需要修改,当有一个新的 Kobe 实例被替换的时候,还是可以通过这个指针找到对应的 Kobe 实例;

但是这样做的坏处是需要通过 Auth 查找;通过这个句柄池再映射一次,会有一次额外的指针定位开销,虽然这个开销比较小,但是 JVM 中对象的创建是比较疯狂的,这块会存在一个积少成多,那么虚拟机又提供了另外一种方式:直接指针;

直接指针

Person p = new Person();

在 HotSpot 中 使用的就是直接指针,这个 p 就是一个引用,这个引用就会指向真实的地址;这样做虽然带来了效率的提升,但是如果对象一直被移来移去,对象在物理区 移来移去,那么这个 reference 就会进行改变;

如何判断对象的存活

在 JVM 中对象是可以被回收的,首先对象是在堆中进行分配的,如果堆空间满了,就会触发垃圾回收,但是在进行垃圾回收之前我们要确定哪些对象是存活的;怎么判断呢?大部分都是采用的下面这两种方式

引用计数法

用一个计数器来统计对象被引用,对象被引用了,计数器就+1,如果这个引用失效了,就 -1,如果等于 0 说明这个对象不被引用了;

这里会存在一个问题:对象的相互引用;

上图中的两个对象就存在相互引用,但是又跟运行方法里面的不相关,外部没有可用的地方与它进行连接,它其实也是死的;

可达性分析(根可达)

JVM 中用的就是可达性分析法,本质上是根据一条链路来追踪的,这条链路以 GC Roots 的变量(静态变量、线程栈变量、常量池变量、JNI指针变量)或者对象(class、Exception、OOM、类加载器、加锁 synchronized 对象、JMXBean、临时性)为根节点,形成引用链路的则为存活对象,没有被 GC Roots 直接引用或者间接引用的都是可以回收的对象;

通过下面的代码可以验证 HotSpot 使用的是可达性分析法

// -XX:+PrintGC
public class ReliabilityAnalysisTest {    
    public Object instance = null; 
    // 辅助作用,占据内存,用来可达性分析   
    private byte[] bigSize = new byte[10 * 1024 * 1024];    
    public static void main(String[] args) {        
        ReliabilityAnalysisTest test = new ReliabilityAnalysisTest();        
        ReliabilityAnalysisTest test1 = new ReliabilityAnalysisTest();        
        // 相互引用        
        test.instance = test1;        
        test1.instance = test;        
        // 解除引用        
        test.instance = null;        
        test1.instance = null;        
        // 回收内存        
        System.gc();    
    }
}

VM参数加上注释的配置信息 -XX:+PrintGC 运行之后可以看到,内存进行了回收,说明引用计数法的方式在 HotSpot 中没有被使用;如果是可达性分析的话,这两个对象必然不会被回收;

可达性分析算法之后,没有引用链,但是互相引用的对象,也不是立马就会被回收,它们其实处于缓刑状态,还是可以被挽救的,但是这个挽救是需要开发者通过代码实现的,但是 finalize 只能执行一次,可以看下面的代码示例;

public class FinalizeTest {    
    public static FinalizeTest instance;    
    public void isAlive() {        
        System.out.println("is Alive");    
    }    
    @Override    
    protected void finalize() throws Throwable {        
        super.finalize();        
        System.out.println("finalize execute");        
        FinalizeTest.instance = this;    
    }    
    public static void main(String[] args) throws InterruptedException {        
        instance = new FinalizeTest();        
        // 第一次 GC        
        instance = null;        
        System.gc();        
        Thread.sleep(1000); // 等待 finalize 方法执行        
        if (instance != null) {            
            System.out.println("第一次GC后,对象实例不为空");            
            instance.isAlive();        
        } else {            
            System.out.println("第一次GC后,对象实例为空");        
        }        
        // 第二次 GC        
        instance = null;        
        System.gc();        
        Thread.sleep(1000); // 等待 finalize 方法执行        
        if (instance != null) {            
            System.out.println("第二次GC后,对象实例不为空");            
            instance.isAlive();        
        } else {            
            System.out.println("第二次GC后,对象实例为空");        
        }    
    }
}

可以看到第一次 GC 后,执行了 finalize 方法,进行了拯救,但是第二次 GC 之后,就被回收了;

这里为什么要加 sleep,是因为 finalize 的线程优先级非常低,如果去掉 sleep 则拯救不成功;

可以看到第一次 GC 之后就被回收了;

所以 finalize 尽量不要使用,这个方法太不可靠了;

JVM中的引用类型

强引用

Object obj = new Object();

这种就是强引用,只要 GC Roots 还在,那么强引用的就不会被回收;

软引用

内存不足,将要发生OOM的时候,会被回收;可以查看下面的代码示例

// -Xms20M -Xmx20
Mpublic class SoftReferencesTest {    
    static class User {        
        private String name;        
        private int age;        
        public User(String name, int age) {            
            this.name = name;            
            this.age = age;        
        }        
        @Override        
        public String toString() {            
            return "User{" +                    
                    "name='" + name + '\'' +                    
                    ", age=" + age +                    
                '}';        
        }
    }    
    public static void main(String[] args) {        
        User user = new User("张三", 18);        
        SoftReference<User> softReference = new SoftReference<>(user);        
        System.out.println("GC 前读取:" + softReference.get());        
        user = null; // 置空,确保只有软引用指向该对象        
        System.gc(); // 手动触发GC        
        System.out.println("GC 后读取:" + softReference.get());        
        // 构造内存溢出        
        List<byte[]> list = new ArrayList<>();        
        try {            
            for (int i = 0; i < 10000; i++) {                
                list.add(new byte[1024 * 1024]);            
            }        
        } catch (Throwable e) {            
            System.out.println("内存溢出:" + softReference.get());        
        }    
    }
}

可以看到,当发生内存溢出的时候,被回收掉了,这个时候我们获取弱引用中的数据是拿不到的;

弱引用

GC 扫描到了就会回收;

public class WeakReferencesTest {    
    static class User {        
        private String name;        
        private int age;        
        public User(String name, int age) {            
            this.name = name;            
            this.age = age;        
        }        
        @Override        
        public String toString() {            
            return "User{" +                    
                "name='" + name + '\'' +                    
                ", age=" + age +                    
                '}';        
        }    
    }    

    public static void main(String[] args) {        
        User user = new User("张三", 18);        
        WeakReference<User> weakReference = new WeakReference(user);        
        System.out.println("GC 前读取:" + weakReference.get());        
        user = null; // 置空,确保只有软引用指向该对象        
        System.gc(); // 手动触发GC        
        System.out.println("GC 后读取:" + weakReference.get());    
    }
}

可以看到 GC 的时候,就被回收掉了;

虚引用

随时都会被回收,不知道什么时候就被回收了;主要用来监控垃圾回收器是否正常工作,一般业务开发中用不到;

对象申请内存空间流程

对象的分配原则

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

对象分配时的优化技术

当我们 new 一个对象的时候,JVM 的第一个优化就是:是否栈上分配?

通常我们总是说:几乎所有对象都是堆中分配,但不是 100%,它也可以在栈上分配,并且在栈上分配的对象,就不需要垃圾回收,这也是为什么方法要在栈中执行的原因,效率高,栈的内存是跟随线程的,线程执行完了,这个栈也就结束了;

如果想在栈上分配对象,HotSpot 需要一项技术:逃逸分析技术

逃逸分析:判断方法的对象有没有逃逸,就是分析这个对象的作用域

  • 是不是可以逃逸出方法体;
  • 是不是可以逃逸出其他线程;

可以看下面代码示例

// -XX:+PrintGC
// -XX:-DoEscapeAnalysis
public class EscapedAnalysisTest {    
    public static void main(String[] args) throws InterruptedException {        
        long start = System.currentTimeMillis();        
        for (int i = 0; i < 6_000_000_0; i++) {                
            allocate();        
        }        
        System.out.println("Escaped Analysis: " + (System.currentTimeMillis() - start));        
        Thread.sleep(60_000);    
    }    

    static void allocate() {        
        Person person = new Person(1000L, 2000L);    
    }    
    
    static class Person {        
        private long age;        
        private long height;        
        public Person(long age, long height) {
            this.age = age;
            this.height = height;        
        }    
    }
}
Person person = new Person(1000L, 2000L); 

person 就会被分配到栈上,它满足 不会逃逸出方法体(方法外没有调用),也不会逃逸出其他线程(只有一个main线程);

-XX:-DoEscapeAnalysis // 关闭逃逸分析

如果不想使用栈上分配(不做逃逸分析)的运行结果,可以加上上面的配置信息;执行结果如下

可以看到触发了 GC;

JVM的第二个优化就是:堆中本地线程分配缓冲,

上面已经介绍了;

对象优先在 Eden 区分配

如果不支持 本地线程分配缓冲,会判断是不是大对象,如果不是大对象,则在 Eden 区分配,满足了对象优先在 Eden 区分配的原则之一,如果是大对象,则直接分配到老年代(满足了大对象直接进入老年代原则之一)

大对象:一般是很长很长的字符串、数组;

如果我们通过参数 -Xms30M -Xmx30M 来设置我们的JVM 堆区为 30M,那么老年代就会分配20M,Eden区分配 8M,From区分配 1M,  To区分配 1M;

也就是说新生代只占堆内存的三分之一,所以说大对象放到老年代可以避免垃圾回收;

JVM 可以通过参数设置是否为大对象,-XX:+PretenureSizeThresold10M,大于等于10M的对象则认为是大对象,直接分配到老年代;

Eden 上分配之后还会遵循一个原则:长期存活的对象进入老年代;

当触发垃圾回收的时候,因为 Eden 区只存放新生对象,Eden 中所有存活的对象都将被移动到 From 区,对象的对象头中的 age(Mark Word 区域的 GC分代年龄) 就会 +1,然后 Eden 被清空,Eden 被清空,当再次充满的时候,所有存活的对象和 From survivor 中所有存活的对象都被移动到 To survivor,然后 Eden 和 From survivor 被清空,这个时候 To 中的对象的对象头中的 age 会再次 +1 (=2),当再次充满触发垃圾回收的时候,会把存活的对象和 To survivor 中所有存活的对象都被移动到 From survivor,然后 Eden 和 To survivor 被清空,这个时候 From 中的对象的对象头中的 age 会再次 +1(=3),From 和 To 循环往复,当 age = 15 的时候,会被移动到老年代(满足了长期存活对象进入老年代原则之一),这种循环往复采用的就是 复制回收 算法;

JVM 为什么不把 Eden、From、To 合并成两个,只保留 From和 To呢?

这是因为复制回收算法要浪费一半的空间,为什么要浪费一半呢?万一复制过去的全是存活对象,比如从 From 复制到 To 的都是存活对象,但是 To 中没有足够的空间容纳下这些对象了;所以往往复制算法的空间都是一分为二,导致内存利用率只有50%;Oracle 和 Sun 公司做过大数据统计,90% 的对象在被垃圾回收的时候都能回收掉,只剩 10% 的存活对象,这 10% 的存活对象放入 From 区,那么就需要一个对等的 To 区,所以采用这样的一种方式的垃圾回收,那么浪费的只有 10% 的空间,空间利用率可以达到 90%;所以就没必要采用标准的复制回收,把堆区一分为二,而是分成三份区域,第一次垃圾回收的时候,移动到 From 区,后续采用标准的复制回收算法,从 From 复制到 To 区;

JVM 的复制回收算法为什么是 15 次,才会移动到老年代,可以修改这个值吗?

JDK 提供的 markOop.hpp(也就是 Mark Word) 文件中有提及到:

不管是 32 位的虚拟机还是 64 位的虚拟机,这个 age 都是存放的 4 位,从二进制来看存放的最大值就是 1111,按照十六进制转换,就是 15,所以说复制回收 age 的最大次数默认是 15 次;

JVM 也提供修改参数,可以修改这个值:

-XX:MaxTenuringThresold = 10 // 就可以修改这个值;

进入老年代的对象,age 就不会在被标记 +1;

垃圾回收的两个概念

在进行垃圾回收的时候,它其实是有两个概念的,在进行分代的时候,它可以采用两种 GC,垃圾回收器回收新生代称之为 Minor GC,回收老年代称之为 Major GC;

空间分配担保

通过堆中的对象分配原则,对象在分配的时候有 Eden 区 进入 From 区或者 To 区,最后进入  Tenured 区,大部分情况下老年代的对象都是由新生代晋级来的,但是假设老年代就只剩下 1M 的空间了,然后还有从 From 或者 To 区做一个对象的晋级,或者通过大对象分配,但是在进行对象晋级或者大对象分配,不能保证一定会有足够的空间来存放,所以在每一次晋级或者大对象分配的时候,自身要做一次Major GC,这种方式比较安全,但是 JVM 认为这种很影响效率,所以 JVM 就提出了一个概念叫作:空间分配担保,这个担保由 JVM 来担保,放心分配,如果确实不够了,再进行一次Major GC,而不用每次晋级都要触发,这就满足了对象分配空间分配担保原则之一

动态年龄判断

为了优化 From 区和 To 区,因为这两个区域本身也不大,假设 From 区中有三个对象,这三个对象的年龄加起来仅仅是5,但是这三个对象占据了 From 区的一半,那么这个时候它会走一个动态年龄判断,并不一定非要达到15,就会让这个几个对象提前晋级到老年代,这些对象就不需要等到15之后再进入老年代;

整体申请内存空间流程

  • 先去 eden 区看看是否有足够的空间;
    • 有,直接分配
  • 无,JVM 开始回收垃圾对象,回收完成之后,判断 eden 是否有足够空间;
    • 有,直接分配;
  • 无,s 区域是否有足够空间;
    • 有,eden 区的存活对象移动到 s 区,新对象就可以在 eden 申请成功;
  • 无,启用担保机制,old 区是否足够空间;
    • 有,将s区的存活对象移动到 old 区,eden将存活对象放到s区,申请成功;
  • 无,JVM 触发 full gc,gc 之后查看 old 区是否有足够空间;
    • 有,将s区的存活对象移动到old区,eden将存活对象放到s区,申请成功;
  • 无,OOM;

简历润色

简历上可写:深度理解JVM内存分配原理,能基于分配原理进行深度优化;

下一章预告

带你玩转垃圾回收;

欢迎三连

来都来了,点个赞,点个关注吧~~~你的支持是我最大的动力

  • 22
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值