更多内容请关注:

锁清秋

Java垃圾回收机制

内存区域中的 程序计数器、虚拟机栈、本地方法栈 这3个区域随着线程而生,线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。在这几个区域不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。

而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC关注的也是这部分内存,下面涉及到“内存”分配与回收也仅指着一部分内存。

基础概念

堆(Heap-线程共享) -运行时数据区

是被线程共享的一块内存区域, 创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。 由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、 From Survivor 区和 To Survivor 区)和老年代。

方法区/永久代 /元空间(线程共享)

即我们常说的永久代(Permanent Generation)(JDK 1.8之后将最初的永久代取消了,由元空间取代。), 用于存储被 JVM 加载的类信息、 常量、 静态变量、 即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代 /元空间来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小)。

运行时常量池(Runtime Constant Pool)是方法区的一部分。 Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池。

(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

什么是垃圾

当一个对象没有被其他对象引用时,该对象就是没有的,对于系统而言它就是 垃圾

如何确定垃圾

  1. 引用计数法
    在 Java 中,引用和对象是有关联的,每个对象实例都有一个引用计数器,被引用则+1,完成引用则-1。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用, 即他们的引用计数都为 0, 则说明对象不太可能再被用到,那么这个对象就是可回收对象。

    优点:执行效率高,程序执行受影响较小
    缺点:无法检测岀循环引用的情况,导致內存泄露。(如:父对象引用子对象,子对象引用父对象)

  2. 可达性分析
    为了解决引用计数法的循环引用问题, Java 使用了可达性分析的方法。通过一系列的“GC roots” 对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。
    要注意的是,不可达对象不等价于可回收对象, 不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收

可作为GCroot对象:

> 虚拟机栈中引用的对象(栈帧中的本地变量表)
> 方法区中的常量引用的对象
> 方法区中的类静态属性引用的对象
> 本地方法栈中JNI( Native方法)的引用对象
> 活跃线程的引用对象。

垃圾回收算法

标记清除算法(Mark and sweep)

  • 标记:从根集合(GCroot)进行扫描,对存活的对象进行标记
  • 清除:对堆内存从头到尾进行线性遍历,回收不可达对象内存

该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。(容易引发段时间里再次进行GC,容易出现 OOM )

复制算法(copying)

为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块,分为对象面和空闲面,对象在对象面上创建,存活的对象被从对象面复制到空闲面,将对象面所有对象内存清除。

这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话, Copying 算法的效率会大大降低。(适用于存活率低的场景,如 年轻代)

标记整理算法(Mark-Compact)

结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同, 标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。

  • 标记:从根集合进行扫描,对存活的对象进行标记
  • 清除:移动所有存活的对象,且按照內存地址次序依次排列,然后将未端内存地址以后的内存全部回收。

避免内存的不连续性,不用设置两块内存互换,适用于存活率高的场景(如老年代)

分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老年代(Tenured/Old Generation)和新生代(Young Generation)。老年代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),

  • 普通GC(minor GC):只针对新生代区域的GC。
  • 全局GC(major GC or Full GC):针对年老代的GC,偶尔伴随对新生代的GC以及对永久代的GC。

新生代与复制算法

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

从年轻代进入老年代的条件:

  • 经历一定Minor次数依然存活的对象(默认是15次)
  • Survivor区中存放不下的对象
  • 新生成的大对象(Eden区放不下)(-XX:+ Pretenuer size threshold)

老年代与标记复制算法

而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。

触发 Full GC 的条件:

  • 老年代空间不足
  • 永久代空间不足(JDK7以前)
  • CMS GO时出现 promotion failed, concurrent mode failure
  • Minor gc 晋升到老年代的平均大小大于老年代的剩余空间调用
  • System. gc
  • 使用RMI来进行RPC或管理的DK应用,每小时执行1次FuGC

Stop-The-World

Stop-The-World是Java中一种全局暂停的现象。

这种现象是 JVM 由于要执行GC而停止了应用程序的执行,任何一种GC算法中都会发生,多数GC优化通过减少Stop-the-world发生的时间来提高程序性能

全局停顿:所有Java代码停止,native代码可以执行,但不能和JVM交互,多半情况下是由于GC引起。少数情况下由其他情况下引起,如:Dump线程、死锁检查、堆Dump。

GC时为什么会有全局停顿

  1. 避免无法彻底清理干净
    打个比方:类比在聚会,突然GC要过来打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。况且,如果没有全局停顿,会给GC线程造成很大的负担,GC算法的难度也会增加,GC很难去判断哪些是垃圾。

  2. GC的工作必须在一个能确保一致性的快照中进行。
    这里的一致性的意思是:在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果的准确性无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程的其中一个重要原因。