什么是直接内存(堆外内存)
直接内存有一种叫法,堆外内存。
直接内存(堆外内存)指的是Java应用程序通过直接方式从操作系统中申请的内存。这个的差别与之前的堆,栈,方法区不同。那些内存都是经过了虚拟化的内存。
哪些代码可以操作直接内存?
Unsafe类
使用Java的Unsafe类,做一些内地内存的操作。
Netty操作直接内存
(Direct Memory),底层会调用操作系统的 malloc 函数。
JNI和JNA程序
有过不同语言间通信经历的一般都知道,它允许Java代码和其他语言(尤其C/C++)写的代码进行交互,只要遵守调用约定即可。首先看下JNI调用C/C++的过程,注意写程序时自下而上,调用时自上而下。
步骤非常的多,很麻烦,使用JNI调用.dll/.so共享库都能体会到这个痛苦的过程。如果已有一个编译好的.dll/.so文件使用JNI技 术调用,首先需要使用C语言另外写一个.dll/.so共享库,使用SUN规定的数据结构替代C语言的数据结构,调用已有的 dll/so中公布的函数。然后再在Java中载入这个库dll/so,最后编写Java native函数作为链接库中函数的代理。经过这些繁琐的步骤才能在Java中调用 本地代码。因此,很少有Java程序员愿意编写调用dll/.so库中原生函数的java程序。这也使Java语言在客户端上乏善可陈,可以说JNI是 Java的一大弱点!
那么JNA是什么呢?
JNA(Java Native Access)是一个开源的Java框架,是Sun公司推出的一种调用本地方法的技术,是建立在经典的JNI基础之上的一个框架。之所以说它是JNI的替代者,是因为JNA大大简化了调用本地方法的过程,使用很方便,基本上不需要脱离Java环境就可以完成。
可以看到步骤减少了很多,最重要的是我们不需要重写我们的动态链接库文件,而是有直接调用的API,大大简化了我们的工作量。
JNA只需要我们写Java代码而不用写JNI或本地代码。
代码案例
Unsafe类
Unsafe 类,-XX:MaxDirectMemorySize 参数的大小限制对这种是无效的
由上述案例,我们申请100M的内存,但是-XX:MaxDirectMemorySize设置为10M。并没有抛出OOM。
ByteBuffer类
ByteBuffer 的这种方式,受到 MaxDirectMemorySize 参数的大小限制
其底层是:
为什么要使用直接内存
直接内存,其实就是不受 JVM 控制的内存。相比于堆内存有几个优势:
- 减少垃圾回收,因为垃圾回收会STW
- 加快了复制速度。当我们进行IO时,会复制一份数据到堆外内存再发送。直接使用直接内存省略了这一步。
- 可以实现进程数据共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现。
- 可以扩展内存。
直接内存的另一面
说白了就是缺点:
- 难以排查的OOM。
- 不适合存储复杂对象,一般来说简单对象比较适合。
直接内存案例和场景分析
内存泄漏案例
工作中经常会使用 Java 的 Zip 函数进行压缩和解压,这种操作在一些对传输性能较高的的场景经常会用到。
程序将会申请 1kb 的随机字符串,然后不停解压。为了避免让操作系统陷入假死状态,我们每次都会判断操作系统内存使用率,在达到 60% 的时候,
我们将挂起程序(不再解压,只不断的让线程休眠)
通过访问 8888 端口,将会把内存阈值提高到 85%。
程序打包上传到 CenterOS 的服务器中(服务器内存 4G)
参数解释:
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 来显示
这么一看,堆内存的使用率都很低。于是我们怀疑是不是虚拟机栈占用过高。于是使用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 了,继续排查就是在干操作系统和其他的语言相关的问题排查了)
内存泄漏问题解决
主要的是解决问题。
这个程序可以访问服务器的 8888 端口,这将会把内存使用的阈值增加到 85%,我们的程序会逐渐把这部分内存占满
访问地址:http://127.0.0.1:8888/
可以看到内存再次增长。运行时数据区的内存一般变化不大。再次帮助我们定位问题。
问题关键点
GZIPInputStream 使用 Inflater 申请堆外内存、我们没有调用 close() 方法来主动释放。如果忘记关闭,Inflater 对象的生命会延续到下一次 GC,有一点类似堆内的弱引用。在此过程中,堆外内存会一直增长。
问题修复
调用 close() 方法来主动释放,放置内存泄漏
原代码:
修改后:
此时内存不会持续正常,得到解决。
直接内存总结
直接内存主要是通过 DirectByteBuffer 申请的内存,可以使用参数“MaxDirectMemorySize”来限制它的大小
其他堆外内存(直接内存),主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。这种情况下没有任何参数能够阻挡它们,要么靠它自己去释放一些内存,要么等待操作系统对它来处理。所以如果你对操作系统底层以及内存分配使用不熟悉,最好不要使用这块,尤其是 Unsafe 或者其他 JNI 手段直接直接申请的内存。
在未来学习EhCache 这种缓存框架,提供了多种策略,可以设定将数据存储在非堆上。还有像 RocketMQ 都走了堆外分配,所以我们也需要去了解他。