如何应对Android面试官->玩转垃圾回收算法
作者:访客发布时间:2023-12-26分类:程序开发学习浏览:97
前言
垃圾回收算法与垃圾收集器
分代收集理论
理论来源于两个结论:
- 绝大部分的对象都是朝生夕死;
- 对象熬过多次垃圾回收,越难被回收掉;
所以会把这种两种对象分别放在不同的区域,朝生夕死的放在新生代,很难被回收掉的放到老年代;
什么是分代收集理论?在 JVM 里面,配置的时候有垃圾收集器,垃圾回收器本身也是一个线程,垃圾回收器对新生代进行回收,称之为Minor GC(Young GC),对老年代进行回收,称之为Major GC(Old GC),在分代收集理论里面,划分了不同的区域,就可以采用分代收集,比如新生代分配内存的时候,Eden 区不够了,垃圾回收器就会介入,介入之后发生的垃圾回收,就是 Minor GC,另外,如果老年代空间也满了,垃圾回收器介入,介入之后发生的垃圾回收,就是Major GC;另外在实现老年代回收的时候,JVM 会实现一次 Full GC,它会回收新生代、老年代、方法区;我们可以通过下面的示例查看 Full GC、MinorGC;
//-XX:+PrintGCDetails
public class GCTest {
public static void main(String[] args) {
AddListThread addListThread = new AddListThread();
TimeThread timeThread = new TimeThread();
addListThread.start();
timeThread.start();
}
static class AddListThread extends Thread {
List<byte[]> list = new LinkedList<>();
@Override
public void run() {
super.run();
try {
while (true) {
if (list.size()*512/1024/1024 >= 1000) {
list.clear();
System.out.println("clear the list");
}
byte[] bytes;
for (int i = 0; i < 100; i++) {
bytes = new byte[512];
list.add(bytes);
}
Thread.sleep(1);
}
} catch (Exception e) {
}
}
}
static class TimeThread extends Thread {
private static long startTime = System.currentTimeMillis();
@Override
public void run() {
super.run();
while (true) {
try {
long t = System.currentTimeMillis() - startTime;
System.out.println(t/1000 + "." + t%1000);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
VM 增加注释的配置参数,打印 GC 详情;
可以看到 Full GC 的时候 包含了 PSYoungGen,ParOldGen,Metaspace也就是我们上面说的新生代、老年代、方法区;
分代之后,每个区域采用了不同的垃圾回收算法,新生代采用了复制算法,老年代采用了标记清除算法或者标记整理算法;
新生代中的复制算法
假设内存中有八份大小相等的区域,那么它一分为二,只用一半,留一半进行复制,当有一个新的对象进来的时候,此时空间不够了,经过可达性分析之后,把可达的对象复制到上图的左边区域,把右边区域直接格式化(把右边区域变成预留),然后把新对象分配到左边区域,这就是复制回收算法;
复制回收算法特点就是:实现简单、运行高效,内存复制,没有内存碎片,但是利用率只有一半;
JVM 为了优化空间利用率只有一半的这个问题,提出了 Appel 式回收,所以 Eden 区诞生;
Appel 式回收
Appel 式回收,我们的空间不一定要留一半;
划分出一块比较大的区域,称为 Eden 区,再划分两块对等的较小的区域,叫做 Surivor 区(From Surivor、To Surivor),默认的比例设置为 8:1:1,然后进行垃圾回收之后,绝大部分的对象都被回收掉了,进行一次垃圾回收之后,存活的对象就会从 Eden 区进入 From 区,接下来Eden 会继续放入新创建的对象,当再次占满的时候,From 和 To 区会采用标准的复制回收算法,垃圾回收之后,From 区中存活的对象会进入 To 区,Eden 区存活的对象也会进入 To 区,但是进入 To 区的这两个对象年龄是不一样的,从 From 区过去的年龄是 2,从 Eden 区过去的年龄是 1,因为不管是第一次的时候进入的 From 区还是 To 区,对象的 age 都要 + 1,接下里循环往复,垃圾回收的时候,存活的对象从 To 区进入 From 区,Eden 区存活的对象也进入 From 区,对应的 age +1,Eden 区的年龄是没有 + 1 的概念;
这种 Appel 的回收方式,可以提高空间利用率,浪费的只有 From 区或者 To 区,空间利用率达到了 90%;
为什么划分成8:1:1 而不是其他比例?因为对象的存活有10%;
老年代中的标记清除、标记整理算法
标记清除算法
空间只会划分成三份:可回收、不可回收、未分配;
垃圾回收之前,将可回收的对象进行标记(上图灰色区域),然后垃圾回收的时候进行回收,采用标记清除算法会产生内存碎片(堆空间不是连续的);
内存碎片带来的问题?
会导致 JVM 提前GC,例如上图回收后,因为内存的不连续,当我们需要在内存中分配一个需要连续的 5 块区域的时候,会分配不了,就会提前触发 GC 来释放空间;
标记清除带来的效率非常低,因为每个对象都要进行挨个标记,如果需要回收的对象比较多,那么就会导致效率比较低下;
标记整理算法
JVM为了解决标记清除带来的内存碎片问题,从而产生了标记整理算法,对比标记清除,多做了一步额外的操作,就是在标记完之后,将存货对象进行了移动,移动到一个连续的空间内;
但是这样就会产生对象的移动,因为是在老年代,大部分都是存货的对象,对象的移动效率会比较低,同时对象的移动会带来引用的更新,因为主流的虚拟机大部分都是使用的直接引用(上一章 如何应对Android面试官->JVM对象回收与逃逸分析 有介绍引用关系);对象的移动还需要用户线程暂停,不然可能都不知道对象移动到了哪里;
所以标记整理算法整体效率还是偏低的;而 标记清楚算法 更适合老年代内存回收,因为老年代本身存放的就是比较难回收的对象;
JVM中常见的垃圾收集器
JVM 中的垃圾回收都是通过垃圾收集器来执行的,常见的有以下几种;
- 单线程垃圾收集器;
- 多线程并行垃圾收集器
- 多线程并发垃圾收集器;
早先的 JVM 只有 Serial 和 Serial Old,都是单线程的,Serial 采用复制算法,Seial Old 采用标记整理算法,随着内存越来越大,诞生了 Parallel Scavenge 和 Parallel Old,算法还是采用的标记整理和标记清除,但是是并行的多线程收集器,可以跑多个垃圾收集器(多个线程);但是不管是单线程还是多线程,在执行垃圾回收的时候,都需要暂停用户线程,JVM 为了优化这个功能,诞生了多线程并发垃圾收集器,出现的第一个并发垃圾收集器就是老年代的 CMS(并发的标记清除)和 新生代的 ParNew;
由上图可以知道,垃圾收集器其实是需要配对出现的,Serial 可以和 Serial Old 配对出现,也可以和 CMS 配对出现,ParNew 可以和 CMS 配对出现,也可以和 Serial Old 配对出现,Parallel Scavenge 可以和 Serial Old 配对出现也可以和 Parallel Old 配对出现;
CMS(Concurrent Mark Sweep)垃圾回收器工作原理
CMS 可以把标记阶段分为三部分,初始标记、并发标记、重新标记;
初始标记
标记 GCRoots 直接关联的对象,暂停所有线程,因为只标记GcRoots直接关联的,所以时间短;
并发标记
用户线程不用暂停,继续进行可达性标记,标记 GcRoots 间接引用的对象,这个过程可能会很长,因为间接引用的对象可能会很多很多,所以 CMS 让这块需要操作很长时间的走并发逻辑,但是这种情况下会存在标记不全的情况(因为是并发,比较的同时,用户线程也在制造垃圾对象),JVM 针对这块增加了重新标记;
重新标记
并发标记阶段用户线程产生的变动重新标记一下,暂停所有的用户线程;
CMS 把清理阶段分为了两部分,并发清理、重置线程;
并发清理
用户线程和GC同时执行,清理工作比较耗时;
重置线程
线程进行重置;
VM 启动 CMS 垃圾回收器
CMS 优缺点
CMS 影响的是最大响应时间,因为清理和标记是并发执行的,并不会其他又比较短,所以用户的感知卡顿就会降低;
CMS 缺点:
CPU敏感:在 CMS 中,既要跑用户线程也要跑垃圾回收线程,就会导致 CPU 比较敏感,如果 CPU 核心数还小于 4,对用户的影响比较大;解决方案就是加大 CPU 核心数;
浮动垃圾:清理阶段是并发的,如果这个时间段产生了垃圾,并不会被清理,这种垃圾就称为浮动垃圾;解决方案就是只能等待下一次的清理;
内存碎片:因为采用的是标记清除,由前面讲的标记清除算法来说,会产生内存碎片;解决方案,大部分的 Java 后端服务器都会在某个时间节点挂上服务维护,本质就是重启,清理所有的碎片;
如果浮动垃圾过多怎么办?
由于是并发清理,所以会产生浮动垃圾,当浮动垃圾过多或者内存碎片过多导致不能存放对象的时候,JVM 就将 CMS 切换成 Seial Old(标记整理算法) 执行清理;
G1 垃圾回收器
G1 垃圾回收器在内存划分做了特殊的处理,增加了 Humongous 区域;它的垃圾回收步骤类似 CMS,也是进行初始标记、并发标记、最终标记,它的核心思想是:筛选回收;
为什么会有筛选回收?因为在 G1 里面,它的内存布局进行了改变,以往的垃圾回收器都会走一个新生代和老年代,新生代是一个,老年代也是一个,按照整块的来划分,但是在 G1 它不在是一个了,它划分了很多的等份(Region 区),例如你的堆有 10G,它不关心,它进行等份的划分,划分的范围按照 1M - 32M,(会根据系统情况内存情况进行划分),假设内存较小的情况下,每块区域划分了 1M,就意味着每个区域是相等的,都是 1M(一般划分都会按照 2 的等次幂来划分的);划分以后不再是一块区域是新生代、一块区域是老年代,而且如果这个区域标记成 O 那么就是老年代,标记成 S 就是 Surivor 区,标记成 E 就是 Eden 区,标记成 H 就是 Humongous 区(大对象专属存放区),以往的大对象会直接分配到老年代,这个 Humongous 区域怎么认为是存放大对象呢?假设每个区域是 1M,如果分配的对象超过了 512KB,就会被认为是大对象,那么就需要分配到大对象专属区域,如果是 3M,就用三个连续的 1M 来存放;
整体思想:内存不再单纯的按照分代划分,但是里面还继续保留了新生代、老年代、交互区,另外多出了一个 Humongous(大对象专属存放区),
G1 中的 Eden Surivor 没有疑问,还是走复制回收算法,老年代和Humongous 走的是标记整理算法,但是它又不是完全的回收,走的是筛选回收,为什么是筛选回收呢?因为可能有硬性要求,要求 JVM 的最小暂停时间(追求暂停时间),JVM 的配置参数中,例如可能要求 JVM 的暂停时间不能超过 1000ms,所以 G1 会进行筛选一下,它根据一些算法来判定,筛选那些回收效率比较高的对象,标记阶段(初始、并发、最终)就可以确认哪些区域回收效率比较高,进行回收的时候,就不用关心其他区域了,只回收标记的回收效率比较高的区域,那么回收这些区域的时候,它的 stop the world 的时间就是可预测的(可预测暂停),但是这个可预测停顿并不是绝对的;
G1 设置最大暂停时间参数
-XX:MaxGCPauseMills = 500
可能会有人有疑问了,G1 的内存碎片不是更严重了吗?
经过实验研究,CMS 和 G1 的平衡点而言,如果堆空间在超过 8G 的时候,G1 的回收效率是高于 CMS 的,它没有内存碎片,只是划分了不同的区域,如果堆空间比较小的话,追求效率还是使用 CMS;
Stop The World
垃圾回收器中无论是单线程、还是多线程、无论是并行还是并发,都会暂停用户线程;这就是 Stop The World;
如果不暂停用户线程,那么是清理不干净的;
Stop The World 带来的危害就是用户卡顿;
通过开篇的示例代码,可以查看 Stop The World 带来的卡顿
正常我们的打印还是比较规整的,每隔 0.1s 打印一次,但是当发生 Full GC 的时候,我们的打印间隔了 0.6s;
常量池与String
常量池
常量池中其实划分了静态常量池、运行时常量池、还有字符串常量池;
静态常量池
就是我们所说的 class,我们找一个 class 通过 javap -v 看下
class 其实是存放在了常量池中;
静态常量池中都有哪些内容呢?
- 字面量,String i = "James";这个 James 就是字面量;
- 符号引用,String 这个类,java.lang.String 这个 java.lang 就是符号引用,符号引用包含一个类或者方法的权限命名;
- 类的、方法的信息;
字符串常量池属于静态常量池的一部分;
运行时常量池
所有的类经过类加载,都会加载到运行时数据区(方法区),无论 JVM 怎么实现,这块区域其实都叫做逻辑区域,不管是在堆区也好还是直接内存也罢,JVM 其实不关心,只需要有这个运行时常量池的概念即可;符号引用最终要对应到一个实体,运行的时候要找到对应的实体,实体怎么去找?根据对象头的 hashMap,这个 hashMap 对应一个实体,所以符号引用在处理的时候必定要变成直接引用(hash值),所以运行时常量池存放的就是对象实体的直接引用(hash 值);
String 创建和内存分配
String str = "abc";
JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建;这种方式可以减少同一个值的字符串对象的重复创建,节约内存;
String str1 =new String("abc");
首先在编译类文件时,"abc" 常量字符串将会放入到常量结构中,在类加载时,"abc" 将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的 "abc" 字符串,在堆内存中创建一个 String 对象;最后,str1 将引用 String 对象。
String str2= "ab" + "cd" + "ef";
首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象;
String a = new String("James").intern();
String b = new String("James").intern();
if(a==b) {
System.out.print("a==b");
} else {
System.out.print("a!=b");
}
new Sting() 会在堆内存中创建一个 a 的String对象,"James" 将会在常量池中创建,在调用intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
调用 new Sting() 会在堆内存中创建一个b的String 对象,在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
所以 a 和 b 引用的是同一个对象。
简历润色
深度理解JVM垃圾回收,可基于垃圾回收原理做内存优化;
下一章预告
什么是 ART?什么是DVM?
欢迎三连
来都来了,点个赞,点个关注吧~~你的支持是我最大的动力~~~
相关推荐
- Nucleus Addons 最好的WordPress通用插件下载 博客插件模块
- 如何应对Android面试官->嵌套滚动原理大揭秘,实战京东首页二级联动
- 如何应对Android面试官->布局原理与xml解析,手写插件化换肤框架核心实现(下)
- 全栈加持,让面试官小抄再次进化!
- 如何应对Android面试官->布局原理与xml解析,手写插件化换肤框架核心实现(上)
- 算法演绎 | 巧妙的 Completer 完成器
- 为什么做app开发岗位的面试官时我很少面算法题?
- 如何应对Android面试官->手撸一个京东流式布局,MeasureSpec&LayoutParams 大揭秘
- 如何应对Android面试官->ART和Dalvik概论
- Android自定义View面试官最爱问的12个高级问题
- 程序开发学习排行
-
- 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常用插件下载博客插件模块添加精简版