Java教程

Java虚拟机笔记 三、垃圾回收机制

本文主要是介绍Java虚拟机笔记 三、垃圾回收机制,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

以下图文基本来自周志明《深入理解Java虚拟机 第3版》

目录

    • 1. 概述
      • 1.1 为什么要去了解垃圾收集和内存分配?
      • 1.2 为什么垃圾回收机制主要服务于堆?
    • 2. 对象的存活判定(哪些内存需要回收?)
      • 2.1 引用计数算法
      • 2.2 可达性分析算法
      • 2.3 引用的概念
      • 2.4 不可达不一定回收
      • 2.5 方法区的回收
    • 3. 分代收集理论 (什么时候回收 )
      • 分代收集理论
      • 分代假说
      • 跨代引用
    • 4. 垃圾回收算法 (如何回收)
      • 标记-清除算法
      • 标记-复制算法(解决内存空间的碎片化问题)
      • 标记-整理算法 (解决执行效率不稳定问题)
      • 三种算法的辨析

1. 概述

在内存结构中的堆,用于对象的存放。而垃圾回收机制主要的作用就是对堆的内存进行管理。
垃圾收集(Garbage Collection, 简称GC)需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

1.1 为什么要去了解垃圾收集和内存分配?

当需要排查各种内存溢出、 内存泄漏问题时, 当垃圾收集成为系统达到更高并发量的瓶颈时, 我们就必须对这些“自动化”的技术实施必要的监控和调节。

1.2 为什么垃圾回收机制主要服务于堆?

程序计数器、 虚拟机栈、 本地方法栈3个区域随线程而生, 随线程而灭,基本是确定的。

栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化, 但在基于概念模型的讨论里, 大体上可以认为是编译期可知的) , 因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题, 当方法结束或者线程结束时, 内存自然就跟随着回收了。

而Java堆和方法区这两个区域则有着很显著的不确定性: 一个接口的多个实现类需要的内存可能
会不一样, 一个方法所执行的不同条件分支所需要的内存也可能不一样, 只有处于运行期间, 我们才
能知道程序究竟会创建哪些对象, 创建多少个对象, 这部分内存的分配和回收是动态的

垃圾收集器所关注的正是这部分内存该如何管理, 所以垃圾回收机制的学习中,“内存”分配与回收也仅仅特指这一部分内存(主要是堆的内存,方法区参见2.5)。

2. 对象的存活判定(哪些内存需要回收?)

堆里存放着几乎所有的对象实例, 垃圾收集器在对堆进行回收前, 第一件事情就是要确定这些对象之中哪些还“存活”着, 哪些已经“死去”(“死去”即不可能再被任何途径使用的对象) 。

2.1 引用计数算法

在对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器值就加一; 当引用失效时, 计数器值就减一; 任何时刻计数器为零的对象就是不可能再被使用的。

优点:原理简单, 判定效率也很高

缺点:无法解决对象之间相互循环引用的问题

主流的Java虚拟机里面都没有选用引用计数算法来管理内存

Q;什么是相互循环引用?
A:两个对象之间互相引用,而除此之外再无任何引用, 实际上这两个对象已经不可能再被访问,相当于孤立于对象网络之外。 但是它们因为互相引用着对方, 导致它们的引用计数都不为零, 引用计数算法也就无法回收它们。

2.2 可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集, 从这些节点开始, 根据引用关系向下搜索, 搜索过程所走过的路径称为“引用链”(Reference Chain) , 如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时, 则证明此对象是不可能再被使用的。
在这里插入图片描述
在Java技术体系里面, 固定可作为GC Roots的对象包括以下几种:

	·在虚拟机栈(栈帧中的本地变量表) 中引用的对象, 譬如各个线程被调用的方法堆栈中使用到的参数、 局部变量、 临时变量等。
	·在方法区中类静态属性引用的对象, 譬如Java类的引用类型静态变量。
	·在方法区中常量引用的对象, 譬如字符串常量池(String Table) 里的引用。
	·在本地方法栈中JNI(即通常所说的Native方法) 引用的对象。
	·Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如NullPointExcepiton、 OutOfMemoryError) 等, 还有系统类加载器。
	·所有被同步锁(synchronized关键字) 持有的对象。
	·反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等 
	
除了这些固定的GC Roots集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不
同, 还可以有其他对象“临时性”地加入, 共同构成完整GC Roots集合。

2.3 引用的概念

上述两个算法谈到对象之间的引用,那么怎样才是“引用”呢?

在JDK 1.2版之前, Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址, 就称该reference数据是代表某块内存、 某个对象的引用。

在JDK 1.2版之后, Java对引用的概念进行了扩充, 将引用分为强引用(Strongly Re-ference) 、 软引用(Soft Reference) 、 弱引用(Weak Reference) 和虚引用(Phantom Reference) 4种, 这4种引用强度依次逐渐减弱

  • 强引用:最传统的“引用”的定义, 是指在程序代码之中普遍存在的引用赋值, 即类似“Object obj=new Object()”这种引用关系。 无论任何情况下, 只要强引用关系还存在, 垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用:有用、非必须的对象。 只被软引用关联着的对象, 在系统将要发生内存溢出异常前, 会把这些对象列进回收范围之中进行第二次回收, 如果这次回收还没有足够的内存,才会抛出内存溢出异常。 在JDK 1.2版之后提供了SoftReference类来实现软引用。
  • 弱引用:非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。 当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。 在JDK 1.2版之后提供了WeakReference类来实现弱引用。
  • 虚引用:也称为“幽灵引用”或者“幻影引用”, 它是最弱的一种引用关系。 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响, 也无法通过虚引用来取得一个对象实例。 为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。 在JDK 1.2版之后提供了PhantomReference类来实现虚引用

强引用是核心骨干,有用且必须保留;软引用就是保留人员,系统还能跑就不会动它,不能跑了马上咔嚓;弱引用是炮灰如流水,下次就咔嚓你;虚引用连炮灰都不如,死的时候叫唤一声就行了。

2.4 不可达不一定回收

即使在可达性分析算法中判定为不可达的对象, 也不是“非死不可”的, 这时候它们暂时还处于“缓刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程:1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记;2. 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。 假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”。

附 对象的拯救办法(待记)

2.5 方法区的回收

《Java虚拟机规范》 中提到过可以不要求虚拟机在方法区中实现垃圾收集, 事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。

方法区垃圾收集的“性价比”通常也是比较低的: 在Java堆中, 尤其是在新生代中, 对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间, 相比之下, 方法区回收囿于苛刻的判定条件, 其区域垃圾收集的回收成果往往远低于此。

简而言之,方法区有点不配用来被回收,具体参见附 方法区的回收判定(待记)

3. 分代收集理论 (什么时候回收 )

确定了什么样的对象该被回收,接下来就要确定,该什么时候去进行一次回收内存的操作。

分代收集理论

收集器将Java堆划分出不同的区域, 然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数) 分配到不同的区域之中存储,分别控制其回收频率。

显而易见, 如果一个区域中大多数对象都是朝生夕灭, 难以熬过垃圾收集过程的话, 那
么把它们集中放在一起, 每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对
象, 就能以较低代价回收到大量的空间; 如果剩下的都是难以消亡的对象, 那把它们集中放在一块,
虚拟机便可以使用较低的频率来回收这个区域, 这就同时兼顾了垃圾收集的时间开销和内存的空间有
效利用

一般至少会把Java堆划分为新生代(Young Generation) 和老年代(Old Generation) 两个区域 。

分代假说

当前商业虚拟机的垃圾收集器, 大多数都遵循了“分代收集”(Generational Collection)的理论进行设计, 分代收集名为理论, 实质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的(创建之后很快被回收的,绝大多数会在新生代区域)。
  • 强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡(每次扫黄都没有你,死不掉的容易进入老人院…不是,老年代区域)。

由上述两点可推出第三点假说:

  • 跨代引用假说( Intergenerational Reference Hypothesis) : 跨代引用相对于同代引用来说仅占极少数 (顽强的对象互相引用,大家一起顽强,顽强的对象和易死对象之间存在引用是很少的情况)。

存在互相引用关系的两个对象, 是应该倾向于同时生存或者同时消亡的。 举个例子, 如果某个新生代对象存在跨代引用, 由于老年代对象难以消亡, 该引用会使得新生代对象在收集时同样得以存活, 进而在年龄增长之后晋升到老年代中, 这时跨代引用也随即被消除了

跨代引用

假如要现在进行一次只局限于新生代区域内的收集(Minor GC) , 但新生代中的对象是完全有可能被老年代所引用的, 为了找出该区域中的存活对象, 不得不在固定的GC Roots之外, 再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性, 反过来也是一样。

但是根据跨代引用假说,考虑解决跨代引用遍历带来的的负担

依据这条假说, 我们就不应再为了少量的跨代引用去扫描整个老年代, 也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用, 只需在新生代上建立一个全局的数据结构( 该结构被称为“记忆集”, Remembered Set) , 这个结构把老年代划分成若干小块, 标识出老年代的哪一块内存会存在跨代引用。 此后当发生Minor GC时, 只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。 虽然这种方法需要在对象改变引用关系( 如将自己或者某个属性赋值) 时维护记录数据的正确性, 会增加一些运行时的开销, 但比起收集时扫描整个老年代来说仍然是划算的。

部分收集( Partial GC) : 指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:
新生代收集( Minor GC/Young GC) : 指目标只是新生代的垃圾收集。
老年代收集( Major GC/Old GC) : 指目标只是老年代的垃圾收集。 目前只有CMS收集器会有单
独收集老年代的行为。 另外请注意“Major GC”这个说法现在有点混淆, 在不同资料上常有不同所指,
读者需按上下文区分到底是指老年代的收集还是整堆收集。
混合收集( Mixed GC) : 指目标是收集整个新生代以及部分老年代的垃圾收集。 目前只有G1收
集器会有这种行为。
整堆收集( Full GC) : 收集整个Java堆和方法区的垃圾收集

4. 垃圾回收算法 (如何回收)

标记-清除算法

算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象, 在标记完成后, 统一回收掉所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回收所有未被标记的对象。
在这里插入图片描述

主要缺点:

1.执行效率不稳定, 如果Java堆中包含大量对象, 而且其中大部分是需要被回收的, 这时必须进行大量标记和清除的动作, 导致标记和清除两个过程的执行效率都随对象数量增长而降低;

2.内存空间的碎片化问题, 标记、 清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法(解决内存空间的碎片化问题)

将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bHPTE5EJ-1636601450398)(C:\Users\xiang\AppData\Roaming\Typora\typora-user-images\1600845010608.png)]

这样实现简单, 运行高效, 不过其缺陷也显而易见, 这种复制回收算法的代价是将可用内存缩小为了原来的一半, 空间浪费未免太多了一点。

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空间。 HotSpot虚拟机默认Eden和Survivor的大小比例是8∶ 1, 也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%) , 只有一个Survivor空间, 即10%的新生代是会被“浪费”的。

当然, 98%的对象可被回收仅仅是“普通场景”下测得的数据, 任何人都没有办法百分百
保证每次回收都只有不多于10%的对象存活, 因此Appel式回收还有一个充当罕见情况的“逃生门”的安
全设计, 当Survivor空间不足以容纳一次Minor GC之后存活的对象时, 就需要依赖其他内存区域(实
际上大多就是老年代) 进行分配担保(Handle Promotion) 。

标记-整理算法 (解决执行效率不稳定问题)

标记过程仍然与“标记-清除”算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存 。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v3NPxmHY-1636601450402)(C:\Users\xiang\AppData\Roaming\Typora\typora-user-images\1600846189218.png)]
存在问题:如果移动存活对象, 尤其是在老年代这种每次回收都有大量对象存活区域, 移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作, 而且这种对象移动操作必须全程暂停用户应用程序才能进行, 这就更加让使用者不得不小心翼翼地权衡其弊端了, 像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。

三种算法的辨析

  1. 标志-清除 vs 标志-整理
    标记-清除算法那样完全不考虑移动和整理存活对象的话, 弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。

    譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间, 能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的) 。 内存的访问是用户程序最频繁的操作, 甚至都没有之一, 假如在这个环节上增加了额外的负担, 势必会直接影响应用程序的吞吐量。

    标志-整理算法解决了碎片化的问题,但移动对象要更新引用并且触发时停。

  2. 移动对象的实际吞吐量更高

是否移动对象都存在弊端, 移动则内存回收时会更复杂, 不移动则内存分配时会更复杂。 从垃圾收集的停顿时间来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看, 移动对象会更划算。 即使不移动对象会使得收集器的效率提升一些, 但因内存分配和访问相比垃圾收集频率要高得多, 这部分的耗时增加, 总吞吐量仍然是下降的。 HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的, 而关注延迟的CMS收集器则是基于标记-清除算法的, 这也从侧面印证这点。

此语境中, 吞吐量的实质是赋值器(Mutator, 可以理解为使用垃圾收集的用户程序) 与收集器的效率总和。

  1. 折衷的办法

另外, 还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担, 做法是让虚拟机平时多数时间都采用标记-清除算法, 暂时容忍内存碎片的存在, 直到内存空间的碎片化程度已经大到影响对象分配时, 再采用标记-整理算法收集一次, 以获得规整的内存空间。 前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法

这篇关于Java虚拟机笔记 三、垃圾回收机制的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!