JVM之垃圾收集(一)
Contents
一、概述
垃圾回收主要包括三个问题:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
前文讲述Java
内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。后续所讲的“内存”分配与回收也仅仅特指这一部分内存。
二、哪些内存需要回收—-对象还活着吗?
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就 是要确定这些对象之中哪些还“存活”着,哪些已经“死去”
2.1 引用计数器算法
引用计数是垃圾收集器中的早期策略。简单来说就是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
**优点:**引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。 **弊端:**两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
|
|
如上所示,因为循环引用的存在,所以 Java 虚拟机不适用引用计数算法
2.2 可达性分析算法
程序把所有的引用关系看作一张图(来自离散数学的图论),从一个节点GC ROOT
开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈中引用的对象(栈帧中的本地变量表)
- 本地方法栈中引用的对象(
Native
方法) - 方法区中,类静态属性引用的对象
- 方法区中,常量引用的对象
- 所有被同步锁(
synchronized
关键字)持有的对象。 - Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- … …
可达性分析算法不会出现对象间循环引用问题:因为GC Root
在对象图之外,是特别定义的“起点”,不可能被对象图内的对象所引用。
2.3 引用类型
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否可被回收都与引用有关。
Java
具有四种强度不同的引用类型。
1. 强引用
程序中最普遍存在的,被 强引用(Strong Reference) 关联的对象不会被垃圾收集器回收。
典型的类似如下:
|
|
2. 软引用
用来描述一些有用但并非必须的对象。被 软引用(Soft Reference) 关联的对象,只有在内存不够的情况下才会被回收。如果这次回收之后还没有足够的内容,才会抛出内存溢出异常。
使用 SoftReference
类来创建软引用。
|
|
3. 弱引用
也是用来描述非必须对象,强度比软引用更弱,被弱引用(Weak Reference)关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次垃圾收集发生之前。
使用 WeakReference
类来创建软引用。
|
|
WeakHashMap
的 Entry
继承自 WeakReference
,主要用来实现缓存。
|
|
Tomcat
中的 ConcurrentCache
就使用了 WeakHashMap
来实现缓存功能。ConcurrentCache
采取的是分代缓存,经常使用的对象放入 eden 中,而不常用的对象放入 longterm。eden 使用 ConcurrentHashMap
实现,longterm 使用 WeakHashMap
,保证了不常使用的对象容易被回收。如下:
|
|
4. 虚引用
又称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
使用 PhantomReference
来实现虚引用。
|
|
2.4 方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比年轻代差很多,因此在方法区上进行回收性价比不高。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
- 回收废弃常量与回收 Java 堆中的对象非常类似。常量池中其他类(接 口)、方法、字段的符号引用也与此类似。例如:
一个字符串
“java”
曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”
。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”
常量就将会被系统清理出常量池。
- 而判断类为不再使用的类型在进行卸载,一般要求满足以下三个条件:
1.该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
2.加载该类的
ClassLoader
已经被回收。3.该类对应的
java.lang.Class
对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
在大量使用反射、动态代理、CGLib 等字节码框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
2.5 finalize()
finalize()
类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally
等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用 finalize()
。
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓 刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与
GC Roots
相连接的引用链,那它将会被第一次标记;随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()
方法。假如对象没有覆盖finalize()
方法,或者finalize()
方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。如果没有必要执行则该对象会被回收。 如果这个对象被判定为确有必要执行finalize()
方法,那么该对象将会被放置在队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer
线程去执行它们的finalize()
方法。 稍后收集器将对队列的对象进行第二次小规模的标记,如果对象要在finalize()
中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this
关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
|
|
二、垃圾收集算法—->如何回收?
2.1 垃圾收集性能
垃圾回收性能指标主要有两点:
- 停顿时间 - 停顿时间是因为 GC 而导致程序不能工作的时间长度。
- 吞吐量 - 吞吐量关注在特定的时间周期内一个应用的工作量的最大值。对关注吞吐量的应用来说长暂停时间是可以接受的。由于高吞吐量的应用关注的基准在更长周期时间上,所以快速响应时间不在考虑之内。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间) 从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。本文所讨论的算法都在追踪式垃圾收集的范畴。
2.2 标记 - 清除算法(Mark-Sweep)
标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。这是一个基础算法,后期的算法都是基于该算法进行改进。如下图所示。
特点: 1.第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低;
2.第二个是内存空间的碎片化问题,不需要进行对象的移动,只需对不存活的对象进行处理。
2.3 标记 - 整理算法(Mark-Compact)
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
特点: 这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。 所以一般用于除以存活率较高的代—老年代,一般存活的老年代都是 100% 存活,比较适合概算法执行而不是复制算法,复制代价过大。
2.4 标记 - 复制算法(Mark-Coping)
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
特点:
主要不足是只使用了内存的一半。耗费内存。 该收集算法来回收年轻代
实际做法是:把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被“浪费”的。
2.5 分代收集理论
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。 收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
一般将 Java 堆分为年轻代和老年代。
- 年轻代使用:标记-复制 算法
- 老年代使用:标记-清理 或者 标记-整理算法
新生代(Young Generation) 新生代是大部分对象创建和销毁的区域,在通常的 Java 应用中,绝大部分对象生命周期都是很短暂的。
其内部又分为 Eden
区域,作为对象初始分配的区域;两个 Survivor
,有时候也叫 from
、to
区域,被用来放置从 Minor GC
中保留下来的对象。
JVM 会随意选取一个 Survivor
区域作为 to
,然后会在 GC 过程中进行区域间拷贝,也就是将 Eden 中存活下来的对象和 from
区域的对象,拷贝到这个 to
区域。这种设计主要是为了防止内存的碎片化,并进一步清理无用对象。
Java 虚拟机会记录 Survivor
区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold
),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor
区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio
),那么较高复制次数的对象也会被晋升至老年代。
注意: 1.新生代发生的GC也叫做
Minor GC
,MinorGC发生频率比较高(不一定等Eden区满了才触发)。 2.当survivor1区不足以存放eden
和 survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC
),也就是新生代、老年代都进行回收。
老年代(Old Generation) 放置长生命周期的对象,通常都是从 Survivor 区域拷贝过来的对象。当然,也有特殊情况,如果对象较大,JVM 会试图直接分配在 Eden 其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。 永久代 -> 持久代(Permanent Generation) 这部分就是早期 Hotspot JVM 的方法区实现方式了,储存 Java 类元数据、常量池、Intern 字符串缓存。在 JDK 8 之后就不存在永久代这块儿了,在方法区。而方法区的回收则参照 2.4 节。
Author 拾光
LastMod 2022-02-18