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源码剖析网

历数java虚拟机GC的种种缺点的更多相关文章

  1. Java 虚拟机 - GC 垃圾回收机制分析

    Java 垃圾回收(Garbage Collection,GC) Java支持内存动态分配.垃圾自动回收,而 C++ 不支持.我想这可能也是 为什么 Java 脱胎于 C++ 的一个原因吧. GC 的 ...

  2. 深入理解Java虚拟机 &GC分代年龄

    堆内存 Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象.在 Java 中,堆被划分成两个不同的区域:新生代 ( Young ).老年代 ( Old ).新生代 ( ...

  3. Java 虚拟机 - GC机制

    GC机制的一些总结 https://blog.csdn.net/super_qing_/article/details/85263991 https://blog.csdn.net/yhyr_ycy/ ...

  4. 《java虚拟机》汇总所有关键要点

    一  .java虚拟机底层结构详解 我们知道,一个JVM实例的行为不光是它自己的事,还涉及到它的子系统.存储区域.数据类型和指令这些部分,它们描述了JVM的一个抽象的内部体系结构,其目的不光规定实现J ...

  5. Java虚拟机5:Java垃圾回收(GC)机制详解

    哪些内存需要回收? 哪些内存需要回收是垃圾回收机制第一个要考虑的问题,所谓“要回收的垃圾”无非就是那些不可能再被任何途径使用的对象.那么如何找到这些对象? 1.引用计数法 这个算法的实现是,给对象中添 ...

  6. Java虚拟机之GC

    ⑴背景 Java堆和方法区实现类所需内存是不一样的,每个方法的多分支需要的内存也可能不一样,我们只有在运行期间才能制动创建哪些对象.这部分内存分配与回收都是动态的,而垃圾回收器所关注的就是这些这部分内 ...

  7. 大战Java虚拟机【2】—— GC策略

    前言 前面我们已经知道了Java虚拟机所做的事情就是回收那些不用的垃圾,那些不用的对象.那么问题来了,我们如何知道一个对象我们不需要使用了呢?程序在使用的过程中会不断的创建对象,这些所创建的对象指不定 ...

  8. 深入理解JAVA虚拟机(内存模型+GC算法+JVM调优)

    目录 1.Java虚拟机内存模型 1.1 程序计数器 1.2 Java虚拟机栈 局部变量 1.3 本地方法栈 1.4 Java堆 1.5 方法区(永久区.元空间) 附图 2.JVM内存分配参数 2.1 ...

  9. 转 Java虚拟机5:Java垃圾回收(GC)机制详解

    转 Java虚拟机5:Java垃圾回收(GC)机制详解 Java虚拟机5:Java垃圾回收(GC)机制详解 哪些内存需要回收? 哪些内存需要回收是垃圾回收机制第一个要考虑的问题,所谓“要回收的垃圾”无 ...

  10. Java虚拟机(二):Java GC算法 垃圾收集器

    概述 垃圾收集 Garbage Collection 通常被称为“GC”,它诞生于1960年 MIT 的 Lisp 语言,经过半个多世纪,目前已经十分成熟了. jvm 中,程序计数器.虚拟机栈.本地方 ...

随机推荐

  1. Python - [04] 面试题汇总

    题记部分 001 || Python的特点和优点 Python可以作为编程的入门语言,因为他具有以下特质: (1)解释型 (2)动态特性 (3)面向对象 (4)语法简洁 (5)开源 (6)丰富的社区资 ...

  2. Linux - centos6忘记root密码怎么办?

    Linux的root密码修改不像Windows的密码修改找回,Windows的登录密码忘记需要介入工具进行解决.CentOS6和CentOS7的密码方法也是不一样的,具体如下 1.开机按esc   2 ...

  3. centos 运行springboot 项目

    jar文件发布: 准备工作: 发布在springboot项目中的pom.xml文件添加如下: <build> <plugins> <plugin> <grou ...

  4. 在Linux系统中下载`gcc-linaro-7.2.1-2017.11-x86_64_aarch64-linux-gnu`工具链

    要在Linux系统中下载gcc-linaro-7.2.1-2017.11-x86_64_aarch64-linux-gnu工具链,你可以按照以下步骤进行操作: 点击查看代码 1. **打开终端**:你 ...

  5. Ubuntu 22.04 添加 AppImage 到应用程序

    前言 AppImage 逐渐成为 Linux 常用的一种软件包格式,本文将介绍如何将 AppImage 文件添加到 Ubuntu 的应用程序中. 如下图中的 CAJViewer : 操作过程 设置相关 ...

  6. 启动本地node服务器报错: Access denied for user ‘root‘@‘localhost‘ (using password: YES)

    背景:今天启动node服务时直接报错,顿时一激灵,之前(几个月前哈哈)明明好好的.主要问题就是在连接数据库上,我登上mysql瞅瞅有没有问题,当要输入密码时,emmm, 很好, 忘记root密码了,于 ...

  7. MySQL查询建表规范

    因为之前一直再查找一些比较好的数据库规范,以方便在开发时连接 MySQL 进行查询/建表的时候,能根据规范来执行,达到提高 查询速度 / 执行 SQL 的性能 和提升 MySQL 的整体性能, 这里主 ...

  8. elmentui input number 数字验证

    问题 需求是文本框只能输入数字.解决方案:使用正则 ,如下使用了 element-ui el-input 组件 整数 文本框只能输入整数 <el-input v-model='count' on ...

  9. 编写你的第一个 Django 应用程序,第5部分

    本教程从教程 4 停止的地方开始.我们已经构建了一个网络投票应用程序,现在我们将为其创建一些自动化测试. 一.自动化测试简介 1.什么是自动化测试? 测试是检查代码操作的例程. 测试在不同级别运行.一 ...

  10. bug|jest|vue|记录:关于【4-4 使用 TDD 的方式开发 Header 组件(1)】05:26时的运行测试用例出错的问题

    错误情景 提示 jest 配置错误 Configuration error Configuration error: Could not locate module @/components/Hell ...