Java IO 学习笔记(二)
一、文件流
与文件系统的交互是程序的重中之重。在 Java IO 中,我们把与文件系统交互的流称为文件流,它们分别是 FileInputStream 与 FileOutputStream。
在文件流的学习中,有两个问题需要注意:
1. 从广义上来讲,计算机文件即为二进制文件。无论是纯文本文件还是图像文件等,本质上都是以二进制的形式存储在设备中的。
2. Java 的文件流是字节流,只能从文件中读取字节,也只能向文件中写入字节。
二、File
File 类,即为文件类,它以抽象的方式代表文件名和目录路径名,主要用于文件和目录的创建、文件的查找和文件的删除等。
无论是 FileInputStream 还是 FileOutputStream,都依赖于 File 类。它们都需要通过 File 对象,来对磁盘中实际存在的文件进行操作。
本文重点要介绍的是 IO 操作,因此对于 File 类不做深入讨论,如有兴趣进一步了解,可自行阅读源码或者阅读以下基础教程:Java File 类
三、FileInputStream
FileInputStream 是 Java 文件流中的输入流,继承自 InputStream,可以从本地文件系统中读取字节数据。
3.1 类图
3.2 构造方法
如上图所示,FileInputStream 有 3 种构造方法:
方法名 | 方法详解 |
---|---|
FileInputStream(String name) | 通过打开一个到实际文件的连接来创建一个 FileInputStream 对象,该文件通过文件系统中的路径名 name 指定。假如文件不存在,会抛出 FileNotFoundException 异常。 |
FileInputStream(File file) | 通过打开一个到实际文件的连接来创建一个 FileInputStream 对象,该文件通过文件系统中的 File 对象 file 指定。假如文件不存在,会抛出 FileNotFoundException 异常。 |
FileInputStream(FileDescriptor fdObj) | 通过文件描述符 fdObj 创建一个 FileInputStream 对象,该文件描述符表示到文件系统中某个实际文件的现有连接。假如文件描述符为空,会抛出 NullPointerException 异常。 |
3.3 方法详解
从类图中可以看出,FileInputStream 的方法并不少。但实际上,开发人员只要重点掌握以下几个方法即可。
方法名 | 作用 |
---|---|
read() | 从文件输入流中读取一个字节数据。 |
read(byte[] b) | 从文件输入流中将最多 b.length 个字节的数据读入一个 byte 数组中。 |
read(byte[] b, int off, int len) | 从文件输入流中第 off 个位置开始,将最多 len 个字节的数据读入一个 byte 数组中。 |
skip(long n) | 从输入流中跳过并丢弃 n 个字节的数据。 |
available() | 返回流中剩余的可读字节数。 |
close() | 关闭流,并释放与该流有关联的系统资源。 |
finalize() | 确保不再持有该输入流的引用时,能调用其 close 方法。 |
3.4 应用实例
首先,我们在项目源目录下创建文本文件 panda.txt
,并在该文件中输入 4 个字符 abcd
。
然后,我们使用 FileInputStream 尝试读取 panda.txt
的数据。
private static final String FILE_PATH = FileUtil.class.getClassLoader().getResource("").getPath();
public static void readFile() {
File file = new File(FILE_PATH + File.separator + "panda.txt");
try {
InputStream in = new FileInputStream(file);
int len;
while ((len = in.read()) != -1) {
System.out.println(len);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
代码编写完后,点击运行,发现控制台打印出如下结果:97 98 99 100
。
这时候我们就遇到一个问题了。为什么我们输入的是 abcd
,结果 FileInputStream 读到的却是这 4 个数字?
答案就在下面这张表格,也是著名的 ASCII 码表的一部分。
字符 | 二进制 | 十进制 |
---|---|---|
a | 0110 0001 | 97 |
b | 0110 0010 | 98 |
c | 0110 0011 | 99 |
d | 0110 0100 | 100 |
我们一再强调,FileInputStream 是字节流,只能从文件中读取字节。因此,当你的文本文件中含有字符 a
时,FileInputStream 所获取的实际上是字符 a
在存储空间中所代表的二进制数据 0110 0001
,然后再以十进制的形式返回到我们的控制台上。
这时候我们又碰到了一个问题。如果以上论述成立的话,那么,字符与二进制之间的关系是如何确立的?
这个问题,就需要编码来解决了。在我们本例中,因为文本文件中存储的是英文字母,所以只需要 ASCII 码表就够了。但假如文本文件中含有中文呢?结果又会是怎样?
现在,我们将 panda.txt
中的数据改为 一
,然后再次运行程序,得到以下结果:228 184 128
。
wtf?我输入的是汉字,你给我的是什么玩意?实际上,这三个数字是由 UTF-8 编码将汉字 一
转化成字节数据的结果。如果此时我们将 panda.txt
的编码格式改为 GBK,会发现程序的运行结果如下:210 187
。
我们可以看到,在 UTF-8 编码中,汉字 一
占据了 3 个字节,而在 GBK 编码中,汉字 一
仅占据了 2 个字节。
综上,我们可以做如下结论:FileInputStream 只能读取字节,而字节的值,是由编码方式与字符集决定的。
四、FileOutputStream
FileOutputStream 是 Java 文件流中的输出流,继承自 OutputStream,可以向本地文件系统写入字节数据。
4.1 类图
4.2 构造方法
如上图所示,FileOutputStream 有 5 种构造方法:
方法名 | 方法详解 |
---|---|
FileOutputStream(String name) | 创建一个向指定名称的文件写入字节数据的文件输出流。假如文件不存在或者文件为根目录,会抛出 FileNotFoundException 异常。 |
FileOutputStream(String name, boolean append) | 创建一个向指定名称的文件写入字节数据的文件输出流。假如 append 为 ture,则输出的字节数据将补充在文件的尾部,否则将在文件的起始位置写入。 |
FileOutputStream(File file) | 创建一个向指定 File 对象表示的文件写入字节数据的文件输出流。假如文件不存在或者文件为根目录,会抛出 FileNotFoundException 异常。 |
FileOutputStream(File file, boolean append) | 创建一个向指定 File 对象表示的文件写入字节数据的文件输出流。假如 append 为 ture,则输出的字节数据将补充在文件的尾部,否则将在文件的起始位置写入。 |
FileOutputStream(FileDescriptor fdObj) | 创建一个向指定文件描述符写入字节数据的文件输出流,该文件描述符表示一个到文件系统中的某个实际文件的现有连接。 |
4.3 方法详解
相对 FileInputStream,FileOutputStream 的方法数量会少一些,常用的方法如下表所示。
方法名 | 作用 |
---|---|
write(int b) | 将指定字节写入到此文件输出流。 |
write(byte[] b) | 将 b.length 个字节从指定 byte 数组写入到此文件输出流。 |
write(byte[] b, int off, int len) | 从 byte 数组第 off 个位置开始,将最多 len 个字节的数据写入到此文件输出流。 |
close() | 关闭流,并释放与该流有关联的系统资源。 |
finalize() | 确保不再持有该输出流的引用时,能调用其 close 方法。 |
4.4 应用实例
首先,我们完成一个 FileOutputStream 的代码实例。
public static void writeFile() {
File file = new File(FILE_PATH + File.separator + "out.txt");
try {
OutputStream out = new FileOutputStream(file);
out.write(97);
out.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
然后我们打开 out.txt
文件,发现里面有一个字符 a
。但是,我们注意到,代码里面 write(int b) 方法的参数明明是 97 啊,为什么会变成字符 a
了?还是跟我们在 FileInputStream 中的解释一样。 FileOutputStream 只能写出字节数据! 所以你给了它一个值为 97 的参数,它不会明白 97 的意思,但是它能找到 97 对应的字符 a
。
我们再来做一件有趣的事情,试一试写入代表中文字符的字节。
public static void writeFile() {
File file = new File(FILE_PATH + File.separator + "out.txt");
try {
OutputStream out = new FileOutputStream(file);
String text = "一";
out.write(text.getBytes());
out.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
因为 FileOutputStream 不能直接写入字符串,因此我们需要调用 String 类的 getBytes() 方法,获取字节数组。
点击运行程序后,我们打开 out.txt
文件,发现里面确实写入了字符 一
。
嗯,看样子我们像是已经完成了 FileOutputStream 的学习,但是别急,我们还有一件事没做。
首先,我们查看了 out.txt
的编码,发现编码方式为 UTF-8
。这时候问题来了,我们什么时候指定编码方式了?仔细思考一下,应该是在调用 getBytes() 方法时实现了编码的设置,因此我们从 getBytes() 方法跟踪进去,最后在 Charset 类中发现了下面的代码:
public static Charset defaultCharset() {
if (defaultCharset == null) {
synchronized (Charset.class) {
String csn = AccessController.doPrivileged(
new GetPropertyAction("file.encoding"));
Charset cs = lookup(csn);
if (cs != null)
defaultCharset = cs;
else
defaultCharset = forName("UTF-8");
}
}
return defaultCharset;
}
这段代码有什么作用呢?它可以返回 JVM 的默认编码格式,而 JVM 的编码格式,又依赖于底层操作系统。因为我的系统使用的是 UTF-8
编码,因此我们在运行上面的示例代码时,默认使用了 UTF-8
。
当然,这个编码方式我们也可以自己指定。
public static void writeFile() {
File file = new File(FILE_PATH + File.separator + "out.txt");
try {
OutputStream out = new FileOutputStream(file);
String text = "一";
out.write(text.getBytes("GBK"));
out.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
点击运行后,我们使用 Binary Viewer 查看 out.txt
文件,发现里面有两个字节,值为 210 187
,刚好与我们在 FileInputStream 中的示例一致。
综上,我们可以做如下结论:FileOutputStream 只能输出字节,而字节的编码方式,可以用户自己设置,也可以使用 JVM 的默认编码。
写在最后:实际上,目前需要程序直接做文本输入输出的情况已经非常少了,大多数情况,我们可以选择使用更为专业的 Property 工具类。但是,我们还是有必要深入学习文本的输入与输出,特别是其中涉及的编码与解码。只有这部分的基础牢固了,才能在日后的开发中避免乱码的发生。