Java 垃圾回收
-
垃圾回收就是由程序自动的回收已死对象, 可以分为两个部分
-
如何判断对象已死
-
如何清理掉已死对象
-
判断对象是否存活
引用计数法
- 给对象中添加一个引用计数器
- 每当有一个地方引用它时,计数器就加 1
- 当引用失效时, 计数器就减 1
- 当对象的计数器值为 0 时,则代表该对象可以被回收了
- 优点是实现简单且回收效率高
- 缺点是无法解决循环引用的问题, 即两个对象相互引用的情况
可达性分析
-
被商用 JVM 采用
-
从
GC ROOT作为起点开始遍历所有节点,GC ROOT指以下几类对象- 虚拟机栈的栈帧的局部变量表所引用的对象
- 本地方法栈的 JNI 所引用的对象
- 方法区的静态变量和常量所引用的对象
-
对于遍历到的每个节点都做一个标识
-
遍历完成后, 没有标识的节点说明是可回收的
回收算法
标记清除
- 通常使用一张表 (类似) 来记录哪些空间已被使用
- 首先通过可达性分析找到所有的垃圾,然后将其占用的空间释放掉
- 该算法的问题是可能会产生大量的内存碎片
标记整理
-
为了解决内存碎片的问题,标记整理在标记清除算法上做了优化
-
在找到所有垃圾对象后,不是直接释放掉其占用的空间,而是将所有存活对象往内存一端移动
-
回收完成后,所有对象都是相邻的
复制算法
- 复制算法将内存区域划分为两个,同一时间只有一个区域有对象
- 每次垃圾回收时,通过可达性分析算法,找出所有存活对象,将这些存活对象移动到另一区域
- 为新对象分配内存时,可以通过智能指针的形式,高效简单
- 复制算法的缺点是会浪费一部分空间以便存放下次回收后存活的对象且需要一块额外的空间进行担保(当一个区域存放不下存活的对象时)
分代收集
-
在商用 JVM 中,大多使用的是分代收集算法
-
根据对象的特性,可以将内存划分为 3 个代:年轻代,老年代,永久代( JVM 8 后称为元空间)
-
年轻代存放新分配的对象,使用的是复制算法
-
老年代使用标记清除或标记整理算法
-
其中年轻代分为一个 Eden 区和两个 Survivor 区 (From, To),其比例默认为 8:1:1(
-XX:SurvivorRatio)-
-
优先在 Eden 区分配对象
- Eden 区空间不足,触发 Minor GC (Young GC),标记可回收对象,然后 Eden 区存活对象拷贝到往 Survivor-From 区,接下来清空 Eden 区
- 再次触发 Minor GC,扫描 Eden 区和 From 区,把存活的对象复制到 To 区,清空 Eden 区和 From 区
- 如果在 Minor GC 复制存活对象到 Survivor 区时,发现 Survivor 区内存不够,则提前把对象放入老年代
-
大对象直接进入老年代
- 如果发现需要大量连续内存空间的 Java 对象,如很长的字符串或者数组,则直接把对象放入老年代
- 可通过
-XX:PretenureSizeThreshold参数设置大对象的最小大小,该参数只对 Serial 和 ParNew 两款收集器有效
- 可通过
- 因为新生代采用复制算法收集垃圾,大对象直接进入老年代,避免在 Eden 区和 Survivor 区发生大量内存复制
- 写程序的时候尽量避免大对象
- 如果发现需要大量连续内存空间的 Java 对象,如很长的字符串或者数组,则直接把对象放入老年代
-
长期存活对象进入老年代
- **固定对象年龄判断:**默认情况,存活对象在 Survivor 的 From 和 To 区来回交换 15 次后,如果对象最终还是存活,就放入老年代
- 可以通过
-XX:MaxTenuringThreshold参数来设置对象的年龄
- 可以通过
- **动态对象年龄判断:**如果发现 Survivor 中有相同年龄的对象空间总和大于 Survivor 空间的一半,那么年龄大于或者等于该年龄的对象直接晋升到老年代
- **固定对象年龄判断:**默认情况,存活对象在 Survivor 的 From 和 To 区来回交换 15 次后,如果对象最终还是存活,就放入老年代
-
空间分配担保
- 为什么需要分配担保
- 如果 Survivor 区存活了很多对象,空间不够了,都需要晋升到老年代,那么就需要老年代进行分配担保,也就是将Survivor 无法容纳的对象直接进入老年代
- 发生 Minor GC 前,JVM 先检查老年代最大可用连续空间是否大于新生代所有对象的总空间
- 大于:空间足够,直接 Minor GC
- 小于:进行一次 Full GC
- JDK 6 Update 24 前会根据
HandlePromotionFailure参数判断是否允许担保失败- 如果允许,则尝试一次 Minor GC
- 否则,则进行 Full GC
- 年轻代老年代比例默认为 1:2 (
-XX:NewRatio, -Xmn)
- 为什么需要分配担保
-
-
-
年轻代使用复制算法的原因是年轻代对象的创建和回收很频繁,同时大部分对象很快都会死亡,所以复制算法创建和回收对象的效率都比较高
-
老年代不使用复制算法的原因是老年代对象通常存活时间比较长,如果采用复制算法,则复制存活对象的开销会比较大,且复制算法是需要其他区域担保的。 所以老年代不使用复制算法
垃圾回收器
Serial 串行回收器(年轻代)
-
使用单线程,复制算法实现
-
在回收的整个过程中需要 STW (Stop The World)
-
在单核 CPU 的机器上,使用单线程进行垃圾回收效率更高
-
使用方法:
XX:+UseSerialGC -
ps:在 JDK Client 模式,不指定 VM 参数,默认是串行垃圾回收器
Serial Old 串行回收器(老年代)
- 与 Serial 相似,但使用标记整理算法实现
ParNew 并行回收器(年轻代)
- Serial 的多线程形式
-XX:+UseParNewGC(新生代使用并行收集器,老年代使用串行回收收集器)- 或者
-XX:+UseConcMarkSweepGC(新生代使用并行收集器,老年代使用 CMS)
Parallel Scavenge 基于吞吐量的并行回收器(年轻代)
-
多线程的回收器,高吞吐量(= 程序运行时间 / (程序运行时间+回收器运行时间)),可以高效率的利用 CPU 时间,尽快完成程序的运算任务,适合后台应用等对响应时间要求不高的场景
-
有一个自适应条件参数(
-XX:+UseAdaptiveSizePolicy),当这个参数打开后,无需手动指定新生代大小(-Xmn),Eden 和Survivor 比例(-XX:SurvivorRatio)等参数,虚拟机会动态调节这些参数来选择最适合的停顿时间(-XX:MaxGCPauseMillis)或吞吐量(-XX:GCTimeRatio) -
Parallel Scavenge 是 Server 级别多 CPU 机器上的默认 GC 方式,也可以通过
-XX:+UseParallelGC来指定,并且可以采用-XX:ParallelGCThread来指定线程数 -
Parallel Scavenge 对应的老年代收集器只有 Serial Old 和 Parallel Old。不能与 CMS 搭配使用的原因是,其使用的框架不同,并不是技术原因
Parallel Old 基于吞吐量的并行回收器(老年代)
- 使用多线程和标记整理算法
- 与 Parallen Scavenge 相似,只不过是运用于老年代
CMS 关注暂停时间的回收器 (老年代)
- 基于标记清除算法实现,关注 GC 的暂停时间,在注重响应时间的应用上使用
三色标记法
-
在说 CMS 具体步骤前,先看下 CMS 使用的垃圾标记算法:三色标记法
-
将堆中对象分为 3 个集合:白色、灰色和黑色
- 白色集合:需要被回收的对象
- 黑色集合:没有引用白色集合中的对象,且从
GC ROOT可达。该集合的对象是不会被回收的 - 灰色集合:从根可达但是还没有扫描完其引用的所有对象,该集合的对象不会被回收,且当其引用的白色对象全部被扫描后,会将其加入到黑色集合中
-
一般来说,会将被
GC ROOT直接引用到的对象初始化到灰色集合,其余所有对象初始化到白色集合,然后开始执行算法:- 将一个灰色对象加入到黑色集合
- 将其引用到的所有白色对象加入到灰色集合
- 重复上述两步,直到灰色集合为空
-
该算法保证从
GC ROOT出发,所有没有被引用到的对象都在白色集合中,所以最后白色集合中的所有对象就是要回收的对象
CMS 回收过程
- 分为 4 个过程,初始标记,并发标记,重新标记,并发清理
-
初始标记
- 从
GC ROOT出发,找到所有被GC ROOT直接引用的节点 - 此过程需要 STW (Stop The World)
- 从
-
并发标记
- 以上一步骤的节点为根节点,并发的遍历所有节点
- 同时会开启
Write Barrier - 如果在此过程中存在黑色对象新增对白色对象的引用,则会通过
Write Barrier记录下来- 如下图,在 GC 过程中,用三色标记法遍历到 A 这个对象(图 1),将A引用到的BCD标记为灰色
- 之后,在应用程序线程中创建了一个对象 E,A 引用了它( 图 2 这个阶段 GC 是并发标记的)
- 然后将 A 标记为黑色(图 3)
- 在 GC 扫描结束后,E 这个对象因为是白色的,所以将被回收掉
- 这显然是不能接受的,并发垃圾回收器的底线是允许一部分垃圾暂时不回收(见下面的浮动垃圾),但绝不允许从根可达的存活对象被当作垃圾处理掉
-
重新标记
- 因为并发标记的过程中可能有引用关系的变化,所以该阶段需要 STW
- 以
GC ROOT,Write Barrier中记录的对象为根节点,重新遍历 - 这里为什么还需要再遍历
GC ROOT?- 因为
Write Barrier是作用在堆上的,无法感知到GC ROOT上引用关系的变更
- 因为
-
并发清理:
- 并发的清理所有垃圾对象
-
CMS 通过将步骤拆分,实现了降低 STW 时间的目的。但 CMS 也会有以下问题:
-
浮动垃圾,在并发标记的过程中(及之后阶段),可能存在原来被引用的对象变成无人引用了
- 在这次 GC 不会对其清理
-
CPU 敏感,因为用户程序是和 GC 线程同时运行的,所以会导致 GC 的过程中程序运行变慢,GC 运行时间增长,吞吐量降低
- 默认回收线程是(CPU 数量 + 3)/ 4,也就是 CPU 不足 4 个时,会有一半的 CPU 资源给 GC 线程
-
空间碎片,标记清除算法共有的问题。当碎片过多时,为大对象分配内存空间就会很麻烦
- 有时候就是老年代空间有大量空间剩余,但没有连续的大空间来分配当前对象,不得不提前触发 Full GC
- CMS 提供一个参数(
-XX:+UseCMSCompactAtFullCollection),在 Full GC 发生时开启内存合并整理- 这个过程是 STW 的
- 同时还可以通过参数(
-XX:CMSFullGCsBeforeCom-paction)设置执行多少次不压缩的 Full GC 后,进行一次压缩的
-
需要更大的内存空间,因为是同时运行的 GC 和用户程序,所以不能像其他老年代收集器一样,等老年代满了再触发 GC,而是要预留一定的空间
- CMS 可以配置当老年代使用率到达某个阈值时(
-XX:CMSInitiatingOccupancyFraction=80),开始 CMS GC
- CMS 可以配置当老年代使用率到达某个阈值时(
-
-
在 Old GC 运行的过程中,可能有大量对象从年轻代晋升,而出现老年代存放不下的问题(因为这个时候垃圾还没被回收掉),该问题叫 Concurrent Model Failure, 这时候会启用 Serial Old 收集器,重新回收整个老年代
-
Concurrent Model Failure 一般伴随着 ParNew promotion failed(晋升担保失败), 解决这个问题的办法就是可以让 CMS 在进行一定次数的 Full GC(标记清除)的时候进行一次标记整理算法,或者降低触发 CMS GC 的阈值
Java 引用类型原理
-
Java 中主要有 4 种引用类型:强引用、软引用、弱引用、虚引用
-
序号 引用类型 取得目标对象方式 垃圾回收条件 是否可能内存泄漏 1 强引用 直接调用 不回收 可能 2 软引用 通过 get() 方法 视内存情况回收 不可能 3 弱引用 通过 get() 方法 永远回收 不可能 4 虚引用 无法取得 不回收 可能 -
强引用就是我们经常使用的
Object a = new Object();这样的形式,在 Java 中并没有对应的 Reference 类 -
其他三种引用类型都继承于
Reference类
Reference
相关字段
public abstract class Reference<T> {
// 引用的对象
private T referent;
// 回收队列,由使用者在 Reference 的构造函数中指定, 开发者可以通过从 ReferenceQueue 中 poll 感知到对象被回收的事件
volatile ReferenceQueue<? super T> queue;
// 当该引用被加入到 queue 中的时候,该字段被设置为 queue 中的下一个元素,以形成链表结构
volatile Reference next;
// 在 GC 时,JVM 底层会维护一个叫 DiscoveredList 的链表,存放的是 Reference 对象,discovered 字段指向的就是链表中的下一个元素,由 JVM 设置
transient private Reference<T> discovered;
// 进行线程同步的锁对象
static private class Lock { }
private static Lock lock = new Lock();
// 等待加入 queue 的 Reference 对象,在 GC 时由 JVM 设置,会有一个 Java 层的线程 (ReferenceHandler) 源源不断的从pending 中提取元素加入到 queue
private static Reference<Object> pending = null;
......
}
生命周期
-
主要分为 Native 层和 Java 层两个部分
-
Native 层在 GC 时将需要被回收的 Reference 对象加入到 DiscoveredList 中,然后将 DiscoveredList 的元素移动到 PendingList 中, PendingList 的队首就是 Reference 类中的 pending 对象
-
Java 层代码
-
private static class ReferenceHandler extends Thread { ... public void run() { while (true) { tryHandlePending(true); } } } static boolean tryHandlePending(boolean waitForNotify) { Reference<Object> r; Cleaner c; try { synchronized (lock) { if (pending != null) { r = pending; // 如果是 Cleaner 对象,则记录下来,下面做特殊处理 c = r instanceof Cleaner ? (Cleaner) r : null; // 指向 PendingList 的下一个对象 pending = r.discovered; r.discovered = null; } else { // 如果 pending 为 null 就先等待,当有对象加入到 PendingList 中时,JVM 会执行 notify if (waitForNotify) { lock.wait(); } // retry if waited return waitForNotify; } } } ... // 如果是 CLeaner 对象,则调用 clean 方法进行资源回收 if (c != null) { c.clean(); return true; } // 将 Reference 加入到 ReferenceQueue ReferenceQueue<? super Object> q = r.queue; if (q != ReferenceQueue.NULL) q.enqueue(r); return true; } -
对于 Cleaner 类型(继承自虚引用)的对象会有额外的处理
- 在其指向的对象被回收时,会调用
clean方法,该方法主要是用来做对应的资源回收 - 在堆外内存 DirectByteBuffer 中就是用 Cleaner 进行堆外内存的回收,这也是虚引用在 Java 中的典型应用
- 在其指向的对象被回收时,会调用
-
SoftReference
public class SoftReference<T> extends Reference<T> {
static private long clock;
private long timestamp;
public SoftReference(T referent) {
super(referent);
this.timestamp = clock;
}
public SoftReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
this.timestamp = clock;
}
public T get() {
T o = super.get();
if (o != null && this.timestamp != clock)
this.timestamp = clock;
return o;
}
}
-
软引用的实现多了两个字段:
clock和timestampclock是个静态变量,每次 GC 时都会将该字段设置成当前时间timestamp字段则会在每次调用get方法时将其赋值为clock(如果不相等且对象没被回收)
-
通过 JVM 源码查看这两个字段的作用
-
size_t ReferenceProcessor::process_discovered_reflist( DiscoveredList refs_lists[], ReferencePolicy* policy, bool clear_referent, BoolObjectClosure* is_alive, OopClosure* keep_alive, VoidClosure* complete_gc, AbstractRefProcTaskExecutor* task_executor) { ... // refs_lists 就是前面提到的 DiscoveredList // 对于 DiscoveredList 的处理分为几个阶段,SoftReference 的处理就在第一阶段 ... for (uint i = 0; i < _max_num_q; i++) { process_phase1(refs_lists[i], policy, is_alive, keep_alive, complete_gc); } ... } // 该阶段的主要目的就是当内存足够时,将对应的 SoftReference 从 refs_list 中移除 void ReferenceProcessor::process_phase1(DiscoveredList& refs_list, ReferencePolicy* policy, BoolObjectClosure* is_alive, OopClosure* keep_alive, VoidClosure* complete_gc) { DiscoveredListIterator iter(refs_list, keep_alive, is_alive); // Decide which softly reachable refs should be kept alive. while (iter.has_next()) { iter.load_ptrs(DEBUG_ONLY(!discovery_is_atomic() /* allow_null_referent */)); // 判断引用的对象是否存活 bool referent_is_dead = (iter.referent() != NULL) && !iter.is_referent_alive(); // 如果引用的对象已经不存活了,则会去调用对应的 ReferencePolicy 判断该对象是不时要被回收 if (referent_is_dead && !policy->should_clear_reference(iter.obj(), _soft_ref_timestamp_clock)) { if (TraceReferenceGC) { gclog_or_tty->print_cr("Dropping reference (" INTPTR_FORMAT ": %s" ") by policy", (void *)iter.obj(), iter.obj()->klass()->internal_name()); } // Remove Reference object from list iter.remove(); // Make the Reference object active again iter.make_active(); // keep the referent around iter.make_referent_alive(); iter.move_to_next(); } else { iter.next(); } } ... } -
refs_lists中存放了本次 GC 发现的某种引用类型(虚引用、软引用、弱引用等),而process_discovered_reflist方法的作用就是将不需要被回收的对象从refs_lists移除掉,refs_lists最后剩下的元素全是需要被回收的元素,最后会将其第一个元素赋值给上文提到过的Reference.java#pending字段 -
ReferencePolicy 一共有4种实现
-
NeverClearPolicy,永远返回 false, 代表永远不回收 SoftReference,在 JVM 中该类没有被使用
-
AlwaysClearPolicy,永远返回 true,在
referenceProcessor.hpp#setup方法中中可以设置 policy 为 AlwaysClearPolicy -
LRUCurrentHeapPolicy,LRUMaxHeapPolicy
-
should_clear_reference方法完全相同-
bool LRUMaxHeapPolicy::should_clear_reference(oop p, jlong timestamp_clock) { jlong interval = timestamp_clock - java_lang_ref_SoftReference::timestamp(p); assert(interval >= 0, "Sanity check"); // The interval will be zero if the ref was accessed since the last scavenge/gc. if(interval <= _max_interval) { return false; } return true; } -
timestamp_clock就是 SoftReference 的静态字段clock -
java_lang_ref_SoftReference::timestamp(p)对应是字段timestamp -
如果上次 GC 后有调用
SoftReference#get,interval值为 0,否则为若干次 GC 之间的时间差 -
_max_interval则代表了一个临界值,它的值在 LRUCurrentHeapPolicy 和 LRUMaxHeapPolicy 两种策略中有差异-
void LRUCurrentHeapPolicy::setup() { _max_interval = (Universe::get_heap_free_at_last_gc() / M) * SoftRefLRUPolicyMSPerMB; assert(_max_interval >= 0,"Sanity check"); } void LRUMaxHeapPolicy::setup() { size_t max_heap = MaxHeapSize; max_heap -= Universe::get_heap_used_at_last_gc(); max_heap /= M; _max_interval = max_heap * SoftRefLRUPolicyMSPerMB; assert(_max_interval >= 0,"Sanity check"); } -
其中
SoftRefLRUPolicyMSPerMB默认为 1000- 前者的计算方法和上次 GC 后可用堆大小有关
- 后者计算方法和(堆大小 - 上次 GC 时堆使用大小)有关
-
-
-
-
-
-
所以 SoftReference 什么时候被回收和使用的策略(默认应该是 LRUCurrentHeapPolicy),堆可用大小,该 SoftReference 上一次调用 get 方法的时间都有关系
WeakReference
public class WeakReference<T> extends Reference<T> {
public WeakReference(T referent) {
super(referent);
}
public WeakReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}
-
WeakReference 在 Java 层只是继承了 Reference,没有做任何的改动
-
那
referent字段是什么时候被置为 null 的呢?我们再看下上文提到过的process_discovered_reflist方法:-
size_t ReferenceProcessor::process_discovered_reflist( DiscoveredList refs_lists[], ReferencePolicy* policy, bool clear_referent, BoolObjectClosure* is_alive, OopClosure* keep_alive, VoidClosure* complete_gc, AbstractRefProcTaskExecutor* task_executor) { ... // Phase 1: 将所有不存活但是还不能被回收的软引用从 refs_lists 中移除(只有 refs_lists 为软引用的时候,这里 policy 才不为 null) if (policy != NULL) { if (mt_processing) { RefProcPhase1Task phase1(*this, refs_lists, policy, true /*marks_oops_alive*/); task_executor->execute(phase1); } else { for (uint i = 0; i < _max_num_q; i++) { process_phase1(refs_lists[i], policy, is_alive, keep_alive, complete_gc); } } } else { // policy == NULL assert(refs_lists != _discoveredSoftRefs, "Policy must be specified for soft references."); } // Phase 2: // 移除所有指向对象还存活的引用 if (mt_processing) { RefProcPhase2Task phase2(*this, refs_lists, !discovery_is_atomic() /*marks_oops_alive*/); task_executor->execute(phase2); } else { for (uint i = 0; i < _max_num_q; i++) { process_phase2(refs_lists[i], is_alive, keep_alive, complete_gc); } } // Phase 3: // 根据 clear_referent 的值决定是否将不存活对象回收 if (mt_processing) { RefProcPhase3Task phase3(*this, refs_lists, clear_referent, true /*marks_oops_alive*/); task_executor->execute(phase3); } else { for (uint i = 0; i < _max_num_q; i++) { process_phase3(refs_lists[i], clear_referent, is_alive, keep_alive, complete_gc); } } return total_list_count; } void ReferenceProcessor::process_phase3(DiscoveredList& refs_list, bool clear_referent, BoolObjectClosure* is_alive, OopClosure* keep_alive, VoidClosure* complete_gc) { ResourceMark rm; DiscoveredListIterator iter(refs_list, keep_alive, is_alive); while (iter.has_next()) { iter.update_discovered(); iter.load_ptrs(DEBUG_ONLY(false /* allow_null_referent */)); if (clear_referent) { // NULL out referent pointer // 将 Reference 的 referent 字段置为 null,之后会被 GC 回收 iter.clear_referent(); } else { // keep the referent around // 标记引用的对象为存活,该对象在这次 GC 将不会被回收 iter.make_referent_alive(); } ... } ... } -
不管是弱引用还是其他引用类型,将字段 referent 置 null 的操作都发生在
process_phase3中,而具体行为是由clear_referent的值决定的。而clear_referent的值则和引用类型相关-
ReferenceProcessorStats ReferenceProcessor::process_discovered_references( BoolObjectClosure* is_alive, OopClosure* keep_alive, VoidClosure* complete_gc, AbstractRefProcTaskExecutor* task_executor, GCTimer* gc_timer) { NOT_PRODUCT(verify_ok_to_handle_reflists()); ... // process_discovered_reflist 方法的第 3 个字段就是 clear_referent // Soft references size_t soft_count = 0; { GCTraceTime tt("SoftReference", trace_time, false, gc_timer); soft_count = process_discovered_reflist(_discoveredSoftRefs, _current_soft_ref_policy, true, is_alive, keep_alive, complete_gc, task_executor); } update_soft_ref_master_clock(); // Weak references size_t weak_count = 0; { GCTraceTime tt("WeakReference", trace_time, false, gc_timer); weak_count = process_discovered_reflist(_discoveredWeakRefs, NULL, true, is_alive, keep_alive, complete_gc, task_executor); } // Final references size_t final_count = 0; { GCTraceTime tt("FinalReference", trace_time, false, gc_timer); final_count = process_discovered_reflist(_discoveredFinalRefs, NULL, false, is_alive, keep_alive, complete_gc, task_executor); } // Phantom references size_t phantom_count = 0; { GCTraceTime tt("PhantomReference", trace_time, false, gc_timer); phantom_count = process_discovered_reflist(_discoveredPhantomRefs, NULL, false, is_alive, keep_alive, complete_gc, task_executor); } ... } -
可以看到,对于 Soft references 和 Weak references
clear_referent字段传入的都是 true- 对象不可达后,引用字段就会被置为 null,然后对象就会被回收
- 对于软引用来说,如果内存足够的话,在 Phase 1 相关的引用就会从 refs_list 中被移除,到 Phase 3 时 refs_list 为空集合
-
对于 Final references 和 Phantom references,
clear_referent字段传入的是 false- 也就意味着被这两种引用类型引用的对象,如果没有其他额外处理,只要 Reference 对象还存活,那引用的对象是不会被回收的
- Final references 和对象是否重写了 finalize 方法有关, 不在本文分析范围之内
-
-
PhantomReference
public class PhantomReference<T> extends Reference<T> {
public T get() {
return null;
}
public PhantomReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}
-
可以看到虚引用的 get 方法永远返回 null,我们看个 demo
-
public static void demo() throws InterruptedException { Object obj = new Object(); ReferenceQueue<Object> refQueue = new ReferenceQueue<>(); PhantomReference<Object> phanRef = new PhantomReference<>(obj, refQueue); Object objg = phanRef.get(); // 这里拿到的是 null System.out.println(objg); // 让 obj 变成垃圾 obj = null; System.gc(); Thread.sleep(3000); // gc 后会将 phanRef 加入到 refQueue 中 Reference<? extends Object> phanRefP = refQueue.remove(); // 这里输出 true System.out.println(phanRefP == phanRef); } -
从以上代码中可以看到,虚引用能够在指向对象不可达时得到一个'通知'(其实所有继承 References 的类都有这个功能)
-
需要注意的是 GC 完成后,phanRef.referent 依然指向之前创建 Object,也就是说 Object 对象一直没被回收
-
造成这一现象的原因在前面也已经说了:
clear_referent字段传入的是 false -
对于虚引用来说,从
refQueue.remove();得到引用对象后,可以调用clear方法强行解除引用和对象之间的关系,使得对象下次可以 GC 时可以被回收掉
-
总结
- 我们经常在网上看到软引用的介绍是:在内存不足的时候才会回收,那内存不足是怎么定义的?为什么才叫内存不足?
- 软引用会在内存不足时被回收,内存不足的定义和该引用对象
get的时间以及当前堆可用内存大小都有关系,计算公式在上文中也已经给出
- 软引用会在内存不足时被回收,内存不足的定义和该引用对象
- 网上对于虚引用的介绍是:形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。主要用来跟踪对象被垃圾回收器回收的活动。真的是这样吗?
- 严格的说,虚引用是会影响对象生命周期的,如果不做任何处理,只要虚引用不被回收,那其引用的对象永远不会被回收
- 所以一般来说,从 ReferenceQueue 中获得 PhantomReference 对象后,如果 PhantomReference 对象不会被回收的话(比如被其他 GC ROOT 可达的对象引用),需要调用
clear方法解除 PhantomReference 和其引用对象的引用关系
- 各个引用的使用场景
- 软引用
- 用于缓存,创建的对象放进缓存中,当内存不足时,JVM 就会回收早先创建的对象
- 弱引用
- WeakHashMap 中的 key 使用的是弱引用
- Threadlocal 中 ThreadLocalMap 的 Entry 继承自弱引用, 避免 Threadlocal 无法回收
- 虚引用
- DirectByteBuffer 中使用虚引用的子类
Cleaner.java来实现堆外内存的回收
- DirectByteBuffer 中使用虚引用的子类
- 软引用
关于 JVM 堆外内存
- Java 中的对象都是在 JVM 堆中分配的,其好处在于开发者不用关心对象的回收
- 但有利必有弊,堆内内存主要有两个缺点
- GC 是有成本的,堆中的对象数量越多,GC 的开销也会越大
- 使用堆内内存进行文件、网络的 IO 时,JVM 会使用堆外内存做一次额外的中转,也就是会多一次内存拷贝
- 和堆内内存相对应,堆外内存就是把内存对象分配在 Java 虚拟机堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响
堆外内存的实现 (DirectByteBuffer)
-
Java 中分配堆外内存的方式有两种
- 一是通过
ByteBuffer.java#allocateDirect得到以一个DirectByteBuffer对象 - 二是直接调用
Unsafe.java#allocateMemory分配内存,但Unsafe只能在 JDK 的代码中调用,一般不会直接使用该方法分配内存
- 一是通过
-
其中
DirectByteBuffer也是用Unsafe去实现内存分配的,对堆内存的分配、读写、回收都做了封装
堆外内存的分配与回收
// ByteBuffer.java
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
// DirectByteBuffer.java
DirectByteBuffer(int cap) { // package-private
// 主要是调用 ByteBuffer 的构造方法,为字段赋值
super(-1, 0, cap, cap);
// 如果是按页对齐,则还要加一个 Page 的大小;我们分析只 pa 为 false 的情况就好了
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
// 预分配内存
Bits.reserveMemory(size, cap);
long base = 0;
try {
// 分配内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 将分配的内存的所有值赋值为 0
unsafe.setMemory(base, size, (byte) 0);
// 为 address 赋值,address 就是分配内存的起始地址,之后的数据读写都是以它作为基准
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
// pa 为 false 的情况,address == base
address = base;
}
// 创建一个 Cleaner,将 this 和一个 Deallocator 对象传进去
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
-
DirectByteBuffer构造方法分为几个步骤- 预分配内存
- 分配内存
- 将刚分配的内存空间初始化为 0
- 创建一个
Cleaner对象,Cleaner对象的作用是当DirectByteBuffer对象被回收时,释放其对应的堆外内存
-
当 GC 发现
DirectByteBuffer对象变成垃圾时,会调用Cleaner#clean回收对应的堆外内存,一定程度上防止了内存泄露- 当然也可以手动的调用该方法,对堆外内存进行提前回收
Cleaner 的实现
public class Cleaner extends PhantomReference<Object> {
...
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk;
}
public void clean() {
if (remove(this)) {
try {
// thunk 是一个 Deallocator 对象
this.thunk.run();
} catch (final Throwable var2) {
...
}
}
}
}
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
// 调用 unsafe 方法回收堆外内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
- 当字段
referent(也就是DirectByteBuffer对象)被回收时,会调用到Cleaner#clean方法,最终会调用到Deallocator#run进行堆外内存的回收 - Cleaner 是虚引用在 JDK 中的一个典型应用场景
预分配内存
static void reserveMemory(long size, int cap) {
// maxMemory 代表最大堆外内存,也就是 -XX:MaxDirectMemorySize 指定的值
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
// 1.如果堆外内存还有空间,则直接返回
if (tryReserveMemory(size, cap)) {
return;
}
// 走到这里说明堆外内存剩余空间已经不足了
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
// 2.堆外内存进行回收,最终会调用到 Cleaner#clean 的方法。如果目前没有堆外内存可以回收则跳过该循环
while (jlra.tryHandlePendingReference()) {
// 如果空闲的内存足够了,则 return
if (tryReserveMemory(size, cap)) {
return;
}
}
// 3.主动触发一次 GC,目的是触发老年代 GC
System.gc();
// 4.重复上面的过程
boolean interrupted = false;
try {
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
if (!jlra.tryHandlePendingReference()) {
try {
Thread.sleep(sleepTime);
sleepTime <<= 1;
sleeps++;
} catch (InterruptedException e) {
interrupted = true;
}
}
}
// 5.超出指定的次数后,还是没有足够内存,则抛异常
throw new OutOfMemoryError("Direct buffer memory");
} finally {
if (interrupted) {
// don't swallow interrupts
Thread.currentThread().interrupt();
}
}
}
private static boolean tryReserveMemory(long size, int cap) {
// size 和 cap 主要是 page 对齐的区别,这里我们把这两个值看作是相等的
long totalCap;
// totalCapacity 代表通过 DirectByteBuffer 分配的堆外内存的大小
// 当已分配大小 <= 还剩下的堆外内存大小 时,更新 totalCapacity 的值返回 true
while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
reservedMemory.addAndGet(size);
count.incrementAndGet();
return true;
}
}
// 堆外内存不足,返回 false
return false;
}
-
在创建一个新的
DirecByteBuffer时,会先确认有没有足够的内存,如果没有的话,会通过一些手段回收一部分堆外内存,直到可用内存大于需要分配的内存。具体步骤如下:-
如果可用堆外内存足够,则直接返回
-
调用
tryHandlePendingReference方法回收已经变成垃圾的DirectByteBuffer对象对应的堆外内存,直到可用内存足够,或目前没有垃圾DirectByteBuffer对象tryHandlePendingReference最终调用到的是Reference#tryHandlePending方法- 此方法在前面有介绍过, 对于
Cleaner对象调用对应的Cleaner#clean方法进行回收
-
触发一次 Full GC, 其主要目的是为了防止冰山现象
-
一个
DirectByteBuffer对象本身占用的内存很小,但是它可能引用了一块很大的堆外内存 -
如果
DirectByteBuffer对象进入了老年代之后变成了垃圾,因为老年代 GC 一直没有触发,导致这块堆外内存也一直没有被回收 -
需要注意的是如果使用参数
-XX:+DisableExplicitGC,那System.gc();是无效的
-
-
重复 1,2 步骤的流程,直到可用内存大于需要分配的内存
-
如果超出指定次数还没有回收到足够内存,则 OOM
-
堆外内存的读写
public ByteBuffer put(byte x) {
unsafe.putByte(ix(nextPutIndex()), ((x)));
return this;
}
final int nextPutIndex() {
if (position >= limit)
throw new BufferOverflowException();
return position++;
}
private long ix(int i) {
return address + ((long)i << 0);
}
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
final int nextGetIndex() { // package-private
if (position >= limit)
throw new BufferUnderflowException();
return position++;
}
- 读写的逻辑比较简单,
address就是构造方法中分配的 native 内存的起始地址 Unsafe的putByte/getByte都是 native 方法,就是写入值到某个地址/获取某个地址的值
堆外内存的使用场景
-
适合长期存在或能复用的场景, 堆外内存分配回收也是有开销的,所以适合长期存在的对象
-
适合注重稳定的场景, 堆外内存能有效避免因 GC 导致的暂停问题
堆外内存能有效避免因GC导致的暂停问题。
-
适合简单对象的存储, 因为堆外内存只能存储字节数组,所以对于复杂的 DTO 对象,每次存储/读取都需要序列化/反序列化
-
适合注重 IO 效率的场景, 用堆外内存读写文件性能更好
文件IO
- 堆外内存 IO 为什么有更好的性能
BIO
-
BIO 的文件写
FileOutputStream#write最终会调用到 native 层的io_util.c#writeBytes方法 -
void writeBytes(JNIEnv *env, jobject this, jbyteArray bytes, jint off, jint len, jboolean append, jfieldID fid) { jint n; char stackBuf[BUF_SIZE]; char *buf = NULL; FD fd; ... // 如果写入长度为 0,直接返回 0 if (len == 0) { return; } else if (len > BUF_SIZE) { // 如果写入长度大于 BUF_SIZE(8192),无法使用栈空间 buffer // 需要调用 malloc 在堆空间申请 buffer buf = malloc(len); if (buf == NULL) { JNU_ThrowOutOfMemoryError(env, NULL); return; } } else { buf = stackBuf; } // 复制 Java 传入的 byte 数组数据到 C 空间的 buffer 中 (*env)->GetByteArrayRegion(env, bytes, off, len, (jbyte *)buf); if (!(*env)->ExceptionOccurred(env)) { off = 0; while (len > 0) { fd = GET_FD(this, fid); if (fd == -1) { JNU_ThrowIOException(env, "Stream Closed"); break; } // 写入到文件,这里传递的数组是我们新创建的 buf if (append == JNI_TRUE) { n = (jint)IO_Append(fd, buf+off, len); } else { n = (jint)IO_Write(fd, buf+off, len); } if (n == JVM_IO_ERR) { JNU_ThrowIOExceptionWithLastError(env, "Write error"); break; } else if (n == JVM_IO_INTR) { JNU_ThrowByName(env, "java/io/InterruptedIOException", NULL); break; } off += n; len -= n; } } } -
GetByteArrayRegion其实就是对数组进行了一份拷贝,该函数的实现在 jni.cpp 宏定义中-
// jni.cpp JNI_ENTRY(void, \ jni_Get##Result##ArrayRegion(JNIEnv *env, ElementType##Array array, jsize start, \ jsize len, ElementType *buf)) \ ... int sc = TypeArrayKlass::cast(src->klass())->log2_element_size(); \ // 内存拷贝 memcpy((u_char*) buf, \ (u_char*) src->Tag##_at_addr(start), \ len << sc); \ ... } \ JNI_END
-
-
传统的 BIO,在 native 层真正写文件前,会在堆外内存(c 分配的内存)中对字节数组拷贝一份,之后真正 IO 时,使用的是堆外的数组, 这样做的原因是:
- 底层通过 write、read、pwrite,pread 函数进行系统调用时,需要传入 buffer 的起始地址和 buffer count 作为参数
- 如果使用 Java Heap 的话,我们知道 JVM 中 buffer 往往以 byte[] 的形式存在,这是一个特殊的对象,由于 Java Heap GC 的存在,这里对象在堆中的位置往往会发生移动,移动后我们传入系统函数的地址参数就不是真正的 buffer 地址了,这样的话无论读写都会发生出错。而 C Heap 仅仅受 Full GC 的影响,相对来说地址稳定
- JVM 规范中没有要求 Java 的 byte[] 必须是连续的内存空间,它往往受宿主语言的类型约束
- 而 C Heap 中我们分配的虚拟地址空间是可以连续的,而上述的系统调用要求我们使用连续的地址空间作为 buffer
- 底层通过 write、read、pwrite,pread 函数进行系统调用时,需要传入 buffer 的起始地址和 buffer count 作为参数
NIO
-
NIO 的文件写最终会调用到
IOUtil#write -
static int write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd, Object lock) throws IOException { // 如果是堆外内存,则直接写 if (src instanceof DirectBuffer) return writeFromNativeBuffer(fd, src, position, nd, lock); // Substitute a native buffer int pos = src.position(); int lim = src.limit(); assert (pos <= lim); int rem = (pos <= lim ? lim - pos : 0); // 创建一块堆外内存,并将数据赋值到堆外内存中去 ByteBuffer bb = Util.getTemporaryDirectBuffer(rem); try { bb.put(src); bb.flip(); // Do not update src until we see how many bytes were written src.position(pos); int n = writeFromNativeBuffer(fd, bb, position, nd, lock); if (n > 0) { // now update src src.position(pos + n); } return n; } finally { Util.offerFirstTemporaryDirectBuffer(bb); } } /** * 分配一片堆外内存 */ static ByteBuffer getTemporaryDirectBuffer(int size) { BufferCache cache = bufferCache.get(); ByteBuffer buf = cache.get(size); if (buf != null) { return buf; } else { // No suitable buffer in the cache so we need to allocate a new // one. To avoid the cache growing then we remove the first // buffer from the cache and free it. if (!cache.isEmpty()) { buf = cache.removeFirst(); free(buf); } return ByteBuffer.allocateDirect(size); } } -
NIO 的文件写,对于堆内内存来说也是会有一次额外的内存拷贝的