Basic I/O 基本输入输出
本节包含了Java平台所提供的用于基础I/O的类。首先介绍的是I/O流,一个非常强大的思想用于简化I/O操作。其次将研究序列化,序列化用于把整个对象写入到流中,并还能从流中将其再读取回来。最后将研究文件I/O 和 文件系统操作,包括随机读写文件。这些类主要分布在 java.io包、java.nio.file包中。

I/O Stream 流
I/O流代表着一个输入源或输出目的地。“流”代表着多种类型的“源”和“目的地”,包括磁盘文件、设备、其它程序或者内存等。“流”支持多种不同类型的数据,包括简单的字符数据、原始类型数据、本地化的字符集数据、对象。有些“流”只是简单地传递数据,另一些“流”除了传递数据外还可以实现数据的操作和转换。
不管“流”在内部是如何工作的,所有的“流”都代表着相同的简单的编程模型。“流”就是一连串的数据。程序使用input stream 读取“源”中的数据;程序使用 output stream 向“目的地”写入数据。如下图示:


我们可以使用“流”来处理数据,从最基本的原始数据类型到高级的对象数据。上图中的“源”和“目的地”,可以是硬盘文件、另一个程序、外部设备、网络socket 或 array阵列。
Byte Stream 字节流
程序使用字节流执行8位字节的输入与输出。所有的字节流class 都继承自 InputStream 和 OutputStream。有很多的字节流的类,下面我们演示一下字节流的工作方式,把注意力放在基于字节流的文件I/O(FileInputStream / FileOutputStream),其它的字节流的使用方式几乎致,举一反三即可。下面示例,我们将使用字节流来复制 demo.txt 文件:
// ByteCopy.java
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class ByteCopy {
public static void main(String[] args) throws IOException {
FileInputStream in = null;
FileOutputStream out = null;
try {
// 创建“源”对象
in = new FileInputStream("demo.txt");
// 创建“目的地”对象
out = new FileOutputStream("demo_copy.txt");
// 读文件
int c;
while ((c = in.read()) != -1) {
// 写文件
out.write(c);
}
} finally {
// 关闭“流”
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
源码解读:CopyFile.java 将花费一定的时间来完成文件的读取与写入。在 while 循环中,一次一个字节地读写文件。其工作方式如下图示:

必须注意:当“流”不再被使用时,一定要关闭掉它们,即使是发生了意外的异常。把“流”的关闭放在finally中,以保证“流”总是能够被关闭,以减少资源浪费。上述代码中,当无法打开文件时就会导致异常的发生,“源”流和“目的地”流就是null,不能调用.close()方法。
上述代码看上去是一个正常的程序,但事实上我们应该尽量避免这么写,因为demo.txt文件中可以包含字符数据。最佳的方式是使用“字符流”来实现文件的读写。“字节流”大多数时候适用于最原始的I/O操作。所有的流类型,都是基于“字节流”而构建的。
Character Stream 字符流
Java平台使用Unicode字符集规则。字符流,使用内部格式或者本地字符集来转换数据。本地字符集通常是8位的ASCII超集。所有的字符流的类,都派生自 Reader / Writer。也有专门用于文件的字符流,如 FileReader / FileWriter。示例如下:
// CharacterCopy.java
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public class CharacterCopy {
public static void main(String[] args) throws IOException {
FileReader in = null;
FileWriter out = null;
try {
in = new FileReader("demo.txt");
out = new FileWriter("demo_copy_2.txt");
int c;
while((c = in.read()) != -1) {
out.write(c);
}
} finally {
if(in != null) {
in.close();
}
if(out != null) {
out.close();
}
}
}
}
FileReader/FileWriter 与 FileInputStream/FileOutputStream 看似都可以实现文件的读写,它们的区别在于,前者每次读写16位,后者每次仅读取8位。
基于行的 I/O
对于文本文件来讲,所谓的“行”就是使用了换行符(\n)或回车符(\r)的字符串。文件的读写操作,我们可以基于“行”来读写,示例如下:
// LinesCopy.java
import java.io.FileReader;
import java.io.FileWriter;
import java.io.BufferedReader;
import java.io.PrintWriter;
import java.io.IOException;
public class LinesCopy {
public static void main(String[] args) throws IOException {
BufferedReader in = null;
PrintWriter out = null;
try {
// 创建“源”对象
in = new BufferedReader(new FileReader("demo.txt"));
// 创建“目的地”对象
out = new PrintWriter(new FileWriter("demo_copy_3.txt"));
// 读一行数据
String line;
while((line = in.readLine()) != null) {
// 写一行数据
out.println(line);
}
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
.readLine() 每次读取文件中的一行数据,.println() 每次向目的地处写入一行数据。事实上,对文本文件的读写操作,有很多种解决方案,更多了解参见 Scanning & Formatting。
Buffered Stream 缓冲流
没有使用缓冲流的文件读写,是直接地依靠操作系统来处理的,这会导致一些麻烦,进而影响流操作的效率。因为这种没有使用缓冲流的操作,会频繁地触发硬盘访问、网络活动或者一些其它开销较大的操作。
为了减少这种开销,Java平台提供了缓冲I/O流。缓冲输入流可以从内存缓冲区中读取数据,缓冲输出流可以向内存缓冲区中写入数据。对一个程序来讲,我们可以把非缓冲流转化成缓冲流,做法是用缓冲流构造器包裹非缓冲流对象,示例如下:
BufferedReader in = new BufferedReader(new FileReader("demo.txt"));
BufferedWriter out = new BufferedWriter(new FileWriter("demo_copy.txt"));
用四个缓冲流的Java类,分别是 BufferedInputStream / BufferedOutputStream / BufferedReader / BufferedWriter。
缓冲流的 flush()
当我们在使用缓冲流来操作I/O时,应该及时地把缓冲区中数据flush出来,而不是等待缓冲区被装满,这是非常重要的。这就是所谓的“flush the buffer”。有些缓冲流的java类,支持自动flush。如果想手动flush,只需调用缓冲流对象的.flush() 方法即可。这个 .flush()对所有的缓冲输出流都有用,但对非缓冲流是没有用的。
Scanning and Formatting
编程I/O通常要在普通数据和接近于人类习惯的数据之间进行相互转化。Java平台分别提供了 Scanner 和 Formatting 来处理这个任务。
Scanning 有两个作用,其一是使用指定的分割符把被输入数据分解开来;其二是根据指定的格式对数据进行输出。
// ScanText.java
import java.io.*;
import java.util.Scanner;
public class ScanText {
public static void main(String[] args) throws IOException {
Scanner scan = null;
try {
scan = new Scanner(new BufferedReader(new FileReader("demo.txt")));
while (scan.hasNext()) {
System.out.println(scan.next());
}
} finally {
if (scan != null) {
scan.close();
}
}
}
}
Formatting 使用格式化字符串对参数进行格式化。格式化字符有很多,常用的有:
%d 用于把整型数据格式化为小数形式
%f 用于把浮点数格式化成小数形式
%n 输出一个换行符
%x 把整型数据格式化成十六进制数
%s 把数值格式化为一个字符串。等等。
public class FormatTest {
public static void main(String[] args) {
int i = 2;
double r = Math.sqrt(i);
// 格式化输出
System.out.format("The square root of %d is %f.%n", i, r);
}
}
命令行环境下的 I/O
有时候程序需要在命令行环境下运行,因此Java平台提供了 Standard Stream 和 Console 支持Java程序在命令行环境下运行。如下示例,在命令行环境下修改密码:
import java.io.Console;
import java.util.Arrays;
import java.io.IOException;
public class Password {
public static void main(String[] args) {
Console c = System.console();
if (c == null) {
System.err.println("No console");
System.exit(1);
}
String login = c.readLine("Enter your login:");
char[] oldPasswd = c.readPassword("Enter your old password");
if (verify(login, oldPasswd)) {
boolean noMatch;
do {
char[] newPasswd1 = c.readPassword("Enter your new password:");
char[] newPasswd2 = c.readPassword("Enter new password again:");
noMatch = !Arrays.equals(newPasswd1, newPasswd2);
if (noMatch) {
c.format("password don't match. Try again. %n");
} else {
change(login, newPasswd1);
c.format("password for %s changed.%n", login);
}
Arrays.fill(newPasswd1, ' ');
Arrays.fill(newPasswd2, ' ');
} while(noMatch);
}
Arrays.fill(oldPasswd, ' ');
}
static boolean verify(String login, char[] passwd) {
// verify
return true;
}
static void change(String login, char[] passwd) {
// change
}
}
Data Streams 原始类型数据流
数据流支持原始数据类型的二进制I/O,比如布尔、字符、字节、整型、浮点类型等。所有的数据流都实现了 DataInput接口 / DataOutput接口 二者之一。

Object Streams 对象流
对象流支持对象的I/O读写操作。大多数情况下,标准的Java类都支持对象序列化。对象流所使用到的类有 ObjectInputStream / ObjectOutputStream ,它们实现了 ObjectInput / ObjectOutput 接口。

文件 I/O 操作
java.nio.file包 / java.nio.file.attribute 提供了大量的API 以支持文件的I/O操作,访问默认的文件系统。尽管这里有很多类,但是我们只需要学习其中几个即可,这些API是非常直观的简单的。
什么是文件系统?什么是 Path 路径?
文件系统基于硬件驱动,用于存储和组织文件。大多数的文件系统都是使用树结构(层级结构)的方式来存储文件的,最顶端的是根节点。路径有相对路径和绝对路径之分。如下图示:

Path类 和 路径操作
Path类是文件系统路径的一种可编程式表示。一个Path对象包含了文件名、目录列表,用于对文件进行检测、定位和操作。
创建一个Path对象:
Path p1 = Paths.get("/tmp/foo");
从Path对象中读取系列信息:
System.out.format("toString: %s%n", p1.toString());
System.out.format("fileName: %s%n", p1.getFileName());
System.out.format("root: %s%n", p1.getRoot());
对路径进行转化:
System.out.format("%s%n", p1.toUri());
合并两个路径:
Paths.get("foo").resolve("/home/joe");
对两个路径进行比较:
if (p1.equals(p2)) {
// equal
} else if (p1.startWith(p3)) {
// startWith
} else if (p1.endsWith(p4)) {
// endsWith
}
文件操作
Files类是 java.nio.file包中的另一个主要的入口。这个类提供了一系列的静态方法,用于对文件和目录进行读、写和操作。Files类中的方法,是基于Path对象来工作的。在开始学习本节之前,你应该先熟悉以下几个通用的概念:
什么是释放系统资源?什么是捕获异常?什么是可变参数?什么是原子操作?什么是方法的链式调用?
What is a Glob ? 在Files 类中有两个方法支持 glob 参数,你可以使用 glob 语法去指定模式匹配行为。glob模式是以字符串的形式被指定的,用于匹配其它字符串,比如目录、文件名等。glob语法遵守以下规则:

对目录和文件进行若干检测
一个Path实例就代表着一个文件或目录。那么该如何判断指定文件是否存在呢?该文件是可读的?可写的?还是可执行的?
验证文件或目录的存在性
Files.exists(path);
Files.notExists(path);
检测文件的可访问性
Files.isRegularFile(path)
Files.isReadable(path)
Files.isExecutable(path);
检测两个Path实例是否指定同一个文件
Files.isSameFile(path1, path2);
删除文件或目录
你可以删除文件、目录或链接。对于符号链接来讲,你删除的只是链接本身,而不会删除源文件。对于目录来讲,被删除的目录必须是空目标,否则删除失败。Files类提供了两个用于删除任务的方法。
Files.delete(path); // 如果路径不存在,会抛出异常
Files.deleteIfExists(path); // 如果路径不存在,不会抛出异常
复制文件或目录
使用 .copy() 方法可以实现文件或目录的复制。如果目标源已经存在,则复制失败,除非指定了 REPLACE_EXISTING 参数选项。
import static java.nio.file.StandardCopyOption.*;
File.copy(source, target, REPLACE_EXISTING);
移动文件或目录
使用 .move() 方法可以移动文件或目录。如果目标已经存在,则移动失败,除非你指定了 REPLACE_EXISTING 参数选项。
import static java.nio.file.StandardCopyOption.*;
Files.move(source, target, REPLACE_EXISTING);
对文件和目录的存储属性(元数据)进行管理
元数据metadata的定义:即数据的数据属性。在文件系统中,数据是存储在文件和目录中的,所谓“元数据”指的就是每个文件对象或目录对象的相关属性,比如是普通文件还是目录?文件大小?创建时间?文件所有者?文件所有组?访问权限等?
在文件系统中,元数据通常指向文件属性。Files类提供了系列方法,可用于获取文件的属性,或者设置文件的属性。(详情参见 Files类 api 文档)
文件的读、写、创建
本小节详细讨论文件的创建、打开与读写。有大量的api方法可供你选择使用。如下图表可以帮助你搞懂这些api的复杂度,由简至繁。

上图中最左边的实用的方法,如 readAllBytes() / readAllLines() / write(),常用于简单的通用的场景。右侧的方法用于循环遍历文本流或文本行,如 newBufferedReader / newBufferedWriter / newInputStream / newOutputStream。 newByteChannel() 用于处理 ByteChannels / ByteBuffers / SeekableByteChannels。 最右侧的方法使用 FileChannel 满足高级应用程序需求,比如文件加锁、内存映射I/O等。
值得注意的是,在使用这些方法创建文件时,允许你为文件指定一系列可选的初始化参数。比如说,在一个支持 POSIX 的文件系统上(如 UNIX),你可以在创建文件时为其指定文件所有者、所属组、文件权限等。
本章节你应该学会以下内容:
- OpenOptions 参数:许多方法都支持可选的 OpenOptions 参数,当不指定这个参数时,这些方法将会采用默认行为。
- 针对小型文件的通用方法:如 readAllBytes() / readAllLines() / write() 等。
- 针对文本文件的缓冲流I/O 方法:java.nio.file 包支持 I/O 通道,可以从缓冲区中转移数据,从而绕开那些可能阻塞I/O流的 layers 。
- 用于非缓冲流的方法。
- 用于通道和 ByteBuffers 的方法。
- 用于创建普通文件、临时文件的方法。
随机存取文件
随机存取文件允许我们无序地、随机地访问文件内容。也就是说你可以打开文件,寻找一个特定的位置,从中读取文件内容,或者向文件中写入内容。
这个功能可能来自 SeekableByteChannel 接口,该接口继承自通道I/O。这些方法使用能够设置或查询一个具体的位置,进而读写数据。这个 API 包含以下简单易用的方法:
- position 返回通道的当前位置。
- position() 设置通道的位置。
- read() 从通道中读取字节数据至缓冲区。
- truncate() 截取文件或者另一个实体,并连接到通道
创建目标、读取目录
前面章节中所讨论的方法,比如 delete() ,都是基于文件、链接和目录来工作的。但是我们该如何列举出文件系统中最顶级目录下所有的目录呢?该如何列表出目录中所有的内容呢?该如何创建一个目录呢?
本小节将讨论以下几个功能性的话题:
- 列举出文件系统根目录下的所有目录。
- 创建一个目录。
- 创建一个临时目录。
- 列举出目录下的内容。
- 使用 Glob 过滤一个目录中的内容。
- 自定义目录过滤器。
链接——符号化链接,或者其它类型的链接
前面已经提到过,java.nio.file包中,Path类尤其重要。每一个Path方法,无论用于检测你想做什么的path方法,还是为你提供参数选项的path方法,都能确保你在遇到符号化链接时能够配置其行为。在本章节之前,我们已经讨论过了符号化链接、软链接。然而还有一些文件系统还支持硬链接,硬链接有着更多的限制,限制如下:
- 硬链接的源文件必须存在。
- 硬链接一般不能用于目录。
- 硬链接不允许跨分区,因此硬链接不可以跨文件系统而存在。
- 硬链接看上去、行为上表现像是普通链接,因此同样难以查找。
- 硬链接的作用和源文件的作用一致,它们有着相同的权限、时间戳等,所有的文件属性都完全一样。
鉴于硬链接的限制条件更多,所以硬链接的使用场合比符号化链接更少。但是,Path类同样可以无缝地基于硬链接而工作。主要学习内容有以下几个点:
- 如何创建一个符号化链接?
- 如何创建一个硬链接?
- 如何检测一个链接是符号化链接?
- 如何查找链接所对应的源文件?
遍历文件树(文件系统)
你的应用是否需要递归地访问文件树中的所有文件?假如你需要删除所有的 .class 文件,假如你想知道过去一年哪些文件被访问过,等等。这些需要,可以使用 FileVisitor 接口来实现。本小节要学习的内容有:
- 学习 FileVisitor 接口。
- 使用 walkFileTree() 方法遍历文件树。
- 创建一个 FileVisitor 时需要注意哪些事项?
- 工作流控制
文件查找
如果你使用过 shell 脚本,你或许用过模式匹配来定位文件。事实上,模式匹配的应用十分广泛。模式匹配使用特殊的字符来创建一个模式,然后用文件名和这个模式进行比较。举个例子,在大多数的shell脚本中,星号 * 可以匹配任意一个字符。
java.nio.file包提供了编程级别的特性支持,每个文件系统都实现了模式匹配,并提供了 PathMatcher 类。你可以使用 FileSystem类的 getPathMatcher() 方法(递归)检索一个文件系统。
监听目录的变化
你是否遇到过这样的场景:当你修改了目录中某个文件时,系统会弹出一个确认框来提示你是否需要重新加载?这个功能,就是文件变化通知。程序必须能够检测到文件系统中相关目录的变化情况。一种实现文案是,轮询整个文件系统以查找变化,但是这种实现文案的效率不高;并且当打开的文件成千上百时,就不适合这么做了。
java.nio.file包提供了“文件变化通知功能”相关的类,它使得我们能够对一个或多个目录注册监听服务。在注册目录监听服务时,你可以选择使用哪种类型的目录变化,是文件新建?是文件删除?还是文件修改?等等。当监听服务检测到你指定的目录变化类型发生时,它会被转发到注册进程,注册进程会调用线程(池)来响应这个目录变化的事件。在本小节要学习的内容如下:
- 监听服务概述。
- 试用监听服务。
- 创建一个监听服务,并注册相关事件。
- 进程事件。
- 检索文件名。
- 监听服务最适宜的一些使用场景?
另一些有用的方法
有一些方法并不适合上述各章节的使用场景,但这些方法同样非常有用。本小节介绍几个这样的方法:
- 判断文件的 MIME 类型: Files.probeContentType()
- 获取默认的文件系统: FileSystems.getDefault()
- 路径字符串分割符: FileSystems.getDefault().getSeparator()
- 文件系统的文件存储: FileSystems.getDefault().getFileStores()
Legacy File I/O Code
在JavaSE 7 发布之前,java.io.File 用于机器系统的文件 I/O 操作,但是确实存在着一些缺陷。
自JavaSE 7 发布之后,关于文件I/O的实现完全重构了。如果你想使用 java.nio.file 包所提供了富功能来替换 JavaSE 7 之前的功能,那么你必须重写你的文件I/O代码。下图是 java.io.file 和 java.nio.file 之间的简单对比,但需要注意的是,它们之间的变化并不是一一对应的,并不是把旧API 替换成新API 就能完成代码的迁移。

本章小结
java.io包中有着很多有用的方法,可以帮助你在程序中实现数据的读与写。它们中大多数的类都实现了流的顺序访问。流的顺序访问,可以被划分成两大模块,一者是字节流的读写,二者是Unicode字符流的读写。每一个顺序访问的流,都有其特定的功能,比如读文件、写文件、数据过滤 或者 对象序列化等。
java.nio.file包提供了更多关于文件I/O、文件系统I/O的扩展功能。java.nio.file包是一个综合性的API集,其核心关键点有如下几个:
- Path类,用于操作和管理文件系统中的路径。
- Files类,用于文件操作,比如移动、复制、删除等;还可以用于获取和设置文件的属性。
- FileSystem类,用于管理文件系统的信息。
本章END 2018年12月27日
网友评论