JVM的垃圾回收机制,在内存充足的情况下,除非你显式的调用System.gc(),否则不会进行垃圾回收;在内存充足的情况下垃圾回收会自动运行。
一、引用计数算法
1.定义:引用计数算法会给对象添加一个引用计数器,每当有一个地方引用他的时候,计数器就加1;当引用失效的时候计数器值就减1。当计数器为0的时候,对象就可以被收回。
2.缺点:存在循环引用的情况,导致两个循环引用对象的内存得不到释放。目前没有一个JVM垃圾回收实现是使用这个算法的。
3.现状:主流的Java虚拟机没有使用引用计数算法来管理内存,因为它很难解决循环引用的问题。
二、可达性分析算法
1.思路:通过一系列"GC Roots"对象作为起点,从这些节点开始向下进行搜索,搜索所走过的路径被称为"引用链"。当一个对象到GC Roots没有任何引用链相连,也就是从GC Roots到这个对象不可达,则证明此对象是不可用的。如图中Object5、Object6、Object7虽然互相关联,但是他们到GC Roots是不可达的,所以他们被判定为可回收的对象。
把一些对象当做root对象,JVM认为root对象是不可回收的,并且root对象引用的对象也是不可回收的。在Java语言中,可作为GC ROOT的对象包含以下几种:
(1)虚拟机栈(栈帧中本地变量表)中引用的队形啊
(2)方法区中静态属性引用的对象
(3)方法区中常量引用的对象
(4)本地方法栈中Native方法引用的对象。
2.即使在可达性分析法中不可达的对象,也不是非死不可的,要真正宣告对象的死亡,只要经历两次标记过程:
(1)如果对象在进行可达性分析之后,发现没有与GC Roots相连的引用链,那么它会被第一次引用。
(2)判断该对象是否有必要执行finallize(),如果对象没有覆盖finallize(),或者finallize()方法已经被覆盖过了,虚拟机将这种情况视为没有必要执行。
- 如果一个对象的finallize()方法执行缓慢,甚至发生了死循环,那么导致F-Queue队列中的其他对象永远等待下去,甚至导致整个回收系统崩溃,因为在FQueue中的对象无法进行垃圾的回收。
- 如果一个对象被判定为有必要执行finalize()方法,那么这个对象将被放置在一个F-Queue的队列中,并在稍后由虚拟机建立的,低优先级的Finalizer线程去执行。这里的执行是指虚拟机会触发这个方法,但是不承诺等待该方法执行完毕,这样做的原因是:finalize()方法是对象最后一次逃脱死亡命运的机会,如果对象在finalize()方法中成功拯救自己,和引用链上的任何一个对象关联起来,比如把自己(this)赋值给某个类变量或者成员变量,那么在第二次标记的时候会被移除即将回收的集合。
- 如果对象没有成功逃脱,那么基本上会被真的回收了(第二次标记)。
任何一个对象的finallize方法只会被系统自动调用一次,如果对象面临下一次回收,他的finalize()方法不会再被执行。尽量避免finalize方法,因为它只是为了是C/C++程序员更容易接受java所做出的的一个妥协,他的运行代价高昂,不确定性高,无法表达各个对象的调用顺序。
1 public class HYFinalize {
2
3
4 public static void main(String[] args) {
5 Book book = new Book(true);
6
7 book.checkIn();
8
9 // 每一本书都应该进行checkIn操作,从而释放内存。
10 // 这本书没有进行 checkIn操作,因此,没有执行清理操作(没有输出finalize execute)。也就是利用finalize方法进行终结验证,从而找出没有释放对象的内存。
11 new Book(true);
12
13 // 手动调用垃圾回收
14 System.gc();
15 }
16
17 }
18
19 class Book {
20 boolean checkOut;
21
22 public Book(boolean checkOut) {
23 this.checkOut = checkOut;
24 }
25
26 void checkIn() {
27 checkOut = false;
28 }
29
30 @Override
31 protected void finalize() throws Throwable {
32 super.finalize();
33
34 if (checkOut) {
35 System.out.println("finalize execute");
36 }
37 }
38 }
三、方法区中的垃圾回收
Java虚拟机确实说过可以不在方法区中实现垃圾收集,方法区中的垃圾收集效率非常低,因为条件苛刻。
1.方法区(在HotSpot虚拟机中也称为永久代),主要回收的内容是:废弃常量和无用的类。
对于废弃常量和回收Java堆找那个的对象非常类似。以常量池中的字面量的回收为例,例如一个字符串"abc"已经进入常量池中,但是当前系统没有任何一个String对象叫做"abc"的,换句话说已经没有没有任何String对象引用常量池中的"abc"常量,也没有其他地方引用这个常量,如果这个时候发生内存回收,并且有必要的话,这个"abc"常量会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。对于无用的类则需要同时满足下面三个条件:
ClassLoader
java.lang.Class
这里解释一下为什么需要回收该类的ClassLoader
public Class<?> getDeclaringClass() throws SecurityException {
final Class<?> candidate = getDeclaringClass0();
/*
* 反射里面使用到ClassLoader,因此要把ClassLoader干掉,才能保证没有地方可以通过反射调用到Class类。
* 然后当类的实例都会被回收了,并且该类没有在任何地方被引用到了,那么这个类就可以被回收了
*/
if (candidate != null)
candidate.checkPackageAccess(
ClassLoader.getClassLoader(Reflection.getCallerClass()), true);
return candidate;
}
可以通过虚拟机参数控制类是否被回收,XnoClassgc。在大量使用反射、动态代理、GCLib等ByteCode框架、动态生成JSP这类频繁定义ClassLoader的场景,都需要虚拟机具有卸载功能,以保证永久代不会溢出。
四、常见的垃圾回收算法
1.标记-清除算法
(1)思想:算法分为标记、清除两个阶段:首先标记所有需要回收的对象,在标记完成后,统一回收被标记的对象。标记过程使用的是可达性分析算法。它是最基础的算法,因为后面的垃圾回收算法都是基于标记清除算法的改进。标记清除也是最简单的算法。
(2)优点:实现简单。
(3)缺点:一个是效率问题,标记和清除的构成,两个效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片。空间内存碎片太多,那么需要给较大的对象分配内存空间的时候,无法找到足够的内存空间,而不得不提前触发一次内存的回收。
2.复制收集算法
(1)思想:将可用的内存分为大小相等的两块,每次只使用其中的一块。当这一块使用完了,就将活着的对象复制到另一块上,然后再把已经使用过的内存空间一次性清理掉。
(2)优点:这样每次都是对整个半区进行回收,内存分配的时候也不用考虑内存碎片等复杂的情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
(3)缺点:这种算法的代价将内存缩小为原来的一般,代价太高了。
(4)现代商业虚拟机都采用这种方法来回收新生代,IBM公司研究表明,新生代中的对象98%都是朝生暮死的,所以不需要按照1:1来划分内存空间,而是将内存空间划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收的时候,将Eden和Survivor还存活的对象一次性复制到另外一个Survivor空间上,最后清理Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存为整个新生代的90%,只有10%的内存空间会被浪费。当然98%的对象只是一般场景下的数据,我们没办法保证每次回收都只有不多于10%内存大小的对象存活,当Survivor空间不够的时候,需要依赖其他内存进行分配担保。分配担保:如果另一块Survivor空间没有足够的存放上一次新生代回收存活下来的对象时,这些对象直接进入老年代。
4.标记-整理算法(Mark-Compact)
(1)思想:复制收集算法在对象存活率较高时,就要进行较多的复制操作,导致效率变低。由于老年代存活率较高一般不采用这种算法。根据老年代的特点,有人提出标记整理算法,标记过程仍然使用可达性分析算法,然后让所有的活着的对象向一端移动,然后直接清理掉边界以外的内存。
(2)优点:不容易产生内存碎片,内存利用率高
(3)缺点:存在对象多并且分散的时候,移动次数多效率低下;程序暂停
5.分代收集算法
(1)只是根据对象存活周期的不同将堆分为新生代和老年代(永久代指的是方法区),这样就可以根据各个年代的特点采用最适当的收集算法。分代收集是目前大多数JVM垃圾收集齐所采用的的算法。在新生代每次对象回收都有大量的对象死去,只有少数存活,那就使用复制算法,只需付出少量的复制成本就可以完成收集。而老年代中对象存活率高,并且没有掐空间对其进行分配担保,就必须使用标记清除活着标记整理算法。
(2)新生代
- 在新生代里面存放的是存活时间比较短的对象,如某一方法的局域变量、循环内的临时变量等等。
- 在新生代中每次垃圾收集的时候都有大批的对象死去,只有少量的存活,那就选用复制算法,只需要付出少量存活的复制成本就可以完成收集。
- 新生代里面分成一个较大的Eden空间和两个较小的Survivor(存活)空间。每次使用Eden空间和其中一块Survivor空间,然后垃圾回收的时候,将存活的对象放到放到未使用的Survivor空间中,清空Eden和刚才使用过的Survivor空间。
- 一块Eden和一块Survivor区,比值是8:1这样的设置是有原因的。新生代采用复制算法,如果单纯把内存划分为两块,由于存活的对象很少,那么存放存活对象的那块堆内存,会有很多的内存浪费。因此使用两块10%的内存作为空闲和活动区间(两块Survivor区),而另一块80%的内存(Eden区),则是用来给新建对象分配内存的。一旦发生GC,就将10%和另外80%的活动区间中存活的对象转移到10%的空闲区间,接下来,将之前90%的空间全部释放
- 绝大多数刚刚创建的对象会被分配到Eden区,其中的大多数对象很快就消亡。Eden区是连续的内存空间,因此在其上分配内存极快。
(3)新生代垃圾回收流程:
- 当Eden区满的时候,执行Minor GC,将消亡的队形啊清理掉,并将剩余的对象复制到一个存活的Survivor0(此时Survivor1是空白的,两个survivor区总有一个是空白的)。
- 此后每个Eden区满了,就执行一次MinorGC,并将Eden剩余的存活对象度添加到Survivor0区域。
- 当Survivor0也满的时候,将其中仍然还活着的对象直接复制到Survivor1区域,然后清理掉Survivor区域。之后Eden区执行MinorGC后,就将剩余的对象添加到Survivor1(此时,Survivor0是空白的)。重复上述步骤,只不过这次是Eden区和Survivor区域配合。
- Eden区是连续的空间,且Survivor区域总有一个为空。经过一次GC和赋值,一个Survivor中保存着当前还活着的对象,而Eden区和另外一个Survivor区中的内容都不再需要了,所以可以直接清空,到下一次GC的时候,两个Survivor角色互换。因此这种方式分配和清理内存的效率都很高,这种垃圾回收的算法就是著名的"停止-复制算法",这不代表停止复制清理法很高效,其实他也只是在这种情况下高效,如果在老年代采用停止复制算法挺悲剧的。
(4)老年代:
- 存放的是存活时间比较长的对象,如缓存对象、数据库连接对象以及单例对象等等。
- 老年代中因为对象的存活率高、没有额外的空间对他进行分配担保,就只能用标记清除或者标记整理算法来进行回收。
- 新生代里的每个对象都会有一个年龄,当这些对象年龄达到一定程度的时候(年龄就是熬过GC的次数,每次GC如果对象存活下来,则年龄加1),则会转到年老代,而这个转入年老代的年龄值,在JVM中是可以设置的。
(5)永久代:
- 在堆外有个永久代
- 堆永久代的回收主要是无效的类和常量,并且回收方法同老年代。
五、HotSpot的GC算法实现
1、枚举根节点
不可以出现分析过程中引用关系还在变化的情况,该点不满足的话,分析结果的准确性就无法保证。这是导致GC进行时必须停顿所有java执行线程的其中一个重要原因。即使是在号称几乎不会停顿的CMS收集器中,枚举根节点时也是要停顿的。
2.准确式内存管理
准确式内存管理,又称为准确式GC。虚拟机可以知道内存中某个位置的数据具体是什么类型。比如内存中一个32位的整数123456的地址,他到底是一个引用类型,指向123456的地址,还是一个数值为123456的整数,虚拟机将有能力辨别出来,这样子才能在GC的时候,准确判断堆上的数据是否还可以被使用。由于使用准确式内存管理,Exact VM抛弃了基于handler的对象查找方式(原因是GC后对象可能被移动位置,比如对象的地址原本为123456,然后对象被移动到654321的地址,在没有明确表明内存中的哪些数据是引用的前提下,虚拟机是不敢把内存中的所有123456的值改为654321的,因为不知道这个值是整数还是指向另外一块内存的地址,因此有些虚拟机使用句柄来保持引用的稳定),通过准确的内存管理,能够快速判断数据是否引用,就可以避免使用句柄,从而减少一次查找的开销,提高执行性能。
由于目前主流的Java虚拟机都是采用准确式内存管理,Exact VM抛弃了基于handler的对象查找方式(原因是GC后对象能被移动位置,比如对象原本地址为123456,然后改对象被移动到654321的地址,在没有明确信息表明内存中的哪些数据是引用的前提下,虚拟机是不敢把内存中所有的123456改成654321的,因为不知道这个值是指向整数还是指向另一块内存的地址,因此有些虚拟机使用句柄来保持引用的稳定性),通过确定式内存管理,能够快速判断该数据是否引用,就可以避免使用句柄,从而减少一直查找地址的开销,提高执行性能。
由于目前主流的Java虚拟机都是采用准确式GC,所以当执行系统停顿下来后,并不需要一个不漏的检查完执行上下文和全局的引用位置,虚拟机应当有办法直接指导哪些地方存放着对象引用。
3.HotSpot怎么快速找到可达对象?
在HotSpot的实现中,使用一组称为OopMap的数据结构来达到这个目的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用。这样子,在GC扫描的时候,就可以直接知道哪些是可达对象 了。
(1)安全点
在OopMap的协助下,HotSpot可以快速准确完成GC Roots枚举。可能导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那么将会需要大量的额外空间,这样GC的成本将会变得很高。
实际上,HotSpot也没有为所有指令生成OopMap,只有在特定位置生成这些信息,这个位置称为“安全点”。程序在执行过程中,并非在所有地方都可以停顿下来进行GC,只有在到达安全点时才能暂停。安全点的选定既不能太以至于让GC等待太长的时间,也不能过多以至于增大运行时的负荷。所以安全点的选定是以“ 是否让程序长时间运行 ”为标准进行选定的。长时间运行最明显的特征是指令复用,比如说 方法调用、循环跳转、异常跳转 等,所以具有这些功能的指令才会产生安全点。对于安全点,另外一个需要考虑的问题,是如何让所有线程跑到最近的安全点再停顿下来,这里有2种方案可供选择:抢占式中断、主动式中断。
(2)抢占式中断
抢占式中断不需要线程的执行代码主动去配合。在GC发生时,首先把所有线程中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机采用抢占式中断来暂停线程。个人觉得是太粗暴了,比如直接中断线程。
(3)主动式中断
主动中断的思想是:当GC需要中断线程时,不直接对线程操作,仅仅设置一个标志,各个线程执行时主动去轮询这个标志, 当发现中断标记为真就自己中断挂起 。轮询标记的位置就是安全点的位置。
4.安全区域
使用安全点是否已经完美解决什么时候进入GC的问题。但是假如程序不执行呢?所以的程序不执行就是没有分配CPU时间片,最典型的就是线程处于sleep或者阻塞状态,这时候线程无法执行到安全点,并且响应中断挂起。JVM也不太可能等待线程重新获得CPU时间片,这时候就需要 安全区域 来解决。
安全区域指 在一段代码片段中,引用关系不会发生变化 。这个区域任务地方开始GC都是安全的。我们可以把安全区域看做是扩展的安全点。
在线程执行到安全区域时,首先标识自己已经进入安全区域了,那样,当这段时间内发生GC时,就不用管那些标识为 安全区域 状态的线程了。
在线程要离开安全区域时,它首先检查系统是否已经完成根节点枚举,如果完成,线程就继续执行,否则,它就继续等待直到收到可以离开安全区域的信号。
六、垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
上图展示了不同分代的垃圾收集器,如果两个收集器之间存在连线,那么说明它们可以搭配使用。垃圾收集器所处的区域,则表明它是新生代收集器,还是老年代收集器。
1.Seria收集器
Serial收集器是最基本,发展历史最悠久的收集器。 Serial是一个单线程收集器。是新生代收集器。
Serial收集器在进行垃圾收集的时候,必须暂停其他所有的线程,直到它收集结束。“Stop the World”暂停线程 这个工作是后台自动发起和完成的,在用户不可见的情况下把用户正常工作的线程停掉,这对于很多应用来说是很难接受的。假如你的计算机每运行1个小时就要停顿5分钟,你会有怎样的心情?下图展示了Serial收集器的运行过程:
“Stop the World”是没有办法避免的,举个简单例子:你妈妈在打扫房间的时候,你还一边扔垃圾,这怎么打扫的完?目前之间尽量减少停顿线程的时间。
serial收集器仍然是虚拟机运行在client模式下的默认新生代垃圾收集器。它也有由于其他收集器的地方:简单而高效。对于单CPU的环境来说,Serial收集器由于线程交互的开销,专心做垃圾收集,自然可以获得最好的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代,停顿时间可以控制在几十毫秒甚至一百毫秒以内,只要不是频繁发生,还是可以接受的。
2.ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本。除了多线程进行垃圾收集之外,其他都和Serial一样。
是新生代收集器。
ParNew收集器的工作过程如图:
ParNew收集器是许多运行在Server模式下的虚拟机首选的 新生代收集器
3.Parallel Scavenge收集器
Parallel Scavenge是新生代收集器。它也是使用复制算法的收集器,又是并行的多线程收集器。看上去了ParNew一样,那么它有什么特别之处呢?
Parallel Scavenge是为了达到一个可控制的吞吐量。吞吐量=运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集的时间)。高吞吐量表明CPU时间被有效的利用,尽快完成程序的运算任务。
Parallel Scavenge收集器提供了参数控制最大垃圾收集停顿时间,虚拟机将尽可能保证垃圾回收的时间不超过该值。不过大家不要任务把这个参数的值设小一点就可以使垃圾收集速度加快, GC停顿时间缩短,是以牺牲吞吐量和新生代空间来换取的 ,系统会把新生代调小一些,收集300MB的新生代肯定比收集500MB的快,但这也导致垃圾收集更频繁一些。原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间是下降了,但是系统吞吐量下来了。
由于和吞吐量关系密切,Parallel Scavenge也被称为 吞吐量优先收集器 。
Parallel Scavenge还有一个参数,这个参数打开以后,就不需要手工指定新生代大小、Eden和Survivor比例等参数,虚拟机会根据运行情况,动态调整这些参数,已提供最适合的停顿时间,这种调节方式成为 GC自适应调节策略 。自适应调节策略也是Parallel Scavenge收集器和ParNew收集器的一个重要区别。Parallel Scavenge无法和CMS配合工作。
4.Serial Old收集器
Serial Old是Serial收集器的老年代版本。它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义是给Client模式下的虚拟机器使用,工作过程如下:
5.Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。
这个收集器是JDK1.6之后才开始提供的,在此之前,Parallel Scavenge收集器一直处于比较尴尬的位置,因为如果新生代选择了Parallel Scanvenge收集器,老年代除了Serial Old收集器之外别无选择。老年代Serial Old收集器在服务端的拖累,使用Parallel Scavenge收集器未必能在整体应用上获得吞吐量最大化的效果。由于单线程的老年代收集,无法充分利用服务端多CPU的能力,在老年代很大而且硬件比较高级的环境,这种组合的吞吐量甚至还不如ParNew + CMS组合给力。
直到Parallel Old收集器出现后,Parallel Scavenge才有了比较名副其实的应用组合。在注重吞吐量与多CPU的场景,可以优先考虑Parallel Scavenge 和 Parallel Old收集器。Parall Old工作状态如图:
7.CMS收集器
(1)概念:CMS是一种以获取 最短停顿时间 为目标的收集器。互联网应用就非常注重服务器的响应速度,希望系统停顿时间最短,已给用户带来最好的体验。CMS收集器就非常符合这类应用的需求。
CMS收集器基于 标记-清除 算法实现的。它的运作过程分为4个步骤: 初始标记、并发标记、重新标记、并发清除 。
其中,初始标记、重新标记两个步骤仍然需要暂停用户线程。
(2)初始标记仅仅是标记一下GC Roots能够直接关联的对象,速度很快。
(3)并发标记就是进行GC Roots 向下查找过程,也就是从GC Roots开始,对堆中对象进行可达性分析。这时候用户线程还可以继续执行。
(4)重新标记阶段是为了 修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象标记记录 。
(5)这个阶段的标记时间一般比初始标记稍长一点,但远比并发标记时间短。
(6)并发清除是GC垃圾收集线程 和 用户线程并行的,清理被回收的对象。
(7)由于整个过程中耗时最长的 并发标记 和 并发清除 的阶段收集器都可以和用户线程并行工作,所以总体上来说,CMS收集器的内存回收是与用户线程一起并发执行的。
(8)优点:减少了GC停顿时间
(9)缺点:
对CPU资源敏感,在 并发阶段,因为占用一部分CPU资源,因此会导致程序变慢 。当CPU个数比较少的时候,对用户影响可能很大。
为了因对这种情况,虚拟机提供了一种增量式并发收集器,是CMS收集器的变种。在并发标记、并发清除阶段,让GC线程、用户线程交替运行,尽量减少GC线程独占资源的时间,这样一来,整个垃圾收集的时间更长,但对用户的影响就少一些。实践证明,增量式并发收集器的效果很一般,已经不提倡用户使用了。
无法处理浮动垃圾。在并发清除阶段,用户线程还在运行着,还会产生新的垃圾。这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC的时候再清理掉。这一部分垃圾就称为 浮动垃圾 。
由于垃圾收集阶段,用户线程还在运行,因此需要预留足够的内存空间给用户使用。因此CMS收集器不能像其他收集器一样,等到老年代几乎被填满了再进行收集,需要预留一部分内存空间提供用户线程使用。在JDK1.6中,CMS的启动阈值为92%,也就是老年代使用了92%之后,CMS收集器就会进行垃圾回收。
如果CMS运行期间预留的内存不够用户线程使用 ,就会出现一次“Concurent Mode Fail”失败,这时虚拟机临时启用Serial Old收集器来重新进行老年代的垃圾回收,这样的停顿时间就长了。因此,如果启动阈值设置得太高,容易导致“Concurrent Mode Fail”,性能反而降低。
CMS是一块基于标记-清除实现的垃圾收集器,那么在收集结束时会有大量内存碎片产生。当内存碎片过多的时候,如果要对大对象进行内存分配,但是无法找到足够大的连续内存空间进行分配,就会触发一次Full GC。
为了解决这个问题,CMS提供一个开关,默认是开启的,表示CMS要进行Full GC的时候,开启内存碎片的整理合并过程,并该过程是无法并发的,因此停顿时间就变长了。