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序列化机制采用了特殊的序列化算法:

  • 所有保存到磁盘中的对象都有一个序列化编号。
  • 当程序试图序列化一个对象时,程序先检测该对象是否已经被序列化过,只有该对象从未(在本虚拟机中)被序列化过,系统才会将该对象转换成字节序列并输出。
  • 如果某个对象已经被序列化,程序只是直接输出一个序列化编号,而不是再次重新序列化该对象。

1

但是此算法会出现如下问题:当程序序列化一个可变对象时,只有第一次使用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\)

2

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.
posted @ 2018-10-28 23:07  Echie  阅读(295)  评论(0编辑  收藏  举报