《深入理解java虚拟机》之垃圾回收总结
判断对象可回收
有以下方法:
引用计数法
维护引用计数的Map,对象被引用时计数加1,引用失效时计数减1,简单粗暴。
实现简单,判断效率高。但主流JVM没有用这个方法的,因为无法解决循环引用的问题。
可达性分析算法
JVM主流实现时可达性分析。
通过一系列GC Root的对象作为起点,开始向下搜索,搜索经过的路径为引用链,当一个对象到GC Root没有任何引用链项链,则引用不可达,可以GC掉。
GC Root通常包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(即一般说的Native方法)引用的对象;
- …………
引用分类
引用分为强引用(平常用的引用,只要还存在有效,就不会被GC),软引用(SoftReference类,将要发生内存溢出前GC回收),弱引用(WeakReference类,生存到下一次GC,只被弱引用关联的对象会被回收!),虚引用(PhantomReference类,不影响实例生存时间,无法取得实例,唯一作用是被GC时收到一个系统通知)。
finalize()方法
可达性分析中发现不可达的对象,会被标记,如果没有覆盖finalize()方法,或者其finalize()方法已经被JVM调用过,则不会执行finalize()方法。
否则需要执行finalize()方法,此对象放入F-Queue队列中,由JVM的低优先级Finalizer线程去执行。
稍后,GC会对F-Queue的对象进行第二次标记,如果对象在finalize()方法中拯救了自己(重新被引用),则将会被移出要回收的集合。
一般不覆盖finalize()方法,该方法设计之初是为了迎合C++语法的析构函数,应该用try-finally取代之。
方法区/永生代的GC
永生代也会进行GC,主要收集废弃的常量和无用的类。
其中无用的类的判定比较严格,要求:
- 该类所有实例已被回收;
- 加载该类的ClassLoader已被回收;
- 该类的Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
然而满足了以上条件的类,也不一定会被回收,仅仅是可以回收。
垃圾收集算法
包括以下:
标记-清除算法(Mark-Sweep)
先标记出所有需要回收的对象,然后统一回收被标记需要回收的对象。
缺点:标记和清除的效率都不高,且清除后产生大量不连续的内存碎片。
复制算法(Copying)
内存划分成等大小的两块,只有一块在使用,GC时将存活对象复制到另一块区域中,清除另一半。
缺点:有一半内存浪费,复制操作效率低,不适合老年代。
实际使用中并不是一半一半的内存分配,因为大部分对象生命周期很短,所以划分成一块很大的Eden区和两块小的Survivor区,一般默认8:1:1的比例。每次使用一块Eden和一块Survivor区,GC时讲Eden和Survivor中存活的对象复制到另一块Survivor区中。当Survivor区不够大时,使用老年代进行分配担保,存活对象放入老年代。
标记-整理算法(Mark-Compact)
类似标记-清除算法,标记之后存活的对象向一端移动,最后清理掉边界以外的内存,保证内存的规整。
HotSpot的算法实现
以下是HotSpot中垃圾回收的几个关键点技术实现方法。
枚举GC Root
可达性分析要求在一个能保证一致性的快照中进行工作,即GC时必须停止所有线程,即Stop The World(JOJO里面Dio的世界 ザ・ワールド?)。
在HotSpot里面,通过一个叫OopMap的数据结构来维护哪些地方存放着对象引用,记录栈上本地变量与堆中对象的引用关系,方便枚举GC Root。
安全点
并不是所有指令都会生成/改变OopMap,这样效率太低。线程必须到达安全点(SafePoint)才会生成OopMap,然后开始GC。安全点的选定是以“有让程序长时间执行的特征”的原则进行的(如方法调用,循环跳转,异常跳转等)。
考虑多线程,必须所有线程都跑到安全点才能开始GC。方法有二:
- 抢先式中断:GC时先中断所有线程,对于还没跑到安全点的线程,让其恢复并等它跑到安全点上再停。——商业JVM几乎没有这样实现的;
- 主动式中断:GC需要中断线程时,设置一个标志,各个线程执行时,跑到安全点的时候轮询这个标志,发现标志为真的时候自己中断挂起。
安全区域
线程执行的时候,可能很久都不会跑到安全点(比如执行了Thread.Sleep()的情况),导致GC不能马上执行。因此引入安全区域的概念(Safe Region),表示这段代码片段中引用关系不会发生变化。
线程执行到安全区域的时候,标识自己进入了安全区域;则发动GC的时候,可以忽略标识为安全区域的线程;而线程在离开安全区域的时候,需要检查是否已完成GC Root枚举(或者是整个GC过程),没完成的话要GC发出等待离开的信号。
实际的垃圾收集器
image上图给出了HotSpot的7个垃圾收集器,中间有连线的标识可以搭配使用。
Serial收集器
image收集时,暂停所有其他工作线程(Stop The World),开一个线程进行GC。对新生代采用复制算法,对老年代采取标记-整理算法。
JVM在client模式下默认新生代垃圾收集器还在用Serial。
优点:简单高效。
缺点:GC时的停顿时间长。
Serial Old收集器
Serial收集器的老年代版本,单线程,标记-整理算法。
ParNew收集器
imageSerial收集器的多线程版本,可以配合CMS收集器工作,不能配合Parallel Scavenge收集器工作。使用-XX+UseParNewGC
指定使用之。
澄清垃圾收集器的两个概念:
- 并行Parallel:多条垃圾回收线程并行工作,用户线程在等待;
- 并发Concurrent:垃圾收集线程和用户线程同事执行(CMS)。
Parallel Scavenge收集器
新生代收集器,多线程、复制算法,与ParNew收集器的区别在于,关注点在于吞吐量(Thoughput,=运行用户代码时间/(运行用户代码时间+GC时间)),适合在后台运算而不需要提案多交互的任务。
用-XX:MaxGCPauseMillis
参数指定保证GC消耗时间的最大值(毫秒),减少GC时间是以牺牲吞吐量及新生代空间来获取的。
用-XX:GCTimeRatio
参数指定GC占总时间的比例,0-100,默认99,即允许1/(1+99)=1%的GC时间。
使用-XX:UseAdaptiveSizePolicy
参数之后,JVM根据当前系统情况动态调整新生代大小、Eden与Survivor比例等参数以保证最佳的吞吐量和设定的最长GC时间。
Parallel Old收集器
Parallel Scavenge收集器的老年代版本,多线程,标记-整理算法,JDK1.6开始提供。
CMS收集器
image老年代收集,设计目标是获取最短回收停顿时间,基于标记-清除算法设计,包括以下步骤:
- 初始标记:标记GC Root可以直接关联到的对象,速度很快,需要Stop The World;
- 并发标记:可达性分析枚举,耗时较长;
- 重新标记:修正并发标记期间因用户程序继续运行而导致的引用变动,需要Stop The World;
- 并发清除:耗时较长。
CMS收集器缺点:
- 对CPU资源敏感,并发阶段总吞吐量降低,CPU数量少的时候对用户程序影响大;
- 无法处理浮动垃圾(Floating Garbage),即并发清理阶段新产生的垃圾,要等到下一次GC;因此需要预留内存空间给用户线程使用,不能等内存快满才进行收集;当
CMS运行期间预留的内存不够,会出现“Concurrent Mode Failure”失败,JVM会临时启用Serial Old收集器重新进行老年代垃圾收集,导致停顿时间变长。可以通过-XX:CMSInitiatingOccupancyFraction
参数调整老年代内存占用比例触发GC的阈值。 - 收集后产生内存碎片。
G1收集器
image面向服务端,全称Garbage-First,停顿时间可控、可预测,不会产生内存碎片。
G1收集器特点:
-
并行并发
-
分代收集
-
空间整合
-
可预测的停顿
G1收集器将整个Java堆划分成多个大小相等的独立区域(Region),跟踪各个Region里面垃圾堆积的价值(根据回收所获得的空间大小及回收耗费时间的经验值),维护优先列表,每次收集的时候,根据允许的收集时间,优先回收价值更大的Region。
此外每个Region维护一个Remembered Set来避免全堆扫描,引用型数据进行写操作的时候,会产生中断写操作,检查引用的对象是否处于不同的Region,如果是,则记录到被引用对象所属的Remember Set中;那么在GC时,GC Root的枚举范围加入Remembered Set,保证不进行全堆扫描也不会有遗漏。
G1收集器的回收步骤包括: -
初始标记:与CMS一样;
-
并发标记:与CMS一样,包含Remembered Set;
-
最终标记:并发标记期间对象变化记录到Remembered Set Logs中,合并到Remembered Set;
-
筛选回收:先根据回收价值和回收成本进行排序,再根据用户期望GC停顿时间制定回收计划。
内存分配、回收策略
- 优先在Eden分配:大多数情况下,对象在新生代Eden区分配,当Eden区空间不够时,发起Minor GC;
-
大对象直接进入老年代:提供
-XX:PretenureSizeThreshold
参数,大于此值的对象直接在老年代分配,避免在Eden区和Survivor区之间大量内存复制; -
长期存活的对象进入老年代:JVM为对象定义年龄计数器,经过Minor GC依然存活且被Survivor区容纳的,移动到Survivor区,年龄加1,每经历一次Minor GC不被清理则年龄加1,增加到一定年龄则移动到老年区(默认15岁,通过
-XX:MaxTenuringThreshold
设置); - 动态对象年龄判定:若Survivor区中同年龄所有对象大小总和大于Survivor空间一半,则年龄大于等于该年龄的对象可以直接进入老年代;
- 空间分配担保:Minor GC之前,JVM检查老年代最大可用连续空间大于新生代所有对象总空间,成立的话Minor GC确认是安全的;否则检查老年代最大可用连续空间大于历次晋升到老年代对象的平均大小,大于的话进行Minor GC;小于的话进行Full GC。
类加载器
让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
类加载器却在类层次划分、 OSGi、 热部署、代码加密等领域大放异彩。
类与类加载器
即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。每一个类加载器,都拥有一个独立的类空间。
不同的类加载器对 instanceof 关键字运算的结果的影响。除此之外,Class对象的equals方法、isAssignableFrom方法、isInstance方法的返回结果也会受影响。
虚拟机中存在了两个 ClassLoaderTest 类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的
双亲委派模型
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器( Bootstrap ClassLoader), 这个类加载器使用 C++ 语言实现[ 1], 是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
绝大部分 Java 程序都会使用到以下 3 种系统提供的类加载器:
启动类加载器(Bootstrap ClassLoader):这个类将器负责将存放在JAVA_HOME\lib 目录中的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar, 名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。
扩展类加载器(Extension ClassLoader):它负责加载JAVA_ HOME\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库。
应用程序类加载器(Application ClassLoader):由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径( ClassPath) 上所指定的类库。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承( Inheritance) 的关系来实现,而是都使用组合( Composition) 关系来复用父加载器的代码。
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。
可以尝试去编写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现可以正常编译,但永远无法被加载运行。
破坏双亲委派模型
双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前————即 JDK 1.2 发布之前。
JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到 findClass() 方法中,在 loadClass() 方法的逻辑里如果父类加载失败,则会调用自己的 findClass() 方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的。如果基础类又要调用回用户的代码,那该怎么办?
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器( Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认是应用类加载器。
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。
OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块( OSGi 中称为 Bundle) 都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。
在 Java 程序员中基本有一个共识: OSGi 中对类加载器的使用是很值得学习的,弄懂了 OSGi 的实现,就可以算是掌握了类加载器的精髓。