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区,只需要将句柄中封装的引用地址更新为最新地址即可。如下图所示。

相关推荐
丘山子6 分钟前
一些鲜为人知的 IP 地址怪异写法
前端·后端·tcp/ip
CopyLower30 分钟前
在 Spring Boot 中实现 WebSockets
spring boot·后端·iphone
天天扭码1 小时前
总所周知,JavaScript中有很多函数定义方式,如何“因地制宜”?(ˉ﹃ˉ)
前端·javascript·面试
.生产的驴1 小时前
SpringBoot 封装统一API返回格式对象 标准化开发 请求封装 统一格式处理
java·数据库·spring boot·后端·spring·eclipse·maven
景天科技苑2 小时前
【Rust】Rust中的枚举与模式匹配,原理解析与应用实战
开发语言·后端·rust·match·enum·枚举与模式匹配·rust枚举与模式匹配
追逐时光者2 小时前
MongoDB从入门到实战之Docker快速安装MongoDB
后端·mongodb
天天扭码2 小时前
深入讲解Javascript中的常用数组操作函数
前端·javascript·面试
方圆想当图灵2 小时前
深入理解 AOP:使用 AspectJ 实现对 Maven 依赖中 Jar 包类的织入
后端·maven
豌豆花下猫3 小时前
Python 潮流周刊#99:如何在生产环境中运行 Python?(摘要)
后端·python·ai
渭雨轻尘_学习计算机ing3 小时前
二叉树的最大宽度计算
算法·面试