Java文件I/O详解
1. 简介
在Java中,文件的I/O大致分为了三类:1)普通IO,存在于java.io包中,分为面向字符和字节两种;2)文件通道FileChannel,存在于 java.nio 包中,属于 NIO 的一种,但是是阻塞的;2)MMap内存映射,由FileChannel调用map方法产生的特殊的读写方式
2. 普通I/O
在Java类库中,对于流的处理方向分为了输入和输出两种类型,每种方向又分为了面向字符和字节两种类型:
- 继承自InputStream和Reader都有read()方法,用于读取单个字节或字节数组
- 继承自OutputStream和Writer都有write()方法,用于写单个字节或字节数组
由于提供的接口过于底层,在使用时通常会叠合多个对象来达到期望的功能,使用的是Java中的装饰器模式;然而,这就导致了创建单一的流却需要创建多个对象
2.1 InputStream和OutputStream
InputStream的作用是用来表示从不同数据源产生的输入类:
- 字节数组
- String对象
- 文件
- 管道的输出口
- 其他种类的流组成的序列
- …
每一种数据源都有对应的InputStream子类作为基础的对象,同时还有一些装饰类来丰富基础类的功能,装饰类之间还可以相互嵌套,将功能组合起来得到我们想要的对象,具体的关系如下图所示:
2.1.1 【示例】格式化的输入与输出
【示例一】
通过FileOutputStream
获取对文件的输出流,再通过BufferedOutputStream
装饰器包装成带缓冲的输出流,最后再用DataOutputStream
装饰器包装,获得了格式化的输出输出数据
public class App {
public static void main(String[] args) {
try {
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream("test.txt")));
out.writeDouble(3.343123);
out.writeUTF("dsdasdas");
out.close();
DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream("test.txt")));
double x = in.readDouble();
String m = in.readUTF();
System.out.println(x);
System.out.println(m);
in.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
【示例二】
使用ByteArrayInputStream
将字符串转换为内存中的数据流,并通过DataInputStream
一个字节一个字节的读取数据,需要注意的是,任何一个读取的字节都是合法的结果,因此无法判断是否读到末尾;当读到了文件的末尾会抛出EOFException异常
public class App {
public static void main(String[] args) throws IOException {
String data = "aaabbbcccddd";
DataInputStream in = new DataInputStream(new ByteArrayInputStream(data.getBytes()));
try {
while (true) {
System.out.print((char) in.readByte());
}
}catch (EOFException e) {
System.out.println();
System.out.println("read to end");
}
}
}
除了抓住异常来判断是否读到文件的结尾,也可以使用in.available()
方法来检测输入是否结束
2.2 Reader和Writer
相较于InputStream和OutputStream,提供了兼容Unicode与面向字节流的I/O功能,并且:
- 不能取代面向字节的输入/输出流
- 当需要将面向字节和面向字符结合,需要用到适配器,
InputStreamReader
可以将InputStream
转换为Reader
;同理,OutputStreamWriter
可以将OutputStream
转换成Writer
和InputStream
和OutputStream
类似,它们也有对应的基础的类和丰富功能接口的装饰类,这里就不再赘述,重点讲解几个不同的点和示例
2.2.1 不同点
PrintWriter
它提供了一个既能接受Writer
对象又能接受任何OutputStream
对象的构造器,还提供了自动执行清空的选项,在每个Println()
之后自动清空内容
readLine()
在使用readLine()
时,应该使用BufferedReader
对象而不应该使用DataInputStream
2.2.2 示例
【示例一】 缓冲输入文件
- 通过
FileReader
打开文件获取输入流, - 再通过
BufferedReader
包装成带缓冲功能的输入流,按行读取数据
public class App {
public static String read(String filename) throws IOException {
BufferedReader in = new BufferedReader(new FileReader(filename));
String str;
StringBuilder sb = new StringBuilder();
while((str = in.readLine()) != null) {
sb.append(str);
}
return sb.toString();
}
public static void main(String[] args) throws IOException {
String str = read("test.txt");
System.out.println(str);
}
}
【示例二】 基本文件输出
- 通过
BufferedReader
包装StringReader
输入流 - 使用
PrintWriter
包装BufferedWriter
包装过的FileWriter
public class App {
static String file = "test.txt";
public static void main(String[] args) throws IOException {
BufferedReader in = new BufferedReader(new StringReader("sdad\nsdasd\ndsdsd"));
PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
int lineCount = 1;
String s;
while((s = in.readLine()) != null) {
pw.write(lineCount++ + ": " + s);
}
pw.close();
in.close();
}
}
【示例三】 文件输出的快捷方式
PrintWriter
中添加了一个辅助构造器,使得每次创建文档并写入数据时,不再需要执行所有的装饰的操作,包含了缓存的操作而不需要自己去实现
PrintWriter out = new PrintWriter(filename);
out.println(...);
out.close();
2.3 RandomAccessFile
使用RandomAccessFile
来读写随机访问文件,类似于组合使用的DataInputStream
和DataOutputStream
的效果,并且提供了seek()
函数在文件中随机移动。
在构造函数中,还有一个参数用于控制是"r(只读)"或者"rw(读写)"的方式来打开文件。
3. FileChannel
称为新I/O,利用通道和缓冲器来加快I/O的速度:我们只能和缓冲器交互,把缓冲器派送到通道处,由它和通道进行交互,完成数据的写入和读取操作。
在这里,缓冲器为ByteBuffer
对象,是一个非常基础的类,有如下功能:
- 通过告知分配的存储空间来创建一个
ByteBuffer
对象 - 没有办法输出和读取对象,处理方式低级,是高效率的映射方式
在旧的I/O库中有三个类被修改可以产生Channel,分别是FileInputStream,FileOutputStream和随机访问文件的RandomAccessFile。
3.1 简单示例
3.1.1【示例一】简单读写文件
以下代码分别展示了三个功能:
- 使用
FileOutputStream
获取Channel,并且用ByteBuffer.wrap()
来包装byte数组的数据 - 使用
RandomAccessFile
获取Channel,使用position()
调整指针位置进行随机的读写 - 使用
FileInputStream
获取Channel,通过ByteBuffer.allocate(SIZE)
创建指定空间的缓冲器,调用fc.read(buff);
将数据读入到缓冲区内,buff.flip()
调整指针信息,最后调用buff.get()
完成读操作
注意
在ByteBuffer
内部有Capacity,Position和Limit三个概念
-
Capacity是用户指定的缓冲大小
-
Position类似于读写指针,表示当前读(写)到什么位置
-
Limit在写模式下表示最多能写入多少数据,此时和Capacity相同,在读模式下表示最多能读多少数据,此时和缓存中的实际数据大小相同
在写模式下调用flip方法,那么limit就设置为了position当前的值(即当前写了多少数据),postion会被置为0,以表示读操作从缓存的头开始读,即调用flip()
后,指针移动到缓存头部,limit为当前数据的尾部,可以读取整个缓存空间中有数据的部分。
public class App {
private static final int BSIZE = 1024;
public static void main(String args[]) throws IOException {
// FileOutputStream
FileChannel fc = new FileOutputStream("test.txt").getChannel();
fc.write(ByteBuffer.wrap("Some text".getBytes()));
fc.close();
// RandomAccessFile
fc = new RandomAccessFile("test.txt","rw").getChannel();
// 移动到文件末尾
fc.position(fc.size());
fc.write(ByteBuffer.wrap("Some more".getBytes()));
fc.close();
// Read file
fc = new FileInputStream("test.txt").getChannel();
ByteBuffer buff = ByteBuffer.allocate(BSIZE);
// 将文件中的数据读入到buff中
fc.read(buff);
buff.flip();
while (buff.hasRemaining()) {
System.out.print((char)buff.get());
}
}
}
3.1.2【示例二】拷贝文件
拷贝的功能通过channel读取文件放入到buffer中,再将buffer中的输入刷入到另一个文件中完成,一个文件读完的标志是in.read(buff) == -1
,则可说明读到文件末尾
- 通过channel读取文件,放入到
ByteBuffer
中 - 调用
flip()
调整指针位置 - 写入到输出的channel中
- 清空
ByteBuffer
public class App {
private static final int BSIZE = 1024;
public static void main(String args[]) throws IOException {
FileChannel in = new FileInputStream("test.txt").getChannel();
FileChannel out = new FileOutputStream("text.txt").getChannel();
ByteBuffer buffer = ByteBuffer.allocate(BSIZE);
while (in.read(buffer) != -1) {
buffer.flip(); // ready to write
out.write(buffer);
buffer.clear(); // ready to read
}
}
}
3.1.3 视图缓冲器
视图缓冲器可以让我们通过某个特定的基本数据类型的视窗查看底层的ByteBuffer
,而ByteBuffer
才是实际存放数据的地方,对视图的任何修改都会实际影响底层存储的数据。
视图还允许非常容易的存取数据,不管是单个数据还是成批数据,具体的操作如下所示:
public class App {
private static final int BSIZE = 1024;
public static void main(String args[]) throws IOException {
ByteBuffer bb = ByteBuffer.allocate(BSIZE);
IntBuffer ib = bb.asIntBuffer();
ib.put(new int[]{1,2,3,4,5,6});
System.out.println(ib.get(3));
ib.put(3,2222);
System.out.println(ib.get(3));
}
}
3.1.4 缓冲器视图
若往缓冲器中放入8个字节的数据[]byte{0,0,0,0,0,0,'a'}
,对应不同的视图可以看到不同的数据,结果如下图所示:
4. MMAP
4.1 简介
mmap 把文件映射到用户空间里的虚拟内存,省去了从内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到物理内存进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高
4.2 读写流程
- 首先通过
RandomAccessFile.getChannel()
获得文件对应的channel信息 - 再通过channel在内存中映射一个128MB大小的空间,会在磁盘上产生一个128MB的文件,内容为字节0
- 通过这个地址进行读写
public class App {
private static final int length = 0x8ffffff; // 128MB
public static void main(String args[]) throws IOException {
MappedByteBuffer out = new RandomAccessFile("test.dat","rw")
.getChannel()
.map(FileChannel.MapMode.READ_WRITE,0,length);
for(int i = 0; i < length; i++) {
out.put((byte)'x');
}
for (int i = 0; i < 100; i++) {
System.out.print((char) out.get(i));
}
}
}
5. 总结
- 传统的I/O需要用装饰器包装基础类来获得想要的功能接口
- FileChannel 采用了 ByteBuffer 这样的内存缓冲区,让我们可以非常精准的控制写盘的大小
- 读操作经历:磁盘->PageCache->用户内存
- 充分利用PageCache对数据的预取和高速的特性
- MMap允许将一个文件直接映射到用户态的虚拟内存中,减少了数据拷贝的开销,效率高
6. 参考
[1] Java编程思想
[2] 文件 IO 操作的一些最佳实践
转载:https://blog.csdn.net/Tommenx/article/details/106005184