历数 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++,那么能将一个对象的生命周期范围缩小在一个块内,如下:
在 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 堆中真实的数组里。
举个例子如下:
其实在调用 GetFloatArrayElements()时返回的是数组副本。
为了提高性能,我们可以使用临界区,在临界区内不允许发生 GC,这样就不用进行数组副本的拷贝了,如下:
将 GetFloatArrayElements 和 ReleaseFloatArrayElements 换成 GetPrimitiveArrayCritical 和 ReleasePrimitiveArrayCritical 就行了。CriticalArray 则是为了解决数组副本问题,它是通过在 GetPrimitiveArrayCritical 和 ReleasePrimitiveArrayCritical 中创建一个阻止 GC 的临界区,得以将数组的真实数据直接暴露给用户。
CriticalNative 是一种特殊的 JNI 函数,整个函数都是一个临界区(当然,也包括跳过一些非关键的安全检查),能够以牺牲 JVM 整体稳定性获取最大的性能。 由于最初是被设计为 JRE 的加密模块使用,考虑到现在的加密算法大多以块为单位,换句话说大多数情况下需要在 JNI 中频繁传递小规模的数组,CriticalNative 被专门设计对数组的传递进行优化。
JavaCritical 函数相比较之前的版本,能更进一步减少 JNI 调用开销,这是由于它可以跳过一些"多余"的检查,并进入一个禁止 JVM 进行垃圾回收的临界区,以此来获得性能上的提升。
4.2、堆外内存
许多的通信框架都会开辟一块堆外内存来提高效率,如 netty 等。实际上,在网络和磁盘 IO 过程中,如果数据是在 Heap 里的,最终也还是会拷贝一份到堆外,然后再进行发送。原因在于,操作系统把内存中的数据写入磁盘或网络时,要求数据所在的内存区域不能变动,但是 GC 会对内存进行整理,导致数据内存地址发生变化,所以只能先拷贝到堆外内存(不受 GC 影响),然后把这个地址发给操作系统。
在 Java 中有个 DirectByteBuffer,DirectByteBuffer 在创建的时候会通过 Unsafe 的 native 方法直接在 Java 堆外通过 malloc 分配一块内存,然后通过 Unsafe 的 native 方法来操作这块内存。对于需要频繁操作的内存,并且仅仅是临时存在一会的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。
举个例子如下:
调用 FileChannel 的 open()方法会返回一个 FileChannelmpl 实例,这个实例的 read()方法会调用 IOUtil.read()方法,这个方法这是我们上面介绍的方法。
文章转载自:鸠摩(马智)
评论