作用
NIO提供了一系列buffer类,用作缓存。可以直接从channel中读数据到buffer,也可以从buffer中写数据到channel。缓冲区本质上是一块固定大小的内存,其作用是一个存储器或运输器。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
类图
Buffer的四个属性
- 容量(capacity):缓冲区能够容纳的数据元素的最大数量。这一容量在缓冲区创建时被设定,并且永远不能被改变
- 上界(limit):缓冲区的第一个不能被读或写的元素。或者说,缓冲区中现存元素的计数
- 位置(position):下一个要被读或写的元素的索引。位置会自动由相应的 get( )和 put( )函数更新
- 标记(mark):下一个要被读或写的元素的索引。位置会自动由相应的 get( )和 put( )函数更新一个备忘位置。调用 mark( )来设定 mark = postion。调用 reset( )设定 position =mark。标记在设定前是未定义的(undefined)。这四个属性之间总是遵循以下关系:0 <= mark <= position <= limit <= capacity
Buffer的基本用法
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用flip()方法
- 从Buffer中读取数据
- 调用clear()方法或者compact()方法
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
举个栗子:
定义一个容量是10的buffer,填入hello:
这时候,limit代表,最多可以写多少,position代表,即将写入的位置
然后进行flip:
flip之后,limit代表最多可以读多少,position,代表开始读的位置。
三种类型buffer
java nio提供了三种不同的buffer,HeapByteBuffer、DirectByteBuffer、MappedByteBuffer。
一些不同:
- HeapByteBuffer是在jvm堆上申请的内存,而DirectByteBuffer、MappedByteBuffer是在堆外申请的内存。
- MappedByteBuffer借助了mmap(内存映射文件),提高了文件读取效率
transferTo:适用于应用程序无需对文件数据进行任何操作的场景;
map:适用于应用程序需要操作文件数据的场景;
HeapByteBuffer
在java堆上申请的内存。通过ByteBuffer.allocate方法申请jvm堆上内存。
看下ByteBuffer.allocate这个方法:
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
直接new了一个HeapByteBuffer对象,下面看下这个构造方法:
HeapByteBuffer(int cap, int lim) { // package-private
super(-1, 0, lim, cap, new byte[cap], 0);
/*
hb = new byte[cap];
offset = 0;
*/
}
构造方法中调用了父类,ByteBuffer的构造方法
super(-1, 0, lim, cap, new byte[cap], 0);
其中new byte[cap],这里就能够确定,HeapByteBuffer申请的内存,确实是在jvm堆上。
DirectByteBuffer
在jvm堆外(OS堆上)申请的内存。通过ByteBuffer的allocateDirect申请。
看下ByteBuffer的allocateDirect方法:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
直接new 了一个DirectByteBuffer对象,看下DirectByteBuffer的构造方法
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
其中申请内存的操作是通过unsafe类进行的
base = unsafe.allocateMemory(size);
Unsafe的allocateMemory方法通过c的malloc方法【底层依赖两个系统调用,一个brk,一个mmap】进行进程堆上内存的分配。下面,我们验证下这个想法:
这里用到了几个涉及到jvm监控和linux内存监控的知识:
- Native Memory Tracking(NMT) jdk7提供的内存工具,跟踪JVM内部的内存使用
- linux的/proc/pid/maps 文件,可以查看进程的虚拟地址空间是如何使用的。
这里不对这两块知识进行再补充,只注重分析内存情况。
测试代码:
public static void main(String[] args) throws Exception {
ByteBuffer b = ByteBuffer.allocateDirect(1024*1024*50);
//反射获取Buffer中 的address属性
Field field = b.getClass().getSuperclass().getSuperclass().getSuperclass().getDeclaredField("address");
//打开私有访问
field.setAccessible(true);
System.out.println(field.get(b).toString());
while (true) {
Thread.sleep(10000);
}
}
代码中申请了50M的堆外内存。而且会打印出native代码申请的内存的地址起始地址。
java命令参数如下:注意下-Xms20m -Xmx100m
java -Djava.rmi.server.hostname=49.234.60.90 -Dcom.sun.management.jmxremote.port=2990 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -XX:NativeMemoryTracking=detail -Xms20m -Xmx100m NonBlockServer
这里打印出了申请的地址的虚拟内存地址起始地址:140260761661472,转换成16进制是,7f9100dff020
下面看下NMT的情况:
着重分析下jvm堆内存情况:
这里,说明java堆内存申请了100M,当前使用的为20M。
下面看具体的堆内存信息:
这里堆内存的虚拟内存地址为: 0x00000000f9c00000 - 0x0000000100000000
下面看下,linux的java进程的内存情况(通过查看/proc/pid/maps 文件):
上文中,我们打印的申请的内存的起始地址为7f9100dff020。
乍一看,maps文件并没有这个起始地址,但是注意这样一个内存块:7f9100dff000-7f9104000000。7f9100dff020刚好在这个区间中。但是7f9100dff000-7f9100dff020这块多申请的内存,不知道是做什么用的。
至此,我们验证了,ByteBuffer的allocateDirect申请的内存并不在jvm堆上,而是在进程堆内存中。
MappedByteBuffer
java对内存文件映射的支持。
内存文件映射的原理:
普通的read io操作原理:
mmap操作的原理:
mmap的核心是,通过使得内核空间和用户空间的虚拟地址映射到同一块物理内存上,进而减少了文件在内核空间和用户空间的拷贝。
下面用一个例子,简单看下,MappedByteBuffer在内存文件映射操作时的java进程内存和jvm内存情况.
public static void main(String[] args) throws Exception {
String path = ModelDubboService.class.getClassLoader().getResource("test.txt").getPath();
File file = new File(path);
FileChannel fileChannel = new FileInputStream(file).getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
//反射获取Buffer中 的address属性
Field field = mappedByteBuffer.getClass().getSuperclass().getSuperclass().getSuperclass().getSuperclass().getDeclaredField("address");
//打开私有访问
field.setAccessible(true);
System.out.println(field.get(mappedByteBuffer).toString());
String path2 = ModelDubboService.class.getClassLoader().getResource("test2.txt").getPath();
FileInputStream fileInputStream = new FileInputStream(file);
byte[] bytes = new byte[1024*2014*40];
fileInputStream.read(bytes);
while(true){
Thread.sleep(10000);
}
}
主要是看下,通过普通的读文件操作和mmap方式读文件有什么区别。test.txt使用内存映射的方式来读,test2.txt使用普通的read方法来读。
java命令参数如下
java -Djava.rmi.server.hostname=49.234.60.90 -Dcom.sun.management.jmxremote.port=2990 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -XX:NativeMemoryTracking=detail -Xms20m -Xmx100m TestMMap
获取打印的内存地址:
地址139907108438016,转换成16进制,7f3ea9800000
下面看下jvm堆内存情况:
jvm最大内存是200M,使用了166M左右。
获取jvm堆内存的虚拟地址:0x00000000f3800000 - 0x0000000100000000
下面看下linux进程的内存(/proc/pid/maps):
为了再验证下,到底是不是通过mmap读取的文件,我们再看下/proc/pid/map_files这个文件:
这里就可以确认,test.txt确实是通过mmap读取的,而text2.txt通过一般的read来读取的,已经从pageCache拷贝到jvm堆上了。
这里总结几个java通过mmap方式相比于普通read方式的有点:
- 不使用jvm堆内存,使用堆外内存,不会对jvm内存造成很大影响
- mmap方式相比read,减少了一次拷贝操作(内核空间->用户空间),速度更加快
同样也有一些需要注意的地方:
- mmap使用的堆外内存的回收问题
- mmap适用于不修改文件的场景,如果需要修改文件,则还是要把文件拷贝到jvm堆内存中去操作