编程技术是改变世界的力量。
本站
当前位置:网站首页 > 后端语言 > 正文

Java面试必知必会 —— 全面解读 Java IO(基础篇)

gowuye 2024-04-04 11:53 9 浏览 0 评论

Java IO 一直以来是大厂面试题中的高频考点,本文将从 Java IO 基础使用说起,以案例 + 源码的方式讲解文件、字节流、字符流、缓冲流、打印流、随机访问流等基础内容,再深入到 Java IO 模型与设计模式,从而构建出对 Java IO 的全面认知。

文章不仅适合完全不了解 Java IO 的新同学,也适合具备一定知识储备的老同学。文中的所有案例代码强烈推荐手写复现一遍,以更好地掌握 Java IO 编程基础。

文章的结尾处给出了更新日志,每次新更新的内容都会写明,便于同学们快速了解更新的内容是否是自己所需要的知识点。

我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。

文件

文件在程序中是以流的形式来操作的。类关系如下:

创建文件

常用构造方法

常用构造方法

描述

File(String pathname)

根据路径名构建

File(File parent, String child)

根据父目录文件 + 子路径构建

File(String parent, String child)

根据父目录路径 + 子路径构建

要想真正地在磁盘中创建文件,需要执行 createNewFile() 方法。

Tips

所有 java.io 中的类的相对路径默认都是从用户工作目录开始的,使用 System.getProperty("user.dir") 可以获取你的用户工作目录。 在 Windows 系统中的分隔符为 "\\",在 Linux 系统中分隔符为 "/",为了保证系统的可移植性,可以通过常量字符串 java.io.File.separator 获取(参见案例中的使用)。

使用案例

  1. 使用 File(String pathname) 创建文件
@Test
public void createFile() {
    // 更换成你想要存放的文件路径,默认情况为用户工作目录,可以通过 System.getProperty("user.dir") 显示获取
    String userDir = System.getProperty("user.dir");
    System.out.println("用户工作目录:" + userDir);
    System.out.println("当前操作系统的文件分隔符为:" + File.separator);
    String fileName = "createFile.txt";
    String path = userDir + File.separator + fileName; // 组装路径
    File file = new File(path); // 此时只是程序中的一个对象
    // File file = new File(fileName); // 默认会创建到用户工作目录中,和上一面的语句创建的文件路径一致。
    try {
        file.createNewFile(); // 执行该方法才会真正地在磁盘中创建文件
        System.out.println("文件创建成功");
    } catch (IOException e) {
        e.printStackTrace();
    }
}
  1. 使用 File(File parent, String child) 创建文件
@Test
public void createFile2() {
    // 更换成你想要存放的文件路径
    File parentFile = new File("/Users/sunnywinter/projects/interviewcode/");
    String fileName = "testFile2.txt";
    File file = new File(parentFile, fileName);
    try {
        file.createNewFile();
        System.out.println("文件创建成功");
    } catch (IOException e) {
        e.printStackTrace();
    }
}
  1. 使用 File(String parent, String child) 创建文件
@Test
public void createFile3() {
    // 更换成你想要存放的文件路径
    String parentFile = System.getProperty("user.dir");
    String fileName = "createFile3.txt";
    File file = new File(parentFile, fileName);
    try {
        file.createNewFile();
        System.out.println("文件创建成功");
    } catch (IOException e) {
        e.printStackTrace();
    }
}

获取文件信息

常用方法

返回值

方法名

描述

String

getName()

获取文件名

String

getAbsolutePath()

获取文件绝对路径

String

getParent()

获取文件父级目录

long

length()

返回文件大小(字节)

boolean

exists()

判断文件是否存在

boolean

isFile()

判断是否是一个文件

boolean

isDirectory()

判断是否是一个目录

使用案例

@Test
public void getFileInfo() {
    File file = new File("/Users/sunnywinter/projects/interviewcode/testFile.txt");
    System.out.println("文件名:" + file.getName());
    System.out.println("文件绝对路径:" + file.getAbsolutePath());
    System.out.println("文件父级目录:" + file.getParent());
    System.out.println("文件大小(字节):" + file.length());
    System.out.println("文件是否存在:" + file.exists());
    System.out.println("是否是一个文件:" + file.isFile());
    System.out.println("是否是一个目录:" + file.isDirectory());
}

目录操作与文件删除

使用方法

返回值

方法名

描述

boolean

mkdir()

创建一级目录

boolean

mkdirs()

创建多级目录

boolean

delete()

删除文件或目录

使用案例

@Test
public void test() {
    String parentPath = "/Users/sunnywinter/projects/interviewcode/";
    String fileName = "testFile.txt";
    String directoryName = "a";
    String mulDirectoryName = "b/c/d";
    // 删除文件
    File file = new File(parentPath, fileName);
    file.delete();
    // 创建一级目录
    File directory = new File(parentPath, directoryName);
    directory.mkdir();
    // 创建多级目录
    File mulDirectory = new File(parentPath, mulDirectoryName);
    mulDirectory.mkdirs();
    // 删除目录
    directory.delete();
}

IO 流概述

IO,Input/Output,即输入/输出。判断输入输出以计算机内存为中心,如果从内存到外部存储就是输出,从外部存储到内存就是输入。数据传输过程类似于水流,因此称为 IO 流。

在 Java 中,根据操作数据单位的不同,IO 流分为字节流和字符流;根据数据流的流向不同,分为输入流和输出流;根据流的角色不同,分为节点流和处理流。

Java IO 流共涉及 40 多个类,但都是从表中的 4 个抽象基类派生而来,派生的子类名称都是以其父类名作为子类名的后缀。

(抽象基类)

字节流

字符流

输入流

InputStream

Reader

输出流

OutputStream

Writer

列举一些常用的类。

字节流

首先,我们先学习如何将数据写入到文件中。

OutputStream(字节输出流)

OutputStream 用于将内存数据(字节信息)写入到文件中,java.io.OutputStream抽象类是所有字节输出流的父类。

常用方法

返回值

方法名

描述

void

write(int b)

将特定字节写入输出流。

void

write(byte b[])

将数组 b 写入到输出流,等价于 write(b, 0, b.length) 。

void

write(byte[] b, int off, int len)

在 write(byte b[]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。

void

flush()

刷新此输出流并强制写出所有缓冲的输出字节。

void

close()

关闭输出流释放相关的系统资源。

FileOutputStream

FileOutputStream 是最常用的字节输出流子类,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。

类图关系如下:

Tips

java.io.Closeable 接口扩展了 java.lang.AutoCloseable 接口。因此,对任何 Closeable 进行操作时,都可以使用 try-with-resource 语句(声明了一个或多个资源的 try 语句,可以自动关闭流,具体使用方法参见使用案例)。 为什么要有两个接口呢?因为 Closeable 接口的 close 方法只抛出了 IOException,而 AutoCloseable.close 方法可以抛出任何异常。

常用构造函数

append 为 true 时,表明追加写入。

使用案例

需求 1:向 mrpersimmon.txt 文件中写入 Hi,Mrpersimmon!

代码:

@Test
public void testFileOutputStream() {
    // FileOutputStream(String name, boolean append) 追加写入
    // FileOutputStream(String name) 覆盖写入
    try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("mrpersimmon.txt"))) {
        String str = "Hi,Mrpersimmon!";
        // write(byte b[]) : 将字节数组 b 写入到输出流,等价于 write(b, 0, b.length)
        bos.write(str.getBytes("UTF-8")); // str.getBytes() 字符串 -> 字节数组
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Tips

FileOutputStream 在使用中要和 BufferedOutputStream 一起使用,性能更好。 try(...OutputStream) 可以自动关闭输出流,无需 try-finally 手动关闭。

运行结果:

DataOutputStream

DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合 FileOutputStream,构造函数源码如下:

类图关系如下:

使用案例

需求:向 mrpersimmon2.txt 写入 Hi,Mrpersimmon!

代码:

@Test
public void testDataOutputStream() {
    try (DataOutputStream dos = new DataOutputStream(new FileOutputStream("mrpersimmon2.txt"))) {
        // 输出任意输入类型
        dos.writeUTF("Hi,Mrpersimmon!");
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果:


ObjectOutputStream

ObjectOutputStream 用于将对象写入到输出流(序列化)。与之相对反地,ObjectInputStream 用于从输入流中读取 Java 对象(反序列化)。

类图关系如下:

序列化与反序列化

什么是序列化和反序列化? 序列化就是在保存数据时,保存数据的值和数据类型;反序列化就是在恢复数据时,恢复数据的值和数据类型。

如何让类支持序列化机制呢? 必须让类实现 Serializable 接口(一个标记接口,没有方法)或者 Externalizable 接口(有方法需要实现)。如果类中有属性不想被序列化,需要使用 transient 修饰。

注意事项

  1. 读写顺序要一致;
  2. 要求序列化和反序列化的对象,需要实现 Serializable 接口
  3. 序列化的类中建议添加 serialVersionUID 以太高版本的兼容性
  4. 序列化对象时,默认将所有属性进行了序列化,但除了 static 或 transient 修饰的成员
  5. 序列化对象时,要求里面属性的类型也需要实现序列化接口
  6. 序列化具备可继承性,即某个类实现了序列化,那么它的所有子类也默认实现了序列化。
  7. 基本类型对应的包装类都实现了序列化。

使用案例

需求:创建一个支持序列化的 Blog 类,向 mrpersimmon3.txt 写入一个 Blog 对象。

代码:

Blog 类

public class Blog implements Serializable {
    private static final long serialVersionUID = -4970674810941727545L;
    String name;
    String url;
    public Blog(String name, String url) {
        this.name = name;
        this.url = url;
    }

    @Override
    public String toString() {
        return "Blog{" +
                "name='" + name + '\'' +
                ", url='" + url + '\'' +
                '}';
    }
}

功能代码

@Test
public void testObjectOutputStream() {
    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("mrpersimmon3.txt"))) {
        Blog blog = new Blog("mrpersimmon", "https://www.mrpersimmon.top");
        oos.writeObject(blog);
        System.out.println("数据写入完成(序列化)");
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果

下面我们来学习如何从文件中读取数据信息。


InputStream(字节输入流)

InputStream 用于从文件读取数据(字节信息)到内存中,java.io.InputStream 抽象类是所有字节输入流的父类。

常用方法

返回值

方法名

描述

JDK 8 ↓

JDK 8 ↓

JDK 8 ↓

int

read()

返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。

int

read(byte b[ ])

从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length ,返回读取的字节数。这个方法等价于 read(b, 0, b.length)。

int

read(byte b[], int off, int len)

在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。

long

skip(long n)

忽略输入流中的 n 个字节 ,返回实际忽略的字节数。

int

available()

返回输入流中可以读取的字节数。

void

close()

关闭输入流释放相关的系统资源。




JDK 9 ↓

JDK 9 ↓

JDK 9 ↓

byte[]

readAllBytes()

读取输入流中的所有字节,返回字节数组。

byte[]

readNBytes(byte[] b, int off, int len)

阻塞直到读取 len 个字节。

long

transferTo(OutputStream out)

将所有字节从一个输入流传递到一个输出流。

FileInputStream

FileInputStream 是一个比较常用的字节输入流子类,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中

类图关系如下:

使用案例

需求:读取 mrpersimmon.txt 文件,并将文件内容显示到控制台中。

方法 1:使用 read() 单个字节读取,效率较低。

代码:

@Test
public void testFileInputStream() {
    // try() 会自动关闭输入流,FileInputStream 与 BufferedInputStream 配合使用
    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("mrpersimmon.txt"))) {
        // int available() 返回输入流中可以读取的字节数。
        System.out.println("文件中可读取的字节数量:" + bufferedInputStream.available());

        // long skip(long n) 忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
        long skipCounts = bufferedInputStream.skip(3); // 忽略 3 个字节
        System.out.println("忽略的字节数量:" + skipCounts);

        // read() 返回输入流中下一个字节的数据。
        System.out.print("从文件中读取的字节内容:");
        int content;
        // 返回值为 -1 时,表示读取完毕
        while ((content = bufferedInputStream.read()) != -1) {
            System.out.print((char) content); // 将读出的 int 类型数据强转成 char 类型
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Tips

FileInputStream 在使用中要和 BufferedInputStream 一起使用,性能更好。 try(...InputStream) 可以自动关闭输入流,无需 try-finally 手动关闭。

运行结果:

复制代码文件中可读取的字节数量:15
忽略的字节数量:3
从文件中读取的字节内容:Mrpersimmon!

方法 2:使用 read(byte[] b) 读取文件,提高效率。

代码:

@Test
public void testFileInputStream2() {
    // try() 会自动关闭输入流,FileInputStream 与 BufferedInputStream 配合使用
    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("mrpersimmon.txt"))) {
        // int available() 返回输入流中可以读取的字节数。
        int bufSize = bufferedInputStream.available();
        System.out.println("文件中可读取的字节数量:" + bufSize);
        byte[] buf = new byte[8]; // 一次读取 8 字节

        // long skip(long n) 忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
        long skipCounts = bufferedInputStream.skip(3); // 忽略 3 个字节
        System.out.println("忽略的字节数量:" + skipCounts);

        // read(byte b[]) 从输入流中读取一些字节存储到数组 b 中。
        // 如果数组 b 的长度为零,则不读取。
        // 如果没有可用字节读取,返回 -1。
        // 如果有可用字节读取,则最多读取的字节数最多等于 b.length,返回读取的字节数。
        // 这个方法等价于 read(b, 0, b.length)。
        System.out.print("从文件中读取的字节内容:");
        int readLen;
        // 返回值为 -1 时,表示读取完毕
        while ((readLen = bufferedInputStream.read(buf)) != -1) {
            System.out.print(new String(buf, 0, readLen)); // 将字符数组 buf 转换成字符串
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果:

复制代码文件中可读取的字节数量:15
忽略的字节数量:3
从文件中读取的字节内容:Mrpersimmon!

DataInputStream

DataInputStream 用于读取指定类型数据,不能单独使用,必须结合 InputStream 的一个实现类使用,构造函数源码如下:

类图关系如下:

使用案例

需求:读取 mrpersimmon2.txt 文件,并将文件内容显示到控制台中。

代码

@Test
public void testDataInputStream() throws IOException {
    // 必须将一个 InputStream 的实现类作为构造参数才能使用
    try(DataInputStream dis = new DataInputStream(new FileInputStream("mrpersimmon2.txt"))) {
        // 可以读取任意具体的类型数据
        System.out.println(dis.readUTF()); // 读取已使用 modified UTF-8 格式编码的字符串。
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果

Hi,Mrpersimmon!

ObjectInputStream

ObjectInputStream 用于从输入流中读取 Java 对象(反序列化)。

类图关系如下:

使用案例

需求:读取 mrpersimmon3.txt 中的 Blog 对象。

代码

@Test
public void testObjectInputStream() {
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("mrpersimmon3.txt"))) {
        System.out.println(ois.readObject());
        System.out.println("数据读取完毕(反序列化完成)");
    } catch (IOException e) {
        e.printStackTrace();
    } catch (ClassNotFoundException e) {
        throw new RuntimeException(e);
    }
}

运行结果

Blog{name='mrpersimmon', url='https://www.mrpersimmon.top'}
数据读取完毕(反序列化完成)

综合案例

需求

完成图片的拷贝。

代码

@Test
public void testCopyPic() {
    String srcPicPath = "data.png";
    String destPicPath = "data2.png";
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcPicPath));
         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destPicPath))) {
        byte[] buf = new byte[1024];
        int readLen = 0;
        while ((readLen = bis.read(buf)) != -1) {
            // bis 输入流从源图片文件读取数据后,写入到 bos 输出流的目标文件地址
            bos.write(buf, 0, readLen);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

字符流

字符流与字节流的对比

为什么 I/O 流操作要分为字节流操作和字符流操作呢?

  1. 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。因此,字节流是必要的。而字符流是由 Java 虚拟机将字节转换得到的,相比较于字节流更加耗时。
  2. 字节流在不知道编码类型的情况下很容易出现乱码问题,因此我们需要字符流来读取文本文件。

何时使用字节流,何时使用字符流?

如果是音频文件、图片等媒体文件使用用字节流会有更好的性能优势;

如果涉及到字符的话(如,文本文件等)使用字符流比较好。

常用字符编码所占字节数?

字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。

utf8,英文占 1 字节,中文占 3 字节;

unicode:任何字符都占 2 个字节;

gbk:英文占 1 字节,中文占 2 字节。

Writer(字符输出流)

Writer用于将内存数据(字符信息)写入到文件,java.io.Writer抽象类是所有字符输出流的父类。

常用方法

返回值

方法名

描述

void

write(int c)

写入单个字符。

void

write(char[] cbuf)

写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)。

void

write(char[] cbuf, int off, int len)

在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

void

write(String str)

写入字符串,等价于 write(str, 0, str.length()) 。

void

write(String str, int off, int len)

在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

Writer

append(CharSequence csq)

将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。

Writer

append(char c)

将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象.

void

flush()

刷新此输出流并强制写出所有缓冲的输出字符。

void

close()

关闭输出流释放相关的系统资源。

FileWriter

OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。

类图如下:

使用案例

代码

@Test
public void testFileWriter() {
    String filePath = "mrpersimmon-1.txt";
    try (BufferedWriter bw = new BufferedWriter(new FileWriter(filePath))) {
        bw.write("Hi,Mrpersimmon!");
        bw.write("\n"); // 添加换行符
        bw.write("欢迎你来到柿子博客".toCharArray(), 0, 3); // toCharArray 可以将字符串转换成字符数组
        bw.write("\n");
        bw.write("欢迎你来到柿子博客", 3, 6);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Tips

FileWriter 要和 BufferedWriter 一起使用,性能更好; 一定要关闭输出流或者 flush ,否则无法写入到文件中。

运行结果

Reader(字符输入流)

Reader用于从文件读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。

常用方法

返回值

方法名

描述

int

read()

从输入流读取一个字符。

int

read(char[] cbuf)

从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length) 。

int

read(char[] cbuf, int off, int len)

在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

long

skip(long n)

忽略输入流中的 n 个字符,返回实际忽略的字符数。

void

close()

关闭输入流并释放相关的系统资源。

FileReader

InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件。

类图关系如下:

使用案例

需求:读取 mrpersimmon-1.txt 中的信息

代码

@Test
public void testFileReader1() {
    try (BufferedReader br = new BufferedReader(new FileReader("mrpersimmon-1.txt"))) {
        char[] cbuf = new char[8];
        int readLen = 0;
        while ((readLen = br.read(cbuf)) != -1) {
            System.out.print(new String(cbuf, 0, readLen));
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果

Hi,Mrpersimmon!
欢迎你
来到柿子博客

字节/字符缓冲流

IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节/字符,从而避免频繁的 IO 操作,提高流的传输效率。

字节缓冲流这里采用了装饰器模式来增强 InputStream 和OutputStream子类对象的功能。字符缓冲流同理。

常见的使用方式已在上面的使用案例中给出,使用方式如下:

BufferedReader br = new BufferedReader(new FileReader("mrpersimmon-1.txt"))

字节流和字节缓冲流性能对比

字节流和字节缓冲流的性能差别主要体现在调用 write(int b) 和 read() 这两种一次只写入/读取一个节点的方式时。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。

测试对比 1(单字节处理)

分别使用字节流和字节缓冲流的方式复制一个 207 MB 的 PDF 文件,查看耗时对比。

代码

1. 使用字节流复制 PDF 文件

@Test
public void copyFileByStream() {
    System.out.println("使用字节流复制 PDF 文件测试开始");
    long startTime = System.currentTimeMillis(); // 记录开始时间
    String srcPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf";
    String destPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes-stream.pdf";
    try (FileInputStream fis = new FileInputStream(srcPath);
         FileOutputStream fos = new FileOutputStream(destPath)) {
        int content = 0;
        while ((content = fis.read()) != -1) {
            fos.write(content);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    long endTime = System.currentTimeMillis(); // 记录结束时间
    System.out.println("使用字节流复制 PDF 文件总耗时 " + (endTime - startTime) + " 毫秒");
}

2. 使用缓冲字节流复制 PDF 文件

@Test
public void copyFileByBufferStream() {
    System.out.println("使用缓冲字节流复制 PDF 文件测试开始");
    long startTime = System.currentTimeMillis(); // 记录开始时间
    // 文件大小 207 MB
    String srcPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf";
    String destPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes-buffer-stream.pdf";
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcPath));
         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destPath))) {
        int content = 0;
        while ((content = bis.read()) != -1) {
            bos.write(content);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    long endTime = System.currentTimeMillis(); // 记录结束时间
    System.out.println("使用缓冲字节流复制 PDF 文件总耗时 " + (endTime - startTime) + " 毫秒");
}

结果对比

使用字节流复制 PDF 文件总耗时 1052141 毫秒

使用缓冲字节流复制 PDF 文件总耗时 6521 毫秒

可以看到,两者耗时差别绝大,相较于字节流,使用缓冲字节流节省约 161 倍的耗时。

测试对比 2(字节数组处理)

分别使用字节流+字节数组、字节缓冲流+字节数组的方式复制一个 207 MB 的 PDF 文件,查看耗时对比。

代码

1. 使用字节流 + 字节数组复制 PDF 文件

@Test
public void copyFileByByteArrStream() {
    System.out.println("使用字节流+字节数组复制 PDF 文件测试开始");
    long startTime = System.currentTimeMillis(); // 记录开始时间
    // 文件大小 207 MB
    String srcPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf";
    String destPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes-arr-stream.pdf";
    try (FileInputStream fis = new FileInputStream(srcPath);
         FileOutputStream fos = new FileOutputStream(destPath)) {
        int readLen = 0;
        byte[] b = new byte[8 * 1024];
        while ((readLen = fis.read(b)) != -1) {
            fos.write(b, 0, readLen);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    long endTime = System.currentTimeMillis(); // 记录结束时间
    System.out.println("使用字节流 + 字节数组复制 PDF 文件总耗时 " + (endTime - startTime) + " 毫秒");
}

2. 使用缓冲字节流 + 字节数组复制 PDF 文件

@Test
public void copyFileByByteArrBufferStream() {
    System.out.println("使用缓冲字节流+字节数组复制 PDF 文件测试开始");
    long startTime = System.currentTimeMillis(); // 记录开始时间
    // 文件大小 207 MB
    String srcPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes.pdf";
    String destPath = "/Users/sunnywinter/Downloads/深入剖析Kubernetes-arr-buf-stream.pdf";
    try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(srcPath));
         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(destPath))) {
        int readLen = 0;
        byte[] b = new byte[8 * 1024];
        while ((readLen = bis.read(b)) != -1) {
            bos.write(b, 0, readLen);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    long endTime = System.currentTimeMillis(); // 记录结束时间
    System.out.println("使用缓冲字节流 + 字节数组复制 PDF 文件总耗时" + (endTime - startTime) + "毫秒");
}

结果对比

使用字节流 + 字节数组复制 PDF 文件总耗时 478 毫秒

使用缓冲字节流 + 字节数组复制 PDF 文件总耗时 391 毫秒

可以看到,两者差距不是特别大,但是缓冲字节流仍具有优势

结论

在日常使用时,应当使用缓冲流,以获取更好的性能优势。

字符缓冲流也是同理,限于篇幅,不再提供测试案例,感兴趣的同学可以自行测试。

源码分析

BufferedInputStream

BufferedInputStream 内部维护了一个缓冲区,这个缓冲区是一个字节数组。下面是源码中的一部分内容:

public
class BufferedInputStream extends FilterInputStream {
    // 缓冲区默认大小
	private static int DEFAULT_BUFFER_SIZE = 8192;
    // 内部缓冲区字节数组
    protected volatile byte buf[];
    // 构造函数,使用默认的缓冲区大小
    public BufferedInputStream(InputStream in) {
        this(in, DEFAULT_BUFFER_SIZE);
    }
    // 构造函数,使用自定义的缓冲区大小
    public BufferedInputStream(InputStream in, int size) {
        super(in);
        if (size <= 0) {
            throw new IllegalArgumentException("Buffer size <= 0");
        }
        buf = new byte[size];
    }
}

BufferedOutputStream

下面是源码中的一部分内容:

public
class BufferedOutputStream extends FilterOutputStream {
    // 内部缓冲区字节数组
    protected byte buf[];
    // 构造函数,默认缓冲区大小为 8192 
    public BufferedOutputStream(OutputStream out) {
        this(out, 8192);
    }
    // 构造函数,使用自定义的缓冲区大小
    public BufferedOutputStream(OutputStream out, int size) {
        super(out);
        if (size <= 0) {
            throw new IllegalArgumentException("Buffer size <= 0");
        }
        buf = new byte[size];
    }
}

BufferedReader

和 BufferedInputStream 一样,在内部维护了一个缓冲区,不同的是,这里是字符缓冲区。下面是源码中的一部分内容:

public class BufferedReader extends Reader {
    // 内部缓冲区字符数组
	private char cb[];
    // 默认缓冲区大小
    private static int defaultCharBufferSize = 8192;
    // 构造函数,使用自定义的缓冲区大小
    public BufferedReader(Reader in, int sz) {
        super(in);
        if (sz <= 0)
            throw new IllegalArgumentException("Buffer size <= 0");
        this.in = in;
        cb = new char[sz];
        nextChar = nChars = 0;
    }
    // 构造函数,使用默认缓冲区大小
    public BufferedReader(Reader in) {
        this(in, defaultCharBufferSize);
    }
}

BufferedWriter

下面是源码中的一部分内容:

public class BufferedWriter extends Writer {
	// 内部缓冲区字符数组
    private char cb[];
    // 默认缓冲区大小
    private static int defaultCharBufferSize = 8192;
    // 构造函数,使用默认缓冲区大小
    public BufferedWriter(Writer out) {
        this(out, defaultCharBufferSize);
    }
    // 构造函数,使用自定义的缓冲区大小
    public BufferedWriter(Writer out, int sz) {
        super(out);
        if (sz <= 0)
            throw new IllegalArgumentException("Buffer size <= 0");
        this.out = out;
        cb = new char[sz];
        nChars = sz;
        nextChar = 0;

        lineSeparator = java.security.AccessController.doPrivileged(
            new sun.security.action.GetPropertyAction("line.separator"));
    }
}

打印流

打印流只有输出流(内存 -> 文件),没有输入流。

PrintStream(字节打印流)

我们经常使用的 System.out 就是用于获取一个 PrintStream 对象,System.out.print 方法实际调用的是 PrintStream 对象的 write 方法。

默认情况下,PrintStream 输出数据的位置是标准输出,即显示器。

类图关系如下:

源码

下面是 PrintStream 的部分源码:

public class PrintStream extends FilterOutputStream
    implements Appendable, Closeable
{
    // 调用的 write 方法
    public void print(String s) {
        if (s == null) {
            s = "null";
        }
        write(s);
    }
    
    private void write(String s) {
        try {
            synchronized (this) {
                ensureOpen();
                textOut.write(s);
                textOut.flushBuffer();
                charOut.flushBuffer();
                if (autoFlush && (s.indexOf('\n') >= 0))
                    out.flush();
            }
        }
        catch (InterruptedIOException x) {
            Thread.currentThread().interrupt();
        }
        catch (IOException x) {
            trouble = true;
        }
    }
}

PrintWriter(字符打印流)

包装了 FileWriter ,提供了更方便的方法来完成输出。

类图关系如下

这里我就给出一个案例来说明字符打印流要如何使用。

需求:将 mrpersimmon-1.txt 中内容打印到 mrpersimmon-copy.txt 文件中。

代码

@Test
public void testPrintWriter() {
    try (BufferedReader br = new BufferedReader(new FileReader("mrpersimmon-1.txt"));
         PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter("mrpersimmon-copy.txt")))) {
        String line = null;
        while ((line = br.readLine()) != null) { // 一次读取一行内容,为空时代表读取结束
            pw.println(line); // 换行并打印到指定文件中
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果

随机访问流

在本小结,主要介绍支持随意跳转到文件任意位置读写的 RandomAccessFile 类。

类关系如下:

构造函数

构造函数的源码如下:

// String name: 指定名称的文件
public RandomAccessFile(String name, String mode)
    throws FileNotFoundException
{
    this(name != null ? new File(name) : null, mode);
}
// String file: 指定文件
public RandomAccessFile(File file, String mode)
        throws FileNotFoundException
{
    // 省略...
}

我们重点介绍输入参数 mode(读写模式)。根据源码中的注释,读写模式共四种:

  1. r: 只读模式;
  2. rw: 读写模式;
  3. rws: 相较于 rw,还需要将对「文件内容」或「元数据」的每次更新同步写入底层存储设备;
  4. rwd: 相较于 rw,还要求对「文件内容」的每次更新都同步写入底层存储设备。

什么是「文件内容」?什么是「元数据」?

「文件内容」指的是文件中实际保存的数据,「元数据」则是用来描述文件属性比如文件的大小信息、创建和修改时间。

rwd 相较于 rws 来说,可以减少执行 IO 操作次数。

文件指针

RandomAccessFile 中有一个文件指针用于表示下一个将要被写入或者读取的字节所处的位置

我们可以通过 seek(long pos) 设置文件指针的偏移量(据文件开头 pos 个字节处)。源码如下:

public void seek(long pos) throws IOException {
    if (pos < 0) {
        throw new IOException("Negative seek offset");
    } else {
        seek0(pos);
    }
}
private native void seek0(long pos) throws IOException;

如果想要获取文件指针当前位置的话,可以使用 getFilePointer() 方法。源码如下:

public native long getFilePointer() throws IOException;

常见方法

返回值

常用方法

描述

long

getFilePointer()

获取文件指针当前位置

void

set(long pos)

设置文件指针的偏移量

long

length()

返回文件的长度

int

read()

读取一个字节

int

read(byte[] b)

从该文件读取最多 b.length字节的数据到字节数组。

int

read(byte[] b, int off, int len)

从该文件读取最多 len个字节的数据到字节数组。

String

readLine()

读取下一行文本。

String

readUTF()

从该文件读取字符串。

void

write(byte[] b)

从指定的字节数组写入 b.length个字节到该文件,从当前文件指针开始。

void

write(byte[] b, int off, int len)

从指定的字节数组写入 len个字节,从偏移量 off开始写入此文件。

void

write(int b)

将指定的字节写入此文件。

void

writeUTF(String str)

以机器无关的方式使用 UTF-8 编码将字符串写入文件。

int

skipBytes(int n)

尝试跳过 n 字节的输入,丢弃跳过的字节。

使用案例

@Test
public void testRandomAccessFile() {
    try(RandomAccessFile raf = new RandomAccessFile(new File("mrpersimmon.txt"), "rw")) {
        // readLine() 读取一行文本
        System.out.println("起始文件内容:" + raf.readLine());
        raf.seek(0); // 设置文件指针偏移量为 0,回到起始位置
        // read() 读取一个字节
        // getFilePointer 获取文件指针当前位置
        System.out.println("读取前的偏移量:" + raf.getFilePointer() + ",当前读取的字符:" + (char) raf.read() + ",读取后的偏移量:" + raf.getFilePointer());
        raf.seek(6); // 设置文件指针偏移量为 6
        System.out.println("读取前的偏移量:" + raf.getFilePointer() + ",当前读取的字符:" + (char) raf.read() + ",读取后的偏移量:" + raf.getFilePointer());
        raf.write(new byte[]{'h', 'i'});
        System.out.println("写入后的偏移量:" + raf.getFilePointer() + ",当前读取的字符:" + (char) raf.read() + ",读取后的偏移量:" + raf.getFilePointer());
        raf.seek(0); // 设置文件指针偏移量为 0,回到起始位置
        System.out.println("当前文件的内容为:" + raf.readLine());
        raf.seek(0); // 设置文件指针偏移量为 0,回到起始位置
        raf.write(new byte[]{'A', 'B', 'C'}); // 覆盖数据
        raf.seek(0);
        System.out.println("覆盖后的文件内容为:" + raf.readLine());
    } catch (IOException e) {
        e.printStackTrace();
    }
}

运行结果

起始文件内容:abcdefg
读取前的偏移量:0,当前读取的字符:a,读取后的偏移量:1
读取前的偏移量:6,当前读取的字符:g,读取后的偏移量:7
写入后的偏移量:9,当前读取的字符:?,读取后的偏移量:9
当前文件的内容为:abcdefghi
覆盖后的文件内容为:ABCdefghi

应用场景

RandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传

何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。

该部分内容我们会在后续实战部分中,手写一个断点续传下载器进行详细讲解。

综合应用案例

案例 1:格式化读取写入文本

需求说明

1. 给定一个 Employee 类。

public class Employee {
    private String name; // 姓名
    private double salary; // 薪水
    private LocalDate hireDay; // 雇佣日期

    public Employee(String n, double s, int year, int month, int day)
    {
        name = n;
        salary = s;
        hireDay = LocalDate.of(year, month, day);
    }

    public String getName()
    {
        return name;
    }

    public double getSalary()
    {
        return salary;
    }

    public LocalDate getHireDay()
    {
        return hireDay;
    }

    // 加薪
    public void raiseSalary(double byPercent)
    {
        double raise = salary * byPercent / 100;
        salary += raise;
    }

    @Override
    public String toString()
    {
        return getClass().getName()
                + "[name=" + name + ",salary=" + salary + ",hireDay=" + hireDay + "]";
    }
}

2. 我们需要按照指定格式写入到 Employee.dat 文件中。

第一行数字是写入的记录数量。

指定格式:姓名|薪水|入职时间

3
Carl Cracker|75000.0|1987-12-15
Harry Hacker|50000.0|1989-10-01
Tony Tester|40000.0|1990-03-15

3. 从 Employee.dat 文件中读取数据打印到控制台中。

代码

public class Main {
   @Test
    public void test() throws IOException {
        Employee[] staff = new Employee[3];
        staff[0] = new Employee("Carl Cracker", 75000, 1987, 12, 15);
        staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
        staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);
        // 按照指定格式写入到 `Employee.dat` 文件中。
        try (PrintWriter out = new PrintWriter("Employee.dat", String.valueOf(StandardCharsets.UTF_8))) {
            out.println(staff.length);
            for (Employee e : staff) {
                out.println(e.getName() + "|" + e.getSalary() + "|" + e.getHireDay());
            }
        }
        // 从 `Employee.dat` 文件中读取数据打印到控制台中。
        try (Scanner in = new Scanner(new FileInputStream("Employee.dat"), String.valueOf(StandardCharsets.UTF_8))) {
            int n = in.nextInt();
            in.nextLine();
            Employee[] employees = new Employee[n];
            for (int i = 0; i < n; i++) {
                employees[i] = readEmployee(in);
            }
            for (Employee e : employees) {
                System.out.println(e);
            }
        }
    }

    public Employee readEmployee(Scanner in) {
        String line = in.nextLine();
        // split 方法的参数是一个描述分隔符的正则表达式。
        // 由于 "|" 在正则表达式中有特殊含义,所以需要 "\" 来转义,而 "\" 还需要一个 "\" 来转义。
        // 所以,表达式为 "\\|"。
        String[] tokens = line.split("\\|");

        String name = tokens[0];
        double salary = Double.parseDouble(tokens[1]);
        LocalDate hireDate = LocalDate.parse(tokens[2]);

        int year = hireDate.getYear();
        int month = hireDate.getMonthValue();
        int day = hireDate.getDayOfMonth();

        return new Employee(name, salary, year, month, day);
    } 
}

运行结果

  1. 工作目录中出现 Employee.dat 文件,有如下内容:
  1. 打印到控制台的内容如下:
io.Employee[name=Carl Cracker,salary=75000.0,hireDay=1987-12-15]
io.Employee[name=Harry Hacker,salary=50000.0,hireDay=1989-10-01]
io.Employee[name=Tony Tester,salary=40000.0,hireDay=1990-03-15]

总结

在这一讲中,我们讲解了如何判断输入、输出流,字节流和字符流的区别和使用场景,缓冲流和普通流的对比实验,什么是打印流,最后介绍了随机访问流。

相关推荐

嵌入式C语言中常量的应用实例(嵌入式c语言中常量的应用实例是什么)

常量,我们都知道,就是数值保持不变的量。在C语言中,常量一旦初始化了,它的值将在整个程序运行周期内,不允许发生任何变化。常量与变量是相对的,我们实际项目中经常会用到它。定义常量的两种方式C语言中主要有...

C语言编程基础知识汇总学习,适合初学者!更新常量知识

(二)整型常量整型常量有3种形式:十进制整型常量、八进制整型常量和十六进制整型常量。(注意:c语言中没有直接表示二进制的整型常量,在c语言源程序中不会出现二进制。)书写方式如下:十进制整型常量:123...

【C语言】第二章第六节:字符串常量

第二章第六节:字符串常量。下表C语言中的常用转义字符。·字符形式功能:ASCIl码(十进制形式)。→\t水平制表(横向跳格:跳到下一个tab位置)。→\b退格8。→\r回车(不换行,光标移到本行行首)...

「GCTT 出品」Go 系列教程——5. 常量

这是我们Golang系列教程的第五篇。定义在Go语言中,术语”常量”用于表示固定的值。比如5、-89、IloveGo、67.89等等。看看下面的代码:varaint=50v...

每日C语言-常量指针、指针常量、指向常量的指针常量

一、常量指针1)什么是常量指针?通过该指针不可以修改其所指向存储单元中的值指针本身即地址可以被修改2)定义:类型说明符const*指针变量;类型说明符表示指针所指向存储单元中的值得数据类型指针...

C语言-符号常量、常变量、变量之我见

更新内容:新增音频。音频和文章一起更配oHello,大家好,又和大家见面了~~相信很多朋友们听了C语言的“符号常量”、“常变量”、“变量”后还是对这三者一脸懵逼吧。不管老师怎么歇斯底里地讲解,同学们迷...

零基础带你学习C语言:四:探索常量与变量

前言常量与变量学习;一:分析:short、float、long类型#include<stdio.h>intmain(){shortage=18;floatweight=12...

C语言中是如何定义常量的?那定义字符串呢?

常量有整型常量、浮点型常量、字符型常量及字符串常量。‘常量定义是指定义符号常量,用一个标识符来代表一个常量,通过宏定义预处理指令来实现。常量的定义:#definecount60这就定义了一个常量...

C语言符号常量的优点,会是那几点?

符号常量是一个常量,是不变量,所以,在编译的时候,就把符号常量出现的地方,替换为符号常量对应的常量。符号常量一般用户定义一个全局使用的数据,而且要改变该数据的时候,只需要改变符号常量的值,代码中引用符...

嵌入式开发- C语言数据类型-常量(c语言嵌入式是干嘛的)

基本数据类型的常量-掌握**整型常量:**常量是指在程序运行期间其数值不发生变化的数据。整型常量通常简称为整数整数可以是十进制数、八进制数、十六进制数八进制06334十六进制0xd1...

c语言解剖课:只读变量、常量、字面量傻傻分不清?

写在前面本篇主题的缘起,是因为一个计算机专业的大学生在和我讨论c语言问题时,说const常量如何如何,我说变量被const修饰了,还是变量,不是“常量”。他给了我一个截图:他说大模型都是这样回答的,变...

C/C++编程笔记:C数组、字符串常量和指针!三分钟弄懂它

想弄懂C语言中数组和指针的关系吗?这篇文章就占据你三分钟时间,看完你肯定会有收获!数组数组声明为数据类型名称[constant-size],并将一个数据类型的一个或多个实例分组到一个可寻址的位...

C语言入门到精通【第008讲】——C语言常量

C语言常量常量是固定值,在程序执行期间不会改变。这些固定的值,又叫做字面量。常量可以是任何的基本数据类型,比如整数常量、浮点常量、字符常量,或字符串字面值,也有枚举常量。常量就像是常规的变量,只不过常...

这是C语言无法修改得东西,C语言基础教程之常量解析

常量是指程序在执行期间不会改变的固定值。这些固定值也称为文字。常量可以是任何基本数据类型,如整数常量,浮点常量,字符常量或字符串文字,还有枚举常量。常量被视为常规变量,除了它们的值在定义后无法修改。整...

C语言中的单精度、双精度、常量等都有什么意思?

刚接触C语言时,对于常量,变量,浮点,单精度,双精度等问题的理解,大都很模糊不清,其实在程序运行过程中,其值不能改变的量称为常量。如12、0、-3为整型常量,4.6、-1.23为实型常量,'a'、'...

取消回复欢迎 发表评论: