网络编程入坑基础-BIO总结

IO总结

前提

参考资料:
- 《Java I/O》 – 这本书没有翻译版,需要自己啃一下。

《Java I/O》这本书主要介绍了IO和NIO的相关API使用,但是NIO部分并不是太专业,同系列的动物书《Java NIO》相对比较详细并且有译本,因此看本书的时候,我直接跳过了NIO部分。

IO概述

IO实际上是INPUT/OUTPUT(输入/输出)的简写,IO是任何计算机操作系统或编程语言的基础。Java中,IO相关的类库主要分布在java.io和java.nio两个包中。

IO分类

从目前来看,IO主要分为BIO、NIO和AIO。

BIO,一般又称为OIO(Old IO),意为Blocking-IO(阻塞IO),在此参考书中称为Basic-IO(基础IO)。这篇文章主要正是针对BIO进行总结。BIO的核心API都是围绕InputStream(输入流)和OutputStream(输出流),Reader和Writer,输入流和输出流都是面向字节的,而Reader和Writer可以说是面向字符的。

NIO,一般又叫New IO,意为Non-Blocking IO,即非阻塞IO,核心组件是Buffer、Channel、Selector。

AIO,意为Asynchronous IO,即异步IO,基于NIO引入了新的异步通道的概念,主要提供了异步文件通道和异步套接字通道的实现。

当然,这里只是简单说一下三种IO的概念,迟点读完了《Unix网络编程》后再做一次详细的总结。

什么是流(Stream)

流(Stream)是一个不定长度的有序的字节序列(个人认为,这个是最准确和最精炼的流的定义)。Java中对流进行了抽象,输入流的抽象父类是java.io.InputStream(下面叫输入流),输出流的抽象父类是java.io.OutputStream(下面叫输出流),这两个父类定义了从字节来源读取字节的方法以及向目标源输出字节的统一方法,这样的好处是我们不需要刻意去知道流的输入源或者流的输出目的地到底是什么。而具体的输入来源或者输出目的地分别由InputStream或者OutputStream的具体子类确定。

另外,在BIO里面,是没有”字符流”的概念(说实话,至少从我目前看到的资料来看没有出现过相关字眼,除了一些博客文章之外),但是有提供了java.io.Reader(下面叫Reader)用于读取字符,java.io.Writer(下面叫Writer)用于写入字符。实际上在需要从外部来源读取字符或者输出字符到外部目标的时候,Reader是从字节源读取字节,再把读取到的字节数组转换为字符数组;Writer是把字符数组转换为字节数组,再输出到外部目标里面,最常见的是FileReader和FileWriter。在上述这种情况下,Reader和Writer都是使用了十分典型的装饰器模式,下面就以Reader和Writer对文件的操作为例,贴点源码再画个图说明这个问题。

先看一下FileReader:

public class FileReader extends InputStreamReader {

    public FileReader(String fileName) throws FileNotFoundException {
        super(new FileInputStream(fileName));
    }
    //暂时忽略其他代码
}


public class InputStreamReader extends Reader {
    private final StreamDecoder sd;

    public InputStreamReader(InputStream in, String charsetName) throws UnsupportedEncodingException {
        super(in);
        if (charsetName == null)
            throw new NullPointerException("charsetName");
        sd = StreamDecoder.forInputStreamReader(in, this, charsetName);
    }
}

见InputStreamReader里面实例化的StreamDecoder实例,就是用于把byte数组转换为char数组,而byte数组来源于FileInputStream实例对应的文件中。

再看一下FileWriter:

public class FileWriter extends OutputStreamWriter {

    public FileWriter(String fileName) throws IOException {
        super(new FileOutputStream(fileName));
    }
    //暂时忽略其他代码
}

public class OutputStreamWriter extends Writer {

    private final StreamEncoder se;

    public OutputStreamWriter(OutputStream out, String charsetName) throws UnsupportedEncodingException {
        super(out);
        if (charsetName == null)
            throw new NullPointerException("charsetName");
        se = StreamEncoder.forOutputStreamWriter(out, this, charsetName);
    }
}

见OutputStreamWriter里面实例化的StreamEncoder实例,就是用于把char数组转换为byte数组,而byte数组存放在FileOutputStream实例中最终用于输出到目标文件。

简单来看,大致就是下图的流程:

当然,像一般只用于内存态的CharArrayWriter或者CharArrayReader等等,它们内部就维护着一个char数组,写入和输出的数据都是基于char数组。

输入流和输出流

InputStream

java.io.InputStream是所有输入流的抽象父类,提供三个基本方法用于从流中数据字节。另外,它还提供关闭流、检查剩余可以读取的字节序列长度、跳过指定长度、标记流中的位置并重新设置当前读取位置、检查是否支持标记和重置。

//读取单字节数据,返回的是"无符号的byte"类型值[0,255],因为不存在无符号byte类型,所以返回值是int
public abstract int read( ) throws IOException
//读取字节数组到指定的byte数组中
public int read(byte b[]) throws IOException
//读取字节数组到指定的byte数组中,可以指定偏移量和总长度
public int read(byte b[], int off, int len) throws IOException
//跳过指定长度的字节序列
public long skip(long n) throws IOException 
//返回剩余可读取的字节序列的长度
public int available() throws IOException
//关闭输入流
public void close() throws IOException
//标记
public synchronized void mark(int readlimit)
//重置
public synchronized void reset() throws IOException
//是否支持标记和重置
public boolean markSupported()

读取

public abstract int read( )是抽象方法,必须由子类实现,它用于读取单字节数据,返回的是”无符号的byte”类型值[0,255](读取到的字节数值),因为Java中不存在无符号byte类型,所以返回值是int。当读取到流的尾部,返回-1。相关的转换公式伪代码大致是:

byte b = xxxx;
int i = (b >= 0) ? b : 256 + b;

public int read(byte b[], int off, int len)用于读取连续的数据块到一个指定的字节数组中,off是用于指定数据写入目标byte数组的起始偏移量,len是用于指定读取字节的最大长度(数量),返回值是当前读取到的字节数值(注意范围是[0,255])。当读取到流的尾部,返回-1。注意此方法的返回值是读取到的字节序列的长度,有可能是目标字节数组的总长度,也有可能是来源的字节序列的总长度,这是因为预先建立的目标字节数组有可能不能容纳来源中的所有字节。变体方法public int read(byte b[])实际上是:

public int read(byte b[]) throws IOException {
    return read(b, 0, b.length);
}

read方法都是阻塞方法,直到有可读字节、到达流的尾部返回-1或者抛出异常。

这里举个栗子:

    public static void main(String[] args) throws Exception {
        ByteArrayInputStream inputStream = new ByteArrayInputStream("abcdefg".getBytes("UTF-8"));
        byte[] array = new byte[10];
        inputStream.read(array, 4, 2);
        System.out.println(new String(array, "UTF-8"));
    }

过程如下图:

这里为了说明,偏移量off是指(将会被写入)目标byte数组写入数据时候的数组下标,读取字节序列的最大长度len是指将会读取到的字节序列的总长度,但是需要注意,读取字节序列的时候是从字节序列的首位开始读取(很容易误认为从off开始读取,其实off是控制写入的偏移量)。因此上面程序执行后控制台打印字符串:ab。

值得注意的是InputStream的三个read方法变体适用于它的所有子类。

跳过

public long skip(long bytesToSkip)用于指定跳过的字节序列长度,返回值是真正跳过的字节序列长度,有可能比指定的bytesToSkip小。遇到流的尾部,会直接返回-1。这个方法的作用是可以选择跳过一些不需要读取到内存的字节序列,以减少内存消耗。

    public static void main(String[] args) throws Exception{
        ByteArrayInputStream inputStream = new ByteArrayInputStream("abcdefg".getBytes("UTF-8"));
        byte[] array = new byte[10];
        inputStream.skip(5);
        inputStream.read(array,0, array.length);
        System.out.println(new String(array,"UTF-8"));
    }

最终输出:fg,也就是跳过了前面的5个字节。

计算剩余可读字节序列长度

public int available()方法用于返回在不阻塞的情况下可以读取的总字节序列长度,如果没有可读字节则返回0。这个方法使用在本地文件(例如读取磁盘文件数据的时候使用的FileInputStream)读取的时候返回的值是真实可信的,但是在使用网络流(例如套接字)则返回的值并不是真实的,因为网络流是非阻塞的。

    public static void main(String[] args) throws Exception {
        ByteArrayInputStream inputStream = new ByteArrayInputStream("abcdefg".getBytes("UTF-8"));
        int size = inputStream.available();
        byte[] data = new byte[size];
        inputStream.read(data);
        System.out.println(new String(data, "UTF-8"));
    }

最后控制台输出:abcdefg。使用available()方法可以提前预支需要写入的字节数组的长度,但是需要警惕该方法返回值是否真实,还有,是否有足够的内存初始化该长度的数组,否则有可能发生OOM。

标记和重置

并不是所有的InputStream子类都支持标记和重置,因此public boolean markSupported()方法就是用于判断流实例是否支持标记和重置。如果markSupported方法返回false而强制使用mark或者reset一般会抛出IOException。下面举个栗子说明一下怎么使用:

    public static void main(String[] args) throws Exception{
        ByteArrayInputStream inputStream = new ByteArrayInputStream("abcdefg".getBytes("UTF-8"));
        byte[] array = new byte[10];
        //注意这里的readAheadLimit可以乱填,参考一下ByteArrayInputStream的源码
        inputStream.mark(10086);
        inputStream.read(array,0, 4);
        inputStream.reset();
        inputStream.read(array,4, 4);
        System.out.println(new String(array,"UTF-8"));
    }

过程如下图:

这里用ByteArrayInputStream为例,pos总是指向字节数组中下一个需要读取的字节元素的下标,mark()方法调用时候,pos的值被记录在mark变量中,而reset()方法调用的时候,pos的值被重置为mark的值。上面的例子最终输出:abcdabcd。

关闭

输入流的关闭可以显式调用close()方法,一般需要把关闭方法置于finally块中并且捕获其异常不进行异常抛出。关闭后的流不能再进行读取等操作,否则会抛出IOException。在Jdk1.7中引入了java.lang.AutoCloseable接口,实现了AutoCloseable接口的流可以使用try-resource的方式进行编码,这样就不需要显式关闭流。例如:

try (FileInputStream inputStream = new FileInputStream("xxxx")){
    byte[] buffer = new byte[10];
    inputStream.read(buffer);
    //....
}

InputStream子类

InputStream的子类主要是用于区分不同的字节数据来源(或者直接叫数据源)。另外,InputStream的子类FilterInputStream使用了典型的装饰器模式,一般称这类流叫装饰(输入)流。常见的装饰流主要是FilterInputStream的子类,包括BufferedInputStream、PushbackInputStream等等(还有很多隐藏在sun包下)。下面最要挑几个常用的InputStream的子类来介绍一下使用方式。这里啰嗦再点一次:InputStream的三个read方法变体适用于它的所有子类。

下图是InputStream的主要子类,不包含sun包下隐藏的类。

下面的分类只是按照个人的理解,并没有科学的根据。

废弃的子类:
- StringBufferInputStream,本来是设计用于读取字符的,已过期,用StringReader替代。
- LineNumberInputStream,本来是设计用于读取字符并且附带行号记录的功能,已过期,用LineNumberReader替代。

介质流:
- ByteArrayInputStream,从byte数组中读取数据。
- FileInputStream,从本地磁盘文件中读取数据。

装饰流:
ObjectInputStream和所有FilterInputStream的子类都是装饰输入流(装饰器模式的主角)。
- ObjectInputStream,可以用于读取对象,实际上是反序列化操作,但是对象类必须实现Serializable接口。
- PushbackInputStream,一般叫回退输入流,这个比较特殊,可以把读取进来的某些数据重新回退到输入流的缓冲区之中,也就是提供了回退机制。
- DataInputStream,提供从流中直接读取具体数据类型的功能。
- BufferedInputStream,缓冲输入流,为字节流读取提供基于内存的缓冲功能。

管道流:
- PipedInputStream,提供从与其它线程共用的管道中读取数据的功能。

合并流:
- SequenceInputStream,用于合并多个输入字节流。

内存输入流

ByteArrayInputStream(字节数组输入流)主要是用于直接读取byte数组(它内部就维护了一个字节数组),操作都是基于内存态的,它实现了InputStream的所有方法,也就是支持标记和重置。例子见前面的分析,这里不做多余举例。

文件输入流

FileInputStream主要用于读取文件内容为字节数组,不支持标记和重置,另外在JSR-51中它引入了FileChannel用于通过Channel读取数据。

这里可能有疑惑,有时候可以用很小的byte数组用来做缓冲区完成文件的复制(文件的复制包括两个步骤,分别是源文件内容读取到内存中和内存中的数据写到目标文件中),下面画个图解释一下整个复制过程。假设场景:源文件1.log里面有10字节数据,使用的缓冲字节数组的长度是6,目标是把1.log的内容拷贝到目标文件2.log中。先在磁盘D建立一个文件1.log,文件内容是:

abcdefghij

文件拷贝的代码如下:

    public static void main(String[] args) throws Exception {
        byte[] buffer = new byte[6];
        try (FileInputStream inputStream = new FileInputStream("D:\\1.log");
             FileOutputStream outputStream = new FileOutputStream("D:\\2.log")) {
            while (true) {
                int len = inputStream.read(buffer);
                if (len < 0) {
                    break;
                }
                outputStream.write(buffer, 0, len);
            }
        }
    }

控制台输出:

Loop:1,len:6
Loop:2,len:4
Loop:3,len:-1

同时可以看到磁盘D中新建了一个2.log文件,内容和1.log中完全一样,这里暂时先忽略OutputStream的使用方式,画图理解整个过程。

每次进行写入的时候是根据read()方法返回的当前读取到的字节序列的长度并且指定写时偏移量为0,总长度为读取到的字节序列的长度实现的。所以缓冲字节数组中即使有上一次循环残余的脏字节,也不会影响此次循环的数据写入。

回退输入流

Java中输入流都是采用顺序的读取方式,即对于一个输入流来讲都是采用从字节序列头到字节序列尾的顺序读取的,如果在输入流读取到实际不需要的字节,则只能通过程序将这些不需要的字节忽略,为了解决这样的处理问题,在Java中提供了一种回退输入流PushbackInputStream,可以把读取到的字节重新回退到输入流的缓冲字节数组之中。

    public static void main(String[] args) throws Exception {
        String message = "doge";
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(message.getBytes());
             PushbackInputStream pushbackInputStream = new PushbackInputStream(byteArrayInputStream)) {
            int temp;
            while ((temp = pushbackInputStream.read()) != -1) {
                //如果读取到'o'
                if (temp == 'o') {
                    //把'o'放回去缓冲字节数组
                    pushbackInputStream.unread(temp);
                    //再读一次
                    temp = pushbackInputStream.read();
                    System.out.print("<这是回退>" + (char) temp + "");
                } else {
                    System.out.print((char) temp);
                }
            }
        }
    }

控制台输出:

d<这是回退>o这是回退>ge

unread()方法主要是把读取到的字节放回去(pos-1)的字节数组中的位置,很好理解,因为pos总是指向下一个读取到的字节元素。这里引用一篇文章的截图说明一下其处理机制:

缓冲输入流

BufferedInputStream继承自FilterInputStream,它是典型的装饰输入流,它内部提供了一个缓冲字节数组,默认长度是8192(也就是总容量是8KB),当然也可以通过构造函数指定。读取数据的时候,源字节序列先填充到其内部的缓冲字节数组,然后在调用read()等相关方法的时候,实际上是从缓冲字节数组中拷贝字节数据到目标字节数组中。当缓冲字节数组中的字节读取(拷贝)完毕之后,如果被读取的字节序列还有剩余,则再次调用底层输入流填充缓冲字节数组。这种做法等于从直接内存中读取数据,其效率每次都要访问磁盘文件高很多。当需要读取的字节序列的长度十分小或者本身使用的目标字节容量比BufferedInputStream提供的缓冲字节数组容量大的时候,使用BufferedInputStream的优势是不明显的。

    public static void main(String[] args) throws Exception {
        try (FileInputStream inputStream = new FileInputStream("D:\\1.log");
             BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream)) {
            byte[] data = new byte[20];
            bufferedInputStream.read(data);
            System.out.println(new String(data, "UTF-8"));
        }
    }

数据输入流

DataInputStream继承自FilterInputStream,它也是典型的装饰输入流,它允许应用程序以与机器无关方式从底层输入流中读取基本Java数据类型。它的方法列表如下:

final int read(byte[] buffer, int offset, int length)
final int read(byte[] buffer)
final boolean readBoolean()
final byte readByte()
final char readChar()
final double readDouble()
final float readFloat()
final void readFully(byte[] dst)
final void readFully(byte[] dst, int offset, int byteCount)
final int readInt()
final String readLine()
final long readLong()
final short readShort()
final static String readUTF(DataInput in)
final String readUTF()
final int readUnsignedByte()
final int readUnsignedShort()
final int skipBytes(int count)

注意到所有的不需要缓冲字节数组的方法都是用于读取字节序列中的下一个字节或者下一个字节块(如果需要转换的话,则转换为相应的类型)。

    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream bo = new ByteArrayOutputStream();
        DataOutputStream outputStream = new DataOutputStream(bo);
        outputStream.writeInt(24);
        outputStream.writeBytes("doge");
        ByteArrayInputStream inputStream = new ByteArrayInputStream(bo.toByteArray());
        DataInputStream dataInputStream = new DataInputStream(inputStream);
        //这里注意,因为写入的时候顺序是先int然后是byte,所以读取的时候必须先读取int再读取byte
        int age = dataInputStream.readInt();
        byte[] nameChars = new byte[4];
        for (int i = 0; i < 4; i++) {
            nameChars[i] = dataInputStream.readByte();
        }
        System.out.println("age int ->" + age);
        System.out.println("name byte->" + new String(nameChars));
    }

管道输入流

Java的管道输入与输出实际上使用的是一个循环缓冲数组来实现,这个数组默认大小为1024字节。输入流PipedInputStream从这个循环缓冲数组中读数据,输出流PipedOutputStream往这个循环缓冲数组中写入数据。当这个缓冲数组已满的时候,输出流PipedOutputStream所在的线程将阻塞;当这个缓冲数组首次为空的时候,输入流PipedInputStream所在的线程将阻塞。但是在实际用起来的时候,却会发现并不是那么好用。一般PipedInputStream和PipedOutputStream是成对出现的,否则没有意义。

    public static void main(String[] args) throws Exception {
        PipedInputStream pipedInputStream = new PipedInputStream();
        PipedOutputStream pipedOutputStream = new PipedOutputStream(pipedInputStream);
        Thread sender = new Thread(() -> {
            try {
                pipedOutputStream.write("hello,doge".getBytes("UTF-8"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        Thread receiver = new Thread(() -> {
            byte[] data = new byte[10];
            try {
                //这里会阻塞
                pipedInputStream.read(data);
                System.out.println("Receive message -> " + new String(data,"UTF-8"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        receiver.start();
        sender.start();
        Thread.sleep(Integer.MAX_VALUE);
    }

上面只是举例所以显式创建线程,如果在实际使用中最好别这样做。

合并输入流

SequenceInputStream会将与之相连接的流集组合成一个输入流并从第一个输入流开始读取,直到到达文件末尾,接着从第二个输入流读取,依次类推,直到到达包含的最后一个输入流的文件末尾为止。合并流的作用是将多个源合并合一个源,也就是说它是一个字节流的合并工具。

    public static void main(String[] args) throws Exception{
        ByteArrayInputStream inputStream1 = new ByteArrayInputStream("hello,".getBytes("UTF-8"));
        ByteArrayInputStream inputStream2 = new ByteArrayInputStream("doge".getBytes("UTF-8"));
        Vector vector = new Vector();
        vector.add(inputStream1);
        vector.add(inputStream2);
        SequenceInputStream sequenceInputStream = new SequenceInputStream(vector.elements());
        byte[] data = new byte[10];
        //注意这里先读取"hello,",长度为6,起始位置为0
        sequenceInputStream.read(data,0, 6);
        //第二次读取"doge",长度为4,起始位置为6(data数组下标)
        sequenceInputStream.read(data,6, 4);
        System.out.println(new String(data,"UTF-8"));
    }

OutputStream

java.io.OutputStream是所有输出流的抽象父类,提供三个基本方法用于写入字节序列,另外还提供关闭流、flush(强制清空缓冲区的字节数组并且将之马上写入到目标中)两个方法。

//单字节数据写入,实际上是"无符号的byte",范围是[0,255]
public abstract void write(int b) throws IOException
//下面的write方法的变体
public void write(byte[] data) throws IOException
//写入指定的字节数组,可以指定偏移量和总长度
public void write(byte[] data, int offset, int length) throws IOException
// 强制清空缓冲区的字节数组并且将之马上写入到目标中
public void flush( ) throws IOException
//关闭流
public void close( ) throws IOException

写入

public abstract void write(int b)是抽象方法,必须由子类实现。它用于写入单字节数据,写入的字节是”无符号的byte”类型值[0,255],因为Java中不存在无符号byte类型,所以入参是int类型。

public void write(byte[] data, int offset, int length)用于写入一个连续的数据块(即一个连续的字节序列)到目标中,offset是指写入的目标字节数组data的偏移量,可以理解为data这个字节数组写入时候的起始索引,而length是需要写入的字节序列的最大长度(这个长度有可能比data这个字节数组的总长度要小)。画个图说明一下:

public void write(byte[] data)方法只是write(byte[] data, int offset, int length)的变体,源码如下:

    public void write(byte b[]) throws IOException {
        write(b, 0, b.length);
    }

flush

很多时候我们需要使用缓冲流来提高写入的性能,使用缓冲流的时候,并不是每次调用write方法就直接写目标中写入字节序列,而是当缓冲字节数组(下面称为缓冲区)填充满了之后再一次性把缓冲区中的所有字节写到目标中。flush()方法被调用的时候,不管缓冲区是否已经填充满,直接把缓冲区的所有字节写入到目标中并且清空缓冲区。其实可以通过源码查看,在FileOutputStream中,flush()方法是空实现,也就是调用它不会产生任何效果,但是在BufferedOutputStream中它就起到前面说到的效果。当然,如果调用了close()方法会强制把缓冲区的数据写入到目标,表面上可以这样理解,close()方法调用的时候必定会触发flush()

关闭

输出流的关闭可以显式调用close()方法,一般需要把关闭方法置于finally块中并且捕获其异常不进行异常抛出。关闭后的流不能再进行读取等操作,否则会抛出IOException。在Jdk1.7中引入了java.lang.AutoCloseable接口,实现了AutoCloseable接口的流可以使用try-resource的方式进行编码,这样就不需要显式关闭流。例如:

try (FileOutputStream outputStream = new FileOutputStream("xxxx")){
    byte[] buffer = new byte[10];
    //填充buffer
    outputStream.write(buffer);
    //....
}

在上一节中提到过,调用了close()方法会强制把缓冲区的数据写入到目标,所以一定要注意必须确保输出流的关闭,一方面可以释放相关的句柄以避免资源被大量占用导致OOM等,另一方面可以避免没有显式调用flush()下导致内存数据没有成功写入到目标中(发生了内存数据的丢失)。

OutputStream子类

下图是OutputStream的主要子类,不包含sun包下隐藏的类。

下面的分类只是按照个人的理解,并没有科学的根据。

管道流:
- PipedOutputStream,提供从与其它线程共用的管道中写入数据的功能。

介质流:
- ByteArrayOutputStream,提供写入数据到内存中的字节数组的功能。
- FileOutputStream,提供写入数据到磁盘文件的功能。

装饰流:
主要包括ObjectOutputStream和FilterOutputStream的子类。
- ObjectOutputStream,提供对象序列化功能。
- PrintStream,打印流,提供打印各种类型的数据的功能,System.out是标准输出,是PrintStream的实例。
- DataOutputStream,数据输出流,提供直接写入具体数据类型的功能。
- BufferedOutputStream,缓冲输出流,为输出数据提供字节缓冲区。

因为输出流和输入流是基本对称的,下面只介绍一个特例:PrintStream。

打印流

PrintStream也使用了典型的装饰器模式,它为其他输出流添加了功能,使它们能够方便地打印各种数据值表示形式。与其他输出流不同,PrintStream永远不会抛出IOException,异常情况仅仅可通过checkError方法返回的布尔值进行判断。另外,PrintStream的构造函数可以通过autoFlush这个布尔值参数设置是否自动flush,这意味着可在写入byte数组之后自动调用flush方法(其实是任何写入操作,因为具体类型的写入操作最终也会转换为byte数组的写入)。其实,PrintStream的API设计是十分优秀的,但是它吞下所有异常的这一设计有点不太友好。

    public static void main(String[] args) throws Exception {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
             PrintStream printStream = new PrintStream(outputStream)) {
            printStream.print("I am doge,");
            printStream.print(25);
            printStream.print(" years old!");
            System.out.println(new String(outputStream.toByteArray(), "UTF-8"));
        }
    }

控制台输出:

I am doge,25 years old!

不得不说,打印流用起来真是十分爽。

关于InputStream和OutputStream的小结

这里小结一下InputStream和OutputStream,以FileInputStream和FileOutputStream,仅仅代表个人的见解。

先在假设Windows系统的D盘有一个文件,文件名称是access.log,内容是:

Hello,doge

我们可以通过文件的绝对路径实例化FileInputStream:

FileInputStream fileInputStream = new FileInputStream(String.format("D:%saccess.log", File.separator));

然后,我们就可以通过FileInputStream读取D:\access.log中的内容,首先我们要先新建一个字节数组用于存放读取的内容,因为”Hello,doge”的长度为10,所以我们建立长度为10的字节数组即可:

byte[] data = new byte[10];

接着我们通过FileInputStream的read(byte b[])方法读取文件内容,写入到新创建的空字节数组data中:

fileInputStream.read(data);

这里值得注意的是:FileInputStream的read(byte b[])当读取到文件的末尾的时候,会返回-1,这里读取过程其实最好写成一个循环:

int len;
while ((len = fileInputStream.read(data)) != -1){
}

这样之后,文件内容就能够读取到data中,可以尝试打印data的内容:

System.out.println(new String(data,"UTF-8"));

当然,这里仅仅是演示如何读取文件内容,但是实际生产环境中,文件的内容有可能极大,如果要新建一个极大的byte数组,一次性读完整个文件,有可能会导致大量内存被占用导致内存溢出。因此,在读取大文件的时候,可以考虑分行、限制每次读取长度或者分段多次读取,在这里读取大文件不做深入分析,后面会写一篇实战的文章。

对于OutputStream,写入数据过程大致和InputStream的读取数据过程相反,以FileOutputStream为例子,假设我们要向D:\access.log文件中写入文件内容,写入的内容为:

Today is Sunday.

类似,先通过文件绝对路径新建FileOutputStream实例:

FileOutputStream fileOutputStream = new FileOutputStream(String.format("D:%saccess.log", File.separator));

然后,我们需要准备写入目标文件的字节数组内容:

byte[] outputData = "Today is Sunday.".getBytes("UTF-8");

最后调用FileOutputStream的write(byte b[])方法即可:

fileOutputStream.write(outputData);

调用完成的后,会发现文件中原来的”Hello,doge”内容被抹掉,替换为”Today is Sunday.”,这是因为
FileOutputStream实例没有指定为追加模式,于是直接把字节数组的内容直接写进去文件,覆盖掉原来的内容。如果想实现文件内容追加,要使用FileOutputStream的另一个构造函数:

//如果指定append为true,则写入的数据的方式是追加,而不是覆盖,默认是覆盖
public FileOutputStream(String name, boolean append){
}

也就是:

FileOutputStream fileOutputStream = new FileOutputStream(String.format("D:%saccess.log", File.separator),true);

有时候要留多个心眼,观察一下OutputStream的具体子类的构造,可能会有惊喜。

输入输出流的分析大致到这里,其实它们的API还是挺容易使用的。

Reader和Writer

Reader

java.io.Reader是所有的字符读取器(Reader暂时不知道怎么翻译,先这样叫)的抽象父类。Reader的核心功能是从来源中读取字节序列并且转换为char数组。它主要提供下面的方法:

public int read(java.nio.CharBuffer target) throws IOException
public int read() throws IOException
public int read(char cbuf[]) throws IOException 
abstract public int read(char cbuf[], int off, int len) throws IOException
public long skip(long n) throws IOException 
public boolean ready() throws IOException    
public boolean markSupported()   
public void mark(int readAheadLimit) throws IOException
public void reset() throws IOException
abstract public void close() throws IOException

大部分方法与InputStream中的方法相似,实际效果也是基本一致,这里主要分析一下ready()read()方法。

ready

当此方法返回true的时候,保证下一次调用read()方法的时候不会阻塞输入,但是当此方法返回false,并不能保证下一次调用read()方法的时候一定会阻塞输入。这个方法的作用是用来判断编码转换器是否已经把字节序列转换为char序列,如果有可用的char序列,此方法返回true,此时可以进行char的读取。其实,个人更建议使用read()方法阻塞和返回值是否为-1来做相关判断。

读取

read方法都是阻塞方法,直到有可读字节、到达流的尾部返回-1或者抛出异常。其实在最开始前已经说过,实际上Reader读取的还是字节,中间通过编码转换把byte序列转换成char序列,下面就直接描述为”读取char序列”或者”读取char数组”。

public int read(java.nio.CharBuffer target)把读取到的char序列写入到指定的CharBuffer实例中,返回写入的char序列的长度,底层依赖到read(char cbuf[], int off, int len)方法。

public int read()方法是单字符读取方法,返回值的范围是[0,65535],当方法返回-1说明已经到达流的尾部,得到的int值可以直接强转为char类型。

abstract public int read(char cbuf[], int off, int len)方法是读取一段char序列或者一块char数据,可以指定写入到目标char数组cbuf的偏移量和写入的总字符长度。注意到此方法是抽象方法,必须由子类实现。public int read(char cbuf[])方法只是read(char cbuf[], int off, int len)的变体,源码如下:

    public int read(char cbuf[]) throws IOException {
        return read(cbuf, 0, cbuf.length);
    }

Reader的主要子类

下图是Reader的主要子类,不包含sun包下隐藏的类。

介质Reader:
- CharArrayReader,从Char数组读取数据。
- StringReader,从字符串中读取数据。

装饰Reader:
- BufferedReader,读取数据时提供基于内存的char数组缓冲区,并且提供了基于行读取的功能。
- LineNumberReader,继承于BufferedReader,添加了设置行号和获取行号的功能。
- PushbackReader,提供回退功能。

管道Reader:
- PipedReader,提供基于线程的管道数据读取的功能。

转换Reader:
- InputStreamReader,比较特殊,它的构造函数入参为InputStream,也就是它是InputStream转化为Reader的桥梁,也可以理解为读取到的byte序列转换为char序列的转换器。
- FileReader,承于InputStreamReader,提供基文件数据读取的更便捷的方法。

一般来说,如果来源中仅仅存在字符,可以优先使用Reader进行数据读取,如果遇到像图片一类的二进制序列,只能考虑使用InputStream。

CharArrayReader

CharArrayReader提供从Char数组中读取数据的功能。

    public static void main(String[] args) throws Exception {
        char[] input = "Hello,doge".toCharArray();
        CharArrayReader charArrayReader = new CharArrayReader(input);
        char[] readData = new char[10];
        charArrayReader.read(readData);
        System.out.println(new String(readData));
    }

StringReader

StringReader提供从字符串读取数据的功能。

    public static void main(String[] args) throws Exception{
        StringReader stringReader = new StringReader("Hello,doge");
        char[] readData = new char[10];
        stringReader.read(readData);
        System.out.println(new String(readData));
    }

BufferedReader

BufferedReader读取数据时提供基于内存的缓冲区,并且提供了基于行读取的功能,换行功能是换行符进行判断,例如”\n”或者”\r”。

    public static void main(String[] args) throws Exception {
        try (StringReader stringReader = new StringReader("Hello,doge!\nToday is Sunday!");
             BufferedReader bufferedReader = new BufferedReader(stringReader)) {
            String value;
            while ((value = bufferedReader.readLine()) != null) {
                System.out.println(value);
            }
        }
    }

控制台输出:

Hello,doge!
Today is Sunday!

BufferedReader提供的readLine()方法返回被装饰Reader下一行的字符串内容,如果读取到流的尾部,则返回null。

LineNumberReader

LineNumberReader继承于BufferedReader,读取数据时提供基于内存的缓冲区,并且提供了基于行读取的功能,增加了设置行号和获取行号的功能。

    public static void main(String[] args) throws Exception {
        try (StringReader stringReader = new StringReader("Hello,doge!\nToday is Sunday!");
             LineNumberReader bufferedReader = new LineNumberReader(stringReader)) {
            String value;
            while ((value = bufferedReader.readLine()) != null) {
                System.out.println(bufferedReader.getLineNumber() + ":" + value);
            }
        }
    }

控制台输出:

1:Hello,doge!
2:Today is Sunday!

PushbackReader

PushbackReader类似于PushbackInputStream,提供数据回退的功能。

    public static void main(String[] args) throws Exception {
        StringReader stringReader = new StringReader("hello,doge");
        PushbackReader pushbackReader = new PushbackReader(stringReader);
        int len;
        while ((len = pushbackReader.read()) != -1) {
            if (len == 'o') {
                //回退当前char
                pushbackReader.unread(len);
                //再次读取
                len = pushbackReader.read();
                System.out.print("<回退字符>" + (char) len + "");
            } else {
                System.out.print((char) len);
            }
        }
    }

PipedReader

PipedReader和PipedWriter与PipedInputStream和PipedOutputStream一样,都可以用于管道通信。PipedWriter是字符管道输出流,继承于Writer;PipedReader是字符管道输入流,继承于Reader,PipedWriter和PipedReader的作用是可以通过管道进行线程间的通讯。两者必须要配套使用,否则意义不大。

public static void main(String[] args) throws Exception{
        PipedReader pipedReader = new PipedReader();
        PipedWriter pipedWriter = new PipedWriter(pipedReader);
        Thread sender = new Thread(() -> {
            try {
                pipedWriter.write("hello,doge");
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        Thread receiver = new Thread(() -> {
            char[] data = new char[10];
            try {
                pipedReader.read(data);
                System.out.println("receive data -> " + new String(data));
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        receiver.start();
        sender.start();
        Thread.sleep(Integer.MAX_VALUE);
    }

InputStreamReader

InputStreamReader是InputStream转化为Reader的桥梁,也可以理解为读取到的byte序列转换为char序列的转换器,可以在构造器中指定具体的编码类型,如果不指定的话将采用底层操作系统的默认编码类型,例如GBK。它的实例化依赖于InputStream的实例,InputStream转化为Reader,就可以使用装饰流操作InputStreamReader实例,这样的话能够大大简化读取数据的操作。

    public static void main(String[] args) throws Exception {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream("Hello,doge!\nToday is Sunday!".getBytes("UTF-8"));
             InputStreamReader inputStreamReader = new InputStreamReader(byteArrayInputStream, "UTF-8");
             BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) {
            String value;
            while ((value = bufferedReader.readLine()) != null) {
                System.out.println(value);
            }
        }
    }

FileReader

FileReader继承于InputStreamReader,它内部原理是通过FileInputStream转化为Reader,作用是简化了读取文件字符的功能。

    public static void main(String[] args) throws Exception{
        try (FileReader fileReader = new FileReader(String.format("D:%saccess.log", File.separator));
            BufferedReader bufferedReader = new BufferedReader(fileReader)){
            String value;
            while ((value = bufferedReader.readLine()) != null) {
                System.out.println(value);
            }
        }
    }

Writer

java.io.Writer是所有的字符写入器(Writer暂时不知道怎么翻译,先这样叫)的抽象父类。Writer的核心功能是把char序列转化为byte序列写入到目标中,char序列转化为byte序列的过程对开发者来说是无感知的。它主要提供下面的方法:

public void write(int c) throws IOException
public void write(char cbuf[]) throws IOException
abstract public void write(char cbuf[], int off, int len) throws IOException
public void write(String str) throws IOException
public void write(String str, int off, int len)
public Writer append(CharSequence csq) throws IOException
public Writer append(CharSequence csq, int start, int end)
public Writer append(char c) throws IOException
abstract public void flush() throws IOException
abstract public void close() throws IOException

读取或者追加

追加方法append()最终都会调用到write()方法,它们只是为了方便构建链式编程(追加方法都返回this)。所有的append()write()方法都是abstract public void write(char cbuf[], int off, int len)方法的变体而已,写入char数组再转换为字节序列到目标中,可以指定char数组的偏移量和写入的总长度。注意到,write(int c)方法,实际上char和int可以相互转换,在此方法中,参数int会直接转换为char类型。int转换为char的时候,只会取低16位,高16位会被忽略,并且它是无符号的,也就是char的范围是[0,65535],这正是Unicode编码的码点范围。

flush

flush()方法调用之后会立即把char序列转换为byte序列写入到目标中,类似于OutputStream的flush方法。

关闭

close()方法用于关闭流,此方法调用后必定先进行flushing,也就是效果类似于首先先调用flush()方法,然后释放流相关的资源和句柄等,类似于OutputStream的close方法。

Writer的主要子类

下图是Writer的主要子类,不包含sun包下隐藏的类。

Writer的子类的和Reader的子类是基本对称的,下面只介绍特例。

打印Writer:
- PrintWriter,提供基于多种数类型格式化的输出功能,使用方式其实跟PrintStream大致相同。

这里再啰嗦一句,Writer的所有子类都实现了父类Writer中的方法,它们都是相当有效和易用的API。

PrintWriter

PrintWriter提供基于多种数类型格式化的输出功能,它使用了装饰器模式。

    public static void main(String[] args) throws Exception {
        try (StringWriter stringWriter = new StringWriter();
             PrintWriter printWriter = new PrintWriter(stringWriter)) {
            printWriter.println("hello,doge");
            printWriter.println(10086);
            System.out.println(stringWriter.toString());
        }
    }

控制台输出:

hello,doge
10086

小结

IO是网络编程的基础,如果想要写出高性能的中间件,必须深入理解IO相关的知识。这篇文章仅仅说到了一些皮毛,主要是通过阅读书中的内容,对IO的一些基础认知进行整理,对BIO相关的一些API进行基于例子的使用讲解。这本书中关于套接字相关的内容并不详细,在《Java NIO》有更深入的分析(但是两本书很多内容是重叠的,有点蛋疼),后面会写另一篇NIO的总结,主要包括NIO的特性以及URL、URI、URLConnection、套接字相关的内容等等。如果有更深入的收获,后面再写具体的文章分享。

(本文完)

你可能感兴趣的:(IO,IO,BIO,网络编程)