java垃圾回收算法介绍,垃圾收集器详解

java垃圾回收算法介绍,垃圾收集器详解

 java与C/C++的有一个很显著的区别是,java回收失效的对象是自动的,即垃圾收集(Garbage Collection),而C/C++则要程序员自己释放内存,这样做有利有弊,但是对于程序员来说绝对是省心了不少,也减少了内存溢出的产生,作为一个程序员,如果想要进阶,垃圾收集是一定要明白的内容,向大家推荐周志明老师的《深入理解Java虚拟机》,我对JVM的绝大部分理解都来自于该书,下面我来介绍一下我对垃圾回收的理解。

 垃圾回收(GC),顾名思义就是对内存中的垃圾进行回收,自然,我们也发出了哲学三问:

  • 什么是垃圾?
  • 哪些是垃圾?
  • 怎么回收垃圾?

接下来我将一一回答这些问题


什么是垃圾?

垃圾回收主要发生于java堆中(方法区也有垃圾回收,但不占主要部分所以本文不涉及),堆中存放着java世界中所有的对象实例,这些对象实例占据了堆的绝大部分,我们要回收的,便是其中那些不再使用的对象实例,这些不再使用的对象实例,便是垃圾。


哪些是垃圾?(对象存活判定方法)

垃圾回收首先遇到的问题便是要回收哪些对象,首先,存活的对象肯定是不能回收的,所以我们要回收的就是哪些没有存活的对象,那么怎么判断一个对象是否存活呢?在Java中,一个对象是否存活主要是由他是否被引用来判断的,这相当好理解,一个没有被引用的对象就是一个没有被用到的对象,用不到的对象自然就是垃圾了。而根据引用,目前有两种方法用来判断对象是否存活:

  • 引用计数法
    • 使用引用计数法,会在对象中加入一个引用计数器,有一个地方引用时计数器就加一;引用失效时,计数器就减一;当计数器为0的时候这个对象就不可再用了,即变成了垃圾,等待回收。
    • 引用计数法虽然简单,但有一些问题不好处理,比如无法解决循环引用,当两个对象互相引用,那么这两个对象就永远不会被清除。
  • 可达性分析算法
    • 这个算法的思想是:通过一系列被称为GCRoots的根对象作为起始节点集,从这些节点开始根据引用关系向下检索,遍历的路径被称为‘引用链’,被检索到的对象代表正在使用(即存活),而遍历完后,没有被遍历到的对象则是不可达的,也说明这个对象不可能被使用(即垃圾)。
    • 这样避免了循环引用的出现,因为即使两个对象出现了互相引用,如果这两个对象没有被其他存活的对象引用,那么这两个对象就不会被遍历到,也就变成了垃圾
    • 在java中,GCRoots对象主要包括以下几种:
      • 虚拟机栈中引用的对象
      • 本地方法栈中引用的对象
      • 静态变量中引用的对象
      • 常量池中引用的对象(字符串常量池、运行时常量池)
      • 虚拟机内部的引用(异常对象、class对象)
      • 加锁的对象
    • 注:目前所有的垃圾收集器均采用可达性分析算法来判断对象是否存活

垃圾收集算法

讲完了什么是垃圾,我们来看一下怎么回收垃圾,目前,主要由两种垃圾收集算法,分别是:

  • 引用计数式垃圾收集(Reference Counting GC)也叫‘直接垃圾收集’
  • 追踪式垃圾收集(Tracing GC)也叫‘间接垃圾收集’

由于引用计数式垃圾收集在主流垃圾收集器中均未使用,本节只介绍追踪式垃圾收集

分代收集理论

最初的几款垃圾收集器都遵循了分代收集的设计思想,所以有必要了解一下什么是分代收集。根据程序运行的实际情况,人们总结了以下两条规律:

  • 绝大多数对象都是朝生夕灭的
  • 熬过越多次垃圾收集的对象就越难以消亡

根据这两条规律,在设计垃圾收集器时,人们将堆空间划分为不同的区域,用不同的回收算法回收不同特点的对象。比如一个区域的对象大部分都是朝生夕灭的,而另一个区域的对象大部分都是一直存活的,那么显然,回收的算法选择就不同。具体到java中,在老版本商用的java虚拟机中,一般将堆区分为新生代老年代两个部分。

  • 新生代
    • 顾名思义,新生代的对象都是年纪比较小的,每次垃圾回收之后大部分都会死去,而存活下来的对象将会逐步被放入老年代中。
  • 老年代
    • 老年代中存放哪些在新生代垃圾回收过程中存活下来的对象。
跨代引用与解决办法(记忆集)

由于引入了分代收集的理念,也由此带了新的问题,那就是跨代引用。我们要清理新生代垃圾的时候,其实完全有可能有老年代的对象引用了新生代的对象,这就不得不在每次根节点枚举的时候再额外遍历老年代的所有对象,这显然效率很低,所以,为了解决跨代引用的问题,引入了记忆集结构,记忆集存储了老年代对象对新生代对象的引用(G1收集器实现的更复杂)。我将在Hotspot虚拟机实现中详细介绍。

标记-清除(Mark-Sweep)算法

标记清除算法是最基础的垃圾收集算法,标记复制和标记整理算法都是在标记清除算法的基础上发展而来。
标记清除算法的执行过程是:

  1. 标记需要回收的对象
  2. 将被标记的对象回收

也可以反过来标记不需要回收的对象,然后将没有标记的对象回收。

标记清除算法有两个缺陷:

  • 执行效率不稳定,如果一个区域有大量的对象都需要被标记然后回收,会导致效率变差
  • 导致内存碎片化问题,标记清除后会产生大量不连续的内存空间,如果这时分配了一个占用内存很大的单个对象,会产生明明有空间却无法分配对象的情况,这对持续工作的系统非常麻烦。

注:标记清除通常用于老年代收集

标记-复制(Mark-Copy)算法

为了解决标记清除算法中对大量对象被标记回收会导致效率变差以及内存碎片化的问题,标记复制算法应运而生。最基本的标记复制算法工作过程为:

  1. 将内存空间分为两个部分
  2. 每次垃圾回收时,将一个部分的内存空间中的存活对象标记
  3. 将被标记的对象复制到另一个部分的内存空间
  4. 将被复制的内存整个释放

标记复制算法解决了标记清除算法遇到的两个问题,但同时也存在自己的缺陷:

  • 如果每次垃圾回收的时候一个内存部分都有大量的存活对象,会造成大量的内存复制开销。
  • 将内存空间分成两部分,事实上我们能用的只有其中的一部分,造成了我们实际可用内存的减少

要解决标记复制算法遇到的问题,我们只要用分代收集理论就可以了。

  • 根据分代收集理论,我们将整个堆内存分为新生代和老年代,而新生代绝大多数的对象熬不过第一次收集(根据专家的研究,新生代中98%的对象熬不过第一次收集),这样内存复制开销就小了;
  • 并且由于新生代对象朝生夕死的特性,我们也不需要将内存空间分为大小差不多的两部分,以apple式回收为例:
    • 我们将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块survivor。
    • 发生垃圾收集时,在使用中的Eden和survivor将存活的对象全部转入未使用的survivor中
    • Eden和survivor空间的比值为8:1,也就是说,在新生代中,只有10%的空间被浪费了
    • 当然,谁也不能保证每次垃圾回收只有10%的对象存活,如果超过survivor的空间限制,apple式回收将启用逃生门设计,使用老年代来存储存活对象,进行分配担保。

注:标记复制算法主要用于新生代的垃圾收集

标记-整理(Mark-Compact)算法

新生代的特性非常适合使用标记复制算法,而对于老年代来说,由于大部分对象在垃圾收集中都可以存活,则标记复制算法并不适用;由于只需要回收少部分对象,标记清除算法就比较时候在老年代使用,但是标记清除算法没办法解决内存碎片化的问题,所以出现了标记整理算法。

注:标记整理算法主要用于老年代的垃圾收集。

标记整理算法的过程如下:

  1. 标记要回收的对象
  2. 将标记的对象清除
  3. 把剩余的对象向内存空间的一端移动

由于移动对象的操作会使引用失效,所以,在移动对象的时候,用户进程是必须要暂停的,而不移动的话又会造成内存碎片化的问题,所以,一般采取的的是标记清除与标记整理共同使用,只有在内存碎片化不可接受时使用标记整理算法。


HotSpot的算法实现细节

根节点枚举

  • 根节点枚举就是将所有的GCRoots找出来的过程,GCRoots主要由全局性的引用(如静态变量或常量)和执行上下文(即虚拟机栈、本地方法栈中的变量)组成
  • 由于在根节点枚举的过程中,为了防止引用没有被获取到GCRoots中,根节点枚举这个步骤是必须要停顿用户线程的
  • 而java应用随着时间的发展做的越来越庞大,堆中的对象也变得越来越多,根节点枚举停顿的时间也越来越长,而停顿用户线程时间太长是不可接受的,在HotSpot虚拟机中,主要采用OopMap的数据结构来优化根节点枚举的效率
    • 优化获取全局性引用的效率:在类加载完成之后,虚拟机会把对象内各个位置的是什么类型的数据计算出来,然后存储到Oopmap中,这样在根节点枚举时便可以直接获取数据。
    • 优化获取执行上下文的效率:在特定的位置(安全点和安全区域)记录下栈中哪些位置是引用,然后在再存储到OopMap中

安全点

前面讲到了根节点枚举通过OopMap结构优化了根节点枚举的效率,可是,如果为每一条正在执行的指令中的引用都记录到OopMap中,开销是很大的,并且根节点枚举之前是必须要停顿用户线程的,所以,为了优化生成Oopmap,HotSpot虚拟机使用安全点来停顿用户线程。

所谓安全点就是每个线程在执行过程中都会轮询最近的安全点,当我们需要中断线程的时候,就把安全点设置一个中断标志,这样,线程轮询时获取到中断标志,就会停顿在安全点上,并主动挂起。

安全点基本上是选取让程序长时间执行的位置,最明显的就是指令序列复用,比如方法调用、循环、异常跳转等

安全区域

安全点解决了正在执行的线程的停顿操作,但是处于sleep或者blocked状态的线程是没办法执行轮询操作的,那怎么办呢,HopSpot虚拟机引入了安全区域的概念。

安全区域指的是线程在这一个区域内引用关系不会发生变化,这样在垃圾处理的时候从安全区域任何位置查找GCRoots都可以,在线程要出安全区域时,要先查询一下是否还处在根节枚举的状态,如果还在枚举中,就一直在安全区域等待,如果不在枚举,那就继续执行。

记忆集与卡表

在分代收集理论中,我们知道记忆集是用来记录跨代指针的,这样就不必遍历整个老年代,在HotSpot中,记忆集是以卡表的形式实现的,具体实现为:

  • 卡表中存在多个卡页
  • 一张卡页中存在一个或多个对象
  • 当一张卡页中有至少一个对象存在跨代引用,则将该页的值表示为1,表示变脏,没有跨代引用的标识为0.
  • 在垃圾收集时,寻找变脏的卡页,将卡页内的所有对象加入GCRoots扫描

写屏障

通过记忆集,我们可以在垃圾收集时不整个遍历老年代,可是,任何本分代的对象引用了其他分代的对象的时候,对应的卡表就需要变脏,如何实现这个操作呢?

在HotSpot虚拟机中,是通过写屏障来实现维护卡表的状态的。写屏障在虚拟机层面的含义就是对引用类型的字段的复制操作的AOP切面,在赋值前操作称为“写前屏障”,同理,在赋值后操作被称为“写后屏障”。

具体到卡表中就是,在一个引用类型赋值时触发了写屏障,系统会查看其赋值的内存地址是否是本分代,如果不是,就将容纳该对象的卡表变脏。

并发的可达性分析

在之前的概念中,我们知道,垃圾收集器是通过可达性分析算法来判定对象是否存活的,而可达性分析算法理论要求是要在一个可以保持一致性的快照中进行,这样便意味着要在遍历对象的时候要停顿用户线程。

随着java应用的发展,java的工程变得越来越大,堆中的对象也越来越多,毫无疑问,随着对象的增多,遍历对象的时间也会增多,要停顿的时间也会更多,如果停顿时间过长,是非常影响性能的,所以,可达性分析算法必须要与用户线程并发进行。并发执行的关键就是保持快照的一致性。

如何保持快照的一致性?HotSpot虚拟机中给予了两种不同的方式:

  • 增量更新。在并发可达性分析开始后的新增引用将被保存,在可达性分析执行完成后以这些新增的引用为根重新遍历。即以可达性分析执行完成后的快照为执行快照。
  • 原始快照。在并发可达性分析开始之后的删除引用的操作,在可达性分析执行完成后以这些删除的引用为根重新遍历。即以可达性分析执行开始时的快照为执行快照。

垃圾收集器

新生代垃圾收集器

Serial 收集器

serial收集器是java最早的新生代收集器他有如下特点:

  • 单线程工作
  • 采用标记复制算法
  • 在进行垃圾收集时必须停顿用户线程
  • 迄今为止依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器
ParNew 收集器

ParNew收集器是Serial收集器的多线程版本,其他与Serial收集器并无太大区别,可以与CMS收集器配合吗,在资源少CPU核心数少的情境下性能不一定比得过Serial收集器,因为线程切换也会消耗很多资源。

  • 多线程
  • 采用标记复制算法

ParNew最后并入了CMS收集器,成为他处理新生代收集的部分。

Parallel Scavenge 收集器

Parallel Scavenge是一个多线程并行的新生代垃圾收集器,这和ParNew很像,那么他们俩有什么区别呢?最大的区别就是设计的目标不同,Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,吞吐量的意思就是处理器执行用户代码的时间与总消耗时间的比值。

吞吐量=用户代码执行时间/(用户代码执行时间+垃圾收集消耗时间)

因此,parallel scavenge收集器也被称作“吞吐量优先收集器”

  • 多线程
  • 吞吐量优先
  • 采用标记复制算法

老年代垃圾收集器

Serial Old 收集器

serial收集器的老年代版本

  • 单线程
  • 采用标记整理算法
Parallel Old 收集器

parallel scavenge 收集器的老年代版本,同样是吞吐量优先

  • 多线程
  • 采用标记整理算法
  • 吞吐量优先
CMS(Concurrent Mark Sweep)收集器
  • 并发低停顿收集器,垃圾收集与用户线程同时进行
  • 采用标记清除算法,由于标记清除算法产生的大量垃圾碎片,虚拟机设置了参数用于不得不fullgc时】
  • 进行碎片整理
  • 采用增量更新作为并发标记时保证对象图一致性的方式,也导致了一些引用被删除却无法gc,所以cms不能再老年代快要填满时才进行收集,必须要提前收集,预留一部分内存用来存放新对象
  • 如果预留的内存无法满足对象分配,就会产生并发失败,临时启用serial old收集器来回收老年代

不分代的垃圾收集器

G1垃圾收集器

与前面的分代垃圾收集器不同,G1垃圾收集器虽然保存了新生代老年代的概念,但是每个空间都可以是新生代或者老年代,是基于region布局设计的。G1将java堆划分成多个大小相等的独立区域,每个区域都可以是新生代(eden或survivor空间)或者老年代,每个区域都可以采取不同的垃圾回收算法来进行回收。分代收集器在垃圾收集时要么收集整个老年代,要么收集整个新生代,而G1可以让内存的任何部分组成回收集,主要的指数就是一个区域中垃圾的多少,如果垃圾多,就将他放入回收集,由于这个特点,所以他被称为Garbage First(垃圾优先)收集器。

由于分代的取消,跨代引用问题再G1中就变成了跨region引用的问题,如何解决呢?

  • G1对记忆集的结构进行了调整,记忆集不是一个数组了,而是一张哈希表,key是region的起始地址,而value则是卡表的索引号,这种记忆集比普通的卡表占用内存更大

G1的运作过程主要以下四个步骤:

  1. 初始标记,需要停顿用户线程,主要是关联GCRoots可以直接关联到的对象,耗时很短
  2. 并发标记,不需要停顿用户线程,遍历对象图,找到要回收的对象
  3. 最终标记,需要停顿用户线程,处理在并发标记阶段的删除引用
  4. 筛选回收,需要停顿用户线程,将各个region的数据进行统计,将垃圾多的region放入回收集,进行垃圾回收,将回收后存活的对象统一放到空的region中,再清理掉旧region的全部空间。
ZGC收集器
  • ZGC收集器的设计目标是低延迟
  • 采用region内存布局,暂时不分代
  • 采用并发的标记整理算法
  • 使用了读屏障、染色指针、内存多重映射等技术

zgc的region区别于G1的region,具有动态性——动态创建和销毁,大小是动态的,一般将region分为3类:

  • 小型region,2MB大小,用来存放256kb以下大小的对象
  • 中型region,32MB大小,用来存放大265KB但小于4MB的对象
  • 大型Region,大小不固定(2MB的整数倍),用来存放4MB一下大小的对象,每个大型region只存放一个对象,这也就意味着不一定大型region就比中型region容量大
ZGC并发的标记整理算法的实现方式——染色指针技术

ZGC的标志性技术就是他的`染色指针技术,在64位的linux系统中,linux只支持46位的物理地址空间,也就是指针只能指向265TB的内存,当然,256TB内存对现在的系统来说很明显用不到,所以zgc在这46位的指针宽度上打起了主意,将前4位用来存放标志信息,这样,内存地址空间缩小到了4TB,但是这也足够使用了。

4位标志信息主要存储了:

  • 该引用的遍历状态(是否未被引用,即三色状态)更新Marked0、Marked1标志位
  • 该引用是否进入重分配集,更新remapped标志位
  • 该引用是否只能通过finalize()方法访问到,更新finalizable标志位

zgc的垃圾收集过程主要分为:

  • 并发标记
    • 进行并发的可达性分析,在根节点枚举以及最终标记阶段会停顿用户线程
  • 并发预备重分配
    • 这个阶段主要是扫描全部的region,将需要回收的region放入重分配集。
  • 并发重分配
    • 将重分配集中的对象复制到新的region中,并给重分配集的每个region都维护一个转发表用来记载旧对象到新对象的转发关系,如果现在一个用户线程访问了位于重分配集中的对象,会被预制的读屏障截获,并根据转发表的记录转发到新复制的对象上,并将这个引用修正成新对象的引用,即只会访问一次旧对象
  • 并发重映射
    • 将重分配集中的所有旧引用全部修正,并删除转发表

垃圾回收常见题目

jdk789的默认垃圾收集器

jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)

jdk1.9 默认垃圾收集器G1

-XX:+PrintCommandLineFlagsjvm参数可查看默认设置收集器类型

-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值