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

前言

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

虚拟机中对象的创建过程

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

检查加载

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

ini 复制代码
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,什么时候分配缓冲?

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

ruby 复制代码
-XX:-UseTLAB 

内存空间初始化

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

什么是零值?

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

设置

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

对象的初始化

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

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

虚拟机中对象的布局

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

对象头

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

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

类型指针

指向类对象信息的指针;

ini 复制代码
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 中对象的创建是比较疯狂的,这块会存在一个积少成多,那么虚拟机又提供了另外一种方式:直接指针;

直接指针

ini 复制代码
Person p = new Person();

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

如何判断对象的存活

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

引用计数法

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

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

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

可达性分析(根可达)

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

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

ini 复制代码
// -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 只能执行一次,可以看下面的代码示例;

csharp 复制代码
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中的引用类型

强引用

ini 复制代码
Object obj = new Object();

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

软引用

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

csharp 复制代码
// -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 扫描到了就会回收;

csharp 复制代码
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 需要一项技术:逃逸分析技术

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

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

可以看下面代码示例

arduino 复制代码
// -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线程);

ruby 复制代码
-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内存分配原理,能基于分配原理进行深度优化;

下一章预告

带你玩转垃圾回收;

欢迎三连

来都来了,点个赞,点个关注吧~~~

相关推荐
张拭心11 分钟前
春节后,有些公司明确要求 AI 经验了
android·前端·人工智能
张拭心21 分钟前
Android 17 来了!新特性介绍与适配建议
android·前端
SimonKing2 小时前
OpenCode AI辅助编程,不一样的编程思路,不写一行代码
java·后端·程序员
FastBean2 小时前
Jackson View Extension Spring Boot Starter
java·后端
Kapaseker3 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴3 小时前
Android17 为什么重写 MessageQueue
android
Seven973 小时前
剑指offer-79、最⻓不含重复字符的⼦字符串
java
皮皮林55112 小时前
Java性能调优黑科技!1行代码实现毫秒级耗时追踪,效率飙升300%!
java
冰_河13 小时前
QPS从300到3100:我靠一行代码让接口性能暴涨10倍,系统性能原地起飞!!
java·后端·性能优化