NIO和传统IO有什么区别
前言
最近在看RocketMQ的源码,发现RocketMQ在读写文件的时候使用的是Java NIO类库下的类,如MappedByteBuffer
等。
当我们使用传统的Java IO读写文件时,可能会用到节点流FileInputStream/FileOutputStream
或者是处理流BufferedInputStream/BufferedOutputStream
,为什么RocketMQ不直接用这些流来读写文件呢?因此在这篇文章中会分析传统IO的读写方式和NIO的读写方式有什么不同。
一、传统IO
当我们使用Java传统IO读写文件时,比如使用FileInputStream
来从磁盘中读一个文件,使用方式如下:
@Test
public void testFileInputStream() {
FileInputStream fis = null;
try {
// 要读的文件
File file = new File("hello.txt");
// 新建一个字节流
fis = new FileInputStream(file);
// 读数据
byte[] buffer = new byte[1024];
int len;// 记录每次读取的字节的个数
while((len = fis.read(buffer)) != -1){
String str = new String(buffer,0,len);
System.out.print(str);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if(fis != null){
// 关闭资源
try {
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
下面从源码来看一下FileInputStream
是怎么从磁盘中读取到数据的。
打开文件
先了解一个概念,文件描述符(File Descriptor, fd)
。《Operating Systems- Three Easy Pieces》有一句话
A file descriptor is just an integer, private per process, and is used in UNIX systems to access files;
在linux中,一切都是文件,fd可以表示打开的文件、socket、其他字节源或接收端(如:键盘,控制台)的句柄。如果熟悉操作系统应该知道,文件在底层有另一个名字,也就是它的inode number
,如果知道一个文件的inode number,就可以在磁盘上找到该文件,而通过fd就可以找到文件的inode numer。这里只简单提一下fd,关于细节大家可以查询相关资料。现在就只要知道,fd相当于一个文件的指针,应用程序可以通过fd和文件进行交互。
在FileInputStream
中就有一个成员变量
/* File Descriptor - handle to the open file */
private final FileDescriptor fd;
下面看一下打开文件的Java调用链,过程如下
序号 | 类 | 方法 |
---|---|---|
1 | public FileInputStream(File file) | |
2 | private void open(String name) | |
3 | private native void open0(String name) |
主要看第三步,是一个native方法,该方法是用c实现的,要看到该源码,可以从openjdk官网下载jdk源码,解压后可以从/openjdk/jdk/src/share/native文件夹下看到与native方法相关的c程序代码。
FileInputStream.c
JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
// fis_fd为JAVA的FileInputStream对象的成员变量fd
fileOpen(env, this, path, fis_fd, O_RDONLY);
}
io_util_md.c # fileOpen()
void fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags)
{
WITH_PLATFORM_STRING(env, path, ps) {
FD fd;
#if defined(__linux__) || defined(_ALLBSD_SOURCE)
/* Remove trailing slashes, since the kernel won't */
char *p = (char *)ps + strlen(ps) - 1;
while ((p > ps) && (*p == '/'))
*p-- = '\0';
#endif
// 打开文件并返回fd
fd = handleOpen(ps, flags, 0666);
if (fd != -1) {
// 将上面得到的fd值设置到FileInputStream对象的fd成员变量中
SET_FD(this, fd, fid);
} else {
throwFileNotFoundException(env, path);
}
} END_PLATFORM_STRING(env, ps);
}
io_util_md.c # handleOpen()
FD
handleOpen(const char *path, int oflag, int mode) {
FD fd;
// 调用Linux open64获取fd
RESTARTABLE(open64(path, oflag, mode), fd);
// 此处代码省略
return fd;
}
小结:简单来说当我们new一个FileInputStream
,就会调用系统调用open()
,并将得到的fd保存在FileInputStream
的成员变量fd中,后续通过该fd值与文件交互。
从文件中读数据
再回顾一下刚才读数据的代码:
byte[] buffer = new byte[1024];
int len;// 记录每次读取的字节的个数
while((len = fis.read(buffer)) != -1){
String str = new String(buffer,0,len);
System.out.print(str);
}
JAVA调用链
序号 | 方法 |
---|---|
1 | public int read(byte b[]) |
2 | private native int readBytes(byte b[], int off, int len) |
下面看一下native方法readBytes
FileInputStream.c
JNIEXPORT jint JNICALL
Java_java_io_FileInputStream_readBytes(JNIEnv *env, jobject this,
jbyteArray bytes, jint off, jint len) {
return readBytes(env, this, bytes, off, len, fis_fd);
}
io_utils.c # readBytes
#define BUF_SIZE 8192
jint
readBytes(JNIEnv *env, jobject this, jbyteArray bytes,
jint off, jint len, jfieldID fid)
{
jint nread;
char stackBuf[BUF_SIZE];// 声明一个静态数组,默认为8k
char *buf = NULL;
FD fd;
// 检查bytes是否为空,bytes是上面java代码创建的数组,readBytes会将读取到的数据
// 转存到bytes中,因此bytes不能为空
if (IS_NULL(bytes)) {
JNU_ThrowNullPointerException(env, NULL);
return -1;
}
if (outOfBounds(env, off, len, bytes)) {
JNU_ThrowByName(env, "java/lang/IndexOutOfBoundsException", NULL);
return -1;
}
if (len == 0) {
return 0;
} else if (len > BUF_SIZE) { // 如果要读的字节数大于默认的8k,则重新申请一个更大的空间
// 申请空间
buf = malloc(len);
if (buf == NULL) {
JNU_ThrowOutOfMemoryError(env, NULL);
return 0;
}
} else {
// 否则buf就是默认分配的8k大小的数组
buf = stackBuf;
}
// 获取FileInputStream对象的fd成员变量,上面打开文件的时候就设置好了
fd = GET_FD(this, fid);
if (fd == -1) {
JNU_ThrowIOException(env, "Stream Closed");
nread = -1;
} else {
// IO_Read调用了系统调用read(后面具体分析)
// 通过fd从文件中读取len字节个数据并存到buf中
nread = IO_Read(fd, buf, len);
if (nread > 0) {
// 把buf中的数据再转存到java代码中的bytes数组内,此时数据才到java堆内
(*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);
} else if (nread == -1) {
JNU_ThrowIOExceptionWithLastError(env, "Read error");
} else { /* EOF */
nread = -1;
}
}
// 如果是动态申请的空间要释放
if (buf != stackBuf) {
free(buf);
}
// 返回读到的字节数
return nread;
}
在io_util_md.h
中定义了宏
#define IO_Read handleRead
因此找handleRead()方法
io_util_md.c # handleRead()
ssize_t
handleRead(FD fd, void *buf, jint len)
{
ssize_t result;
// 调用系统调用read,读取len个字节到buf中
RESTARTABLE(read(fd, buf, len), result);
// 返回读取到的字节数
return result;
}
关于系统调用,这是内核的事了,这里不再深入下去。只要知道read
系统调用在内核中还会用到一个内核缓冲区。磁盘控制器会先通过DMA将数据读到内核缓冲区中。因此使用Java传统IO——FileInputStream读取文件的整个流程如下图:
因此使用FileInputStream
读取文件的过程中涉及到三次文件拷贝
- 将数据从磁盘拷贝到内核缓冲区,由DMA完成
- 将数据从内核缓冲区拷贝到用户空间的临时缓冲区stackBuf,CPU完成
- 将数据从临时缓冲区stackBuf拷贝到堆内的bytes数组,CPU完成
至于BufferedInputStream
,我们在使用的时候会先传入一个FileInputStream
File file = new File("test.txt");
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
可见BufferedInputStream
是对FileInputStream
的一层封装,提供了一些额外的功能,比如内部提供了一个大小为8K的buffer,但是其从文件读数据的时候,本质上还是使用传进来的FileInputStream
来读的,详细过程大家可以自行查阅相关资料。
上面介绍了读文件的过程,写入过程大家反过来思考一下就知道了。
二、Java New IO
在JDK1.4中JAVA引入了一个新的IO类库,包名为java.nio。一些核心的抽象类有Buffer
,Channel
,Charset
,Selector
。
Charset
负责字节和字符之间的转换,Selector
可以支持多路复用以及非阻塞IO,在网络编程中经常使用,有兴趣大家自行查阅资料。下面具体来看Channel
和Buffer
。
在NIO中,Channel
分为
FileChannel
:处理本地文件SocketChannel
:TCP网络编程的客户端的ChannelServerSocketChannel
:TCP网络编程的服务器端的ChannelDatagramChannel
:UDP网络编程中发送端和接收端的Channel
现在要研究的是读写本地文件,因此看一下什么是FileChannel
。官方对Channel
的定义是:
A channel represents an open connection to an entity such as a hardware device, a file, a network socket, or a program component that is capable of performing one or more distinct I/O operations, for example reading or writing.
《Thinking in Java》中写道:
通道和缓冲器。我们可以把它想象成一个煤矿,通道是一个包含煤层(数据)的矿藏,而缓冲器则是派送到矿藏的卡车。卡车载满煤炭而归,我们再从卡车上获得煤炭。也就是说,我们并没有和通道直接交互,我们只是和缓冲器交互,并把缓冲器派送到通道。通道要么从缓冲器获得数据,要么向缓冲器发送数据。
2.1 使用HeapByteBuffer源码解析
先看一下如何使用通道和缓冲器来读一个txt文件。关于ByteBuffer的使用(比如内部的position和limit),可以看这篇——java.nio.ByteBuffer用法小结
public void testFileChannel() throws IOException {
File file = new File("test.txt");
// 1.通过FileInputStream获取通道
FileChannel channel = new FileInputStream(file).getChannel();
// 2.为ByteBuffer分配空间
ByteBuffer buffer = ByteBuffer.allocate(16);
// 3.将数据读到buffer中
channel.read(buffer);
// 修改ByteBuffer的position和limit的位置
buffer.flip();
// 读数据
while (buffer.hasRemaining()){
System.out.print((char)buffer.get());
}
// 关闭通道
channel.close();
}
接下来分析一下FileChannel
和ByteBuffer
。
先看第1步,调用了FileInputStream.getChannel()
方法获取到FileChannel
。
Java方法调用链
序号 | 类 | 方法 |
---|---|---|
1 | FileInputStream | public FileChannel getChannel() |
2 | FileChannelImpl | public static FileChannel open(FileDescriptor var0, String var1, boolean var2, boolean var3, Object var4) |
3 | FileChannelImpl | private FileChannelImpl(FileDescriptor var1, String var2, boolean var3, boolean var4, boolean var5, Object var6) |
首先FileChannel
是一个抽象类,它的实现类为FileChannelImpl
,因此getChannel()
方法返回的是一个FileChannelImpl
。在第2步的open()
方法仅仅是调用FileChannelImpl
的构造函数,也就是第3步,代码如下
private FileChannelImpl(FileDescriptor var1, String var2, boolean var3, boolean var4, boolean var5, Object var6) {
this.fd = var1; // 就是FileInputStream传递进来的fd成员变量
this.readable = var3;
this.writable = var4;
this.append = var5;
this.parent = var6;// FileInputStream把自己传进来
this.path = var2;
this.nd = new FileDispatcherImpl(var5);
}
创建FileChannel
的过程就是初始化其内部的成员变量,关键就是要获取到文件的fd。
其他获取FileChannel
的方法
- 通过
FileChannel.open()
方法 - 通过
RandomAccessFile.getChannel()
方法
再看第二步,为ByteBuffer
分配空间。ByteBuffer
是一个抽象类,当我们调用ByteBuffer.allocate()
方法时,创建的是一个HeapByteBuffer
,看名字就可以知道这是一个堆内缓冲区,实际上还可以通过ByteBuffer.allocateDirect()
方法来分配一个堆外的缓冲区DirectByteBuffer
,这个后面再讲。先看这个HeapByteBuffer
。
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
HeapByteBuffer
继承自ByteBuffer
,内部维护了一个字节数组。(当然还维护了一些其他信息)
protected final byte[] hb;
hb的大小就是我们调用ByteBuffer.allocate()
时指定的参数。
接下来就是第3步FileChannel把数据读到HeapByteBuffer
的hb中,这个方法的调用链稍微有点长。大家可以对照着下面的源代码来看这个调用链。
序号 | 类 | 方法 | 备注 |
---|---|---|---|
1 | FileChannelImpl | public int read(ByteBuffer var1) | 把数据读到HeapByteBuffer,后面步骤都是为了这一步服务 |
2 | IOUtil | static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) | 有4点,创建临时堆外缓冲区、把数据从内核缓冲区读到临时堆外缓冲区、把临时堆外缓冲区数据读到HeapByteBuffer、回收临时堆外缓冲区,对于应步骤3,7,9,13 |
3 | Util | public static ByteBuffer getTemporaryDirectBuffer(int var0) | 创建临时堆外缓冲区,使用了步骤4,5,6 |
4 | ByteBuffer | public static ByteBuffer allocateDirect(int capacity) -> DirectByteBuffer(int cap) | |
5 | Unsafe | public native long allocateMemory(long var1) | 本地方法分配堆空间 |
6 | Unsafe | public native void setMemory(Object var1, long var2, long var4, byte var6) | 临时空间内容初始化为0值 |
7 | IOUtil | private static int readIntoNativeBuffer(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) | 把数据读到临时堆外缓冲区中,包括步骤8 |
8 | FileDispatcherImpl | int read(FileDescriptor var1, long var2, int var4) -> static native int read0(FileDescriptor var0, long var1, int var3) | 系统调用读取文件内容到临时堆外缓冲区 |
9 | HeapByteBuffer | public ByteBuffer put(ByteBuffer src) | 把临时堆外缓冲区的数据读到HeapByteBuffer,包含步骤10,11,12 |
10 | DirectByteBuffer | public ByteBuffer get(byte[] dst, int offset, int length) | |
11 | Bits | static void copyToArray(long srcAddr, Object dst, long dstBaseOffset, long dstPos,long length) | |
12 | Unsafe | public native void copyMemory(Object var1, long var2, Object var4, long var5, long var7) | |
13 | Util | 临时DirectByteBuffer的回收,这里就不列出来了 |
调用链第1步
:
public int read(ByteBuffer var1) throws IOException {
this.ensureOpen();
if (!this.readable) {
throw new NonReadableChannelException();
} else {
synchronized(this.positionLock) {
int var3 = 0;
int var4 = -1;
byte var5;
try {
this.begin();
var4 = this.threads.add();
if (this.isOpen()) {
do {
// 关键看这一步,其他不用看了
// 调用了IOUtil的read方法,把文件描述符传进去了
var3 = IOUtil.read(this.fd, var1, -1L, this.nd);
} while(var3 == -3 && this.isOpen());
int var12 = IOStatus.normalize(var3);
return var12;
}
var5 = 0;
} finally {
this.threads.remove(var4);
this.end(var3 > 0);
assert IOStatus.check(var3);
}
return var5;
}
}
}
调用链第2步
:
// var0是文件描述符
// var1就是我们最初传进来的HeapByteBuffer
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if (var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if (var1 instanceof DirectBuffer) {
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {
// 看名字就知道该方法创建了一个临时的DirectBuffer var5
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
int var7;
try {
// 把数据读到上面那个临时的DirectBuffer var5
int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
var5.flip();
if (var6 > 0) {
// 把临时的DirectBuffer var5中的数据放到最初传进来的那个HeapByteBuffer里
var1.put(var5);
}
var7 = var6;
} finally {
// 释放临时空间,即DirectBuffer var5
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
调用链第3步
:分配一个临时的DirectBuffer
public static ByteBuffer getTemporaryDirectBuffer(int var0) {
if (isBufferTooLarge(var0)) {
return ByteBuffer.allocateDirect(var0);
} else {
Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();
ByteBuffer var2 = var1.get(var0);
if (var2 != null) {
return var2;
} else {
if (!var1.isEmpty()) {
var2 = var1.removeFirst();
free(var2);
}
// 分配一个堆外缓冲区并返回
return ByteBuffer.allocateDirect(var0);
}
}
}
调用链第4步
:
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 {
// 调用unsafe类的native方法,看下面对应的c代码
// 简单来说就是用c分配内存并返回申请到的空间地址
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 调用unsafe类的native方法,看下面对应的c代码
// 简单来说就是用c把上面分配的空间都初始化为0
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.cpp
调用链第5步
UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
size_t sz = (size_t)size;
assert(is_aligned(sz, HeapWordSize), "sz not aligned");
// 动态分配内存,使用的是malloc,因此是在OS的堆上
void* x = os::malloc(sz, mtOther);
// 把返回的地址转换为java的long类型返回
return addr_to_java(x);
} UNSAFE_END
调用链第6步
UNSAFE_ENTRY(void, Unsafe_SetMemory0(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jlong size, jbyte value)) {
size_t sz = (size_t)size;
oop base = JNIHandles::resolve(obj);
// 先把java中long类型的地址转换成指针
void* p = index_oop_from_field_offset_long(base, offset);
// 把p指向的空间全部初始化为值value(看上面的java代码可以发现java传进来的value值是0)
Copy::fill_to_memory_atomic(p, sz, value);
} UNSAFE_END
调用链第7步
// 这里的ByteBuffer var1是上一步在堆外分配的DirectByteBuffer
private static int readIntoNativeBuffer(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
int var5 = var1.position();
int var6 = var1.limit();
assert var5 <= var6;
int var7 = var5 <= var6 ? var6 - var5 : 0;
if (var7 == 0) {
return 0;
} else {
boolean var8 = false;
int var9;
if (var2 != -1L) {
var9 = var4.pread(var0, ((DirectBuffer)var1).address() + (long)var5, var7, var2);
} else {
// 把数据读到堆外缓冲区
// 实际上调用了FileDispatcherImpl的read0方法,即会调用read系统调用
var9 = var4.read(var0, ((DirectBuffer)var1).address() + (long)var5, var7);
}
if (var9 > 0) {
var1.position(var5 + var9);
}
return var9;
}
}
调用链第8步
:其中native的read0()
方法位于FileDispatcherImpl.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_read0(JNIEnv *env, jclass clazz,
jobject fdo, jlong address, jint len)
{
// 读fd的值
jint fd = fdval(env, fdo);
// 把java的long转换为指针,这里是堆外内存
void *buf = (void *)jlong_to_ptr(address);
// 先调了read,把数据读到buf中,也就是堆外内存
// 再调用了convertReturnVal对返回值进行处理,即read到的长度大于0则直接返回对应的值,如果读不到数据就返回-1
return convertReturnVal(env, read(fd, buf, len), JNI_TRUE);
}
这里也是用了read
系统调用,就像前面说的,会把数据从磁盘先读到内核缓冲区。再读到堆外缓冲区DirectByteBuffer
。
调用链9-12步
public ByteBuffer put(ByteBuffer src) {
if (src instanceof HeapByteBuffer) {
if (src == this)
throw new IllegalArgumentException();
HeapByteBuffer sb = (HeapByteBuffer)src;
int spos = sb.position();
int pos = position();
int n = sb.remaining();
if (n > remaining())
throw new BufferOverflowException();
System.arraycopy(sb.hb, sb.ix(spos),
hb, ix(pos), n);
sb.position(spos + n);
position(pos + n);
} else if (src.isDirect()) {// src是一个DirectByteBuffer,直接执行这里
int n = src.remaining();
if (n > remaining())
throw new BufferOverflowException();
// 调用了DirectByteBuffer的get方法把数据放到了HeapByteBuffer的hb字节数组中
// 具体怎么放就是调用了第11和12步,在native方法unsafe.copyMemory中完成数据复制
src.get(hb, ix(position()), n);
position(position() + n);
} else {
super.put(src);
}
return this;
}
public ByteBuffer get(byte[] dst, int offset, int length) {
if (((long)length << 0) > Bits.JNI_COPY_TO_ARRAY_THRESHOLD) {
checkBounds(offset, length, dst.length);
int pos = position();
int lim = limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
if (length > rem)
throw new BufferUnderflowException();
// 复制数据
Bits.copyToArray(ix(pos), dst, arrayBaseOffset,
(long)offset << 0,
(long)length << 0);
position(pos + length);
} else {
super.get(dst, offset, length);
}
return this;
}
static void copyToArray(long srcAddr, Object dst, long dstBaseOffset, long dstPos,
long length)
{
long offset = dstBaseOffset + dstPos;
while (length > 0) {
long size = (length > UNSAFE_COPY_THRESHOLD) ? UNSAFE_COPY_THRESHOLD : length;
unsafe.copyMemory(null, srcAddr, dst, offset, size);
length -= size;
srcAddr += size;
offset += size;
}
}
UNSAFE_ENTRY(void, Unsafe_CopyMemory0(JNIEnv *env, jobject unsafe, jobject srcObj, jlong srcOffset, jobject dstObj, jlong dstOffset, jlong size)) {
size_t sz = (size_t)size;
oop srcp = JNIHandles::resolve(srcObj);
oop dstp = JNIHandles::resolve(dstObj);
void* src = index_oop_from_field_offset_long(srcp, srcOffset);
void* dst = index_oop_from_field_offset_long(dstp, dstOffset);
{
GuardUnsafeAccess guard(thread);
if (StubRoutines::unsafe_arraycopy() != NULL) {
MACOS_AARCH64_ONLY(ThreadWXEnable wx(WXExec, thread));
StubRoutines::UnsafeArrayCopy_stub()(src, dst, sz);
} else {
Copy::conjoint_memory_atomic(src, dst, sz);
}
}
} UNSAFE_END
第13步涉及到的本地方法
UNSAFE_ENTRY(void, Unsafe_FreeMemory0(JNIEnv *env, jobject unsafe, jlong addr)) {
// 先把java中long类型的地址转换成指针
void* p = addr_from_java(addr);
// 释放空间
os::free(p);
} UNSAFE_END
整个流程如下图:
当数据拷贝到了HeapByteBuffer以后,就可以操作HeapByteBuffer来获取数据了。
因此使用FileChannel
和HeapByteBuffer
读取文件的过程中涉及到三次文件拷贝
- 将数据从磁盘拷贝到内核缓冲区,由DMA完成
- 将数据从内核缓冲区拷贝到用户空间的临时堆外缓冲区DirectByteBuffer,CPU完成
- 将数据从临时堆外缓冲区DirectByteBuffer拷贝到堆内的HeapByteBuffer中的hb字节数组,CPU完成
2.2 使用DirectByteBuffer源码解析
再看一下刚才使用FileChannel读文件的写法
public void testFileChannel() throws IOException {
File file = new File("test.txt");
// 1.通过FileInputStream获取通道
FileChannel channel = new FileInputStream(file).getChannel();
// 2.为ByteBuffer分配空间
ByteBuffer buffer = ByteBuffer.allocate(16);
// 3.将数据读到buffer中
channel.read(buffer);
// 修改ByteBuffer的position和limit的位置
buffer.flip();
// 读数据
while (buffer.hasRemaining()){
System.out.print((char)buffer.get());
}
// 关闭通道
channel.close();
}
第二步分配空间的时候我们使用的是ByteBuffer.allocate()
方法,其实还可以使用ByteBuffer.allocateDirect()
方法。该方法如下:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
直接new了一个DirectByteBuffer
,这是MappedByteBuffer
的一个子类(当然也是ByteBuffer
的子类),前面的HeapByteBuffer
中有一个hb字节数组,DirectByteBuffer
不需要这样一个字节数组。new一个DirectByteBuffer
的过程上面已经讲过了,就是上面那个很长的调用链的第4、5、6步,使用Unsafe
分配了一个Java堆外的空间,并将里面的值都初始化为0。
然后看注释 “3.将数据读到buffer”中,和上面介绍HeapByteBuffer
时一样,都是调用IOUtil.read()
方法,也就是上面很长的调用链的第二步。再看一下这段代码
// ByteBuffer var1就是我们传进来的,现在是DirectByteBuffer
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if (var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if (var1 instanceof DirectBuffer) {
// 应该走这一步,前面已经介绍过这个方法了,就是把数据从内核读到这个堆外的缓冲区
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {// 前面介绍HeapByteBuffer的时候走这里
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
int var7;
try {
int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
var5.flip();
if (var6 > 0) {
var1.put(var5);
}
var7 = var6;
} finally {
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
数据读到堆外的直接内存以后,我们就可以读数据了,再回顾一下上面的HeapByteBuffer
,数据读到堆外内存以后,再复制到HeapByteBuffer
的hb字节数组,我们读数据的时候从hb中读。现在使用DirectByteBuffer
以后,需要从直接内存里读数据,看一下DirectByteBuffer.get()
方法,就是从直接内存读数据的方法。
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
public native byte getByte(long var1)
用到了Unsafe
的native方法来读直接内存。再画张图来看一下使用DirectByteBuffer
读文件的过程。
数据只需要复制两次即可。
三、传统IO和NIO对比
传统IO VS FileChannel+HeapByteBuffer
首先,在使用传统IO的时候,我们会创建一个byte数组来不停接收数据,在使用HeapByteBuffer
时,虽然我们没有手动创建一个byte数组,但是在调用ByteBuffer.allocate()
方法时内部自动会创建一个byte数组,因此虽然使用方式不同,但都需要用到一个字节数组作为缓冲区,避免每次只读一个字节。
传统IO和NIO(使用HeapByteBuffer)都会创建一个堆外的临时缓冲区,只不过传统IO的临时缓冲区stackBuf是在栈上分配的,并且大小默认为8K,而NIO是在OS堆内,Java堆外创建了一个DirectByteBuffer作为临时缓冲区。
数据从磁盘读到内核缓冲区后,都要先复制到临时缓冲区中,在复制到Java堆内的字节数组里。
数据都经过了三次复制。
FileChannel+DirectByteBuffer
不需要一个Java堆内的字节数组,直接操作堆外内存,数据只要复制两次。
四、小结
不管是用ByteBuffer.allocate()
方法还是用ByteBuffer.allocateDirect()
方法,都调用了一个readIntoNativeBuffer()
方法把数据读到了一个缓冲区,而readIntoNativeBuffer()
底层是调用了read
系统调用。
用传统IO读文件的时候,比如上面介绍的FileInputStream
,底层也是用了read
系统调用。
read系统调用会把内核缓存区的数据再复制一份到用户空间,因此一份数据既会在内核缓冲区有一份,还会在用户空间有一份
这一点很重要,如果能省掉这一步复制是不是会更快呢?MappedByteBuffer
就是基于这么一个思想,所以使用MappedByteBuffer
来读写文件的时候,调用的并不是read
或者write
系统调用,而是mmap
系统调用。
五、MappedByteBuffer源码解析
终于到MappedByteBuffer
了,要理解MappedByteBuffer
,首先得明白虚拟内存(不清楚的小伙伴快去补补课)。
我们在写代码的时候,用的地址都是虚拟地址,所以从程序员的角度/程序的角度来看read
系统调用如下图:
但是实际上是这样(简化了一下,实际有可能映射到物理内存中的多个frame)
虚拟地址到真实的物理地址之间的转换是由MMU完成的,OS隐藏了这个部分,因此我们操作虚拟地址就好像真的在使用物理内存一样。
内存映射
由于用户程序不能访问硬件设备,这些功能由内核提供,而内核向上提供了read
,write
等系统调用供用户来使用这些功能,这也是为什么不能直接把数据从磁盘读到用户空间中,必须经过内核来中转一次,就像上图一样,数据经过了两次复制才到用户空间。那如果可以让用户直接访问硬件设备,不经过内核,那就可以节省一次数据复制的开销,对于大文件来说这就非常有价值了。
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,既完成了对文件的操作又不需要调用read
,write
等系统调用函数。假设我们映射了一个文件到虚拟内存中,此时如下图所示:
从上图可以看到,进程的地址空间是分段的,为什么要分段呢?熟悉操作系统应该都知道。比如代码段只能读不能写,而数据段可读可写,如果不分段,混在一起,怎么保证代码段不会被修改呢?分治更好,从用户的角度来看,我们是需要分段的。但是分段会造成内存碎片,因此从内存的利用率来看,分页更好。用户希望能分段,内存希望能分页,因此段页结合就有了虚拟内存。稍微复习了一下OS。
mmap内存映射就是把设备的物理地址映射到了上面的MMAP段。那mmap内存映射做了什么呢?其实就是分配空间,新建了一个数据结构指向该空间,在把该空间和物理磁盘地址对应起来。下面看看这个数据结构是什么。
操作系统为了管理进程,需要知道每个进程的具体信息,每个进程的信息都保存在一个结构体task_struct
中,一个task_struct
就是一个进程,task_struct
里面有一个成员mm_struct
,即进程的内存描述符,mm_struct
中有多个vm_area_struct
,每个vm_area_struct
就表示上面一个个段,这些vm_area_struct
组成一个链表。
所以上面说的“把设备的物理地址映射到了上面的MMAP段”其实就是在MMAP段中找到一块空闲的空间,然后新建一个vm_area_struct
指向这个空闲空间,然后把这个新建的vm_area_struct
加到上面的链表中,再将这个空闲空间和磁盘物理地址关联起来。
MMAP的过程
- 在当前进程的虚拟地址空间中,找一段空闲且满足要求的连续地址,给这个空闲区分配一个
vm_area_struct
并初始化,再把这个vm_area_struct
加入到上面那个链表里。接下来调用mmap系统调用,进入内核空间。 - 分配了新的虚拟地址空间后,通过待映射的文件的fd,找到对应的inode,进而就可以找到文件在磁盘中的物理地址,将该文件的物理地址和虚拟地址空间建立关联。如何建立?就是通过建立页表。
上面两步完成后,用户空间的虚拟地址就和磁盘的物理地址建立起关系了,但是可以看到,此时,文件并没有被读到内存里。没有数据的拷贝发生。
当我们真正的开始读写文件的时候,会访问到上面映射的虚拟地址,通过查页表,发现这段地址实际上并不在物理内存中,这时就会引发缺页中断
,然后操作系统开始请求调页,调页的时候会先查看swap分区里有没有,没有说明该页从来没有被读到内存中,因此开始把缺的页从磁盘读到内存中,之后进程就可以对这块内存进行读写了。
因此,使用MMAP的方式,文件仅仅在缺页中断发生的时候被读进内存,数据的复制只发生了一次。
MappedByteBuffer使用
public void testMappedByteBuffer(){
File file = new File("test.txt");
long len = file.length();
byte[] ds = new byte[(int) len];
try {
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "rw")
.getChannel()// 获取FileChannel,前面说过这个方法
.map(FileChannel.MapMode.READ_WRITE, 0, len);// 调用FileChannel的map方法得到MappedByteBuffer
for (int offset = 0; offset < len; offset++) {
byte b = mappedByteBuffer.get();
ds[offset] = b;
}
System.out.print(new String(ds));
} catch (IOException e) {}
}
MappedByteBuffer
是通过FileChannel.map()
方法得到的。其调用链为:
序号 | 类 | 方法 |
---|---|---|
1 | FileChannelImpl | public MappedByteBuffer map(MapMode mode, long position, long size) |
2 | FileChannelImpl | private native long map0(int prot, long position, long length) |
3 | Util | static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) |
4 | Util | private static void initDBBConstructor() |
第一步FileChannelImpl.map()
:
// 只保留关键部分代码
public MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException
{
......
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
// native方法,执行内存映射,返回的addr就是上面说的在虚拟内存找到的空闲空间的地址
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
// OutOfMemoryError可能表明内存耗尽,手动触发gc,再尝试映射一次
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
// 再试一次
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
throw new IOException("Map failed", y);
}
}
......
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize,
addr + pagePosition,
mfd,
um);
} else {
// 利用上面的addr,新建一个MappedByteBuffer并返回
return Util.newMappedByteBuffer(isize,
addr + pagePosition,
mfd,
um);
}
}
}
第二步本地方法map0()
,位于FileChannelImpl.c
中
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len)
{
void *mapAddress = 0;
jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
jint fd = fdval(env, fdo); // 获取fd值
int protections = 0;
int flags = 0;
if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {// 如果传入的是FileChannel.MapMode.READ_ONLY就执行这里
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) { // 如果传入的是FileChannel.MapMode.READ_WRITE就执行这里
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
}
// 调用了mmap系统调用
mapAddress = mmap64(
0, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */
if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}
// 返回映射的虚拟内存地址
return ((jlong) (unsigned long) mapAddress);
}
第三步Util.newMappedByteBuffer()
:
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) {
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
// 用反射的方法,实际上是创建了一个DirectByteBuffer对象,它是MappedByteBuffer的子类
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size),
new Long(addr),
fd,
unmapper
}
return dbb;
}
第四步Util.initDBBConstructor()
private static void initDBBConstructor() {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
try {
Class cl = Class.forName("java.nio.DirectByteBuffer");
Constructor ctor = cl.getDeclaredConstructor(Integer.TYPE, Long.TYPE, FileDescriptor.class, Runnable.class);
ctor.setAccessible(true);
Util.directByteBufferConstructor = ctor;
return null;
} catch (NoSuchMethodException | IllegalArgumentException | ClassCastException | ClassNotFoundException x) {
throw new InternalError(x);
}
}
});
}
通过三四步可以看到,最终是通过反射的方式,根据mmap返回的虚拟地址,创建了一个DirectByteBuffer
(MappedByteBuffer
是一个抽象类,DirectByteBuffer
实现了MappedByteBuffer
)。对应的DirectByteBuffer
的构造函数如下:
// For memory-mapped buffers -- invoked by FileChannelImpl via reflection
//
protected DirectByteBuffer(int cap, long addr,
FileDescriptor fd,
Runnable unmapper)
{
super(-1, 0, cap, cap, fd);
address = addr;
cleaner = Cleaner.create(this, unmapper);
att = null;
}
也就是说,通过FileChannel.map()
方法,最终创建的是一个DirectByteBuffer
,并将mmap得到的虚拟地址保存在了address
成员变量中,并且和要读取的文件在磁盘中的物理地址建立起了联系。
接下来读数据时,我们会调用MappedByteBuffer.get()
方法,即DirectByteBuffer.get()
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}
由于已经通过map0()
函数返回内存文件映射的address
,这样就无需调用read
或write
方法对文件进行读写,通过address
就能够操作文件。底层采用unsafe.getByte
方法(native方法),通过(address + offset)获取指定内存的数据。首次调用会产生缺页中断,就像前面介绍的那样。
个人水平有限,理解错误的地方请大家帮忙指正