// DirectMemory.java
package com.infuq.memory;
import org.jctools.util.UnsafeAccess;
import sun.misc.Unsafe;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import java.util.Scanner;
public class DirectMemory {
public static void main(String[] args) throws Exception {
Scanner scanner = new Scanner(System.in);
long _30M = 30 * 1024 * 1024;
long direct = 0;
Unsafe unsafe = UnsafeAccess.UNSAFE;
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals("1")) {
System.out.println("malloc...");
// 向操作系统申请内存,底层调用glibc库的malloc库函数
direct = unsafe.allocateMemory(_30M);
}
else if (input.equals("2")) {
System.out.println("init...");
byte b = 6;
// 使用上一步向操作系统申请的内存
unsafe.setMemory(direct, _30M, b);
}
}
}
}
上面这个程序的功能, 执行之后, 等待用户输入, 如果输入1,那么程序会向操作系统申请30M的内存, 如果输入2, 那么程序会初始化申请的30M内存.
这里说的初始化的言外之意是模拟程序使用向操作系统申请的内存
程序运行之后, 我们会通过使用JDK自带的jconsole(或jvisualvm)工具查看进程内存情况, 使用top,ps等命令查看进程内存情况, 使用JDK自带的jcmd命令查看进程内存情况, 使用pmap命令查看进程内存情况, 使用阿里云的arms查看进程的内存情况, 使用smem工具查看进程的内存情况. 从多维度查看内存情况 .
本次实验的环境: JDK1.8 Win10下的WSL2的Ubuntu20
还会使用2个三方包: jctools-core-2.1.2.jar jol-core-0.9.jar
文件结构如下图
run.py中的内容如下
其实就是调用了 javac 和 java 命令而已,
设置堆空间50M, -XX:MaxMetaspaceSize=16M
访问 https://www.selenic.com/smem/download/ 下载一个smem工具, 可以用于查看进程的内存
解压下载的 smem-1.4.tar.gz
最后我们的目录结构如下
运行程序
运行之后, 程序阻塞, 等待用户的输入
使用 jps 查看进程的PID = 15933
我们先使用 smem 工具查看下内存, 如下图
./smem -t -k
以上输出当前系统所有进程的内存情况, 由于我实验使用的是Win10的WSL系统, 所以系统里的进程很少. 能够看出进程15933使用的内存, USS=27.8M, PSS=28M, RSS=30.3M
USS,PSS,RSS都是表示进程实际使用的内存. 更多关于USS,PSS,RSS关系和区别, 读者自行了解.
我们经常听到RSS/RES, 在使用top和ps命令的时候会看到, 如下图
如上图, 使用 top 和 ps 查看进程15933的RSS/RES = 54552KB, 即53.27M, 约等于使用 smem 工具查看的RSS=54.2M内存.
RSS是常驻于内存的内存, RSS中还会包含与其他进程一起共享的内存.
我们使用如下shell命令可以每隔2秒打印进程15933的实际使用的内存情况
i=0;while true; do echo $((i++)) $(./smem -t -k | tail -3 | head -1); sleep 2; done
我们还会使用如下shell命令每隔2秒打印进程15933的committed内存
i=0;while true; do echo $((i++)) $(pmap -d 15933 | tail -1); sleep 2; done
关于reserved(预留内存), committed(提交内存), used(已使用内存)的关系如下图, 更详细内容读者自行了解
比如我们向操作系统申请30M的内存, 则committed=30M. 但是操作系统并不会马上将真实的30M内存全部分配给进程, 只会先分配一小部分真实内存给进程使用, 当再次需要真实内存的时候再次分配. 因此一个进程的committed内存一定大于等于used的内存.
好了, 我们把上面两个shell命令运行起来
然后我们捕捉某一时刻的内存情况如下图
进程15933当前时刻实际使用的内存54.2M, 虚拟内存1641572K=1603M, committed内存114552K=111.86M
接下来输入1,那么我们的程序会向操作系统申请30M的内存
如上图, 我们向操作系统申请了30M的内存, 而进程的已使用内存并没有变化, 但是进程commited内存从114552K->145276K, 相差30724K=30M.
我们继续再输入1, 结果如下图
如上图, 继续向操作系统申请30M内存, 进程已使用的内存也没有变化, 而进程committed内存又从145276增长到176000K, 又相差了30M.
我们不做任何操作, 时间过去了一会...
进程的内存如下图所示
在这一段时间我们并没有任何操作, 内存有了一些小变化, 这很正常, 毕竟JVM进程里面还有一些JVM自身的线程也要随着程序的运行需要申请一些内存, 后面我们使用 jconsole 连接到进程, 内存也会发生一些增长, 这都是正常情况.
我们使用JDK自带的 jcmd 命令查看内存
committed=173226KB 与上图使用pmap显示的176000KB有一些差. 毕竟它们是两个不同的命令, 统计的角度不一样.
pmap 命令统计的会比 jcmd统计的更准确. 查看man手册, pmap统计的是进程自身的smaps文件
接下来
如上图, 重点需要关注Heap和Internal内存的情况
我们使用JDK自带的 jconsole 工具查看内存
上图查看的是堆空间的内存情况, committed=49152KB, 与使用 jcmd 命令查看的51200KB有一些差, 可以忽略, 毕竟是2个不同的工具统计的. 上图同时也说明了, 虽然向操作系统申请了50M的堆空间, 但是目前实际使用了Used=10578KB, 此时操作系统也只是把部分真实内存分配给进程, 只有随着进程的运行需要的内存越多, 操作系统才会分配更多的真实内存给进程, 当分配的真实内存一旦超过committed时, 也就会报OOM了.
我们再次捕捉某一时刻的内存情况
接下来我们输入2, 我们写的程序就会使用申请到的内存
如上图, 当我们真正使用内存的时候, committed(374664KB)内存没有发生变化, 而使用内存发生了变化, 增大了30M, 和我们之前申请的30M是一致的.
这个时候我们看一下通过 jconsole 统计的非堆内存的情况
我们继续输入1, 再申请30M内存, 再输入2, 使用申请的内存,
看一下内存的变化
和之前的实验一样, 当输入1申请内存时, committed内存发生了变化, 已经使用内存没有发生变化
输入2之后
committed内存没有发生变化, 已使用内存增长了30M
而且我们再次看一下非堆内存, 与之前的统计几乎一样, 没变化.
我们所说的非堆内存包括Metaspace, CodeCache, CCS, 使用ByteBuffer.allocateDirect(),使用unsafe.allocateMemory(), 使用FileChannel.map(), 使用FileChannel.transferTo()等申请的内存.其中重点要说的是ByteBuffer.allocateDirect()和unsafe.allocateMemory().虽然都是申请的直接内存, 也就是操作系统本地内存, 但是 jconsole 只能统计到使用ByteBuffer.allocateDirect()申请的直接内存, 它是无法统计到使用unsafe.allocateMemory()申请的直接内存. 我们使用的-XX:MaxMetaspaceSize也是控制ByteBuffer.allocateDirect()申请的直接内存大小, 无法控制unsafe.allocateMemory()申请的直接内存大小.比如我们使用的Dubbo, RocketMQ等底层网络通信都是使用Netty, Netty就是通过unsafe.allocateMemory()向操作系统申请内存并自己管理这块内存, Netty也会自己管理向操作系统申请内存的空间大小, 毕竟不能无限制向操作系统申请内存.
FileChannel.map() 和 FileChannel.transferTo() 涉及到零拷贝知识, 读者朋友可以去了解下, 在我的 https://www.yuque.com/infuq/others/miqbcc 文章也有记录
如果读者朋友所在公司的服务器部署在阿里云上, 通过阿里云的arms监控平台查看服务器的内存情况
上图右下角的直接缓冲区与 jconsole 统计的直接内存一样, 它们都无法统计到使用unsafe.allocateMemory()申请的内存.
如果要查看堆内存的使用情况, 可以使用 jconsole 或者 arms 查看堆内存的情况, 它们的统计没问题.
如果要查看直接内存的情况, 或者查看进程的内存情况, 仅仅使用 jconsole 或者 arms 是不完全的, 看到的内存是比实际要少的.
上图并非此次实验程序的内存统计, 我是从线上找的一个服务器
接下来
当我一直输入1, 也就是一直向操作系统申请内存, 只能表明进程的committed内存一直在增长
而且我的宿主机Win10的内存也不会随着committed内存增长而增长
接下来我们输入2, 让进程使用申请到的内存
进程已使用的内存到了1.5G
宿主机的内存也从之前的6.6增长到了7.1G, 进程已使用内存也从828M增长到了1.5G, 两者增长量基本吻合的.
【总结1】
通过实验, 零零散散介绍了如何查看进程的内存, 包括committed内存, 已使用内存等. 进程使用unsafe.allocateMemory()申请内存只是属于committed内存, 只有在进程真正使用这块内存的时候, 操作系统才会一部分一部分的将真实的内存分配给进程使用. 通过实验也能知道, 使用unsafe.allocateMemory()方式申请内存是不受-XX:MaxMetaspaceSize参数控制的, 实验中设置-XX:MaxMetaspaceSize=16M , 但是我们程序已经申请使用了好几百M的内存.
【总结2】
1.如果要查看进程的committed内存, 使用pmap -d <进程ID>查看
2.如果要查看进程已使用的内存(USS,PSS,RSS), 使用smem工具查看, 使用top和ps命令也可以查看到RSS值, 但这个RSS值包含共享的内存, 因此我们也要关注PSS,USS
3.如果要查看JVM的直接内存, 可以使用 jcmd <进程ID> VM.native_memory scale=KB
4.当使用unsafe.allocateMemory(30M)申请内存的时候, committed内存会增长30M, 但是已使用内存不会增长
5.当程序使用unsafe.allocateMemory(30M)申请到的内存时, 已使用内存会增长
6.jconsole , jvisualvm, 阿里云arms 是监控不到unsafe.allocateMemory()方式申请的内存
关于如何监控远程Java进程可以查看我的这篇语雀文章
https://www.yuque.com/infuq/default/wwmdfk#rJSoP
关于JVM内存的布局图可以在下面这篇语雀文章中查找到
https://www.yuque.com/infuq/default/bzu9ef
再贴一张图