值得一用的IO神器Okio

9,005 阅读11分钟

本文主题:

  • 传统Java IO的使用

  • Java IO内部的设计实现为什么是这样?

  • IO神器Okio

传统的java io不太好用啊

记得在我第一次使用java的io去操作一个文件时,从网上copy类似这样一段代码:

 InputStream in = null;
 InputStream binStream = null;
 try {
     in = new FileInputStream("./test.txt");
     binStream = new BufferedInputStream(in);
     byte[] data = new byte[128];
     while (binStream.read(data) != -1) {
         //...
     }
 } catch (IOException e) {
     e.printStackTrace();
 }finally {
     if (in != null){
         try {
             in.close();
         } catch (IOException e) {
             e.printStackTrace();
         }
     }
     if (binStream != null ){
         try {
             binStream.close();
         } catch (IOException e) {
             e.printStackTrace();
         }
     }
 }

当时就觉得很麻烦,不理解为啥要这么写。由于没有弄懂后面的原理,之后每次写类似的代码都会去网上copy一份。

现在再写这段代码,第一反应是想去搞清楚为啥自己不能徒手撸出这段代码。学技术要弄懂背后的本质呀。于是,就有了一段探索之旅:

探索过程中最困惑的一点是,

FileInputStream不就能直接把文件内容读取出来,BufferedInputStream出现的意义是什么?如果BufferedInputStream如它的名字一样,有缓存功能,那直接用它不行吗?还要把FileInputStream对象注入到它里面,这么做真的有必要吗?

要解决这个困惑,就要通过源码看看下背后的实现机制:

class FileInputStream extends InputStream{
  
}
class BufferedInputStream extends FilterInputStream {
  
}
class FilterInputStream extends InputStream {
  protected volatile InputStream in;
  protected FilterInputStream(InputStream in) {
    this.in = in;
  }
  public int read() throws IOException {
    return in.read();
  }
  public void close() throws IOException {
    in.close();
  }
  //...
}

可以看出来的是,文件输入流FileInputStream很正常的继承了InputStream抽象类,但奇怪的是BufferedInputStream,它继承的却是FilterInputStream,进入这个类的实现只是简单的重写父类的几个方法,具体实现也只是调用父类的方法,其他没有做任何额外操作。

这里就迷惑了,这么设计意图何在?只是简单封装一层,没看出有什么意义。

如果你对装饰者设计模式很熟悉,很轻松就可看出这里的设计就是装饰者设计模式的运用。如果不了解的话,那可能就和我一样困惑了。

装饰器设计模式应用场景有个很重要的特点:装饰器类会附加跟原始类相关的增强功能

BufferedInputStream就是这个装饰器类,它提供的增强功能:增加缓存功能。通过提供一块缓冲区,输入流可以先放到这个缓冲区里面,然后再输出到目的地(内存或网络)。它的好处就减少和内存的读取交互次数,毕竟频繁的读取交互是比较耗费性能的。

举个例子解释下缓冲区是如何提升性能的:

假设有一个8K大小的文件,如果仅使用InputStream来读取,每次读取1K,则需要读取8次,也就需要和文件交互8次。但如果使用缓冲流BufferedInputStream来读取,在第一次读取文件的时候,就会从文件中一次性读取8K(BufferedInputStream中默认缓冲区大小)数据到缓冲区中,虽然最后还是得会从缓冲区每次读取1K,共读取8次,但是这8次是从缓冲区读,远比直接与文件交互的性能高。另外可见的是,当文件数据越大,通过缓冲区的方式效率提升越明显。

至于为啥这里要用装饰器模式呢?下面简单探讨一下。

我们假设不用装饰器模式,功能增强就只是通过继承的方式实现,会出现什么问题呢?

比如需要文件缓存功能,我们就要加一个 BufferedFileInputStream,一个类而已完全可以接受。

如果我们还需要对功能进行其他方面的增强,比如支持按照基本数据类型(int、boolean、long 等)来读取数据(命名为DataInputStream)。这种情况下,如果我们继续按照继承的方式来实现的话,就需要再继续派生出类似像 DataFileInputStreamDataPipedInputStream 这样的类。如果我们还需要既支持缓存、还要按照基本类型读取数据的类,那就要再继续派生出 BufferedDataFileInputStreamBufferedDataPipedInputStream 等。

这才添加了两个增强功能,假设有m个增强功能,n个基础类。那通过继承就会有m*n个类。同时类继承结构会变得复杂,代码也不好扩展,不好维护。

但通过装饰者设计模式,开发者需要哪些功能自己去组合。这样就可以通过组合的方式解决这种继承爆炸的问题,只需要m+n个类。

下图就是JDK通过装饰者模式实现后的io读取字节流相关的类,可以看出来还是有相当多的类:

stream

现在明白了背后设计的核心思想,最开始的那段代码也就很轻松的理解了。

但依然感觉不是很好用,我只是想读写一个文件,这样写还是有点麻烦。有没有更简易的方式,比如对这些操作封装后的API,或者新的IO框架?

我相信我的问题肯定有很多人也有,Google一番,发现了一个IO神器,有趣的是,这个神器的名字曾在Okhttp框架源码里见过 - Okio。

IO神器Okio

官方是这么介绍Okio的:

Okio is a library that complements java.io and java.nio to make it much easier to access, store, and process your data. It started as a component of OkHttp, the capable HTTP client included in Android. It’s well-exercised and ready to solve new problems.

用Google翻译成“人话“:

Okio是对java.io和java.nio的补充,它使访问,存储和处理数据变得更加容易。它作为OkHttp(Android包含的功能强大的HTTP客户端)的组件开始的。它已被很好地锻炼,并准备解决新问题。

重点是这一句它使访问,存储和处理数据变得更加容易,既然Okio是对java.io的补充,那是否比传统IO好用呢?

看下Okio这么使用的,用它读写一个文件试试:

    // OKio写文件 
    private static void writeFileByOKio() {
        try (Sink sink = Okio.sink(new File(path));
             BufferedSink bufferedSink = Okio.buffer(sink)) {
            bufferedSink.writeUtf8("write" + "\n" + "success!");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    //OKio读文件
    private static void readFileByOKio() {
        try (Source source = Okio.source(new File(path));
             BufferedSource bufferedSource = Okio.buffer(source)) {
            for (String line; (line = bufferedSource.readUtf8Line()) != null; ) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

使用try后面跟随()括号是Java7新特性: try括号内的资源会在try语句结束后自动释放,前提是这些可关闭的资源必须实现 java.lang.AutoCloseable 接口。

从代码中可以看出,读写文件关键一步要创建出 BufferedSource BufferedSink 对象。有了这两个对象,就可以直接读写文件了。

不过也没比传统IO使用简洁到哪里去。其实是因为这个例子比较简单,前面提到传统IO使用了装饰者设计模式来可以提供增强能力。

把场景稍微变复杂点,那假设需要读取一个整数或浮点数,就需要用DataInputStream来增强,同时为了效率还需要缓存功能,就还要装饰一层BufferInputStream。类似下面这样(核心代码):

fileStream = new FileInputStream(path);
binStream = new BufferedInputStream(fileStream);
dataInputStream = new DataInputStream(binStream);
dataInputStream.readInt();

但Okio为我们提供的BufferedSinkBufferedSource就具有以上基本所有的功能,不需要再串上一系列的装饰类。类似下面这样(核心代码):

Source source = Okio.source(new File(path));
BufferedSource bufferedSource = Okio.buffer(source)) {
bufferedSource.readInt()

这里可以看出,Okio把传统IO的复杂场景使用简单化了,也确实让我们访问数据更容易了。到这里,我最开始的需求已经解决。

现在开始好奇Okio是怎么设计成这么好用的?看一下它的类设计:

okio design

在Okio读写使用中,比较关键的类有Source、Sink、BufferedSource、BufferedSink。

Source和Sink

SourceSink是接口,类似传统IO的InputStreamOutputStream,具有输入、输出流功能。

Sourece接口主要用来读取数据,而数据的来源可以是磁盘,网络,内存等

public interface Source extends Closeable {
  long read(Buffer sink, long byteCount) throws IOException;
  Timeout timeout();
  @Override void close() throws IOException;
}

Sink接口主要用来写入数据

public interface Sink extends Closeable, Flushable {
  void write(Buffer source, long byteCount) throws IOException;
  @Override void flush() throws IOException;
  Timeout timeout();
  @Override void close() throws IOException;
}

BufferedSource和BufferedSink

BufferedSourceBufferedSink是对SourceSink接口的扩展处理。Okio将常用方法封装在BufferedSource/BufferedSink接口中,把底层字节流直接加工成需要的数据类型,摒弃Java IO中各种输入流和输出流的嵌套,并提供了很多方便的api,比如readInt()readString

public interface BufferedSource extends Source, ReadableByteChannel {
  Buffer getBuffer();
  int readInt() throws IOException;
  String readString(long byteCount, Charset charset) throws IOException;
}
public interface BufferedSink extends Sink, WritableByteChannel {
  Buffer buffer();
  BufferedSink writeInt(int i) throws IOException;
  BufferedSink writeString(String string, int beginIndex, int endIndex, Charset charset)
      throws IOException;
}

RealBufferedSink和RealBufferedSource

上面的BufferedSourceBufferedSink都还是接口,它们对应的实现类就是RealBufferedSinkRealBufferedSource了。

final class RealBufferedSource implements BufferedSource {
  public final Buffer buffer = new Buffer();
  
  @Override public String readString(Charset charset) throws IOException {
    if (charset == null) throw new IllegalArgumentException("charset == null");
    buffer.writeAll(source);
    return buffer.readString(charset);
  }
  
  //...
}
final class RealBufferedSink implements BufferedSink {
  public final Buffer buffer = new Buffer();
  
  @Override public BufferedSink writeString(String string, int beginIndex, int endIndex,
      Charset charset) throws IOException {
    if (closed) throw new IllegalStateException("closed");
    buffer.writeString(string, beginIndex, endIndex, charset);
    return emitCompleteSegments();
  }
  //...
}

RealBufferedSourceRealBufferedSink内部都维护一个Buffer对象。里面的实现方法,最终实现都转到Buffer对象处理。所以这个Buffer类可以说是Okio的灵魂所在。下面会详细介绍。

Buffer

Buffer的好处是以数据块SegmentInputStream读取数据的,相比单个字节读取来说,效率提高了,是一种空间换时间的策略。

public final class Buffer implements BufferedSource, BufferedSink, Cloneable, ByteChannel {
  Segment head;
  @Override public Buffer getBuffer() {
    return this;
  }
  
  @Override public String readString(long byteCount, Charset charset) throws EOFException {
    checkOffsetAndCount(size, 0, byteCount);
    if (charset == null) throw new IllegalArgumentException("charset == null");
    if (byteCount > Integer.MAX_VALUE) {
      throw new IllegalArgumentException("byteCount > Integer.MAX_VALUE: " + byteCount);
    }
    if (byteCount == 0) return "";
    Segment s = head;
    if (s.pos + byteCount > s.limit) {
      // If the string spans multiple segments, delegate to readBytes().
      return new String(readByteArray(byteCount), charset);
    }
    String result = new String(s.data, s.pos, (int) byteCount, charset);
    s.pos += byteCount;
    size -= byteCount;
    if (s.pos == s.limit) {
      head = s.pop();
      SegmentPool.recycle(s);
    }
    return result;
  }
  //...
}

从代码中可以看出,这个Buffer是个集大成者,实现了BufferedSinkBufferedSource的接口,也就是意味着它同时具有读和写的功能。

它的内部维护了一个数据块Segment,它又是什么呢?

final class Segment {
  //大小是8kb
  static final int SIZE = 8192;
  //读取数据的起始位置
  int pos;
  //写数据的起始位置
  int limit;
  //后继
  Segment next;
  //前继
  Segment prev;
  
  //将当前的Segment对象从双向链表中移除,并返回链表中的下一个结点作为头结点
  public final @Nullable Segment pop() {
    Segment result = next != this ? next : null;
    prev.next = next;
    next.prev = prev;
    next = null;
    prev = null;
    return result;
  }
  //向链表中当前结点的后面插入一个新的Segment结点对象,并移动next指向新插入的结点
  public final Segment push(Segment segment) {
    segment.prev = this;
    segment.next = next;
    next.prev = segment;
    next = segment;
    return segment;
  }
  //单个Segment空间不足以存储写入的数据时,就会尝试拆分为两个Segment
  public final Segment split(int byteCount) {
   //...
  }
  //合并一些邻近的Segment
  public final void compact() {
     
  }
}

poppush方法可以看出Segment是一个双向链表的数据结构。一个Segment大小是8kb。正是由于Segment使IO读写操作能如此高效。

和Segment紧密相关的还有一个SegmentPoll

final class SegmentPool {
  static final long MAX_SIZE = 64 * 1024;
  static @Nullable Segment next;
  
  //当池子里面有空闲的 Segment 就直接复用,否则就创建一个新的 Segment
  static Segment take() {
    synchronized (SegmentPool.class) {
      if (next != null) {
        Segment result = next;
        next = result.next;
        result.next = null;
        byteCount -= Segment.SIZE;
        return result;
      }
    }
    return new Segment(); // Pool is empty. Don't zero-fill while holding a lock.
  }
  //回收 segment 进行复用,提高效率
  static void recycle(Segment segment) {
    if (segment.next != null || segment.prev != null) throw new IllegalArgumentException();
    if (segment.shared) return; // This segment cannot be recycled.
    synchronized (SegmentPool.class) {
      if (byteCount + Segment.SIZE > MAX_SIZE) return; // Pool is full.
      byteCount += Segment.SIZE;
      segment.next = next;
      segment.pos = segment.limit = 0;
      next = segment;
    }
  }
  
}

SegmentPool是一个缓存Segment的池,它有64kb大小也就是8Segment的长度。既然作为一个池,就和线程池的作用类似,为了复用前面被回收的Segmentrecycle() 方法的作用则是回收一个Segment对象。被回收的Segment对象将会被插入到SegmentPool中的单链表的头部,以便后面继续复用。

SegmentPool的作用防止已申请的资源被回收,增加资源的重复利用,减少GC,过于频繁的GC是会降低性能的

可以看到Okio在内存优化上下了很大的功夫,提升了资源的利用率,从而提升了性能。

另外需要注意的是,对于OKio来说,它的Buffer是个外部工具而已。什么意思呢,OKio要把数据写入到Buffer,是需要通过sourceread方法,而不是xxx.write方法:

try (Source source = Okio.source(new File(path))) {
    Buffer buffer = new Buffer();
    //把source的数据写入到buffer里面去
    source.read(buffer, 1024);
    System.out.println("okio buffer read:" + buffer.readUtf8Line());
} catch (IOException e) {
    e.printStackTrace();
}

总结

不仅如此,Okio还提供其他很有用的功能:

比如提供了一系列的方便工具

  • GZip的透明处理
  • 对数据计算md5、sha1等都提供了支持,对数据校验非常方便

再比如提供了超时机制的处理,内部的设计也很有意思,感兴趣可参考

高效易用的okio(四)

写本篇的目的很明确就是为了安利大家把Okio这个IO神器用起来,基本上IO操作它都可以比传统IO更加高效,简单的使用,不过有一个不足就是不能像传统IO那样灵活搭配自己想要的增强功能。

本文笔者自觉写的不是很好,如果大家觉得没看懂还可以参考下面的文章

该篇对源码讲解对比较细:

Okio原理解析

该篇对内容讲解的比较细,提到了Socket通信

使用Socket进行通信

其他相关零碎知识:

输入输出流的缓冲区设置多大比较合适