引言
I/O问题是我们无法绕开的话题,数据不可能只存在于本地内存中,必须要经过网络、磁盘等方式传输数据,也就是说数据需要流动。那么流动中就涉及I/O问题,而且可以说大部分系统的性能瓶颈都出现在I/O上面。学习和掌握I/O相关知识点对于开发人员来说是必须的,本文即是对I/O工作机制的一个讲解。笔者是Java程序员,所以就以Java中的I/O架构展开论述了。
Java中的I/O分类
java的I/O操作类基本都在java.io下,大概有近80个,主要分成如下4种:
-
基于字节操作的I/O接口:InputStream和OutputStream
-
基于字符操作的I/O接口:Writer和Reader
-
基于磁盘操作的I/O接口:File
-
基于网络操作的I/O接口:Socket
I/O操作只是人与机器或者机器与机器之间交互的手段,除了完整这个交互功能外,我们更应该关注的是如何提升它的交互效率。能做事是一方面,能做的又快又好往往才是引人注目的不是吗?
基于字节操作的I/O接口
在Java中,基于字节的I/O操作接口主要包括InputStream和OutputStream。这两个接口是Java I/O框架的核心,用于处理字节流,即以8位字节为单位的数据流。下面是一些常见的基于字节的I/O操作接口和类:
输入流(InputStream)
- InputStream:这是所有输入流的超类,定义了基本的读取方法。
- FileInputStream:用于从文件中读取字节。
- ByteArrayInputStream:用于从字节数组中读取字节。
- PipedInputStream:用于管道流的输入端。
- FilterInputStream:过滤输入流,可以添加额外的功能,如缓冲或数据转换。
- BufferedInputStream:提供带缓冲功能的输入流。
- DataInputStream:提供从输入流中读取基本数据类型的方法。
- ObjectInputStream:用于反序列化对象。
- SequenceInputStream:可以顺序地读取多个输入流。
- PushbackInputStream:允许将已读取的字节推回输入流。
输出流(OutputStream)
- OutputStream:这是所有输出流的超类,定义了基本的写入方法。
- FileOutputStream:用于向文件写入字节。
- ByteArrayOutputStream:用于向字节数组写入字节。
- PipedOutputStream:用于管道流的输出端。
- FilterOutputStream:过滤输出流,可以添加额外的功能,如缓冲或数据转换。
- BufferedOutputStream:提供带缓冲功能的输出流。
- DataOutputStream:提供向输出流写入基本数据类型的方法。
- ObjectOutputStream:用于序列化对象。
- PrintStream:提供打印功能的输出流,可以方便地输出各种数据类型。
使用这些基于字节的I/O操作接口时,通常遵循以下步骤:
-
创建适当的输入或输出流实例。
-
执行读取或写入操作。
-
关闭流,释放资源。
基于字符操作的I/O接口
不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,所以I/O操作都应该是字节而不是字符,但为啥还有操作字符的I/O接口呢?因为程序中通常操作的数据都是字符形式存在,为了方便当然需要一些操作字符的I/O接口了。
Java中基于字符操作的I/O接口主要围绕Reader和Writer接口展开,它们用于处理文本数据,即以16位Unicode字符为单位的数据流。这些接口和类专为处理字符数据设计,更适合文本文件的读写。下面是一些常用的基于字符的I/O操作接口和类:
输入流(Reader)
- Reader:这是所有字符输入流的基类,定义了读取字符的基本方法。
- FileReader:用于从文件中读取字符。
- StringReader:用于从字符串中读取字符。
- BufferedReader:提供缓冲功能的字符输入流,可以提高读取效率。
- CharArrayReader:用于从字符数组中读取字符。
- InputStreamReader:将字节输入流转换为字符输入流,可以指定字符集编码。
- LineNumberReader:扩展了BufferedReader,可以追踪当前行号。
输出流(Writer)
- Writer:这是所有字符输出流的基类,定义了写入字符的基本方法。
- FileWriter:用于向文件写入字符。
- StringWriter:用于向字符串写入字符。
- BufferedWriter:提供缓冲功能的字符输出流,可以提高写入效率。
- CharArrayWriter:用于向字符数组写入字符。
- OutputStreamWriter:将字符输出流转换为字节输出流,可以指定字符集编码。
- PrintWriter:提供打印功能的字符输出流,可以方便地输出各种数据类型。
特殊用途的Reader和Writer
- PushbackReader:允许将已读取的字符重新推回到输入流。
- PipedReader 和 PipedWriter:用于线程间通信的管道流。
字节和字符的转化接口
我们在内存中操作可能会是字符形式的,但是数据的持久化和网络传输都是字节形式的,所以要有这么一个转化工具。
InputStreamReader表示的是从InputStream到Reader的转换,注意转换过程要指定编码字符集,否则会采用操作系统默认的字符集。
package com.hulei.memcached;import java.io.*;public class InputStreamReaderExample {public static void main(String[] args) {try (InputStreamReader isr = new InputStreamReader(System.in);BufferedReader br = new BufferedReader(isr)) {String line;while ((line = br.readLine()) != null) {System.out.println(line);}} catch (IOException e) {throw new RuntimeException(e);}}
}
当从 System.in 从控制台读取数据时,首先得到的是字节流,然后通过 InputStreamReader 转换为字符流,最后由 BufferedReader 进行读取和处理。整个过程是从字节到字符的转换。
磁盘I/O工作机制
先介绍几种访问文件的方式:
-
标准访问文件的方式
当应用程序调用read()接口时,操作系统检查在内核的高速缓存中有无需要的数据,如果已经缓存了,则直接从缓存中返回,否则从磁盘中读取,然后缓存在操作系统的缓存中。
写入时,用户应用程序调用write()接口,把数据从用户地址空间复制到内核地址空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘由操作系统决定,除非显示调用了sync同步命令。 -
直接I/O的方式
用户的应用直接访问磁盘不经过操作系统内核数据缓冲区,这样做的目的是减少数据的一次拷贝。
-
同步访问文件的方式
这种方式很容易理解,数据的写入和读取同时同步操作,和标准访问模式不同点是需要等待文件写入磁盘并返回结果,性能较差,对一些数据安全性要求比较高的场景中才会使用。 -
异步访问文件的方式
写入文件时不需要阻塞等待返回结果可以干其他的事情,提高应用程序的性能。 -
内存映射方式
内存映射文件(Memory-Mapped File)是一种在操作系统中高效访问大文件的技术。它允许将一个文件或文件的一部分直接映射到进程的虚拟地址空间,这样文件的内容就可以像访问内存一样进行读写操作,而无需显式地调用读写系统调用。以下是内存映射文件访问的一些关键点:- 映射过程: 当文件被映射到内存后,操作系统会创建一个虚拟内存区域,并将文件的部分或全部内容与这个区域关联起来。这样,对虚拟内存区域的访问就会间接地访问到磁盘上的文件。
- 高效性: 使用内存映射文件可以显著提高文件访问的速度,因为操作系统可以利用页面缓存机制来优化数据的加载和存储。只有当虚拟内存中的页面被访问时,它们才会被实际加载到物理内存中,这被称为按需加载(demand paging)。
- 共享性: 内存映射文件支持多个进程共享同一个映射区域。这意味着多个进程可以同时访问同一个文件的不同部分,而无需每个进程都读取整个文件到内存中。
- 持久性: 对内存映射区域的修改会自动反映到磁盘上的文件中,除非特别指定为只读映射。这使得修改文件内容变得非常简单,因为不需要显式调用写入操作。
- 资源管理: 当不再需要映射时,可以解除映射,这会释放与文件关联的虚拟内存区域,但不会删除文件本身。
Java访问磁盘文件
数据在磁盘中的唯一最小描述就是文件。上层应用只能通过文件来操作磁盘上的数据。
Java中的File并不代表一个真实存在的文件对象,当我们指定一个路径描述符时,它就会返回一个代表这个路径的虚拟对象,可能是一个真实存在的文件,也可能是一个包含多个文件的目录。之所以这么设计是因为多数情况下,我们不关心文件是否存在,我们关心的是如何操作文件。
什么时候会检查一个文件是否存在?答案是在真正要读取这个文件的时候。例如FileInputStream类都是操作一个文件的接口,在创建FileInputStream这个对象时会创建一个FileDescriptor对象,其实这个对象就表示真正存在的文件对象的描述。我们在操作一个文件对象时可以通过getFD()方法获取真正操作系统的与底层操作系统相关联的文件描述。
Java序列化技术
Java的序列化是将Java对象转换为字节流的过程,以便将其存储在文件中或通过网络发送到另一个计算机上。反序列化则是将字节流转换回Java对象的过程。
Java的序列化机制提供了一种方便的方式来存储和传输对象,一些常见的使用情况包括:
-
将对象存储在磁盘上,以便在以后使用时恢复它们。
-
通过网络发送对象,以便在远程系统上使用它们。
-
在Java RMI(远程方法调用)中传递对象。
尽管Java序列化提供了许多优点,但也存在一些缺点。以下是其中的一些:-
序列化的性能并不高,因为它需要将对象转换为字节流并进行网络传输或写入磁盘。
-
序列化的格式可能不是公开的,这意味着它在不同版本之间可能不兼容。
-
序列化可能存在安全问题,因为序列化可以为攻击者提供一种方式来执行恶意代码。
-
因此,在使用Java序列化时,需要注意一些潜在的问题,并根据具体需求进行权衡和选择。在纯Java环境下,Java序列化能够很好的工作,但是在多语言环境下,使用Java序列化存储后很难用其他语言还原出来。一般当我们需要把对象进行网络传输时最好还是使用一些通用的存储结构比如JSON或者XML结构数据。
网络I/O接口
Java中的网络I/O(输入/输出)主要涉及与网络设备(如网卡)交互,以发送和接收数据。Java提供了多种API来处理网络I/O,包括传统的阻塞I/O(BIO)、非阻塞I/O(NIO)以及异步I/O(AIO)。下面是这些I/O模型的基本概述:
- 阻塞I/O(BIO)
阻塞I/O是最传统的网络I/O模型,它基于java.io包下的InputStream和OutputStream。在BIO模型中,当一个线程发起I/O操作(如读取或写入数据)时,该线程将被阻塞,直到操作完成。这意味着线程在此期间不能执行其他任务。
- 优点:
实现简单,易于理解和使用。 - 缺点:
阻塞性质限制了并发能力,每个连接都需要一个独立的线程来处理,当连接数增加时,系统资源消耗大,容易达到瓶颈。
- 非阻塞I/O(NIO)
NIO(Non-blocking I/O)是Java SE 1.4引入的,它使用java.nio包下的类,如Buffer、Channel和Selector。NIO允许I/O操作是非阻塞的,即线程可以发起I/O操作并立即返回,然后继续执行其他任务,而不需要等待I/O操作完成。
- 优点:
提高了并发能力,可以处理更多的连接,因为一个线程可以管理多个连接。
减少了系统资源的消耗,因为不需要为每个连接分配一个线程。 - 缺点:
相对于BIO,NIO的编程模型更加复杂,需要处理事件和回调,增加了开发难度。
NIO的性能优势在高并发场景下更为明显,对于低并发或简单的应用,BIO可能已经足够。
- 异步I/O(AIO)
AIO(Asynchronous I/O)是Java 7引入的,它基于java.nio.channels包下的AsynchronousServerSocketChannel和AsynchronousSocketChannel。AIO是真正的异步I/O,它允许线程发起I/O操作后立即返回,而I/O操作在后台线程池中完成,完成后通过回调通知发起者。
- 优点:
最高的并发能力,因为I/O操作完全异步,线程无需等待I/O操作完成。
减少了线程的开销,因为不需要为每个连接分配线程。 - 缺点:
实现复杂,编程模型比NIO更难理解。
AIO在某些JVM实现中可能没有得到很好的优化,实际性能提升可能有限。
总结
选择哪种I/O模型取决于应用程序的具体需求,包括预期的并发级别、性能要求以及开发者的技能和经验。在现代高并发网络应用中,NIO和AIO通常是更优的选择,但它们也带来了额外的复杂性。在较低并发或对性能要求不高的场景下,BIO可能仍然是一个简单有效的选择。