返回 登录
8

Java IO(2)阻塞式输入输出(BIO)

阅读3667

本文所述的输出输出指的是Java中传统的IO,也就是阻塞式输入输出(Blocking I/O, BIO),在JDK1.4之后出现了新的输入输出API——NIO(New I/O或Non-blocking I/O),也就是同步非阻塞式输入输出,再到后面随着NIO的发展出现了新的异步非阻塞式的输入输出——AIO。

  本文将对BIO,即阻塞式输入输出的字节流以及字符流做简要概述。 需要明确对于输出:InputStream、Reader表示输入,前者表示字节流,后者表示字符流;OutStream、Writer表示输出,前者表示字节流,后者表示字符流。

字节流(InputStream、OutputStream)

  对于字节流的输入顶层类是InputStram、其输出对应的顶层类是OutputStream。

输入流(InputStream)

  站在程序的角度,读取文件的动作称为输入,InputStream是一个抽象类,Java中IO的设计并不仅仅是只有InputStream类,因为存在许多输入流,例如网络、文件等,这些都能为程序提供数据源,而不同的数据源则通过不同的InputStream子类来接收。

ByteArrayInputStream——字节数组。
StringBufferInputStream——String对象,这个类年代久远已经被废除了,想要将String对象转换为流,推荐使用StringReader。
FileInputStream——从文件中读取信息,这个流是比较常用的类,因为通常情况下我们都是对文件进行读写操作,所以也会着重讨论这个类。
PipedInputStream——和PipedOutputStream配合使用实现“管道化”的概念。
FileterInputStream——这个类比较特殊,从名字上看叫做“过滤器输入流”,它是在输入流中为“装饰器”提供基类。
  着重来看FileInputStream类,如何从文件中读取信息。

  FileInputStream 一共有3个构造方法:

InputStream in = new FileInputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接传递文件路径字符串,在这个构造函数中会为路径中的文件创建File对象。
InputStream in = new FileInputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)); //传递File类型的对象,也就是我们自己为路径中的文件构造为File文件类型。
InputStream in = new FileInputStream(new FileDescriptor()); //第三个构造方法传递的是“文件描述符”对象,通过文件描述符来定位文件,如果比较了解Linux和C的话应该是对“文件描述符”这个概念有所耳闻,在许多C源码中就时常出现“fd”这个变量,其表示的就是文件描述符,就是用于定位文件。这一个在Java日常的应用开发中不常用,用到它的地方其实就是System.out.println的封装。暂时可以忽略。
  其实深入到FileInputStream这个对象的源码可以发现,大部分核心的源码都是native方法,之所以只用nativa方法是因为本地方法速度快。

1 File file = new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”);
2 InputStream in = new FileInputStream(file);
3 byte[] b = new byte[64];
4 in.read(b);
5 System.out.println(new String(b));

  这段代码是读取本地文件获取文件中的信息,其中read方法关键,FileInputStream中一共有3个read重载方法:

public int read() //返回读取的字节,FileInputStream是按照字节流的方式读取,使用该方法将一次读取一个字节并返回该字节。该方法中会调用private native int read0()本地方法。
public int read(byte b[]) //将读取的字节全部放到字节数组b中,这个字节数组b是我们提前定义好的,用于存放读取文件的字节表示,返回一共读取的字(1个字母表示1个字,1中文通常则是3个字)。该方法会调用private native int readBytes(byte b[], int off, int len)本地方法。
read(byte b[], int off, int len) //读取数据的开始处以及待存放字节数组的长度,基本同上,返回一共读取的字符(1个字母表示1个字符,1中文通常占用3个字节也就是3个字符)。该方法会调用private native int readBytes(byte b[], int off, int len)本地方法。
  这基本上就构成了通过FileInputStream字节流读取文件的API,到了这里应该会有一个疑问,那就是读取出来的字节放到我们定义的字节数组中,而这个数组有需要在初始化时给定大小,那此时是如何知道待读取的文件大小呢?上面定义的64个字节大小的数组,如果待读取的文件有128字节甚至更大呢?就好像上面的例子,如果之定义1个字节大小,那么最后只会输出文件中的第1个字节。但如果定义64个字节大小的字节数组,那又显得比较浪费。

输出流(OutputStream)

  同样是站在程序的角度,写入文件的操作称为输出。和InputStream类比,它也有许多实现类,在这里不再一一举出,着重来看FileOutputStream输出到本地文件的类。如果文件不存在则创建。

1 OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”);
2 String str = “this is data”;
3 out.write(str.getBytes()); // 由于是以字节流的方式输出,自然也是需要将输出的内容转换为字节。

  FileOutputStream类的构造方法一共有5个:主要是分为“文件地址”、“是否以追加方式写入”、“文件描述符”。

OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接传递文件路径字符串,在构造方法中会将其构造为一个File对象,如果文件不存在则会新建文件,默认将覆盖文件的内容进行写入。因为它实际上是调用FileInputStream(File, boolean)构造方法。
OutputStream out = new FileOutputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)) //传递File对象,默认将覆盖文件的内容进行写入。实际还是调用FileInputStream(File, boolean)。
OutputStream out = new FileOutputStream(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”, true); //第一个参数如第一点所述,第二个参数则表示以追加的方式写入。
OutputStream out = new FileOutputStream(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””), true) //向上参考
OutputStream out = new FileOutputStream (new FileDescriptor()); //第三个构造方法传递的是“文件描述符”对象,不需要过多的关注这个构造方法,因为实在能用的地方不多。
  对于文件输出的核心API是write方法,对应文件输入的read方法。既然read能单个读取,那么write也有单个写入,其重载方法一共有3个。

public void write(int b); //写入单个字节,该方法会调用private native write(b, append)这个方法是私有且本地的,至于第二个append的参数则是表示是否追加写入文件,这里的参数是在构造方法中定义的,默认不追加写入而是以覆盖的方式写入。
public void write(byte b[]); //写入字节,这里传递转换后的字节数组,通常我们是需要写入一个字符串,而这里调用String.valueOf将其转换为字符数组。此方法会调用private native void writeBytes(byte b[], int off, int len, boolean append),和写入的类似,第二个参数表示字节数组从哪个地方开始写入,len表示写入多少,最后一个还是表示是否是追加写入。
public void write(byte b[], int off, int len); //分析见上 这是对OutputStream的其中一个实现类做的简要讲述,API也较为简单,类比很好掌握。
字符流(Reader、Writer)

输入流(Reader)

  对于字符流的文件读取方式可以不用像字节流那样,读取出来是一个字节,想要输出显示这个字节则需要将这个字节转换为字符。字符流读取出来的文件则直接就是字符,不需要再重新转化。Reader和InputStream类似,也是一个抽象类,它也有不少的实现,其主要实现如下。

CharArrayReader
StringReader
InputStreamReader——这个类略有不同,这个类是字节流和字符流之间的桥梁,它能将字节流转换为字符流,相对比于“FileInputStream”,字节流的本地文件读取实际上是InputStreamReader的子类——FileReader
PipedReader
FilterReader
  对比字符流的FileInputStream类,此处使用FileReader。和FileInputStream类似它同样有3个构造方法:

Reader reader = new FileReader(/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”); //直接传递文件路径字符串,在这个构造函数中会为路径中的文件创建File对象。
Reader reader = new FileReader(new File(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json””)); //传递File类型的对象,也就是我们自己为路径中的文件构造为File文件类型。
Reader reader = new FileReader(new FileDescriptor()); //第三个构造方法传递的是“文件描述符”对象,通过文件描述符来定位文件,如果比较了解Linux和C的话应该是对“文件描述符”这个概念有所耳闻,在许多C源码中就时常出现“fd”这个变量,其表示的就是文件描述符,就是用于定位文件,暂时对它可以忽略。
  可以看到它的API操作几乎和FileInputStream如出一辙,唯一不同的是,它定义的是字符数组而不是字节数组。

1 Reader reader = new FileReader(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”);
2 char[] c = new char[64];
3 reader.read(c);
4 System.out.println(String.valueOf(c));

  同字节输入流FileInputStream类似,它的读取API也是read,并且它也有3个重载方法。如果还能记得FileInputStream的3个read重载方法,那么这里也不难猜出FileReader的3个read重载方法分别是:读取一个字符;读取所有字符;读取范围内的字符。实际上进入FileReader类后可以发现在FileReader类中并没有read方法,因为它继承自InputStreamReader,最后发现实际上FileReader#read调用的是父类InputputStreamReader#read方法,而且和字节流的read使用native本地方法略有不同,InputputStreamReader并没有采用native方法,而是使用了一个叫做StreamDecoder类,这个类源于sun包,并没有源代码,不过还是可以带着好奇心来一看反编译后的结果。  

//InputputStreamReader#read
public int read(char cbuf[], int offset, int length) throws IOException {
return sd.read(cbuf, offset, length); //调用的StreamDecoder#read方法
}

  对于使用FileReader#read方法调用的则是它的父类InputStreamReader#read,其实我认为可以这么理解:基于字符流的输入输出实际上是我们人为对它进行了转换,数据在网络中的传输实际还是以二进制流的方式,或者说是字节的方式,为了我们方便阅读,在传输到达时人为地将其转换为了字符的形式。所以即使这里是使用的FileReader以字符流的方式输入,但实际上它使用了字节-字符之间的桥梁——InputStreamReader类。也就是说StreamDecoder类很就是字节-字符转换的核心类。关于StreamDecoder类确实涉及比较复杂,Reader字符流本身也比字节流要复杂不少。这个地方的源码暂时还未深入了解。

输出流(Writer)

  和字节输出流以及字符输入流之间的对比Writer也有很多实现类,我们找到有关本地文件写入的类——FileWriter,同样发现它继承自OutputStreamWriter,这个类是Writer的字节子类和InputStreamReader类似是字节流和字符流转换的桥梁。

  有了上面的例子,这里不再逐个叙述它的构造方法以及write重载方法,有一个需要关注的地方就是它的flush方法。

1 Writer writer = new FileWriter(“/Users/yulinfeng/Documents/Coding/Idea/maveneg/src/main/java/bio/test.json”);
2 String str = “hello”;
3 writer.write(str);
4 writer.flush();

  上面的代码中如果不调用flush方法,字符串将不会写入到文件中。这是因为在写文件时,Java会将数据先存入缓存区,缓存区满后再一次写入到文件中,在这里“hello”并没有占满缓存,故需要在调用write方法后再调用flush方法防止在缓存区中的数据没有及时写入文件。

  不过这里有一个令我比较疑惑的是,在使用字节流输出只含1个字符到文件时,并没有使用flush也会将数据写到文件;而在字符流中则像上面的那种情况如果不使用flush则数据不会写入文件。答案确实是使用字节流输出数据到文件时,不需要使用flush,因为调用FileInputStream并没有重写flush方法,而是直接调用了父类OutputStream的falush方法,而OutputStream#flush方法里什么都没有,就是一个空方法;而使用FileWriter中虽然也并未实现flush方法,但在其父类OutputStreamWriter却实现了Writer的flush方法,因为在Writer类中flush方法是一个抽象方法必须实现。这里实际又会有一个疑问,为什么字符流不需要缓存,而字节流需要呢?其实就是因为对于字节流来说,是直接操作文件流,可以理解为“端到端”,而对于字符流来说中间多了一次转换为字符在“端到端”的中间利用了缓存(内存)将字符存放在了缓存中。所以在实际开发中利用字节流的方式输入输出相对更多。

小结

  上面说了这么多,看似并没有多少干货,大多是关于这几个流的使用方法,如果仔细看下来会发现最大的干货在于最后的flush疑问。这实际上能揭开关于“字节流”和“字符流”之间的区别。 在重复一次,尽管字节流中有flush方法,但是flush在字节流FileOutputStream并没用,JDK源码能说明一切,因为FileOutputStream调用的flush方法根本就是一个空实现。然而在字符流中那就可得注意了,在FileReader调用了write方法后记住调用flush方法,清空缓存写入文件。 这个问题基本就能解释字节流和字符流之间的区别了,字节流直接操作文件,字符流虽然最后的呈现以及写入是字符,但其最终还是以字节在传输,字节到字符的转换是在内存中完成的,这也就是字符流用到了缓存的原因。其实想想就可以知道,对于两者哪个更好,字节流更常用,因为它直接操作文件读取写入字节并且不限于文本,可以是音乐、图片、视频,而字符流主要是针对纯文本文件,况且它还要转换一次,效率恐怕就没有字节来得那么快了,故一般就是直接使用字节流——InputStream和OutputStream操作文件。

什么是(同步)阻塞式输入输出(Blocking I/O)

  这一部分的内容将解释本文的另一主题——阻塞式输出输出。

  首先需要了解何为“阻塞”。如果对显示锁Lock有所了解的话,应该是会知道它的两个方法一个是阻塞式获取锁——lock,直到成功地获取所后才返回;另一个是非阻塞式获取锁——tryLock,它首先尝试获取锁,成功获取所则成功返回,未能获取锁也会立即返回,并不会一直等在这里获取锁。相对于阻塞式的IO也是类似,阻塞式IO也会一直等待数据的读取和写入直到完成;而对应的非阻塞式IO则不会这样做,它会立即返回,不管是完成或未完成。

  再举个例子,在现实生活中你去买烟,老板说等下我去仓库里拿,你就一直在那里等老板从仓库里拿烟,这个时候你啥也不做就干等着,这就是阻塞;对于非阻塞,你还是在买烟,你还是在等老板给你拿烟,不过此时你可以玩玩手机,时不时问下老板好了没有。

  上面的例子都是在“同步”条件下的阻塞与非阻塞。当然还有异步阻塞与非阻塞,这里暂不涉及异步相关,所以本文所述阻塞与非阻塞均是在同步状态下。

  在此有必要了解什么是同步,通俗地说就是你进行下一步动作需要依赖上一步的执行结果。有时在我们的应用程序中,读取文件并不是下一步所必需的,也就是说这是两个不相干的逻辑,此时如果采用同步的手段去读取文件,读完过后再做另外的逻辑显然这个时间就被浪费了,通常情况下采取的措施是——伪异步,单独创建一个线程执行读取文件的操作,代码形如以下所示:

1 new Thread(new Runnable() {
2 @Override
3 public void run() {
4 readFile();
5 }
6 }).start();
7 doSomething();
8 //lamda表达式则更加简单:
9 //new Thread(() -> readFile()).start();
10 //doSomething();

  脱离场景谈同步阻塞式的传统IO显得很无力也不好理解,下面将结合Socket网络编程再次试着进一步理解“同步阻塞式IO”。

  以Java中使用UDP进行数据通信为例,服务器端在创建一个socket后会调用其receive等待客户端数据的到来,而DatagramSocket#receive就是阻塞地等待客户端数据,如果数据一直不来,它将会一直“卡”在这个方法的调用处,也就是程序此时被阻塞挂起,程序无法继续执行。

1 //同步阻塞式,服务器端接收数据
2 DatagramPacket request = new DatagramPacket(new byte[1024], 1024);
3 socket.receive(request);
4 processData(new String(request.getData()));

  试想以上代码,客户端发来的第1条、第2条……这些数据并无直接联系,它们只需要交给服务器端处理即可,但此时服务器端是同步阻塞式的获取数据并进行处理,在第1条数据未处理完时,第2条数据就必须等待,通常地做法就是上面提到的采用伪异步的方式对接收到的数据进行处理。

1 //(伪)异步阻塞式,服务器端接收数据
2 DatagramPacket request = new DatagramPacket(new byte[1024], 1024);
3 socket.receive(request);
4 new Thread(() -> { //lamda表达式
5 try {
6 processData(new String(request.getData()));
7 } catch (InterruptedException e) {
8 e.printStackTrace();
9 }
10 }).start();

  上面代码服务端接收到数据后将新开启一个线程对数据进行处理(更好地方式是利用线程池来管理线程),尽管采用了“伪异步”的方式处理数据,但实际上这是针对的是客户端发送数据多,发送数据快时所做的改进措施,但如果客户端发送的数据少,发送数据慢,实际上上面的修改并无多大意义,因为此时的症结不在于对服务器端对数据接收与处理的快慢,而在于服务器端将会一直阻塞获取数据使得服务器端程序被挂起。所以问题还是回到了“阻塞”式IO上来。

评论