java是一门内存动态分配的语言,因此就需要考虑到垃圾的收集和回收,主要考虑下列三个方面
哪些内存需要回收?
什么时候进行回收?
如何进行回收?
哪些内存需要回收
- 对于java来说,内存运行时区域主要分为与线程有关的和无关的,本地方法栈,虚拟机栈,程序计数器这三个区域随线程创建而创建消亡而消亡,因此不需要考虑;而方法区和堆内存是共享区域,因此需要考虑,由于方法区已经被元空间代替,因此垃圾回收主要是在堆上进行回收的
- (栈)基本上每一栈帧分配多少内存是在类的结构确定下来的时就已知的,也就是编译期就已知的,因此内存分配和回收都具有确定性。
- 为什么每一栈帧分配的内存是确定的? 原因是 每个方法被执行的时候,虚拟机会创建一个栈帧,其中存储着局部变量表,操作数栈,动态连接,方法出口信息,局部变量表中存放了编译期可知的各种基本类型数据,对象引用类型,也就是对象的句柄或者指针,和returnAddress类型,局部变量表在编译期间内存分配就已经完成了,在运行期间不会改变;一个方法具体消耗多少空间,跟其引用的对象的大小以及对应的方法的实现大小有关,但是对于局部变量表来说,都是一个指针,因此不需要进行变更大小
- (堆)一个接口的多少实现类需要的内存可能不一样,一个方法所执行的不同条件分支所需要的内存也不一样,只有处于运行期我们才知道程序究竟会创建哪些对象,创建多少对象,哪些是可以进行回收的,这也是对堆内存上垃圾回收的主要原因
什么时候进行回收
- 判定依据是对象是否已经死亡?主要通过引用计数法和可达性分析法,由于引用计数法无法解决已死亡对象之间的循环引用的问题,因此使用可达性分析来进行升级版的查询对象是否已死问题
- 引用计数法:对象中添加一个引用计数器,用于记录对象的使用情况,当使用次数为0的时候,标志着可以进行垃圾回收;虽然会有一个记录的开销,但是简单高效,判断效率高,大多数情况下是一个不错的选在,netty中就有使用引用计数法来回收对象的操作;但是在主流的java虚拟机中没有使用引用计数法来管理内存的,因为没有高效的办法确定引用次数不为0的对象一定是不需要回收的对象,容易产生内存泄漏
- 可达性分析法: 如果某个对象到Gc Roots之间没有任何引用链的话,认为对象不可能再被使用
- 在java技术体系中固定可作为GC Roots的对象包括:
- 在虚拟机栈帧中引用的对象,比如各个线程被调用的方法堆栈中使用的参数,局部变量,临时变量
- 方法区中类静态属性引用的对象,java类的引用类型静态变量(这也就是为什么静态变量不会被回收的原因)
- 方法区中常量引用的对象,譬如字符串常量池里的引用
- 在本地方法栈中JNI引用的对象,即native方法
- java虚拟机内部的引用,如基本类型对应的Class对象,一些常驻的异常对象,还有类加载器
- 所有被同步锁持有的对象
- 反映java虚拟机内部情况的JMXBean,JVMTI中注册的毁掉,本地代码缓存等
- 在java技术体系中固定可作为GC Roots的对象包括:
- 什么时候进行回收,主要是在可达性分析之后,是否存在与GC Roots有相关的引用链,如果没有的话,会被进行标记筛选,筛选的条件是对象是否有必要执行finalize()方法,假如对象没有覆盖该方法,或者该方法已经被调用过,那么虚拟机就要执行垃圾回收了,否则的话还会执行finalize()方法,如果该方法中有复活的操作的话,对象可能进行复活
如何进行回收
- 分代收集理论奠定了当前绝大多数虚拟机的垃圾回收器的设计规则;
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次数垃圾回收过程的对象就越难消亡
- 跨代引用假说:跨代引用的对象相对于同代引用来说是极少的
- 主要使用三种算法进行操作:标记-清除算法,标记-复制算法,标记-整理算法;
- 标记清除算法,会产生大量的内存碎片,导致无法处理大对象的分配
- 标记复制算法,需要将内存划分成两个区域,一个是使用过的,一个是空闲的,不断将对象从使用区域移到未使用区域;造成空间浪费,但是根据二八定理的延伸,扩展出了新生代的内存分配形式,8:1:1 来将使用内存,未使用内存进行划分,减少了内存的浪费
- 标记整理算法,是复制和清除的结合,既高效又减少内存的浪费;但是缺点是回收的时候会更复杂,产生的停顿时间更长;因为如果移动对象的话,需要重新处理指针问题,如果不移动对象的话,需要处理内存分配上的碎片问题
HotSpot 解决对象已死的问题的实现细节
- 整个垃圾回收器在执行垃圾回收时候的理论依据,以及如何解决并发收集,枚举等问题的准则
- 根节点枚举:可达性分析已经可以做到与用户线程并发执行,但是根节点枚举始终还是必须在一个能保障一致性的快照中进行,以保证在分析的过程中根节点集合对象的引用关系不发生变化,现在还没有一款收集器在根节点枚举时可以和用户线程并发执行;
- 那么如何进行枚举呢?是一个不漏的从GC Roots对象中进行查询么?目前主流java虚拟机都是使用准确式垃圾收集,虚拟机在stop the world的时候,可以从某个地方直接获取对象的引用,然后进行分析的;hotspot使用一组OopMap的数据结构来记录对象的引用
- 安全点:主要是针对于如何记录OopMap来说的,虚拟机不会为每条指令都记录一个OopMap,只有在特定位置才会进行记录这些信息,这些特定位置就是安全点,安全点选用的标准基本上是 程序是否长时间执行的特征,比如方法调用,循环跳转,异常跳转等,方法达到安全点之后进行OopMap操作,然后才能快速的进行根节点枚举;
- 那如何使程序达到安全点就是一个问题,主要有两种思路,第一种抢先式中断(到达某个地方就不执行了,几乎没有虚拟机使用),第二种主动式中断,在垃圾回收的时候,系统先让所有线程中断,然后不在安全点上的线程恢复执行,直到跑到安全点上
- 安全区域: 安全点只能够保证程序是在执行的情况下能够到达,假如程序休眠了的话,如何处理,因此引入了安全区域的概念,安全区域是🈯️能够确保在某一段代码片段之中,引用关系不会发生变化,因此在这个区域中任意地方开始垃圾收集都是安全的;如果在执行垃圾收集过程中,用户线程休眠结束了的话,用户线程会首先监测是否已经完成根节点枚举,如果没有完成则等待,直到收到可以离开的信号为止
- 记忆集与卡表:为了解决跨代引用的问题,垃圾收集器在新生代中建立了Remembered Set 的数据结构,用于记录从非收集区域指向收集区域的指针集合的映射,减少GC Roots的枚举范围,提高垃圾收集的效率;
- 记忆集的精度和维护成本成正比,精度越高,维护成本越大;一般来说有三种精度可以选择
- 字长精度:每个记录精确到一个机器字长,即从一个内存指针到另一个内存指针 ,最高精度
- 对象精度:每个记录精确到一个对象,对象中包含跨代引用的指针
- 卡精度:精确到一块内存区域,该区域中包含跨代指针
- 卡表就是实现了卡精度的记忆集,用一个字节数组来维护整个内存区域,每一个数组元素(称之为卡页)指向一个特定大小的内存区域,一般来说大小是2的N次幂,hotspot中是2的9次幂;一个卡页中不止有一个对象,只要卡页内有一个以上的对象字段存在着跨代指针,认为这个卡页是脏页,需要扫描;一般用0和1进行标示
- 记忆集的精度和维护成本成正比,精度越高,维护成本越大;一般来说有三种精度可以选择
- 写屏障:卡表可以维护跨代引用的内存区域,但是如何维护卡表,什么时候让卡表变脏,变脏时间点原则上在对象赋值的那一刻上。因此在编译期间将对象赋值做一个aop的切面,在前后生成一个机器指令流;G1一般都使用写后屏障,即环切后半部分;另一个优化是,在维护卡表的时候,需要注意到伪共享,也就是说cpu缓存比较大,卡表只是其中一个字节,在变脏卡表的时候,先检查该卡表是否已经是脏卡表,如果是的话,就不做处理,这样的话,就不会因为更新了一个没有必要更新的字节而影响整个内存的正确性; 使用-XX:+UserCondCardMark,来决定是否开启卡表更新的条件判断;
- 并发的可达性分析:在分析对象是否已死的问题上,理论上要求全过程都是在一个能够保障一致性的快照上进行的,但是如果堆内存比较大的情况下,可达性分析的停顿时间会比较长,因此需要并发的进行可达性分析;
- 如何在并发可达性分析呢,经典的三色标记法证明了同时满足以下两个条件的时候(会产生对象消失问题,将正常对象进行了垃圾回收,这是一个很致命的问题): 赋值器插入了一条或多条从黑色对象到白色对象的新引用;赋值器删除了全部灰色对象到该白色对象的直接或间接引用;
- 三色法是,黑色的是可用对象,灰色的是被垃圾回收器访问过的对象,但是至少还有一个引用,白色的是可以被回收的对象;三色追踪的时候,是从根节点开始(黑色) ,逐渐枚举,遇到一个对象,如果这个对象的所有引用都被扫描过且都有引用的时候,标记为黑色,如果不是全部被引用的话,标记成灰色;在并发收集的时候会产生什么问题呢?如果赋值器插入了一个或多个直接从黑色到白色对象的新引用,由于黑色对象是扫描过的对象,因此该白色对象是不会再次被扫描的,同时又将本来能够触发下次扫描的灰色对象和该白色对象之间的关系给删除了,就会导致最终该白色对象被回收;
- 两种解决方法,也就是破坏其中的一个条件即可:增量更新(在扫描完之后,对那些新插入的记录再次进行扫描,CMS垃圾收集器就是使用这个套路进行并发可达性分析),原始快照(当灰色对象要删除指向白色对象的引用关系的时候,需要记录下来,等并发扫描之后再次从灰色(原本的灰色,现在也许是黑色,但如果是白色的话,就不需要进行扫描)对象为根进行一次扫描,G1和Shenandoah主要靠原始快照来解决问题)https://patchouli-know.com/2020/04/05/reachability-analysis-of-concurrent/
- 如何在并发可达性分析呢,经典的三色标记法证明了同时满足以下两个条件的时候(会产生对象消失问题,将正常对象进行了垃圾回收,这是一个很致命的问题): 赋值器插入了一条或多条从黑色对象到白色对象的新引用;赋值器删除了全部灰色对象到该白色对象的直接或间接引用;
- 根节点枚举:可达性分析已经可以做到与用户线程并发执行,但是根节点枚举始终还是必须在一个能保障一致性的快照中进行,以保证在分析的过程中根节点集合对象的引用关系不发生变化,现在还没有一款收集器在根节点枚举时可以和用户线程并发执行;
经典的垃圾回收器解决如何进行回收对象
-
曾经的高效收集器,在G1等收集器出现之后,逐渐被替代;G1 垃圾收集器是在java9之后默认的垃圾收集器,在执行效果上有着革命性的改进
Serial 收集器: 最原始的收集器,单线程可达性分析,以及单线程回收,使用标记复制的方式对新生代进行收集;
-
Serial Old 收集器: 同Serial一样,都是单线程进行的,整个收集过程中stop the world,老年代使用标记整理算法进行的;
- 整个serial系列的收集器的有点是简单高效,在内存资源比较紧张的客户端或者单核处理器的环境中,serial收集器没有线程并发的开销,专注于垃圾收集,可以获取单线程中的最高效率,适合场景一般是桌面应用;
ParNew收集器: Serial 收集器的多线程版本,在回收的时候,采用多线程进行回收,使用CMS垃圾收集器的话,新生代垃圾收集器首先选择ParNew进行收集;在java9之后,ParNew 合并入CMS,成为CMS专门处理新生代的垃圾回收器
Parallel Scavenge 收集器: 基于标记复制算法实现的收集器,能够进行并行的收集,与其他收集器不同的一点是,其他收集器注重降低用户的停顿时间,而该收集器注重达到一个可控制的吞吐量(运行用户代码时间/用户运行代码时间 + 运行垃圾收集时间)的收集器;主要的工作方式是通过设置各种参数,进行控制新生代的大小以及垃圾收集器的时间,将垃圾收集的频率提高,或者减小每次垃圾收集的时间(不一定能够完全的收集完成,需要多次)
Parallel Old 收集器: Parallel Scavenge 的老年代版本,支持多线程并发收集,基于标记整理算法实现;在资源稀缺的情况下,可以使用Parallel 系列的新生代和老年代垃圾收集器,也许能够达到对资源的合理利用
-
CMS垃圾收集器:原理上主要是使用增量更新来解决并发可达性分析,目标是以获取最短回收停顿时间,基于标记-清除算法实现,GC Roots 的时候,stp,然后并发标记,重新标记,以及并发清理;并发收集低停顿是CMS的优点;但是CMS有三个明显的缺点:
- CMS收集器对处理器资源非常的敏感,在并发阶段占用处理器的计算能力,导致程序变慢,吞吐量降低;默认启动的回收线程数是(处理器核心数量 + 3)/4,如果处理器的数量超过四个的话,资源占用不足25%,但是当处理器数量不足四个的时候,CMS对用户程序影响可能变大;
- CMS 收集器无法处理浮动垃圾,可能会出现并发收集失败,从而导致一次stp的fullGc的发生,浮动垃圾的产生:并发标记和并发清理过程中用户线程还在继续还在不断的产生新的垃圾,如果产生的垃圾过多的话,等不到下一次垃圾收集的话,会触发fullGC,在jdk6的时候,启动的fullGc的阀值已经达到了92%,当到达阀值的时候,虚拟机需要冻结用户线程执行,临时启用Serial Old对老年代进行垃圾收集;
- 基于标记-清除算法来实现的,会产生大量的空间碎片,但是提供了一个参数用于开始标记整理,这个参数在jdk9之后被废弃,标记整理的过程中移动的都是活的对象,无法进行并发收集,因此停顿时间会延长
- 并发收集失败并不意味着cms相比于其他经典垃圾收集器差,因为这只是一个尝试的过程,在正常的场景下,可以低停顿的进行收集,已经是节约时间了;
-
G1 垃圾收集器 Garbage First ,在jdk 8 update 40 之后,G1正式上线,是一款不分代的全功能垃圾收集器,主要面向服务端应用;
- 在G1之前,垃圾回收的时候,需要进行分代回收或者fullGc,但是G1是直接面向堆内的任何部分来组成回收集,不在衡量是哪个分代,而是靠哪个内存中存放的垃圾最多,回收的收益最大为标准进行垃圾回收,这就是G1 的 MixedGc模式;
- 内存划分上:G1 开创了将整个内存区域换成不同Region的设计思想,每个Region既可以是Eden空间、surrvior空间还可以是老年代空间,收集器对不同的空间角色采用不同的策略进行处理,同时G1设置 Humongous区域,专门用来存储大的对象,G1认为只要大小超过一个Region的一半容量的话,即可以判断成大对象,每个region的大小可以通过 -XX:G1HeapRegionSize来设定,一般取之在1-32M之间,超过Region的对象会被放到N个连续的Humongous Region中,G1的大多数情况下会把Humongous Region当作老年代来处理;
- 垃圾收集策略上: G1会维护一个收集价值表,用来跟踪各个region中垃圾堆的价值大小,每次根据用户设置的最小停顿时间来衡量计算哪些垃圾需要优先回收,以保证获取最大的收益;设置允许收集停顿时间的参数是 -XX:MaxGCPauseMillis 默认值是200毫秒,一般这个值设置在200-300之间是比较合理的,如果设置过小的话,每次回收的region数量优先,可能会最终导致fullgc
- 上述是G1的基本工作流程,那么实现上述工作流程需要解决的问题有以下几个:
- 如何解决Region中的跨Region引用问题,其他垃圾收集器使用卡表来维护,整个内存中就一份,但是G1这个数量多,维护起来更加麻烦;解决思路是每个region都维护自己的记忆集,并且在原来卡表的基础上增加了新的功能,原来卡表是单向指定,即直到某个内存区域到另一个内存区域的引用,现在是直接记录一个hash结构,key是region,valve是一个卡表的索引号;这也导致整个G1在内存占用上比其他收集器有着更高的占用率,根据经验表明,g1至少要消耗大约相当于java堆容量的10%-20%的额外内存来维持收集器的工作
- 如何解决并发标记(并发可达性分析),G1使用的是原始快照算法来实现并发可达性分析的,根CMS类似,在垃圾回收速度大于内存分配速度的情况下,也会产生full Gc; G1 会在每个region中设置两个指针,新分配的对象会拥有两个以上的标记,g1默认为存活,不进行回收
- 如何建立起可靠的停顿时间预测模型:一般是将垃圾收集过程中每个region的回收耗值,脏卡数量等信息做一个统计,然后计算出衰减均值,进行下一次评估
- 回收的四个过程 初始标记(单线程,标记下能够直接关联GCroots的对象) --- 并发标记(可与用户线程同步,GC Roots对象可达性分析) --- 最终标记(补漏) --- 筛选回收(停顿回收region)
- 与CMS的对比,内存占用和程序额外执行的负载都比CMS要高;在选用的时候根据实际服务器规格进行选用,大内存的话使用G1进行回收,一般临界点在6G到8G之间,小于这个规格的话,使用CMS否则的话使用G1