2018-07-20

Java GC(垃圾回收)原理

与C/C++相比,JAVA并不要求我们去人为编写代码进行内存回收和垃圾清理。JAVA提供了垃圾回收器(Garbage Collector),可自动把不再被使用的存储空间释放掉,也就是说,GC机制可以有效防止内存泄露和内存溢出。

JAVA 垃圾回收器的主要任务:

1)分配内存
2)确保被引用对象的内存不被错误的回收
3)回收不再被引用的对象的内存空间
垃圾回收器在把程序员从释放内存的复杂工作中解放出来的同时,为了实现垃圾回收,GC必须跟踪内存的使用情况,释放没用的对象,在完成内存的释放之后还需要处理堆中的碎片,这样做必定会增加JVM的负担。

JAVA内存区域

了解GC机制之前,需要首先搞清楚JAVA程序在执行的时候,内存是如何划分的。

image.png

1) 私有内存区
程序计数器:指示当前程序执行到了哪一行,执行JAVA方法时记录正在执行的虚拟机字节码指令地址;执行本地方法时,计数器值为undefined。
虚拟机栈:用于执行JAVA方法。栈帧存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。程序执行时栈帧入栈;执行完后栈帧出栈。
本地方法栈:用于执行本地方法,其它和虚拟机栈类似。
虚拟机栈中的局部变量表,里面存放了三个信息:

  • 各种基本数据类型(boolean、byte、char、short、int、float、long、double)
  • 对象引用(reference)
  • returnAddress地址
    这里的returnAddress和程序技术器的区别是前者是只是JVM的指令执行到哪一行,后者是你的代码执行到哪一行。
    私有内存区伴随着线程的产生而产生,一旦线程中止,私有内存区也会自动消除,因此在本文中讨论的内存回收主要是针对共享内存区。
    2)共享内存区
    JAVA堆:JAVA虚拟机管理内存中最大的一块,所有线程共享,几乎所有的对象实例和数组都在这类分配内存。GC主要就是在JAVA堆中进行。
    方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。但是已经被最新的JVM取消了。现在,被加载的类作为元数据加载到底层操作系统的本地内存区。

JAVA堆

堆内存是由存活和死亡的对象组成的。存活的对象是应用可以访问的,不会被垃圾回收。死亡的对象是应用不可访问尚且还没有被垃圾收集器回收掉的对象。一直到垃圾收集器把这些对象回收掉之前,他们会一直占用对内存空间。堆是应用程序在运行期请求操作系统分配给自己的向高地址扩展的数据结构,是不连续的内存地址。用一句话总结堆的作用:程序运行时动态申请某个大小的内存空间。

image.png

新生代:刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发依次Minor GC, Eden和S0中的存货对象又会被复制到第二块survivor space S1。S0和Eden被清空,然后下一轮S0和S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。
老年代:如果某个对象经历了几次垃圾回收之后还存活,就会被送到老年代中。老年代的空间一般比新生代大。

GC名称 描述
Minor GC 发生在新生代,频率高,速度快
Major GC 发生在老年代,速度慢
Full GC 清理整个堆空间

不过实际运行中,Major GC会伴随至少一次 Minor GC,因此也不必过多纠结于到底是哪种GC(在有些资料中看到把full GC和Minor GC等价的说法)。
那么,当我们创建一个对象后,它会被放在堆内存的哪个部分呢?


image.png

何时回收-对象生死判定

引用计数器法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值加1;当引用失效时,计数器的值减1;当该对象的计数器的值为0时,标志该对象失效。

可达性分析算法

在主流商用语言(如Java、C#)的主流实现中, 都是通过可达性分析算法来判定对象是否存活的: 通过一系列的称为 GC Roots 的对象作为起点, 然后向下搜索; 搜索所走过的路径称为引用链/Reference Chain, 当一个对象到 GC Roots 没有任何引用链相连时, 即该对象不可达, 也就说明此对象是不可用的, 如下图: Object5、6、7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象:


image.png

在Java, 可作为GC Roots的对象包括:

  • 方法区: 类静态属性引用的对象;
  • 方法区: 常量引用的对象;
  • 虚拟机栈(本地变量表)中引用的对象.
  • 本地方法栈JNI(Native方法)中引用的对象。
    注: 即使在可达性分析算法中不可达的对象, VM也并不是马上对其回收, 因为要真正宣告一个对象死亡, 至少要经历两次标记过程: 第一次是在可达性分析后发现没有与GC Roots相连接的引用链, 第二次是GC对在F-Queue执行队列中的对象进行的小规模标记(对象需要覆盖finalize()方法且没被调用过).

GC原理- 垃圾收集算法

分代收集算法 VS 分区收集算法
1)分代收集
当前主流VM垃圾收集都采用”分代收集”(Generational Collection)算法, 这种算法会根据对象存活周期的不同将内存划分为几块, 如JVM中的 新生代、老年代、永久代. 这样就可以根据各年代特点分别采用最适当的GC算法:
在新生代: 每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集.
在老年代: 因为对象存活率高、没有额外空间对它进行分配担保, 就必须采用“标记—清理”或“标记—整理”算法来进行回收, 不必进行内存复制, 且直接腾出空闲内存.
2)分区收集
上面介绍的分代收集算法是将对象的生命周期按长短划分为两个部分, 而分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间.
在相同条件下, 堆空间越大, 一次GC耗时就越长, 从而产生的停顿也越长. 为了更好地控制GC产生的停顿时间, 将一块大的内存区域分割为多个小块, 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次GC所产生的停顿.

分代收集

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

image.png

这使得每次只对其中一块内存进行回收, 分配也就不用考虑内存碎片等复杂情况, 实现简单且运行高效。
image.png

现代商用VM的新生代均采用复制算法, 但由于新生代中的98%的对象都是生存周期极短的, 因此并不需完全按照1∶1的比例划分新生代空间, 而是将新生代划分为一块较大的Eden区和两块较小的Survivor区(HotSpot默认Eden和Survivor的大小比例为8∶1), 每次只用Eden和其中一块Survivor. 当发生MinorGC时, 将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor上, 最后清理掉Eden和刚才用过的Survivor的空间. 当Survivor空间不够用(不足以保存尚存活的对象)时, 需要依赖老年代进行空间分配担保机制, 这部分内存直接进入老年代。

老年代-标记清除算法
该算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象(可达性分析), 在标记完成后统一清理掉所有被标记的对象。

image.png

该算法会有以下两个问题:

  1. 效率问题: 标记和清除过程的效率都不高;
  2. 空间问题: 标记清除后会产生大量不连续的内存碎片, 空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集.

image.png

老年代-标记整理算法
标记清除算法会产生内存碎片问题, 而复制算法需要有额外的内存担保空间, 于是针对老年代的特点, 又有了标记整理算法. 标记整理算法的标记过程与标记清除算法相同, 但后续步骤不再对可回收对象直接清理, 而是让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。
image.png

永久代-方法区回收
在方法区进行垃圾回收一般”性价比”较低, 因为在方法区主要回收两部分内容: 废弃常量和无用的类。回收废弃常量与回收其他年代中的对象类似, 但要判断一个类是否无用则条件相当苛刻:

  • 该类所有的实例都已经被回收, Java堆中不存在该类的任何实例;
  • 该类对应的Class对象没有在任何地方被引用(也就是在任何地方都无法通过反射访问该类的方法);
  • 加载该类的ClassLoader已经被回收.
    但即使满足以上条件也未必一定会回收, Hotspot VM还提供了-Xnoclassgc参数控制(关闭CLASS的垃圾回收功能). 因此在大量使用动态代理、CGLib等字节码框架的应用中一定要关闭该选项, 开启VM的类卸载功能, 以保证方法区不会溢出。

空间分配担保
在执行Minor GC前, VM会首先检查老年代是否有足够的空间存放新生代尚存活对象, 由于新生代使用复制收集算法, 为了提升内存利用率, 只使用了其中一个Survivor作为轮换备份, 因此当出现大量对象在Minor GC后仍然存活的情况时, 就需要老年代进行分配担保, 让Survivor无法容纳的对象直接进入老年代, 但前提是老年代需要有足够的空间容纳这些存活对象. 但存活对象的大小在实际完成GC前是无法明确知道的, 因此Minor GC前, VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小, 如果条件成立, 则进行Minor GC, 否则进行Full GC(让老年代腾出更多空间)。

然而取历次晋升的对象的平均大小也是有一定风险的, 如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然可能导致担保失败(Handle Promotion Failure, 老年代也无法存放这些对象了), 此时就只好在失败后重新发起一次Full GC(让老年代腾出更多空间)。

GC实现-垃圾收集器

image.png
新生代

1. Serial收集器
Serial收集器是Hotspot运行在Client模式下的默认新生代收集器, 它的特点是 只用一个CPU/一条收集线程去完成GC工作, 且在进行垃圾收集时必须暂停其他所有的工作线程(“Stop The World” -后面简称STW)

image.png

虽然是单线程收集, 但它却简单而高效, 在VM管理内存不大的情况下(收集几十M~一两百M的新生代), 停顿时间完全可以控制在几十毫秒~一百多毫秒内。
2. ParNew收集器
ParNew收集器其实是前面Serial的多线程版本, 除使用多条线程进行GC外, 包括Serial可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等都与Serial完全一样(也是VM启用CMS收集器-XX: +UseConcMarkSweepGC的默认新生代收集器)。
image.png

由于存在线程切换的开销, ParNew在单CPU的环境中比不上Serial, 且在通过超线程技术实现的两个CPU的环境中也不能100%保证能超越Serial. 但随着可用的CPU数量的增加, 收集效率肯定也会大大增加(ParNew收集线程数与CPU的数量相同, 因此在CPU数量过大的环境中, 可用-XX:ParallelGCThreads参数控制GC线程数).
3. Parallel Scavenge收集器
与ParNew类似, Parallel Scavenge也是使用复制算法, 也是并行多线程收集器. 但与其他收集器关注尽可能缩短垃圾收集时间不同, Parallel Scavenge更关注系统吞吐量:
系统吞吐量=运行用户代码时间(运行用户代码时间+垃圾收集时间)
停顿时间越短就越适用于用户交互的程序-良好的响应速度能提升用户的体验;而高吞吐量则适用于后台运算而不需要太多交互的任务-可以最高效率地利用CPU时间,尽快地完成程序的运算任务. Parallel Scavenge提供了如下参数设置系统吞吐量。

老年代

1. Serial Old收集器
Serial Old是Serial收集器的老年代版本, 同样是单线程收集器,使用“标记-整理”算法。

image.png

2. Parallel Old收集器
Parallel Old是Parallel Scavenge收老年代版本, 使用多线程和“标记-整理”算法, 吞吐量优先, 主要与Parallel Scavenge配合在 注重吞吐量 及 CPU资源敏感 系统内使用:
image.png

3. CMS收集器
CMS(Concurrent Mark Sweep)收集器是一款具有划时代意义的收集器, 一款真正意义上的并发收集器, 虽然现在已经有了理论意义上表现更好的G1收集器, 但现在主流互联网企业线上选用的仍是CMS(如Taobao、微店).
CMS是一种以获取最短回收停顿时间为目标的收集器(CMS又称多并发低暂停的收集器), 基于”标记-清除”算法实现, 整个GC过程分为以下4个步骤:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark: GC Roots Tracing过程)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep: 已死象将会就地释放, 注意: 此处没有压缩)
    初始标记、重新标记仍需STW. 但初始标记仅只标记一下GC Roots能直接关联到的对象, 速度很快; 而重新标记则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录, 虽然一般比初始标记阶段稍长, 但要远小于并发标记时间。


    image.png

    image.png

分区收集-G1收集器

G1(Garbage-First)是一款面向服务端应用的收集器, 主要目标用于配备多颗CPU的服务器治理大内存.

  • G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS).
  • -XX:+UseG1GC 启用G1收集器。
    与其他基于分代的收集器不同, G1将整个Java堆划分为多个大小相等的独立区域(Region), 虽然还保留有新生代和老年代的概念, 但新生代和老年代不再是物理隔离的了, 它们都是一部分Region(不需要连续)的集合。
    image.png

    每块区域既有可能属于O区、也有可能是Y区, 因此不需要一次就对整个老年代/新生代回收. 而是当线程并发寻找可回收的对象时, 有些区块包含可回收的对象要比其他区块多很多. 虽然在清理这些区块时G1仍然需要暂停应用线程, 但可以用相对较少的时间优先回收垃圾较多的Region(这也是G1命名的来源). 这种方式保证了G1可以在有限的时间内获取尽可能高的收集效率。
    新生代收集
    image.png

    G1的新生代收集跟ParNew类似: 存活的对象被转移到一个/多个Survivor Regions. 如果存活时间达到阀值, 这部分对象就会被提升到老年代。
    image.png

G1的新生代收集特点如下:

  • 一整块堆内存被分为多个Regions.
  • 存活对象被拷贝到新的Survivor区或老年代.
  • 年轻代内存由一组不连续的heap区组成, 这种方法使得可以动态调整各代区域尺寸.
  • Young GCs会有STW事件, 进行时所有应用程序线程都会被暂停.
  • 多线程并发GC.

老年代收集
G1老年代GC特点如下:

1)并发标记阶段(index 3)

  • 在与应用程序并发执行的过程中会计算活跃度信息.
  • 这些活跃度信息标识出那些regions最适合在STW期间回收(which regions will be best to reclaim during an evacuation pause).
  • 不像CMS有清理阶段.

2)再次标记阶段(index 4)

  • 使用Snapshot-at-the-Beginning(SATB)算法比CMS快得多.
  • 空region直接被回收。

3)拷贝/清理阶段(Copying/Cleanup Phase)

  • 年轻代与老年代同时回收.
  • 老年代内存回收会基于他的活跃度信息

JAVA性能调优

大多说针对内存的调优,都是针对于特定情况的。但是实际中,调优很难与JAVA运行动态特性的实际情况和工作负载保持一致。也就是说,几乎不可能通过单纯的调优来达到消除GC的目的。

真正影响JAVA程序性能的,就是碎片化。碎片是JAVA堆内存中的空闲空间,可能是TLAB剩余空间,也可能是被释放掉的具有较长生命周期的小对象占用的空间。

下面是一些在实际写程序的过程中应该注意的点,养成这些习惯可以在一定程度上减少内存的无谓消耗,进一步就可以减少因为内存不足导致GC不断。

  • 减少new对象。每次new对象之后,都要开辟新的内存空间。这些对象不被引用之后,还要回收掉。因此,如果最大限度地合理重用对象,或者使用基本数据类型替代对象,都有助于节省内存;
  • 多使用局部变量,减少使用静态变量。局部变量被创建在栈中,存取速度快。静态变量则是在堆内存;
  • 避免使用finalize,该方法会给GC增添很大的负担;
  • 如果是单线程,尽量使用非多线程安全的,因为线程安全来自于同步机制,同步机制会降低性能。例如,单线程程序,能使用HashMap,就不要用HashTable。同理,尽量减少使用synchronized
  • 用移位符号替代乘除号。eg:a*8应该写作a<<3
  • 对于经常反复使用的对象使用缓存;
  • 尽量使用基本类型而不是包装类型,尽量使用一维数组而不是二维数组;
  • 尽量使用final修饰符,final表示不可修改,访问效率高
  • 单线程情况下(或者是针对于局部变量),字符串尽量使用StringBuilder,比StringBuffer要快;
  • String为什么慢?因为String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象。如果不能保证线程安全,尽量使用StringBuffer来连接字符串。这里需要注意的是,StringBuffer的默认缓存容量是16个字符,如果超过16,apend方法调用私有的expandCapacity()方法,来保证足够的缓存容量。因此,如果可以预设StringBuffer的容量,避免append再去扩展容量。如果可以保证线程安全,就是用StringBuilder。

你可能感兴趣的:(2018-07-20)