为什么 Java 要进行垃圾回收?
手动回收的缺点:容易造成引用悬挂(所引用的对象不存在了,引用再继续操作执行结果不可预知)内存泄漏(当某些引用变量不再引用该内存对象的时候,而该对象原本占用的内存并没有被释放)手动管理成本太高,风险太大。
垃圾回收可以有效的防止内存泄露,有效的使用可以使用的内存。
垃圾回收,就是通过垃圾收集器把内存中没用的对象清理掉。垃圾回收涉及到的内容有:
1、判断对象是否已死;
2、选择垃圾收集算法;
3、选择垃圾收集的时间;
4、选择适当的垃圾收集器清理垃圾(已死的对象)。
1:判断对象是否以死
判断对象是否已死就是找出哪些对象是已经死掉的,以后不会再用到的,就像地上有废纸、饮料瓶和百元大钞,扫地前要先判断出地上废纸和饮料瓶是垃圾,百元大钞不是垃圾。判断对象是否已死有引用计数算法和可达性分析算法。
(1)引用计数算法
给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加 1;每当有一个地方不再引用它时,计数器值减 1,这样只要计数器的值不为 0,就说明还有地方引用它,它就不是无用的对象。如下图,对象 2 有 1 个引用,它的引用计数器值为 1,对象 1 有两个地方引用,它的引用计数器值为 2。
这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死,如下图,对象 1 和对象 2 都没有被堆外的变量引用,而是被对方互相引用,这时他们虽然没有用处了,但是引用计数器的值仍然是 1,无法判断他们是死对象,垃圾回收器也就无法回收。
(2)可达性分析算法
了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,可以作为 GCRoots 的有:
1:虚拟机栈中本地变量表中引用的对象
每个方法执行的时候看,JVM 都会创建一个相应的栈帧,栈帧包括(操作数栈,局部变量表,运行时常量池的引用),栈帧中包含这个方法内部使用的所有对象的引用,这就是虚拟机栈中的引用对象,一旦该方法执行完后,该栈帧就会从虚拟机栈中弹出,这样一来这些局部(临时)对象的引用也就不存在了,或者说没有任何 GCRoot 指向这些临时对象,所以这些对象在下一次 GC 时就会被回收掉。
2:方法区中静态属性引用的对象
一般指被 static 修饰的对象,加载类的时候就加载到内存中
private static User user = new User();
private static User user1;
3:方法区中常量引用的对象
private final User user2 = new User();
4:本地方法栈中 JNI(Native 方法)引用的对象。
当一个对象到 GC Roots 没有任何引用链相连(GCRoots 到这个对象不可达)时,就说明此对象是不可用的,是死对象。如下图:object1、object2、object3、object4 和 GC Roots 之间有可达路径,这些对象不会被回收,但 object5、object6、object7 到 GC Roots 之间没有可达路径,这些对象就被判了死刑。
1:四种引用
(1)强引用 只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。
当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
(2) 软引用(SoftReference) 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象 可以配合引用队列来释放软引用自身。适合做缓存。缓存个图片。
SoftReference
(3) 弱引用(WeakReference) 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
可以配合引用队列来释放弱引用自身
(4) 虚引用(PhantomReference) 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队, 由 Reference Handler,虚引用根本 get 不到。回收时会去队列里检查是否还有引用。
线程调用虚引用相关方法释放直接内存。
主要用来管理直接内存,即堆外内存。NIO 中零拷贝,会将资源拷贝到堆外内存 buffer,JVM 要想使用 buffer,要么拷贝到虚拟机堆内,要么使用虚引用进行直接操作。
(3)方法区回收
上面说的都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类。
判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。
判断类是否废弃需要同时满足如下条件:
该类所有的实例已经被回收(堆中不存在任何该类的实例)
加载该类的 ClassLoader 已经被回收
该类对应的 java.lang.Class 对象在任何地方没有被引用(无法通过反射访问该类的方法)
2、常用垃圾回收算法
(1)标记-清除算法
分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,如下图。
缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。
(2)复制算法
把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图。
缺点:实际可使用的内存空间缩小为原来的一半,比较适合。
(3)标记-整理算法
先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存,如下图。
(4)分代收集算法
把堆内存分为新生代和老年代,新生代又分为 Eden 区、From Survivor 和 To Survivor。
一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;
老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。
对象首先分配在伊甸园区域
新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的 对象年龄加 1 并且交换 from to
minor gc 会引发 stop theworld,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行,当对象寿命超过阈值时,会晋升至老年代,最大寿命是 15(4bit)
当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW 的时 间更长
为什么有两个 Survivor 区?如果只有一个 Survivor 区的话,容易造成碎片,如果两个的话,当回收的时候就可以将 Eden 和 From 区的都放到 To 区就好了。
3、选择垃圾收集的时间
当程序运行时,各种数据、对象、线程、内存等都时刻在发生变化,当下达垃圾收集命令后就立刻进行收集吗?肯定不是,他们要在保证线程安全的前提下进行垃圾回收
安全点:从线程角度看,安全点可以理解为是在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程。当垃圾收集时,如果需要暂停当前的用户线程,但用户线程当时没在安全点上,则应该等待这些线程执行到安全点再暂停。理论上,解释器的每条字节码的边界上都可以放一个安全点,实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定。
安全区:安全点是相对于运行中的线程来说的,对于如 sleep 或 blocked 等状态的线程,收集器不会等待这些线程被分配 CPU 时间,这时候只要线程处于安全区中,就可以算是安全的。安全区就是在一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点。
4、常见垃圾收集器
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、CMS、Parallel Old
堆内存垃圾收集器:G1
前六种叫做分代模型,G1 逻辑分代,物理不分代,ZGC 逻辑物理都不分,Epsilon 是啥也不做
图中展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
查看命令:java -XX:+PrintCommandLineFlags -version
1.8 默认的是 Paralle
1.9 默认的是 G1
G1 适用于 8/16G 以上的内存适用,清理垃圾时虽然 STW,但是是可控的.
CMS 并发但是不可控
(1)串行 Serial
单线程,堆内存较小,适合个人电脑
-XX:+UseSerialGC = Serial + SerialOld
- 新生代采用复制算法,Stop-The-World
- 老年代采用标记-整理算法,Stop-The-World
(2)并行
花费了大量时间在进程调度上。
-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
-XX:GCTimeRatio=ratio
-XX:MaxGCPauseMillis=ms
-XX:ParallelGCThreads=n
- 新生代采用复制算法,Stop-The-World
- 老年代采用标记-整理算法,Stop-The-World
(5)CMS(并发标记清除)垃圾收集器
老年代收集器
以获取最短回收停顿时间
“Concurrent”并发是指垃圾收集的线程和用户执行的线程是可以同时执行的。
CMS 是基于“标记-清除”算法实现的,整个过程分为 4 个步骤:
1、初始标记(CMS initial mark):找到根对象,标记老年代中所有的 GC Root 对象,标记年轻代中活着的对象引用到老年代的对象(指年轻带中还存活的引用类型对象,引用指向老年代的对象)
2、并发标记(CMS concurrent mark):过滤对象树,可能产生错误标记,已经标记为垃圾,又被连上了,该阶段会把上述对象所在的 Card 标识为 Dirty,后续只需扫描这些 Dirty Card 的对象,避免扫描整个老年代;并发标记阶段只负责将引用发生改变的 Card 标记为 Dirty 状态,不负责处理;
3、重新标记(CMS remark):由于前面是并发标记的,这时候年轻代的对象对老年代的引用已经发生了改变,修正错标,CMS 和 G1 都采用的三色标记,CMS 采用增量更新,G1 使用快照的方式。ZGC 采用颜色指针。
4、并发清除(CMS concurrent sweep)。
上图中,初始标记和重新标记时,需要 stop the world。整个过程中耗时最长的是并发标记和并发清除,这两个过程都可以和用户线程一起工作。
优点:
- 支持并发收集.
- 低停顿,
缺点:
1、CMS 收集器对 CPU 资源非常敏感。
2、CMS 收集器无法处理浮动垃圾(Floating Garbage,并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,只能等待下一次 GC 再将该对象回收,所以这种对像就是浮动垃圾)可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生
3,采用标记清理算法,清理后可能会产生大量的内存碎片,如果没有整块空间存了,就会触发 Full GC ,然后进行空间整理压缩。
解决内存碎片:
-XX:CMSFullGCsBeforeCompaction=n 意思是说在上一次 CMS 并发 GC 执行过后,到底还要再执行多少次 full GC 才会做压缩。默认是 0,也就是在默认配置下每次 CMS GC 顶不住了而要转入 full GC 的时候都会做压缩。 如果把 CMSFullGCsBeforeCompaction 配置为 10,就会让上面说的第一个条件变成每隔 10 次真正的 full GC 才做一次压缩。
TLABs
为每一个线程分配一个Buffer,线程分配内存就在这个Buffer内分配。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。
(4)G1
适用场景
同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是 200 ms
适用于超大堆内存,会将堆划分为多个大小相等的 Region
整体上是标记+整理算法,两个区域之间是复制算法
软实时(G1会努力在一定时限内完成垃圾回收,但是不保证每次都能在这个时限内完成),低延时
结构
G1 的内存布局不再是新生代老年代等等的了,变成了
每个Region的大小可以通过-XX:G1HeapRegionSize 参数设置。大小只能是2的幂次方。在HotSpot的实现中,整个堆被划分为2048左右各Region。
跨代引用:
Card Table 和 Remebered Set(记住谁引用了我)
RS(Remember Set)是一种抽象概念,在G1回收器里面,RS被用来记录从其他Region指向一个Region的指针情况。因此,一个Region就会有一个RS。
这种记录可以带来一个极大的好处:在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用。如果一个线程修改了Region内部的引用,就必须要去通知RS。
Writer Barrier(写屏障)
GC流程:
G1中提供了三种模式垃圾回收模式,young GC ,Mixed GC和Full GC,在不同的条件下触发。
1:Fully young GC 完全的年轻代 GC,产生一个 STW,构建 CS(Eden + Surivor),扫描 GC Rooot,排空 Dirty Card Queue,处理 Remebered Set (找到被老年代所引用的对象)使用卡表(Card Table)进行卡标记(card Marking)来解决老年代与新生代直接的引用问题,复制对象到 Survivor 区,处理软,虚等引用
具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记并加快对 GC Roots 的扫描。卡表的设计师将堆内存平均分成 2 的 N 次方大小(默认 512 字节)个卡,并且维护一个卡表,用来储存每个卡的标识位。当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为脏页。在 YGC 只需要扫描卡表中的脏卡,将脏中的对象加入到 YGC 的 GC Roots 里面。当完成所有脏卡扫描时候,虚拟机会将卡表的脏卡标志位清空。
2:ConCurrent Marking:并发标记进行,三色(黑灰白)标记算法:初始标记,标记根节点直接到达的对象;根区域扫描,扫描 survivor 区直接可达的老年代区域对象,并标记被引用的对象;并发标记,在整个堆中进行标记;再次标记,修正标记(STW),独占清理(STW),计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域;并发清理
3:Mixed GC
当越来越多的对象晋升到老年代 old region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个 Young Region,还会回收一部分的 Old Region。
- 全局并发标记:在MixGC之前,会先进行全局并发标记。其中会分为五个步骤:初始标记(STW,从GC Root触发标记全部直接子节点),根区域扫描(在初始标记的存活区,扫描老年代的引用),并发标记(整个堆中查找存活对象),再标记(STW,,清除垃圾
- 拷贝存活对象
G1 垃圾回收周期如下图所示
优点:
- 并行与并发
- 管理不同的代
- 没有内存碎片,整体上是标记整理算法,从局部看(相关的两块 Region)看是复制算法,都不会产生内存碎片。
- 可控的 STW
缺点:
使用了着色指针和内存屏障
ZGC 只有三个 STW 阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间很短,最多 1ms,超过 1ms 则再次进入并发标记阶段。
关键技术:
ZGC 通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着 GC 线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在 ZGC 中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM 是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。
着色指针:
ZGC 将对象存活信息存储在 42~45 位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
读屏障:
读屏障是 JVM 向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
1:Young GC 的改变
CMS 新生代的 Young GC、G1 和 ZGC 都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。
标记-复制算法应用在 CMS 新生代(ParNew 是 CMS 默认的新生代垃圾回收器)和 G1 垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从 GC Roots 集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
补:三色标记算法
在从GCroot往下找时
当节点被访问到,且访问到其成员变量,标记为黑色
当节点被访问到,但是没有访问其成员变量,标记为灰色
没有被访问到的节点,被标记为白色
第一种情况:灰色B指向白色D消失了
本来能找到D,可是顺着B找的时候找不到了,扫描不到了,这是叫做浮动垃圾,就直接被回收,无所谓。
第二种:B指向D消失了,但是增加了A指向D
顺着B找不到D了,此时D找不到了,因为此时A已经走过去了,没有办法再找A的相关节点了,此时D会被标记为垃圾进行清除。
CMS方案:增量更新。
当增加了A指向D时,现将A变成灰色,此时就会对A进行重新扫描,
这其中涉及为写屏障,
CMS有隐蔽问题:并发标记产生漏标
remark阶段必须重头到位扫描一遍。
CMS满了之后,直接变成Series单进程进行标记回收
G1方案:
当B指向D的引用消失的时候,要把这个引用推到GC的堆栈,保证D还能被GC扫描到,配合RSet,即记录到一个栈中,下次再取出来判断一遍。
5:内存分配与回收策略
Minor GC 和 Full GC
- Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
内存分配策略
- 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
- 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
- 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值。
- 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
- 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
- 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
- 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。
- JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
- Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。