Java基础教程(25)--I/O
一.I/O流
I/O流表示输入源或输出目标。流可以表示许多不同类型的源和目标,例如磁盘文件、设备、其他程序等。
流支持许多不同类型的数据,包括字节、原始数据类型、字符和对象等。有些流只传递数据; 有些流则可以操纵和转换数据。
无论各种流的内部是如何工作的,所有流都提供相同的简单模型:流是一系列数据。程序使用输入流从源头获取数据,一次一项:
程序使用输出流将数据写入目的地,一次一项:
在本文中,我们会看到流可以处理各种各样的数据,无论是基本数据还是复杂对象。先来几张IO流的全家福:
InputStream家族:
OutputStream家族:
Reader家族:
Writer家族:
点击此处可查看完整大图。
1.字节流
一个字节(byte)代表8个二进制位(bit)。字节流,顾名思义,它所传递和操作的最小单位是字节。所有的字节流类都是抽象类InputStream和OutputStream的子类。
I/O体系中有许多字节流类。为了演示字节流如何工作,我们选择了文件I/O字节流——FileInputStream和FileOutputStream。其他字节流类的使用方式都大致相同,不同之处主要在于它们的构造方式。
下面的程序使用字节流将src.txt中的文本复制到dest.txt中,每次一个字节:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class CopyBytes {
public static void main(String[] args) throws IOException {
FileInputStream in = null;
FileOutputStream out = null;
try {
in = new FileInputStream("src.txt");
out = new FileOutputStream("dest.txt");
int c;
while ((c = in.read()) != -1) {
out.write(c);
}
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
在while循环中,程序每次从输入流in中读取一个字节,然后再将这个字节写入输出流out中,直到输入流中的字节全部被读取完。
在不再需要流时关闭流是非常重要的。上面的程序中使用了finally块来保证无论是否发生错误都要关闭流。
虽然上面的程序看起来很正常,但是实际上我们应该避免使用这种低级的,或者说底层的I/O。由于src.txt文件中存储的是字符数据,因此我们应该使用字符流,我们马上会在下一节中见到它。字节流应该仅用于最原始的I/O。那么为什么要谈论字节流呢?因为所有其他流类型都是基于字节流构建的。
2.字符流
所有的字符流类都是Reader和Writer的子类。为了演示字符流如何工作,和上一节一样,这里我们选择文件I/O字符流——FileReader和FileWriter。
下面的程序使用字符流将src.txt中的文本复制到dest.txt中,每次一个char(注意,这里不是每次一个字符,因为有些字符无法使用一个char类型来表示,具体可以参考我之前的文章《Java基础教程(5)--变量》中关于char类型的介绍):
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public class CopyCharacters {
public static void main(String[] args) throws IOException {
FileReader inputStream = null;
FileWriter outputStream = null;
try {
inputStream = new FileReader("src.txt");
outputStream = new FileWriter("dest.txt");
int c;
while ((c = inputStream.read()) != -1) {
outputStream.write(c);
}
} finally {
if (inputStream != null) {
inputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
}
}
}
字符流通常是字节流的“包装器”。字符流使用字节流来执行物理I/O,而字符流则负责处理字符和字节之间的转换。FileReader内部使用FileInputStream来读取数据,而FileWriter则使用FileOutputStream来写入数据。
FileReader和FileWriter是专门用于文件读写的字符流。如果需要从其他的源读取字符,或者需要向其他目标写入字符,可以使用InputStreamReader和OutputStreamWriter来定制自己的流。这两个类只是简单的从输入源读取字符和向输出目标写入字符,我们可以使用它们自定义输入源和输出目标。
FileReader和FileWriter所处理的最小单位是char类型。实际上,还可以每次处理一行字符。行是指一串字符与末尾的行终止符。现在我们修改上面的程序,来让我们的程序每次处理一行字符。这里会使用到两个新的类——BufferedReader和PrintWriter,我们会在下一节更深入地讨论这些类:
import java.io.FileReader;
import java.io.FileWriter;
import java.io.BufferedReader;
import java.io.PrintWriter;
import java.io.IOException;
public class CopyLines {
public static void main(String[] args) throws IOException {
BufferedReader inputStream = null;
PrintWriter outputStream = null;
try {
inputStream = new BufferedReader(new FileReader("src.txt"));
outputStream = new PrintWriter(new FileWriter("dest.txt"));
String l;
while ((l = inputStream.readLine()) != null) {
outputStream.println(l);
}
} finally {
if (inputStream != null) {
inputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
}
}
}
3.缓冲流
到目前为止,我们上面的例子使用的都是无缓冲的I/O。这意味着每次读取或写入请求都由底层操作系统直接处理。这样会使得程序的效率低很多,因为每个请求都会触发磁盘访问、网络请求或其他代价相对较高的操作。
为了减少这种开销,Java平台实现了使用缓冲的I/O流。缓冲输入流从称为缓冲区的存储区读取数据,仅当缓冲区为空时才会重新获取数据。类似地,缓冲输出流将数据写入缓冲区,并且仅在缓冲区已满时才将数据写入输出目标。
下面的两个例子将无缓冲的流转换为缓冲流:
inputStream = new BufferedReader(new FileReader("src.txt"));
outputStream = new BufferedWriter(new FileWriter("dest.txt"));
有四个缓冲流类用于包装无缓冲流:BufferedInputStream与BufferedOutputStream创建缓冲字节流,而 BufferedReader与BufferedWriter创建缓冲字符流。
有时候我们需要再缓冲区未填充满的时候就将它写出缓冲区,此时就需要刷新缓冲区。一些缓冲输出流支持自动刷新,这个行为由可选的构造方法参数指定。在启用自动刷新时,某些关键事件会导致刷新缓冲区。例如,PrintWriter会在每次调用println和format的时候刷新缓冲区。如果要手动刷新缓冲区,可以调用该输出流的flush方法。
4.格式化
通过使用具有格式化功能的流可以将数据转换为格式标准、易于阅读的形式。提供格式化功能的流是是字符流类PrintWriter和字节流类PrintStream。
注意,唯一需要使用PrintStream的地方是System.out和System.err(具体内容见下一小节)。当你需要格式化输出流,应该使用PrintWriter而不是PrintStream。
正如其他输出流一样,PrintStream和PrintWriter为简单的字节或字符输出提供了一组write方法。除此之外,它们还提供了两种格式化方法来对输出内容进行格式化:
- print和println——以标准方式格式化单个值。
- format——基于格式化字符串来对任意数量的值进行格式化。
print和println方法
print和println用于打印单个变量的值,如果是对象则会打印对该对象调用toString方法后返回的字符串,它们唯一的区别是println会在每次调用后换行,而print则不会。下面是一个使用print和println的例子:
public class Root {
public static void main(String[] args) {
int i = 2;
double r = Math.sqrt(i);
System.out.print("The square root of ");
System.out.print(i);
System.out.print(" is ");
System.out.print(r);
System.out.println(".");
i = 5;
r = Math.sqrt(i);
System.out.println("The square root of " + i + " is " + r + ".");
}
}
该程序将会输出:
The square root of 2 is 1.4142135623730951.
The square root of 5 is 2.23606797749979.
format方法
format方法使用指定的格式字符串对多个参数进行格式化。格式字符串是指嵌入格式说明符的字符串。格式字符串支持非常多的格式,本文中只会介绍一些基础知识。有关完整说明请参考官方提供的格式字符串语法。
下面的例子调用了一次format方法,但同时格式化了两个值:
public class Root2 {
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);
}
}
下面是输出:
The square root of 2 is 1.414214.
上面的例子中,%d、%f和%n是三个格式说明符。所有的格式说明符都以一个%开头,并以一个或两个字符结束。这里使用的三个格式说明符是:
- %d——将整数值转换为十进制数。
- %f——将浮点数值转换为十进制数。
- %n——表示基于当前平台的行结束符。
还有很多格式说明符,由于篇幅所限,再加上本文的重点是I/O流,因此这里就不再列举其他格式说明符和其他格式化的细节,感兴趣的读者可以自行查阅官方文档。
5.标准流
标准流是许多操作系统的特性。默认情况下,它们从键盘读取输入并将输出写入显示器。它们还支持文件I/O以及程序之间的I/O,但该功能由命令行解释器控制,而不是程序。
Java平台支持三种标准流:标准输入,通过System.in访问;标准输出,通过System.out访问;标准错误,通过System.err访问。这些流是自动定义的并且不需要打开。标准输出和标准错误均用于输出。
你可能觉得标准流是字符流,但由于历史原因,它们实际上是字节流。System.out和System.err都是PrintStream类型。虽然从技术上讲它们是字节流,但是PrintStream内部利用字符流对象来模拟字符流的许多功能。
相比之下,System.in是一个没有字符流功能的字节流。如果要使用标准输入作为字符流,可以使用InputStreamReader或Scanner对它进行包装。
6.数据流
文本格式是易于人类阅读的,因此使用起来很方便。但是它并不像以二进制格式传递数据那样高效。下面我们将会学习如何用二进制数据来完成输入和输出。
DataOutput接口定义了一组用于以二进制格式写各种类型的值的方法。例如,writeInt总是将一个整数写出为4字节的二进制整数,writeDouble总是将一个Double值写出为8字节的二进制浮点数。这样产生的结果并非人类可阅读的,但是对于给定类型的每个值,所需的空间都是相同的,而且将其读回也比解析文本要更快。
同理,为了读取二进制数据,可以使用在DataInput接口中定义的一组方法。DataInputStream类实现了DataInput接口。为了读入二进制数据,可以将DataInputStream与某个字节流相结合,例如:
DataInputStream in = new DataInputStream(new FileInputStream("a.dat"));
类似的,如果要写出二进制数据,可以使用实现了DataOutput接口的DataOutputStream类:
DataOutputStream out = new DataOutputStream(new FileOutputStream("a.dat"));
7.对象流
就像数据流支持基本数据类型的I/O,对象流支持对象的I/O。Java支持一种称为对象序列化的机制,它可以将对象写出到对象输出流中,也可以从对象输入流中将对象读入。这样我们就可以将对象通过文件、网络等环境来传递并随时将其恢复。所有需要在对象输出流中存储或从对象输入流中恢复的类都必须实现Serializable接口,这只是一个标记接口,它没有定义任何方法。
Java中提供的对象输入流和输出流分别是ObjectInputStream和ObjectOutputStream。通过ObjectOutputStream的writeObject方法可以将一个对象写入到输出流中,通过ObjectInputStream的readObject方法则可以从输出流中读取一个对象。
对于一些所有域都是基本数据类型的对象来说,对其进行序列化是很简单的。但是对于某些域是引用类型的对象来说,在对这些对象进行序列化时,还要对其引用的对象也进行序列化,并且这些引用的对象内部可能还含有对其他对象的引用。在这种情况下,writeObject方法将会遍历该对象内部所有的引用,并将这些对象写入流。
下图演示了这种情况。调用writeObject方法将a对象写入流,但该对象包含了对对象b和c的引用,而b包含对d和e的引用。在将a写入输出流时,会同时写入其他四个对象。当读回a时,也会读回其他四个对象,并保留原有的引用关系。
您可能想知道如果将一个对象向同一个流中写入两次会发生什么。当它们被读回时,还会引用同一个对象吗?答案是肯定的。无论写入多少次,流只会包含一个对象的副本。因此,如果明确地将对象写入流两次,那么实际上只写入了两次引用。例如,下面的代码将对象ob写入两次:
Object ob = new Object();
out.writeObject(ob);
out.writeObject(ob);
每次调用readObject方法都会读回一个ob对象:
Object ob1 = in.readObject();
Object ob2 = in.readObject();
System.out.println(ob1 == ob2);
上面的程序会输出true,因为ob1和ob2引用了同一个对象。但是,如果将一个对象写入两个不同的流,那么读回的将是两个不同的对象。
这里还要介绍一个关键字transient。若类的某个域被transient修饰,则在将该类的实例序列化时将不会对该字段进行序列化。当从输入流中读入对象时,该域将会使用默认值填充。
二.文件操作
上文中提到的文件输入和输出流只能用于获取和写入文件内容,不能对这个文件进行移动、复制、删除等操作,且写入文件时只能覆盖或在文件尾部追加,而不能修改文件内容。这一节将会讨论有关文件的操作以及如何对文件内容进行随机读写。
1.File类
File类是java.io包中代表与平台无关的文件和目录。注意,File对象不仅可以表示文件,还可以用来表示目录。如果希望在程序中操作文件和目录,都可以通过File类来完成。File能对文件或目录进行新建、删除、重命名等操作,但它不能访问文件内容本身。
下面是File类的四个构造方法:
方法 | 描述 |
---|---|
File(String pathname) | 根据指定的路径名称创建一个File实例 |
File(File parent, String child) | 根据父路径和子路径名称创建一个File实例 |
File(String parent, String child) | 根据父路径名称和子路径名称创建一个File实例 |
File(URI uri) | 根据指定的URI创建一个File实例 |
创建File实例之后,并不是真的创建了一个文件或目录,而是对指定路径的一个抽象,通过File对象可以判断当前路径对应的文件或目录是否存在,或者对其进行复制、删除等操作。下面是一些常用的操作:
- boolean createNewFile()
根据当前File对象对应的路径创建文件,若文件不存在且创建成功则返回true,若文件不存在则返回false。 - boolean delete()
删除当前文件或目录,删除成功返回true,若目录非空或因其他原因删除失败则返回false。 - boolean exists()
判断当前文件或目录是否存在。 - public boolean mkdir()
根据当前路径创建目录。若父目录不存在或当前目录创建失败则返回false。 - public boolean mkdirs()
根据当前路径创建目录。若父目录不存在则创建所有不存在的父目录。
File类中还提供了许多其他操作文件和查看文件属性的方法,这里不再一一列举,必要时可查看官方API文档。
2.RandomAccessFile
RandomAccessFile是一个功能丰富的文件内容访问类,它提供了众多的方法来访问文件内容,它既可以读取文件内容,也可以向文件中写入数据。与I/O流不同的是,它支持“随机访问”的方式,即程序可以直接跳转到文件的任意位置来读写数据。
RandomAccessFile对象包含了一个指针,用以表示当前读写处的位置。当创建一个RandomAccessFile对象时,该指针位于文件头(也就是0处),当读写了n个字节后,指针会向后移动n个字节。此外,还可以手动移动该指针,既可以向前,也可以向后。
RandomAccessFile类中定义了以下两个方法来操作指针:
- long getFilePointer()
返回指针当前所在位置。 - void seek(long pos)
移动指针至指定位置。
RandomAccessFile类有两个构造器,一个使用File对象来指定文件,另一个使用String参数来指定文件路径。此外,这两个构造器还需要制定另外一个mode参数,该参数用于指定对指定文件的访问模式,该参数有以下4个值:
- “r”:以只读方式打开文件。
- “rw”:以可读可写方式打开文件。
- “rwd”:以可读可写方式打开文件。相对于“rw”模式,还要求对文件内容的每个更新都写入到底层存储设备。
- “rws”:以可读可写方式打开文件。相对于“rw”模式,还要求对文件的元数据和文件内容的每个更新都写入到底层存储设备。
这里解释一下元数据的概念。元数据,即metadata,可以理解为关于数据的数据。我们将数据存储在文件中,可以将文件看作数据,那么关于文件本身的数据就可以称为元数据,例如修改时间、拥有者、访问权限等。
当使用“rw”模式读写文件时,只有在关闭文件的时候才会将修改同步到存储设备上;使用“rwd”模式时,每次对于文件内容的修改都会同步到存储设备上,但只有在关闭文件时才会同步文件的元数据;使用“rws”模式时,每次对文件的元数据和文件内容的修改都会同步到存储设备上。
RandomAccessFile类提供了一系列readXX和writeXX方法来支持各种类型的数据的读取和写入,这里不再一一列出。需要注意的是,每次操作完文件后要记得调用close方法来关闭文件。
下面是对RandomAccessFile类的读取、写入、移动指针等操作的一个演示程序。这个程序将指定文件中的大写字母转换为小写:
import java.io.IOException;
import java.io.RandomAccessFile;
public class RandomAccessFileDemo {
public static void main(String[] args) {
try {
RandomAccessFile raf = new RandomAccessFile("a.txt", "rw");
for (int i = 0, b; (b = raf.read()) != -1; i++) {
if (b >= 65 && b < 90) {
raf.seek(i);
raf.write(b + 32);
}
}
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}