JAVA核心技术笔记总结--第11章 输入输出流
第11章 输入/输出流
Java的IO通过java.io包下的类和接口来支持,在java.io包下主要包括输入、输出两种IO流。每种输入、输出流又可以分为字节流、字符流两大类。其中字节流以字节为单位处理输入、输出操作,而字符流则以字符为单位来处理输入、输出操作。
此外,java的IO流使用一种装饰器设计模式,将IO流分成底层节点流和上层处理流。节点流用于和底层的物理存储节点直接关联,不同类型的物理节点获取节点流的方法存在差异,但程序可以把不同的物理节点流包装成统一的处理流,从而允许程序使用统一的输入、输出方法来读取不同物理存储节点的资源。
11.1 File类
File代表与平台无关的文件和目录,也就是说,不管在程序中操作文件还是目录,都可以通过File类完成,File能新建、删除、重命名文件和目录,但是File不能访问文件内容本身。如果需要访问文件内容本身,则需要使用输入\输出流。
11.1.1 访问文件和目录
File类可以使用文件路径字符串来创建File实例,该文件路径字符串既可以是绝对路径,也可以是相对路径。默认情况下,相对路径指用户的工作路径,即运行java虚拟机时所在的路径。例如:
//用相对路径创建文件,工作路径为java工程所在的文件夹
File file = new File("out.txt");
//相对路径创建目录的File对象
File file = new File("out");
//绝对路径创建
File file = new File("C:\Users\Echo\Desktop\untitled\out.txt");
一旦创建了File对象后,就可以调用File对象的方法来操作文件和目录。下面列出了一些常用的方法。
1.访问文件名相关的方法
String getName():返回此File对象所表示的文件名或目录名。
String getPath():返回此File对象(文件或目录)的路径名,若为相对路径,就给出相对路径名,否则给出绝对路径名。
File getAbsolutePath():返回此File对象的绝对路径名。
File getAbsoluteFile():返回此File对象的绝对路径。等价于用绝对路径创建的File对象。
String getParent():返回File对象的上级目录名。若File对象所在路径为相对路径的根目录,则返回null。否则,对于相对路径和绝对路径均返回上级目录。
boolean renameTo(File newName):重命名此File对象对应的文件,如果命名成功,则返回true,否则返回false。要求调用方法的File对象对应的文件必须存在,同时newName对象对应的文件不存在。方法的作用是:将调用方法的File对象对应的文件重命名为newName对应的文件名,并剪切到newName对应的路径中。若调用方法的File对象是目录,那么调用方法后,整个目录被剪切过去,包括目录里的文件和子目录。若原File对象是目录,则rename后的对象仍是目录;反之则为文件。
2.文件检测相关的方法(对文件和目录均适用)
boolean exists():判断File对象对应的文件或目录是否存在。
boolean canWrite():判断File对象对应的文件和目录是否可写。
boolean canRead():判断File对象绑定的文件和目录是否可读。
boolean isFile():判断File对象对应的是否是文件,而不是目录。
boolean isDirectory():判断File对象是否是目录,而不是文件。
boolean isAbsolute():判断File对象所对应的文件或目录是否是绝对路径。该方法消除了不同平台的差异,可以直接判断File对象是否为绝对路径。
3.获取常规文件信息(对文件和目录都适用)
long lastModified():返回文件的最后修改时间。
long length():返回文件内容的长度。
4.文件操作相关的方法
boolean createNewFile():当调用方法的File对象对应的文件不存在时,该方法将新建一个该File对象所指定的新文件,如果创建成功,则返回true,如果File对象对应的文件存在,则返回false。只用于创建文件。
boolean delete():删除File对象所对应的文件或目录。
static File createTempFile(String prefix,String suffix):在默认的临时文件目录中创建一个临时空文件,使用指定前缀、系统生成的随机数和给定后缀作为文件名。这是一个静态方法。prefix参数至少是3字节长。suffix可以为null,在这种情况下,将使用默认的后缀".tmp"。
static File createTempFile(String prefix, String suffix, File directory):在directory指定的目录中创建一个临时的空文件,使用给定前缀、系统生成的随机数和给定后缀作为文件名。是静态方法。
void deleteOnExit():注册一个删除钩子,指定当java虚拟机退出时,删除File对象对应的文件或目录。
5 目录操作相关的方法
boolean mkdir():试图创建一个File对象对应的目录,如果目录存在,则返回false,否则创建该目录,并返回true。
String[] list():列出File对象对应目录下的所有文件名和目录名,返回String数组。
File[] listFiles():列出File对象对应目录下的所有文件和目录的File对象,返回File数组,当调用方法的File对象是绝对路径时,得到哦File数组都是绝对路径。反之,都是相对路径。
static File[] listRoots():列出系统所有的根路径。这是静态方法。
Windows的路径分隔符使用反斜线(),而java程序中的反斜线表示转移字符,所以如果需要在windows的路径下包括反斜线,则应该使用两条反斜线,如F:\abc\test.txt,或者直接使用斜线(/),java支持将斜线当成平台无关的路径分隔符。
11.1.2 文件过滤器
在File类的list()方法中可以接收一个FilenameFilter参数,通过该参数可以列出符合条件的文件。
FilenameFilter是函数式接口,里面包含一个accept(File dir, String name)方法,该方法将依次对File对象目录下的目录或文件进行过滤,如果该方法返回true,则list()会列出该子目录或文件。例如:
public void main(String[] args){
File file = new File(".");
//只列出.java结尾的文件或目录
String[] nameList = file.list((dir,name)->name.endsWith(".java") || new File(name).isDirectory());
}
11.2 理解java的IO流
java的IO流是实现输入、输出的基础,它可以方便地实现数据的输入/输出操作,在java中不同的输入/输出源(键盘、文件、网络连接等)抽象表述为”流“(stream),通过流的方式允许java程序使用相同的操作访问不同的输入/输出源。stream是从起源(source)到接收(sink)的有序数据。
java把所有传统的流类型(类或抽象类)都放在java.io包类,用以实现输入/输出功能。
11.2.1 流的分类
1.输入流和输出流
按照流的流向来分,可以分为输入流和输出流。
- 输入流:只能从中读数据,不能向其中写数据。
- 输出流:只能向其写数据,不能从中读数据。
java的输入流主要由InputStream和Reader作为基类,而输出流主要由OutputStream和Writer作为基类。他们都是抽象类,无法创建实例。
2.字节流和字符流
字节流和字符流的用法几乎相同,区别在于:字节流操作的数据单元是8位的字节,而字符流操作的数据单元是16位的字符。
3 节点流和处理流
按照流的角色来分,可以分为节点流和处理流
可以从/向一个特定的IO设备(如磁盘、网络)读/写数据的流,称为节点流,节点流也被称为低级流。
当使用节点流进行输入/输出时,程序直接连接到实际的数据源,和实际的输入/输出节点连接。
处理流用于对一个已存在的节点流进行封装,通过封装后的流实现读/写功能。处理流也被称为高级流。
当使用处理流进行输入/输出时,程序并不会直接连接到实际的数据源,没有和实际的输入/输出节点连接。使用处理流的好处是,只要使用相同的处理流,程序就可以采用相同的读/写方法访问不同的数据源,随着处理流所包装节点流的变化,程序实际访问的数据源也相应的变化。
java使用处理流来包装节点流是一种典型的装饰器设计模式,通过处理流包装不同的节点流,既可以消除不同节点流的实现差异,也可以提供更方便的方法来完成输入/输出的功能。
11.2.2 流的概念模型
java的IO流的40多个类是从如下4个抽象基类派生的。
- InputStream/Reader:所有输入流的基类,前者是字节输入流,后者是字符输入流。
- OutputStream/Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。
11.3 字节流和字符流
11.3.1 InputStream和Reader
在InputStream里包含如下三个方法:
int read():从输入流中读取单个字节,返回所读取的字节数据(字节数据可直接转换为int类型)。
int read(byte[] b):从输入流中最多读取b.length个字节数据,并将其存储在字节数组b中,返回实际读取的字节数。
int read(byte[] b,int off, int len):从输入流中最多读取len个字节的数据,并将其存储在数组b中,从off偏移处开始存放,返回实际读取的字节数。
在Reader里包含如下三个方法。
int read():从输入流中读取单个字符,返回所读取的字符数据(字符数据可直接转换为int类型)。
int read(char[] b):从输入流中最多读取b.length个字符数据,并将其存储在字符数组b中,返回实际读取的字符数。
int read(char[] b,int off, int len):从输入流中最多读取len个字符的数据,并将其存储在数组b中,从off偏移处开始存放,返回实际读取的字符数。
当read(char[] b)或read(byte[] b)返回-1时,表明到了输入流的结束点。
InputStream和Reader都是抽象类,本身不能创建实例,但他们分别有一个用于读取文件的输入流:FileInputStream和FileReader,它们都是节点流----直接和指定文件连接。示例如下:
FileInputStream file = new FileInputStream("E:\\读书笔记\\临时笔记\\疑问.md");
byte[] buf = new byte[1024];
int hasRead = 0;
while((hasRead = file.read(byte)) > 0){
out.println(new String(byte,0,hasRead));
}
file.close();
除了上述三个方法之外,InputStream和Reader还支持如下几个方法来移动记录指针。
void mark(int readAheadLimit):在记录指针当前位置记录一个标记(mark)。
boolean markSupported():判断此输入流是否支持mark操作,即是否支持记录标记。
void reset():将此流的记录指针重新定位到上次记录标记(mark)的位置。
long skip(long n):记录指针向前移动n个字节/字符 。
11.3.2 OutPutStream和Writer
OutPutStream和Writer都提供流如下三个方法。
void write(int c):将指定的字节/字符输出到输出流中,其中c既可以代表字节,也可以代表字符。
void write(byte[]/char[] buf):将字节数组/字符数组中的数据输出到指定输出流中。
void write(byte[]/char[] buf,int off,int len):将字节/字符数组中从off位置开始,长度为len的字节/字符输出到输出流中。
因为字符流以字符为操作单位,所以Writer可以用字符串来代替字符数组,即以String对象来作为参数。Writer里还包含如下两个方法。
void write(String str):将str字符串里包含的字符输出到指定输出流。
void write(String str, int off,int len):将str字符串里从off位置开始,长度为len的字符输出到指定输出流中。
使用java的IO流执行输出时,不要忘记关闭输出流,关闭输出流除了保证物理资源被回收之外,还可以将输出流缓冲区中的数据flush到物理节点里(因为在执行close()方法之前,自动执行输出流的flush()方法)。java的很多输出流都默认提供了缓冲功能,所以最好都关闭。
11.4 输入/输出流体系
11.4.1 处理流的用法
使用处理流的典型思路是,先创建节点流,然后使用处理流来包装节点流,程序通过处理流来执行输入/输出功能,让节点列与底层I/O设备、文件交互。
识别处理流方法是:流的构造器参数不是物理节点,而是已经存在的流,而所有节点流都是直接以物理IO节点作为构造器参数。
下面使用PrintStream处理流来包装OutputStresm,使用处理流后的输出流在输出时更加方便。
try(
FileOutputStream fos = new FileOutputStream("test.txt");
PrintStream ps = new PrintStream(fos))
{
ps.println("普通字符串");
ps.println(new PrintStreamTest());
}
catch(IOException e){
e.printStackTrace();
}
PrintStream类的输出功能非常强大,通常如果需要输出文本内容,都应该将输出流包装成PrintStream后进行输出。System.out的类型就是PrintStream。
在使用处理流包装节点流后,关闭输入/输出流资源时,只要关闭最上层的处理流即可。关闭最上层的处理流时,系统会自动关闭封装的节点流。
11.4.2 输入/输出流体系
Java把IO流按功能分类,而每类中又分别提供了字节流和字符流(有些流仅有字节流或字符流之一),字节流和字符流又分别提供输入流和输出流两大类。下表显示了输入/输出流体系中常用的流分类。
流分流 | 分类 | 字节输入流 | 字节输出流 | 字符输入流 | 字符输出流 |
---|---|---|---|---|---|
抽象基类 | InputStream | OutputStream | Reader | Writer | |
节点流 | 访问文件 | FileInputStream | FileOutStream | FileReader | FileWriter |
节点流 | 访问数组 | ByteArrayInputStream | ByteArrayOutStream | CharArrayReader | CharArrayWriter |
节点流 | 访问管道 | PipedInputStream | PipedOutStream | PipedReader | PipedWriter |
节点流 | 访问字符串 | StringReader | StringWriter | ||
处理流 | 缓存流 | BufferedInputStream | BufferedOutputStream | BufferedReader | BufferedWriter |
处理流 | 转换流 | InputStreamReader | OutputStreamWriter | ||
处理流 | 对象流 | ObjectInputStream | ObjectOutputStream | ||
处理流 | 抽象基类(过滤) | FilterInputStream | FilterOutputStream | FilterReader | FilterWriter |
处理流 | 打印流 | PrintStream | PrintWriter | ||
处理流 | 推回输入流 | PushbackInputStream | PushbackReader | ||
处理流 | 特殊流 | DataInputStream | DataOutputStream |
通常来说字节流的功能比字符流强大,因为计算机里的数据都是二进制的,字节流能处理所有的二进制文件。但是使用字节流来处理文本文件,需要把字节转换为字符,增加了复杂度。所以通常规则是:如果输入/输出的内容为文本内容,则使用字符流,例如文件;如果输入/输出的内容是二进制内容,则使用字节流,例如对象的序列化。
计算机的文件分为文本文件和二进制文件两大类--所有能用记事本打开并看到其中字符内容的文件称为文本文件,反之则称为二进制文件。实际上所有的文件都是二进制文件。当文件内容是所有字符的二进制编码时,就称为文本文件。
表中列出的访问管道的流,都是用于实现进程之间通信的。
表中的4个缓冲流增加了缓存功能,缓冲可以提高输入、输出的效率,增加缓冲功能后,需要使用flush()才可以将缓冲区的内容写入实际的物理节点。
对象流主要用于实现对象的序列化。
11.4.3 转换流
输入/输出流体系中还提供了两个转换流,这两个转换流用于实现将字节流转换成字符流,其中InputStreamReader将字节输入流转换成字符输入流,OutputStreamWriter将字节输出流转换成字符输出流。示例如下:
InputStreamReader read = new InputStreamReader(System.in);
BufferedReader br = new BufferedReader(read);
System.in是InputStream的实例,代码中将其转换为字符输入流,由于BufferedReader流带有缓存功能,它的readLine()方法可以一次读取一行内容,所以将字符输入流又包装成代理流。
由于BufferedReader具有一个readLine()方法,可以一次读入一行内容,所以经常把读入文本内容的输入流包装成BufferedReader。
11.4.4 推回输入流
在输入/输出流体系中,有两个特殊的流:PushBackInputStream和PushBackReader,它们都提供了如下三个方法:
void unread(int b):将一个字节/字符推回到推回缓冲区。
void unread(byte[]/char[] buf):将一个字节/字符数组推回到推回缓冲区里,从而允许再次读取刚刚这些的内容。
void unread(byte[]/char[] buf, int off, int len):将一个字节/字符数组里从off开始,长度为len字节/字符的内容推回到推回缓冲区,从而允许重复读取刚刚读取的内容。
两个推回输入流都带有推回缓冲区,当程序调用这两个推回输入流的unread方法时,系统会将指定数组的内容推回到数组缓冲区,而推回输入流每次调用read()方法时,总是先从推回缓冲区读取,只有完全读取了推回缓冲区的内容后,但还没有装满read()所需的数组时,才会从原输入流中读取。
在创建推回输入流对象时,需要指定推回缓冲区的大小,默认的推回缓冲区的长度为1。当程序中推回到推回缓冲区的内容超出了推回缓冲区的容量时,将会引发Pushback buffer overflow的IOException异常。
PushbackReader pr = new PushbackReader(new FileReader("Mytest.java"),64);
try{
char[] buf = new char[32];
pr.read(buf);
pr.unread(buf,0, 3);
}
finally{
pr.close();
}
11.5 重定向标准输入/输出
在System类里提供了如下三个重定向标准输入/输出的方法。
static void setIn(InputStream in):重定向“标准”输入流。
static void setErr(PrintStream err):重定向“标准”错误输出流。
static void setOut(PrintStream out):重定向“标准”输出流。
输出流重定向示例如下:
try(
FileOutputStream fp = new FileOutputStream("a.txt");
FileInputStream in = new FileInputStream("b.txt");
){
PrintStream out = new PrintStream(fp);
System.setOut(out);
System.setIn(in);
Scanner under = new Scanner(System.in);
while(under.hasNext()){
System.out.println(under.nextLine());
}
under.close();
out.close();
}
如上所示,在一段代码中,可以根据需要将输出流重定向到不同的设备中。重定向后,还需通过Scanner对象实现输出,通过System.out输出。
11.6 Java虚拟机读写其他进程的数据
使用Runtime对象的exec()方法可以运行平台上其他程序,该方法创建一个Process对象,代表由Java程序创建的子进程。Process类提供了如下三个方法,实现程序和子进程之间的通信。
InputStream getErrorStream():获取子进程的错误流。
InputStream getInputStream():获取子进程的输入流
OutputStream getOutputStream():获取子进程的输出流
三个方法的输入输出方向都是对于父进程来说的,是父进程的输入流、输出流。例如第二个方法是作为父进程的输入流,对于子进程来说,是输出流。
示例如下:
String[] arg = {"cmd.exe", "/C","java C:\\Users\\Echo\\myThread"};
Process p = Runtime.getRuntime().exec(arg);
BufferedReader br = new BufferedReader(new InputStreamReader(p.getErrorStream()));
String str = null;
while((str = br.readLine()) != null){
System.out.println(str);
}
15.7 RandomAccessFile
RandomAccessFile是java输入/输出流体系中功能最丰富的文件内容访问类,它既可以读取文件内容,也可以向文件输出数据。
与普通的输入、输出流不同的是,RandomAccessFile允许自由定位文件记录指针,可以直接跳转到文件的任意位置,实现随机访问文件数据。所以如果只需要访问文件部分内容,而不是把文件从头读到尾,使用RandomAccessFile是较好的选择。
RandomAccessFile的局限是:只能读写文件,不能访问其他IO节点。
RandomAccessFile对象包含一个记录指针,以标识当前读写位置,当程序新创建一个RandomAccessFile对象时,记录指针为0,当读写了n字节后,记录指针后移n个字节。除此之外,RandomAccessFile可以自由移动记录指针,既可以向前移动,也可以后移。RandomAccessFile包含了如下两个操纵记录指针的方法:
long getFilePointer():返回文件记录指针的当前位置
void seek(long pos):将文件记录指针定位到pos位置。
RandomAccessFile既可以读文件,也可以写文件,所以它既包含了用法完全类似于InputStream的三个read()方法,也包含了用法完全类似于OutputStream的三个write()方法。此外,RandomAccessFile还包含一系列的readXxx()和writeXxx()方法来完成输入、输出。
RandomAccessFile类有两个构造器,两个构造器基本相同,只是指定文件的形式不同---一个使用String参数指定文件名,一个使用File参数指定文件本身。此外,创建RandomAccessFile对象时,还需要指定一个mode参数,表示RandomAccessFile文件的读写模式,参数有如下4种模式:
"r":以只读方式打开文件。如果试图写文件,则抛出IO异常
"rw":以读、写方式打开文件。如果该文件不存在,则创建该文件。
"rws":以读、写方式打开指定文件。相对于"rw"模式,还要求对文件的内容或元数据的每个更新都写入到底层存储设备。
"rwd":以读、写方式打开指定文件。相对于"rw"模式,还要求对文件内容的每个更新都写入到底层存储设备。
下面程序使用了RandomAccessFile访问指定的中间部分数据。并向另一个对象的末尾写内容。
RandomAccessFile ram = new RandomAccessFile("src/Mytest.java","r");
//将文件记录指针设为20
ram.seek(20);
byte[] buf = new byte[30];
//从RandomAccessFile中读取内容
ram.read(buf);
System.out.println(new String(buf));
RandomAccessFile writeRam = new RandomAccessFile("test.java","rw");
//将记录指针移到文件末尾
writeRam.seek(writeRam.length());
buf = new byte[]{'1','2','3','4'};
writeRam.write(buf);
RandomAccessFile不能向文件的指定位置插入内容,如果直接将文件记录指针移到到中间某位置后输出,则新输出的内容会覆盖文件中原有的内容。如果需要向指定位置插入内容,需要先把插入点后面的内容读入临时文件,插完数据后,再把临时文件中的内容追加到文件尾部。
11.8 对象序列化
11.8.1 序列化的含义和意义
对象的序列化(Serialize)指将对象的实例域保存为平台无关的二进制流,便于将对象保存到磁盘中,或允许在网络中直接传输对象。与此对应的是,对象的反序列化(Deserialize)指从IO流中恢复该java对象。。
如果需要让某个对象支持序列化机制,则必须让它的类是可序列化的(serializable)。为了让某个类是可序列化的,该类必须实现如下两个接口之一。
- Serializable
- Externalizable
Java的很多类已经实现了Serializable,该接口是一个标记接口,实现该接口无须实现任何方法,它只是表明该类是可序列化的。
所有可能在网络上传输的对象的类都应该是可序列化的,否则程序将会出现异常。
11.8.2 使用对象流实现序列化
一旦某个类实现了Serializable接口,该类的对象就可序列化,程序可以通过如下两步来序列化该对象:
-
创建一个ObjectOutputStream,这个输出流是一个处理流,所以必须建立在其他节点流的基础之上。
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"));
-
调用ObjectOutputStream对象的writeObject()方法输出可序列化对象。
oos.writeObject(per);
如果希望从二进制流中恢复Java对象,则需要反序列化。反序列化的步骤如下:
-
创建一个ObjectInputStream输入流,这个输入流是处理流,所以必须建立在其他节点流的基础之上。
ObjectInputStream oos = new ObjectInputStream(new FileInputStream("object.txt"));
-
调用ObjectInputStream对象的readObject()方法读取流中的对象,该方法返回一个Object类型的Java对象,如果程序知道该Java对象的类型,则可以将该对象强制类型转换成其真实的类型。例如:
Person p = (Person)oos.readObject();
输入输出对象的二进制流的代码如下:
try(
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"));
)
{
Person pre = new Person();
oos.writeObject(pre);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"));
Person pre = (Person)ois.readObject();
}
必须指出的是,反序列化读取的仅仅是Java对象的数据,而不是Java类,因此采用反序列化恢复Java对象时,必须提供该对象所属类的class文件,否则会引发ClassNotFoundException异常。所以,调用readObject()的方法需要显式捕获或者声明抛出该异常。此外,反序列化机制无需通过构造器来初始化Java对象。
当一个可序列化类有多个父类时(包括直接父类和间接父类),这些父类要么有无参数的构造器,要么也是可序列化的---否则反序列化时,将抛出InvalidClassException异常。如果父类是不可序列化的,只是带有无参数的构造器,则该父类中定义的成员变量值不会序列化到二进制流中。
经测试,读入子类的二进制流时,可以将得到的对象赋给父类变量,但父类变量的运行时类型仍为子类。此外,若父类不可序列化且没有无参构造器时,子类对象可以序列化,但是不能反序列化。例如:
public class Mytest{// implements Serializable
int a;
String b;
public Mytest(int a,String b){
this.a = a;
this.b = b;
}
//无参构造器取消注释时,反序列化正常,说明可以将子类对象额度二进制流赋给父类变量。
//注释无参构造器时,对象可序列化,但是执行到反序列化语句时,会抛出异常。
//public Mytest(){}
public static void main(String[] args) throws Exception {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.txt"));
Sub obj = new Sub(1,"xiaoming",1);
oos.writeObject(obj);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("object.txt"));
Mytest s = (Mytest)ois.readObject();
System.out.print("hello");
}
}
class Sub extends Mytest implements Serializable{
int c;
public Sub(int a,String b,int c){
super(a,b);
this.c = c;
}
}
11.8.3 对象引用的序列化
如果某个类的成员变量的类型不是基本类型,而是引用类型,那么这个引用类型必须可序列化,否则拥有该类型成员变量的类也不可序列化。
因为,当序列化某个可序列化类A的对象时,若该类的成员变量是引用类型B,为了反序列化时,可以正常恢复A类对象,程序也会序列化B类型的成员变量。所以B类必须可序列化,否则A类将不可序列化。因此,序列化一个类的过程类似于对象的深拷贝。
当有两个A类对象,他们的实例变量var引用了同一个B类对象obj,如果要将这三个对象序列化时,会向输出流中写入几个B类对象。
B obj = new B(...);
A oa1 = new A(obj,..);
A oa2 = new A(obj,..);
如果系统向输出流中写入三个B类对象,那么后果是当程序从输入流中反序列化这些对象时,将会得到三个B类对象,两个A类对象的var成员变量不再引用同一个B类对象。显然违背java序列化机制的初衷。
所以,java序列化机制采用了特殊的序列化算法:
- 所有保存到磁盘中的对象都有一个序列化编号。
- 当程序试图序列化一个对象时,程序先检测该对象是否已经被序列化过,只有该对象从未(在本虚拟机中)被序列化过,系统才会将该对象转换成字节序列并输出。
- 如果某个对象已经被序列化,程序只是直接输出一个序列化编号,而不是再次重新序列化该对象。
但是此算法会出现如下问题:当程序序列化一个可变对象时,只有第一次使用writeObject()方法输出时,才会将该对象转换成字节序列并输出,当程序再次调用writeObject()时,程序只是输出前面的序列化编号,即使后面该对象的实例变量已被改变,改变的实例变量值不会被输出。
11.8.4 自定义序列化
通过在实例变量前添加transient修饰符,可以指定java序列化时无须理会该实例变量(可理解会不序列化这些成员变量,反序列化时,给这些变量赋默认值)。
transient只用于修饰实例变量,不可修饰java程序中的其他成分。
使用transient修饰符的缺陷是,在反序列恢复java对象时无法取得该实例变量值。Java还提供了一种子定义序列化机制,通过子定义序列化方法可以让程序控制如何序列化各实例变量,甚至完全不序列化某些实例变量。
在序列化和反序列化过程中需要子定义序列化方法的类,应该提供如下方法。
private void writeObject(java.io.ObjectOutputStream out) throws IOException
private void readObject(java.io.ObjectInputStream in) throws IOException,ClassNotFoundException
private void readObjectNoData()throws ObjectStreamException
writeObject()负责写入特定类的实例状态,以便相应的readObject()方法可以恢复它。通过重写该方法,程序员可以完全获得对序列化机制的控制,自主决定序列化哪些实例变量,以及怎样序列化。在默认情况下,该方法会调用out.defaultWriteObject来保存java对象的各实例变量。从而可以实现序列化java对象状态的目的。
readObject()方法负责从流中读取并恢复对象实例变量,通过重写该方法,程序员可以对反序列化机制进行控制,自主决定反序列化哪些实例变量,以及如何进行反序列化。默认情况下,该方法会调用in.defaultReadObject来恢复java对象的非瞬态(非transient)实例变量。通常情况下,readObject()与writeObject()对应,如果writeObject()对Java对象的实例变量进行了一些处理,则在readObject()中应该对其实例变量进行相应的反处理,以便正确的恢复该对象。
当序列化流不完整时,例如接收方使用的反序列化类的版本不同于发送方,或者接收方版本扩张的类不是发送方版本扩展的类,或者序列化流被篡改时,系统都会调用readObjectNoData()方法来初始化反序列化对象。
private void writeObject(java.io.ObjectOutput out) throws IOException{
out.writeObject(new StringBuilder(name).reverse());
out.writeInt(age);
}
private readObject(java.io.ObjectInput in) throws IOException,ClassNotFoundException{
this.name = (StringBuilder)in.readObject().reverse();
this.age = in.readInt();
}
writeObject()方法存储实例变量的顺序应该和readObject()方法中恢复实例变量的顺序一致,否则将不能正常恢复该java对象。
还有一种更彻底的自定义机制,它甚至可以在序列化对象时将该对象替换成其他对象。如果需要实现序列化某个对象时替换该对象,则应为序列化类提供如下方法:
Object writeReplace() throws ObjectStreamException;
此writeReplace()将由序列化机制调用,只要该方法存在。该方法的访问权限可以设为private、protected和public。所以其子类可能获得该方法。例如,下面的Person类提供了writeReplace方法,这样在写入Person对象时,将该对象替换成ArrayList。
class Person implements Serializable{
private String name;
private int age;
...
private Object writeObject()throws ObjectStreamException{
ArrayList<Object> list = new ArrayList<>();
list.add(name);
list.add(age);
return list;
}
}
Java的序列化机制保证在序列化某个对象之前,先调用该对象的writeReplace()方法,如果该方法返回另一个java对象,则系统将再次调用另一个对象的writeReplace()方法...直到该方法不再返回另一个对象为止,程序然后将调用该对象的writeObject()来保存该对象的状态。
11.9 NIO
传统的输入流、输出流都是通过字节的移动来处理的,也就是说,面向流的输入、输出系统一次只能处理一个字节,因此面向流的输入/输出系统效率不高。
11.9.1 NIO概述
NIO和传统的IO一样,都是用于输入/输出,但NIO采用了不同的方式来处理输入/输出,NIO采用内存映射文件的方式来处理输入/输出,NIO将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样访问文件了(这种方式模拟了操作系统中虚拟内存的概念),通过这种方式进行输入/输出的效率比传统方式快很多。
java中与NIO相关的包有:
java.nio包:主要包含各种与Buffer相关的类。
java.nio.channels:主要包含与Channel和Selector相关的类。
java.nio.charset包:主要包含与字符集相关的类。
java.nio.channels.spi包:包含于Channel相关的服务提供者编程接口。
java.nio.charset.api包:主要包含与字符集相关的服务提供者编程接口
Channel(通道)和Buffer(缓冲)是NIO的两个核心对象,Channel是对传统的输入/输出系统的模拟,在NIO系统中所有的数据都要通过通道传输;Channel与传统的InputStream、OutputStream最大的区别在于它提供了一个map()方法,通过该map()方法可以直接将“一块数据”映射到内存中。如果说传统的输入/输出系统是面向流的处理,则NIO是面向块的处理。
Buffer可看作容器,本质是一个数组,发送到Channel中的所有对象都必须首先存放到Buffer中,而从Channel中读取的数据也必须先放到Buffer中。
11.9.2 使用Buffer
Buffer用于保存多个类型相同的数据。Buffer是一个抽象类,最长用的子类是ByteBuffer,它可以在底层字节数组上进心get/set操作。除了ByteBuffer之外,对应其他基本数据类型(boolean除外)都有相应的Buffer类:CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。
这些Buffer类采用相同或类似的方法管理数据,它们都没有提供构造器,通过使用如下方法得到一个Buffer对象。
static XxxBuffer allocate(int capacity):创建一个容量为capacity的XxxBuffer对象。
例如:
IntBuffer ib = IntBuffer.allocate(10);
使用较多的是ByteBuffer和CharBuffer,其中ByteBuffer有一个子类:MappedByteBuffer,用于表示Channel将磁盘文件的部分或全部内容映射到内存中后得到的结果,通常MappedByteBuffer对象由Channel的map()方法返回。
Buffer有三个重要的属性:容量(capacity)、界限(limit)和位置(position)。
- 容量:表示缓冲区的容量。创建后,缓冲区的容量不能改变。
- 界限:第一个不应该被读/写的缓冲区位置索引。即位于limit后的数据不可读写。
- 位置:指明下一个可以被读/写的缓冲区位置索引。Buffer对象刚创建时,position为0。
此外,Buffer还支持一个可选的mark标记,允许直接将position定位到该mark处。这些值满足下面关系:
\(0\leq mark \leq position \leq limit \leq capacity\)
Buffer的主要作用是装入数据,然后输出数据。
- 开始时 ,Buffer的position为0,limit为capacity,程序通过put()方法向Buffer中放入一些数据,每放入一个数据,position后移相应的距离。
- 当Buffer装入数据结束时,调用Buffer的flip()方法,该方法将limit设置为position所在位置,并将position设为0,从而调用flip()之后,Buffer为输出数据做好准备;每输出一个数据,position右移相应的距离。
- 当输出数据结束后,Buffer调用clear()方法,将position置为0,limit置为capacity,为再次装入数据做好准备。但clear()不会清空Buffer中的数据。仅仅改变了下标。
此外,Buffer还包含如下常用方法:
int capacity():返回Buffer的capacity
boolean hasRemaining():判断position和limit之间是否还有元素可供处理。
int limit():返回Buffer的limit值。
Buffer limit(int newLt):设置Buffer的limit值,并返回一个具有新limit的缓冲区对象。
Buffer mark():设置Buffer的mark值,只能在0~position之间做mark。
int position():返回position值
Buffer position(int newPs):设置position值,并返回被修改后的Buffer对象。
int remaining():返回limit与position的差值,表示待处理元素的个数。
Buffer reset():将position值修改为mark,返回修改后的Buffer对象。
Buffer rewind():将position置为0,取消设置的mark。
Buffer的所有子类都提供了两个方法:put()和get()方法,用于向Buffer中放入/取出数据。当使用put()和get()操纵数据时,Buffer既支持操纵单个数据,也支持操纵批量数据。
当使用put()和get()操纵Buffer中的数据时,分为相对和绝对两种:
- 相对(relative):从Buffer的当前position处读/写数据,然后对position右移相应距离。
- 绝对(Absolute):直接根据索引从Buffer中读/写数据,使用绝对方式访问Buffer里的数据时,不会修改position的值。
示例如下:
CharBuffer buff = CharBuff.allocate(8);
buff.put('a');
buff.put('b');
buff.put('c');
buff.flip();
char ch = buff.get();
buff.clear();
//获取buffer中下标为2处的char数据,从而说明clear()未清空缓冲区。
buff.get(2);
通过allocate()创建的Buffer是普通Buffer,ByteBuffer还提供了allocateDirect()方法来创建直接Buffer。直接Buffer的创建成本比普通Buffer高,但直接Buffer的读取效率更高。
由于直接Buffer的创建成本高,所以,直接Buffer只适用于长生存期的Buffer,而不适用于短生存期、一次用完就丢弃的Buffer。而且只有ByteBuffer才提供了allocateDirect()方法。直接Buffer的方法与普通Buffer没有区别。
11.9.3 使用Channel
Channel类似于传统的流对象,但与传统的流对象有两个主要区别:
- Channel可以直接将指定文件的部分或全部映射成Buffer。
- 程序不能直接读写Channel中的数据,Channel只能与Buffer进行交互。从而要读写Channel中的数据,必须先用Buffer从Channel中读部分数据,然后程序再从Buffer中读这些数据。反之,则先向Buffer中写数据,然后Buffer再向Channel中写数据。
Java为Channel接口提供了DatagramChannel、FileChannel、Pipe.SinkChannel、Pipe.SourceChannel、SelectableChannel、ServerSocketChannel、SocketChannel等实现类,这些类都是按功能划分的。Pipe.SinkChannel、Pipe.SourceChannel用于支持线程之间通信的管道Channel;ServerSocketChannel、SocketChannel用于支持TCP网络通信的Channel;DatagramChannel用于支持UDP网络通信的Channel。
所有的Channel都不应该通过构造器直接创建,而是通过传统的字节节点流InputStream、OutputStream的getChannel()来返回对应的Channel。不同的节点流得的Channel不一样,例如FileInputStream、FileOutputStream的getChannel()返回的是FileChannel,而PipedInputStream、PipedOutputStream的getChannel()返回的是Pipe.SinkChannel、Pipe.SourceChannel。仅限使用字节节点流获取Channel,字符节点流没有getChannel()方法。
Channel中最常用的三类方法是map()、read()和write()。
- MappedByteBuffer map(FileChannel.MapMode mode, long position, long size):用于将Channel对应的部分或全部数据映射成MapppedByteBuffer。第一个参数指定映射时的模式,分别为只读、读写等模式。而第二、三个参数用于控制将Channel的哪些数据映射成ByteBuffer。size的值决定了ByteBuffer的容量。
- read()和write()都有一系列的重载形式,用于仅从ByteBuffer中读取数据或写入数据。
File f = new File("Test.java");
try(
FileChannel inChannel = new FileInputStream(f).getChannel();
FileChannel outChannel = new FileOutputStream("out.txt").getChannel();
)
{
//将FileChannel里的全部数据映射成ByteBuffer
MappedByteBuffer buffer = inChannel.map(FileChannel.MapMode.READ_ONLY,0,f.length());
//直接将buffer里的全部数据输出
outChannel.write(buffer);
buffer.clear();
}
FileInputStream获取的FileChannel只能读、FileOutputStream获取的FileChannel只能写。
除了InputStream、OutputStream包含getChannel()方法,在RandomAccessFile中也包含了一个getChannel()方法,RandomAccessFile返回的FileChannel是只读还是读写,取决于RandomAccessFile打开文件的模式。
上面的代码一次性将输入文件中的内容都map到了Buffer中,此外,也可以采取每次只放入一部分,多次放入的方式。例如:
try(
FileInputStream file = new FileInputStream("Test.java");
FileChannel fcin = file.getChannel();
FileChannel outChannel = new FileOutputStream("out.txt").getChannel();
)
{
ByteBuffer buffer = ByteBuffer.allocate(256);
//每次放入256个字节
while(fcin.read(buffer) != -1){
buffer.flip();
outChannel.write(buffer);
buffer.clear();
}
}
15.9.4 字符集和Charset
所有文件都是以二进制格式存储,即都是字节码文件。将文件存入设备时,系统将文本文件编码(Encode)为二进制文件。当需要显示文本内容时,系统将二进制文件解码(Decode)为文本文件。
Java默认使用Unicode字符集,但很多操作系统并未使用Unicode字符集。当从系统中读取数据到Java程序中时,可能出现乱码。
因此,Java提供了Charset来处理字节序列和字符序列(字符串)之间的转换关系,该类包含了用于创建解码器和编码器的方法,Charset是不可变类。
Charset提供了一个availableCharsets()静态方法获取当前JDK支持的所有字符集。
SortedMap<String,Charset> map = Charset.availableCharsets();
上述代码获取了当前Java所支持的全部字符集的Map集合,可以通过字符串的别名,获取对应的Charset对象。每个Charset对象都有一个字符串名称,称为字符串别名。下面几个中国程序员常用的字符串别名:
- GBK:简体中文字符集
- BIG5:繁体中文字符集
- ISO-8859-1:ISO拉丁字母表No.1,也叫做ISO-LATIN-1.
- UTF-8:8为UCS转换格式。
- UTF-16BE:16位UCS转换格式,大端字节顺序。
- UTF-16LE:16位UCS转换格式,小端字节顺序。
- UTF-16:16位UCS转换格式,字节顺序由可选的字节顺序标记来标识。
可使用System类的getProperties()方法来访问本地系统的文件编码格式,文件编码格式的属性名为file.encoding。例如file.encoding = GBK,这表明操作系统使用GBK编码。
一旦知道字符集别名,就可以调用Charset的forName()来创建对应的Charset对象,参数就是相应字符集的别名。
获得了Charset对象之后,就可以通过该对象的newDecoder()、newEncoder()方法分别返回CharsetDecoder和CharsetEncoder对象,代表该Charset的解码器和编码器。调用CharsetDecoder的decode()方法可以将ByteBuffer(字节序列)转换成CharBuffer(字符序列),调用CharsetEncoder的encode()方法可以将CharBuffer或String(字符序列)转成ByteBuffer(字节序列)。
Java7新增了一个StandCharsets类,该类里包含了ISO_8859_1、UTF-8、UTF-16等类变量,这些类变量代表了最常用的字符集对应的Charset对象。
Charset cn = Charset.forName("GBK");
CharsetEncoder cnEncoder = cn.newEncoder();
CharsetDecoder cndecoder = cn.newDecoder();
CharBuffer cbuff = CharBuffer.allocate(8);
cbuff.put('孙');
cbuff.put('悟');
cbuff.put('空');
cbuff.flip();
ByteBuff bbuff = cnEncoder.encode(cbuff);
System.out.println(cnDecoder.decode(bbuff));
获取了Charset对象后,如果仅仅需要进行简单的编码、解码操作,无须创建CharsetEncoder和CharsetDecoder对象,Charset类也提供了三种编、解码方法,直接调用Charset的方法进行编、解码即可:
CharBuffer decode(ByteBuffer bb):将ByteBuffer中的字节序列解码为字符序列的快捷方法。
ByteBuffer encode(CharBuffer cb):将CharBuffer中的字符序列编码为ByteBuffer的快捷方法。
ByteBuffer encode(String str):将String中的字符序列编码为ByteBuffer字节序列的快捷方法。
在String类里也提供了一个getBytes(String charset),方法返回byte[],方法使用指定的字符集将字符串转换成字节序列。
11.9.5 文件锁
使用文件锁可以有效地阻止多个进程并发修改同一个文件。文件锁控制文件的全部或部分字节的访问。
在NIO中,java提供了FileLock来支持文件锁定功能,在FileChannel中提供的lock/tryLock()方法可以获得文件锁FileLock对象,从而锁定文件。lock()和tryLock()的区别是:lcok()未获得文件锁时,程序会一直阻塞;而tryLock()如果获得了文件锁,方法返回文件锁对象,否则直接返回null,而不是阻塞。
如果FileChannel只想锁定文件的部分内容,而不是锁定全部内容,则可以使用如下的lock()或tryLock()方法。
- lock(long position, long size, boolean shared):对文件从position开始,长度为size的内容加锁。方法是阻塞式的。
- tryLock(long position, long size, boolean shared):非阻塞式加锁方法。作用与lock()相同。
当参数shared为true时,表明该锁是共享锁,它将允许多个进程读取该文件,但阻止其他进程获得对该文件的排他锁。当shared为false时,表明该锁是一个排他锁,它将锁住对该文件的读写,程序可以通过调用FileLock的isShared来判断它获得的锁是否为共享锁。
直接使用lock()或tryLock()方法获取的文件锁是排它锁。
处理完文件后通过FileLock的release()方法释放文件锁。
FileChannel channel = new FileOutPutStream("a.txt").getChannel();
FileLock lock = channel.trylock();
Thread.sleep(1000);
lock.release();
文件锁虽然可以用于控制并发访问,但对于高并发访问的情形,推荐使用数据库来保持程序信息,而不是使用文件。
关于文件锁需要注意以下几点:
- 在某些平台上,文件锁仅仅是建议性的,并不是强制性的。意味着即使一个程序不能获得文件锁,它也可以对该文件进行读写。
- 在某些平台上,不能同步地锁定一个文件并把它映射到内存中。
- 文件锁由java虚拟机持有的,如果两个Java程序使用同一个Java虚拟机运行,则他们不能对同一个文件进行加锁。
- 在某些平台上关闭FileChannel时,会释放java虚拟机在该文件上的所有锁,因此应该避免对同一个被锁定的文件打开多个FileChannel.