前排提醒,本章文字信息较多,建议耐心看完,你一定有所收获!!!
Java文件的操作
基本概念和常识
二进制思维
为了透彻理解文件,我们首先要有一个二进制思维。所有文件,不论是可执行文件、图片文件、视频文件、Word文件、压缩文件、txt文件,都没什么可神秘的,它们都是以0和1的二进制形式保存的。我们所看到的图片、视频、文本,都是应用程序对这些二进制的解析结果。
文件类型
文件类型通常以后缀名的形式体现,比如,PDF文件类型的后缀是.pdf,图片文件的一种常见后缀是.jpg,压缩文件的一种常见后缀是.zip。每种文件类型都有一定的格式,代表着文件含义和二进制之间的映射关系。比如一个Word文件,其中有文本、图片、表格,文本可能有颜色、字体、字号等,doc文件类型就定义了这些内容和二进制表示之间的映射关系。有的文件类型的格式是公开的,有的可能是私有的,我们也可以定义自己私有的文件格式。
文件类型可以粗略分为两类,一类是文本文件,另一类是二进制文件。文本文件的例子有普通的.txt文件, 程序源代码文件.java, HTML文件.html等,二进制文件的例子有压缩文件.zip, pdf文件, mp3文件, excel文件等。
文本文件的编码
对于文本文件,我们还必须注意文件的编码方式。文本文件中包含的基本都是可打印字符,但字符到二进制的映射,即编码,却有多种方式,如GB18030, UTF-8等等。
文件系统
我们都知道文件一般是放在电脑中的硬盘上的, 一个机器上可能有多个硬盘,但各种操作系统都会隐藏物理硬盘概念,提供一个逻辑上的统一结构。在Windows中,可以有多个逻辑盘,C, D, E等,每个盘可以被格式化为一种不同的文件系统,常见的文件系统有FAT32和NTFS。在Linux中,只有一个逻辑的根目录,用斜线/表示,Linux支持多种不同的文件系统,如Ext2/Ext3/Ext4等。不同的文件系统有不同的文件组织方式、结构和特点,不过,一般编程时,语言和类库为我们提供了统一的API,我们并不需要关心其细节。
相对路径: 是文件相对于当前目录而言的
绝对路径: 从根目录开始到当前文件的完整路径
文件读写
文件是放在硬盘上的,程序处理文件需要将文件读入内存,修改后,需要写回硬盘。操作系统提供了对文件读写的基本API,不同操作系统的接口和实现是不一样的,不过,有一些共同的概念,Java封装了操作系统的功能,提供了统一的API。
一个基本常识是,硬盘的访问延时,相比内存,是很慢的,操作系统和硬盘一般是按块批量传输,而不是按字节,以摊销延时开销,块大小一般至少为512字节,即使应用程序只需要文件的一个字节,操作系统也会至少将一个块读进来。一般而言,应尽量减少接触硬盘,接触一次,就一次多做一些事情,对于网络请求,和其他输入输出设备,原则都是类似的。
另一个基本常识是,一般读写文件需要两次数据拷贝,比如读文件,需要先从硬盘拷贝到操作系统内核,再从内核拷贝到应用程序分配的内存中,操作系统运行所在的环境和应用程序是不一样的,操作系统所在的环境是内核态,应用程序是用户态,应用程序调用操作系统的功能,需要两次环境的切换,先从用户态切到内核态,再从内核态切到用户态,问题是,这种用户态/内核态的切换是有开销的,应尽量减少这种切换。
为了提升文件操作的效率,应用程序经常使用一种常见的策略,即使用缓冲区。读文件时,即使目前只需要少量内容,但预知还会接着读取,就一次读取比较多的内容,放到读缓冲区,下次读取时,缓冲区有,就直接从缓冲区读,减少访问操作系统和硬盘 。
操作系统一般支持一种称之为内存映射文件的高效的随机读写大文件的方法,将文件直接映射到内存,操作内存就是操作文件,在内存映射文件中,只有访问到的数据才会被实际拷贝到内存,且数据只会拷贝一次,被操作系统以及多个应用程序共享。
java文件概述
流
在Java中(很多其他语言也类似),文件一般不是单独处理的,而是视为输入输出(IO - Input/Output)设备的一种。Java使用基本统一的概念处理所有的IO,包括键盘、显示终端、网络等。
这个统一的概念是流,流有输入流和输出流,输入流就是可以从中获取数据,输入流的实际提供者可以是键盘、文件、网络等,输出流就是可以向其中写入数据,输出流的实际目的地可以是显示终端、文件、网络等。
Java IO的基本类大多位于包 java.io 中,类 InputStream表示输入流,OutputStream表示输出流,而FileInputStream表示文件输入流,FileOutputStream表示文件输出流。
注意:Java 8 函数式编程中的
Stream
类和这里的 I/O stream 没有任何关系。这又是另一个例子,如果再给设计者一次重来的机会,他们将使用不同的术语。
#### 装饰器设计模式
基本的流按字节读写,没有缓冲区,这不方便使用,Java解决这个问题的方法是使用装饰器设计模式,引入了很多装饰类,对基本的流增加功能,以方便使用,一般一个类只关注一个方面,实际使用时,经常会需要多个装饰类。
Java中有很多装饰类,有两个基类,过滤器输入流FilterInputStream和过滤器输出流FilterOutputStream,所谓过滤,就类似于自来水管道,流入的是水,流出的也是水,功能不变,或者只是增加功能,它有很多子类,这里列举一些:
- 对流起缓冲装饰的子类是BufferedInputStream和BufferedOutputStream。
- 可以按八种基本类型和字符串对流进行读写的子类是DataInputStream和DataOutputStream。
- 可以对流进行压缩和解压缩的子类有GZIPInputStream, ZipInputStream, GZIPOutputStream, ZipOutputStream。
- 可以将基本类型、对象输出为其字符串表示的子类有PrintStream。
众多的装饰类,使得整个类结构变的比较复杂,完成基本的操作也需要比较多的代码,但优点是非常灵活,在解决某些问题时也很优雅。
Reader/Writer
以InputStream/OutputStream为基类的流基本都是以二进制形式处理数据的,不能够方便的处理文本文件,没有编码的概念,能够方便的按字符处理文本数据的基类是Reader和Writer,它也有很多子类:
- 读写文件的子类是FileReader和FileWriter。
- 起缓冲装饰的子类是BufferedReader和BufferedWriter。
- 将字符数组包装为Reader/Writer的子类是CharArrayReader和CharArrayWriter。
- 将字符串包装为Reader/Writer的子类是StringReader和StringWriter。
- 将InputStream/OutputStream转换为Reader/Writer的子类是InputStreamReader OutputStreamWriter。
- 将基本类型、对象输出为其字符串表示的子类PrintWriter。
File
上面介绍的都是操作数据本身,而关于文件路径、文件元数据、文件目录、临时文件、访问权限管理等,Java使用File这个类来表示。
Java NIO
以上介绍的类基本都位于包java.io下,Java还有一个关于IO操作的包java.nio,nio表示New IO,这个包下同样包括大量的类。
NIO代表一种不同的看待IO的方式,它有缓冲区和通道的概念,利用缓冲区和通道往往可以达成和流类似的目的,不过,它们更接近操作系统的概念,某些操作的性能也更高。比如,拷贝文件到网络,通道可以利用操作系统和硬件提供的DMA机制(Direct Memory Access,直接内存存取) ,不用CPU和应用程序参与,直接将数据从硬盘拷贝到网卡。
序列化和反序列化
简单来说,序列化就是将内存中的Java对象持久保存到一个流中,反序列化就是从流中恢复Java对象到内存。序列化/反序列化主要有两个用处,一个是对象状态持久化,另一个是网络远程调用,用于传递和返回对象。
Java主要通过接口Serializable和类ObjectInputStream/ObjectOutputStream提供对序列化的支持,基本的使用是比较简单的,但也有一些复杂的地方。
字节流
即以二进制的方式进行读写,主要流有:
- InputStream/OutputStream: 这是基类,它们是抽象类。
- FileInputStream/FileOutputStream: 输入源和输出目标是文件的流。
- ByteArrayInputStream/ByteArrayOutputStream: 输入源和输出目标是字节数组的流。
- DataInputStream/DataOutputStream: 装饰类,按基本类型和字符串而非只是字节读写流。
- BufferedInputStream/BufferedOutputStream: 装饰类,对输入输出流提供缓冲功能。
InputStream/OutputStream
InputStream
InputStream是抽象类,主要方法是:
public abstract int read() throws IOException
read从流中读取下一个字节,返回类型为int,但取值在0到255之间,当读到流结尾的时候,返回值为-1,如果流中没有数据,read方法会阻塞直到数据到来、流关闭、或异常出现,异常出现时,read方法抛出异常,类型为IOException,这是一个受检异常,调用者必须进行处理。read是一个抽象方法,具体子类必须实现,FileInputStream会调用本地方法。
InputStream还有如下方法,可以一次读取多个字节:
public int read(byte b[]) throws IOException
读入的字节放入参数数组b中,第一个字节存入b[0],第二个存入b[1],以此类推,一次最多读入的字节个数为数组b的长度,但实际读入的个数可能小于数组长度,返回值为实际读入的字节个数。如果刚开始读取时已到流结尾,则返回-1,否则,只要数组长度大于0,该方法都会尽力至少读取一个字节,如果流中一个字节都没有,它会阻塞,异常出现时也是抛出IOException。该方法不是抽象方法,InputStream有一个默认实现,主要就是循环调用读一个字节的read方法,但子类如FileInputStream往往会提供更为高效的实现。
批量读取还有一个更为通用的重载方法:
public int read(byte b[], int off, int len) throws IOException
读入的第一个字节放入b[off],最多读取len个字节,read(byte b[])就是调用了该方法:
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
流读取结束后,应该关闭,以释放相关资源,关闭方法为:
public void close() throws IOException
不管read方法是否抛出了异常,都应该调用close方法,所以close通常应该放在finally语句内。close自己可能也会抛出IOException,但通常可以捕获并忽略。
OutputStream
OutputStream的基本方法是:
public abstract void write(int b) throws IOException;
向流中写入一个字节,参数类型虽然是int,但其实只会用到最低的8位。这个方法是抽象方法,具体子类必须实现,FileInputStream会调用本地方法。
OutputStream还有两个批量写入的方法:
public void write(byte b[]) throws IOException
public void write(byte b[], int off, int len) throws IOException
在第二个方法中,第一个写入的字节是b[off],写入个数为len,最后一个是b[off+len-1],第一个方法等同于调用:write(b, 0, b.length);。OutputStream的默认实现是循环调用单字节的write方法,子类往往有更为高效的实现,FileOutpuStream会调用对应的批量写本地方法。
OutputStream还有两个方法:
public void flush() throws IOException
public void close() throws IOException
flush将缓冲而未实际写的数据进行实际写入,比如,在BufferedOutputStream中,调用flush会将其缓冲区的内容写到其装饰的流中,并调用该流的flush方法。基类OutputStream没有缓冲,flush代码为空。
FileInputStream/FileOutputStream
FileOutputStream
FileOutputStream的主要构造方法有:
public FileOutputStream(File file) throws FileNotFoundException
public FileOutputStream(File file, boolean append) throws FileNotFoundException
public FileOutputStream(String name) throws FileNotFoundException
public FileOutputStream(String name, boolean append) throws FileNotFoundException
有两类参数,一类是文件路径,可以是File对象file,也可以是文件路径name,路径可以是绝对路径,也可以是相对路径,如果文件已存在,append参数指定是追加还是覆盖,true表示追加,没传append参数表示覆盖。new一个FileOutputStream对象会实际打开文件,操作系统会分配相关资源。如果当前用户没有写权限,会抛出异常SecurityException,它是一种RuntimeException。如果指定的文件是一个已存在的目录,或者由于其他原因不能打开文件,会抛出异常FileNotFoundException,它是IOException的一个子类。
OutputStream只能以byte或byte数组写文件,为了写字符串,我们调用String的getBytes方法得到它的UTF-8编码的字节数组,再调用write方法,写的过程放在try语句内,在finally语句中调用close方法。
FileInputStream
FileInputStream的主要构造方法有:
public FileInputStream(String name) throws FileNotFoundException
public FileInputStream(File file) throws FileNotFoundException
参数与FileOutputStream类似,可以是文件路径或File对象,但必须是一个已存在的文件,不能是目录。new一个FileInputStream对象也会实际打开文件,操作系统会分配相关资源,如果文件不存在,会抛出异常FileNotFoundException,如果当前用户没有读的权限,会抛出异常SecurityException。
ByteArrayInputStream/ByteArrayOutputStream
不确定文件内容的长度,不希望一次性分配过大的byte数组,又希望将文件内容全部读入
ByteArrayOutputStream
ByteArrayOutputStream的输出目标是一个byte数组,这个数组的长度是根据数据内容动态扩展的。它有两个构造方法:
public ByteArrayOutputStream()
public ByteArrayOutputStream(int size)
第二个构造方法中的size指定的就是初始的数组大小,如果没有指定,长度为32。在调用write方法的过程中,如果数组大小不够,会进行扩展,扩展策略同样是指数扩展,每次至少增加一倍。
ByteArrayOutputStream有如下方法,可以方便的将数据转换为字节数组或字符串:
public synchronized byte[] toByteArray()
public synchronized String toString()
public synchronized String toString(String charsetName)
toString()方法使用系统默认编码。
ByteArrayOutputStream中的数据也可以方便的写到另一个OutputStream:
public synchronized void writeTo(OutputStream out) throws IOException
ByteArrayOutputStream还有如下额外方法:
public synchronized int size()
public synchronized void reset()
size返回当前写入的字节个数。reset重置字节个数为0,reset后,可以重用已分配的数组。
InputStream input = new FileInputStream("hello.txt");
try{
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int bytesRead = 0;
while((bytesRead=input.read(buf))!=-1){
output.write(buf, 0, bytesRead);
}
String data = output.toString("UTF-8");
System.out.println(data);
}finally{
input.close();
}
读入的数据先写入ByteArrayOutputStream中,读完后,再调用其toString方法获取完整数据。
ByteArrayInputStream
ByteArrayInputStream将byte数组包装为一个输入流,是一种适配器模式,它的构造方法有:
public ByteArrayInputStream(byte buf[])
public ByteArrayInputStream(byte buf[], int offset, int length)
第二个构造方法以buf中offset开始length个字节为背后的数据。ByteArrayInputStream的所有数据都在内存,支持mark/reset重复读取。
为什么要将byte数组转换为InputStream呢?这与容器类中要将数组、单个元素转换为容器接口的原因是类似的,有很多代码是以InputStream/OutputSteam为参数构建的,它们构成了一个协作体系,将byte数组转换为InputStream可以方便的参与这种体系,复用代码。
注
使用DataInputStream/DataOutputStream读写对象,非常灵活,但比较麻烦,所以Java提供了序列化机制,了解即可。
字符流
对于文本文件,字节流没有编码的概念,不能按行处理,使用不太方便,更适合的是使用字符流。
Java中的主要字符流 :
- Reader/Writer:字符流的基类,它们是抽象类。
- InputStreamReader/OutputStreamWriter:适配器类,输入是InputStream,输出是OutputStream,将字节流转换为字符流。
- FileReader/FileWriter:输入源和输出目标是文件的字符流。
- CharArrayReader/CharArrayWriter: 输入源和输出目标是char数组的字符流。
- StringReader/StringWriter:输入源和输出目标是String的字符流。
- BufferedReader/BufferedWriter:装饰类,对输入输出流提供缓冲,以及按行读写功能。
- PrintWriter:装饰类,可将基本类型和对象转换为其字符串形式输出的类。
字符流的具体操作与字节流的使用方法类似,只是操作的数据不一样,这里只介绍下PrintWrite。
PrintWriter的方便之处在于,它有很多构造方法,可以接受文件路径名、文件对象、OutputStream、Writer等,对于文件路径名和File对象,还可以接受编码类型作为参数,如下所示:
public PrintWriter(File file) throws FileNotFoundException
public PrintWriter(File file, String csn)
public PrintWriter(String fileName) throws FileNotFoundException
public PrintWriter(String fileName, String csn)
public PrintWriter(OutputStream out)
public PrintWriter(OutputStream out, boolean autoFlush)
public PrintWriter (Writer out)
public PrintWriter(Writer out, boolean autoFlush)
写文件时,可以优先考虑PrintWriter,因为它使用方便,支持自动缓冲、支持指定编码类型、支持类型转换等。读文件时,如果需要指定编码类型,需要使用InputStreamReader,不需要,可使用FileReader,但都应该考虑在外面包上缓冲类BufferedReader。
标准流
我们之前一直在使用System.out向屏幕上输出,它是一个PrintStream对象,输出目标就是所谓的"标准"输出,经常是屏幕。除了System.out,Java中还有两个标准流,System.in和System.err。
System.in表示标准输入,它是一个InputStream对象,输入源经常是键盘。比如,从键盘接受一个整数并输出,代码可以为:
Scanner in = new Scanner(System.in);
int num = in.nextInt();
System.out.println(num);
System.err表示标准错误流,一般异常和错误信息输出到这个流,它也是一个PrintStream对象,输出目标默认与System.out一样,一般也是屏幕。
标准流的一个重要特点是,它们可以重定向,比如可以重定向到文件,从文件中接受输入,输出也写到文件中。在Java中,可以使用System类的setIn, setOut, setErr进行重定向,比如:
System.setIn(new ByteArrayInputStream("hello".getBytes("UTF-8")));
System.setOut(new PrintStream("out.txt"));
System.setErr(new PrintStream("err.txt"));
try{
Scanner in = new Scanner(System.in);
System.out.println(in.nextLine());
System.out.println(in.nextLine());
}catch(Exception e){
System.err.println(e.getMessage());
}
标准输入重定向到了一个ByteArrayInputStream,标准输出和错误重定向到了文件,所以第一次调用in.nextLine就会读取到"hello",输出文件out.txt中也包含该字符串,第二次调用in.nextLine会触发异常,异常消息会写到错误流中,即文件err.txt中会包含异常消息,为"No line found"。
在实际开发中,经常需要重定向标准流。比如,在一些自动化程序中,经常需要重定向标准输入流,以从文件中接受参数,自动执行,避免人手工输入。在后台运行的程序中,一般都需要重定向标准输出和错误流到日志文件,以记录和分析运行的状态和问题。
文件和目录操作
File类中的操作大概可以分为三类:
- 文件元数据
- 文件操作
- 目录操作
File类
构造方法
File既可以表示文件,也可以表示目录,它的主要构造方法有:
public File(String pathname)
public File(String parent, String child)
public File(File parent, String child)
可以是一个参数pathname,表示完整路径,该路径可以是相对路径,也可以是绝对路径。还可以是两个参数,表示父目录的parent和表示孩子的child。
File中的路径可以是已经存在的,也可以是不存在的。
通过new新建一个File对象,不会实际创建一个文件,只是创建一个表示文件或目录的对象,new之后,File对象中的路径是不可变的。
文件元数据
文件名与文件路径
有了File对象后,就可以获取它的文件名和路径信息,相关方法有:
public String getName()
public boolean isAbsolute()
public String getPath()
public String getAbsolutePath()
public String getCanonicalPath() throws IOException
public String getParent()
public File getParentFile()
public File getAbsoluteFile()
public File getCanonicalFile() throws IOException
getName()返回的就是文件或目录名称,不含路径名。isAbsolute()判断File中的路径是否是绝对路径。
getPath()返回构造File对象时的完整路径名,包括路径和文件名称。getAbsolutePath()返回完整的绝对路径名。getCanonicalPath()返回标准的完整路径名, 它会去掉路径中的冗余名称如".","…" 。
如:
File f = new File("../io/test/students.txt");
System.out.println(System.getProperty("user.dir"));
System.out.println("path: " + f.getPath());
System.out.println("absolutePath: " + f.getAbsolutePath());
System.out.println("canonicalPath: " + f.getCanonicalPath());
这里,使用相对路径来构造File对象,…表示上一级目录,输出为:
/Users/majunchang/io
path: …/io/test/students.txt
absolutePath: /Users/majunchang/io/…/io/test/students.txt
canonicalPath: /Users/majunchang/io/test/students.txt
getParent()返回父目录路径,getParentFile()返回父目录的File对象,需要注意的是,如果File对象是相对路径,则这些方法可能得不到父目录,比如:
File f = new File("students.txt");
System.out.println(System.getProperty("user.dir"));
System.out.println("parent: " + f.getParent());
System.out.println("parentFile: " + f.getParentFile());
输出为:
/Users/majunchang/io
parent: null
parentFile: null
即使是有父目录的,getParent()的返回值也是null。那如何解决这个问题呢?可以先使用getAbsoluteFile()或getCanonicalFile()方法,它们都返回一个新的File对象,新的File对象分别使用getAbsolutePath()和getCanonicalPath()的返回值作为参数构造。比如,修改上面的代码为:
File f = new File("students.txt");
System.out.println(System.getProperty("user.dir"));
System.out.println("parent: " + f.getCanonicalFile().getParent());
System.out.println("parentFile: " + f.getCanonicalFile().getParentFile());
这次,就能得到父目录了,输出为:
/Users/majunchang/io
parent: /Users/majunchang/io
parentFile: /Users/majunchang/io
File类中有四个静态变量,表示路径分隔符,它们是:
public static final String separator
public static final char separatorChar
public static final String pathSeparator
public static final char pathSeparatorChar
separator和separatorChar表示文件路径分隔符,在Windows系统中,一般为"",Linux系统中一般为"/"。
pathSeparator和pathSeparatorChar表示多个文件路径中的分隔符,比如环境变量PATH中的分隔符,Java类路径变量classpath中的分隔符,在执行命令时,操作系统会从PATH指定的目录中寻找命令,Java运行时加载class文件时,会从classpath指定的路径中寻找类文件。在Windows系统中,这个分隔符一般为’;’,在Linux系统中,这个分隔符一般为’:’。
文件基本信息
除了文件名和路径,File对象还有如下方法,以获取文件或目录的基本信息:
//文件或目录是否存在
public boolean exists()
//是否为目录
public boolean isDirectory()
//是否为文件
public boolean isFile()
//文件长度,字节数
public long length()
//最后修改时间,从纪元时开始的毫秒数
public long lastModified()
//设置最后修改时间,设置成功返回true,否则返回false
public boolean setLastModified(long time)
对于目录,length()方法的返回值是没有意义的。
需要说明的是,File对象没有返回创建时间的方法,因为创建时间不是一个公共概念,Linux/Unix就没有创建时间的概念。
文件操作
文件操作主要有创建、删除、重命名。
创建
新建一个File对象不会实际创建文件,但如下方法可以:
public boolean createNewFile() throws IOException
创建成功返回true,否则返回false,新创建的文件内容为空。如果文件已存在,不会创建。
删除
File类如下删除方法:
public boolean delete()
public void deleteOnExit()
delete删除文件或目录,删除成功返回true,否则返回false。如果File是目录且不为空,则delete不会成功,返回false,换句话说,要删除目录,先要删除目录下的所有子目录和文件。
deleteOnExit将File对象加入到待删列表,在Java虚拟机正常退出的时候进行实际删除。
重命名
方法为:
public boolean renameTo(File dest)
参数dest代表重命名后的文件,重命名能否成功与系统有关,如果成功返回true,否则返回false。
目录操作
创建
有两个方法用于创建目录:
public boolean mkdir()
public boolean mkdirs()
它们都是创建目录,创建成功返回true,失败返回false。需要注意的是,如果目录已存在,返回值是false。这两个方法的区别在于,如果某一个中间父目录不存在,则mkdir会失败,返回false,而mkdirs则会创建必需的中间父目录。
遍历
有如下方法访问一个目录下的子目录和文件:
public String[] list()
public String[] list(FilenameFilter filter)
public File[] listFiles()
public File[] listFiles(FileFilter filter)
public File[] listFiles(FilenameFilter filter)
它们返回的都是直接子目录或文件,不会返回子目录下的文件。list返回的是文件名数组,而listFiles返回的是File对象数组。
计算一个目录下的所有文件的大小(包括子目录),代码可以为:
public static long sizeOfDirectory(final File directory) {
long size = 0;
if (directory.isFile()) {
return directory.length();
} else {
for (File file : directory.listFiles()) {
if (file.isFile()) {
size += file.length();
} else {
size += sizeOfDirectory(file);
}
}
}
return size;
}
再比如,在一个目录下,查找所有给定文件名的文件,代码可以为:
public static Collection<File> findFile(final File directory,
final String fileName) {
List<File> files = new ArrayList<>();
for (File f : directory.listFiles()) {
if (f.isFile() && f.getName().equals(fileName)) {
files.add(f);
} else if (f.isDirectory()) {
files.addAll(findFile(f, fileName));
}
}
return files;
}