前言
G1 GC 全称叫 Garbage-First Garbage Collector,下文简称 G1。G1 在 JDK 6中出现实验版本,在 JDK 7中达到商用版本的成熟度,JDK 8 中提供并发的类卸载支持,在此之后 G1 被 Oracle 官方称之为 “全功能的垃圾收集器”。在 JDK 9中 G1 成为默认垃圾收集器,JDK 10 将其提升为并行 full GC,JDK 12 在超出停顿目标时间情况下,加入混合回收下的放弃策略、积极返回未使用内存给操作系统,JDK 14 的 NUMA 感知内存分配。另外根据致力于提高 G1 性能的开发者 Thomas Schatzl 表示,从 JDK 8 到 JDK 18 G1 的延迟、吞吐量、内存占用一直都在优化。内存占用方面,在不影响吞吐量和延迟的情况下从 30% 下将到 6%。本文说的是另一个特性 JEP 423: Region Pinning for G1 (openjdk.org)。
JNI
JNI(Java Native Interface)是一种编程框架,允许Java代码调用和被调用本地(本机)应用程序或库,用其他编程语言编写的代码,通常是C或C++。JNI提供了一种与Java虚拟机(JVM)交互的机制,使Java程序能够利用现有的C/C++代码库,或者访问与Java标准库中未提供的系统级资源。
使用 JNI 调用 C/C++ 代码通常需要将 Java 对象作为参数传入。在 C/C++ 代码代码中使用 Java 对象通常有两种方式:
- 使用 JNI 提供的成对方法(GetPrimitiveArrayCritical/ ReleasePrimitiveArrayCritical)获取/释放对象的直接指针。在这两个方法之间的代码称为临界区,在临界区中被使用的对象称之为临界对象(critical object)。下面是一段 Netty native 的 代码 Do not use GetPrimitiveArrayCritical(...) due multiple not-fixed bugs… by normanmaurer · Pull Request #8921 · netty/netty (github.com)。
int netty_unix_socket_initSockaddr(JNIEnv* env, jbyteArray address, jint scopeId, jint jport,const struct sockaddr_storage* addr, socklen_t* addrSize) {
// Use GetPrimitiveArrayCritical and ReleasePrimitiveArrayCritical to signal the VM that we really would like
// to not do a memory copy here. This is ok as we not do any blocking action here anyway.
// This is important as the VM may suspend GC for the time!
jbyte* addressBytes = (*env)->GetPrimitiveArrayCritical(env, address, 0);
//临界区
//操作jbyteArray address
(*env)->ReleasePrimitiveArrayCritical(env, address, addressBytes, JNI_ABORT);
}
Netty 在注释中写到使用 JNI 函数而不是使用内存拷贝的原因是临界区不会做任何阻塞的操作。还提到 JNI 函数的使用会暂停 GC,GC 时会移动对象,导致 JNI 拿到的对象地址错误。正因为如此 Netty 在后续的版本中采用了内存拷贝的方式。
- 由于 JNI 函数的使用会暂停 GC,Netty 使用内存拷贝的方式做了优化。
int netty_unix_socket_initSockaddr(JNIEnv* env, jbyteArray address, jint scopeId, jint jport,const struct sockaddr_storage* addr, socklen_t* addrSize) {
jbyte addressBytes[16];
int len = (*env)->GetArrayLength(env, address);
(*env)->GetByteArrayRegion(env, address, 0, len, addressBytes);
}
Netty 首先在栈上申明了一段数组,然后将 jbyteArray address
的内容拷贝到分配的数组中,而没有临界区。
使用 JNI 性能要比内存拷贝性能好一些,但是会暂停 GC,二者不可得兼,并且使用 JNI 在某些情况下会导致 OOM, 严重时会导致系统几分钟无法响应 JIRA Running out of memory due to GC。
当线程处于临界区,垃圾回收时不能移动与之关联的临界对象。当垃圾回收器要么固定临界对象在原来的位置上,要么被禁止执行,G1 采用了后者。G1 的 JEP 423: Region Pinning for G1 致力于解决这个问题。
GCLocker
如果是 JNI 导致暂停 GC,可能会打印出如下日志:
[info][gc] GC(19107) Pause Young (Normal) (GCLocker Initiated GC) 185311M->185284M(185696M) 7.019m
[warning][gc,alloc] Thread-231880: Retried waiting for GCLocker too often allocating 65538 words
Terminating due to java.lang.OutOfMemoryError: Java heap space
当系统需要进行 GC 时,JVM 需要查看是否有线程持有 GCLocker,如果有任何持有,那么在使用 Parallel、CMS、G1 不能直接进行 GC。当释放 GCLocker 的时候,JVM 会检测是否有因为 GCLocker 而被阻塞的 GC ,如果有则触发 GC,并且打印 “GCLocker Initiated GC”。
为了观察 Java 堆的对象分布,可以使用 Eclipse MAT ,可以发现有大量的垃圾对象没有被回收。试想下面发生 OOM 的情况。
当线程等待的次数 count 大于 GCLockerRetryAllocationCount 的次数时就会发生 OOM。GCLockerRetryAllocationCount 默认为2,为了延迟 OOM 出现,可以适当设置的大一些,在 Full GC 发生时释放内存,不过这需要对系统做整体性的评估。
模拟 GCLocker
Java 测试代码如下,在临界区内构造大量对象。最好使用 Java 11 运行。
public class CriticalGC {
static final int ITERS = Integer.getInteger("iters", 100);
static final int ARR_SIZE = Integer.getInteger("arrSize", 10_000);
static final int WINDOW = Integer.getInteger("window", 10_000_000);
static native void acquire(int[] arr);
static native void release(int[] arr);
static final Object[] window = new Object[WINDOW];
public static void main(String... args) throws Throwable {
System.loadLibrary("CriticalGC");
int[] arr = new int[ARR_SIZE];
for (int i = 0; i < ITERS; i++) {
acquire(arr);
System.out.println("Acquired");
try {
for (int c = 0; c < WINDOW; c++) {
window[c] = new Object();
}
} catch (Throwable t) {
// omit
} finally {
System.out.println("Releasing");
release(arr);
}
}
}
}
C 代码
static jbyte* sink;
JNIEXPORT void JNICALL Java_CriticalGC_acquire(JNIEnv* env, jclass klass, jintArray arr) {
sink = (*env)->GetPrimitiveArrayCritical(env, arr, 0);
}
JNIEXPORT void JNICALL Java_CriticalGC_release(JNIEnv* env, jclass klass, jintArray arr) {
(*env)->ReleasePrimitiveArrayCritical(env, arr, sink, 0);
}
完整的代码地址:shipilev.net/jvm/anatomy…
MacOS 上运行需要修改 Make file
javac CriticalGC.java
javah CriticalGC
cc -I. -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux -shared -o libCriticalGC.so CriticalGC.c
为
javac -h . CriticalGC.java
cc -I. -I${JAVA_HOME}/include -I${JAVA_HOME}/include/darwin -shared -o libCriticalGC.dylib CriticalGC.c
Parallel
编译运行 make all && make run-parallel
从 GC log 可以看到在 Acquired 和 Releasing 之间没有发生 GC。
[0.003s][info][gc] Using Parallel
Acquired
Releasing
[1.591s][info][gc] GC(0) Pause Young (GCLocker Initiated GC) 607M->692M(1963M) 751.579ms
Acquired
Releasing
Acquired
Releasing
[5.922s][info][gc] GC(1) Pause Full (Ergonomics) 1607M->577M(1963M) 1365.811ms
在函数 jni_GetPrimitiveArrayCritical
和 jni_ReleasePrimitiveArrayCritical
中可以到获取和释放 GCLocker。
JNI_ENTRY(void*, jni_GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy))
JNIWrapper("GetPrimitiveArrayCritical");
GCLocker::lock_critical(thread); // <--- 获取 GCLocker!
if (isCopy != NULL) {
*isCopy = JNI_FALSE;
}
oop a = JNIHandles::resolve_non_null(array);
...
void* ret = arrayOop(a)->base(type);
return ret;
JNI_END
JNI_ENTRY(void, jni_ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode))
JNIWrapper("ReleasePrimitiveArrayCritical");
...
// The array, carray and mode arguments are ignored
GCLocker::unlock_critical(thread); // <--- 释放 GCLocker!
...
JNI_END
G1
运行 make run-g1
[0.006s][info][gc] Using G1
Acquired
<HANGS>
程序直接被挂起不再执行了。
使用 jstack
查看线程状态为运行态。
"main" #1 prio=5 os_prio=31 cpu=126.90ms elapsed=1159.70s tid=0x000000012200d800 nid=0x2903 waiting on condition [0x000000016f552000]
java.lang.Thread.State: RUNNABLE
at CriticalGC.main(CriticalGC.java:22)
为了看到更多的细节,使用用参数 fastdebug 构建的 jdk 来运行程序,可以看到下面日志。
#
# A fatal error has been detected by the Java Runtime Environment:
#
# Internal Error (/home/shade/trunks/jdk9-dev/hotspot/src/share/vm/gc/shared/gcLocker.cpp:96), pid=17842, tid=17843
# assert(!JavaThread::current()->in_critical()) failed: Would deadlock
#
Native frames: (J=compiled Java code, A=aot compiled Java code, j=interpreted, Vv=VM code, C=native code)
V [libjvm.so+0x15b5934] VMError::report_and_die(...)+0x4c4
V [libjvm.so+0x15b644f] VMError::report_and_die(...)+0x2f
V [libjvm.so+0xa2d262] report_vm_error(...)+0x112
V [libjvm.so+0xc51ac5] GCLocker::stall_until_clear()+0xa5
V [libjvm.so+0xb8b6ee] G1CollectedHeap::attempt_allocation_slow(...)+0x92e
V [libjvm.so+0xba423d] G1CollectedHeap::attempt_allocation(...)+0x27d
V [libjvm.so+0xb93cef] G1CollectedHeap::allocate_new_tlab(...)+0x6f
V [libjvm.so+0x94bdba] CollectedHeap::allocate_from_tlab_slow(...)+0x1fa
V [libjvm.so+0xd47cd7] InstanceKlass::allocate_instance(Thread*)+0xc77
V [libjvm.so+0x13cfef0] OptoRuntime::new_instance_C(Klass*, JavaThread*)+0x830
v ~RuntimeStub::_new_instance_Java
J 87% c2 CriticalGC.main([Ljava/lang/String;)V (82 bytes) ...
v ~StubRoutines::call_stub
V [libjvm.so+0xd99938] JavaCalls::call_helper(...)+0x858
V [libjvm.so+0xdbe7ab] jni_invoke_static(...) ...
V [libjvm.so+0xdde621] jni_CallStaticVoidMethod+0x241
C [libjli.so+0x463c] JavaMain+0xa8c
C [libpthread.so.0+0x76ba] start_thread+0xca
从日志中看到,有严重错误发生,并且可能有死锁。死锁日志来自 stall_until_clear
。由于当前本身就处于临界区中,在内存不够使创建对象需要等待 GC 完成,但是 GC 有需要等待临界区被释放,这样就造成了死锁。
void GCLocker::stall_until_clear() {
assert(!JavaThread::current()->in_critical(), "Would deadlock");// 检测死锁
MutexLocker ml(JNICritical_lock);
if (needs_gc()) {
log_debug_jni("Allocation failed. Thread stalled by JNI critical section.");
}
// Wait for _needs_gc to be cleared
while (needs_gc()) {
JNICritical_lock->wait();
}
}
Region Pinning for G1
G1 将整个堆分成大小固定的内存块(fixed-size memory regions),并且 G1 是分代收集器,所以每个不为空的内存块都扮演了年轻代或者老年代的角色。垃圾回收器工作时将存活的对象从原来的内存块移动到其他空白的内存块。
G1 在 full GC 情况是拥有了固定对象的能力,但是在 minor gc 中没有(JEP 423中提到的 minor gc 就是通常说的 mixed gc java - Why Old generation objects clearing up by minor GC - Stack Overflow)。JEP 423 给 G1 的 minor GC 增加了固定对象的能力。
准确来说,是将固定内存块(pin region)的能力从 major GC 扩展到 minor GC,思路如下:
在每个内存块中维护一个计数器,用于记录每个内存块临界对象的个数。当内存块临界对象增加时计数器随着增加,临界对象减少时,计数器也减少。当计数器为 0 时,垃圾收集器正常回收内存块,当计数器不为 0 时,则固定内存块不允许回收。
当垃圾器收集器工作时,计数器不为 0 的区域将被固定,其他区域将被正常回收。
优化后的 G1 将允许我们使用 JNI 临界区域而不会暂停 GC。唯一的潜在风险是过多的内存块被固定导致内存耗尽,openJDk团队对此没有解决方案,但是根据Shenandoah GC(同样使用固定内存块的方案)的使用来看,从未发生过内存耗尽的情况。
该特性在 JDK 22 中加入。
总结
G1 作为 JDK 目前默认并且主推的垃圾收集器,其性能及功能性问题都一直在被优化。它在内存占用、延迟、吞吐量综合方面是当前最优秀的。当开启新项目时可以适当考虑新版本的 Java,因为新版本不仅有更多的语言级别的特性,更有很多 JVM 层级的优化,不用修改任何代码就能提升程序的性能。
参考资料
- JEP 307: Parallel Full GC for G1 (openjdk.org)
- JEP 344: Abortable Mixed Collections for G1 (openjdk.org)
- JEP 346: Promptly Return Unused Committed Memory from G1 (openjdk.org)
- JEP 345: NUMA-Aware Memory Allocation for G1 (openjdk.org)-
- Java garbage collection: The 10-release evolution from JDK 8 to JDK 18 (oracle.com)
- tschatzl.github.io/about/
- GCLocker too often allocating 256 words - Elastic Stack / Logstash - Discuss the Elastic Stack
- JVM Anatomy Quark #9: JNI Critical and GC Locker (shipilev.net)