最近看到网上有些文章在讨论JAVA中普通文件IO读/写的时候经过了几次数据拷贝,如果从系统调用开始分析,以读取文件为例,数据的读取过程如下(以缓存I/O为例):
- 应用程序调用read函数发起系统调用,此时由用户空间切换到内核空间;
- 内核通过DMA从磁盘拷贝数据到内核缓冲区;
- 将内核缓冲区的数据拷贝到用户空间的缓冲区,回到用户空间;
整个读取过程发生了两次数据拷贝,一次是DMA将磁盘上的文件数据拷贝到内核缓冲区,一次是将内核缓冲区的数据拷贝到用户缓冲区。
在JAVA中,JVM划分了堆内存,平时创建的对象基本都在堆中,不过也可以通过NIO包下的 ByteBuffer
申请堆外内存 DirectByteBuffer
:
ByteBuffer.allocateDirect(size);
无论是普通IO或者是NIO,在进行文件读写的时候一般都会创建一个buffer作为数据的缓冲区,读写相关方法底层是通过调用native函数(JNI调用)来实现的,在进行读写时将buffer传递给JNI。
JNI一般使用C/C++代码实现,JNI底层调用C函数库时,要求buffer所在内存地址上的内容不能失效,但是JVM在进行垃圾回收的时候有可能对对象进行移动,导致地址发生变化,所以通过NIO进行文件读取的时候,从源码中可以明显看到对buffer的对象类型进行了判断,如果buffer是 DirectByteBuffer
类型,使用的是堆外内存,直接使用即可,反之则认为使用的是堆内内存,此时需要先申请一块堆外内存作为堆外内存buffer,然后进行系统调用,进行数据读取,读取完毕后将堆外内存buffer的内容再拷回JVM堆内内存buffer中,这里一般是没有疑问的。
比较有疑问的点是在普通IO中,读写文件传入的是字节数组 byte[]
,一种说法是数组一般分配的是连续的内存空间,即使内存地址发生了变化,根据数组的首地址依旧可以找到整个数组的内存,所以使用普通IO进行文件读写的时候,不需要重新分配堆外内存,直接使用堆内的字节数组即可,为了探究普通IO到底有没有重新申请堆外内存,接下来我们去看下源码。
普通IO
首先来看一下使用普通IO进行文件读取的例子,创建一个文件输入流和字节数组,通过输入流读取文件到字节数组中, 这里的字节数组占用的是JVM的堆内内存 :
// 创建输入流 try (InputStream is = new FileInputStream("/document/123.txt")) { // 创建字节数组(堆内内存) byte[] bytes = new byte[1024]; int len = 0; // 通过read方法读取数据到bytes数组 while ((len = is.read(bytes)) != -1){ String content = new String(bytes, 0, len); System.out.print(content); } is.read(bytes); } catch (Exception e) { e.printStackTrace(); }
由于输入流使用的 FileInputStream
,所以读取文件会进入到 FileInputStream
中的 read
方法,可以看到里面又