# IO 常见类

Java 的 I/O 大概可以分成以下几类:

  • 磁盘操作: File
  • 字节操作: InputStream 和 OutputStream
  • 字符操作: Reader 和 Writer
  • 对象操作: Serializable
  • 网络操作: Socket

FileInputStream 这样的类都是字节操作的子类,不算入常见类讲解,之后会对这些子类单独开坑。

# 文件流

# File 类

File 类可以用于表示文件和目录的信息,但是它不表示文件的内容。不能访问文件内容本身,需要通过输入输出流进行访问。访问路径可以是绝对 / 相对路径。相对路径是由系统属性 user.dir 指定,即为 Java VM 所在路径。

可以递归列出目录下所有文件

public static void listAllFiles(File dir) {
    if (dir == null || !dir.exists()) {
        return;
    }
    if (dir.isFile()) {
        System.out.println(dir.getName());
        return;
    }
    for (File file : dir.listFiles()) {
        listAllFiles(file);
    }
}

File 类中大部分方法都是判断类的方法,返回一个 boolean 值,方法体简单,此处不会过多讲解,详细全面的方法可以参考 Java8 API 文档

  • public void deleteOnExit() :在 VM 关闭的时候,删除该文件或者目录,不像 delete() 方法一调用就删除。一般用于临时文件比较合适。
  • public boolean renameTo(File dest) :重命名此 File 对象所对应的文件或目录,如果重命名成功,则返回 true;否则,返回 false。
  • public boolean setReadOnly() :设置此 File 对象为只读权限。
  • public boolean setWritable(boolean writable, boolean ownerOnly) :写权限设置, writable 如果为 true ,允许写访问权限;如果为 false ,写访问权限是不允许的。 ownerOnly 如果为 true ,则写访问权限仅适用于所有者;否则它适用于所有人。
  • public boolean setWritable(boolean writable) : 底层实现是:通过 setWritable(writable, true) 实现,默认是仅适用于文件或目录所有者。
public boolean setWritable(boolean writable) {
        return setWritable(writable, true);
}
// 同样的,还有 setReadable,setExecutable 方法
  • public static File createTempFile(String prefix, String suffix, File directory) :在指定的临时文件目录 directort 中,创建一个临时空文件。可以直接使用 File 类来调用,使用给定前缀、系统生成的随机数以及给定后缀作为文件名。 prefix 至少 3 字节长。如果 suffix 设置为 null ,则默认后缀为 .tmp
  • public String[] list() :列出 File 对象的所有子文件名和路径名,返回的是 String 数组。

# 文件过滤器

public class DemoApplication {
    public static void main(String[] args) {
        File file = new File("D:testDir");
        String[] nameArr = file.list(((dir, name) -> name.endsWith(".doc")));
        for (String name : nameArr) {
            System.out.println(name);
        }   
    }
}

这里的 list 函数接收一个 Lambda 表达式,其方法如下

public String[] list(FilenameFilter filter) {
    String names[] = list();
    if ((names == null) || (filter == null)) {
        return names;
    }
    List<String> v = new ArrayList<>();
    // 将符合条件的文件加入到返回值中
    for (int i = 0 ; i < names.length ; i++) {
        if (filter.accept(this, names[i])) {
            v.add(names[i]);
        }
    }
    return v.toArray(new String[v.size()]);
}
@FunctionalInterface
public interface FilenameFilter {
    boolean accept(File dir, String name);
}

# 字节流相关

这里介绍关于 InputStreamOutputStream 最简单的使用

public void copyFile(File in,File out) {
    InputStream input = new FileInputStream(in);
    OutputStream output = new FileOutputStream(out);
    byte[] buffer = new Byte[20*1024];
    
    while((int r = input.read(buffer)) != -1) {
        output.write(buffer,0,r);
    }
}

# 字符流相关

最简单的使用,逐行输出文本

public void copyFile(File in) {
    FileReader filReader = new FileReader(file);
    BufferReader bufferReader = new ReaderBuffer(fileReader);
    String line;
    
    while((line = bufferReader.readLine() != null) {
        System.out.println(line);
    }
    
    // 装饰者模式使得 BufferedReader 组合了一个 Reader 对象
    // 在调用 BufferedReader 的 close () 方法时会去调用 Reader 的 close () 方法
    // 因此只要一个 close () 调用即可
    bufferedReader.close();
}

# 序列化

一般为了将一个对象存储到文件中,我们可以使用 Json ,也可以使用序列化,序列化就是将一个对象转换成字节序列,方便存储和传输。你可以将需要存储的对象(多个)放在一个 list 或者 map 集合中,然后再通过序列化将集合放入文件中(但是你打开文件里面是乱码,不像 Json 可以清晰的看到保存的实际内容)。

相关方法:

  • 序列化: ObjectOutputStream.writeObject ()
  • 反序列化: ObjectInputStream.readObject ()

看看最基本的使用

public static void main(String[] args) throws IOException, ClassNotFoundException {
    A a1 = new A(123, "abc");
    String objectFile = "file/a1";
    ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile));
    objectOutputStream.writeObject(a1);
    objectOutputStream.close();
    ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile));
    A a2 = (A) objectInputStream.readObject();
    objectInputStream.close();
    System.out.println(a2);
}
private static class A implements Serializable {
    private int x;
    private String y;
    A(int x, String y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public String toString() {
        return "x = " + x + "  " + "y = " + y;
    }
}

不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态。对于非静态属性,也可以使用 transient 关键字,该关键字可以避免被修饰属性序列化。

例如 ArrayList 中存储数据的数组 elementData 是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。

private transient Object[] elementData;

ArrayList 中实现了 writeObjectreadObject

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
        // 防止序列化期间有修改
        int expectedModCount = modCount;
    
        // 写出非 transient 非 static 属性(会写出 size 属性)
    	// ArrayList 不可能只有一个数组需要序列化吧
        s.defaultWriteObject();
        // 写出大小作为与 clone()行为兼容的容量
        s.writeInt(size);
        // 依次写出元素
        for (int i=0; i<size; i++) {
            s.writeObject(elementData[i]);
        }
        // 如果有修改,抛出异常
        if (modCount != expectedModCount) {
            throw new ConcurrentModificationException();
        }
}

一般地,只要实现了 Serializable 接口即可自动序列化, writeObject()readObject() 是为了自己控制序列化的方式,这两个方法必须声明为 private ,在 java.io.ObjectStreamClass#getPrivateMethod() 方法中通过反射获取到 writeObject() 这个方法。

readObject 同理

private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    // 声明为空数组
    elementData = EMPTY_ELEMENTDATA;
    // 读入非 transient 非 static 属性(会读取 size 属性)
    s.defaultReadObject();
    // 读入元素个数,没什么用,只是因为写出的时候写了 size 属性,读的时候也要按顺序来读
    s.readInt(); // ignored
    if (size > 0) {
        // 计算容量
        int capacity = calculateCapacity(elementData, size);
        SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, capacity);
        // 检查是否需要扩容
        ensureCapacityInternal(size);
        Object[] a = elementData;
        // 依次读取元素到数组中
        for (int i=0; i<size; i++) {
            a[i] = s.readObject();
        }
    }
}

# 缓冲流

缓冲流也分输入流和输出流,字节流和字符流。使用方法大同小异,这里以缓冲流的字节输入流 BufferedInputStream 为例讲解。

构造方法:

public BufferedInputStream(InputStream in, int size) {
    super(in);
    if (size <= 0) {
        throw new IllegalArgumentException("Buffer size <= 0");
    }
    buf = new byte[size];
}
public BufferedInputStream(InputStream in) {
    this(in, DEFAULT_BUFFER_SIZE);
}

一般接收的参数可以是 FileInputStream ,如果你了解装饰者模式,就会对这种设计非常熟悉。

缓冲流的出现主要是 FileInputStream 这样的文件类都是直接和硬盘进行交互,但是磁盘的随机读取是十分消耗时间的。我们可以假设这么一种情况:一般我们使用 FileInputstream 时,都会在外界定义一个数组,然后循环调用 read 函数将磁盘中的数据读到数组中。但是这有一个问题,我们假设定义的数组很小,那么要将磁盘中的数据全部都出来就需要调用多次 read 方法。假设循环第一次调用 read 方法,磁盘将磁头定位到相应位置(这个过程对于 CPU 来说非常慢),然后转动磁盘进行顺序读取。此时电脑中其他程序也需要访问磁盘中的数据,那么磁盘又需要重新转动磁头定位到磁盘中相应的位置进行数据读取。到了后面的循环调用 read ,由于磁头并不在第一次 read 读取到的磁盘区域,所以磁头又要重新定位......

所以缓冲流就是将我们定义的那个数组放到类里面进行封装,同时提供一些其他便于使用的方法。

//read 方法
public synchronized int read(byte b[], int off, int len)
public synchronized int read()

read 对外提供的方法没有变化,只是说返回的结果不一定是从硬盘中直接读取的,也可能是从缓冲数组返回的。

这里着重讲一下 mark 方法和 reset 方法

//mark 方法能够在当前位置打一个标记,之后再次调用 reset 方法,"指针就会回到标记处"
public synchronized void mark(int readlimit) {
    marklimit = readlimit;
    markpos = pos;
}
public synchronized void reset() throws IOException {
    getBufIfOpen(); // Cause exception if closed
    if (markpos < 0)
        throw new IOException("Resetting to invalid mark");
    pos = markpos;
}

方法的实现很简单,只是设置了几个变量的值,所以我们需要看一下 BufferedInputStream 相关的变量设计

// 缓冲数组
protected volatile byte buf[];
//mark 方法最多保存的字节
protected int marklimit;
// 标记位置
protected int markpos = -1;
// 数组中存储的数据个数
// 源码注释为缓冲数组中最后一个有效的元素的下标 + 1
protected int count;
// 当前读取到数组中的位置
protected int pos;

我们可以思考一下,如果没有 markreset 方法(以及相关的属性都没有),那么关于缓冲的设计,无非就是当 (pos==buff.length) 时将 pos 设为 0,然后重新读一组数组覆盖 buff 数组。

再复杂一点就是,有次读取也许并不会将数组填满(这很正常,比如磁盘中数组不足以填满缓冲数组),假设长度为 10,有可能读了 9 个数据进去,此时 count=9 。那么我们在重新覆盖数组前,应该先判断一下 pos>=count 这个条件。

所以我们来看一下 read 源码

public synchronized int read() throws IOException {
    if (pos >= count) {// 数组中所有数据都被读取了
        fill();// 重新填入数据
        if (pos >= count) // 如果重新填入数据都 pos 仍然大于 count,说明没有数据了
            return -1;
    }
    // 反正就是返回读到的元素
    return getBufIfOpen()[pos++] & 0xff;
}

如果我们不考虑 mark 方法的实现,那么 fill() 应该这么实现

private void fill() throws IOException {
     byte[] buffer = getBufIfOpen();// 获取缓冲数组
 	pos = 0;
    count = pos;
    
    // 读取数据到缓冲数组中
    int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
    if(n > 0) {// 读取到数据
        count += n;
    } 
}
// 如果没有数据了,fill 结束时 pos 和 count 都是 0,在 read 中就返回 - 1

但是如果要考虑 mark 标记,我们就不能随意覆盖标记,我们先举一种极端的情况

public class Main {
    public static void main(String[] args) throws IOException {
        FileInputStream in = new FileInputStream("1.txt");
        BufferedInputStream bI = new BufferedInputStream(in,4);
        bI.mark(40);// 我们允许打上标记后,如果进行了 > 40 次 read,reset 可能会失效
        for(int i = 0;i < 20;i++) {
            bI.read();
        }
        bI.reset();
    }
}

上面的代码,我们只设置了缓冲区数组大小为 4,但是 mark 的参数是 40,我们在 reset 处打断点查看一下变量

mark参数

显然 mark 参数会影响数组大小(其实是赋值给 marklimit ,然后在 fill 中扩容的),但是代码注释中我也写了是可能会失效, BufferedInputStream 你可以理解为是尽力在维护保存这个标记,如果 mark 的参数 k 很小,也就是使用者能够容忍在额外调用了 kreadreset 会失效的,但是如果缓冲数组能够在读取了 k 次后还能保存标记位,它是会去保存的。所以我们就需要看一下 fill 原本完整的代码

private void fill() throws IOException {
    byte[] buffer = getBufIfOpen();
    //markpos < 0 说明没有调用过 mark,也就是不考虑 mark 的情况
    if (markpos < 0)
        pos = 0;           
    else if (pos >= buffer.length)  //no room left in buffer -- 需要扩容
        if (markpos > 0) {  
            // 将 markpos 到末尾的数据都重新复制到数组前面的位置
            // 此时不用管 marklimit, 因为现在是调用 reset 一定能回到标志处
            // 这就是上面所说的尽量保存标记位
            // 你可以想想,如果 marklimit 很小,但是数组仍会保存这个标记位
            int sz = pos - markpos;
            System.arraycopy(buffer, markpos, buffer, 0, sz);
            pos = sz;
            markpos = 0;
        } else if (buffer.length >= marklimit) {// 此时 markpos 一定为 0
            // 哪怕尽量保存,当 markpos 不断前移,直到为 0 时,就不能再保存了
            // 所以此时就会失效
            // 所以你会看到,当 marklimit 很小时,会出现两种情况
            // 1. 如果数组很大很大,失效就会出现得更晚(其实次数就是 length)
            // 2. 如果数组很小(但是依然 >=marklimit), 失效也会很早(次数其实也是 length)
            markpos = -1;   
            pos = 0;       
        } else if (buffer.length >= MAX_BUFFER_SIZE) {
            throw new OutOfMemoryError("Required array size too large");
        } else {
            // 如果我们设置的 marklimit 很大很大,超过了 length
            // 那么数组就应该迎合使用者,哪怕对数组扩容也要在 marklimit 失效前保存标记位
            // 这里采用的是二倍扩容
            int nsz = (pos <= MAX_BUFFER_SIZE - pos) ?
                pos * 2 : MAX_BUFFER_SIZE;
            if (nsz > marklimit)
                nsz = marklimit;
            byte nbuf[] = new byte[nsz];
            System.arraycopy(buffer, 0, nbuf, 0, pos);
            if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
                throw new IOException("Stream closed");
            }
            buffer = nbuf;
        }
    count = pos;
    int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
    if (n > 0)
        count = n + pos;
}

差不多缓冲流就是这样了,在装饰者模式下,实现简单易懂。

# 转换流

假设我们只拿到了 FileInputStream ,却希望使用字符流,这里还是需要借助装饰者模式

public static void main(String[] args) {
    try(InputStreamReader reader = new InputStreamReader(new FileInputStream("test.txt"))) {
        System.out.println((char) reader.reader());
    }catch (IOException e) {
        e.printStackTrace
    }
}
// 普通的字符流是:Reader reader = new FileReader ();

writer 是差不多的,不再赘述。

# 打印流

平时使用的 System.out 就是打印流 PrnitStream ,该类也继承了 FilterOutputStream 类。存在自动帅那些机制,当向打印流中写入一个字节数组后自动调用 flush 方法。内部不会抛出异常,而是使用 checkError() 方法进行错误检查。他能格式化任意类型并以字符串的形式写入到输出流中。

public static void main(Strin[] args) {
    try(PrintStream stream = new PrintStream(new FileOutputStream("test.txt"))) {
        stream.println("Hello World");
    }catch (IOException e) {
        e.printStackTrace();
    }
}

# 参考

掘金:https://juejin.cn/post/6844904126858428424#heading-6

ArrayList 是如何实现序列化的:https://blog.csdn.net/qq_43561507/article/details/109439693

Java 全栈知识体系:https://pdai.tech/md/java/io/java-io-basic-usage.html