写点什么

JVM 最佳学习笔记<二>--- 垃圾收集器与内存分配策略

用户头像
Loubobooo
关注
发布于: 2020 年 05 月 27 日
JVM最佳学习笔记<二>---垃圾收集器与内存分配策略

前言

本笔记参照了周志明`《深入理解Java虚拟机:JVM高级特性与最佳实践》`第三版,读完之后受益匪浅,让我对Java虚拟机有了一个深刻的认识,这也是Jvm书籍中最好的读物之一。

1. 判断对象是否已死

引用计数算法

给对象中添加一个引用计数器,每当一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不可能再被使用的。



引用计数算法的缺陷:它很难解决对象之间相互循环引用的问题。案例代码如下:

/**
* @Description -Xms20m -Xmx20m -XX:+PrintGCDetails
* testGC()方法执行后,objA和objB会不会被GC呢?
* @Author loubobooo
* @Date 2019/3/10 12:54
**/
public class ReferenceCountingGC {

public Object instance = null;
private static final int _1MB = 1024 * 1024;
// 这个成员属性唯一的意义就是占点内存,以便能在GC日志中看清楚是否被回收过
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这里发生GC,objA和objB是否能被回收?
System.gc();
}

public static void main(String[] args) {
testGC();
}
}

运行结果:

[GC (Allocation Failure) [PSYoungGen: 3587K->512K(6144K)] 3587K->2608K(19968K), 0.0029676 secs] [Times: user=0.01 sys=0.01, real=0.01 secs]
[GC (System.gc()) [PSYoungGen: 2616K->512K(6144K)] 4712K->2616K(19968K), 0.0010145 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 512K->0K(6144K)] [ParOldGen: 2104K->386K(13824K)] 2616K->386K(19968K), [Metaspace: 3152K->3152K(1056768K)], 0.0052974 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 6144K, used 281K [0x00000007bf980000, 0x00000007c0000000, 0x00000007c0000000)
```
从结果可知,GC日志中包含``[ParOldGen: 2104K->386K(13824K)]``,以为虚拟机并不是通过引用计数算法来判定对象是否存活。

## 可达性分析算法
从可达性算法,可以引申出可达对象和不可达对象。
* 可达对象:指通过根对象进行引用搜索,最终可以达到的对象
* 不可达对象:指通过根对象进行引用搜索,最终没有被引用的对象

![](http://img.loubobooo.com/jvm_21.png)

而可达性分析算法,便是指 **从根节点(GC Roots)开始是否可以访问这个对象**。如果可以,则说明当前对象正在被使用,**如果从所有的根节点开始都无法访问到某个对象,说明该对象已经不再使用了**。一般来说,该对象需要被回收。

### 对象a, b 可回收,就一定会被回收吗?
并不是。对象的``finalize`` 方法给了对象一次垂死挣扎的机会。**当对象不可达(可回收)时,当发生``GC``时,会先判断对象是否执行了``finalize``方法,如果未执行,则会先执行``finalize``方法,我们可以在此方法里将当前对象与 ``GC Roots``关联,这样执行``finalize``方法之后,``GC`` 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!**

**注意**: ``finalize``方法只会被执行一次,如果第一次执行``finalize`` 方法此对象变成了可达确实不会回收,但如果对象再次被``GC``,则会忽略``finalize`` 方法,对象会被回收!这一点切记!

那么``GC Roots``到底是什么东西呢,哪些对象可以作为``GC Root``呢,有以下几类
* 虚拟机栈(栈帧中的本地变量表)中引用的对象
* 方法区中类静态属性引用的对象
* 方法区中常量引用的对象
* 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

### 虚拟机栈中引用的对象
如下代码所示,a 是栈帧中的本地变量,当``a = null``时,由于此时 a 充当`` GC Root``的作用,a 与原来指向的实例``new Test()``断开了连接,所以对象会被回收。

public class Test {

public static void main(String[] args) {

Test a = new Test();

a = null;

}

}

### 方法区中类静态属性引用的对象
如下代码所示,当栈帧中的本地变量``a = null``时,由于 a 原来指向的对象与``GC Root (变量 a)`` 断开了连接,所以 a 原来指向的对象会被回收,而由于我们给 s 赋值了变量的引用,s 在此时是类静态属性引用,充当了``GC Root``的作用,它指向的对象依然存活!

public class Test {

public static Test s;

public static void main(String[] args) {

Test a = new Test();

a.s = new Test();

a = null;

}

}

### 方法区中常量引用的对象
如下代码所示,常量 s 指向的对象并不会因为 a 指向的对象被回收而回收

public class Test {

public static final Test s = new Test();

public static void main(String[] args) {

Test a = new Test();

a = null;

}

}

### 本地方法栈中 JNI 引用的对象
这是简单给不清楚本地方法为何物的童鞋简单解释一下:所谓本地方法就是一个``Java``调用``非Java``代码的接口,该方法``并非 Java``实现的,可能由``C 或 Python``等其他语言实现的,``Java``通过 ``JNI``来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。通过调用本地的库文件的内部方法,使``Java`` 可以实现和本地机器的紧密联系,调用系统级的各接口方法,还是不明白?见文末参考,对本地方法定义与使用有详细介绍。

当调用``Java方法``时,虚拟机会创建一个栈桢并压入``Java栈``,而当它调用的是本地方法时,虚拟机会保持``Java栈``不变,不会在``Java栈祯``中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法。

![](http://img.loubobooo.com/jvm_26.png)


JNIEXPORT void JNICALL JavacompecuyujnirefdemoMainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {

...

// 缓存String的class

jclass jc = (*env)->FindClass(env, STRING_PATH);

}

如上代码所示,当``Java``调用以上本地方法时,``jc``会被本地方法栈压入栈中, ``jc`` 就是我们说的本地方法栈中``JNI``的对象引用,因此只会在此本地方法执行完成后才会被释放。

## 回收方法区
永久代的垃圾收集主要回收两部分内容: **废弃常量和无用的类**。而无用的类需同时满足以下3个条件:
* 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
* 加载该类的``ClassLoader``已经被回收
* 该类对应的``java.lang.Class`` 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

# 2. 垃圾收集算法
### 标记-清除算法
算法分为“标记”和“清除”两个阶段。首先,标记出所有需要回收的对象,在标记完成,统一回收所有被标记的对象。存在两点不足:
* **效率**问题,标记和清除两个过程的效率都不高
* **空间**问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

#### 缺陷
* 可能产生大量的空间碎片

![](http://img.loubobooo.com/jvm_27.png)

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

#### 优点:
* 既保证了空间的连续,又避免了大量的内存空间浪费。

#### 缺陷
* 占用大量内存

![](http://img.loubobooo.com/jvm_43.png)

### 标记-整理算法
**标记**整理出所有需要回收的**对象**,让所有存活的对象都向一端**移动**,然后直接**清理**掉端边界以外的内存,适合**老年代**的收集算法。

#### 优点
* 避免了碎片的产生,又不需要两块相同的内存空间

#### 缺陷
* 每进一次垃圾清除都要频繁地移动存活的对象,效率十分低下。

![](http://img.loubobooo.com/jvm_29.png)

### 分代收集算法
将Java堆分为新生代和老年代。在**新生代**中,每次垃圾收集时都发现大批对象死去,只有少量存活,那就选用**复制算法**,只需要付出少量存活对象的复制成本就可以完成收集。而**老年代**中因为对象存活率高、没有额外空间对它进行分配担保,就必须**使用“标记-清理”或“标记-整理”算法**来进行回收。

#### 1. 对象在新生代的分配与回收
由以上的分析可知,大部分对象在很短的时间内都会被回收,对象一般分配在``Eden``区

![](http://img.loubobooo.com/jvm_38.png)

当``Eden``区将满时,触发``Minor GC``

![](http://img.loubobooo.com/jvm_39.png)

我们之前怎么说来着,大部分对象在短时间内都会被回收, 所以经过``Minor GC`` 后只有少部分对象会存活,它们会被移到``S0``区(这就是为啥空间大小**Eden: S0: S1 = 8:1:1**, ``Eden``区远大于``S0,S1``的原因,因为在``Eden``区触发的``Minor GC`` 把大部对象(接近**98%**)都回收了,只留下少量存活的对象,此时把它们移到``S0``或``S1``(绰绰有余),同时对象年龄+1(对象的年龄即发生``Minor GC``的次数),最后把``Eden`` 区对象全部清理以释放出空间,动图如下

![](http://img.loubobooo.com/jvm_40.GIF)

当触发下一次``Minor GC``时,会把``Eden``区的存活对象和``S0(或S1)``中的存活对象,经过每次 ``Minor GC``都可能被回收一起转移到``S1``(**Eden 和 S0 的存活对象年龄+1**),同时清空``Eden``和``S0``的空间。

![](http://img.loubobooo.com/jvm_41.GIF)

若再触发下一次``Minor GC``,则重复上一步,只不过此时变成了 从``Eden,S1``区将存活对象复制到 ``S0``区,每次垃圾回收,``S0, S1``角色互换,都是从``Eden,S0(或S1) ``将存活对象移动到 ``S1(或S0)``。也就是说在``Eden``区的垃圾回收我们采用的是复制算法,因为在``Eden`` 区分配的对象大部分在``Minor GC``后都消亡了,只剩下极少部分存活对象(这也是为啥``Eden:S0:S1`` 默认为``8:1:1`` 的原因),``S0,S1`` 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。

#### 2. 对象何时晋升老年代
* 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代

![](http://img.loubobooo.com/jvm_42.GIF)

如图示:年龄阈值设置为**15**, 当发生下一次``Minor GC``时,``S0``中有个对象年龄达到**15**,达到我们的设定阈值,晋升到老年代!


* 大对象。当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在``Eden`` 区,会直接分配在老年代,因为如果把大对象分配在``Eden``区,``Minor GC``后再移动到``S0,S1`` 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满``S0,S1`` 区,所以干脆就直接移到老年代。

* 还有一种情况也会让对象晋升到老年代,即在``S0(或S1)``区相同年龄的对象大小之和大于`` S0(或S1)``空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。

#### 3. 空间分配担保
在发生``MinorGC``之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么``Minor GC``可以确保是安全的,如果不大于,那么虚拟机会查看 ``HandlePromotionFailure`` 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行``Minor GC``,否则可能进行一次``Full GC``。

#### 4. Stop The World(STW)
如果老年代满了,会触发``Full GC``,``Full GC`` 会同时回收新生代和老年代(即对整个堆进行GC),它会导致``Stop The World``(简称 STW),造成挺大的性能开销。

所谓的``STW``, 即在``GC(minor GC 或 Full GC)``期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。

![](http://img.loubobooo.com/jvm_30.png)

**题外:为啥在垃圾收集期间其他工作线程会被挂起?想象一下,你一边在收垃圾,另外一群人一边丢垃圾,垃圾能收拾干净吗。**

一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理**整个堆**中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)。

现在我们应该明白把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了**尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC**。想想新生代如果只设置 Eden 会发生什么,后果就是每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。

由于 Full GC(或Minor GC)会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:

* 循环的末尾
* 方法返回前
* 调用方法的 call 之后
* 抛出异常的位置 另外需要注意的是由于新生代的特点(大部分对象经过 Minor GC后会消亡), Minor GC 用的是复制算法,而在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销(复制算法在对象存活率较高时要进行多次复制操作,同时浪费一半空间)所以根据老生代特点,在老年代进行的 GC 一般采用的是标记整理法来进行回收。

# 3. 垃圾收集器
* Serial收集器
> 串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。
* ParNew收集器
> ParNew收集器其实就是Serial收集器的多线程版本。

它的垃圾收集过程如下

![](http://img.loubobooo.com/jvm_32.png)

* Parallel收集器
> Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。
* Parallel Old 收集器
> Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法

它的垃圾收集过程如下

![](http://img.loubobooo.com/jvm_34.png)

* CMS收集器
> CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

采用的是标记清除法,主要有以下四个步骤

1. 初始标记
2. 并发标记
3. 重新标记
4. 并发清除

![](http://img.loubobooo.com/jvm_35.png)

* G1收集器
> G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征

G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址,如图示

![](http://img.loubobooo.com/jvm_36.png)

G1 收集器的工作步骤如下

1. 初始标记
2. 并发标记
3. 最终标记
4. 筛选回收

![](http://img.loubobooo.com/jvm_37.png)

可以看到整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。

## 垃圾收集器归类
* 在新生代工作的垃圾回收器:Serial, ParNew, ParallelScavenge
* 在老年代工作的垃圾回收器:CMS,Serial Old, Parallel Old
* 同时在新老生代工作的垃圾回收器:G1

![](http://img.loubobooo.com/jvm_31.png)

## 各个垃圾回收器的特点
![](http://img.loubobooo.com/jvm_20.png)

# 4. 一次完成的GC过程

![](http://img.loubobooo.com/jvm_12.png)

## 总结
在 JDK1.8 环境下,默认使用的是 Parallel Scavenge(年轻代)+Parallel Old(老年代)垃圾收集器

注:首先通过 ps 命令查询出进程 ID,再通过 jmap -heap ID 查询出 JVM 的配置信息

$ ps -ef|grep java

501(UID) 26699(PID) 25587(PPID) 0(CPU) 7:19PM ??

jmap -heap 26699


或者:

$ java -XX:+PrintCommandLineFlags -version

//5g-8g

-XX:InitialHeapSize=536870912 -XX:MaxHeapSize=8589934592 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC

java version "1.8.0_172"

Java(TM) SE Runtime Environment (build 1.8.0_172-b11)

Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)

```



GC 调优策略

降低 Minor GC 频率

降低 Full GC 的频率

  1. 减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过`Minor GC之后也会进入到老年代。这种大对象很容易产生较多的Full GC`。

  2. 增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低`Full GC`的频率。



选择合适的 GC 回收器



理解GC日志

通过两张图非常明显看出GC日志(YoungGC和FullGC)构成:

  • Young GC日志:





  • Full GC日志:





  1. "33.125"和"100.667":代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。这部分时间未作显示

  2. `[GC[Full GC 说明了这次垃圾收集停顿类型,并不是来区分**新生代GC**还是**老年代GC**。如果是Full,说明这次GC是发生了Stop-The-World。如果是调用System.gc()方法所触发的收集,那么这里将显示[Full GC(System)`

  3. `[DefNew[Tenured[Perm` 表示所发生GC的区域,与GC收集器相关。

* Serial收集器,新生代名称为`Default New Generation(DefNew)`。

* ParNew收集器,新生代名称为`Parallel New Generation(ParNew)`。

* Parallel Scavenge收集器,新生代名称为`PSYoungGen`



  1. 334480K->4736K(334480K) 含义是 GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)

  2. 0.0209890 secs 表示该区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据。如"`"[Times: user=0.01 sys=0.00,real=0.01 secs]"` 分别表示用户态消耗CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间

  3. 新生代总空间为334480K=Eden区+1个Survivor区的总容量



参数设置

  • `-XX:+PrintGC-verbose:gc 是一样的,可以认为-verbose:gc-XX:+PrintGC`的别名,用于垃圾收集GC时的信息打印。

  • `-XX:+PrintHeapAtGC` 打印垃圾收集GC中堆的信息

  • `-XX:+PrintGCDetails` 在垃圾收集行为GC时,打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况

  • `-Xmn` 分配新生代

  • `-XX:SurvivorRatio=8`决定了新生代中Eden区和一个Survivor的空间比例是8:1



新生代GC(Minor GC)和老年代GC(FullGC)区别

  • 新生代(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度较快

  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC,且Major GC的速度一般会比Minor GC慢10倍以上



4. 内存分配与回收策略

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的java对象,典型的是那种很长的字符串以及数组。



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

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会晋升到老年代中。



5. 本章小结

内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获取更高的性能。



发布于: 2020 年 05 月 27 日阅读数: 78
用户头像

Loubobooo

关注

还未添加个人签名 2018.04.27 加入

还未添加个人简介

评论

发布
暂无评论
JVM最佳学习笔记<二>---垃圾收集器与内存分配策略