【Java虚拟机】《深入理解Java虚拟机》| 垃圾收集器与内存分配策略

 2019-12-22 10:45  阅读(538)
文章分类:JVM

垃圾收集器与内存分配策略

  • 前提概念

    • 什么是垃圾回收(GC)?
    • 那些内存要回收?
    • 什么时候回收?
    • 如何回收?
  • 如何判断那些对象可以回收?

    • 引用计数法
    • 可达性分析法
    • 强引用、软引用、弱引用、虚引用
    • 不可达对象死亡前的救赎 – finalize()方法
    • 回收方法区
  • 垃圾收集算法

    • 标记 – 清除算法
    • 复制算法
    • 标记 – 整理算法
    • 分代收集算法
  • 垃圾收集器的介绍(基于JDK 1.7)

    • Serial收集器/Serial Old收集器
    • ParNew收集器
    • Parallel Scavenge收集器/Parallel Old收集器
    • CMS收集器
    • G1收集器
  • 内存分配与回收策略

    • 前提概念
    • 新生对象优先分配Eden区
    • 大对象直接进入老年代Old Generation
    • 长期存活的对象将进入老年代Old Generation

前提概念

什么是垃圾回收(GC)?

在Java中,我们通常所说的垃圾回收,大部分说的就是对Java堆和方法区中的无用的内存进行回收的行为策略。因为Java不像C系语言,需要自行管理内存。而是将内存交给虚拟机来管理。

那些内存要回收?

我们知道运行时数据区有五大块,程序计数器,虚拟机栈,本地方法栈,堆和方法区。

  • 而程序计数器,虚拟机栈,本地方法栈都是线程私有的,生命周期跟随线程,当线程结束的时候,它们的内存就会被自然释放。且栈中的栈帧随着方法的进入和退出而有条无紊的执行着入栈和出栈操作。每一个栈帧中分配多少内存基本上是类结构确定下来时就已知的,所以大体可以认为程序计数器,虚拟机栈,本地方法栈三个区域的内存分配和回收都具有确定性,所以这几个区域就不需要过多的考虑回收的问题。
  • 而堆和方法区是线程共享的,不跟随线程的生命周期,且只有程序在运行期间,我们才会知道创建了什么对象,加载了什么类,所以关于堆和方法区的内存分配和回收都是动态性的,是不具有确定性的。所以垃圾回收主要关注的也是这个两个部分。

所以后文所说的垃圾回收是针对堆和方法区而言的,也可以说主要是针对堆而言的,因为方法区在某些虚拟机的实现是永久代,一般不怎么回收。

什么时候回收?

在Java虚拟机中,什么时候回收的策略是交给虚拟机自行去判断的,是无法用代码是显式执行的。

如何回收?

通过各种垃圾收集器去回收不需要再用到的内存。

如何判断那些对象可以回收?

在堆里存放这Java世界中几乎所有的对象实例,垃圾收集器在进行回收之前,第一件事情就是要确定这些对象中,那些是可以回收的,那些是不能回收的。既那些对象已死,那些还活着?

引用计数算法

引用计数算法就是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值+1,当引用失效,则计数器-1。当计数器为0时,则代表该对象已死,没有被任何别的对象引用,既不会再被使用。

优点
  • 实现简单
  • 判断效率高
缺陷

虽然引用计数算法有简单高效的优点,但是主流虚拟机都没有采用这种算法,因为这种算法存在很大的缺陷

  • 无法解决对象之间互相循环引用的问题,既多个无用对象互相引用,引用计数算法就会判断错误

可达性分析法

可达性分析算法是主流虚拟机所采用的一种算法。基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连的时候,则证明该对象是不可用的,已死,可回收。

2019120001277\_1.png

如上图,A, B之间虽然相互引用,但是从GC Roots开始走,却没有路径到达,这所说A,B对象已死,可被回收。用图论的话说,就是从GC Roots到A,B对象不可达。

什么是GC Roots

GC Roots就是可达性分析时作为查找对象引用的起点

那些对象可以作为GC Roots?
  • 虚拟机栈中(栈帧中本地变量表)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象(既Native方法)

总结来说,能做GC Roots都是全局性引用和执行上下文,既静态变量、常量、局部变量所指向的在堆中的对象 ;

对象的成员变量是不能做为GCRoots的:

  • 因为我们要考察的成员就是对象,而成员变量是属于对象的一部分,如果一个对象A有一个成员变量指向对象A本身,但除了自己引用了自己外,就没有任何东西指向了对象A,这就会导致对象A永远都不会被回收
  • 又或者说对象A因为不可达,要被回收了,但它的成员属性所指向的对象B却引用了很多的对象,如果成员变量可以作为GC Roots ,那成员属性对象B所引用的对象们到底要不要被回收呢?这就是一个矛盾

强引用、软引用、弱引用、虚引用

强引用: 普遍存在的,如Object obj = new Object这类引用(obj变量引用堆中的Object对象),只要强引用在,垃圾收集器就永远不会回收被引用的对象。

软引用: 有用但并非必须的对象,内存不够,将发生内存溢出,将软引用关联着的对象进行第二次回收,如果这次回收还有没足够的内存,才会抛出内存溢出异常。既第一次回收内存任然不够,就第二次回收,将软引用的对象回收。

弱引用: 同样是非必须对象,强度比软引用更弱,弱引用对象只能存活到垃圾回收之前,既垃圾回收器工作时,弱引用必然被回收,不管内存是否足够

虚引用: 幽灵引用,幻影引用,是最弱的引用关系。无法通过虚引用取得一个对象实例,设置虚引用的目的仅仅是当这个对象被回收时收到一个系统通知。

不可达对象死亡前的救赎 – finalize()方法

当一个对象被可达性分析判断为不可达对象,不代表这个对象非死不可,只是暂时判了缓刑死刑。这个对象还有最有一次救赎的机会。那就是实现finalize()方法,这个方法是在GC时被自动调用的方法。只要你在这个方法里实现了重新引用,那么该对象就可以的到复活的机会。如果在finalize()方法中,你任然没有获得复活的机会,那这个对象就死定了。

通俗点讲就是:可达性分析发现对象不可达,此时将继续判断对象是否覆盖finalize()方法,如果覆盖了finalize()并且是第一次被调用,如果在finalize()方法中重新与任何一个对象建立了连接,对象就仍然可以存活。

回收方法区

因为方法区在Java8以前的HotSpot虚拟机中实现为永久代,所以很多人认为是没有垃圾收集的。Java虚拟机规范也说过,可以不要求在方法区实现垃圾收集。而且通常在方法区回收的效率和性价比也很低。

永久代的回收分为两个部分
  • 废弃常量
  • 无用的类
废弃常量的回收

回收废弃常量和回收堆中的对象类似,比如说如果字符串常量池中有一个“abc”的对象,而当前系统没有一个字符串对象为"abc",既没有任何一个String对象引用了常量池中的"abc"对象且没有其他地方引用了这个"abc"字面量。如果方法区发生了GC,那么这个"abc"字符串对象就会被回收`

无用类的回收

回收无用类的条件就很苛刻了,至少要满足下面三个条件

  • 该类的所有实例都已经被回收
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用

虚拟机要满足上面三个条件,才允许可以被回收。

垃圾收集算法

标记 – 清除算法

标记 – 清除算法是最基础的垃圾收集算法,其他的算法都是在他的基础上改进的

标记 – 清除算法步骤:

分为标记清理两个步骤,首先标记所有要回收的对象,在标记完成后统一回收所有被标记的对象

缺点
  • 效率不足,标记和清理两个过程的效率都不高
  • 空间碎片问题,标记清理之后会产生大量不连续的内存碎片。

2019120001277\_2.png

(图片来源于网上)首先标记出要回收的对象,最后统一回收,从图中,我们就可以看到,垃圾回收过后,存活对象和未使用的空间的凌乱的,当空间碎片太多时,可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续空间而不得不提前触发另一次GC

复制算法

为了解决标记 – 清除算法的效率问题,所以复制算法诞生了

复制算法的步骤:
  • 复制算法把可用的内存空间分配两个块大小相等的区域,每次只使用其中一块。
  • 当一块使用完了,就将还存活的对象全部复制到另一块空闲的区域上,再队已经使用过的一块的空间一次全部清理掉

2019120001277\_3.png

优点
  • 复制算法解决了标记 – 清除算法的效率问题,实现简单,运行高效,每次对半块内存空间进行垃圾回收,且不存在空间碎片的问题
缺点
  • 空间代价太高,每次可用的空间被减半
复制算法的应用

现在的商业虚拟机都采用复制算法来回收新生代。IBM公司专门研究表明新生代中98%的对象就是朝生夕死,所以不需要按1:1的空间来划分内存空间。

  • 而是将新生代内存分为一个较大的Eden区域和两块较小的Survivor区域。
  • 每次使用只使用Eden和其中一块Survivor。
  • 当回收时,将Eden和Survivor还存活的对象一次性复制到没有使用的Survivor空间上,最后清理掉Eden和使用过的Survivor。

HotSpot虚拟机默认Eden和Survivor的大小比例为8:1,所以新生代内存空间只有百分之10被”浪费”。当然也有意外,当survivor空间不够放存活对象时怎么办?这时候就需要依赖其他内存进行分配担保(如老年代)

标记 – 整理算法

复制算法在对象存活率较高的情况下就需要进行较多的复制操作,这样效率比较低,且有空间浪费和需要其他内存分配担保。所以在老年代一般不会选用复制算法,而是交给一种改进型算法 “标记- 整理算法” ,该算法根据老年代的特点(对象存活率高)进行了算法的优化和修改。

标记 – 整理算法的步骤:

分为三个部分: 标记整理清理

  • 首先跟标记 – 清理算法一样,将需要回收的对象进行标记
  • 然后让所有存活的对象都向一端移动,让存储对象集中起来(减少空间碎片,让有用对象集中)
  • 最后清理掉边界以外的内存(既无用对象)

2019120001277\_4.png

优点

根据老年代对象存活率高的问题进行了优化和改进,并且整理了空间,减少了空间碎片

缺点

多了整理的步骤,相对来说,效率降低

分代收集算法

当前的商业虚拟机都是采用分代收集算法来进行垃圾收集的,这起也不是一个什么算法,说白了就是采用了策略模式,根据不同的需要采用不同的算法去收集。

分代收集算法的思想
  • 根据对象的存活周期的不同,将内存划分为几块。比如将堆内存分为新生代和老年代
  • 然后根据每个年代的特点采用最合适的收集算法
小结

新生代对象存活率低,所以我们就使用可以使用复制算法。老年代对象存活率高,我们就可以采用标记 – 整理算法或标记 – 清理算法

2019120001277\_5.png

垃圾收集器的介绍(基于JDK 1.7)

  • 如果是垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
  • Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,所以不同的厂家不同版本的虚拟机所提供的垃圾收集器都可能由很大的差别。

这里要介绍的垃圾收集器有7种,如图:

2019120001277\_6.png

  • Serial收集器
  • ParNew收集器
  • Parallel Scavenge收集器
  • Serial Old收集器
  • Parallel Old收集器
  • CMS收集器
  • G1收集器

从图上,我们可以获得一些信息:

  • 如果两个收集器之间存在连线,就说明他们可以搭配使用。
  • 收集器也常分为新生代收集器和老年代收集器
  • 新生代收集器有SerialParNewParallel Scavenge,老年代收集器有CMS,Serial Old,Parallel Old 。还有一个新老都可以的G1收集器

吞吐量、停顿时间的概念

吞吐量: CPU用于运行用户代码的时间和CPU总消耗时间的比值。既吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾收集时间) ,如虚拟机运行了100分钟,垃圾收集用了1分钟,那么吞吐量就是99%

停顿时间: 垃圾回收线程运行时,让其他线程停止等待的时间

两者的关系:

  • 每次GC都需要停顿一定的时间,所以,同样的GC量,如果GC很频繁,那么最小停顿时间就会变小,但是吞吐量会下降,如果GC次数很少,那么最小停顿时间就会变长,那么吞吐量就会增加。
  • 停顿时间越短,越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

可能你会很矛盾,同样的GC量,我只是停顿时间小了,分批处理,要花的时间应该跟一次性GC(停顿时间大)用的一样吧,为什么我的吞吐量就小了呢?毕竟吞吐量小了就代表我停顿的时间更多了,难道是我理解错了吗?

  • 不不不,其实这里有一个误区,实际上追求停顿时间小和追求吞吐量大的确是两个对立关系,通常情况下,同样的GC量,追求停顿时间小的策略花费的GC时间要比吞吐量优先策略花费的更多
  • 我个人的猜测是,停顿时间小,代表此次GC能回收的东西就少了,同时每次GC可能需要做许多的判断,比如判断那些对象才能被GC,而每次判断都需要耗费一些时间;这样在效率上不如停顿时间长,GC次数少的高吞吐量优先策略;从这篇博客上GC对吞吐量的影响 – @作者:deepinmind看,的确最短回收停顿时间策略和高吞吐优先策略的GC次数和GC总时间相差还是很大的

这里的并行和并发的概念

并行: 指多条垃圾收集线程并行工作,但此时的用户线程处于等待状态
并发: 指用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行),如用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

Serial收集器/Serial Old收集器

  • Serial收集器/Serial Old收集器是最基本、发展历史最悠久的收集器
  • Serial收集器使用复制算法,用于新生代,Serial Old收集器是Serial收集器老年代版本,使用标记 – 整理算法
  • Serial收集器/Serial Old收集器是单线程的,既只会用一个收集线程去完成垃圾收集
  • Serial收集器/Serial Old收集器在垃圾收集时必须停止其他所有工作线程
  • 适合运行在Client模式下,适合在单cpu环境下,不适合多线程环境

2019120001277\_7.png

Serial收集器/Serial Old收集器可以当做单线程的垃圾收集器组合来搭配使用,分别负责新生代和老生代的垃圾收集~

ParNew收集器

  • ParNew收集器就是Serial的多线程版本,也是新生代收集器,也是复制算法
  • 适合在Server模式下运行,是Server模式下新生代的首选,适合多线程
  • 目前只有ParNew能和CMS收集器配合工作
  • 其他的情况跟Serial是差不过的,垃圾收集时也是要停止其他线程

Parallel Scanvenge收集器/Parallel Old收集器

  • Parallel Scanvenge收集器是新生代收集器,使用复制算法。Parallel Old收集器是Parallel Scanvenge收集器的老年代版,使用标记-整理算法
  • Parallel Scanvenge收集器/Parallel Old收集器都是 “吞吐量优先” 的垃圾收集器
  • Parallel Scanvenge收集器/Parallel Old收集器都是多线程并行的垃圾收集器,可以搭配使用,称为多线程吞吐量优先收集器组合

2019120001277\_8.png

CMS收集器(Concurrent Mark Sweep)

在JDK1.5时期,HotSpot推出了划时代意义的垃圾收集器,因为CMS是一个真正意义上的并发收集器

  • CMS是一个老年代收集器,基于标记 – 清除算法
  • CMS是一种以获取最短回收停顿时间为目标,既不是以高吞吐量为目标的收集器
  • 常与ParNew收集器做新老年代搭配,ParNew是新生代多线程收集器,CMS是老年多多线程并发收集器
CMS垃圾收集步骤
  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

2019120001277\_9.png

  • 初始标记和重新标记还是要暂停其他线程,既”stop the world”。
  • 初始标记仅仅标记一下GC Roots能直接关联到的对象,速度很快
  • 并发标记是并发的,跟用户线程一起执行的(可能交替),并发标记是GC Roots Tracing的过程,寻找路径,发现不可达点
  • 重写标记则是为了修正因为用户继续运行期间而导致标记产生变动的哪一部分对象的标记记录,时间比初始标记长,但原并并发标记短
  • 并发清除就是跟用户线程一起并发的运行,用户线程在运行,我同时也在清理无用内存,回收垃圾
  • 耗时最长的就是并发的并发标记和并发清除
优点
  • 并发,可以于用户线程一起运行
  • 可以做到低停顿,虽然初始标记和重新标记也需要停顿,但是耗时很短
缺点
  • 对CPU资源太敏感,虽然在并发运行期间,用户线程没有暂停,但是收集器也占用了一部分的CPU资源,导致程序的响应速度变慢
  • CMS无法处理浮动垃圾,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在本次GC集中处理它们,只好在下一次GC的时候处理。
  • 由于CMS收集器是基于 “标记-清除” 算法的,前面说过这个算法会导致大量的空间碎片的产生,一旦空间碎片过多,大对象就没办法给其分配内存,那么即使内存还有剩余空间容纳这个大对象,但是却没有连续的足够大的空间放下这个对象,所以虚拟机就会触发一次Full GC

G1收集器(Garbage-First)

G1(Garbage-First)收集器是现今收集器技术的最新成果之一,之前一直处于实验阶段,直到jdk7u4之后,才正式作为商用的收集器。G1收集器是面向服务端应用的垃圾收集器,希望他可以在未来替换掉1.5发布的CMS收集器

  • G1跟CMS一样,也是一个追求低停顿时间为目标的垃圾收集器
G1收集器的特点
  • 并行和并发: G1能充分的利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短停顿时间
  • 分代收集: G1中任然保留了分代,但是G1不需要搭配其他的垃圾收集器,可以自行完成新生代和老年代的内存回收。但他能采用不同的方式去处理新创建的对象、已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果
  • 空间整合: G1整体上是基于标记 – 整理算法,但从局部的两个Region来看又是基于复制算法。总之就是没有空间碎片
  • 可预测的停顿: G1在追求低停顿的目标时还建立了可预测的停顿时间模型,能让使用者明确顶端时间在一个M毫秒内的时间片段,消耗在垃圾收集上的时间不能超过N毫秒。这是G1比CMS先进的地方
region区域

使用G1收集器,Java堆的内存布局就不太一样了,虽然也分新生代和老年代,但是它们之间不再由物理的隔离。而是被分成多个大小相等的独立区域(Region)

小结

所以我们知道了通常情况下,垃圾收集器通常有自己的目标,追求低停顿时间和追求高吞吐量,二选一,鱼和熊掌不可兼得。

通常情况下,同样的GC量,追求低停顿时间的收集器耗费的时间要比追求高吞吐量的收集器多;所以虚拟机的根据具体需求的人为调优有时候也是很必要的;选择正确的收集器组合也是非常必要的;

如果你是响应时间要很高要求的情况下,建议使用追求低停顿时间的垃圾收集器,当然相应的可能性能上会有所降低;如果你追求的就是高吞吐量,以性能优先,对客户的短时间中断没有什么影响的情况下就可以选择高吞吐量优先的垃圾收集器

内存分配与回收策略

2019120001277\_10.png

堆内存的划分根据不同的参数,不同的垃圾收集器会有些不太一样,所以我这里说一下通常的情况:

如图,通常情况下,堆空间划分两个年代,新生代(New Generation)老年代(Old Generation)
而新生代还可以继续划分为三个区域。一个就是Eden区,另两个就是大小相等的Survivor区(From,To)

前提概念

TLAB

什么是TLAB?内存分配的动作,可以按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程需要分配内存,就在哪个线程的TLAB上分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。这么做的目的之一,也是为了并发创建一个对象时,保证创建对象的线程安全性。TLAB比较小,直接在TLAB上分配内存的方式称为快速分配方式,而TLAB大小不够,导致内存被分配在Eden区的内存分配方式称为慢速分配方式。

各类型GC
  • Minor GC:指发生在新生代的垃圾收集动作,非常频繁,速度较快。
  • Major GC : 指发生在老年代的GC,出现Major GC,经常会伴随一次Minor GC(不一定),因为Major GC可能是Minor GC引起的,不频繁。
  • Full GC:指发生在老年代和新生代的GC,速度很慢

其实Major GC和Full Gc有时候可以说的同一个说法,因为很多实用Major GC都是Minor Gc引起的。

新生对象优先分配Eden区

大多数情况下,对象在新生代Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机会发起一次Minor GC,既在新生代发起一次GC

大对象直接进入老年代Old Generation

大对象直接进入老年代,何为大对象?大对象就是需要大量连续内存空间的Java对象,最经典的就是那种很长的字符串和数组

长期存活的对象将进入老年代Old Generation

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过了第一次Minor Gc任然存活,并且被Survivor容纳,那将移入Survivor区,并且对象年龄设置为1。对象在Survivor每熬过一次Minor Gc,年龄就加1。当年龄到达了一定的程度(默认15岁),就会被晋升到老年代中。

当然还有一种情况不一定要到达年龄阈值才能晋升到老年代,比如Survivor的相同年龄的所有对象大小总和大于Survivor空间的一半,那么年龄大于或等待该年龄的对象就直接进入老年代了。

GC流程图

2019120001277\_11.png

参考答案

  • 《深入理解Java虚拟机》
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 【Java虚拟机】《深入理解Java虚拟机》| 垃圾收集器与内存分配策略

相关推荐