Java传家宝:微信公众号(Java传家宝)、Java传家宝-B站、Java传家宝-知乎、Java传家宝-CSND
Java堆–垃圾回收
首先给出《深入理解JAVA虚拟机》中给出的概念:Java堆(Java Heap)是JVM所管理的内存最大的一块,线程共享,在虚拟机启动时创建,唯一目的就是用来存储对象实例。几乎所有的对象实例和数组都在堆上分配。
可以提炼出一些概念:
- 堆只用来存储对象实例和数组。
- 不是所有实例对象都在堆上分配(逃逸分析)。
- 堆上的数据线程共享。
其实Java可以分为两种对象:实例对象和类对象,实例对象就是通过new关键字产生的对象,一般存储在堆上,类对象就是Class对象,一般在类加载阶段在方法区创建对应类的Class对象。
逃逸分析
既然提到了逃逸分析,就先插一嘴逃逸分析的概念,和为什么是几乎而不是所有实例对象在堆上分配(栈上分配对象)。《深入理解JAVA虚拟机》中说明了两种逃逸类型,分为线程逃逸和方法逃逸
- 线程逃逸:当线程A定义的对象被线程B访问到,就称为线程逃逸。
- 方法逃逸:在同一个线程之中,当一个对象在方法中被定义,然后被外部方法所引用,就称为方法逃逸。
其实说白了就是在方法中创建的对象没有随着方法结束而销毁,而是被外部引用了。
如果可以确定某个对象不会发生逃逸,是随着方法的结束而销毁的,那么就可能做出一些高效的优化(并不一定实现了):
- 栈上分配:可以在栈上为对象分配内存,这样做的好处就是可以随着栈帧出栈回收对象内存,降低GC的压力。
-
同步消除:如果对象不会发生线程逃逸,那么一定不会存在线程并发问题,就可以将同步措施消除。
-
标量替换:标量就是指不能够被分解的基本类型,与之对应的有聚合量,Java对象就是典型的聚合量,如果不会发生逃逸那么就可以将对象分解为标量,就省去了创建对象的步骤。
垃圾回收
其实堆的概念就大概如上文所述。对于Java堆,我们主要关注的是垃圾回收,理解JVM是怎么将我们不需要的对象回收掉的。说到垃圾回收,自然就会想到有哪些垃圾回收算法,包括复制、标记-清除、标记整理和分代收集。还要考虑有哪些垃圾回收器,包括新生代垃圾回收器serial,parNew,Parallel Scavenge,老年代垃圾回收器Parallel old,serical old,cms,还有通用的Garbage first。下文开始详细解析。
另外方法区也是会进行垃圾回收的,会回收掉废弃常量和无用的类,这里不细讲。
对象已死
垃圾回收的前提是判断对象已经不需要了,即对象已死,那么此时就需要将其回收,从而释放内存。一般存在两种办法,分别是引用计数法和可达性分析算法。
引用计数法
字面意思,就是每当有一个地方引用该对象时,计数器值就+1,引用失效时,计数器值就-1。当计数器值为0时,判断对象已死。听起来没什么问题,但是当对象A和对象B互相引用时,就会出现内存泄漏,A,B对象永远不能回收。
就如图所示,当对象A==>对象B,对象B==>对象A,那么此时如果丢掉栈帧中的引用,但对象之间的引用还在,此时触发GC是回收不了对象A和对象B的。
可达性分析法
以GC Root根对象为起始点,向下搜索,搜索路径称为引用链,对于未搜索到的对象,即对象不可达时,就判断该对象已死。其中GC Root对象包括有虚拟机栈中引用的对象、方法区中的类静态属性引用的对象(我的理解是符号引用?)、常量引用的对象和本地方法栈引用的对象。
引用(Reference):又分为强(Strong)、软(Soft)、弱(Weak)、虚(Phantom)四类,不同引用对待的GC策略会有区别。
引用类型 GC策略 强引用 平常代码普遍存在的,比如Object obj = new Object(),不会回收 软引用 GC回收后,内存空间仍不足,就会将其回收 弱引用 不管空间是否足够,触发GC就回收 虚引用 触发GC就回收,回收时会加入到引用队列,以此可以进行一些额外操作。
垃圾回收算法
这部分的概念还是比较清楚且易理解的,但是具体的算法实现还没有深入了解,下文仅仅解析算法的思想。
复制算法
复制算法就是将可用内存划分为大小相同的两块内存(暂且称为A,B),当A内存用完了之后,直接将存活对象复制到B内存上,然后将A内存整个回收的过程。黄色指可回收对象,绿色指存活对象,白色是空闲内存。
该算法的特点可以总结为:
- 实现简单,运行高效
- 内存代价太大
- 适用于新生代(新生代的对象大多都是朝生夕死,复制的对象不多)
标记-清除
标记清除算法概念也很简单,就是标记所有的可回收对象,然后清除所有标记对象。过程如图,黄色指可回收对象,绿色指存活对象,白色是空闲内存。
该算法的特点可以总结为:
- 效率不高,标记和清除两个过程都耗费时间
- 会产生大量内存碎片,导致后续的大对象可能没有足够内存分配
- 适用于老年代
标记-整理
标记整理,标记过程和标记清除一样,就是标记所有的可回收对象,然后将所有存活对象向一端移动,清理掉端边界以外的对象。如图,黄色指可回收对象,绿色指存活对象,白色是空闲内存。
该算法的特点可以总结为:
- 效率不高,标记和清除两个过程都耗费时间
- 不会产生内存碎片
- 适用于老年代
分代收集
分代收集就是将内存分为几块,每块采用合适的算法进行收集。在Java虚拟机中一般是分为新生代和老年代。
- 新生代:又分为Eden(伊甸园)区和2个Survivor(幸存)区。一般采用复制算法。
- 老年代:对象存活率高,没有额外的分配担保,一般采用标记整理和标记复制算法。
可通过以下参数调节大小
参数 | 作用 |
---|---|
-Xmn | 新生代大小 |
-XX:Survivor | Eden与Servivor比例 |
-XX:PretenureSizeThreshold | 晋升老年代对象大小 |
分配担保这里提一下:比如新生代在为对象分配空间时是有额外的内存空间进行担保的,那就是老年代。具体在解析完垃圾回收整个过程后在做解释会更加清晰。
垃圾回收过程
我们要知道虚拟机是什么时候进行垃圾回收的,不能出现GC时,对象引用还在不断变化。以GC Root为例,当分析整个引用链时,需要整个分析期间,执行系统就像是被冻结在某个时间点一样,这就是所谓的Stop the word(STW)。为了避免gc停顿时间STW太长,在Java虚拟机中,会通过一组OopMap的数据结构在每一个安全点来记录必要的对象内什么偏移量是什么数据类型的数据(我的理解就是直接引用?)记录下来,这样在安全点时,就能够快速的完成GC root根节点枚举。
安全点:不是每个位置都可以STW,进行GC操作,需要在选定一个安全点才能够开始进行GC。(一般选取长时间执行的指令,例如方法调用、循环跳转、异常跳转)
安全区域:在这个区域内都可以进行GC,一般指线程Sleep或者Blocking时,此时对象引用是不变的。
垃圾回收器
垃圾回收算法算内存回收的方法论,垃圾回收器就是内存回收的具体实现。可分为用于适用于新生代的垃圾回收器和适用于老年代的垃圾回收器。如图所示
这是《深入理解JVM虚拟机》的配图,新生代区域标记了可以用于新生代的垃圾回收器,老年代则标记了用于老年代的垃圾回收器,连线则表示两者之间可以配合完成整个堆的垃圾回收工作。或许用表格表示更加清晰。
新生代垃圾回收器 | 可配合使用的老年代垃圾回收器 |
---|---|
Serial | Serial Old、CMS |
ParNew | Serial Old、CMS |
Parallel Scavenge | Serial Old、Parallel |
下面开始详细解析各个垃圾回收器,均基于Java虚拟机而言。
Serial/Serial Old
Serial就是一个单线程的垃圾回收器,当程序到达安全点时,暂停所有线程,开启单个GC线程开始新生代的垃圾回收操作(STW),采用的是复制算法直到垃圾回收完成。(线程数只是做个示例)
Serial Old就是Serial的老年代版本,单线程回收,不过采用的是标记-整理算法。
ParNew
相当于Serial的多线程版本,当程序到达安全点时,暂停所有线程,开启多个GC线程开始新生代的垃圾回收操作。采用的是复制算法直到垃圾回收完成。
Parallel Scavenge/Parallel Old
Parallel Scavenge类似于ParNew,但是Parallel Scavenge主要侧重于提高吞吐量(程序运行时间/(程序运行时间+垃圾回收时间)),故其经常也被称为吞吐量优先收集器。可通过以下参数调节
吞吐量白话就是单位时间,垃圾回收的效率最快,但停顿可能会更长
参数 | 作用 |
---|---|
-XX:GCTimeRatio | 设置吞吐量大小 |
-XX:MaxGCPauseMills | 设置最大垃圾收集停顿时间 |
-XX:+UseAdaptiveSizePolicy | 自适应控制新生代、老年代大小 |
Parallel Old是Parallel Scavenge的老年代版本,多线程回收,采用标记-整理算法。
CMS(Concurrent Mark Sweep)
CMS并发标记清除,是一种老年代的垃圾收集器,目标在于获取最短停顿时间,一般分为4个步骤:
低停顿白话就是STW时间短,但整个垃圾回收的时间可能会变长
- 初始标记:到达安全点,开始标记GC Root对象能够直接关联的对象,速度很快。(即不会进行向下查找引用链)存在STW
JDK8版本以前,初始标记是单线程的
JDK8之后包括JDK8,初始标记是多线程的
- 并发标记:初始标记之后,进行GC Roots Tracing,就是向下查找引用链,这部分与用户线程并发进行,没有STW
- 重新标记:在并发标记这个过程中,使用的GC Root对象是在初始标记到的对象,相当于一个快照。并发标记的过程用户线程也在执行,所以会存在某些对象引用发生变化,所以需要对其进行修正,存在STW。
其实很容易理解,相当于要将程序冻结在一个时间点(STW),这样重新标记时,对象引用才不会发生变化。
- 并发清除:重新标记完成后,就可以知道那些是存活对象了,就开始进行GC操作,这部分直接与用户线程并发进行,没有STW。
和并发标记类似的,与用户线程并发执行,不可避免会在这个过程中会用户线程会继续产生垃圾,这部分垃圾称之为浮动垃圾,只能留到下一次GC时才能清理。
总结一下CMS的特点:
- 低停顿,GC Roots Tracing和清除垃圾阶段都是并发执行的,没有STW。
- 并发清除阶段,由于用户线程仍在运行,所以在这个过程中,会产生新的垃圾,这些垃圾称之为浮动垃圾。只能留待下一次GC清理。
由于浮动垃圾的产生,就导致CMS不能等待老年代几乎被占用满了才开始GC,因为此时用户线程还会不断产生新的对象,所以需要提前GC,JDK5默认设置为老年代使用了68%就开始GC。
- 由于是并发,所以会多开线程,占用CPU资源。CMS默认开启的线程数量为**(CPU数+3)/4**
- 由于算法采用的是标记-清除,所以会产生大量内存碎片
Garbage First
Garbage First就是我们常说的G1垃圾回收器。不同于其他垃圾回收器的分代,G1垃圾回收器兼顾了新生代和老年代。它将Java堆划分为大小相等的Region,与上述分代收集的概念图不同,新生代和老年代不是连续的内存区域,而是一个个Region的集合组成如下:
由于每个Region肯定不是独立的,肯定存在RegionA的对象引用了RegionB的对象或者更多,这样在做可达性分析时,就需要对整个堆进行扫描,为了避免这个问题,G1通过在每个Region中通过一个Remembered Set记录本Region中的对象被引用的信息,这样就可以顺着引用信息去查找,而不需要对每个Region挨个扫描。
OopMap:
每个方法对应一个栈帧,每个栈帧对应一个OopMap,同时也包含多个安全点,每个栈帧内的OopMap在每个安全点更新,记录什么位置对应着什么类型的引用。Hotspot在枚举根对象时,就只需要遍历每个栈帧的OopMap即可,而不需要对栈中非引用对象进行无效扫描。Remembered Set:
对于G1垃圾回收器,堆中每个Region对应一份Remembered Set,记录该Region中对象被引用的信息,用于可达性分析,避免进行整个堆的扫描。
在说回G1,它也可抽象为四个步骤:
- 初始标记:标记一下GC Roots对象能直接关联的对象,存在STW
- 并发标记:与用户线程一起执行,进行可达性分析,并记录对象引用变化,生成Remembered Set Logs
- 最终标记:STW,将Remembered Set Logs与Remembered Set合并,并修正在并发标记阶段存在变化的对象标记
- 筛选回收:通过价值大小,在允许的停顿时间内,收集价值更大的Region
价值:G1通过回收时间和回收可获得的空间来衡量
总结一下G1的特点:
- 可预测的停顿:可以指定垃圾回收消耗的时间不得超过某个值
- 空间整合:G1从整体上看是基于标记-整理算法的,标记存活对象,并向一端移动,回收端边界以外的内存。从局部看,又是复制算法,Region之间的移动过程是通过复制实现的。但都保证了空间连续,没有内存碎片产生。
- 并行和并发:G1能够充分利用多CPU、多核环境的硬件优势。
分配担保
最后简单谈一下上文提到的分配担保。在发生Minor GC之前,会先检查老年代最大可用空间是否大于新生代所有对象总空间,大于则本次Minor GC是安全的,不需要担保,如果不成立,那么此时进行Minor GC就是存在风险的。
风险:如果此时,新生代Eden区对象都存活了,Survivor区此时也正好容纳不了,那么此时就需要晋升到老年代,前提是老年代剩余空间足以容纳这些对象。如果担保失败了,那么就会进行full gc。此时担保失败的效率还不如直接进行full gc。
判断存在风险后,需要检查是否允许担保失败
- 允许则比较每一次晋升到老年代对象容量的平均大小与老年代剩余空间大小,决定是否进行Full Gc。
- 不允许直接进行Full Gc。
Minor GC: 就是新生代垃圾回收,速度很快
Full GC:就是老年代垃圾回收,速度很慢,相较于Minor GC慢10倍左右