前言

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

怎么应对Android面试官->JVM目标收回与逃逸剖析

虚拟机中目标的创建过程

怎么应对Android面试官->JVM目标收回与逃逸剖析

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

检查加载

检查对应的类有没有加载进来,假如没有加载进来,则要从头进行类加载,直到类加载成功,成功之后继续进行检查加载,详细检查什么呢?

Object obj = new Object();

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

什么是类的符号引证?

用一组符号来描绘你所引证的目标,例如 NBA 球员 James,这个 James 便是一个符号引证,假定 James 不来中国,那么你是看不到他的,你只看到了这个符号;那么关于类来说,这个类的前面加了 com.american.James,一起它要检查这个 James 类有没有被加载过;

分配内存

检查加载成功之后,就要开始分配内存,那么 JVM 是怎么区分内存的呢?目标请求内存空间流程是怎样的呢?

区分内存有两种办法,一种是指针磕碰、一种是空闲列表

指针磕碰

怎么应对Android面试官->JVM目标收回与逃逸剖析

假定咱们的堆内存比较规整,赤色代表已经分配了内存,白色代表未分配的内存,这个堆内存比较规整,咱们能够运用一个指针指向堆内存中的最终一个目标的偏移量,当咱们给一个目标请求内存空间的时分,这个指针就会依据这个目标的 size 挪动到指定的方位来放下创建的这个目标,这个就叫做指针磕碰(移动一个目标巨细的间隔);别的这种指针磕碰只能在堆空间比较规整的情况下,可是经过废物收回之后,就会变成零散的,不规整的,那么指针磕碰在这种情况下就不适宜了,这种时分,JVM 就会保护一个空闲列表;

空闲列表

怎么应对Android面试官->JVM目标收回与逃逸剖析

JVM 用空闲列表来符号对应的方位是否有目标存在,在分配方位的时分,假定目标需求一个方位巨细,就分配到 1 的方位,假如需求三个巨细,就分配到 3-5 这个方位;

上面两种区分办法都是由于:在 JVM 中目标要占有的内存必定要是连续的;

JVM 用哪种办法来区分,取决于堆的规整程度;堆空间的规整度 又是由废物收回器决议的,废物收回器是否带有整理功用;

不管运用 指针磕碰 仍是 空闲列表,JVM 为了提高功率,同样运用了多线程,那么就会带来多线程安全问题,那么 JVM 是怎么解决并发安全的问题呢?

CAS加失利重试

A B 两个分配内存的时分,都会去抢同一块内存,会进行查询操作,查询下这块空间是不是空的,A B 两个线程拿到的都是空的,就会进行 CAS 操作,由于 CAS 操作是由 CPU 确保线程的履行次序,假定 A 比 B 先履行,当 A 进行 CAS 操作的时分,判别这块空间是空的,就进行交流占有这块内存,当 B 进行 CAS 操作的时分,比较发现这块区域不空了,就会进行重试,直到找到一块为空的区域,然后进行交流操作;

怎么应对Android面试官->JVM目标收回与逃逸剖析

CAS原理能够检查之前的解说:怎么应对Android面试官->CAS基本原理

本地线程分配缓冲

CAS 比较而且交流,比较和交流,必定消耗功能,所以 JVM 供给了第二种办法:本地线程分配缓冲(Thread Local Allocation Buffer)简称 TLAB;

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

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

分配缓冲默认开启,假如要禁用,能够运用下面的装备选项

-XX:-UseTLAB

怎么应对Android面试官->JVM目标收回与逃逸剖析

内存空间初始化

内存空间的初始化不是结构办法,而是在内存分配之后,区分了一块区域,可是这块区域是空的,需求把里边的一些数据设置为 **零值,**这一步确保了目标在分配完内存后,在代码里边不需求赋值就能够直接运用,程序越早运用目标,它的功率就越高,这便是内存空间初始化;

什么是零值?

比方 int 类型,那么它的零值便是 0,boolean 类型,它的零值便是 false,

设置

目标归于哪个实例,需求设置一下,以及设置目标头;

目标的初始化

调用结构办法进行目标的初始化;

以上过程,针对的是 一般的目标(也便是咱们编写的程序),由于 Java 中万物介目标;

虚拟机中目标的布局

怎么应对Android面试官->JVM目标收回与逃逸剖析

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)目标的拜访定位都有两种办法:运用句柄、直接指针

运用句柄

什么是句柄?

怎么应对Android面试官->JVM目标收回与逃逸剖析

句柄便是在堆空间划一块区域,叫作句柄池,句柄池中寄存的是什么呢?目标的拜访经过 reference ,这个 reference 中就不会寄存目标的地址了,而是寄存一个叫作目标实例的指针,句柄其实便是做了一次中转,经过句柄池找到真实的目标实例数据,这样做的好处便是:假如目标进行了移动,句柄池不需求修正,仍是能够经过句柄池找到对应的目标实例;

例如:句柄池中寄存的是 Kobe 的目标实例指针,实例池中寄存的是 Kobe 的实例,可是 Kobe 离开之后,句柄池的这个指针不需求修正,当有一个新的 Kobe 实例被替换的时分,仍是能够经过这个指针找到对应的 Kobe 实例;

可是这样做的害处是需求经过 Auth 查找;经过这个句柄池再映射一次,会有一次额外的指针定位开销,尽管这个开销比较小,可是 JVM 中目标的创建是比较疯狂的,这块会存在一个积少成多,那么虚拟机又供给了别的一种办法:直接指针;

直接指针

怎么应对Android面试官->JVM目标收回与逃逸剖析

Person p = new Person();

在 HotSpot 中 运用的便是直接指针,这个 p 便是一个引证,这个引证就会指向真实的地址;这样做尽管带来了功率的提高,可是假如目标一直被移来移去,目标在物理区 移来移去,那么这个 reference 就会进行改变;

怎么判别目标的存活

在 JVM 中目标是能够被收回的,首先目标是在堆中进行分配的,假如堆空间满了,就会触发废物收回,可是在进行废物收回之前咱们要确认哪些目标是存活的;怎么判别呢?大部分都是选用的下面这两种办法

引证计数法

用一个计数器来统计目标被引证,目标被引证了,计数器就+1,假如这个引证失效了,就 -1,假如等于 0 阐明这个目标不被引证了;

这里会存在一个问题:目标的彼此引证;

怎么应对Android面试官->JVM目标收回与逃逸剖析

上图中的两个目标就存在彼此引证,可是又跟运转办法里边的不相关,外部没有可用的地方与它进行连接,它其实也是死的;

可达性剖析(根可达)

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();    
    }
}

怎么应对Android面试官->JVM目标收回与逃逸剖析

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后,目标实例为空");        
        }        
        // 第2次 GC        
        instance = null;        
        System.gc();        
        Thread.sleep(1000); // 等待 finalize 办法履行        
        if (instance != null) {            
            System.out.println("第2次GC后,目标实例不为空");            
            instance.isAlive();        
        } else {            
            System.out.println("第2次GC后,目标实例为空");        
        }    
    }
}

怎么应对Android面试官->JVM目标收回与逃逸剖析

能够看到第一次 GC 后,履行了 finalize 办法,进行了解救,可是第2次 GC 之后,就被收回了;

这里为什么要加 sleep,是由于 finalize 的线程优先级十分低,假如去掉 sleep 则解救不成功;

怎么应对Android面试官->JVM目标收回与逃逸剖析

能够看到第一次 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());        
        }    
    }
}

怎么应对Android面试官->JVM目标收回与逃逸剖析

能够看到,当产生内存溢出的时分,被收回掉了,这个时分咱们获取弱引证中的数据是拿不到的;

弱引证

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());    
    }
}

怎么应对Android面试官->JVM目标收回与逃逸剖析

能够看到 GC 的时分,就被收回掉了;

虚引证

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

目标请求内存空间流程

目标的分配准则

  • 目标优先在Eden分配;
  • 空间分配担保;
  • 大目标直接进入老时代;
  • 长时间存活的目标进入老时代;
  • 动态目标年纪判定;

目标分配时的优化技能

怎么应对Android面试官->JVM目标收回与逃逸剖析

当咱们 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 // 关闭逃逸剖析

假如不想运用栈上分配(不做逃逸剖析)的运转结果,能够加上上面的装备信息;履行结果如下

怎么应对Android面试官->JVM目标收回与逃逸剖析

能够看到触发了 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),当再次充溢触发废物收回的时分,会把存活的目标和 Tosurvivor中一切存活的目标都被移动到 Fromsurvivor,然后 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) 文件中有提及到:

怎么应对Android面试官->JVM目标收回与逃逸剖析

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

JVM 也供给修正参数,能够修正这个值:

-XX:MaxTenuringThresold = 10 // 就能够修正这个值;

怎么应对Android面试官->JVM目标收回与逃逸剖析

进入老时代的目标,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内存分配原理,能基于分配原理进行深度优化;

下一章预告

带你玩转废物收回;

欢迎三连

来都来了,点个赞,点个重视吧~~~