java高级------IO、NIO解读和不同点,以及常用的文件流操作方法
- 前情提要
- 文章介绍
- [1. 什么是IO](#1. 什么是IO)
-
- [1.1 节点的分类](#1.1 节点的分类)
- [1.2 传输方式](#1.2 传输方式)
- [2. 七大传输方式解读](#2. 七大传输方式解读)
-
- [2.1 File类解读](#2.1 File类解读)
-
- [2.1.1 创建文件的三种方式](#2.1.1 创建文件的三种方式)
- [2.2.2 File的常用方法](#2.2.2 File的常用方法)
- [2.2.3 如何正确认识FileUtils](#2.2.3 如何正确认识FileUtils)
- [2.2 字节流(核心)](#2.2 字节流(核心))
-
- [2.1.1 FileOutputStream](#2.1.1 FileOutputStream)
- [2.1.2 FileInputStream](#2.1.2 FileInputStream)
- [2.3 字符流](#2.3 字符流)
-
- [2.2.1 FileReader](#2.2.1 FileReader)
- [2.2.2 FileWriter](#2.2.2 FileWriter)
- [2.4 缓冲流](#2.4 缓冲流)
-
- [2.4.1 字节缓冲流(重点)](#2.4.1 字节缓冲流(重点))
- [2.4.2 字符缓冲流](#2.4.2 字符缓冲流)
- [2.5 转换流(不常用,但是得知道原理)](#2.5 转换流(不常用,但是得知道原理))
- [2.6 序列化流(了解)](#2.6 序列化流(了解))
- [2.7 打印流(了解)](#2.7 打印流(了解))
- 号外
- 总结
前情提要
上一篇文章我们探索了Exception类,一个我们经常用却很容易忽略的知识点,看完发现自己又专业了一点,主要还是针对异常的分类和如何有效的规避异常进行了讲解,还是建议大家看一下。
文章介绍
这一篇文章主要讲一下java中的文件流
,也就是我们常说的IO流
,虽然在开发中对普通的Excel、PDF、图片等文件经常读写,但毕竟只是一些基本的文件,而且相对都是一些导入导出操作。近期公司开发了在线学习的模块,里面涉及了大部分的文件读写,而且都是比较大的文件,在保证数据完整的同时还要兼具效率,所以采用了阿里的ssm服务器进行传输,虽然没有参与开发,但是在过程意识到了这部分的不足,所以进行系统学习,方便后期遇到这部分需求能快速有效的开发。
除了对基本知识的讲解外,同时也会糅杂当前最为流行的NIO
,本文将不再对源码进行过多的分析,主要还是针对实际开发和实用的知识点
。
本文参考自Java IO------二哥的Java进阶之路,在此基础上进行部分扩展。
1. 什么是IO
首先对IO进行拆分,I表示Input(输入
),O表示Output(输出)
,那么研究IO我们要从哪几个方面入手呢?首先要知道输入和输出的起点
和终点
,专业名词就是节点
,之后就是传输方式
了。而节点分为很多种,举个例子,你要跨越1000公里去旅游,很难说直接从家到目的地吧,中途肯定要从A------B,B------C,依此类推,才能到达最终目的地,这这每个地方我们都可以称作节点
,不一定说只有起点和终点才能称之为节点。
1.1 节点的分类
文件
:
文件是我们最常见的节点,开发中我们经常需要将数据导出
到Excel或者PDF文件中进行存储,对应的类有FileInputStream 、FileOutputStream 、FileReader 、FileWriter
等。字节数组
:
ByteArrayInputStream
和ByteArrayOutputStream
大多数用于对字节进行流形式
的存储和传输,一般用于操作图片、视频
等。字符串
:
很难想到字符串也算节点,其实这相当于中转站
,我们读取的数据大多数都是字符串,包括从文件中读取的数据,所以这也是一个节点,StringReader 和StringWriter
用流的形式操作字符串。控制台
:
这里的控制台不仅仅是代表我们开发中的控制台,键盘也算做控制台
,都是数据的来源,Java中通常使用System.in和System.out对数据进行控制台
的交换。内存映射文件
:
这个概念可能有些生僻,这是一种高效的文件读取方式
,非常适合读取大型文件
和需要频繁进行随机读写
的文件,通过直接将文件映射到内存
的方式,我们可以直接访问内存,效率很高,同时也可以多线程共享(包括加锁)
,因为普通的文件读写都是一个线程使用的,而且都需要请求全部数据(当然可以分片)。这是一个非常高效的手段,因为其按需读取
的原因,也不用担心内存被占满,可谓香的一批。Java中对应的是FileChannel
。
6.网络套接字
:
就是读取和写入数据
,只不过对象是网络传输
,也可以理解成网线,通常我们前后端发的post和get请求
啊这就是从网络中读写数据,这个概念可能大一些,名词就是Socket
。数据结构
:
这个也比较抽象,一般我们可以看到Java中有些类是需要实现Serializable接口
的,这样的接口一般都可以将从文件啊或者网络读取
的数据存储到这个类中,也可以将这个类存储的数据回显出去
,代表性的类就是Collection
类下的子类和String
类。管道
:
这是一种为线程和进程
之间通信
而出现的概念,Java和Linux中不太一样,Linux中不同的进程可以通过管道通信
,而Java中进程之间是不可以相同通信的
,Java中的进程就是一个jvm
,这和你new一个线程是完全不同的,而Java中不同的线程可以通过管道通信
。流
:
这可谓是Java中最流行的概念了,这是一个抽象的概念,就是指一连串的数据在一个通道内进行传输
,而这个通道就可以称之为流
。一般有以下几个特点:
先进先出
:类似于隧道。- 只能
顺序存取
,不能随机存取。 - 一个流
不能兼具输入和输出
两个功能,也就是单车道。
对象序列化
:
上面说过网络套接字发送请求吧,那这个请求是如何进行获取数据的呢?对象序列化就上场了,严格来说这数据数据结构,只不过更加直观一些,就是实现了Serializable的类
,也叫对象序列化。
1.2 传输方式
我们习惯将传输方式分为两大类,一类是字节流,一类是字符流。
字节流:
字节流(byte)
是所有数据的终极形态
,在网络中或者内存、硬盘等都是用byte来表示的,一个字节通常有8位(bit)
,而bit就是0和1,这是计算机能识别的最小单位
,那么我们常说的二进制或者十进制就是通过bit表示的,如下图。
一般情况下数据传输用的都是字节流
,因为所有数据类型都可以通过字节流传输
,最常见的就是图片和视频
,在Java中对应的就是InputStream
和OutputStream
两大类。
字符流:
字符流就是我们所能识别的char
,通常看到的字母、汉字等就是用字符流
表示的,一个字母就是一个字符,一个汉字可能由于字符编码的方式不同而体现的个数不同,UTF-8编码下一个汉字是三个字符
。一般我们传输文本类型数据的时候首选字符流
,Java中对应Reader
和Writer
。
当然这是大的分类,如果细分的话可以分为8大类
,本文就将围绕这8大类进行详细解读,先看图:

2. 七大传输方式解读
声明一下,严格来说文件流可以归类到字节流和字符流中,所以我们先对Java中File类进行解读,方便我们进行代码编写,之后的七大传输方式都会掺杂文件流的相关内容。
2.1 File类解读
2.1.1 创建文件的三种方式
java
public static void main(String[] args) {
String pathName = "D:/test";
// 直接通过完整的文件路径创建文件
File file = new File(pathName + "/test.txt");
// 通过文件夹的路径和创建文件的文件名创建文件,简称父子创建方式
File file1 = new File(pathName, "test.txt");
// 通过父文件和子文件的名称创建文件,也是父子创建方式的一种,只不过父参数是一个文件
File parent = new File(pathName);
File file2 = new File(parent, "test.txt");
}
这是Java中创建文件的三种方式,一般我们用的都是直接通过路径创建的
,有懂得同学可能会有疑问,说这里可以直接更改文件的,为啥不算IO呢?请看下面的例子:
java
File test1 = new File("D:/test/test1.txt");
File test3 = new File("D:/test/test3.txt"); // 无法创建test3.txt
System.out.println("test3.exists() = " + test3.exists()); // false
System.out.println("test1.exists() = " + test1.exists()); // true
test1.renameTo(test3); // 可以重命名
test1.delete(); // 可以删除文件
上面的例子能进行的操作是删除
和重命名
文件,但是无法创建
文件,更无法修改文件的内容(如果不涉及IO操作的话),严格来说上面的操作只是指令
,没有进行数据的传输
,也就不涉及IO的操作,想想,我给你一个指令,你自己去执行一些操作,这不算IO操作哦。
还有一点就是文件路径拼接的时候,很少我们会直接向电脑硬盘直接操作文件,一般的网站都是从浏览器读取一个文件解析上传到Linux服务器,但是我们要知道,Window和Linux的文件路径拼接符号不一样,Window是\
,Linux是/
,Java中可以通过File.separator获取,当然正常我们在进行文件操作的时候,都直接用/,也可以读取到window系统的文件。
2.2.2 File的常用方法
常用的获取文件信息的方法
java
// window下使用相对路径创建文件,文件一般在你的idea工作目录下
File file = new File("/test111/t1/hello.txt"); // 文件不存在
// 推荐使用绝对路径创建文件,当然开发中文件都是在Linux服务器,不存在分盘
File file1 = new File("D:/test/test2.txt");
// 获取文件的绝对和相对路径
System.out.println("file.getAbsolutePath() = " + file.getAbsolutePath());
System.out.println("file.getPath() = " + file.getPath());
// 获取文件或者文件夹的名称(文件名)
System.out.println("file.getName() = " + file.getName());
// 获取文件的大小
System.out.println("file1.Length = " + file1.length());
System.out.println("file.Length = " + file.length());
// 判断文件是否存在
System.out.println("file.exists() = " + file.exists());
System.out.println("file1.exists() = " + file1.exists());
// 判断是文件还是文件夹
System.out.println("file1.isFile() = " + file1.isFile());
System.out.println("file1.isDirectory() = " + file1.isDirectory());

常用的操作文件的几种方法
java
File file = new File("D:/test/test2.txt");
// 文件重命名
file.renameTo(new File("D:/test/test3.txt"));
// 创建文件,但只能创建一级,如果有多个层级则无法创建
file.mkdir();
// 如果文件不存在,则使用这个方法,确保文件正确创建,没有层级限制
file.mkdirs();
// 删除文件,会有删除成功和失败的返回值
boolean delete = file.delete();
// 返回目录中所有的子文件或目录路径(file必须是一个文件夹)
String[] list = file.list();
// 返回目录中所有的子文件或目录(file必须是一个文件夹)
File[] listFiles = file.listFiles();
2.2.3 如何正确认识FileUtils
在了解了文件的基本使用的,后续我们对文件的大部分操作都在创建文件
、删除文件
、复制文件
、移动文件
和下载文件
中,下载文件都是从服务器传输到浏览器中,大多数伙伴对于下载文件还是搞不太清楚,同样对应的还有上传文件,如何将文件快速的读取并传输的服务器当中(提一下:对于普通的office文件和图片文件一般存储在公司的服务器中,但是大型文件都会选择专业的文件服务器,类似阿里的文件服务器)
。
后续我们在开发当中都会使用工具类,常用的是Apache FileUtils 类
和Hutool FileUtil
类,一般公司会有架构师进行再次封装
,实际的核心代码还是使用上面的工具包进行,封装的意义就是为了统一管理,常见的手段就是增加日志
和限流
或者说切割
,保证代码的流畅运行。
这里对两个工具类不进行过多阐述,需要的伙伴可以打开官网自行查看或自己在idea中增加依赖查看代码,正确使用工具类能大大提高编码效率(二次封装的代码后续会放在例子中)。
需要注意的几点
- 开发中操作文件时一定要记得
判断文件是否存在
,不然会报错。 - 传递文件路径的时候,
使用绝对路径
,虽然Linux中不存在window的分盘。 - 获取文件大小返回的
单位是KB
,如果返回给前台是需要根据需求进行单位转换的。 - 开发中创建文件时尽量使用
mkdirs方法
。 - 删除文件最好根据返回值
判断是否删除成功
,因为可能会因为文件占用而导致删除失败。 - 遍历文件夹时最好使用
exist方法
判断文件是否存在,并且使用isDirectory判断
是一个目录。
2.2 字节流(核心)
最开始说过,字节是计算机中最基础也是最重要的单位,同理,字节流也是所有IO流中最基本最重要的,万物皆可用字节流传输
。
首先我们要知道,字节流中OutputStream
和InputStream
是两个超类
,下面有N多个实现类,包括缓冲流(Buffered Streams)、转换流(Filter Streams)等等,具有代表性的就是FileOutputStream
和FileInputStream
,这一小节主要围绕这两个进行研究。
2.1.1 FileOutputStream
java
public static void main(String[] args) {
File file = new File("/test/test111.txt");
FileOutputStream fileOutputStream = null;
try {
fileOutputStream = new FileOutputStream(file);
String content = "这是一段测试内容";
fileOutputStream.write(content.getBytes());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileOutputStream != null) {
try {
fileOutputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

上面这段代码很简单,创建了一个txt文件,写入了一段测试内容并将其输出到了硬盘,创建输出流FileOutputStream我使用的构造方法是传入一个文件
,当然还可以直接给一个文件名
,但是这种基本不使用。
上面使用的方法只有一个write
和close
方法,记住,一定要再使用完流后将其关闭
,不然会导致IO异常。而write方法则是我们常用的写数据方法
,你可以理解为new 一个FileOutputStream是内存与硬盘之间构建了一个通道
,但是你要传输数据,而传输数据的方法就是write
。
write常用的有两个重载方法:
-
write(byte[] b)
:将 b.length 个字节从指定的字节数组写入此输出流。 -
write(byte[] b, int off, int len)
:从指定的字节数组写入 len 字节到此输出流,相当于可以指定范围进行输出输出,一般用于给文档开头还是尾部写数据。
开发中怎样才能高效的写数据呢?如果我这个文件有几个G,这么直接写入会不会出现阻塞或其它情况呢?那么正常情况下我们肯定是不能直接这么写的,要么使用循环写入
,要么使用缓冲流
,之后会说到。
上面的输出有一个问题,如果需求是往一个文件中追加一部分内容
,那上面的写法就失效了,因为你直接write写数据的话,会将原有的内容直接清空
,如果要实现追加的内容,则需要在构造方法中加一个参数append
。
java
public static void main(String[] args) {
File file = new File("/test/test111.txt");
FileOutputStream fileOutputStream = null;
try {
fileOutputStream = new FileOutputStream(file, true);
// 注意:window的换行符是\r\n,Linux的换行符是\n
String content = "\r\n这是一段追加内容";
fileOutputStream.write(content.getBytes());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileOutputStream != null) {
try {
fileOutputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
这里在提一下往浏览器中输出一个文件
,因为我们现在是直接给硬盘或服务器输出文件的,这时候的outputStream是直接可以new的,本身的流指向就是硬盘或服务器
,如果想改变指向
,在web开发中前端发送的请求都是有一个HttpServletResponse
参数的,我们可以直接从这个参数中拿到outputStream
,并设置相关的响应类型
,然后写数据,这时候自然就实现了向浏览器输出文件。总的来说,就是创建通道
,确认通道的起点和终点
,给通道中输送数据
。
2.1.2 FileInputStream
字节输入流代表性的类就是FileInputStream,功能就是从服务器或者硬盘读取数据,使用思路是和输出流一致的,只不过在使用方法上有一些区别。
java
public static void main(String[] args) {
File file = new File("/test/test111.txt");
FileInputStream fileInputStream = null;
try {
fileInputStream = new FileInputStream(file);
// read方法一次只读取一个字节,注意,是一个字节
// 返回的是ASCII码,所以要转换为char字符类型
// 如果返回的是-1,则代表没有数据了,已经到最后了
while (fileInputStream.read() != -1) {
System.out.println("fileInputStream.read() = " + (char)fileInputStream.read());
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
上面是一个很简单的从文件中读取内容的例子,读取内容的方法变成了read,很直观,只不过需要注意的是,read方法一次性只会读取一个字符,而且返回的是ASCII码,如果需要查看,还需要转换为char类型。
上面的实现稍微有一个缺陷,就是在while循环中不建议使用read的返回值进行判断,相当于我们对同一个字符进行了两次读取,没有必要,可以修改为fileInputStream.available() != 0
判断,较为标准。

看一下程序的输出结果,发现中文乱码了,原因我们在一开始就提到过,中文在UTF-8编码下是3个字节,因为read一次性读取的只有一个字节,所以转为char类型就是乱码。那就没有办法了吗,肯定有,稍微改造一下代码即可,请看。
java
public static void main(String[] args) {
File file = new File("/test/test111.txt");
FileInputStream fileInputStream = null;
try {
fileInputStream = new FileInputStream(file);
// 定义一个指定大小的byte数组,一般都是1MB,也就是1024KB(字节)
byte[] bytes = new byte[1024];
while (fileInputStream.available() != 0) {
// 返回数据的长度
int len = fileInputStream.read(bytes);
System.out.println(new String(bytes, 0, len));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

能看到上面的输出和文件中内容是一致的
,包含换行符,当然肯定也有指定范围读取
的参数,除了利用while循环读取外,还有一个readAllBytes()
方法,读取所有的字节,但一般不建议使用
,除非你明确知道这个文件的内容比较小,比如这是一个配置的Properties文件
。
正常情况下,读取这种文件使用的都是字符流,这里只是举例来说明字节输入流的用法,毕竟这是最基础的内容。
简单提一下浏览器中的图片是怎么展示的,一般我们需要再浏览器中展示图片,给一个图片的路径即可,浏览器会自己加载,那加载的原理和过程是什么呢?其实就是浏览器发送了一个get请求,设置了请求类型是图片,而且一般都有Connection: Keep-Alive属性,这是HTTP协议中的一个缓存属性,你的请求数据是会被缓存的,下次加载页面就会快很多。发送请求后服务器返回的数据除了正常的响应头外,图片的数据就是使用字节流返回的二进制串,浏览器收到后进行解码,就变成了我们能看到的图片啦。
2.3 字符流
2.2.1 FileReader
字符流在上面已经简单讲过了,和字节流相比受限比较大,这是一种专为操作文本而设计的,其实操作和字节流大差不差,主要区别还是在flush
方法上和读取的方式上
,字符流每次读取的是一个字符
,所以相对比较简单。
java
public static void main(String[] args) {
File file = new File("/test/test111.txt");
FileReader fileReader = null;
try {
fileReader = new FileReader(file);
int ch = -1;
while ((ch = fileReader.read()) != -1) {
System.out.println((char) ch);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileReader != null) {
try {
fileReader.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

上面的运行结果很清晰,将文件中的每个字符依次输出,包含换行符和空格等,那些都属于字符,但一般我们都不会这么读取的,因为read方法每次默认只读取一个字符,程序修改如下。
java
public static void main(String[] args) {
File file = new File("/test/test111.txt");
FileReader fileReader = null;
try {
fileReader = new FileReader(file);
char[] chars = new char[1024];
int len = -1;
while ((len = fileReader.read(chars)) != -1) {
System.out.print(new String(chars, 0, len));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileReader != null) {
try {
fileReader.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

相对于字节流差别不大,如果是操作文本文件(例如一些配置文件和日志文件)推荐使用字符流,当然字符流也同样拥有指定范围读取的功能,这里就不做演示了,重点还是放在FileWriter上。
2.2.2 FileWriter
- write(int c): 写入单个字符。
- write(char[] cbuf) :写入字符数组。
- write(char[] cbuf, int off, int len) :写入字符数组的一部分,off为开始索引,len为字符个数。
- write(String str) :写入字符串。
- write(String str, int off, int len): 写入字符串的某一部分,off 指定要写入的子串在 str 中的起始位置,len 指定要写入的子串的长度。
上面是五个常用的写入数据的方法,有直接通过char字符写入
的,也有通过String字符串写入
的,个人比较喜欢String,操作起来相对方便一些。同样的,FileWriter有覆盖
和追加
两种方式写入,和字节流的append参数
一样。
java
public static void main(String[] args) {
File file = new File("/test/test222.txt");
FileWriter fileWriter = null;
try {
fileWriter = new FileWriter(file);
fileWriter.write("字符流写入数据。\r\n 这是另一段测试内容");
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fileWriter != null) {
try {
fileWriter.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
这里我们就演示常用的一种写入数据的方式,其余方式都是一样的,上面的例子比较简单,也是很常用的一种,需要明白的是,不一定要创建一个存在的文件,如果文件不存在,FileWriter会自动创建
,而且在最后一定要关闭流
,不然会导致数据无法写入的情况。
java
public static void main(String[] args) {
// 此例子只是为了研究flush的作用,开发中不能这么使用
File file = new File("/test/test333.txt");
FileWriter fileWriter = null;
try {
fileWriter = new FileWriter(file);
fileWriter.write("字符流写入数据。\r 测试不关闭流只刷新,数据是否正常写入");
fileWriter.flush();
} catch (Exception e) {
e.printStackTrace();
}
}
上面这个例子很有趣,我没有关闭流,但是文件正常创建了,数据也正常写入了,这是为什么?
答案就在我们的flush
方法上,因为字符流在设计上是有一个缓冲区
的,你的数据优先会写入缓冲区
,需要我们手动将数据刷出去
。一般开发中如果数据比较大,在一定大小是需要调用flush方法将数据刷出去的,清空缓冲区。而close方法里面是会调用flush方法的
,所以数据可以正常写入。
为什么要知道这个,一方面是了解字符流的设计原理,同时开发中有时候我们不需要立即关闭这个流,可以先将数据刷出去,在执行一些逻辑后继续写入数据。要知道,频繁创建输入输出流是很耗费资源的
,就和不断创建数据库连接一样,为啥不能再for循环中写sql
,知道为啥了吧。
2.4 缓冲流
缓冲流从字面意思理解稍微抽象一些,不过核心就5个字:空间换时间
!!!
上面5个字的意思就是缓冲流将每次读写的数据放在一块内存中,当达到预定的大小后再将数据写入到磁盘中,大大减少了磁盘和内存的交互次数,因为交互过程相对来说是很浪费时间的,这就是用空间换时间的概念,具体我们根据下面的例子来说明。
2.4.1 字节缓冲流(重点)
万物皆可使用字节流,当然字节流我们着重说一下,围绕复制一个比较大的文件来举例。
java
public static void main(String[] args) {
// 记录开始时间
long start = System.currentTimeMillis();
// 创建流对象
// 准备一个100mb以下的就可以,太大使用普通流复制需要时间比较长
try (FileInputStream fis = new FileInputStream("D:/test/file1.txt");
FileOutputStream fos = new FileOutputStream("D:/test/file1Copy.txt")){
// 读写数据
int b;
while ((b = fis.read()) != -1) {
fos.write(b);
}
} catch (Exception e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("普通流复制时间:"+(end - start)+" 毫秒");
}
上面使用了最基本的字节流进行文件复制,文件大小为48.7Mb,1Mb = 1048576 Byte(字节)
,那这个文件就是51,065,651.2字节
,而此次交互次数为文件大小的2倍(从磁盘读写到内存在复制到磁盘),那交互次数最终为 102,131,302.4 字节
,简单点那就算1亿
,虽然计算机读写速度也很快,单页扛不住这么造,上面程序最终的运行时间如下:231229毫秒,接近4分钟
(取决于电脑运行速度)

接下来使用字节缓冲流进行复制,看一下效果。
java
public static void main(String[] args) {
// 记录开始时间
long start = System.currentTimeMillis();
// 创建流对象
// 准备一个100mb以下的就可以,太大使用普通流复制需要时间比较长
try (BufferedInputStream fis = new BufferedInputStream(new FileInputStream("D:/test/file1.txt"));
BufferedOutputStream fos = new BufferedOutputStream(new FileOutputStream("D:/test/file1Copy.txt"))){
// 读写数据
int b;
while ((b = fis.read()) != -1) {
fos.write(b);
}
} catch (Exception e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("普通流复制时间:"+(end - start)+" 毫秒");
}
稍微替换了一个类速度得到了明显提升,最终花费1089毫秒,也就是1秒。既然缓冲流减少了IO的交互次数,从代码中具体是在哪里体现的呢?

上面这张图是缓冲输入流的构造方法(输出流同理),可以看到构造时创建了一个8192字节大小的byte数组,这就是用空间换时间所指的空间
。

接下来这张图是缓冲流的Write方法,真正写出数据的方法是flushBuffer()
,只有当读取大小达到指定空间大小后才会写出(read同理),从创建到操作都减少了IO的读取次数。
既然知道了原理不难发现,其实这就是对普通的流进行了二次封装
,那我们自己是不是也可以搞一个类似的代码,毕竟47Mb的文件复制执行了1秒还是太慢了,注意看,上面每次我们的读写都还是每次1个字节1个字读取的,那是不是可以用数组
呢。来吧,try it
。
java
public static void main(String[] args) {
// 记录开始时间
long start = System.currentTimeMillis();
// 创建流对象
try (BufferedInputStream fis = new BufferedInputStream(new FileInputStream("D:/test/file1.txt"));
BufferedOutputStream fos = new BufferedOutputStream(new FileOutputStream("D:/test/file1Copy.txt"))){
// 读写数据
int len;
byte[] bytes = new byte[8 * 1024];
while ((len = fis.read(bytes)) != -1) {
fos.write(bytes, 0, len);
}
} catch (Exception e) {
e.printStackTrace();
}
// 记录结束时间
long end = System.currentTimeMillis();
System.out.println("复制时间:"+(end - start)+" 毫秒");
}

表现很优异,只花费了63毫秒,这里我们创建的数组大小刚好是和Java中默认的读取大小一致,如果将这个大小继续调整,变成16*1024或者更大呢?经过测试发现只有扩大一倍后速度有显著提升,达到44毫秒,继续扩大速度提升都在几毫秒,而为了这几毫秒浪费那么大的空间倒是有些没必要了。当然,也可以修改构造方法,改变默认IO读写的空间大小,但很遗憾,经过不断测试,速度每次减少也在几毫秒,可能跟内存大小有关,一般按照规范的大小其实就可以了。
2.4.2 字符缓冲流
字符缓冲流这里只简单进行介绍,和字节缓冲流使用方法基本一致,最大的不同应该就是多了一个readLine和newLine方法比较常用,如果有兴趣的可以在idea中写例子进行探索。
2.5 转换流(不常用,但是得知道原理)
为什么会有转换流的存在,是因为编码格式在作祟,因为一个汉字、字母、数字在计算机中存储的格式是不一样的,用二进制存储,和我们看到的肯定不一样,比如字母A在计算机中存储1000001,而计算机之所以知道这是A,就是因为编码格式记录好了,1000001表示A。
那上面说的和转换流有什么关系,为什么说转换流不常用,就是因为数据在传输的时候用的是二进制,我们需要使用转换流才能识别成我们能看懂的文字,其实总结来说转换流就是字节流和字符流转换的工具
。
说到这和大姐在普及一个知识点,不同编码方式的区别在哪里,其实在最开始没有汉字的编码方式,搞得中国很难受,最后还是有牛人做了中文编码集GBK才解决了,所以,吾辈要自强啊
。下面是常见编码格式的区别:
ASCII
(美国信息交换标准代码)
- 定义:ASCII 是最早的字符编码标准之一,用于表示英文字符和一些控制字符。
- 范围:
ASCII 使用 7 位二进制数(即 1 字节,最高位为 0)表示字符,共有 128 个字符,包括:
26 个大写英文字母(A-Z)
26 个小写英文字母(a-z)
10 个数字(0-9)
34 个标点符号和控制字符(如空格、换行符等) - 特点:
简单高效,适合英文字符。
在早期计算机系统中广泛使用。
不支持非英文字符(如中文、日文等)。
应用场景:主要用于英文文本处理,如早期的计算机通信、操作系统和编程语言。
ISO-8859-1
(Latin-1)
- 定义:ISO-8859-1 是一种扩展的 ASCII 编码,用于支持西欧语言(如法语、德语、西班牙语等)。
- 范围:
使用 8 位二进制数(1 字节),共 256 个字符。其中:
前 128 个字符与 ASCII 相同。
后 128 个字符用于表示西欧语言中的特殊字符(如 é、ü、ö 等)。 - 特点:
支持多种西欧语言。
仍然无法支持其他语言(如中文、日文、阿拉伯语等)。
应用场景:主要用于西欧语言的文本处理,如早期的网页、电子邮件等。
GBK 和 GB2312
(中文编码)
- 定义:GBK 和 GB2312 是用于表示中文字符的编码标准。
- 范围:
GB2312:使用双字节表示字符,支持 6763 个汉字和符号,主要用于简体中文。
GBK:是 GB2312 的扩展,支持更多汉字(包括繁体字)和符号,共约 21003 个字符。 - 特点:
专门用于中文字符,兼容 ASCII(单字节表示英文字符)。
不支持其他语言(如日文、韩文等)。
应用场景:主要用于中文操作系统(如 Windows 简体中文版)、中文网页等。
Shift-JIS
(日文编码)
- 定义:Shift-JIS 是一种用于表示日文字符的编码标准。
- 范围:使用单字节和双字节混合编码,支持日文假名、汉字(Kanji)和 ASCII 字符。
- 特点:
支持日文字符,兼容 ASCII。
不支持其他语言。
应用场景:主要用于日文操作系统、日文网页等。
UTF-8
(Unicode 转换格式)
- 定义:UTF-8 是一种可变长度的 Unicode 编码,用于表示 Unicode 字符集。
- 范围:
根据字符的不同,使用 1 到 4 个字节表示:
ASCII 字符(0-127)使用 1 个字节。
拉丁语系、希腊语、西里尔语等字符使用 2 个字节。
中文、日文、韩文等字符使用 3 个字节。
特殊字符(如表情符号)使用 4 个字节。 - 特点:
兼容 ASCII(单字节表示英文字符)。
支持全球所有语言。
可变长度编码,节省存储空间。
应用场景:广泛应用于现代互联网(如 HTML、JSON)、操作系统(如 Linux、macOS)、编程语言(如 Python、Java)等。
当然现在大多数语言和编码工具都解决了乱码的问题,但这不代表说我们之后不会遇到,大家接触最多的可能就是GBK和UTF-8了,当然首选肯定是UTF-8
,因为这是最全的。
2.6 序列化流(了解)
序列化流和转换流有相同点,都是将某个东西转为字节传输和存储,而序列化流是对象和字节相互转换的途径,这里主要是也会将对象的映射关系等其它信息也会序列化,信息比较完整。(图片引自"二哥的Java进阶之路")
因为序列化流使用次数不多,这里仅做简单的了解,我们只需要知道怎么回事就可以了。
Java中使用ObjectInputStream 和ObjectOutputStream进行序列化,需要注意的是序列化和反序列的对象必须要继承 Serializable 接口
,这个没说的,创建完对象之后使用writeObject和readObject方法进行序列化和反序列化,但是因为Java自带的这个序列化实际弊端很大,容易破解还占用大,所以一般我们使用第三方库进行,主要弊端如下:
安全漏洞
:
Java 序列化对象容易受到安全风险的影响,例如反序列化漏洞可能允许攻击者执行任意代码,从而对系统造成严重的安全威胁。性能限制
:
序列化和反序列化过程相对较慢,尤其是对于大型或复杂的对象。
序列化后的字节体积较大,增加了存储和传输成本。版本控制
问题:
当类结构发生变化时,反序列化旧的序列化对象可能导致兼容性问题和潜在的运行时异常。- 处理
复杂对象图的限制
:
Java 序列化可能难以处理具有循环引用或瞬态字段的复杂对象图,导致意外行为或异常。 可移植性差
:
Java 序列化是 Java 特有的,无法跨语言进行序列化和反序列化。
Java 序列化的替代产品
为了解决上述痛点,以下是一些流行的替代方案:
- Kryo
- 优势:
高性能、高效率,生成的序列化数据体积小。
易于使用和扩展,支持多种数据类型。
- Protocol Buffers(Protobuf)
- 优势:
高效紧凑,生成的二进制数据体积小,传输速度快。
跨平台兼容,支持多种编程语言。
强类型支持,减少运行时错误。 - 劣势:
需要定义 .proto 文件来描述数据结构,增加了开发复杂性。
二进制格式难以直接调试。
- Apache Avro
- 优势:
支持模式演变,即使数据结构发生变化,也能处理旧数据。
高效性能,以二进制格式存储数据。
轻量运行,模式信息嵌入序列化文件中。 - 劣势:
模式定义复杂,学习曲线较陡。
JSON
- 优势:
简单易读,适合调试和开发。
跨语言支持,广泛应用于 Web 开发。 - 劣势:
数据体积相对较大,性能不如二进制格式。
在上述几种常用的替代方案中我们用的最多的可能就是JSON了,这是在网络传输(尤其是不涉及文件或图片视频的情况)几乎是首选,而在之前的版本中fastjson是阿里的开源产品
最受欢迎,不过由于后面发现了重大的漏洞则改变为fastjson2
。
2.7 打印流(了解)
打印流这个概念可能大多数伙伴第一次听说,打印两个字不知道是不是你们小时候的噩梦(去,把这份卷子打印一下发给全班同学),真的很惨。其实Java的打印流我们在没有接触框架前经常使用,就是向控制台打印信息,而具体的类就是PrintStream ,获取方式就是:System.out。
PrintStream 类的常用方法包括:
- print():输出一个对象的字符串表示形式。
- println():输出一个对象的字符串表示形式,并在末尾添加一个换行符。
- printf():使用指定的格式字符串和参数输出格式化的字符串。
java
PrintStream ps = System.out;
ps.printf("姓名:%s,年龄:%d,成绩:%f", "沉默的程序猴", 18, 99.9);
为什么说这一部分是了解,虽然我们在学习的时候都是通过打印流向控制台打印信息的,但终归只是学习,在真正的开发中很少能见到上面这种打印日志的方式,都是通过各种的日志框架实现的,虽然底层都是打印流,常见的就是log4j。
这里稍微多说两句,一个好的程序猴对于日志的处理是非常优秀的,往往通过日志的查看就能快速定位问题,在实际开发中非常的节省时间,这样就不用测试人员再复现问题,然后你的本地进行debug找出问题。分享一个打印日志的好习惯
- 开发中打印日志
不要一下输出整个对象
,尤其是那种字段很多的对象,你很难在一堆字母中找到你想要的属性,建议的做法是只打印出你需要的属性即可。 - 一个方法中的日志最好在
开头带上方法名和一段"==============="
,这样日志非常清晰,在实际开发中非常有用,毕竟日志是相当多的。 - 实际开发中一个方法会存在很深层次的调用,通常会涉及多角度逻辑处理,打印日志时有一个
总体方法的开始和结束标识
,日后在日志分割时也能快速定位。同时尽量将每一个业务的日志也打上开始和结束标识
,标明这一段业务逻辑结束了。
号外
RandomAccessFile
RandomAccessFile 是 Java 中一个非常强大的类,位于 java.io 包中。它允许对文件进行随机访问
,即可以随机读取或写入文件的任意位置,而不仅仅是顺序读取或写入。这使得它在处理大型文件或需要频繁访问文件特定位置的场景中非常有用。
- 特点
随机访问:可以自由地定位到文件的任意位置进行读写操作。
支持读写:既可以读取文件内容,也可以写入文件内容。
文件指针:通过维护一个文件指针(file pointer),记录当前读写的位置。
灵活性:支持多种数据类型(如 int、double、String 等)的读写。 - 构造方法
RandomAccessFile 的构造方法需要两个参数:
文件路径:可以是文件名或 File 对象。
访问模式:指定文件的打开模式,常见的模式有:
"r":只读模式。
"rw":读写模式。
"rws":读写模式,同时要求对文件内容或元数据的更新同步写入到底层存储设备。
"rwd":读写模式,要求对文件内容的更新同步写入到底层存储设备。 - 常用方法
(1)定位方法
seek(long pos):将文件指针移动到指定位置(从文件开头开始计数,单位为字节)。
getFilePointer():获取当前文件指针的位置。
length():获取文件的长度(单位为字节)。
(2)读取方法
read():读取单个字节。
read(byte[] b):读取字节到数组。
readLine():按行读取(已废弃,建议使用其他方式替代)。
readInt()、readDouble()、readUTF() 等:读取特定数据类型。
(3)写入方法
write(int b):写入单个字节。
write(byte[] b):写入字节数组。
writeInt(int v)、writeDouble(double v)、writeUTF(String s) 等:写入特定数据类型。
(4)其他方法
close():关闭文件,释放资源。 - 使用场景
RandomAccessFile 适用于以下场景:
随机读写文件:例如,对文件的某一部分进行修改,而不需要重新写入整个文件。
处理大型文件:通过随机访问,可以高效地读取文件的特定部分,而不需要加载整个文件。
模拟数据库:在文件中存储结构化数据,并通过随机访问实现快速查询和更新。 - 示例代码
示例 1:随机读取文件内容
java复制
java
public class RandomAccessFileExample {
public static void main(String[] args) {
try (RandomAccessFile raf = new RandomAccessFile("example.txt", "r")) {
// 跳到文件的第10个字节
raf.seek(10);
byte[] buffer = new byte[10];
int bytesRead = raf.read(buffer);
System.out.println("Read: " + new String(buffer, 0, bytesRead));
} catch (IOException e) {
e.printStackTrace();
}
}
}
示例 2:随机写入文件内容
java复制
java
public class RandomAccessFileExample {
public static void main(String[] args) {
try (RandomAccessFile raf = new RandomAccessFile("example.txt", "rw")) {
// 跳到文件的第20个字节
raf.seek(20);
// 写入字符串
raf.write("Hello, RandomAccessFile!".getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
}
示例 3:读写特定数据类型
java复制
java
public class RandomAccessFileExample {
public static void main(String[] args) {
try (RandomAccessFile raf = new RandomAccessFile("data.dat", "rw")) {
// 写入数据
raf.writeInt(123);
raf.writeDouble(45.67);
raf.writeUTF("Hello");
// 重新定位到文件开头
raf.seek(0);
// 读取数据
int intValue = raf.readInt();
double doubleValue = raf.readDouble();
String stringValue = raf.readUTF();
System.out.println("Int: " + intValue);
System.out.println("Double: " + doubleValue);
System.out.println("String: " + stringValue);
} catch (IOException e) {
e.printStackTrace();
}
}
}
- 注意事项
文件指针:seek() 方法会移动文件指针,因此需要小心管理指针位置。
文件长度:如果写入的位置超过了文件当前长度,文件会自动扩展。
线程安全:RandomAccessFile 不是线程安全的,如果需要多线程访问,需要手动同步。
性能:随机访问文件可能会导致磁盘寻道次数增加,影响性能,因此需要合理使用。 - 总结
RandomAccessFile 是 Java 中一个非常灵活的文件操作类,支持随机读写操作,适合处理大型文件或需要高效访问文件特定位置的场景。通过合理使用其方法,可以实现复杂的文件操作需求。
关于RandomAccessFile的介绍我就先说到这,因为我对其本身也仅限于文字了解和基本的使用,我在公司负责的业务板块并不是这里,可能之后有空闲时间会和其他组进行交流和实践后会发布一篇文章,具体的业务是在线学习和直播,这里对于流的操作非常专业,同时也涉及和阿里对接(oss服务器和转码)。
总结
好了,这篇文章到这里就告一段落,关于IO流这篇文章只是想完善一下Java的学习体系,也是想让大家对于Java的IO流不那么陌生,同时也满足基本的开发和使用,如果说业务方向是视频、图片和直播等那这篇文章就显得不专业了,希望大家理解,下一篇我们将讲解一下NIO。