Java I/O
BIO详解
BIO就是: blocking IO。最容易理解、最容易实现的IO工作方式,应用程序向操作系统请求网络IO操作,这时应用程序会一直等待;另一方面,操作系统收到请求后,也会等待,直到网络上有数据传到监听端口;操作系统在收集数据后,会把数据发送给应用程序;最后应用程序受到数据,并解除等待状态。
重要概念
阻塞IO
和非阻塞IO
这两个概念是程序级别
的。主要描述的是程序请求操作系统IO操作后,如果IO资源没有准备好,那么程序该如何处理的问题: 前者等待;后者继续执行(并且使用线程一直轮询,直到有IO资源准备好了)
同步IO
和非同步IO
这两个概念是操作系统级别
的。主要描述的是操作系统在收到程序请求IO操作后,如果IO资源没有准备好,该如何响应程序的问题: 前者不响应,直到IO资源准备好以后;后者返回一个标记(好让程序和自己知道以后的数据往哪里通知),当IO资源准备好以后,再用事件机制返回给程序。
传统的BIO通信方式简介
以前大多数网络通信方式都是阻塞模式的,即:
- 客户端向服务器端发出请求后,客户端会一直等待(不会再做其他事情),直到服务器端返回结果或者网络出现问题。
- 服务器端同样的,当在处理某个客户端A发来的请求时,另一个客户端B发来的请求会等待,直到服务器端的这个处理线程完成上一个处理。
传统的BIO的问题
同一时间,服务器只能接受来自于客户端A的请求信息;虽然客户端A和客户端B的请求是同时进行的,但客户端B发送的请求信息只能等到服务器接受完A的请求数据后,才能被接受。
由于服务器一次只能处理一个客户端请求,当处理完成并返回后(或者异常时),才能进行第二次请求的处理。很显然,这样的处理方式在高并发的情况下,是不能采用的。
多线程方式-伪异步方式
上面说的情况是服务器只有一个线程的情况,那么读者会直接提出我们可以使用多线程技术来解决这个问题:
- 当服务器收到客户端X的请求后,(读取到所有请求数据后)将这个请求送入一个独立线程进行处理,然后主线程继续接受客户端Y的请求。
- 客户端一侧,也可以使用一个子线程和服务器端进行通信。这样客户端主线程的其他工作就不受影响了,当服务器端有响应信息的时候再由这个子线程通过 监听模式/观察模式(等其他设计模式)通知主线程。
但是使用线程来解决这个问题实际上是有局限性的:
- 虽然在服务器端,请求的处理交给了一个独立线程进行,但是操作系统通知accept()的方式还是单个的。也就是,实际上是服务器接收到数据报文后的“业务处理过程”可以多线程,但是数据报文的接受还是需要一个一个的来(下文的示例代码和debug过程我们可以明确看到这一点)
- 在linux系统中,可以创建的线程是有限的。我们可以通过cat /proc/sys/kernel/threads-max 命令查看可以创建的最大线程数。当然这个值是可以更改的,但是线程越多,CPU切换所需的时间也就越长,用来处理真正业务的需求也就越少。
- 创建一个线程是有较大的资源消耗的。JVM创建一个线程的时候,即使这个线程不做任何的工作,JVM都会分配一个堆栈空间。这个空间的大小默认为128K,您可以通过-Xss参数进行调整。当然您还可以使用ThreadPoolExecutor线程池来缓解线程的创建问题,但是又会造成BlockingQueue积压任务的持续增加,同样消耗了大量资源。
- 另外,如果您的应用程序大量使用长连接的话,线程是不会关闭的。这样系统资源的消耗更容易失控。 那么,如果你真想单纯使用线程解决阻塞的问题,那么您自己都可以算出来您一个服务器节点可以一次接受多大的并发了。看来,单纯使用线程解决这个问题不是最好的办法。
BIO通信方式深入分析
BIO虽然可以使用多线程来进行伪异步,但是说到底,操作系统
级别的accept()
和read()
操作都是阻塞的;
就算服务端接受信息使用多线程:
也避免不了BIO在操作系统级别上的问题
问题根源
重点在于,为什么accept和read方法会被阻塞。
异步I/O就是为了避免这样的并发问题存在的
- 注意,是询问操作系统。也就是说socket套接字的IO模式支持是基于操作系统的,那么自然同步IO/异步IO的支持就是需要操作系统级别的了。如下图:
NIO详解
新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的,弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。
Standard IO是对字节流的读写,在进行IO之前,首先创建一个流对象,流对象进行读写操作都是按字节 ,一个字节一个字节的来读或写。而NIO把IO抽象成块,类似磁盘的读写,每次IO操作的单位都是一个块,块被读入内存之后就是一个byte[],NIO一次可以读或写多个字节。
流与块
I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
-
面向流的 I/O: 一次处理一个字节数据: 一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。
-
面向块的 I/O: 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
I/O 包和 NIO 已经很好地集成了,java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快
通道与缓冲区
通道
通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。
通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。
通道包括以下类型:
FileChannel
: 从文件中读写数据;DatagramChannel
: 通过 UDP 读写网络中数据;SocketChannel
: 通过 TCP 读写网络中数据;ServerSocketChannel
: 可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。
缓冲区
发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。
缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
缓冲区包括以下类型:
ByteBuffer
:用于存储字节数据。它是最通用的缓冲区类型,可以用于读取和写入各种基本数据类型CharBuffer
:用于存储字符数据,处理文本数据ShortBuffer
:用于存储短整型数据(16位)IntBuffer
:用于存储整型数据(32位)LongBuffer
:用于存储长整型(64位)FloatBuffer
:用于存储浮点型数据(32位)DoubleBuffer
:用于存储双精度浮点型数据(64位)
缓冲区状态变量
capacity
:最大容量
position
:当前已经读写的字节数
limit
:还可以读写的字节数
文件NIO示例
public static void fastCopy(String src, String dist) throws IOException {
/* 获得源文件的输入字节流 */
FileInputStream fin = new FileInputStream(src);
/* 获取输入字节流的文件通道 */
FileChannel fcin = fin.getChannel();
/* 获取目标文件的输出字节流 */
FileOutputStream fout = new FileOutputStream(dist);
/* 获取输出字节流的通道 */
FileChannel fcout = fout.getChannel();
/* 为缓冲区分配 1024 个字节 */
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while (true) {
/* 从输入通道中读取数据到缓冲区中 */
int r = fcin.read(buffer);
/* read() 返回 -1 表示 EOF */
if (r == -1) {
break;
}
/* 切换读写 */
buffer.flip();
/* 把缓冲区的内容写入输出文件中 */
fcout.write(buffer);
/* 清空缓冲区 */
buffer.clear();
}
}
选择器
NIO的核心组件之一是选择器。选择器允许单个线程管理多个通道,并监视这些通道的IO事件(如可读、可写、连接就绪等)。当某个通道准备好进行IO操作时,选择器会通知线程,线程可以立即进行IO操作,而不需要等待。
应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。
确实,只有套接字通道(Socket Channel)才能配置为非阻塞模式,而文件通道(FileChannel)不能,这是因为它们的底层实现和使用场景不同。
- 套接字通道(Socket Channel):
- 网络通信:套接字通道用于网络通信,如TCP和UDP通信。网络通信的特点是数据传输可能会有延迟,因为数据需要通过网络传输,可能会受到网络拥塞、延迟等因素的影响。
- 非阻塞模式:在非阻塞模式下,当线程请求IO操作时,如果数据还没有准备好,通道不会阻塞线程,而是立即返回一个状态码或空值,线程可以继续执行其他任务。这对于提高系统的并发处理能力和响应能力非常重要。
- 选择器(Selector):套接字通道可以注册到选择器上,选择器可以监视多个通道的IO事件,当某个通道准备好进行IO操作时,选择器会通知线程,线程可以立即进行IO操作,而不需要等待。
- 文件通道(FileChannel):
- 本地文件操作:文件通道用于本地文件的读写操作。本地文件操作通常是直接在磁盘上进行的,磁盘的读写速度相对较快,且通常不会出现网络通信中的延迟问题。
- 阻塞模式:文件通道通常工作在阻塞模式下。在阻塞模式下,当线程请求IO操作时,如果数据还没有准备好,线程会等待,直到数据准备好。由于本地文件操作的速度较快,阻塞的时间通常很短,因此阻塞模式对于文件操作来说是可以接受的。
- 非阻塞模式的无意义:对于文件通道来说,配置为非阻塞模式并没有实际意义。因为文件操作通常是顺序进行的,且速度较快,不需要通过非阻塞模式来提高并发处理能力。此外,文件通道不支持注册到选择器上,因此无法利用选择器的多路复用机制。
综上所述,只有套接字通道才能配置为非阻塞模式,而文件通道不能,这是因为它们的底层实现和使用场景不同。套接字通道用于网络通信,需要非阻塞模式来提高并发处理能力和响应能力,而文件通道用于本地文件操作,阻塞模式已经足够满足需求。
创建选择器
Selector selector = Selector.open();
将通道注册到选择器上
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。
在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:
SelectionKey.OP_CONNECT
SelectionKey.OP_ACCEPT
SelectionKey.OP_READ
SelectionKey.OP_WRITE
SelectionKey.OP_CONNECT
:
- 用途:用于表示通道已经准备好连接到远程服务器。
- 适用通道:
SocketChannel
。- 场景:当客户端尝试连接到服务器时,如果连接成功,通道会准备好进行连接操作,选择器会通知注册的键,表示可以进行连接操作。
SelectionKey.OP_ACCEPT
:
- 用途:用于表示服务器通道已经准备好接受一个新的连接。
- 适用通道:
ServerSocketChannel
。- 场景:当服务器监听到一个新的连接请求时,服务器通道会准备好接受这个连接,选择器会通知注册的键,表示可以进行接受操作。
SelectionKey.OP_READ
:
- 用途:用于表示通道已经准备好读取数据。
- 适用通道:
SocketChannel
、ServerSocketChannel
、DatagramChannel
等。- 场景:当通道中有数据可读时,选择器会通知注册的键,表示可以进行读取操作。
SelectionKey.OP_WRITE
:
- 用途:用于表示通道已经准备好写入数据。
- 适用通道:
SocketChannel
、ServerSocketChannel
、DatagramChannel
等。- 场景:当通道可以写入数据时,选择器会通知注册的键,表示可以进行写入操作。通常在缓冲区有可用空间时触发。
这些操作集常量可以组合使用,通过位或(
|
)操作符来表示通道对多个事件感兴趣。例如:int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
这样,通道就会对读取和写入事件都感兴趣,选择器会在这些事件发生时通知注册的键。
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;
监听事件
int num = selector.select();
使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。
获取到达的事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
NIO示例
package com.xiwen.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NIOServer {
public static void main(String[] args) throws IOException {
// 创建一个 Selector
Selector selector = Selector.open();
// 创建一个 ServerSocketChannel,并设置为非阻塞模式
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
// 将 ServerSocketChannel 注册到 Selector 上,监听 ACCEPT 事件
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
// 绑定端口
ServerSocket serverSocket = ssChannel.socket();
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
serverSocket.bind(address);
// 轮询 Selector
while (true) {
// 等待有事件发生
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
// 处理事件
while (keyIterator.hasNext()) {
// 获取事件对应的 SelectionKey
SelectionKey key = keyIterator.next();
// 判断事件类型
// 如果是 ACCEPT 事件,说明有新连接到来
if (key.isAcceptable()) {
ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();
// 服务器会为每个新连接创建一个 SocketChannel
SocketChannel sChannel = ssChannel1.accept();
sChannel.configureBlocking(false);
// 这个新连接主要用于从客户端读取数据
sChannel.register(selector, SelectionKey.OP_READ);
}// 如果是 READ 事件,说明客户端有数据可读
else if (key.isReadable()) {
// 读取数据并打印
SocketChannel sChannel = (SocketChannel) key.channel();
System.out.println(readDataFromSocketChannel(sChannel));
sChannel.close();
}
keyIterator.remove();
}
}
}
// 读取SocketChannel中的数据
private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder data = new StringBuilder();
while (true) {
buffer.clear();
// 一次读取1024字节
int n = sChannel.read(buffer);
// 如果读到结尾,则退出循环
if (n == -1) {
break;
}
// buffer.flip()的作用是将buffer从写模式切换到读模式,并将limit设置为当前position,position设置为0
buffer.flip();
int limit = buffer.limit();
char[] dst = new char[limit];
for (int i = 0; i < limit; i++) {
dst[i] = (char) buffer.get(i);
}
data.append(dst);
// 将position设置为0,limit设置为capacity,准备接受下一次读入
buffer.clear();
}
return data.toString();
}
}
内存映射文件
内存映射文件 I/O 是一种读和写文件数据的方法,比常规的基于流或者基于通道的 I/O 快得多。
内存映射文件(Memory-Mapped Files)是一种将文件的内容映射到进程的地址空间的技术。通过内存映射文件,操作系统可以将文件的全部或部分内容直接映射到内存中,使得应用程序可以直接通过内存访问文件内容,而不需要通过传统的文件I/O操作。
内存映射文件的主要优点包括:
- 提高I/O性能:内存映射文件可以显著提高文件I/O的性能,特别是在处理大文件时。由于文件内容直接映射到内存中,应用程序可以直接通过内存访问文件内容,避免了频繁的磁盘I/O操作。
- 简化编程模型:内存映射文件提供了一种更直观的编程模型,使得文件操作类似于内存操作。应用程序可以直接使用指针或数组来访问文件内容,而不需要显式的读写操作。
- 支持共享内存:内存映射文件可以用于在多个进程之间共享内存。多个进程可以将同一个文件映射到各自的地址空间中,从而实现进程间的数据共享。
向内存映射文件写入可能是危险的,只是改变数组的单个元素这样的简单操作,就可能会直接修改磁盘上的文件。修改数据与将数据保存到磁盘是没有分开的。
package com.xiwen.nio;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public class neicun {
public static void main(String[] args) {
try (RandomAccessFile file = new RandomAccessFile("D:\\Code\\Java\\vscode\\nettytest\\src\\test\\java\\com\\xiwen\\nio\\test.txt", "rw");) {
FileChannel fch = file.getChannel();
MappedByteBuffer mbb = fch.map(FileChannel.MapMode.READ_WRITE, 0, file.length());
// 使用 UTF-8 编码读取数据
Charset charset = StandardCharsets.UTF_8;
byte[] buffer = new byte[1024];
StringBuilder sb = new StringBuilder();
while (mbb.hasRemaining()) {
int length = Math.min(buffer.length, mbb.remaining());
mbb.get(buffer, 0, length);
sb.append(new String(buffer, 0, length, charset));
}
System.out.println(sb.toString());
mbb.clear();
// put() 方法向缓冲区中写入数据
// 该数据将会被写入到文件中
mbb.put("你好".getBytes(charset));
// 强制将缓冲区内容写入文件
mbb.force();
} catch (Exception e) {
e.printStackTrace();
}
}
}
MappedByteBuffer
类
MappedByteBuffer
是 Java NIO 中的一个类,它允许将文件的一部分或全部直接映射到内存中,从而实现高效的文件读写操作。以下是 MappedByteBuffer
的基本使用方法:
- 创建
MappedByteBuffer
要创建 MappedByteBuffer
,首先需要通过 FileChannel
打开一个文件,然后调用 map
方法进行内存映射。
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
public class MappedByteBufferExample {
public static void main(String[] args) {
try (RandomAccessFile file = new RandomAccessFile("example.txt", "rw");
FileChannel channel = file.getChannel()) {
// 将文件的全部内容映射到内存中
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, file.length());
// 进行读写操作
// ...
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 读取数据
使用 MappedByteBuffer
读取数据非常简单,可以直接从缓冲区中读取字节。
// 读取数据
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
- 强制写入磁盘
// 强制将缓冲区内容写入磁盘
buffer.force();
- 强制写入磁盘
为了确保数据被写入磁盘,可以使用 force
方法。
// 强制将缓冲区内容写入磁盘
buffer.force();
- 清空缓冲区
如果需要清空缓冲区,可以使用 clear
方法。
// 清空缓冲区
buffer.clear();