如何应对Android面试官->JVM对象回收与逃逸分析
作者:访客发布时间:2023-12-25分类:程序开发学习浏览:100
前言
本节主要围绕下面三个方向进行知识点的讲解,带你从架构师角度认识内存溢出
虚拟机中对象的创建过程
当 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内存分配原理,能基于分配原理进行深度优化;
下一章预告
带你玩转垃圾回收;
欢迎三连
来都来了,点个赞,点个关注吧~~~
相关推荐
- Nucleus Addons 最好的WordPress通用插件下载 博客插件模块
- 轻松上手:
(三)笔记可再编辑 - 如何在iPhone,iPad和Android上使用WordPress应用程序
- 一款简单高效的Android异步框架
- [Android][NDK][Cmake]一文搞懂Android项目中的Cmake
- Android---View中的setMinWidth与setMinimumWidth的踩坑记录
- Android广播如何解决Sending non-protected broadcast问题
- 有关Android Binder面试,你未知的9个秘密
- 开启Android学习之旅-2-架构组件实现数据列表及添加(kotlin)
- 如何应对Android面试官->嵌套滚动原理大揭秘,实战京东首页二级联动
- 程序开发学习排行
-
- 1鸿蒙HarmonyOS:Web组件网页白屏检测
- 2HTTPS协议是安全传输,为啥还要再加密?
- 3HarmonyOS鸿蒙应用开发——数据持久化Preferences
- 4记解决MaterialButton背景颜色与设置值不同
- 5鸿蒙HarmonyOS实战-ArkUI组件(RelativeContainer)
- 6鸿蒙HarmonyOS实战-ArkUI组件(Stack)
- 7[Android][NDK][Cmake]一文搞懂Android项目中的Cmake
- 8Android广播如何解决Sending non-protected broadcast问题
- 9鸿蒙HarmonyOS实战-ArkUI组件(mediaquery)
- 最近发表
-
- WooCommerce最好的WordPress常用插件下载博客插件模块的相关产品
- 羊驼机器人最好的WordPress常用插件下载博客插件模块
- IP信息记录器最好的WordPress常用插件下载博客插件模块
- Linkly for WooCommerce最好的WordPress常用插件下载博客插件模块
- 元素聚合器Forms最好的WordPress常用插件下载博客插件模块
- Promaker Chat 最好的WordPress通用插件下载 博客插件模块
- 自动更新发布日期最好的WordPress常用插件下载博客插件模块
- WordPress官方最好的获取回复WordPress常用插件下载博客插件模块
- Img to rss最好的wordpress常用插件下载博客插件模块
- WPMozo为Elementor最好的WordPress常用插件下载博客插件模块添加精简版