直接内存与 JVM 源码分析

直接内存(堆外内存)

直接内存有一种叫法,堆外内存。

直接内存(堆外内存)指的是 Java 应用程序通过直接方式从操作系统中申请的内存。这个差别与之前的堆、栈、方法区,那些内存都是经过了虚拟化。所以严格来说,这里是指直接内存。

直接内存有哪些?

 使用了 Java 的 Unsafe 类,做了一些本地内存的操作;

 Netty 的直接内存(Direct Memory),底层会调用操作系统的 malloc 函数;

 JNI 或者 JNA 程序,直接操纵了本地内存,比如一些加密库。

JNI 是 Java Native Interface 的缩写,通过使用 Java 本地接口书写程序,可以确保代码在不同的平台上方便移植。

JNA(Java Native Access )提供一组 Java 工具类用于在运行期间动态访问系统本地库(native library:如 Window 的 dll)而不需要编写任何 Native/JNI 代码。

开发人员只要在一个 java 接口中描述目标 native library 的函数与结构,JNA 将自动实现 Java 接口到 native function 的映射。

JNA 是建立在 JNI 技术基础之上的一个 Java 类库,它使您可以方便地使用 java 直接访问动态链接库中的函数。

原来使用 JNI,你必须手工用 C 写一个动态链接库,在 C 语言中映射 Java 的数据类型。

JNA 中,它提供了一个动态的 C 语言编写的转发器,可以自动实现 Java 和 C 的数据类型映射,你不再需要编写 C 动态链接库。

也许这也意味着,使用 JNA 技术比使用 JNI 技术调用动态链接库会有些微的性能损失。但总体影响不大,因为 JNA 也避免了 JNI 的一些平台配置的开销。

代码案例

1、 Unsafe 类,-XX:MaxDirectMemorySize 参数的大小限制对这种是无效的:

2、ByteBuffer 的这种方式,受到 MaxDirectMemorySize 参数的大小限制,其实底层是:

为什么要使用直接内存?

直接内存,其实就是不受 JVM 控制的内存。相比于堆内存有几个优势:

1、减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。

2、加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),然后再发送,而堆外内存相当于省略掉了这个工作。

3、可以在进程间共享,减少 JVM 间的对象复制,使得 JVM 的分割部署更容易实现。

4、可以扩展至更大的内存空间。比如超过 1TB 甚至比主存还大的空间。

直接内存的另一面

直接内存有很多好处,我们还是应该要了解它的缺点:

1、 直接内存难以控制,如果内存泄漏,那么很难排查;

2、 直接内存相对来说,不适合存储很复杂的对象。一般简单的对象比较适合。

直接内存案例和场景分析

内存泄漏案例

工作中经常会使用 Java 的 Zip 函数进行压缩和解压,这种操作在一些对传输性能较高的的场景经常会用到。

程序将会申请 1kb 的随机字符串,然后不停解压。为了避免让操作系统陷入假死状态,我们每次都会判断操作系统内存使用率,在达到 60% 的时候,我们将挂起程序(不在解压,只不断的让线程休眠)通过访问 8888 端口,将会把内存阈值提高到 85%。

程序打包上传到 CenterOS 的服务器中(服务器内存 4G)。

使用以下命令把程序跑起来:

java -cp ref-jvm3.jar -XX:+PrintGC -Xmx1G -Xmn1G -XX:+AlwaysPreTouch -XX:MaxMetaspaceSize=10M -XX:MaxDirectMemorySize=10Mex15.LeakProblem 。

参数解释:

分别使用 Xmx、MaxMetaspaceSize、MaxDirectMemorySize 这三个参数限制了堆、元空间、直接内存的大小。

AlwaysPreTouch 这个参数,在 JVM 启动的时候,就把它所有的内存在操作系统分配了,默认情况下,此选项是禁用的,并且所有页面都在 JVM 堆空间填充时提交。我们为了减少内存动态分配的影响,把这个值设置为 True。

这个程序很快就打印一下显示,这个证明操作系统内存使用率,达到了 60%。

通过 top 命令查看,确实有一个进程占用了很高的内存,

VIRT:virtual memory usage 虚拟内存。

1、 进程“需要的”虚拟内存大小,包括进程使用的库、代码、数据等;

2、假如进程申请 100m 的内存,但实际只使用了 10m,那么它会增长 100m,而不是实际的使用量。

RES:resident memory usage 常驻内存 达到了 1.5G 。

如果申请 100m 的内存,实际使用 10m,它只增长 10m,与 VIRT 相反。

常规排查方式

按照之前的排查方式,如果碰到内存占用过高,我们使用 top 命令来跟踪,然后使用 jmap –heap 来显示:

我们发现这个 3468 的 java 进程,占据的堆空间是比较小的,合计数远远小于 top 命令看到的 1.5G;

我们怀疑是不是虚拟机栈占用过高。于是使用 jstack 命令来看下线程:

发现也就那么 10 来个左右的线程,这块占用的空间肯定也不多。

jmap -histo 3468 | head -20 显示占用内存最多的对象:

发现这个才 20 多 M,没有达到 1.5G;

发现不了,我们前面讲过 MAT,我们把内存 dump 下来,放到 MAT 中进行分析。

发现没什么问题?堆空间也好,其他空间也好,这些都没有说的那么大的内存 1.5G 左右。

使用工具排查

这种情况应该是发生了直接内存泄漏。如果要跟踪本地内存的使用情况,一般需要使用 NMT。

NMT

NativeMemoryTracking,是用来追踪 Native 内存的使用情况。通过在启动参数上加入 -XX:NativeMemoryTracking=detail 就可以启用。使用 jcmd (jdk 自带)命令,就可查看内存分配。

Native Memory Tracking (NMT) 是 Hotspot VM 用来分析 VM 内部内存使用情况的一个功能。我们可以利用 jcmd(jdk 自带)这个工具来访问 NMT 的数据。

NMT 必须先通过 VM 启动参数中打开,不过要注意的是,打开 NMT 会带来 5%-10%的性能损耗。

在服务器上重新运行程序:

java -cp ref-jvm3.jar  -XX:+PrintGC  -Xmx1G  -Xmn1G  -XX:+AlwaysPreTouch  -XX:MaxMetaspaceSize=10M      -XX:MaxDirectMemorySize=10M  -XX:NativeMemoryTracking=detail ex15.LeakProblem 。

jcmd $pid VM.native_memory summary

可惜的是,这个工具一样很烂,看到我们这种泄漏的场景。下面这点小小的空间,是不能和 1~2GB 的内存占用相比的。

其实问题排查到这里,很明显了,这块的问题排查超出了一般 java 程序员的范畴了(说白了就是你做到这点就 OK 了,继续排查就是在干操作系统和其他的语言相关的问题排查了),如果你有时间,有兴趣,我推荐你使用 perf 这个工具,这个工具安装很容易,但是容易遇到操作系统内核一些功能没支持,也分析不了,这里我就不多去花时间去分析:

perf 安装:yum install perf 。

内存泄漏问题解决

主要的是解决问题。

这个程序可以访问服务器的 8888 端口,这将会把内存使用的阈值增加到 85%,我们的程序会逐渐把这部分内存占满:

curl http://127.0.0.1:8888/

特意这么做也是为了方便你的观察,通过内存的增长我们可以大致知道问题的点。

问题关键点

GZIPInputStream 使用 Inflater 申请堆外内存、我们没有调用 close() 方法来主动释放。如果忘记关闭,Inflater 对象的生命会延续到下一次 GC,有一点类似堆内的弱引用。在此过程中,堆外内存会一直增长。

问题修复

调用 close() 方法来主动释放,防止内存泄漏:

直接内存总结

直接内存主要是通过 DirectByteBuffer 申请的内存,可以使用参数“MaxDirectMemorySize”来限制它的大小;

其他堆外内存(直接内存),主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。这种情况下没有任何参数能够阻挡它们,要么靠它自己去释放一些内存,要么等待操作系统对它来处理。

所以如果你对操作系统底层以及内存分配使用不熟悉,最好不要使用这块,尤其是 Unsafe 或者其他 JNI 手段直接直接申请的内存. 那为什么要讲呢,EhCache 这种缓存框架,提供了多种策略,可以设定将数据存储在非堆上。

还有像 RocketMQ 都走了堆外分配,所以我们又必须要去了解他。


JVM 源码分析

使用 SourceInsight 来查看 OpenJDK 源代码了;

如何查看可以见一下文档。工具使用的 SourceInsight:https://cloud.tencent.com/developer/article/1585224 。

堆外内存默认是多大

如果我们没有通过-XX:MaxDirectMemorySize 来指定最大的堆外内存,那么默认的最大堆外内存是多少呢?

一般来说,如果没有显示的设置-XX:MaxDirectMemorySize 参数,通过 ByteBuffer 能够分配的直接内存空间大小就是堆的最大大小。

对应参数-Xmx,真的是这么样吗?

案例分析

1、VM 参数配置:-XX:MaxDirectMemorySize=100m:

2、 VM 参数配置:-XX:MaxDirectMemorySize=128m:

3、 VM 参数配置:-Xmx128m

4、 VM 参数配置:-Xmx135m -Xmn100m -XX:SurvivorRatio=8

5、 VM 参数配置:-Xmx138m -Xmn100m -XX:SurvivorRatio=8

几个案例分析,我们得出以下结论:

没有显示的设置-XX:MaxDirectMemorySize 参数,通过 ByteBuffer 能够分配的直接内存空间大小就是堆的最大大小。

这句话是有毛病的。

毛病在哪里,应该是没有显示的设置-XX:MaxDirectMemorySize 参数,通过 ByteBuffer 能够分配的直接内存空间大小就是堆的最大的可使用的大小。

堆的最大的可使用的大小= 堆的最大值减去一个 Survivor 的大小(预留的空间)。

所以案例 4 为什么会 OOM?

堆的最大的可使用的大小=135-10m=125m ,不能分配 128M 的对象;

所以案例 5 为什么不会 OOM!

堆的最大的可使用的大小=138-10m=128m ,刚好可以分配 128M 的对象。

源码分析

我们从代码出发,依次来找:

看到上面的代码之后不要误以为默认的最大值是 64M?其实不是的。

说到这个值得从 java.lang.System 这个类的初始化说起;

上 面 这 个 方 法 在 jvm 启 动 的 时 候 对 System 这 个 类 做 初 始 化 的 时 候 执 行 的 , 因 此 执 行 时 间 非 常 早 , 我 们 看 到 里 面 调 用 了sun.misc.VM.saveAndRemoveProperties(props):

这个地方是一个 native 方法,这个是一个本地方法,本地方法里面怎么实现的。

这个地方就需要看 JVM 的源码。

像这种本地方法,在 VM 的源码中一般都是会把包名加上,因为是给 java 用的所以前缀上还有一个 java。

大致推算出(其实已经知道了)是 JVM 的这个函数 :Java_java_lang_Runtime_maxMemory 。

这个容量其实就是每一个代的大小相加,比如 YGen+OldGen 之类。

在这里可以看到,新生代的最大值 = 新生代的最大值减去一个 survivor 的大小。

为什么会这样,因为在新生代采用复制回收算法,一个幸存者区域是浪费的,所以实际空间最大大小要减去一个交换器的大小。

而老年代是没有空间浪费的,所以还是全区域。

也得出我们设置的-Xmx 的值里减去一个 survivor 的大小就是默认的堆外内存的大小。

总结

读 JVM 的源码确实可以解决不少问题,但读 JVM 的源码门槛很高(C++的基础),同时 JVM 的源码体系非常大,如果想学源码的话,可以参考今天这个场景,从场景切入(找几个简单的场景),读源码要有目的,这样才能做到有的放矢,才能真正的提高自己的技术水平。

你可能感兴趣的:(直接内存与 JVM 源码分析)