深入JVM(二)JVM垃圾回收器
什么垃圾?为什么要回收?
前提:在java中创建对象的过程就是在内存中分配区域的过程,每次创建,虚拟机会为对象分配一块区域。
定义垃圾:和实际生活一样,没用用处的东西就叫做垃圾,对于Java虚拟机来说,如果这个对象不会被利用,那么这个对象就是垃圾对象。
为什么要回收:其实看完前面已经解释了为什么要回收,因为如果不回收,既不会被使用,而且还会占着内存空间,导致内存溢出,最终OOM异常,这是不允许出现的问题。所以需要对垃圾对象进行回收。在有些语言中需要使用命令、指令手动回收空间,如C、C++,一不小心就忘记回收或多次回收,都会产生问题。而在Java中使用的是垃圾回收器自动回收。
垃圾回收器如何找到垃圾?
有两种方式可以让垃圾回收器来寻找在到在堆中死亡(不能再被任何途径使用的对象)的对象。也就是对对象进行存活分析。
引用计数(Refrence Count)
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1,任何时候引用为 0 的对象就是不可能再被使用的。
这种方式即简单,效率又高,但现在的java虚拟机并没有采用这种方式,原因是这种方式有一个致命的缺陷就是无法找到循环引用的对象。
根可达算法(Root Searching)
根可达算法根据一系列的GC Root
作为起点向下搜索,搜索所走过的路径称为引用链(Reference Chain
),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
在主流的商用程序语言中(Java和C#),都是使用可达性分析算法判断对象是否存活的。
在Java语言中,可作为GCRoots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
GC 算法
在对对象的存活分析后,知道了哪些对象已死亡,哪些对象还存活,会通过下面三种方法对死亡的对象进行清除。
标记清除(Mark-Sweep)
首先会为死亡的对象添加标记,然后在完成标记后,清除标记的对象。此算法为基础的清除算法,因为后面的算法都是基于此改进而来。
这种算法产生两个问题,一是效率问题,标记和清除两步的效率都不高,二是空间碎片化问题,在内存中产生大量的内存碎片。
拷贝算法(Copying)
将内存一分为二,每次分配内存只分配在第一块里面,将存活的对象顺序拷贝的另外一块中,然后将第一块中的对象全部抹除掉。这种算法实现简单运行高效,但是带来了一个问题就是内存的利用率不高,每次只能用一半。
标记压缩(Mark-Compact)
和标记清除相似,同样是做标记,但是后续步骤却有大不同,标记压缩不同于标记清除,直接将死亡对象进行清除,而是在标记死亡的对象后,将存活的对象向一端移动,最后清理到端边界以外的内存。相当于将存活的对象压缩到顺序的内存中后,清理掉其他内容。
垃圾回收器
现在JVM中一共有10中垃圾回收器,按照模型来分,可分为分代模型和不分代模型(.8之后)
分代模型
虚拟机的垃圾收集可能会采用“分代收集”(GenerationalCollection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。根据不同的区域不同的特性使用不同的垃圾回收算法。
默认新生代与老年代的占比默认是1:2。新产生的对象默认会生成在新生代中,经过gc增加对象的年龄(age),当age超过一定数值时,将对象从新生代转移到老年代中。
新生代
因为在新生代产生的对象,大部分都会被回收掉,也就是说有很少的对象会存活下来,因此,新生代适合使用复制算法能够尽可能少的挪动活的对象,又能够尽可能快的清除死亡的对象。在这个基础上,来谈一谈新生代中的分区。
因为新生代的存活对象非常少,大概占比1/9,所以新生代的分区并不像上面介绍拷贝算法一样是均分为1:1,而是将新生代分为 Eden:survive1:survive2为 8:1:1。具体为何分为三个区域通过下面的图就能够了解:
第一次回收会扫描Eden区,将存活对象从eden区挪到S1中,清空Eden区,第二次会扫描Eden区和S1区,将存活的对象放到S2中,清空Eden和S1区,一次
老年代
当对象经历了多次GC仍然没有被回收掉时,会被挪到老年代中,因为老年代中的回收次数和回收效率并不想新生代一样,经历一次gc只有少量的对象被回收掉。所以老年代采用标记整理算法,能够减少老年代中出现内存碎片的概率。
TLAB
多线程同时在堆中分配内存时,为了避免多线程冲突,操作同一地址,所以需要对整堆进行加锁,进而影响分配速度。TLAB 能够解决这个问题。
通过对 Eden 区域再进行划分, Thread Local Allocation Buffer(TLAB),这是 JVM 在eden区中为每个线程分配的一个私有缓存区域。多线程分配时,会优先在自己的TLAB中分配,分配不下再在Eden区中分配。
对象在内存中的生命周期
- 首先会尝试在栈上分配。在栈上分配的好处:执行速度快,不需要的对象直接弹出,不需要垃圾回收器的介入即可完成内存的释放。
- 小对象会现在Eden中的TLAB中进行分配,如果TLAB中分配不下会在Eden区域公共部分进行分配。
- 如果对象太大,无法在新生代找到足够长的连续空闲空间, JVM 会直接将对象分配到老年代。
- GC过程,GC过程可以分为对新生代、老年代的回收、和全部回收的过程。其中新生代的YGC(young GC)的发生频率最高。
垃圾回收器的类型
在列举垃圾回收器的类型时,先强调几个Hotspot在垃圾器算法的实现:
OopMap:因为Hotspot在GC之前,查找内存中所有存活的对象使用可达性算法寻找死亡的对象,对于一个庞大的系统,每次都进行遍历查找引用是不可行的。因为在进行可达性算法的寻找对象时,需要将所有线程保持一个冻结的状态,Hotspot将这种状态成为”Stop The World“,也就是STW,来配合可达性分析,这样会停顿的时间会非常长。
所以为了避免STW的时间过长,Hotspot使用一个OopMap的数据结构,让虚拟机知道哪些地址存放着对象引用。当类加载完成的时候,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。
SafePoint:使用OopMap可以减少STW的时间,但是在运行过程中,因为引用关系随时会发生变化,OopMap的内容也要随之变化,但不是所有指令都会及时的更改内容,这时引入SafePoint的概念。
程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。
SafeRegion:SafePoint的扩充,停滞的线程,内容不会发生变化,所以这段代码区域内任何地方进行GC都是没有问题的。
JVM共有10中垃圾回收器,使用分代模型的共有六种,三种新生代的、三种老年代的,需要配对使用。
一般的配对类型为:
- Serial&Serial-Old 在暂停所有工作线程后,使用单个GC线程进行垃圾回收
- Parallel Scavenge & Parallel Old 暂停所有工作线程使用多个GC线程进行回收
- ParNew & CMS , ParNew和 Parallel Scavenge 其实在功能上是相同的,只是名字不同,CMS在回收老年代时,会经历下图中的三个标记阶段、一个清理阶段:
- 初始标记
- 并发标记
- 重新标记
- 并发清理
CMS无法清理浮动垃圾,只能等到下一次清理时才能清除。
因为CMS是一款基于“标记—清除”算法实现的收集器,如果读者对前面这种算法介绍还有印象的话,就可能想到这意味着收集结束时会有大量 空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有 很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,当遇到这种问题时,会通过Serial Old,也就是单线程使用标记-整理在进行垃圾回收,此时的回收效率非常低。
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”
不分代模型
所谓的不分代模型指的是物理区域不分代,但是在逻辑上风染保留年轻代老年代的概念
G1
G1的出现是为了替代掉在1.5时发布的CMS垃圾回收器。
- 并行与并发,和CMS相似,G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者 CPU核心)来缩短Stop-The-World停顿的时间。
- 分代收集:分代概念在G1中依然得以保留。虽然G1可以不需要其 他收集器配合就能独立管理整个GC堆。
- 收集算法:CMS使用标记-清除算法,意味着会产生大量的空间碎片,而G1使用标记-整理,在收集后能提供规整的可用内存
- 可预测的停顿:G1不像于分代模型中的收集器收集范围是整个年轻代或者老年代,在G1中产生一个概念叫做Region。G1会将堆分为大小相等的Region,在G1中所谓的老年代和年轻代就是不连续的Region区域。因为以上的特性,在G1中进行垃圾回收可以避免对整个区域回收,避免了停顿和增加了回收的效率。