联系我们
简单又实用的WordPress网站制作教学
当前位置:网站首页 > 程序开发学习 > 正文

如何应对Android面试官->玩转垃圾回收算法

作者:访客发布时间:2023-12-26分类:程序开发学习浏览:97


导读:前言垃圾回收算法与垃圾收集器分代收集理论理论来源于两个结论:绝大部分的对象都是朝生夕死;对象熬过多次垃圾回收,越难被回收掉;所以会把这种两种对象分别放在不同的区域,朝...

前言

垃圾回收算法与垃圾收集器

分代收集理论

理论来源于两个结论:

  1. 绝大部分的对象都是朝生夕死;
  2. 对象熬过多次垃圾回收,越难被回收掉;

所以会把这种两种对象分别放在不同的区域,朝生夕死的放在新生代,很难被回收掉的放到老年代;

什么是分代收集理论?在 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 中的垃圾回收都是通过垃圾收集器来执行的,常见的有以下几种;

  1. 单线程垃圾收集器;
  2. 多线程并行垃圾收集器
  3. 多线程并发垃圾收集器;

早先的 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 其实是存放在了常量池中;

静态常量池中都有哪些内容呢?

  1. 字面量,String i = "James";这个 James 就是字面量;
  2. 符号引用,String 这个类,java.lang.String 这个 java.lang 就是符号引用,符号引用包含一个类或者方法的权限命名;
  3. 类的、方法的信息;

字符串常量池属于静态常量池的一部分;

运行时常量池

所有的类经过类加载,都会加载到运行时数据区(方法区),无论 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?

欢迎三连

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


标签:玩转算法如何应对面试官垃圾GT


程序开发学习排行
最近发表
网站分类
标签列表