文件IO的前世今生 目录1. 认识文件1.1 硬盘的相关知识1.2 文件的路径1.3 文件的重要分类2. 针对文件系统操作的 API —— File 类3. 针对文件内容的操作 —— 流4. 文件操作综合案例4.1 查找硬盘上的文件位置4.2 文件复制4.3 按文件内容搜索文件5. 小结在这篇博客中我们进入文件 IO 的学习。这篇博客会从文件系统、硬盘、存储等基本常识如何针对文件系统进行操作如何对文件内容进行操作以及一些文件 IO 的实例等方面具体阐述文件 IO 的相关知识。光学补偿 (wangjunyi6526) - Gitee.com 这是我本人的码云里面包含了一些关于 Java 和 C 语言的代码大家感兴趣可以关注一下1. 认识文件相信大家对于文件都或多或少有自己的见解我们这里谈到的文件是保存在硬盘(磁盘)上的文件。对于计算机来说文件是一个广义的概念。它既能指硬盘上的普通文件又能指硬盘上的目录(文件夹)。很多的硬件设备也是被操作系统抽象成了文件比如键盘、显示器、网卡……1.1 硬盘的相关知识我们要注意硬盘和磁盘可不是一个东西。磁盘是外存的一种而软盘/硬盘(机械硬盘)是磁性介质来存储二进制数据的。2017 年之后SSD 硬盘(固态硬盘)兴起了内部完全是集成电路和磁性介质是没有关系的。机械硬盘还有但是越来越少了。SSD 相比于机械硬盘最大的优势是读写速度快了很多大概有 10 倍左右。一般 SSD 每秒读写 3-4GB(好一点的能达到 6-7GB)而机械硬盘速度大概就是 200MB。我们来看计算机中存储数据的设备可以看到硬盘的读写速度虽然是最慢的但好处就是数据不会因为电源的断开或电脑的关机而丢失。由于此处谈到的文件是存储在硬盘上的文件因此很多特性都和硬盘特性相关。我们接着来看机械硬盘的具体结构如图所示机械硬盘的大致结构为磁头 盘片。我们要切记机械硬盘不要自行拆开因为这里都是要求无尘的。对于机械硬盘来说更擅长顺序读写不擅长随机读写。如果有一个很大的文件你要把这个文件整体复制一下机械硬盘往往会采用顺序读写(一般不太需要移动磁头速度就很快)。但如果是某个目录中有很多小文件(每个文件很小文件数目特别多)你要把整个目录都拷贝一下这里就得用随机读写了。这么多文件在硬盘上的存储不是连续的而是离散的可能出现在硬盘的各个位置上。这时磁头就需要不停地移动去找位置。磁头再小也有质量因此就需要做功也就需要消耗更长的时间。1.2 文件的路径虽然硬盘有上述结构但对于程序员来说写代码不太涉及到这些硬盘的结构细节的操作系统都封装好了。操作系统有一个专门的模块——文件系统把硬件细节封装好提供统一的 API 来供我们调用。我们打开文件资源管理器找到此电脑就能看到我们电脑里的硬盘存储的相关文件。可以看到此处文件系统是按照树形结构来组织文件的。这里的树不是二叉树而是 N 叉树每一个普通文件(非目录)就是叶子节点。对一个目录文件来说里面又可以有很多的叶子节点。那么如何描述一个文件的具体位置呢操作系统使用路径这样的概念来描述一个文件的具体位置形如上述这里从树根节点出发(Windows 上是从盘符出发)一级一级往下走走到目标的文件把中间经过所有的目录名字都串起来使用 / 或者 \ 分割此时就构成了路径。这个过程只是访问其中的一小部分节点路径有两种不同的风格1.绝对路径指的是从树根节点出发(Windows 是盘符)一层一层最终到达目标文件。2.相对路径先指定一个当前目录/工作目录/基准目录从当前目录出发找到目标文件。举几个栗子如果当前目录是C:\Users\1此时相对路径就是./test.txt如果当前目录是C:\Users此时相对路径就是./1/test.txt如果当前目录是C:\Users\1\AppData此时相对路径就是../test.txt此处的..表示上层目录实际开发中更多的是使用相对路径。操作系统中分隔目录的各个部分正统是使用 /(上古时期的知名操作系统)后来有一个系统横空出世叫做 DOS(微软早期的成名战)。据说DOS 本来也是使用 / 来分割临近上线之前产品经理突然改需求了非得使用 \ 来分割。结果改成 \ 之后用户们并不买账。后来 DOS 退而求其次同时能够支持 \ 和 / 再后来Windows 属于 DOS 的继任者也就继承了上述的设定而 Linux 这样的系统都是 / 。后续写路径也会优先写作 / 写作 \ 在代码中可能还得转义一下。1.3 文件的重要分类文件主要分为两种1. 文本文件2. 二进制文件此处说的文本文件是按照文本/字符串的方式理解文件内容(文本文件里面的二进制内容都是表示的字符串)进一步的我们可以认为文本文件的内容都是合法字符。此处的合法字符指的是符合字符编码(普通英文字母使用 ASCII汉字使用的是 GBK/UTF-8无论是哪种方式都会有一个码表)。文件中存储的数据都是在指定码表上有据可查就是合法的字符此时这个文件就是文本文件。而二进制文件则没有上述限制这里的内容存储任何数据都可以。那么如果给你一个文件如何判定是文本文件还是二进制文件呢这个时候可以用记事本来判定。使用记事本打开一个文件如果你看到的内容是正常内容不是乱码就说明这是文本文件如果是乱码就是二进制文件。图片、音频、视频、可执行程序、动态库……都属于二进制文件.class 也是二进制文件。文本文档就是文本文件.c、.java、.cpp 也是文本文件。2. 针对文件系统操作的 API —— File 类Java 对于文件操作的 API 分为以下两种1. 针对文件系统的操作包括不限于创建文件删除文件重命名文件列出目录内容……2. 针对文件内容的操作读文件/写文件。我们先来看针对文件系统的操作用到的类。Java 中针对文件系统的操作使用 File 类来进行。这个类所在的包叫做 java.io。i 代表的是 input(输入)o 代表的是 output(输出)。注意我们不要搞混淆输入和输出的方向。对于计算机来说CPU 是最关键的部分因此我们应该坐在 CPU 上来看待这个问题。这样一来输入输出的方向就如下图所示我们接着来看如何构造一个 File 对象由图可知构造一个 File 对象需要指定路径。此处的路径可以是绝对路径也可以是相对路径。当然这个路径也可以是不存在的路径。如果写作相对路径1如果你是直接在 IDEA 中运行程序此时工作目录就是项目所在目录。2如果你是把代码打包成一个单独的 jar 包来执行此时工作目录就是 jar 包所在目录。可以看到还有构造方法中有 parent 这样的参数。举个栗子C:\Users\1\test.txt对于这个文件来说parent 就是 C:\Users\1我们再来看 File 类对应的一些方法如果我们要创建一个三级目录形如./aaa/bbb/ccc 这样的。我们就要用到 mkdirs() 这个方法了如果只是创建 ./aaa使用 mkdir() 即可。这里还提到了文件的权限所谓的权限就是哪些事情你能做哪些事情你不能做。对于 Windows 的文件来说权限最核心的是两种读和写。光说不行我们来动手写一下相关的代码import java.io.File; import java.io.IOException; public class IOTest { public static void main(String[] args) throws IOException { File f new File(C:/Users/wlegion/test.txt); System.out.println(f.getParent()); System.out.println(f.getName()); System.out.println(f.getPath()); System.out.println(f.getAbsolutePath()); System.out.println(f.getCanonicalPath()); } }当我们写到最后一个操作的时候会有异常提示我们直接把异常添加到方法签名即可。这个异常指的是在你 IO 操作的过程中出现的异常。可以看到如果构造 File 对象使用绝对路径此时上述三个操作确实没啥区别。那让我们换成相对路径(./test.txt)再来看看此时可以看到getPath 的结果就是 File 构造方法中的参数(./test.txt)。而 getAbsolutePath 就能把刚才的相对路径给转成绝对路径. 表示当前的路径此处是先一层一层找到 JAVA251015 这个一级目录然后再找 . 此时 . 就是 JAVA251015(原地踏步了一下)。最后一个操作就是针对上面的绝对路径整理化简之后的效果了。我们再来看一段代码import java.io.File; import java.io.IOException; public class IOTest1 { public static void main(String[] args) throws IOException { File f new File(./test.txt); System.out.println(f.isFile()); System.out.println(f.isDirectory()); boolean ret f.createNewFile(); System.out.println(ret ret); } }可以看到当没有真正创建文件的时候test.txt 既不是文件也不是目录。当我们再次运行程序的时候可以看到这时可以看到test.txt 已经是文件了但是再次进行创建文件操作会失败。这是因为文件的路径相当于文件的身份标识在一个计算机上文件的路径必须是唯一的。如果我们想删除文件可以执行以下代码import java.io.File; import java.io.IOException; public class IOTest1 { public static void main(String[] args) throws IOException { File f new File(./test.txt); // System.out.println(f.isFile()); // System.out.println(f.isDirectory()); // boolean ret f.createNewFile(); // System.out.println(ret ret); boolean ret f.delete(); System.out.println(ret ret); } }可以看到test.txt 已经被删除了。除了 delete删除文件还可以使用 deleteOnExit它指的是程序退出时再删除。接着拿刚才的 test.txt 来举例import java.io.File; import java.io.IOException; public class IOTest1 { public static void main(String[] args) throws IOException, InterruptedException { File f new File(./test.txt); // System.out.println(f.isFile()); // System.out.println(f.isDirectory()); // boolean ret f.createNewFile(); // System.out.println(ret ret); // boolean ret f.delete(); f.deleteOnExit(); Thread.sleep(5000); } }这里设置的是 5 秒之后再退出程序删除文件大家注意观察左上角 test.txt 的存在状态。程序运行之后等待 5 秒可以看到test.txt 文件已经被删除成功退出之后再删除这样的文件也称为临时文件。有些程序就带有临时文件的功能比如 WPS。这个隐藏文件就是一个退出后就删除的临时文件它的作用是给你实时编辑的内容进行保存防止出现你文件没保存电脑突然断电的情况。如果我们想要打印当前目录下有什么文件可以执行以下代码import java.io.File; import java.util.Arrays; public class IOTest3 { public static void main(String[] args) { File file new File(.); String[] files file.list(); System.out.println(Arrays.toString(files)); } }接着来看如何创建目录这里需要在构造方法中把路径创建好再通过 mkdir 来创建3. 针对文件内容的操作 —— 流流是操作系统提供的概念因此 C/Java 才能基于流进行封装。我们都知道水流是生生不息绵延不断的。水流的特点这里可以再具体一些假如要接 100ml 的水可以一次接 10ml分十次接也可以一次接 5ml分 20 次接也可以一次接 100ml一次接完。此处我们的接法是有任意多种最终得到的效果是一样的。文件流也是类似的情况比如要读写 100 字节的数据可以一次读写 10 字节分 10 次读写也可以一次读写 5 字节分 20 次读写也可以一次读写 100 字节一次搞完。此处的读写方式也是任意多的情况最终得到的效果也是一样的。Java 标准库对于流操作进行了一系列的封装提供了一组类来负责进行这些工作针对这么多类可以大体分为两大类别一. 字节流以字节为单位进行读写一次最少读写一个字节代表类InputStream 输入OutputStream 输出二. 字符流以字符为单位进行读写。比如如果是 UTF-8 表示汉字3 个字节就是一个汉字每次都写都得以 3 个字节为单位(一个汉字为单位)来进行读写不能一次读写半个汉字。代表类Reader 输入Writer 输出这几个类的使用非常相似会用一个其他的就会了。读写文件内容在各种编程语言中都是固定套路1打开文件2关闭文件3读文件4写文件我们来看一下具体的代码是如何编写的首先在如图所圈出的目录下创建一个 test.txt 文件内容随便写即可。接着我们需要在 main 方法中使用 InputStream 类使用快捷键 CtrlQ 可以看到InputStream 这个类是一个抽象类。我们都知道所谓抽象类就是不能实例化的类。有些类我们不希望它能够创建出实例就用 abstract 来描述编译器就可以进行更严格的检查。抽象本身理解起来就比较抽象但是这个词也是计算机中比较常见的术语。那么如何理解抽象呢举个栗子一个具体的小猫如下图可以看到一个具体的小猫信息量能看到更多。而一个抽象的小猫如下图可以看到一个抽象的小猫能看到的信息很少。所以说信息越多就越具体信息越少就越抽象。程序员写代码很多时候都是要用抽象的方式来表示具体的事物。一个具体的大学生包含的信息就非常多。但是在写代码解决实际问题的过程中只是关注一部分信息量写代码的过程就是把一个具体的大学生身上需要的几个信息提取出来用代码进行表示这就是抽象。回到正题所以说这里的 InputStream 不能直接实例化它本身需要创建出它的一个子类(FileInputStream)。读取的路径有很多种FileInputStream 指定了这里从文件来读取。创建对象的过程就是打开文件的过程。注意这里还需要抛出一个异常这个异常是 IOException 的子类表示文件不存在。关闭文件使用的是 close 操作也是需要抛出一个异常 —— IOException。由于 FileNotFoundException 是 IOException 的子类因此直接在方法签名中抛出 IOException 是没有问题的。可以认为FileNotFoundException 也是一种特殊的 IOException。关闭文件可以理解成释放了文件的相关资源。我们都知道一个进程可以由一个或者多个 PCB 进行表示。PCB 中有一个属性叫做文件描述符表记录了当前进程都打开了哪些文件。我们可以把文件描述符表当成一个顺序表或者数组数组中的每个元素都是一个结构体这个结构体就具体描述了你打开的这个文件在系统上的一些属性。每次打开一个文件都是需要在文件描述符表中占据一个位置的。如果不关闭的话还一直打开就会导致文件描述符表被耗尽(文件描述符表长度有限)。当文件描述符表被耗尽之后后续再打开文件就会失败进一步就会导致逻辑出现问题。但是如果关闭直接像下面这么写很可能是执行不到的如果中间的逻辑出现 return或者出现抛出异常close 就都执行不到了。因此这里就需要用到 try 和 finally 了。Java 中的 try 操作还提供了一个版本try with resources来实现文件的关闭。实现了 Closeable 接口的类才能放到 try( ) 里面。一旦代码出了 try 代码块此时 try 就会自动调用 inputStream1 的 close。接下来我们来看具体怎么读文件。这里使用的是通过 inputStream1 来调用 read() 方法read() 方法这里有三个版本第一个是无参数版本每次调用读取一个字节返回值就表示读取到的这个字节的值。这里的返回值类型看起来是 int实际上是 byte实际的取值是 0-255。此处还有一个特殊情况如果读取到文件末尾继续进行 read就会返回 -1。第二个是带有一个参数的版本传入的是字节数组参数是一个输出型参数。byte[ ] 是引用类型方法内部针对数组内容进行修改方法执行结束之后方法外部也能生效。使用 read 的时候往往就是定义一个内容为空的数组(不是 null)把空的数组交给 readread 内部对数组内容进行填充。这个过程就好比是去食堂吃饭可以有两种风格1. 空着手去你说要吃什么食堂阿姨就给你拿个盘子给你装饭连饭带盘子一起给你。2. 自己带个碗把碗递给阿姨人家给你把碗装满饭然后再还给你。第二种设定在 Java 中不太常见也会偶尔遇到C 对于这样的操作是更常见的。除此之外read 方法还有第三个版本。假如你的饭盒里有若干个格子去窗口打饭可以告诉阿姨把这些格子都装满也可以告诉阿姨只能从第二个格子装到第四个格子其他格子不要装。read 方法的第二个和第三个版本返回的 int 表示实际读取的字节个数。默认情况下read 会尝试把数组填满但是文件的实际长度可能不足以填满了此时返回值就可以告诉我们实际是填充了多少字节。buffer 也是计算机中的常见术语表示缓冲区。往往是一个内存空间。而读文件就是把硬盘数据读取到内存中。我们来举一个具体的栗子上面这个写法就是一次读若干字节这种方式要比一次读一个字节更高效。同样地一次写若干字节也比一次写一个字节分很多次来写更高效。操作硬盘本身就是一个比较低效的操作我们都希望低效的操作出现的次数越少越好这样效率才会更高。访问硬盘是低效操作但访问内存是高效操作我们就希望借助内存来减少读写硬盘的次数此时对应的这个内存就称为缓冲区buffer。接着我们来看怎么写文件。写文件用到的是 OutputStream 类中的 write 方法该方法也有三个版本。这三个版本和读文件操作中所使用到的 read 方法类似。但是有一点我们需要注意就是此处的 OutputStream 默认情况下会把之前文件的内容都清空掉然后从头重新写。这并非是 write 引起的而是打开操作引起的。但是写文件的时候也不是非得要把文件内容清空。也可以采用追加写的方式不清空文件内容把新的内容写到文件末尾。 在 write 方法后再添加一个参数 append设为 true 即可。除了上述两种读文件和写文件的方式之外读写文件还可以用 Reader 和 Writer 这两种依靠字符流的方式。使用方式和 InputStream 和 OutputStream 基本差不多。以 Reader 类为例可以看到此处的 read 方法就是按照 char 为单位进行操作了。再来看 Writer 类可以看到该类提供的 write 方法既可以用字符串进行写入也可以用字符数组进行写入。综上关于文件流最核心的类就是InputStream、OutputStream、Reader、Writer。它们的用法都类似首先就是打开(构造对象)然后进行读/写操作read 可以一次读一个字节/字符或一次读 N 个字节/字符write 可以一次写一个字节/字符或一次写 N 个字节/字符也可以写字符串最后操作完进行关闭(try)。除此之外我们熟悉的Scanner也可以用来辅助我们读取文件。众所周知我们在 new 一个 Scanner 对象的时候会在括号中写 System.in 这样的字段这个 System.in 本质上就是一个 InputStream。4. 文件操作综合案例了解完上述关于文件操作的知识后我们再来看几个与文件操作相关的综合案例。4.1 查找硬盘上的文件位置第一个案例是查找硬盘上的文件位置也就是给定一个文件名去指定的目录中进行搜索。当找到文件名匹配的结果就打印出完整的路径。我们都知道文件系统的目录是树形结构既然是搜索那就会涉及到遍历。针对树的遍历一般都是要借助递归的。此处的递归要比二叉树更复杂一点目录中有几个子目录就递归几次。进行递归就是要进行树的遍历把目录中以及其中的子目录所有的文件都遍历一遍看看是否有符合要求的。这里只能叫做遍历谈不上前序中序后序。此处是 N 叉树并且每个节点上也有很多文件。除此之外我们还要注意访问操作出现的时机对于此处来说访问指的就是判定文件名这样的操作。接下来我们来看核心代码的实现为了方便测试代码的正确性我在我的电脑的某一个位置创建一个名为 test.txt 的文件然后运行代码输入该文件所在的根目录以及该文件名看看是否能找到。经过一敲回车可以看到是可以找到的相信大家都用过 Everything 这样的工具来进行查找文件像 Everything 这样的程序就需要遍历你的目录来获取到你的文件/目录结构信息遍历的过程就是类似于上述递归的过程。4.2 文件复制文件复制指的就是把一个文件复制一下成为另一个文件。代码实现原理也比较简单把第一个文件以读方式打开依次读取出这里的每个字节再把读到的内容写入到另一个文件里。接下来我们来看具体代码的实现这些针对合法性的判定逻辑在实际开发中非常重要不能随便省略。使用这个语法来编写代码的时候可以让( )里写多个对象多个之间使用 ; 来分隔开此处每个对象都会在 try 结束之后执行 close。上述是按照二进制的方式来复制的。因此可以复制任何文件不仅限于图片。4.3 按文件内容搜索文件第三个例子就是把上述两个例子结合一下。第一个例子是在目录中搜索名字匹配到的文件第二个例子是直接复制文件。而第三个例子还是在目录中搜索但是是按照文件内容的方式搜索。用户输入一个目录一个要搜索的词遍历文件的过程中如果文件包含了要搜索的词此时就把文件的路径打印出来。判定文件是否包含要搜索的词就需要把文件内容读取出来再在文件内容中进行查找。我们来看代码的具体实现上述代码中基于 indexOf 这样的方式进行文件内容的匹配执行速度其实是非常慢的。5. 小结通过本篇文章的学习我们对 Java 文件 IO 有了一个系统而全面的认识。首先我们从硬盘的物理结构和文件系统的基本概念出发理解了文件存储的底层逻辑以及路径、文件分类等核心概念。在此基础上我们深入学习了 Java 提供的两大 API 体系一是针对文件系统操作的 File 类掌握了文件的创建、删除、重命名、目录遍历等基础操作二是针对文件内容操作的流包括字节流InputStream 和 OutputStream和字符流Reader 和 Writer理解了流的抽象概念、缓冲区的意义以及资源关闭的重要性。最后我们通过查找文件位置、文件复制、按内容搜索文件三个综合案例将文件系统操作和文件内容操作串联起来实现了从理论到实践的转化。文件 IO 是 Java 开发中不可或缺的基础知识无论后续学习网络编程、数据库操作还是各类框架都会频繁用到这些技能。希望大家能够亲自动手敲一遍文中的示例代码在实践中加深对文件 IO 的理解为后续更深入的学习打下坚实的基础。