历数java虚拟机GC的种种缺点

Java通过垃圾收集器(Garbage Collection,简称GC)实现自动内存管理,这样可有效减轻Java应用开发人员的负担,也避免了更多内存泄露的风险。

如果你用过C++等需要手动管理内存的语言,那么你就会体会到GC带来的便利,降低了语言使用的门槛。

不过在我们享受自动内存管理带来的便利时,也不得不关注它带来的一些缺点。Java的垃圾收集器最被人诟病的可能就是STW了,不过除此之外,它还有一些缺点,这一篇我们就列举一下GC的几大缺点。

1、停顿(SWT,stop-the-world)

在垃圾收集时,垃圾收集周期要求所有的应用程序线程停顿,这样是为了避免在垃圾收集时,应用程序代码破坏垃圾收集线程所掌握的堆状态信息。

STW会让所有业务线程暂停执行,等待GC的标记,即使是ZGC以及C4等相对先进的垃圾收集器,仍然在根扫描等阶段避免不了完全STW。这会降低整个业务的吞吐量,因为垃圾收集并不是在做业务相关的事情。STW也会让增加时延,降低响应速度。

如果你的应用程序关注的是时延,那么看看JDK是否支持最新的垃圾收集器,如ZGC 这样的就是主打低延迟的垃圾收集器;如果你的应用程序只关注吞吐量,那就选择Parallel GC,这个垃圾收集器虽然早就存在,但就吞吐量而言,仍然要比其它的收集器有一定的优势。

另外,整个虚拟机是一个系统,而GC也是这个系统的一部分,并不是单独运行,需要和栈、编译器以及线程等交互,线程安全点的检查和写屏障等也会直接影响到程序的效率。

2、占用更多的内存/内存利用率低

最直接的空间浪费就是To Survivor区了,目前许多GC都是采用分代垃圾收集,将整个堆划分为年轻代和老年代,其中年轻代又被划分为Eden、From Survivor和To Survivor区。年轻代多采用的复制算法不允许使用To Survivor区,其大小通常是整个年轻代的1/10。

老年代的空间利用率不是太高,总要有一部分担保空间来保证年轻代GC的顺利执行。

为了实现单独回收年轻代GC,需要将老年代的对象也做为根对象进行扫描,为了加快老年代的扫描速度,需要卡表和偏移表等数据结构进行辅助,这些都需要空间,如卡表通常是512字节需要1个字节的卡表,那么一个2G大小的老年代需要约4MB的卡表,而G1的记忆集需要占用更多的内存记录代际之间的引用关系。

在为堆分配内存空间时,通常会调用mmap()申请和分配,不过Linux采用的是两阶段提交,也就是说首先会申请到虚拟内存空间,当某个地址被访问时才会真正分配到物理空间。目前的JDK中可指定或不指定堆大小,当不指定时可由GC自动调整,不过好像大多数人在使用时仍然会为虚拟机指定堆大小参数,甚至会为了降低延迟配置AlwaysPreTouch等参数,让堆提前申请到所有的物理内存,避免在程序运行时动态分配,影响效率。无论是手动还是自动调整的堆大小,一旦申请到了物理空间后就不会释放,试想一下,如果在流量高峰时,可能申请到了许多的物理内存,而在流量低时内存利用率可能非常低,不过阿里的JDK开发过归还物理内存的特性。从JDK13起,ZGC新增内存归还特性(Uncommit Unused Memory),可将未使用的堆内存归还操作系统,很适用于容器化场。这些措施有利于提高内存使用率。

3、GC发生时间未知

当GC发生时间未知时,Java对象什么时候被回收就不确定,也就是Java的生命周期(存活时间)不确定。垃圾收集发生的时机没有确定性,也不是以固定的频率发生,这也会造成一些浮动垃圾,也就是本来需要回收的对象还在占用空间,不能及时释放也会影响到空间利用率。

我们这里探讨一个与Java生成周期不确定导致Java的finalize特性变成鸡肋的问题。

如果要写C++,那么能将一个对象的生命周期范围缩小在一个块内,如下:

复制代码
class ResoruceMark{
   ResourceMark(){
     // 在构造函数中申请资源,如互斥锁 
  }
  ~ResourceMark(){
    // 在析构函数中释放资源
  }
};

// 在块内使用ResourceMark管理资源
{
  ResourceMark mark; // 申请到资源
  ...
 // mark生命周期已经结束,自动调用构造函数释放资源
}

在Java虚拟机HotSpot中,有各种Mark字符串结尾的类,大多都是如上这样的使用方式,如ResourceMark和HandleMark等。

Java的finalize()机制也尝试提供自动资源管理,可通过重写finalize()方法来释放资源(类似于C++的析构函数),当对象被回收时,自动调用这个finalize()方法释放资源。

在HotSpot VM中,在GC进行可达性分析的时候,如果当前对象是finalize类型的对象(重写了finalize()方法的对象),并且本身不可达,则会被加入到一个ReferenceQueue类型的队列中。而系统在初始化的过程中,会启动一个FinalizerThread类型的守护线程(线程名Finalizer),该线程会不断消费ReferenceQueue中的对象,并执行其finalize()方法。对象在执行finalize()方法后,只是断开了与Finalizer的关联,并不意味着会立即被回收,还是要等待下一次GC时才会被回收,而每个对象的finalize()方法都只会执行一次,不会重复执行。

它的问题在于,这个finalize()方法非常依赖于GC回收动作,GC运行的时间是不确定的,所以finalize()方法什么时候被调用释放其中的资源也是不确定的。假设需要回收的是文件句柄,如果这个finalze()迟迟不发生的话,那么这从某种意义上来说,也算是资源泄漏了,尽早有可以让资源耗尽。所以它并不能安全地实现自动资源管理。

finalize()在后序的版本中已标记过时‌,Java 官方明确建议避免使用(详见 JEP 421)

我们无法预知GC什么时候发生,这也会导致其它非预期的行为出现,例如CMS垃圾收集器发生FullGC,这样的FullGC收集效率低,STW时间长,如果此时有大量的Http请求,可能会在某个时刻有大量超时行为发生。

4、GC移动对象

Java对象在GC后会被移动到其它地方,所以在GC期间不允许操作Java对象,引用这个Java对象的地址在GC后也需要更新。

4.1、临界区

之前写过一篇文章"GC垃圾收集时,居然还有用户线程在奔跑",在GC发生期间,执行本地native的线程还在运行,不过这个线程可能会持有Java对象的间接引用,对对象的操作都需要通过JNI API来完成。

通过JNI API操作数组的方式是使用GetXXXArrayElements和ReleaseXXXArrayElements,不过这样的操作非常影响效率,因为GC会让数组在内存中的位置发生变化,以及直接将Java堆上的内存地址交给用户有些不安全,因此GetXXXArrayElements返回给用户的是一个数组副本,而ReleaseXXXArrayElements则是将副本复制回Java堆中真实的数组里。

举个例子如下:

复制代码
JNIEXPORT void JNICALL Java_cn_hotspotvm_TestArray_mul(
     JNIEnv *env, jclass klass, 
     jfloatArray mat1, jfloatArray mat2)
{
    jboolean isCopyA, isCopyB;
    float *A = env->GetFloatArrayElements(mat1, &isCopyA);
    float *B = env->GetFloatArrayElements(mat2, &isCopyB);
    mult_SSE(A, B);
    // 第3个参数0表示将修改后的数据同步回 Java 数组,并释放本地缓冲区
    env->ReleaseFloatArrayElements(mat1, A, 0);
    // 不将修改同步回 Java 数组,直接释放缓冲区(适用于只读操作)
    env->ReleaseFloatArrayElements(mat2, B, JNI_ABORT);
}

其实在调用GetFloatArrayElements()时返回的是数组副本。

为了提高性能,我们可以使用临界区,在临界区内不允许发生GC,这样就不用进行数组副本的拷贝了,如下:

复制代码
JNIEXPORT void JNICALL Java_cn_hotspotvm_TestArray_mul(
     JNIEnv *env, jclass klass, 
     jfloatArray mat1, jfloatArray mat2)
{

    jboolean isCopyA, isCopyB;
    float *A = static_cast<float*>(env->GetPrimitiveArrayCritical(mat1, &isCopyA));
    float *B = static_cast<float*>(env->GetPrimitiveArrayCritical(mat2, &isCopyB));
    mult_SSE(A, B);
    env->ReleasePrimitiveArrayCritical(mat1, A, 0);
    env->ReleasePrimitiveArrayCritical(mat2, B, JNI_ABORT);
}

将GetFloatArrayElements和ReleaseFloatArrayElements换成GetPrimitiveArrayCritical和ReleasePrimitiveArrayCritical就行了。CriticalArray则是为了解决数组副本问题,它是通过在GetPrimitiveArrayCritical和ReleasePrimitiveArrayCritical中创建一个阻止GC的临界区,得以将数组的真实数据直接暴露给用户。

复制代码
JNIEXPORT void JNICALL JavaCritical_cn_hotspotvm_TestArray_mul( 
 jint length1, jfloat* mat1,
 jint length2, jfloat* mat2)
{
   mult_SSE(mat1, mat2);
}

CriticalNative是一种特殊的JNI函数,整个函数都是一个临界区(当然,也包括跳过一些非关键的安全检查),能够以牺牲JVM整体稳定性获取最大的性能。 由于最初是被设计为JRE的加密模块使用,考虑到现在的加密算法大多以块为单位,换句话说大多数情况下需要在JNI中频繁传递小规模的数组,CriticalNative被专门设计对数组的传递进行优化。

JavaCritical函数相比较之前的版本,能更进一步减少JNI调用开销,这是由于它可以跳过一些"多余"的检查,并进入一个禁止JVM进行垃圾回收的临界区,以此来获得性能上的提升。

4.2、堆外内存

许多的通信框架都会开辟一块堆外内存来提高效率,如netty等。实际上,在网络和磁盘IO过程中,如果数据是在Heap里的,最终也还是会拷贝一份到堆外,然后再进行发送。原因在于,操作系统把内存中的数据写入磁盘或网络时,要求数据所在的内存区域不能变动,但是GC会对内存进行整理,导致数据内存地址发生变化,所以只能先拷贝到堆外内存(不受GC影响),然后把这个地址发给操作系统。

复制代码
源代码位置:openjdk/jdk/src/share/classes/sun/nio/ch/IOUtil.java
 
static int read(FileDescriptor fd, 
    ByteBuffer dst, long position,
    NativeDispatcher nd)
        throws IOException
{
        if (dst.isReadOnly())
            throw new IllegalArgumentException("Read-only buffer");
        // 如果是在堆外内存DirectBuffer时,直接读取内容并返回就可以
        if (dst instanceof DirectBuffer)
            return readIntoNativeBuffer(fd, dst, position, nd);

        // 申请一个临时的DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
        try {
            // 将堆中的内容拷贝到DirectBuffer
            int n = readIntoNativeBuffer(fd, bb, position, nd);
            bb.flip();
            if (n > 0)
                dst.put(bb);
            return n;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(bb);
        }
}

在Java中有个DirectByteBuffer,DirectByteBuffer在创建的时候会通过Unsafe的native方法直接在Java堆外通过malloc分配一块内存,然后通过Unsafe的native方法来操作这块内存。对于需要频繁操作的内存,并且仅仅是临时存在一会的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。

举个例子如下:

复制代码
try (FileChannel channel = FileChannel.open(Paths.get("/tmp/data.txt"), StandardOpenOption.READ)) {
     // 直接缓冲区
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    while (channel.read(buffer) > 0) {
        buffer.flip();
        // 处理数据...
        buffer.clear();
    }
} catch (IOException e) {
    e.printStackTrace();
}

调用FileChannel的open()方法会返回一个FileChannelmpl实例,这个实例的read()方法会调用IOUtil.read()方法,这个方法这是我们上面介绍的方法。

更多文章可访问:JDK源码剖析网