目录
下面开始本篇主要介绍的内容:
Java虚拟机垃圾回收
------ 为什么需要了解垃圾回收
------ GC回收那些对象,何时回收,如何回收
------ 1、垃圾回收器回收那些内存?
------ 2、什么时候回收?即GC发生在什么时候?
------ 3、如何回收?
关于GC什么时候回收?即GC发生在什么时候?
------ 判断可回收有2种方式:
------ 1、引用计数算法(Recference Counting)
------ 2、可达性分析算法(Reachability Analysis)
------ 再谈引用
------ 对象生存还是死亡?
------ 判断对象死亡的条件
------ 一次对象的自我救赎
------ 再次解析 JVM虚拟机 可达性的实现
------ 1、可达性分析的问题
------ 2、枚举根节点查找GC Roots
安全点
安全区域
参考文献
1、前篇介绍了【 JAVA虚拟机堆内存结构以及堆内存作用对象回收机制 】,主要包含四部分
一、堆区(Heap)
二、对象的内存布局
三、对象的访问定位
四、Java堆的内存划分
2、前篇博文已将对JVM虚拟机内存中的 方法栈 【JAVA虚拟机内存结构之虚拟机栈(JVM Stack)】做了详细的介绍,栈的四大部分:
虚拟机栈主要用于存储四部分内容
栈帧(Stack Frame)
------ 局部变量表
------ 操作数栈
------ 动态连接
------ 方法返回地址
想了解栈的内存结构,已将栈的运行原理,可以去看一下。
3、JAVA虚拟机程序计数器深度解析 【JAVA虚拟机程序计数器深度解析】
------ 程序计数器(Program Counter Register)
------ JAVA虚拟机多线程的执行过程
------ java多线程下程序计数器如何起作用的
想了解JVM整体内存架构的可以看一下这篇博文 【JAVA虚拟机的整体内存模型】,可以从整体了解虚拟机的组成,以及各部分功能如何组合在一起工作的。
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。
jvm 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存垃圾回收 主要集中于 java 堆和方法区中 ,在程序运行期间,这部分内存的分配和使用都是动态的.
目前内存的动态分配与内存回收技术已经相当成熟,但为什么还需要去了解内存分配与GC呢?
1、当需要排查各种内存溢出、内存泄漏问题时;
2、当垃圾收集成为系统达到更高并发量的瓶颈时;
我们就需要对这些"自动化"技术实话必要的监控和调节;
即如何判断对象已经死亡可以回收? ==》 需要了解回收策略;
关于GC什么时候回收 ==》 需要了解GC策略,与垃圾回收器实现有关;
回收对象的算法,回收的方式 ==》 即需要了解垃圾回收算法,及算法的实现--垃圾回收器;
内存回收系统模块以及各部分的功能,以及垃圾回收器在堆内存哪部分使用:
垃圾收集器对堆进行回收前,首先要确定堆中的对象哪些还"存活",哪些已经"死去";
下面先来了解两种判断对象不再被引用的算法,再来谈谈对象的引用,最后来看如何真正宣告一个对象死亡。
很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。笔者面试过很多应届生和一些有多年工作经验的开发人员,他们对于这个问题给予的都是这个答案。
客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用案例,例如微软COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域得到许多应用的Squirrel中都使用了引用计数算法进行内存管理。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
优点
实现简单,判定高效,可以很好解决大部分场景的问题,也有一些著名的应用案例;
缺点
(A)、很难解决对象之间相互循环引用的问题
(B)、并且开销较大,频繁且大量的引用变化,带来大量的额外运算;
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
当两个对象不再被访问时,因为相互引用对方,导致引用计数不为0;
更复杂的循环数据结构,如图(《编译原理》7-18):
证明Java虚拟机里面都没有选用引用计数算法来管理内存:
因此:
主流的JVM都没有选用引用计数算法来管理内存;在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存。
微软COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域得到许多应用的Squirrel中都使用了引用计数算法进行内存管理
当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(ReferenceChain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
算法基本思路:
通过一系列"GC Roots"对象作为起始点,开始向下搜索;
搜索所走过和路径称为引用链(Reference Chain);
当一个对象到GC Roots没有任何引用链相连时(从GC Roots到这个对象不可达),则证明该对象是不可用的;
哪些对象可以做GC Roots对象
Java中,GC Roots对象包括:
除了这些固定的GC Roots集合以外:
根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
目前最新的几款垃圾收集器无一例外都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀,它们在实现上也做出了各种优化处理。关于这些概念、优化技巧以及各种不同收集器实现等内容
优点
- 更加精确和严谨,可以分析出循环数据结构相互引用的情况;
缺点
- 实现比较复杂;
- 需要分析大量数据,消耗大量时间;
- 分析过程需要GC停顿(引用关系不能发生变化),即停顿所有Java执行线程(称为"Stop The World",是垃圾回收重点关注的问题);
后面会针对HotSpot虚拟机实现的可达性分析算法进行介绍,看看是它如何解决这些缺点的。
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为:
·强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
·软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
对于软引用,可以使用命令行选项"-XX:SoftRefLRUPolicyMSPerMB =
"来控制清除速率;
·弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
·虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
4种,这4种引用强度依次逐渐减弱。
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
finalize()方法
上面已经说到finalize()方法与垃圾回收第二次标记相关,下面了解下在Java语言层面有哪些需要注意的。
finalize()是Object类的一个方法,是Java刚诞生时为了使C/C++程序员容易接受它所做出的一个妥协,但不要当作类似C/C++的析构函数;
因为它执行的时间不确定,甚至是否被执行也不确定(Java程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用);
如果需要"释放资源",可以定义显式的终止方法,并在"try-catch-finally"的finally{}块中保证及时调用,如File相关类的close()方法;
finalize()方法主要有两种用途:
1、充当"安全网"
当显式的终止方法没有调用时,在finalize()方法中发现后发出警告;
但要考虑是否值得付出这样的代价;
如FileInputStream、FileOutputStream、Timer和Connection类中都有这种应用;
2、与对象的本地对等体有关
本地对等体:普通对象调用本地方法(JNI)委托的本地对象;
本地对等体不会被GC回收;
如果本地对等体不拥有关键资源,finalize()方法里可以回收它(如C/C++中malloc(),需要调用free());
如果有关键资源,必须显式的终止方法;
一般情况下,应尽量避免使用它,甚至可以忘掉它。
要真正宣告一个对象死亡,至少要经历两次标记过程。
1、第一次标记
在可达性分析后发现到GC Roots没有任何引用链相连时,被第一次标记;
并且进行一次筛选:此对象是否必要执行finalize()方法;
(A)、没有必要执行
没有必要执行的情况:
(1)、对象没有覆盖finalize()方法;
(2)、finalize()方法已经被JVM调用过;
这两种情况就可以认为对象已死,可以回收;
(B)、有必要执行
对有必要执行finalize()方法的对象,被放入F-Queue队列中;
稍后在JVM自动建立、低优先级的Finalizer线程(可能多个线程)中触发这个方法;
2、第二次标记
GC将对F-Queue队列中的对象进行第二次小规模标记;
finalize()方法是对象逃脱死亡的最后一次机会:
(A)、如果对象在其finalize()方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移出"即将回收"的集合;(B)、如果对象没有,也可以认为对象已死,可以回收了;
一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用;
上面关于对象死亡时finalize()方法的描述可能带点悲情的艺术加工,笔者并不鼓励大家使用这个方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它并不能等同于C和C++语言中的析构函数,而是Java刚诞生时为了使传统C、C++程序员更容易接受Java所做出的一项妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。
前面对可达性分析算法进行介绍,并看到了它在判断对象存活与死亡的作用,下面看看是HotSpot虚拟机是如何实现可达性分析算法,如何解决相关缺点的。
上文中已经简单介绍了可达性分析的一些优缺点,缺点主要有三条:
1-1、消耗大量时间
从前面可达性分析知道,GC Roots主要在全局性的引用(常量或静态属性)和执行上下文中(栈帧中的本地变量表);
去除上述的7中方式GC Roots,甚至还会根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时 性”地加入,共同构成完整GC Roots集合。
要在这些大量的数据中,逐个检查引用,会消耗很多时间;
1-2、GC停顿
可达性分析期间需要保证整个执行系统的一致性,对象的引用关系不能发生变化;
导致GC进行时必须停顿所有Java执行线程(称为"Stop The World");
(几乎不会发生停顿的CMS收集器中,枚举根节点 时也是必须要停顿的)
Stop The World:
在新生代进行的GC叫做minor GC,在老年代进行的GC都叫 major GC,Full GC同时作用于新生代和老年代。在垃圾回收过程中经常涉及到对对象的挪动(比如对象在Survivor 0和Survivor 1之间的复制),进而导致需要对对象引用进行更新。为了保证引用更新的正确性,Java将暂停所有其他的线程,这种情况被称为“Stop-The-World”,导致系统全局停顿。Stop-The-World对系统性能存在影响,因此垃圾回收的一个原则是尽量减少“Stop-The-World”的时间。
不同垃圾收集器的Stop-The-World情况,Serial、Parallel和CMS收集器均存在不同程度的Stop-The-Word情况;而即便是最新的G1收集器也不例外。
- Java中一种全局暂停的现象,jvm挂起状态
- 全局停顿,所有Java代码停止,native代码可以执行,但不能和JVM交互
Stop The World的发生情况:
多半由于jvm的GC引起,如:
1.老年代空间不足。
2.永生代(jkd7)或者元数据空间(jkd8)不足。
3.System.gc()方法调用。
4.CMS GC时出现promotion failed和concurrent mode failure
5.YoungGC时晋升老年代的内存平均值大于老年代剩余空间
6.有连续的大对象需要分配除了GC还有以下原因:
1.Dump线程--人为因素。
2.死锁检查。
3.堆Dump--人为因素。
Full GC 是清理整个堆空间—包括年轻代和老年代。
为何GC全局停顿无法避免:
当gc线程在处理垃圾的时候,其它java线程要停止才能彻底清除干净,否则会影响gc线程的处理效率增加gc线程负担,特别是在垃圾标记的时候。
GC全局停顿带来的危害:
- 长时间服务停止,没有响应
- 遇到HA系统,可能引起主备切换,严重危害生产环境。
- 新生代的gc时间比较短(),危害小。
- 老年代的gc有时候时间短,但是有时候比较长几秒甚至100秒--几十分钟都有。
- 堆越大花的时间越长。
可作为GC Roots根节点的对象主要是在全局性的引用(如常量、类静态属性)和执行上下文中(如栈帧中的本地变量表),现在的很多应用仅方法区就有数百兆,逐个检查里边的引用显然很耗费时间。
目前主流JVM都是准确式GC,可以直接得知哪些地方存放着对象引用,所以执行系统停顿下来后,并不需要全部、逐个检查完全局性的和执行上下文中的引用位置;
在HotSpot实现中,利用了空间换取时间,是使用一组OopMap的数据结构来完成的。类加载完成后,会把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,在特定的位置(即安全点)使用OopMap记录下栈和寄存器哪些位置是引用,这样GC发生的时候就不用全部扫描了。
实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)
梳理枚举GC Roots实现:
在HotSpot中,是使用一组称为OopMap的数据结构来达到这个目的的;
在类加载时,计算对象内什么偏移量上是什么类型的数据;
在JIT编译时,也会记录栈和寄存器中的哪些位置是引用;
这样GC扫描时就可以直接得知这些信息;
注意:
实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
如果每条指令都生成OopMap那将需要大量的额外空间。此时在特定的位置即安全点使程序不是在所有的地方都停顿(Stop The World)下来开始GC,只有在到达安全点时才停顿开始GC。安全点的选定不能使GC等待时间过长也不能使GC频繁触发,如具有方法调用、循环跳转、异常跳转的指令才会有安全点。
首先看为何让所有的线程都跑到安全点附近停顿下来??
参考: 理解进入safepoint时如何让Java线程全部阻塞
VM thread在进行GC前,必须要让所有的Java线程阻塞,从而stop the world,开始标记。JVM采用了主动式阻塞的方式,Java线程不是随时都可以进入阻塞,需要运行到特定的点,叫safepoint,在这些点的位置Java线程可以被全部阻塞,整个堆的状态是一个暂时稳定的状态,OopMap指出了这个时刻,寄存器和栈内存的哪些具体的地址是引用,从而可以快速找到GC roots来进行对象的标记操作。
那么当Java线程运行到safepoint的时候,JVM如何让Java线程挂起呢?这是一个复杂的操作。很多文章里面说了JIT编译模式下,编译器会把很多safepoint检查的操作插入到编译偶的指令中,比如下面的指令来自内存篇:JVM内存回收理论与实现
另一个问题是GC发生时如何让所有的线程都跑到安全点附近停顿下来?两种方案:抢先式中断和主动式中断。
抢先式中断,GC发生时首先让所有的线程都停顿下来,然后让还没到安全点的线程恢复,跑到安全点。几乎没有虚拟机采用这种方式响应GC。
主动式中断,GC发生需要中断所有的线程时,不直接对线程操作而是设置一个中断标志,该标志和安全点位置重合,各线程执行时都会去轮询该中断标志,如果线程发现该标志为真时就自己中断挂起。
1、安全点是什么,为什么需要安全点
HotSpot在OopMap的帮助下可以快速且准确的完成GC Roots枚举,但是这有一个问题:
运行中,非常多的指令都会导致引用关系变化;
如果为这些指令都生成对应的OopMap,需要的空间成本太高;
问题解决:
只在特定的位置记录OopMap引用关系,这些位置称为安全点(Safepoint);
即程序执行时并非所有地方都能停顿下来开始GC;
2、安全点的选定
不能太少,否则GC等待时间太长;也不能太多,否则GC过于频繁,增大运行时负荷;
所以,基本上是以程序"是否具有让程序长时间执行的特征"为标准选定;
"长时间执行"最明显的特征就是指令序列复用,如:方法调用、循环跳转、循环的末尾、异常跳转等;
只有具有这些功能的指令才会产生Safepoint;
3、如何在安全点上停顿
对于Safepoint,如何在GC发生时让所有线程(不包括JNI线程)运行到其所在最近的Safepoint上再停顿下来?
主要有两种方案可选:
(A)、抢先式中断(Preemptive Suspension)
不需要线程主动配合,实现如下:
(1)、在GC发生时,首先中断所有线程;
(2)、如果发现不在Safepoint上的线程,就恢复让其运行到Safepoint上;
现在几乎没有JVM实现采用这种方式;
(B)、主动式中断(Voluntary Suspension)
(1)、在GC发生时,不直接操作线程中断,而是仅简单设置一个标志;
(2)、让各线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起;
而轮询标志的地方和Safepoint是重合的;
在JIT执行方式下:test指令是HotSpot生成的轮询指令;
一条test汇编指令便完成Safepoint轮询和触发线程中断;
程序运行时安全点可以完美的解决GC触发的问题,但是当程序不执行时即没有分配到CPU时间时,此时线程不能响应JVM的中断请求,JVM显然不可能等待线程分配到CPU时间跑到安全点时再开始GC。这是需要安全区域来解决问题。
安全区域是指一段代码段中引用关系不会发生变化,在该区域何时何地开始GC都是安全的。线程执行安全区域的代码块时会标识自己已经进入了安全区域,此时如果JVM发起GC线程不会再标志中断状态标识,线程离开安全区域时会检查GC枚举GC Roots根节点(或者是整个GC过程)是否已经完成,如果完成了就继续执行,没完成的话就等待GC完成回收任务收到可以离开的信号再离开安全区域。
1、为什么需要安全区域
对于上面的Safepoint还有一个问题:
程序不执行时没有CPU时间(Sleep或Blocked状态),无法运行到Safepoint上再中断挂起;
这就需要安全区域来解决;
2、什么是安全区域(Safe Region)
指一段代码片段中,引用关系不会发生变化;
在这个区域中的任意地方开始GC都是安全的;
3、如何用安全区域解决问题
安全区域解决问题的思路:
(1)、线程执行进入Safe Region,首先标识自己已经进入Safe Region;
(2)、线程被唤醒离开Safe Region时,其需要检查系统是否已经完成根节点枚举(或整个GC);
如果已经完成,就继续执行;
否则必须等待,直到收到可以安全离开Safe Region的信号通知;
这样就不会影响标记结果;
虽然HotSpot虚拟机中采用了这些方法来解决对象可达性分析的问题,但只是大大减少了这些问题影响,并不能完全解决,如GC停顿"Stop The World"是垃圾回收重点关注的问题,后面介绍垃圾回收器时应注意:低GC停顿是其一个关注。
下文继续分析垃圾收集器及收集算法。