GC垃圾收集时,居然还有用户线程在奔跑

之前面试被问到过"当GC垃圾收集时,是所有的用户线程都停止了吗?",这一篇我们来探究一下这个问题。

其实执行本地代码的线程仍然可以运行,那么这些线程一旦改变了对象中的引用关系或创建了新的对象,这会不会造成GC错误,引发问题呢?

首先举一个例子,证明在GC期间,执行native函数的线程仍然在运行,实例如下:

c 复制代码
#include "include/cn_hotspotvm_TestJNI.h"

#include <jvmti.h>
#include <stdio.h>
#include "pthread.h"

// 垃圾收集开始时回调
static void JNICALL GarbageCollectionStart(jvmtiEnv *jvmti) {}
// 垃圾收集结束时回调
static void JNICALL GarbageCollectionFinish(jvmtiEnv *jvmti) {}

JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
    jvmtiEnv *jvmti = NULL;
    jvmtiCapabilities capabilities = {0};
    jvmtiEventCallbacks callbacks = {0};
    jint result;

    // 1.获取JVMTI环境
    if (vm->GetEnv((void **) &jvmti, JVMTI_VERSION_1_2) != JNI_OK) {
        fprintf(stderr, "Failed to get JVMTI environment\n");
        return JNI_ERR;
    }

    // 2.设置事件回调
    callbacks.GarbageCollectionStart = &GarbageCollectionStart;
    callbacks.GarbageCollectionFinish = &GarbageCollectionFinish;
    if ((result = jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks))) != JVMTI_ERROR_NONE) {
        fprintf(stderr, "SetEventCallbacks failed: %d\n", result);
        return JNI_ERR;
    }

    // 3.启用GC事件通知能力
    capabilities.can_generate_garbage_collection_events = 1;
    if ((result = jvmti->AddCapabilities(&capabilities)) != JVMTI_ERROR_NONE) {
        fprintf(stderr, "AddCapabilities failed: %d\n", result);
        return JNI_ERR;
    }

    // 4.注册事件监听
    if ((result = jvmti->SetEventNotificationMode(JVMTI_ENABLE,
          JVMTI_EVENT_GARBAGE_COLLECTION_START, NULL)) != JVMTI_ERROR_NONE) {
        fprintf(stderr, "Enable GC start failed: %d\n", result);
        return JNI_ERR;
    }
    if ((result = jvmti->SetEventNotificationMode(JVMTI_ENABLE,
           JVMTI_EVENT_GARBAGE_COLLECTION_FINISH, NULL)) != JVMTI_ERROR_NONE) {
        fprintf(stderr, "Enable GC finish failed: %d\n", result);
        return JNI_ERR;
    }

    return JNI_OK;
}

简单编写了一个JVMTIAgent,这个Agent在Java虚拟机启动时通过-agentpath来挂载,在这个Agent中可以写一个native方法的C/C++实现,当垃圾收集开始时执行用户线程的运算,当垃圾收集结束时停止运算并返回,这样就能很好的证明有线程在GC垃圾收集器期间发生GC了。

我们看一下,HotSpot是在什么时候进行回调呢?这主要是使用JvmtiGCMarker类来完成的,在类的构造函数中回调GC开始函数,在析构函数中调用GC结束函数。

scss 复制代码
JvmtiGCMarker::JvmtiGCMarker() {
  if (JvmtiExport::should_post_garbage_collection_start()) {
    JvmtiExport::post_garbage_collection_start();
  }
}

JvmtiGCMarker::~JvmtiGCMarker() {
  if (JvmtiExport::should_post_garbage_collection_finish()) {
    JvmtiExport::post_garbage_collection_finish();
  }
}

void JvmtiExport::post_garbage_collection_start() {
  Thread* thread = Thread::current(); // this event is posted from vm-thread.
  JvmtiEnvIterator it;
  for (JvmtiEnv* env = it.first(); env != NULL; env = it.next(env)) {
    if (env->is_enabled(JVMTI_EVENT_GARBAGE_COLLECTION_START)) {
      JvmtiThreadEventTransition jet(thread);
      jvmtiEventGarbageCollectionStart callback = env->callbacks()->GarbageCollectionStart;
      if (callback != NULL) {
        (*callback)(env->jvmti_external());
      }
    }
  }
}

void JvmtiExport::post_garbage_collection_finish() {
  Thread *thread = Thread::current();
  JvmtiEnvIterator it;
  for (JvmtiEnv* env = it.first(); env != NULL; env = it.next(env)) {
    if (env->is_enabled(JVMTI_EVENT_GARBAGE_COLLECTION_FINISH)) {
      JvmtiThreadEventTransition jet(thread);
      jvmtiEventGarbageCollectionFinish callback = env->callbacks()->GarbageCollectionFinish;
      if (callback != NULL) {
        (*callback)(env->jvmti_external());
      }
    }
  }
}

现在来看一下这个JvmtiGCMarker是如何使用的呢?

arduino 复制代码
VMThread::loop()
  VMThread::evaluate_operation()
    VM_Operation::evaluate()
      VM_ParallelGCFailedAllocation::doit()
        ParallelScavengeHeap::failed_mem_allocate()
          PSScavenge::invoke()
             PSScavenge::invoke_no_policy()
             PSParallelCompact::invoke_no_policy()

在VMThread获取到垃圾收集任务时,YGC会执行PSScavenge::invoke_no_policy(),FGC会执行PSParallelCompact::invoke_no_policy(),无论YGC还是FGC都会由VM_ParallelGCFailedAllocation::doit() 函数调用,在这个函数中有如下代码:

scss 复制代码
// 当执行这个函数时,线程已经进入了安全点
void VM_ParallelGCFailedAllocation::doit() {
  // 在VMThread线程进入函数时,调用SvgGCMarker的构造函数,当函数返回前,调用析构函数
  SvcGCMarker sgcm(SvcGCMarker::MINOR);

  ParallelScavengeHeap* heap = (ParallelScavengeHeap*)Universe::heap();

  GCCauseSetter gccs(heap, _gc_cause);
  _result = heap->failed_mem_allocate(_size);
  // ...
}

这里要注意,VMThread完成GC开始函数和结束函数的回调,并且是在安全点内回调的,按理来说,此时的业务线程已经不再运行了。 

下面我们继续完成实例,如下:

java 复制代码
package cn.hotspotvm;

public class TestJNI {
    public native int inc(int value);

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> {
            try {
                // 等待下面的inc()函数调用
                Thread.sleep(2000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 在inc()函数调用后触发FGC
            System.gc();
        }).start();

        // 传入0,在native函数中会加数值后返回
        int r = new TestJNI().inc(0);
        System.out.println(r);
    }
}

native函数的实现如下:

arduino 复制代码
WaitableMutex mutex; // 互斥锁
static bool volatile isEnd = false;

JNIEXPORT jint JNICALL Java_cn_hotspotvm_TestJNI_inc
        (JNIEnv *env, jobject obj, jint value) {
    mutex.lock();
    mutex.wait();
    while(!isEnd){
        value++;
    }
    mutex.unlock();
    return value;
}

static void JNICALL GarbageCollectionStart(jvmtiEnv *jvmti) {
    mutex.notify();
}

static void JNICALL GarbageCollectionFinish(jvmtiEnv *jvmti) {
    isEnd = true;
}

在开始时,main线程首先执行Java_cn_hotspotvm_TestJNI_inc()函数,导致main()函数在wait()处等待,但是另外一个线程调用了System.gc(),这样VMThread线程就会调用回调函数GarbageCollectionStart()让main()线程开始执行加一的逻辑,在GC结束时停止加1逻辑,并将结果返回。

某一次在我本地机器上运行的结果为3699329,可以看到在GC垃圾回收期间,执行native函数的线程确实在运行。线程交互图如下所示。

这里还有个问题,native线程还在运行,那么如果它操作了Java对象,那不会引起应用程序错误吗?其实native函数原则上并不允许直接操作Java对象,如果要操作,那只能通过JNI来操作,在JNI中定义了许多操作Java对象的方法,举个例子如下:

ini 复制代码
JNIEXPORT jobject JNICALL Java_cn_hotspotvm_TestJNI_createObject(JNIEnv *env, jobject) {
    // 1. 获取jclass
    jclass clazz = env->FindClass("cn/hotspotvm/TestJNI");

    // 2. 获取构造函数ID
    jmethodID constructorId = env->GetMethodID(clazz, "<init>", "()V");

    // 3. 创建对象
    jobject obj = env->NewObject(clazz, constructorId);
    return obj;
}

NewObject()函数的调用由于涉及到了Java对象,所以这个线程在进入HotSpot世界时,如果GC垃圾收集还在继续,当前的线程会阻塞,直到GC完成后唤醒,这样就能继续执行了,所以通过JNI接口来保证线程不会干扰到GC。

在《深入剖析Java虚拟机HotSpot:源码剖析与实例详解》一书中的 执行本地代码线程进入安全点 一小节详细剖析过代码实现,这里简单给一个交互的图示。

调用的NewObject()函数会在GC垃圾收集器期间调用到SafepointSynchronize::block()阻塞,在GC执行完成后继续执行。

不过有时候为了效率,native中还是能直接操作Java对象的,不过在直接操作Java对象前,需要进入临界区才可以。举个例子如下:

java 复制代码
public class TestJNI {
    // 对int数组每个元素+1
    public native void processIntArray(int[] array);
}  

native的C/C++函数实现如下:

ini 复制代码
#include <jni.h>

JNIEXPORT void JNICALL Java_cn_hotspotvm_NativeArrayProcessor_processIntArray(
    JNIEnv *env, jobject obj, jintArray arr) {

    jint *c_array = NULL;
    jboolean isCopy = JNI_FALSE;

    // 1. 进入临界区获取数组指针
    c_array = (jint*) env->GetPrimitiveArrayCritical(arr, &isCopy);
    if (c_array == NULL) {
        return; // 内存不足或JVM不支持时返回NULL
    }

    // 2. 操作数组(临界区内禁止调用其他JNI函数!)
    jsize length = env->GetArrayLength(arr);
    for (int i = 0; i < length; i++) {
        c_array[i] += 1; // 每个元素+1
    }

    // 3. 退出临界区(必须严格配对调用)
    env->ReleasePrimitiveArrayCritical(arr, c_array, 0);
}

在操作Java堆中的基本类型数组时,可通过GetPrimitiveArrayCritical()进入临界区,通过ReleasePrimitiveArrayCritical()退出临界区。在调用GetPrimitiveArrayCritical()函数时返回了一个指针,这个指针不再是句柄,而是直接指向堆中数组首地址的指针,函数的实现如下:

ini 复制代码
JNI_ENTRY(void*, jni_GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy))
  GC_locker::lock_critical(thread);
  if (isCopy != NULL) {
    *isCopy = JNI_FALSE;
  }
  oop a = JNIHandles::resolve_non_null(array);
  BasicType type;
  if (a->is_objArray()) {
    type = T_OBJECT;
  } else {
    type = TypeArrayKlass::cast(a->klass())->element_type();
  }
  void* ret = arrayOop(a)->base(type);
  return ret;
JNI_END

调用GC_locker::lock_critical()函数进入临界区,这里就不多介绍了,后续会详细介绍。  

在如上函数中,最重要的就是调用了JNIHandles::resolve_non_null()函数获取句柄里封装的对象引用,直接返回了这个对象引用。

如果在返回数组首地址时,GC将数组从一个地方移动到另外一个地方,此时在native中操作的数组其实是一个无效数组,这样就会出现错误,为了防止这样的问题,才会有临界区。

当线程进入临界区时,会阻塞GC垃圾收集,当最后一个线程离开时,会触发一个原因为_gc_locker的GC垃圾收集。

临界区是为了让native线程高效操作数组,如果没有临界区,那么我们就需要在做数组操作时,将数组拷贝到C堆上,然后做才行,如果拷贝的数组很大,这会严重影响应用程序效率的。

这里还涉及到了句柄,句柄也是一种设计,也能让native函数可以很好的和GC配合起来,如下所示。

与直接引用比起来,句柄就是一种间接引用,不过将所有引用集中在句柄区就能让GC高效的扫描,native函数通过句柄也能安全操作对象,假设GC将对象Oop1从Eden区移动到了To区,只需要将句柄中封装的引用地址更新为最新地址即可。如下图所示。

 

相关推荐
Bs_MoneyMagnet3 小时前
基于springboot+vue的滑雪场票务与装备租赁系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计
AlienZHOU7 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
野生技术架构师8 小时前
2026 Java 面试全套总结,八股 + 场景 + AI 相关面试考点
java·人工智能·面试
狗头大军之江苏分军9 小时前
《潮水漫过十七岁》开学了
后端
苏三说技术10 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong10 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno70410 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc10 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
aramae11 小时前
模拟实现strlen()函数 (C语言)
c语言·开发语言·后端
kyriewen12 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程