0%

Java NIO之浅谈内存映射文件原理与DirectMemory

概述

学习了很久的NIO,但是一直对NIO中内存映射和DirectMemory有很多的不理解,恰好最近在读《深入理解操作系统》,对其中一些不理解的地点有了自己的一些感悟。此篇文章将结合操作系统谈谈自己对NIO中的内存映射和DirectMemory的理解。

基本文件IO操作原理

在基本的文件IO操作中,我们都是调用操作系统提供的底层标准IO系统调用函数read()、write(),此时调用此函数的进程(在Java中即java进程)会从用户态切换到内核态,接着OS的内核代码负责将相应的文件数据读取到内核的IO缓冲区,然后再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作。至于为什么要多此一举搞一个内核IO缓冲区把原本只需一次拷贝数据的事情搞成需要2次数据拷贝呢?我想学过操作系统或者计算机系统结构的人都知道,这么做是为了减少磁盘的IO操作,为了提高性能而考虑的,因为我们的程序访问一般都带有局部性,也就是所谓的局部性原理,在这里主要是指的空间局部性,即我们访问了文件的某一段数据,那么接下去很可能还会访问接下去的一段数据,由于磁盘IO操作的速度比直接访问内存慢了好几个数量级,所以OS根据局部性原理会在一次read()系统调用过程中预读更多的文件数据缓存在内核IO缓冲区中,当继续访问的文件数据在缓冲区中时便直接拷贝数据到进程私有空间,避免了再次的低效率磁盘IO操作。在Java中当我们采用IO包下的文件操作流,如:

1
2
FileInputStream in = new FileInputStream("D:\\java.txt");
in.read();

Java虚拟机内部便会调用OS底层的read()系统调用完成操作,如上所述,在第二次调用in.read()的时候可能就是从内核缓冲区直接返回数据了(可能还有经过native堆做一次中转,因为这些函数都被声明为native,即本地平台相关,所以可能在C代码中有做一次中转,如win32中是通过 C代码从OS读取数据,然后再传给JVM内存)。既然如此,Java的IO包中为啥还要提供一个BufferedInputStream类来作为缓冲区呢。关键在于四个字系统调用当读取OS内核缓冲区数据的时候,便发起了一次系统调用操作(通过native的C函数调用),而系统调用的代价相对来说是比较高的,涉及到进程用户态和内核态的上下文切换等一系列操作,所以我们经常采用如下的包装:

1
2
3
FileInputStream in = new FileInputStream("D:\\java.txt"); 
BufferedInputStream buf_in = new BufferedInputStream(in);
buf_in.read();

这样一来,我们每一次buf_in.read() 时候,BufferedInputStream 会根据情况自动为我们预读更多的字节数据到它自己维护的一个内部字节数组缓冲区中,这样我们便可以减少系统调用次数,从而达到其缓冲区的目的。所以要明确的一点是BufferedInputStream的作用不是减少磁盘IO操作次数(这个OS已经帮我们做了),而是通过减少系统调用次数来提高性能的。同理 BufferedOuputStream , BufferedReader/Writer也是一样的。在C语言的函数库中也有类似的实现,如 fread(),这个函数就是 C语言中的缓冲IO,作用与BufferedInputStream()相同.

这里简单的引用下JDK8 中 BufferedInputStream 的源码验证下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BufferedInputStream extends FilterInputStream {
/**
* 只列出了重要的部分
*/
protected volatile byte buf[];

public synchronized int read() throws IOException {
if (pos >= count) {
fill();
if (pos >= count)
return -1;
}
return getBufIfOpen()[pos++] & 0xff;
}
}

我们可以看到BufferedInputStream内部维护着一个字节数组byte[] buf来实现缓冲区的功能,我们调用的 buf_in.read()方法在返回数据之前有做一个if判断,如果buf数组的当前索引不在有效的索引范围之内,即if条件成立,buf字段维护的缓冲区已经不够了,这时候会调用内部的fill()方法进行填充,而fill()会预读更多的数据到buf数组缓冲区中去,然后再返回当前字节数据,如果if条件不成立便直接从buf缓冲区数组返回数据了。其中getBufIfOpen()返回的就是buf字段的引用。顺便说下,源码中的buf字段声明为protected volatile byte buf[];主要是为了通过volatile 关键字保证buf数组在多线程并发环境中的内存可见性.

什么是内存映射

Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。虚拟内存区域可以映射到俩种类型的对象一种:

  1. linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分。并且磁盘文件区被分成页大小的片,每一片包含一个虚拟页面的初始内容。此时并没有拷贝数据到内存中去,而是当进程代码第一次引用这段代码内的虚拟地址时,触发了缺页异常,这时候OS根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去,当有操作第N页数据的时候重复这样的OS页面调度程序操作。现在终于找出内存映射效率高的原因,原来内存映射文件的效率比标准IO高的重要原因就是因为少了把数据拷贝到OS内核缓冲区这一步(可能还少了native堆中转这一步)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
  2. 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件时有内核穿件的,包含的去不是二进制零。CPU第一次引用这样的一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,经这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页。

注:如果你对虚拟内存不是很明白,推介你去看《深入理解操作系统》第九章

java中内存映射

上面介绍了普通文件IO和内存映射,已经总结了为什么内存映射比普通的IO函数要快。现在来了解java中的内存映射,这也是NIO中的一个特性。其实java中的内存映射就是用c语言封装了一层,方便我们用java来调用,因此在了解概念的基础后,我们来看看如何使用。
java中提供了3种内存映射模式,即:只读(readonly)、读写(read_write)、专用(private) ,

  1. 对于只读模式来说,如果程序试图进行写操作,则会抛出ReadOnlyBufferException异常;
  2. 第二种的读写模式表明了通过内存映射文件的方式写或修改文件内容的话是会立刻反映到磁盘文件中去的,别的进程如果共享了同一个映射文件,那么也会立即看到变化!而不是像标准IO那样每个进程有各自的内核缓冲区,比如Java代码中,没有执行 IO输出流的 flush() 或者 close() 操作,那么对文件的修改不会更新到磁盘去,除非进程运行结束;
  3. 最后一种专用模式采用的是OS的写时拷贝原则,即在没有发生写操作的情况下,多个进程之间都是共享文件的同一块物理内存(进程各自的虚拟地址指向同一片物理地址),一旦某个进程进行写操作,那么将会把受影响的文件数据单独拷贝一份到进程的私有缓冲区中,不会反映到物理文件中去。

在Java NIO中可以很容易的创建一块内存映射区域,下面创建了一个只读方式的内存映射,代码如下:

1
2
3
4
File file = new File("E:\download\office2007pro.chs.ISO");
FileInputStream in = new FileInputStream(file);
FileChannel channel = in.getChannel();
MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,channel.size());

接下来是我对普通的读写和内存映射方式的读写做的一个性能对比。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/**************************************
* Author : zhangke
* Date : 2019-03-16 16:29
* email : [email protected]
* Desc : 400万条数据输入到文件中并且取出来
* 对比io和nio的效率
***************************************/
public class Demo {

public static void main(String[] args) throws IOException {


int count = 400_000;
String path = "temp_cache_tmp";
String path3 = "temp_nio_mem.tmp";

long start = System.currentTimeMillis();
bioWrite(path, count);
long end = System.currentTimeMillis();
System.out.println("io写入时间" + (end - start));

start = System.currentTimeMillis();
bioRead(path, count);
end = System.currentTimeMillis();
System.out.println("io读取时间" + (end - start));


start = System.currentTimeMillis();
mmapWrite(path3, count);
end = System.currentTimeMillis();
System.out.println("nio内存映射文件写入" + (end - start));

start = System.currentTimeMillis();
mmapRead(path3, count);
end = System.currentTimeMillis();
System.out.println("nio内存映射文件读取" + (end - start));
}


/**
* 使用mmap方式读取数据
* @param path 文件路径
* @param count 读取数据的条数
*/
public static void mmapRead(String path,int count) {

try(FileChannel fc = new FileInputStream(path).getChannel()) {
IntBuffer ib = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size()).asIntBuffer();
while (ib.hasRemaining() && count > 0) {
ib.get();
count--;
}
System.out.println("读取:"+count+"条数据");
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}


/**
* 使用mmap方式来写入数据
* @param path 写入文件的路径
* @param count 写入数据的条数
*/
public static void mmapWrite(String path, int count) {

try(FileChannel fc =new RandomAccessFile(path,"rw").getChannel()) {
MappedByteBuffer mappedByteBuffer = fc.map(FileChannel.MapMode.READ_WRITE, 0, count * 4);
IntBuffer ib = mappedByteBuffer.asIntBuffer();
for (; count > 0; count--) {
ib.put(count);
}
System.out.println("写入:"+count+"条数据");
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}


/**
* BIO来进行数据的读取
* @param path
* @param count
*/
public static void bioRead(String path, int count) {

try(FileInputStream fileInputStream = new FileInputStream(path)) {
byte[] intBytes = new byte[Integer.SIZE/8];
int len = 0;
for (; count > 0 && len != -1; count--) {
len = fileInputStream.read(intBytes);
}
System.out.println("读取:"+count+"条数据");
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}


/**
* BIO写入数据
* @param path
* @param count
*/
public static void bioWrite(String path, int count) {
try(FileOutputStream fileOutputStream = new FileOutputStream(path)) {
for (;count > 0; count--) {
fileOutputStream.write(Integer.toString(count).getBytes());
}
System.out.println("写入:"+count+"条数据");
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

}

测试结果

1
2
3
4
5
6
7
8
写入:0条数据
io写入时间2001
读取:0条数据
io读取时间385
写入:0条数据
nio内存映射文件写入15
读取:0条数据
nio内存映射文件读取8

上面测试了普通IO和内存映射的读写性能。从上可以看出,内存映射的读写性能远胜于普通IO。读取或者写入的数据越多,差距便越大。所以内存映射文件特别适合于对大文件的操作,Java中的限制是最大不得超过Integer.MAX_VALUE,即2G左右,不过我们可以通过多次映射文件(channel.map)的不同部分来达到操作整个文件的目的。

按照jdk文档的官方说法,内存映射文件属于JVM中的直接缓冲区,还可以通过ByteBuffer.allocateDirect() ,即DirectMemory的方式来创建直接缓冲区。他们相比基础的 IO操作来说就是少了中间缓冲区的数据拷贝开销。同时他们属于JVM堆外内存,不受JVM堆内存大小的限制。

直接内存

DirectMemory默认的大小是等同于JVM最大堆,理论上说受限于进程的虚拟地址空间大小,比如32位的windows上,每个进程有4G的虚拟空间除去2G为OS内核保留外,再减去JVM堆的最大值,剩余的才是DirectMemory大小。通过设置JVM参数 -Xmx64M,即JVM最大堆为64M,然后执行以下程序可以证明DirectMemory不受JVM堆大小控制:

1
2
3
public static void main(String[] args) {
ByteBuffer.allocateDirect(1024*1024*100); // 100MB
}

我们设置了JVM堆64M限制,然后在直接内存上分配了100MB空间,程序执行后直接报错:Exception in thread “main” java.lang.OutOfMemoryError: Direct buffer memory

接着我设置-Xmx200M,程序正常结束。然后我修改配置:-Xmx64M -XX:MaxDirectMemorySize=200M,程序正常结束。因此得出结论:直接内存DirectMemory的大小默认为-Xmx的JVM堆的最大值,但是并不受其限制,而是由JVM参数 MaxDirectMemorySize单独控制。接下来我们来证明直接内存不是分配在JVM堆中。我们先执行以下程序,并设置JVM参数 -XX:+PrintGC

1
2
3
4
5
public static void main(String[] args) {
for(int i=0;i<20000;i++) {
ByteBuffer.allocateDirect(1024*100); //100K
}
}

输出结果如下:

1
2
3
4
[GC 1371K->1328K(61312K), 0.0070033 secs]
[Full GC 1328K->1297K(61312K), 0.0329592 secs]
[GC 3029K->2481K(61312K), 0.0037401 secs]
[Full GC 2481K->2435K(61312K), 0.0102255 secs]

我们看到这里执行 GC的次数较少,但是触发两次Full GC,原因在于直接内存不受GC(新生代的Minor GC)影响,只有当执行老年代的 Full GC时候才会顺便回收直接内存!而直接内存是通过存储在JVM堆中的DirectByteBuffer对象来引用的,所以当众多的DirectByteBuffer对象从新生代被送入老年代后才触发了full gc。

再看直接在JVM堆上分配内存区域的情况:

1
2
3
4
5
public static void main(String[] args) {	   
for(int i=0;i<10000;i++) {
ByteBuffer.allocate(1024*100); //100K
}
}

ByteBuffer.allocate 意味着直接在 JVM堆上分配内存,所以受新生代的 Minor GC影响,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[GC 16023K->224K(61312K), 0.0012432 secs]
[GC 16211K->192K(77376K), 0.0006917 secs]
[GC 32242K->176K(77376K), 0.0010613 secs]
[GC 32225K->224K(109504K), 0.0005539 secs]
[GC 64423K->192K(109504K), 0.0006151 secs]
[GC 64376K->192K(171392K), 0.0004968 secs]
[GC 128646K->204K(171392K), 0.0007423 secs]
[GC 128646K->204K(299968K), 0.0002067 secs]
[GC 257190K->204K(299968K), 0.0003862 secs]
[GC 257193K->204K(287680K), 0.0001718 secs]
[GC 245103K->204K(276480K), 0.0001994 secs]
[GC 233662K->204K(265344K), 0.0001828 secs]
[GC 222782K->172K(255232K), 0.0001998 secs]
[GC 212374K->172K(245120K), 0.0002217 secs]

可以看到,由于直接在 JVM堆上分配内存,所以触发了多次GC,且不会触及Full GC,因为对象根本没机会进入老年代。
在这里要来探讨一下内存映射和DirectMemory的内存回收问题。NIO中的DirectMemory和内存文件映射同属于直接缓冲区,但是前者和-Xmx和-XX:MaxDirectMemorySize有关,而后者完全没有JVM参数可以影响和控制,这让我不禁怀疑两者的直接缓冲区是否相同,前者指的是Java进程中的native堆,因为C语言中的malloc()分配的内存就属native堆,不属JVM堆,这也是DirectMemory能在一些场景中显著提高性能的原因,因为它避免了在native堆和jvm堆之间数据的来回复制;而后者则是没有经过native堆,是由Java进程直接建立起某一段虚拟地址空间和文件对象的关联映射关系,所以内存映射文件的区域并不在JVM GC的回收范围内,因为它本身就不属于堆区,卸载这部分区域只能通过系统调用 unmap()来实现 (Linux)中,而Java API 只提供了 FileChannel.map 的形式创建内存映射区域,却没有提供对应的 unmap()。

事实是由JVM帮助我们自动回收这部分内存,在定义这些类时,通过一个虚引用包裹这些创建的NIO对象,当JVM进行GC时检测指向这些内存映射或者直接内存的java对象是否被回收(java对象都是保存在堆上,只是对象中使用变量空间指向对外内存)。如果这些对象被回收,那么JVM就会自动的帮助我们回收这些堆外内存。具体参考:堆外内存 之 DirectByteBuffer 详解