Java编程思想拾遗(14)I/O系统

271 阅读7分钟

编程语言的I/O类库中常使用流这个抽象概念,它代表任何有能力产出数据源对象或者是有能力接收数据的接收端对象,“流”屏蔽了实际的I/O设备中处理数据的细节。

流的方向

在Java1.0中,类库的设计者首先限定于输入有关的所有类都应该从InputStream继承,用于读取单个字节或者字节数组,而与输出有关的所有类都应该从OutputStream继承,用于写单个字节或者字节数组。read()和write()都是抽象方法,约定了子类的实现方式。

InputStream:

    // 从read()返回一个字节给调用者,以int形式返回,忽略高24位
    // 此方法可能阻塞直到数据可用、EOF、IOE,-1代表EOF
    public abstract int read() throws IOException;
    
    // 从read()返回字节给数组b[],从off开始最多读取len个字节
    // 返回值代表实际读取字节数,-1代表EOF
    public int read(byte b[], int off, int len) throws IOException

OutputStream:

    // 将字节b写到字节流,,以int形式传入,忽略高24位
    public abstract void write(int b) throws IOException;
    
    // 将字节数组b[]写到字节流,从off开始最多写len个字节
    public void write(byte b[], int off, int len) throws IOException 

字节数据流程图:

graph TD
InputStream --> OuterCaller --> OutputStrem

那么,InputStream的字节数据从哪里来的呢?从ByteArrayInputStream为例:

    // 字节数据在构造器传入,称为buf数组
    public ByteArrayInputStream(byte buf[])
    public ByteArrayInputStream(byte buf[], int offset, int length)
    
    // 数据读取时,从buf数组中获取
    // ByteArrayInputStream内部维护了buf数组被读取的状态,并且还做了并发同步
    public synchronized int read() {
        return (pos < count) ? (buf[pos++] & 0xff) : -1;
    }

同理,FileInputStream便也是在构造器阶段指定文件描述符File,后续read()将由操作系统根据文件描述符File执行数据读取返回。

另外一个方向,ByteArrayOutputStream从外部接收字节数据后便会在内部存储:

    // 指定内部buf数组的初始存储空间
    public ByteArrayOutputStream(int size)
    
    // 每次写数据到buf数组前,需要保证其容量可以支撑(当然溢出控制是需要的)
    public synchronized void write(int b) {
        ensureCapacity(count + 1);
        buf[count] = (byte) b;
        count += 1;
    }

同理,FileOutputStream便也是在构造器阶段指定文件描述符File,后续write()将由操作系统根据文件描述符File执行数据写入。

由上可知,Stream下的继承类,其区别在于字节数据在内部的存储形式不同而已,其数据流方向均依据流程图所示流向。

流的装饰

我们很少使用单一的类来创建流对象,而是通过叠合多个对象来提供所期望的功能,即装饰器设计模式。

既然是需要装饰,那么就需要保证其被装饰对象原有逻辑不变,在此之上才能进行功能补充,显然这是代理的应用范畴了,为此JDK提供了FilterInputStream作为适配基类。


public class FilterInputStream extends InputStream {

    protected volatile InputStream in;
    
    protected FilterInputStream(InputStream in) {
        this.in = in;
    }
    
    public int read() throws IOException {
        return in.read();
    }
}

经典装饰类BufferedInputStream实际上就是在读取时一次性缓存整个buf数组,以便减少对代理InputStream的调用。如果外部请求的数据范围超过了buffer大小,此时会直接跳过缓存直连代理。

public class BufferedInputStream extends FilterInputStream {
    
    public synchronized int read() throws IOException {
        if (pos >= count) {
            // 这里会尽可能地从代理InputStream填充整个buf数组
            fill();
            if (pos >= count)
                return -1;
        }
        return getBufIfOpen()[pos++] & 0xff;
    }
}

此时字节数据流程图内部多了FilterInputStream和FilterOutputStream。

graph TD
ProxyInputStream --> FilterInputStream* --> OuterCaller --> FilterOutputStream* --> ProxyOutputStream

从调用角度上读取就是不断上钻,写入就是不断下钻

stateDiagram-v2
OuterCaller --> FilterInputStream*
FilterInputStream* --> ProxyInputStream
ProxyInputStream --> FilterInputStream*
FilterInputStream* --> OuterCaller

OuterCaller --> FilterOutputStream*
FilterOutputStream* --> ProxyOutputStream

前面的文章中介绍过,通过接口带来的多重继承机制,可以为类库增强功能,JDK提供了DataInputStream和DataOuputStream,可以为调用者提供字节和其他基本数据类型(int,char,long等)之间的转换功能。

public class DataInputStream extends FilterInputStream implements DataInput {

    public final int readInt() throws IOException {
        // 前面提到,实际返回真正有数值意义的只有低8位
        int ch1 = in.read();
        int ch2 = in.read();
        int ch3 = in.read();
        int ch4 = in.read();
        if ((ch1 | ch2 | ch3 | ch4) < 0)
            throw new EOFException();
        return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0));
    }
}

Reader和Writer

Java1.1对基本的I/O流库进行了重大的修改,InputStream和OutputStream在以面向字节形式的I/O中仍可以提供极有价值的功能,Reader和Writer则提供兼容Unicode与面向字符的I/O功能。

有时我们必须把来自字节层次结构中的类和字符层次结构中的类结合起来使用,为了实现这个目的,要用到适配器类:InputStreamReader可以把InputStream转换为Reader,而OutputStreamWriter可以把OutputStream转换为Writer(其实也算是用适配)。PrintWriter提供了格式化机制。

public class BufferedInputFile {
    public static String read(String filename) throws IOException {
        // FileReader其实只是简单将FileInputStream传给父类InputStreamReader
        BufferedReader in = new BufferedReader(new FileReader(filename));
        String s;
        StringBuilder sb = new StringBuilder();
        // null代表EOF
        while((s = in.readLine()) != null) {
            sb.append(s + "\n");
        }
        in.close();
        return sb.toString();
    }
}

新I/O

JDK1.4的java.nio.*包中引入了新的Java I/O类库,其目的在于提高速度,速度的提高来自于所使用的结构更接近于操作系统执行I/O的方式:通道和缓存器。

我们并没有直接和通道交互,我们只是和缓存器交互,并把缓冲器派送到通道,通道要么从缓冲器获得数据,要么向缓冲器发送数据。唯一直接和通道交互的缓存器是ByteBuffer。

旧I/O类库中有三个类被修改了,用以产生FileChannel,分别是FileInputStreeam、FileOutputStream和RandomAccessFile,另外java.nio.channels.Channels类提供了实用方法用以在通道中产生Reader和Writer。

public class GetChannel {
    private static final int BSIZE = 1024;
    public static void main(String[] args) throws IOException {
        FileChannel fc = new FileOutputStream("data.txt").getChannel();
        fc.write(ByteBuffer.wrap("Some text".getBytes()));
        fc.close();
        
        fc = new FileInputStream("data.txt").getChannel();
        ByteBuffer buff = new ByteBuffer.allocate(BSIZE);
        fc.read(buff);
        buff.flip();
        while (buff.hasReamining) {
            System.out.print((char)buff.get());
        }
    }
}

ByteBuffer基于Buffer,本质上存储的是一个数组,通过一些变量标志和切换方法,对数组的读写进行控制。

变量方向为 0 <= mark <= position <= limit <= capacity。

  • capacity 数组最大容量,视为物理限制
  • position 读写指针,表示下一个可读或可写的位置
  • limit 读写限制位置,表示读写最多可以到达的位置
  • mark 是position的一个备份位,可用于恢复position

控制方法

  • mark()和reset() 用于position的备份和恢复
  • clear() 全部清除,是整个buffer的reset
  • flip() 表示buffer自己读取就绪,可以对外被读取。limit = position,position = 0
  • rewind() 表示buffer可以从头读或写。position = 0

尽量ByteBuffer只能保存字节类型的数据,但是它具有可以从其所容纳的字节中产生各种不同基本类型值的方法,在底层使用的还是同一个字节数组,这种使用方式被称为视图缓冲器。

public class GetClass {
    private static final int BSIZE = 1024;
    public static void main(String[] args) {
        ByteBuffer bb = ByteBuffer.allocate(BSIZE);
        bb.asCharBuffer().put("abcdef");
        print(Arrays.toString(bb.array(());
        bb.rewind();
        
        bb.order(ByteOrder.LITTLE_ENDIAN);
        
        bb.asCharBuffer().put("abcdef");
        print(Arrays.toString(bb.array(());
    }
}

当存储量大于一个字节时,就要考虑字节的顺序问题了,ByteBuffer是以高位优先的形式BigEndian存储数据的,并且数据在网上传送时也常常使用高位优先的形式。

// 小端:逻辑高位存储在物理高位
static void putIntL(ByteBuffer bb, int bi, int x) {
    bb._put(bi + 3, int3(x));
    bb._put(bi + 2, int2(x));
    bb._put(bi + 1, int1(x));
    bb._put(bi    , int0(x));
}
// 大端:逻辑高位存储在物理低位,概念定义估计是按物理位置递增区分的
static void putIntB(ByteBuffer bb, int bi, int x) {
    bb._put(bi    , int3(x));
    bb._put(bi + 1, int2(x));
    bb._put(bi + 2, int1(x));
    bb._put(bi + 3, int0(x));
}

RandomAccessFile

RandomAccessFile适用于由大小已知的记录组成的文件,所以我们可以使用seek()将记录从一处转移到另一处,然后读取或者修改记录。它实现了DataInput和DataOutput接口,但不使用InputStream和OutputStream类中已有的任何功能,它是一个完全独立的类,直接从Object派生而来。

public class UsingRandomAccessFile {
    static String file = "rtest.dat";
    
    static void display() throws IOException {
        RandomAccessFile rf = new RandomAccessFile(file, "r");
        for(int i = 0; i < 7; i++) {
            System.out.println("Value " + i + ": " + rf.readDouble());
        }
        System.out.println(rf.readUTF());
        rf.close();
    }
    
    public static void main(String[] args) throws IOException {
        RandomAccessFile rf = new RandomAccessFile(file, "rw");
        for(int i = 0; i < 7; i++) {
            rf.writeDouble(i * 1.414);
        }
        rf.writeUTF("The end of the file");
        rf.close();
        
        display();
        
        rf = new RandomAccessFile(file, "rw");
        rf.seek(5 * 8);
        rf.writeDouble(47.00001);
        rf.close();
        
        display();
    }
}

内存映射文件允许我们创建和修改那些因为太大而不能放入内存的文件,由RandomAccessFile开始,获得该文件上的通道,然后调用map()产生MappedByteBuffer,这是一种特殊类型的直接缓冲器。通过指定映射文件的初始位置和映射区域的长度,这样就可以映射某个大文件的较小部分。

public class LargeMappedFiles {
    static int length = 0x8FFFFFF;
    public static void main(String[] args) throw IOException {
        MappdedByteBuffer out = new RandomAccessFile("test.dat", "rw").getChannel().map(FileChannel.MapMode.READ_WRITE, 0, length);
        for(int i = 0; i< length; i++) {
            out.put((byte)'x');
        }
        print("Finished writing");
        for(int i = length /2; i< length/2 + 6; i++) {
            printlnb((char)out.get(i));
        }
    }
}

对象序列化

笔者目前在编程实战上几乎没使用过Serializable,对于网络传输序列化主要用的是PB、Thrift和自定义协议,而数据存储直接用DB,所以这里简单介绍一下。

在对一个Serializable对象进行还原的过程中,没有调用任何构造器,包括默认的构造器,整个对象都是通过从InputStream中取得数据恢复而来的。

transient关键字可以对字段序列化进行忽略。

如果有特殊的需求,可以通过实现Externalizable接口来对序列化过程进行控制,整个Externalizable接口继承了Serializable接口,同时增添了两个方法:writeExternal()和readExternal(),会在序列化和反序列化的过程中被自动调用,以便执行一些特殊操作。注意此时就会调用默认构造器了。