一句话结论(30s)
Epsilon 的本质是一个只分配、从不回收的”工具型”GC——堆用尽时直接抛 OutOfMemoryError,而不是尝试回收。它存在的价值是隔离变量:因为只有把 GC 彻底关掉,性能基准才能测出 GC 的真实开销、内存压力测试才能测出应用的真实内存需求。权衡是它完全不能用于常规应用,只适合测量、隔离和给足内存的短生命周期任务。
核心原理(2min)
Epsilon 在 JDK 11(JEP 318)引入,核心代码只保留内存分配、移除所有回收触发逻辑:每个线程在 TLAB 内用 Bump-Pointer(指针碰撞)分配小对象,大对象直接进堆,因此分配效率是所有 GC 中最高的;堆耗尽时不回收,直接抛 OutOfMemoryError: Java heap space,并可用 -XX:+HeapDumpOnOutOfMemoryError 导出从未被清理过的完整堆快照。由此支撑三大用途:JMH 基准对比(Epsilon 与 G1 的吞吐/延迟差即 GC 额外开销,差值 2-3% 无需优化、超 15% 才值得投入)、二分逼近真实内存需求(刚好不 OOM 的 Xmx 值)、以及给足内存的短生命周期任务。
想一想:一个”从不回收”的 GC,凭什么反而能测出 GC 的真实开销?因为 Epsilon 代表了”零 GC 开销”的基线——它的分配是全 GC 最快、又绝不回收,拿 G1 的吞吐减去这个基线,剩下的就是标记、复制、卡表等 GC 全部工作的净开销。
底层深入(5-10min)
如果在JVM参数里加上-XX:+UseEpsilonGC,你得到的将是一个完全不回收垃圾的JVM。是的,你没看错——它分配内存、从不回收,堆用完了就抛OutOfMemoryError。这听起来像是工程师的噩梦,但实际上,Epsilon GC是一个精心设计的工具型收集器。
Epsilon的设计哲学
Epsilon(希腊字母E,在数学中常表示”极小量”)由Aleksey Shipilev(JMH和Shenandoah的作者)设计,在JDK 11中以JEP 318正式引入。它的核心代码极少——只实现了内存分配,所有垃圾回收的触发逻辑都被移除。
为什么需要这样一个”什么都不做”的GC?
因为它消除了GC这个变量。当你在做性能测试、基准对比、内存压力分析时,GC的介入会让结果变得不可预测——这次分配触发了Young GC,那次没触发,性能波动可能高达20%以上。Epsilon让GC完全退出舞台,你才能看到纯粹的”应用本身”的性能表现。
光看头文件里这一串”空实现”,就能直观感受到 Epsilon 的”极简主义”——所有需要回收器参与的钩子全部留空:
// 源码:src/hotspot/share/gc/epsilon/epsilonHeap.hpp
// Object pinning support: every object is implicitly pinned
void pin_object(JavaThread* thread, oop obj) override { }
void unpin_object(JavaThread* thread, oop obj) override { }
// No GC threads
void gc_threads_do(ThreadClosure* tc) const override {}
// No nmethod handling
void register_nmethod(nmethod* nm) override {}
void unregister_nmethod(nmethod* nm) override {}
void verify_nmethod(nmethod* nm) override {}
// No heap verification
void prepare_for_verify() override {}
void verify(VerifyOption option) override {}
注意看:gc_threads_do 没有 GC 线程、register_nmethod 不追踪 JIT 编译的代码、verify 不做堆校验——这些在 G1/ZGC 里都是重头戏的钩子,在 Epsilon 里全部是空函数体。它从 CollectedHeap 继承的几十个虚函数里,真正有实现的只有”分配”和”记录内存池状态”两类,这就是”只分配、不回收”在代码层面最直接的体现。
适用场景一:性能基准测试
当你用JMH(Java Microbenchmark Harness)做微基准测试时,GC线程的调度可能污染测试结果。解决方式之一是使用Epsilon,让JVM只做一件事——跑你的代码。
java -XX:+UseEpsilonGC -Xmx2G -jar benchmark.jar
但要注意:用了Epsilon,你得确保堆内存足够支撑整个测试周期。建议为测试设置充足的Xmx,并在测试中监控堆使用量。
适用场景二:GC开销对比
这是Epsilon最有价值的用途。你可以这样对比:
- 用G1跑一次,记录吞吐量和延迟。
- 用Epsilon跑一次(确保不OOM),记录吞吐量和延迟。
- 两者之差 = GC的额外开销。
这个差值可以帮助你决定”是否需要优化GC”以及”要不要尝试低延迟GC”。如果差值只有2-3%,优化GC的收益有限;如果差值超过15%,那值得投入时间调整GC配置或升级GC。
适用场景三:内存压力测试
Epsilon可以帮你精准回答一个问题:“我这个应用在极限负载下到底需要多少内存?”
传统测试中,你给JVM分4GB堆内存,GC会不停地清理垃圾,你看到的”峰值使用量”可能只有2.5GB。但真实峰值(包含所有短暂存活的对象)可能接近4GB——只不过被GC及时回收了,你看不到。
想一想:为什么传统测试看到的”峰值使用量”会偏低?因为 GC 在测试期间不断把短暂存活的对象回收掉了,你看到的 2.5GB 是被 GC”抹平”后的假象;关掉 GC 后,这些短暂对象全部留在堆里,真实峰值才浮出水面。
用Epsilon关掉GC后:
- 用
-Xmx8G跑压力测试,系统正常运行——说明8G足够。 - 用
-Xmx4G跑压力测试,5分钟内OOM——说明4G不够,真实需求介于4G和8G之间。 - 二分搜索出刚好不OOM的Xmx值,这就是应用的真实内存需求。
这个数值对你做容器内存限制(K8s resource limits)和成本预算非常有参考价值。
适用场景四:短生命周期任务
如果你的Java进程只运行几秒到几分钟(比如Lambda函数、批处理脚本),而且处理的都是小数据量,完全可以给足内存用Epsilon——减少GC的启动开销和内存占用。但这需要你对任务的内存行为有充分了解,否则后果就是OOM。
Epsilon的行为特征
当堆用完时,Epsilon的行为和其他GC不同:
- 其他GC:堆快满时触发GC,尝试回收。
- Epsilon:堆满时直接抛
OutOfMemoryError: Java heap space。不会尝试回收,因为没有回收代码。
想一想:为什么 Epsilon 导出的 Heap Dump 反而更”诚实”?因为它从不清理,dump 里装的是程序运行以来所有曾分配过的对象,没有被 GC 动过手脚,能完整还原内存的”全貌”而不是”幸存者”。
Epsilon也支持-XX:+HeapDumpOnOutOfMemoryError,在OOM时生成Heap Dump供分析。这反而比普通GC更”诚实”——你看到的就是所有曾经分配过的对象,没有被GC清理过的”干净”堆。
当 JVM 真的向 Epsilon 发出一次 GC 请求(包括堆快满时的 _java_lang_system_gc 或 _heap_inspection 等),它做的事只有下面这些:
// 源码:src/hotspot/share/gc/epsilon/epsilonHeap.cpp
void EpsilonHeap::collect(GCCause::Cause cause) {
switch (cause) {
case GCCause::_metadata_GC_threshold:
case GCCause::_metadata_GC_clear_soft_refs:
// Receiving these causes means the VM itself entered the safepoint for metadata collection.
// While Epsilon does not do GC, it has to perform sizing adjustments, otherwise we would
// re-enter the safepoint again very soon.
assert(SafepointSynchronize::is_at_safepoint(), "Expected at safepoint");
log_info(gc)("GC request for \"%s\" is handled", GCCause::to_string(cause));
MetaspaceGC::compute_new_size();
print_metaspace_info();
break;
default:
log_info(gc)("GC request for \"%s\" is ignored", GCCause::to_string(cause));
}
_monitoring_support->update_counters();
}
这是 Epsilon 整个 GC 逻辑的全部——switch 里除了处理元空间(Metaspace)触发的大小调整外,其余所有 GC 原因(包括堆满)都只打一行日志 GC request ... is ignored 然后直接返回。换句话说,collect() 被调用时它”装作没听见”:不扫描、不标记、不复制,这正是”堆用尽直接 OOM、绝不回收”的源码依据。
Epsilon的内存分配
Epsilon的内存分配器非常简单——一个带TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区)的Bump-Pointer分配器:
- 每个线程在TLAB中分配小对象,TLAB用完了申请新的TLAB。
- 大对象直接在堆中分配。
- Bump-Pointer(指针碰撞):维护一个空闲内存指针,分配时指针往前移动即可,速度极快。
因为不需要GC,也不需要维护任何GC元数据(如卡表、Remembered Set),Epsilon的内存分配效率是所有GC中最高的。
而所谓”Bump-Pointer 分配”,在源码里就是这段 allocate_work:
// 源码:src/hotspot/share/gc/epsilon/epsilonHeap.cpp
HeapWord* EpsilonHeap::allocate_work(size_t size) {
assert(is_object_aligned(size), "Allocation size should be aligned: %zu", size);
HeapWord* res = nullptr;
while (true) {
// Try to allocate, assume space is available
res = _space->par_allocate(size);
if (res != nullptr) {
break;
}
// Allocation failed, attempt expansion, and retry:
{
MutexLocker ml(Heap_lock);
// Try to allocate under the lock, assume another thread was able to expand
res = _space->par_allocate(size);
if (res != nullptr) {
break;
}
// Expand and loop back if space is available
size_t size_in_bytes = size * HeapWordSize;
size_t uncommitted_space = max_capacity() - capacity();
size_t unused_space = max_capacity() - used();
size_t want_space = MAX2(size_in_bytes, EpsilonMinHeapExpand);
assert(unused_space >= uncommitted_space,
"Unused (%zu) >= uncommitted (%zu)",
unused_space, uncommitted_space);
if (want_space < uncommitted_space) {
// Enough space to expand in bulk:
bool expand = _virtual_space.expand_by(want_space);
assert(expand, "Should be able to expand");
} else if (size_in_bytes < unused_space) {
// No space to expand in bulk, and this allocation is still possible,
// take all the remaining space:
bool expand = _virtual_space.expand_by(uncommitted_space);
assert(expand, "Should be able to expand");
} else {
// No space left:
return nullptr;
}
_space->set_end((HeapWord *) _virtual_space.high());
}
}
// ... 后续只是按步长更新监控计数、打印堆/元空间信息 ...
assert(is_object_aligned(res), "Object should be aligned: " PTR_FORMAT, p2i(res));
return res;
}
_space->par_allocate 就是典型的 Bump-Pointer:ContiguousSpace 只维护一个 top 指针,分配时直接后移,无需任何卡表或 Remembered Set 元数据。失败路径也很纯粹——先尝试 expand_by 扩展已保留但未提交的虚拟空间再重试,一旦 unused_space 耗尽就返回 nullptr,由上层 CollectedHeap 统一抛出 OutOfMemoryError。整个分配路径没有任何回收逻辑,这也是 Epsilon 分配效率领跑所有 GC 的原因。
Epsilon的局限
Epsilon不能用于常规应用——除非你能保证堆内存永远够用(或者能容忍OOM)。它不是”优化”方案,而是”测量”和”隔离”工具。同时Epsilon不处理任何类型的引用(软引用、弱引用、虚引用),这些引用对象的行为和其他GC下不同。
总结
把Epsilon理解成一个”显微镜”而不是”手术刀”——它的作用不是让应用跑得更好,而是让你看清楚应用的内存行为。当你需要测量GC的真实开销、确定精确的内存需求、或者在短生命周期的无状态任务中追求极致性能时,打开Epsilon,你会看到一个更真实、更纯粹的应用。
章末提问
追问 1:Epsilon GC 既然什么都不回收,那它到底”做”了什么?
回答思路:结论先行——它只保留两件事:内存分配和元空间大小调整。因为源码里 collect() 的 switch 对除元空间外的所有 GC 原因(包括堆满)都只打一行 is ignored 日志就返回,allocate_work 里只有 Bump-Pointer 分配和 expand_by 扩容,没有任何标记/复制/回收逻辑;所以”不回收”不是偷懒,而是刻意把回收路径删空,让 GC 这个变量从实验中彻底消失。
追问 2:Epsilon 凭什么能测出 GC 的真实开销?这个开销怎么算?
回答思路:结论先行——用同一负载分别跑 G1 和 Epsilon,两者的吞吐/延迟差就是 GC 的净开销。因为 Epsilon 分配最快且从不回收,代表”零 GC 开销”的基线,G1 减去这个基线剩下的就是标记、复制、卡表等全部 GC 工作;再按差值决策:2-3% 说明 GC 已足够好、不必优化,超 15% 才值得投入调参或换低延迟 GC。
追问 3:什么场景能用 Epsilon,什么场景绝对不能用?
回答思路:结论先行——只能用于”给足内存的短生命周期任务”和”测量/基准”场景,绝不能用于常规生产服务。因为 Epsilon 堆满直接抛 OOM、不回收任何对象、也不处理软/弱/虚引用,只要运行时间稍长或对象分配总量超过 Xmx 就会崩溃;它本质是”显微镜”而非”手术刀”,用错场景的代价就是直接 OOM。