假设我们想要用Java读取一个二进制文件,有好几种方式,本文会选取其中比较典型的三种方式进行详细分析
0. 准备工作
安装openjdk-1.8.0.141(普通的jdk中涉及IO的很多代码是闭源的,点进去是编译之后的字节码,没法看)
1. FileInputStream.read
最朴素的方法就是先申请一段byte数组作为缓冲区,然后调用FileInputStream.read方法把文件里的数据灌到缓冲区中,代码如下所示
FileInputStream reader = new FileInputStream(fileName); byte[] buf = new byte[1024 * 64];//在heap memory中开辟一块缓冲区 while (reader.read(buf) != -1) {//调用FileInputStream.read()方法遍历文件 }
FileInputStream.read方法会直接调用到FileInputStream.readBytes()方法,这是一个native方法,具体实现位于src/share/native/java/io/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); }
它调用了src/share/native/java/io/io_util.c的readBytes方法:
jint readBytes(JNIEnv *env, jobject this, jbyteArray bytes, jint off, jint len, jfieldID fid) { jint nread; char stackBuf[BUF_SIZE]; char *buf = NULL; FD fd; 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) { buf = malloc(len);//申请一个与传入buf等长的char数组 if (buf == NULL) { JNU_ThrowOutOfMemoryError(env, NULL); return 0; } } else { buf = stackBuf; } //前面的代码全是检查参数 fd = GET_FD(this, fid);//获取打开文件的fd if (fd == -1) { JNU_ThrowIOException(env, "Stream Closed"); nread = -1; } else { nread = IO_Read(fd, buf, len);//调用IO_Read方法从fd中读取数据 if (nread > 0) { (*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);//将装有数据的char数组里的数据复制到buf中 } else if (nread == -1) { JNU_ThrowIOExceptionWithLastError(env, "Read error"); } else { /* EOF */ nread = -1; } } if (buf != stackBuf) { free(buf);//释放临时申请的char数组 } return nread; }
先申请一个等长的char数组(系统底层调用无法直接操作从Java应用层面传入的jbyteArray,只能先将数据读取到临时申请的char数组中,再复制到jbyteArray里)
然后调用一个叫做IO_Read的方法从指定的fd里读取数据
这个IO_Read实际上只是一个宏,其定义根据平台有所不同
Windows中的定义位于src/windows/native/java/io/io_util_md.h中,对应于handleRead方法
其源码如下所示:
JNIEXPORT jint handleRead(FD fd, void *buf, jint len) { DWORD read = 0; BOOL result = 0; HANDLE h = (HANDLE)fd; if (h == INVALID_HANDLE_VALUE) { return -1; } result = ReadFile(h, /* File handle to read */ buf, /* address to put data */ len, /* number of bytes to read */ &read, /* number of bytes read */ NULL); /* no overlapped struct */ if (result == 0) { int error = GetLastError(); if (error == ERROR_BROKEN_PIPE) { return 0; /* EOF */ } return -1; } return (jint)read; }
直接调用Windows平台提供的ReadFile函数完成文件的读取
Linux中的定义位于src/solaris/native/java/io/io_util_md.h中,也对应于handleRead方法:
ssize_t handleRead(FD fd, void *buf, jint len) { ssize_t result; RESTARTABLE(read(fd, buf, len), result); return result; }
其中RESTARTABLE是一个简单的宏,用于发生中断时的重试读取
/* * Retry the operation if it is interrupted */ #define RESTARTABLE(_cmd, _result) do { do { _result = _cmd; } while((_result == -1) && (errno == EINTR)); } while(0)
最终还是调用read这个系统调用。
再往下就是Linux的内核实现,超出本文的范围了。
现在我们可以对FileInputStream.read方法的实现做一个总结:
1. 调用native的FileInputStream.readBytes()方法,参数中带有一个byte数组作为读缓冲区
2. 通过jni将这个heap memory中的byte数组的指针传递给jvm
3. jvm开辟一个等长的char类型数组,然后调用IO_Read宏从指定的fd中读取数据将其填满
4. Windows系统中IO_Read的实现是ReadFile,Linux系统中的实现则是read系统调用
5. 调用SetByteArrayRegion方法将step2中的char数组里的数据写到step1中传入的byte数组里,在Java应用的层面来看,此时FileInputStream.read已经读到数据了
6. 释放step2中开辟的临时数组并返回
2. FileChannel.read
第二种方式是使用NIO里FileChannel的read方法,申请一段heap buffer或者direct buffer,然后将文件里的数据灌到buffer里,代码如下所示
FileInputStream reader = new FileInputStream(fileName); ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 64);//此处也可以调用allocate()方法申请heap buffer while (reader.getChannel().read(buffer) != -1) { buffer.clear(); }
FileChannel.read是个抽象方法,其实现在FileChannelImpl中:
public int read(ByteBuffer dst) throws IOException { ensureOpen(); if (!readable) throw new NonReadableChannelException(); synchronized (positionLock) { int n = 0; int ti = -1; try { begin(); ti = threads.add(); if (!isOpen()) return 0; do { n = IOUtil.read(fd, dst, -1, nd); } while ((n == IOStatus.INTERRUPTED) && isOpen()); return IOStatus.normalize(n); } finally { threads.remove(ti); end(n > 0); assert IOStatus.check(n); } } }
其中调用了IOUtil.read()方法
static int read(FileDescriptor fd, ByteBuffer dst, long position, NativeDispatcher nd) throws IOException { if (dst.isReadOnly()) throw new IllegalArgumentException("Read-only buffer"); if (dst instanceof DirectBuffer)//如果传入的是direct buffer,则直接读取数据 return readIntoNativeBuffer(fd, dst, position, nd); // Substitute a native buffer ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());//如果是heap buffer,则先申请一块direct buffer,然后读取数据 try { int n = readIntoNativeBuffer(fd, bb, position, nd); bb.flip(); if (n > 0) dst.put(bb);//将临时申请的direct buffer中的数据复制到heap buffer里来 return n; } finally { Util.offerFirstTemporaryDirectBuffer(bb); } }
IOUtil.read()方法中有一个很有趣的细节:如果传入的缓冲区是heap buffer,则申请一块与原buffer剩余空间等长的direct buffer,然后再读取数据。为什么这样做我们后续会解释。
IOUtil.read()方法中又调用了readIntoNativeBuffer()方法
private static int readIntoNativeBuffer(FileDescriptor fd, ByteBuffer bb, long position, NativeDispatcher nd) throws IOException { int pos = bb.position(); int lim = bb.limit(); assert (pos <= lim); int rem = (pos <= lim ? lim - pos : 0); if (rem == 0) return 0; int n = 0; if (position != -1) { n = nd.pread(fd, ((DirectBuffer)bb).address() + pos, rem, position);//获取direct buffer的内存地址 } else { n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);//获取direct buffer的内存地址 } if (n > 0) bb.position(pos + n); return n; }
readIntoNativeBuffer方法中调用了NativeDispatcher.pread与NativeDispatcher.read方法,两者的区别在于文件是否为从头读取,我们只分析参数较多的NativeDispatcher.pread方法,这是一个空方法
int pread(FileDescriptor fd, long address, int len, long position) throws IOException { throw new IOException("Operation Unsupported"); }
NativeDispatcher.pread方法的具体实现在FileDispatcherImpl.pread中
int pread(FileDescriptor fd, long address, int len, long position) throws IOException { return pread0(fd, address, len, position); }
其中又调用了FileDispatcherImpl.pread0方法,这又是一个native方法
static native int pread0(FileDescriptor fd, long address, int len, long position) throws IOException;
pread0的具体实现在根据平台不同有不同
Linux下的实现对应于src/solaris/native/sun/nio/ch/FileDispatcherImpl.c
Windows下的实现对应于src/windows/native/sun/nio/ch/FileDispatcherImpl.c
限于篇幅我们只分析Linux中的pread0实现:
JNIEXPORT jint JNICALL Java_sun_nio_ch_FileDispatcherImpl_pread0(JNIEnv *env, jclass clazz, jobject fdo, jlong address, jint len, jlong offset) { jint fd = fdval(env, fdo);//获取当前的文件的fd void *buf = (void *)jlong_to_ptr(address);//获取direct buffer的内存地址 return convertReturnVal(env, pread64(fd, buf, len, offset), JNI_TRUE);//调用pread64函数从指定的fd中读取数据 }
其中调用了pread64,跟踪代码后发现实际只是一个宏,对应于pread函数
这个pread函数又是一个系统调用,可以将文件中指定位置的数据写入指定的buf中。
而FileDispatcherImpl.pread0方法传入的direct buffer实际上对应于Java进程中的一块固定区域,直接用pread系统调用来操作是安全的。
总结一下FileChannel.read的实现:
1. 如果传入的buffer是heap buffer,那么申请一块临时的direct buffer执行后续操作
2. 调用native的FileDispatcherImpl.pread0或者FileDispatcherImpl.read0
3. 调用Linux提供的pread或者read系统调用,从文件中读取数据并复制到direct buffer对应的内存区域中
现在我们再回过头来分析一下前面的问题:为什么在IOUtil.read()方法中,一定要将heap buffer转换为direct buffer之后再调用native的FileDispatcherImpl.pread0方法呢?
因为Linux提供的pread或者read系统调用,只能操作一块固定的内存区域。这就意味着只能对direct memory进行操作(heap memory中的对象在经历gc后内存地址会发生改变,如果一定要直接写入heap memory,就必须要将这个对象pin住,但是hotspot不提供单个对象层面的object pinning,一定要pin的话就只能暂时禁用gc了,也就是把整个Java堆都给pin住,这显然代价太高了。)所以heap buffer要么直接在上层就转成direct buffer,要么像前面介绍的FileInputStream.read中那样在jvm层面申请一个临时byte数组再调用read/pread方法,然后将临时byte数组里的数组写入到heap buffer里。出于统一逻辑的角度考虑,当然是直接申请一个临时direct buffer对象的好。
3. FileChannel.map
第三种方式是使用NIO里FileChannel的map方法,也就是之前博客里介绍过的内存映射文件,然后像直接访问内存一样的读取文件即可。范例代码如下所示:
private static void mmapReadTest(String filenamm) throws IOException { Stopwatch stopwatch = Stopwatch.createStarted(); File file = new File(filenamm); long len = file.length(); byte[] buf = new byte[1024 * 64]; for (int i = 0; i <= len / Integer.MAX_VALUE; i++) { //一次不能映射超过Integer.MAX_VALUE大小的数据,因此需要对文件做分段映射 long position = (long) i * Integer.MAX_VALUE; long size = len - (long) i * Integer.MAX_VALUE > Integer.MAX_VALUE ? Integer.MAX_VALUE : len - (long) i * Integer.MAX_VALUE; if (size <= 0) { break; } MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r").getChannel() .map(FileChannel.MapMode.READ_ONLY, position, size); //循环读取文件 while (mappedByteBuffer.remaining() >= buf.length) { mappedByteBuffer.get(buf); } if (mappedByteBuffer.hasRemaining()) { mappedByteBuffer.get(buf, 0, mappedByteBuffer.remaining()); } } System.out.println(stopwatch); }
先看FileChannel.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 { // If no exception was thrown from map0, the address is valid addr = map0(imode, mapPosition, mapSize);//尝试调用map0方法创建内存映射文件 } catch (OutOfMemoryError x) { // An OutOfMemoryError may indicate that we've exhausted memory // so force gc and re-attempt map System.gc();//如果发生OOM异常,强制gc,睡眠100ms后重试 try { Thread.sleep(100); } catch (InterruptedException y) { Thread.currentThread().interrupt(); } try { addr = map0(imode, mapPosition, mapSize);//重试创建内存映射文件 } catch (OutOfMemoryError y) { // After a second OOME, fail throw new IOException("Map failed", y); } } // On Windows, and potentially other platforms, we need an open // file descriptor for some mapping operations. FileDescriptor mfd; try { mfd = nd.duplicateForMapping(fd); } catch (IOException ioe) { unmap0(addr, mapSize); throw ioe; } assert (IOStatus.checkAll(addr)); assert (addr % allocationGranularity == 0); 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 { return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um); } } finally { threads.remove(ti); end(IOStatus.checkAll(addr)); } }
可以看到调用了FileChannelImpl.map0()方法,这又是一个native方法
// Creates a new mapping private native long map0(int prot, long position, long length) throws IOException;
FileChannelImpl.map0()的Linux版本实现位于src/solaris/native/sun/nio/ch/FileChannelImpl.c中
Windows版本实现位于src/windows/native/sun/nio/ch/FileChannelImpl.c中
本文只介绍Linux版本的实现:
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); int protections = 0; int flags = 0; if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) { protections = PROT_READ; flags = MAP_SHARED; } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) { 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; } 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);//将mmap64生成的内存映射地址返回给Java应用,jdk会将其包装成ByteBuffer的形式给应用使用 }
前后的代码都比较无聊,关键之处是这个mmap64函数,不出意外的,mmap64也是个宏,对应于Linux的mmap系统调用
native的FileChannelImpl.map0()方法的返回值为long,代表了文件的内存映射的起始位置,但是应用无法根据内存地址直接操作direct memory,需要将其包装为ByteBuffer的格式
这一过程是由FileChannel.map方法中调用的Util.newMappedByteBufferR与Util.newMappedByteBuffer方法完成的
我们看一下其中的Util.newMappedByteBuffer方法:
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) { MappedByteBuffer dbb; if (directByteBufferConstructor == null) initDBBConstructor(); try { dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance( new Object[] { new Integer(size), new Long(addr), fd, unmapper }); } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { throw new InternalError(e); } return dbb; }
这里使用反射来创建DirectByteBuffer实例的原因是因为java.nio.DirectByteBuffer与sun.nio.ch.Util不在同一个包里,无法直接访问。
最后我们调用的MappedByteBuffer.get()从内存映射文件里读取数据的实际实现是DirectByteBuffer.get()方法
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; }
大概意思是先计算出需要get的数据在直接内存中的偏移量,以及数据的长度,然后调用Bits.copyToArray将这些数据从直接内存拷贝到传入的byte数组中。
Bits.copyToArray的实现如下:
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);//将数据从direct memory拷贝到heap memory length -= size; srcAddr += size; offset += size; } }
具体的拷贝操作是由Unsafe.copyMemory来实现的,里面的黑魔法比较复杂,这里就不做进一步探究了。
总结一下FileChannel.map的实现:
1. 调用native的FileChannelImpl.map0()方法
2. 调用系统调用mmap,创建内存映射文件,返回文件在内存中映射区域的起始位置。这可以理解为direct memory中的一个指针
3. 用Util.newMappedByteBufferR与Util.newMappedByteBuffer将这个指针包装为一个DirectByteBuffer对象并返回给用户
4. 用户调用DirectByteBuffer.get()方法的时候,jvm会尝试将direct memory中的数据复制到用户指定的byte数组里
5. 第一次访问会触发缺页中断,操作系统会尝试在swap空间里寻找数据,如果找不到(这个文件从未被载入内存)则触发磁盘的读操作,文件数据会被直接复制到物理内存中(read/pread这些系统调用还需要经过kernel中维护的文件缓冲区),复制完毕后direct memory中也可以直接访问这些数据了,现在可以继续执行step4中的复制操作。
6. 如果物理内存不够用了,则会通过虚拟内存机制将暂时不用的物理页面交换到硬盘的虚拟内存中。
4. 测试性能
我手上有一台vps,1 core+ 512M内存,配置看起来相当寒酸,但是硬盘性能倒是不错:读2g/s,写700m/s,估计底层用的是pcie接口的ssd
创建了一个大小为6.9G的文件,并用上面提到的三种方式遍历这个文件(只读取数据不做任何处理)
性能数据如下所示:
a. FileInputStream.read,平均耗时4.3秒
b. FileChannel.read,使用direct memory作为缓冲区,平均耗时3.6秒
c. FileChannel.read,使用heap memory作为缓冲区,平均耗时4.7秒
d. FileChannel.map,由于VPS内存有限,我将文件分成64MB大小的区块进行映射,但是平均耗时在7.8秒
分析:
a/c比b要慢,是因为b只将文件数据读到了direct memory,而a与c还要将这些direct memory复制到heap memory里,多出了一些开销。
FileChannel.map最慢,可能是因为vps的内存过小,影响了mmap的发挥吧。
参考资料: