一、NIO介绍
NIO (New lO)也有人称之为java non-blocking lO是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java lO API。
NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区
的、基于通道的IO操作。NIO将以更加高效
的方式进行文件的读写操作
。
NIO可以理解为非阻塞IO
,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式。
NIO相关类都被放在java.nio
包及子包下,并且对原java.io
包中的很多类进行改写。
NIO有三大核心部分: Buffer(缓冲区),Channel(通道),Selector(选择器)。
二、Buffer(缓冲区)
- 缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存
- 这块内存被包装成
NIO Buffer对象
,并提供了一组方法,用来方便的访问该块内存 - 缓冲区主要用于
与NIO通道进行交互
,数据是从通道读入缓冲区,从缓冲区写入通道中 - 所有缓冲区都是Buffer抽象类的子类.
1、常见Buffer子类
- ByteBuffer:用于存储字节数据(最常用)
- ShortBuffer:用于存储Short类型数据
- IntBuffer:用于存储Int类型数据
- LongBuffer:用于存储Long类型数据
- FloatBuffer:用于存储Float类型数据
- DoubleBuffer:用于存储Double类型数据
- CharBuffer:用于存储字符数据
ByteBuffer最常用,ByteBuffer三个子类的类图如下
1.1、HeapByteBuffer
- 存储内存是在
JVM堆
中分配 - 在堆中分配一个
数组
用来存放 Buffer 中的数据
public abstract class ByteBuffer
extends Buffer
implements Comparable<ByteBuffer>
{
//在堆中使用一个数组存放Buffer数据
final byte[] hb;
...
}
- 通过
allocate()
方法进行分配,在jvm堆上申请堆上内存 - 如果要做IO操作,会先从本进程的堆上内存复制到系统内存,再利用本地IO处理
- 读写效率较低,受到 GC 的影响
ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024);
1.2、DirectByteBuffer
- DirectBuffer 背后的存储内存是在
堆外内存(操作系统内存)
中分配,jvm内存只保留堆外内存地址
public abstract class Buffer {
//堆外内存地址
long address;
...
}
- 通过
allocateDirect()
方法进行分配,直接从系统内存中申请 - 如果要作IO操作,直接从系统内存中利用本地IO处理
- 使用直接内存会具有更高的效率,但是它比申请普通的堆内存需要耗费更高的性能
- 读写效率高(少一次拷贝),不会受 GC 影响,分配的效率低
ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(1024);
2、Buffer结构
ByteBuffer 有以下重要属性
- 容量 (capacity) :作为一个内存块,Buffer具有一定的固定大小, 也称为"容量"
- 缓冲区容量不能为负,并且创建后
不能更改
- 缓冲区容量不能为负,并且创建后
- 限制 (limit):表示缓冲区中
可以操作数据的大小
(limit 后数据不能进行读写)- 缓冲区的限制不能为负,并且不能大于其容量
- 写入模式,限制等于 buffer的容量
- 读取模式下,limit等于写入的数据量
- 位置 (position):
下一个
要读取或写入的数据的索引- 缓冲区的位置不能为负,并且不能大于其限制
ByteBuffer写入和读取原理
@Test
public void simpleTest() {
// 1. 分配一个指定大小的缓冲区
ByteBuffer buf = ByteBuffer.allocate(10);
// 2. 利用put()存入数据到缓冲区中
buf.put("data".getBytes());
// 3. 切换读取数据模式
buf.flip();
// 判断缓冲区中是否还有元素
while (buf.hasRemaining()) {
// 4. 利用 get()读取单个字节
byte b = buf.get();
System.out.println("实际字节 " + (char) b);
}
// 清空缓冲区
buf.clear();
}
输出结果:
实际字节 d
实际字节 a
实际字节 t
实际字节 a
- 创建容量为10的ByteBuffer
- 写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态
- flip 动作发生后,position 切换为读取位置,limit 切换为读取限制
- 读取 4 个字节后,状态如下
- clear 动作发生后,状态如下,然后切换至写模式
特别说明:compact方法,是把未读完的部分向前压缩,然后切换至写模式
3、常见方法
位置相关
int capacity()
:返回 Buffer 的 capacity 大小int limit()
:返回 Buffer 的界限(limit) 的位置int position()
:返回缓冲区的当前位置 position- int remaining() :返回 position 和 limit 之间的元素个数
@Test
public void test1() {
// 分配一个指定大小的缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
System.out.println(buf.position());// 0: 表示当前的位置为0
System.out.println(buf.limit());// 1024: 表示界限为1024,前1024个位置是允许我们读写的
System.out.println(buf.capacity());// 1024:表示容量大小为1024
System.out.println(buf.remaining());// 1024:表示position和limit之间元素个数
}
读写相关
put(byte b)
:将给定单个字节写入缓冲区的当前位置put(byte[] src)
:将 src 中的字节写入缓冲区的当前位置- put(int index, byte b):将指定字节写入缓冲区的索引 位置(
不会移动 position
) - boolean hasRemaining(): 判断缓冲区中是否还有元素
get()
:读取单个字节get(byte[] dst)
:批量读取多个字节到 dst 中- get(int index):读取指定索引位置的字节(
不会移动 position
)
@Test
public void test2() {
ByteBuffer buf = ByteBuffer.allocate(10);
// 默认写模式,写入数据
buf.put("abcde".getBytes());
System.out.println(buf.position());// 5: 当前位置5,表示下一个写入的位置是5
System.out.println(buf.limit());// 10: 表示界限为10,前10个位置是允许写入的
// 切换为读模式
buf.flip();
System.out.println(buf.position());// 0: 从0位置开始读取数据
System.out.println(buf.limit());// 5: 表示界限为5,前5个位置是允许读取的
// 读取两个字节
byte[] dst = new byte[2];
buf.get(dst);
System.out.println(new String(dst, 0, 2)); // 输出:ab
System.out.println(buf.position());// 2: 从2位置开始读取数据,因为0,1已经读取
System.out.println(buf.limit());// 5: 表示界限为5,前5个位置是允许读取的
// 根据索引读取,position不会移动
byte b = buf.get(3);
System.out.println((char) b); // 输出:d
System.out.println(buf.position());// 2: 依然是2,没有移动
}
切换模式相关
Buffer flip()
:将缓冲区的界限设置为当前位置, 并将当前位置重置为0(切换为读模式)Buffer clear()
:清空缓冲区(切换为写模式)Buffer compact()
:向前压缩未读取部分(切换为写模式)
@Test
public void test3() {
ByteBuffer buf = ByteBuffer.allocate(10);
// 默认写模式,写入数据
buf.put("hello".getBytes());
// 切换为读模式
buf.flip();
// 读取两个字节
byte[] dst = new byte[2];
buf.get(dst);
System.out.println(buf.position());// 2: 当前位置2,前两个位置已经读取,读取下一个位置是2
System.out.println(buf.limit());// 5: 表示界限为5,前5个位置是允许读取的
// 向前压缩未读取,并切换为写模式
buf.compact();
System.out.println(buf.position());// 3: 当前位置3,因为之前有两个位置没有被读取,放到了最前面,写入的下一个位置是3
System.out.println(buf.limit());// 10: 表示界限为10,前10个位置是允许写入的
}
修改Buffer相关
- Buffer limit(int n):设置缓冲区界限为 n,并返回修改后的 Buffer 对象
- Buffer position(int n) :设置缓冲区的当前位置为 n, 并返回修改后的 Buffer 对象
标记相关
- Buffer mark(): 对缓冲区设置标记
- Buffer reset() :将位置 position 转到以前设置的mark 所在的位置
- Buffer rewind() :将位置设为为0, 取消设置的mark
@Test
public void test4() {
ByteBuffer buf = ByteBuffer.allocate(10);
// 默认写模式,写入数据
buf.put("hello".getBytes());
// 切换为读模式
buf.flip();
// 读取两个字节
System.out.println((char) buf.get());
buf.mark();
System.out.println((char) buf.get());
System.out.println((char) buf.get());
buf.reset();
System.out.println((char) buf.get());
System.out.println((char) buf.get());
System.out.println((char) buf.get());
System.out.println((char) buf.get());
// hello读完再读,抛异常java.nio.BufferUnderflowException
// System.out.println((char) buf.get());
}
输出:
h
e
l
e
l
l
o
总结Buffer读写数据四个步骤
- 写入数据到Buffer
- 调用flip()方法,转换为读取模式
- 从Buffer中读取数据
- 调用buffer.clear()方法或者buffer.compact()方法清除缓冲区并转换为写入模式
4、字符串与ByteBuffer互转
public class TestByteBufferString {
public static void main(String[] args) {
// 字符串转为ByteBuffer
// 方式一:put
ByteBuffer buffer1 = ByteBuffer.allocate(16);
buffer1.put("hello".getBytes());
// 方式二:Charset
ByteBuffer buffer2 = StandardCharsets.UTF_8.encode("hello");
// 方式三:wrap
ByteBuffer buffer3 = ByteBuffer.wrap("hello".getBytes());
// ByteBuffer转为字符串
// 方式一:Charset
String str1 = StandardCharsets.UTF_8.decode(buffer1).toString();
// 方式二:String
String str2 = new String(buffer2.array(), 0, buffer2.limit());
}
}
三、Channel(通道)
传统流是单向
的,只能读或者写,而NIO中的Channel(通道)是双向
的,可以读操作,也可以写操作。
1、常见Channel实现类
- FileChannel:用于读取、写入、映射和操作文件的通道
- DatagramChannel:通过UDP读写网络中的数据通道
- ServerSocketChannel和SocketChannel:通过TCP读写网络中的数据的通道
- 类似于Socke和ServerSocket(阻塞IO),不同的是前者可以设置为非阻塞模式
2、FileChannel(文件通道)
- FileChannel只能工作在
阻塞
模式下
2.1、常用方法
获取FileChannel
不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有getChannel
方法。
- 通过
FileInputStream
获取的 channel 只能读 - 通过
FileOutputStream
获取的 channel 只能写 - 通过
RandomAccessFile
是否能读写根据构造RandomAccessFile时的读写模式决定
// 只能读
FileChannel channel1 = new FileInputStream("hello.txt").getChannel();
// 只能写
FileChannel channel2 = new FileOutputStream("hello.txt").getChannel();
// 以只读方式打开指定文件
FileChannel channel3 = new RandomAccessFile("hello.txt", "r").getChannel();
// 以读、写方式打开指定文件。如果该文件尚不存在,则尝试创建该文件
FileChannel channel4 = new RandomAccessFile("hello.txt", "rw").getChannel();
读取数据
int read(ByteBuffer dst)
:从Channel到中读取数据到ByteBuffer,返回值表示读到的字节数量
,-1
表示到达了文件的末尾
- long read(ByteBuffer[] dsts): 将Channel中的数据“分散”到ByteBuffer数组中
@Test
public void testRead() throws IOException {
// 获取只读文件通道
FileChannel channel = new RandomAccessFile("hello.txt", "r").getChannel();
// 创建字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
// 循环读取通道中的数据,并写入到 buf 中
while (channel.read(buf) != -1) {
// 缓存区切换到读模式
buf.flip();
// 读取 buf 中的数据
while (buf.position() < buf.limit()) {
// 将buf中的数据追加到文件中
System.out.println((char) buf.get());
}
// 清空已经读取完成的 buffer,以便后续使用
buf.clear();
}
// 关闭通道
channel.close();
}
写入数据
int write(ByteBuffer src)
:将ByteBuffer中的数据写入到Channel- long write(ByteBuffer[] srcs):将ByteBuffer数组中的数据“聚集”到 Channel
@Test
public void testRead() throws IOException {
// 获取写文件通道
FileChannel channel = new FileOutputStream("hello.txt").getChannel();
// 将ByteBuffer数据写到通道
channel.write(ByteBuffer.wrap("abc".getBytes()));
// 强制将数据刷出到物理磁盘
channel.force(false);
// 关闭通道
channel.close();
}
其他
long position()
:返回此通道的文件位置long size()
:返回此通道的文件的当前大小void force(boolean metaData)
:强制将所有对此通道的文件更新写入到存储设备中- FileChannel position(long p) :设置此通道的文件位置
- FileChannel truncate(long s) :将此通道的文件截取为给定大小
@Test
public void testOther() throws IOException {
// 获取写文件通道
FileChannel channel = new FileOutputStream("hello.txt").getChannel();
System.out.println(channel.position());// 0:当前位置为0,表示下次写入的位置为0
System.out.println(channel.size());// 0:文件大小为0
// 写入3个字符到 hello.txt 文件中
channel.write(ByteBuffer.wrap(("abc").getBytes()));
System.out.println(channel.position());// 3:当前位置为3,表示下次写入的位置为3
System.out.println(channel.size());// 3:文件大小为3,因为写入3个字符
channel.position(5);// 设置当前位置为5,表示下次写入的位置为5
// 再写入123,此时会跳过索引3和4,写入索引5
channel.write(ByteBuffer.wrap(("123").getBytes()));
// 将数据刷出到物理磁盘
channel.force(false);
// 关闭通道
channel.close();
}
输出结果:索引3和4的位置为空,这是应该特殊字符吧
2.2、复制(transferTo/transferFrom)
- 两个方式都能实现复制的功能
/**
* 方法一(目标文件调用者)
*/
@Test
public void transferFrom() throws Exception {
// 1、字节输入管道
FileInputStream is = new FileInputStream("hello.txt"); // 源文件输入流
FileChannel fromChannel = is.getChannel();
// 2、字节输出流管道
FileOutputStream fos = new FileOutputStream("hello的副本.txt"); // 目标文件输出流
FileChannel toChannel = fos.getChannel();
// 3、复制
toChannel.transferFrom(fromChannel, fromChannel.position(), fromChannel.size());
fromChannel.close();
toChannel.close();
}
/**
* 方法二(资源文件调用者)
*/
@Test
public void transferTo() throws Exception {
// 1、字节输入管道
FileInputStream is = new FileInputStream("hello.txt"); // 源文件输入流
FileChannel fromChannel = is.getChannel();
// 2、字节输出流管道
FileOutputStream fos = new FileOutputStream("hello的副本.txt"); // 目标文件输出流
FileChannel toChannel = fos.getChannel();
// 3、复制
fromChannel.transferTo(fromChannel.position(), fromChannel.size(), toChannel);
fromChannel.close();
toChannel.close();
}
- 超过
2g
大小的文件传输(因为超过2g,多出的部分会丢失) - 循环复制,每次30MB(
FileUtils.copyFile(final File srcFile, final File destFile)
方法的内部实现)
@Test
public void transferFromBig() throws IOException {
// 使用try-with-resources语句确保流在使用完毕后被正确关闭
try (FileInputStream fis = new FileInputStream("hello.txt"); // 源文件输入流
FileChannel input = fis.getChannel(); // 获取源文件的文件通道
FileOutputStream fos = new FileOutputStream("hello的副本.txt"); // 目标文件输出流
FileChannel output = fos.getChannel()) { // 获取目标文件的文件通道
final long size = input.size(); // 获取源文件的大小
long pos = 0;
long count;
// 循环读取源文件内容,直到全部复制完毕
while (pos < size) {
// 计算剩余待复制的字节数
final long remain = size - pos;
// 根据剩余字节数决定本次要复制的字节数,最多30MB
count = remain > 1024 * 1024 * 30 ? 1024 * 1024 * 30 : remain;
// 从源文件通道复制数据到目标文件通道
final long bytesCopied = output.transferFrom(input, pos, count);
if (bytesCopied == 0) {
// 如果没有复制任何数据,跳出循环
break;
}
// 更新已复制的字节位置
pos += bytesCopied;
}
}
}
3、ServerSocketChannel和SocketChannel(TCP网络通道)
3.1、阻塞模式
- 阻塞模式下,相关方法都会导致线程暂停
- ServerSocketChannel.accept 会在没有连接建立时让线程暂停
- SocketChannel.read 会在没有数据可读时让线程暂停
- 阻塞的表现其实就是线程暂停,暂停期间不会占用cpu,线程相当于闲置什么也不能做
- 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
- 但多线程下,有新的问题,体现在以下方面
- 32位jvm一个线程320k,64位jvm一个线程1024k,如果
连接数过多
,必然导致OOM
,并且线程太多,反而会因为频繁上下文切换
导致性能降低 - 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间inactive,会阻塞线程池中所有线程,因此
不适合长连接,只适合短连接
- 32位jvm一个线程320k,64位jvm一个线程1024k,如果
服务端
- 默认情况与Socke和ServerSocket一样,是阻塞IO,accept和read为阻塞方法
- 当没有客户端连接时,线程会阻塞在
accept()
方法,等待客户端的连接 - 当客户端连接,当没有发送数据时,线程会阻塞在
read()
方法,等待客户端的发送
@Test
public void server() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建一个ServerSocketChannel通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 2. 绑定监听端口
serverSocketChannel.bind(new InetSocketAddress(8080));
// 3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
log.debug("connecting...");
SocketChannel sc = serverSocketChannel.accept(); // 阻塞方法,线程停止运行
log.debug("connected... {}", sc);
channels.add(sc);
// 遍历连接集合
for (SocketChannel channel : channels) {
// 5. 接收客户端发送的数据
log.debug("before read... {}", channel);
channel.read(buffer); // 阻塞方法,线程停止运行,等待客户端发消息读取
buffer.flip(); // 转为读模式
// 打印出响应信息
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear(); // 清空缓冲区,转为写模式
log.debug("after read...{}", channel);
}
}
}
客户端
@Test
public void client() throws IOException {
// 创建一个SocketChannel通道,并连接到本地的8080端口
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8080));
socketChannel.write(ByteBuffer.wrap("a".getBytes()));
System.in.read();
}
3.2、非阻塞模式
- 非阻塞模式下,相关方法都会不会让线程暂停
- 在ServerSocketChannel.accept没有连接建立时,会返回
null
,继续运行 - 在SocketChannel.read没有数据可读时,会返回
0
,但线程不会阻塞
- 在ServerSocketChannel.accept没有连接建立时,会返回
- 但非阻塞模式下,即使没有连接建立和可读数据,线程仍然在不断运行,白白浪费了cpu
服务端
- 设置
ServerSocketChannel和SocketChannel.configureBlocking(false)
即为非阻塞模式 - 这种情况程序不会阻塞,程序一直运行,也就代表着cpu一刻不停,不论是否有新连接和数据读取
- 下文通过Selector解决浪费cpu的问题
@Test
public void server() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); // 非阻塞模式
// 2. 绑定监听端口
serverSocketChannel.bind(new InetSocketAddress(8080));
// 3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
// 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) {
log.debug("connected... {}", socketChannel);
socketChannel.configureBlocking(false); // 非阻塞模式
channels.add(socketChannel);
}
for (SocketChannel channel : channels) {
// 5. 接收客户端发送的数据
// 非阻塞,线程仍然会继续运行,如果没有读到数据,read 返回 0
int read = channel.read(buffer);
if (read > 0) {
buffer.flip();
// 打印出响应信息
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
buffer.clear();
log.debug("after read...{}", channel);
}
}
}
}
四、Selector(选择器)
- Java的NIO用非阻塞的IO方式,可以用
一个线程
,处理多个的客户端
连接,就会使用到Selector(选择器) - Selector能够检测多个注册的通道上是否有事件发生,才会处理,如果
没有事件发生,则处于阻塞状态
,防止cpu浪费
1、Selector的应用
//1. 获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//2. 切换非阻塞模式
serverSocketChannel.configureBlocking(false);
//3. 绑定连接
serverSocketChannel.bind(new InetSocketAddress(9898));
//4. 获取选择器
Selector selector = Selector.open();
//5. 将通道注册到选择器上, 并且指定“监听接收事件”
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
监听的事件类型(SelectionKey四个int常量)
读
:SelectionKey.OP_READ (1)写
:SelectionKey.OP_WRITE (4)连接
:SelectionKey.OP_CONNECT (8)接收
:SelectionKey.OP_ACCEPT (16)
若注册时不止监听一个事件,则可以使用“位或”
操作符连接
// 监听读和写事件
serverSocketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
2、多路复用
- 单线程可以配合Selector完成对多个Channel可读写事件的监控,这称之为
多路复用
- 多路复用仅针对网络 IO、普通文件 IO 没法利用多路复用
- 如果不用Selector的非阻塞模式,线程大部分时间都在做无用功,而Selector能够保证有事件发生cpu才运行
服务端
- 将
一个
服务端通道ServerSocketChannel和多个
SocketChannel客户端通道注册到selector上 - 当没有事件发生时,线程会阻塞再selector.select()方法,有事件发生,返回事件数量,进入while循环
- selectionKey表示某个注册的客户端的接入或者读写事件
- read()方法的三种返回值
- 返回值大于0:读到了数据,直接对字节进行编解码
- 返回值等于0:没有读到字节,属于正常场景,忽略
- 返回值为-1:链路已经关闭,需要关闭SocketChannel释放资源
@Test
public void server() throws IOException {
// 1.获取管道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 2.设置非阻塞模式
serverSocketChannel.configureBlocking(false);
// 3.绑定端口
serverSocketChannel.bind(new InetSocketAddress(8888));
// 4.获取选择器
Selector selector = Selector.open();
// 5.将通道注册到选择器上,并且开始指定监听的接收事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 6.轮询已经就绪的事件
// select方法, 没有事件发生,线程阻塞,有事件,线程才会恢复运行,返回事件数量
// 事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则即使item.remove(),selector.select()还是会获取到没处理的事件
while (selector.select() > 0) {
System.out.println("开启事件处理");
// 7.获取选择器中所有注册的通道中已准备好的事件
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
// 8.开始遍历事件
while (it.hasNext()) {
SelectionKey selectionKey = it.next();
System.out.println("客户端通道事件对象key:" + selectionKey);
// 9.判断这个事件具体是啥
if (selectionKey.isAcceptable()) { // 客户端接入事件
// 10.获取当前接入事件的客户端通道
SocketChannel socketChannel = serverSocketChannel.accept();
// 11.切换成非阻塞模式
socketChannel.configureBlocking(false);
// 12.将本客户端注册到选择器
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) { // 读事件
// 13.获取当前选择器上的读通道
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
// 14.读取
ByteBuffer buffer = ByteBuffer.allocate(1024);
/*
* read()方法的三种返回值
* 返回值大于0:读到了直接,对字节进行编解码
* 返回值等于0:没有读到字节,属于正常场景,忽略
* 返回值为-1:链路已经关闭,需要关闭SocketChannel释放资源
*/
int len = socketChannel.read(buffer);
if (len > 0) {
buffer.flip(); // 转为读模式
System.out.println(new String(buffer.array(), 0, len));
buffer.clear(); // 清空缓冲区,转为写模式
} else if(len < 0) {
// 如果读不到数据,取消事件
// 否则客户端断开时,len=-1,数据没有读取到也就是没有处理,会一直循环调用此读事件内容
selectionKey.cancel();
socketChannel.close();
}
}
// 15.处理完毕后,移除当前事件
it.remove();
}
}
}
客户端
public static void main(String[] args) throws Exception {
// 1、获取通道
SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("localhost", 8888));
// 2、切换成非阻塞模式
sChannel.configureBlocking(false);
// 3、分配指定缓冲区大小
ByteBuffer buf = ByteBuffer.allocate(1024);
// 4、发送数据给服务端
Scanner sc = new Scanner(System.in);
while (true) {
System.out.println("请说:");
String msg = sc.nextLine();
buf.put((msg).getBytes());
buf.flip();
sChannel.write(buf);
buf.clear();
}
}
五、零拷贝
1、传统IO
- 传统的 IO 将一个文件通过 socket 写出
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");
byte[] buf = new byte[(int)f.length()];
file.read(buf);
Socket socket = ...;
socket.getOutputStream().write(buf);
内部工作流程是这样的:
- java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的
用户态
切换至内核态
,去调用操作系统的读能力,将数据读入内核缓冲区
。这期间用户线程阻塞,操作系统使用 DMA(可以理解为硬件单元)来实现文件读,其间也不会使用 cpu - 从
内核态
切换回用户态
,将数据从内核缓冲区
读入用户缓冲区
(即 byte[] buf),这期间 cpu 会参与拷贝,无法利用 DMA - 调用write方法,这时将数据从
用户缓冲区
(byte[] buf)写入socket 缓冲区
,cpu 会参与拷贝 - 接下来要向网卡写数据,这项能力 java 又不具备,因此又得从
用户态
切换至内核态
,调用操作系统的写能力,使用 DMA 将socket 缓冲区
的数据写入网卡,不会使用 cpu
java 的 IO 实际不是物理设备级别的读写,而是缓存的
复制
,底层的真正读写是操作系统
来完成的
- 用户态与内核态的切换发生了
3
次,这个操作比较重量级 - 数据拷贝了共
4
次
2、NIO优化
2.1、DirectByteBuffer
- java可以使用DirectByteBuffer将堆外内存(系统内存)映射到jvm内存中来
直接访问使用
- java中的DirectByteBuffer对象仅维护了
此内存的虚引用
用户态与内核态的切换次数与数据拷贝次数
- 用户态与内核态的切换发生了
3
次 - 数据拷贝了共
3
次
2.2、linux2.1提供的sendFile方法
- 进一步优化(底层采用了linux 2.1后提供的
sendFile
方法),java 中对应着两个channel调用transferTo/transferFrom
方法拷贝数据
- java 调用 transferTo 方法后,要从 java 程序的
用户态
切换至内核态
,使用 DMA将数据读入内核缓冲区
,不会使用 cpu - 数据从
内核缓冲区
传输到socket 缓冲区
,cpu 会参与拷贝 - 最后使用 DMA 将
socket 缓冲区
的数据写入网卡,不会使用 cpu
用户态与内核态的切换次数与数据拷贝次数
- 用户态与内核态的切换发生了
1
次 - 数据拷贝了共
3
次
2.3、linux 2.4
- java 调用 transferTo 方法后,要从 java 程序的
用户态
切换至内核态
,使用 DMA将数据读入内核缓冲区
,不会使用 cpu - 只会将一些 offset 和 length 信息拷入
socket 缓冲区
,几乎无消耗 - 使用 DMA 将
内核缓冲区
的数据写入网卡,不会使用 cpu
用户态与内核态的切换次数与数据拷贝次数
- 用户态与内核态的切换发生了
1
次 - 数据拷贝了共
2
次
整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中。零拷贝适合小文件传输。