主要是对黑马的总结
一、三大组件简介
Channel 与 Buffer
Java NIO系统的核心在于: 通道(Channel) 与 缓冲区 (Buffer)。 **
通道表示打开到IO设备(文件、套接字)的连接。若需要使用NIO系统,需要获取用于连接IO设备的通道以及用于容纳数据的缓冲区**。然后操作缓冲区,对数据进行处理。
简而言之,通道负责传输,缓冲区负责存储.
**常见的channel有以下四种,**其中FileChannel主要用于文件传输,其余三种用于网络通信.
- FileChannel
- DatagramChannel UDP
- SocketChannel TCP client,server
- ServerSocketChannel TCP server
Buffer 有以下几种,其中使用较多的是byteBuffer
- ByteBuffer
- MappedByteBuffer
- DirectByteBuffer
- HeapByteBuffer
- ShortBuffer
- Int,Long,Float, Double,CharBuffer
1、Selector
在使用Selector之前,处理socket链接还有以下两种方法
使用多线程技术
为每个连接分别开辟一个线程,分别去处理对应的socket连接
存在问题:
- 内存占用高
- 每个线程都需要占用一定的内存,当连接较多时,会开辟大量线程,导致占用大量内存.
- 线程上下文切换成本高 主要os记录线程状态,和切换线程结构.
- 只适合连接数少的场景
- 连接数过多,会导致创建很多线程,从而出现问题
使用线程池技术
既然多线程无法管理和切换成本高,我们能否采用线程池去处理,提高线程利用效率,减少线程。
存在的问题
- 阻塞模式下,线程仅能处理一个连接
- 线程池中的线程获取任务(task)后,只有当其执行完任务之后(断开连接后),才会取获取并执行下一个任务
- 若socket连接一直未断开,则其对应的线程无法处理其他socket连接
- 仅适合短连接场景
- 短连接即建立连接发送请求并响应后就立即断开,使得线程池中的线程可以快速处理其他连接.
使用选择器
selector的作用其实就是配合一个线程来管理多个channel (FileChannel是阻塞式的,所以无法使用selector)
获取这些channel上的事件,这些channel工作在非阻塞模式下,当一个channel中没有执行任务时,可以去执行其他channel中的任务。适合连接数多,但流量较少的场景
若事件未就绪,调用selector的select() 方法会阻塞线程,直到channel发生了就绪事件。这些事件就绪后,select方法就会返回这些事件交给thread来处理.
2、ByteBuffer
使用案例
使用方式
- 向buffer中写入数据,例如调用 channel.read(buffer)
- 调用flip()切换至读模式
- flip 会让buffer中 limit变为position,position变为0
- 从buffer读取数据,例如调用buffer.get()
- 调用clear() 或者 compact() 切换至写模式
- 调用clear()时position=0, limit变为capacity
- 调用compact()方式时,会将缓冲区中未读数据压缩到缓冲区前面
- 重复以上步骤
使用byteBuffer读取文件中的内容
package com.xlg.component.nio;
import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author wangqingwei
* Created on 2022-05-28
*/
public class ByteBufferTest {
private static final Logger logger = LoggerFactory.getLogger(ByteBufferTest.class);
private static final String INPUT_FILE_NAME = "src/testByteBufferText.txt";
private static final int BUFFER_CAPACITY = 10;
@Test
public void testByteBufferRead() {
// 1. 获取FileChannel
try (FileChannel channel = new FileInputStream(INPUT_FILE_NAME).getChannel()) {
// 2. 获取缓冲区
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_CAPACITY);
StringBuilder bufferData = new StringBuilder();
while (channel.read(buffer) > 0) {
// 3. 切换模式, limit=position, position=0
buffer.flip();
// 4. buffer是否还有数据
while (buffer.hasRemaining()) {
bufferData = bufferData.append((char)buffer.get());
}
// 5. 切换模式, limit=capacity, position=0
buffer.clear();
}
System.out.println(bufferData);
} catch (Exception e) {
e.printStackTrace();
}
}
}
结果:
1234567890abc
核心属性
字节缓冲区的父类buffer中有几个核心属性, 如下
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
- capacity: 缓冲区的容量。通过构造函数赋于,一旦设置,无法更改.
- limit: 缓冲区的界限。位于limit后的数据不可读写。缓冲区的限制不能为负值,并且不能大于容量
- position: 下一个读写位置的索引(类似pc)。缓冲区的位置不能是负的,并且不能大于limit.
- **mark: **记录当前的position的值。position被改变后,可以通过调用reset()方法恢复到mark的位置.
上面四个属性应满足:
mark <= position <= limit <= capacity
核心方法
put()方法
- put()方法可以将一个数据放入缓冲区中
- 进行该操作后,position的值+1, 指向下一个可以放入的位置。capacity = limit, 为缓冲区容量的值。
flip()方法
- flip()方法会切换对缓冲区的操作模式. 由读->写,由写->读
- 进行该操作之后
- 如果写模式 -> 读模式, limit = position, postion = 0
- 读 -> 写, 其实没有啥影响,put就行
get()方法
- get()方法会读取缓冲区的一个值
- 进行该操作后,position + 1, 如果超过limit会有异常
- 注意: get(index) 方法不会改变position 的值.
rewind()方法
- 改方法只能在读模式使用 ,position 归零
clear()方法
- 各个属性恢复为最初的样子, position = 0, limit = capacity
- 此时缓冲区的数据依旧存在,处于被遗忘状态,下次进行写操作就会覆盖.
mark和reset()方法
- mark() 会把当前position 给mark, 初始mark = -1
- reset() 会把 position = mark , 其实就是回溯
compact() 方法
此方法是ByteBuffer的方法,而不是buffer的方法
- compact会把未读完的数据向前压缩,然后切换到写模式
- 数据前移后,原位置的值并未清零,写时会覆盖之前的值
clear 和 compact对比
clear只是对position、limit、mark进行重置,而compact在对position进行设置,以及limit、mark进行重置的同时,还涉及到数据在内存中拷贝(会调用arraycopy)。所以compact比clear更耗性能。
但compact能保存你未读取的数据,将新数据追加到为读取的数据之后;而clear则不行,若你调用了clear,则未读取的数据就无法再读取到了
所以需要根据情况来判断使用哪种方法进行模式切换
字符串与ByteBuffer的相互转换
方法一
编码: getByte, put入byteBuffer
解码: flip(), StandardCharsets.UTF_8.decode
@Test
public void testStrBuffer2ConvertVersion1() {
String str = "hello nio!";
String result;
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put(str.getBytes());
ByteBufferUtil.debugAll(buffer);
buffer.flip();
result = StandardCharsets.UTF_8.decode(buffer).toString();
ByteBufferUtil.debugAll(buffer);
System.out.println("version1 result : " + result);
}
方法二
编码: StandardCharsets.UTF_8.encode 获取byteBuffer, 此时获取的buffer为读模式.
解码: StandardCharsets.UTF_8.decode
@Test
public void testStrBuffer2ConvertVersion2() {
String str = "hello nio!";
String result;
final ByteBuffer buffer = StandardCharsets.UTF_8.encode(str);
result = StandardCharsets.UTF_8.decode(buffer).toString();
ByteBufferUtil.debugAll(buffer);
System.out.println("version2 result : " + result);
}
方法三:
编码: ByteBuffer.warp(str.getByte())
解码: StandardCharsets.UTF_8.decode
@Test
public void testStrBuffer2ConvertVersion3() {
String str = "hello nio!";
String result;
final ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());
result = StandardCharsets.UTF_8.decode(buffer).toString();
ByteBufferUtil.debugAll(buffer);
System.out.println("version3 result : " + result);
}
粘包和半包
现象
网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为
- Hello,world\n
- I’m Nyima\n
- How are you?\n
变成了下面的两个 byteBuffer (粘包,半包)
- Hello,world\nI’m Nyima\nHo
- w are you?\n
出现原因
粘包
发送方在发送数据时,并不是一条一条发送的,而是将数据整合在一起,达到一定数据量后一起发送,
这就会导致多条信息被放在一个缓冲区中被一起发送出去.
半包
接受方的缓冲区的大小是有限的,当接受方的缓冲区满了以后,就需要将**信息截断,**等缓冲区空了之后再继续放入数据。这就会发生一段完整的数据最后被截断的现象.
解决办法
发送定长包
发送包和包体长度 接受方先接到包体长度,然后接受包
特殊分隔符方式 但有些报文会误解析
- 通过get(index)方法遍历ByteBuffer,遇到分隔符时进行处理。注意:get(index)不会改变position的值
- 记录该段数据长度,以便于申请对应大小的缓冲区
- 将缓冲区的数据通过get()方法写入到target中
- 调用compact方法切换模式,因为缓冲区中可能还有未读的数据
/**
* 利用特殊符号分隔粘包
*/
@Test
public void processStickPck() {
ByteBuffer buffer = ByteBuffer.allocate(32);
// 模拟粘包+半包
buffer.put("Hello,world\nI'm Nyima\nHo".getBytes());
byte splitStr = '\n';
stickPckSplit(buffer, splitStr);
buffer.put("w are you?\n".getBytes());
stickPckSplit(buffer, splitStr);
}
private void stickPckSplit(ByteBuffer buffer, byte splitStr) {
// 切换为读模式
buffer.flip();
for (int index = 0; index < buffer.limit(); index++) {
final byte element = buffer.get(index);
if (element == splitStr) {
int subLength = index - buffer.position();
ByteBuffer subBuffer = ByteBuffer.allocate(subLength);
for (int subIndex = 0; subIndex < subLength; subIndex++) {
subBuffer.put(buffer.get());
}
// 有一位是\n
buffer.get();
debugAll(subBuffer);
}
}
// 因为有些数据还没有拿完
buffer.compact();
// debugAll(buffer);
}
二、文件编程
1、FileChannel
工作模式
FileChannel 只能在阻塞模式下工作,所以无法搭配Selector
获取
不能直接打开FileChannel, 必须通过FileInputStream、FileOutputStream 或者RandomAccessFile 来获取FileChannel, 它们都有getChannel 方法
- FileInputStream 的channel 只能读
- **FileOutputStream **的channel 只能写
- RandomAccessFile 是否能读写根据构造 randomAccesFile 时的读写模式决定
读取
FileInputStream.getChannel.read(buffer) 把数据写入buffer中,
// 表示读到了多少字节, 若读到了文件末尾则返回给 -1
int readBytes = channel.read(buffer);
while (channel.read(buffer) > 0) {
}
写入
因为channel也是有大小的,所以 write 方法并不能保证一次将 buffer 中的内容全部写入 channel。必须需要按照以下规则进行写入
// 通过hasRemaining()方法查看缓冲区中是否还有数据未写入到通道中
while(buffer.hasRemaining()) {
channel.write(buffer);
}
关闭
通道需要close,一般情况通过try-with-resource进行关闭,最好使用以下方法获取strea以及channel,避免某些原因使得资源未被关闭
public class TestChannel {
public static void main(String[] args) throws IOException {
try (FileInputStream fis = new FileInputStream("stu.txt");
FileOutputStream fos = new FileOutputStream("student.txt");
FileChannel inputChannel = fis.getChannel();
FileChannel outputChannel = fos.getChannel()) {
}
}
}
位置
channel 也有一个保存读取数据位置的属性,即position
long pos = channel.position();
// 可以通过position(int pos)设置channel中position的值
long newPos = ...;
channel.position(newPos);
设置当前位置时,如果设置为文件的末尾
- 这时读取会返回 -1
- 这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞(00)
强制写入
操作系统处于性能考虑,会将数据缓存,不是立即写入磁盘,而是等到缓存满了以后将所有的数据一次性的写入磁盘。可以调用**force(true) **方法将文件内容和元数据(文件的权限等信息) 立即写入磁盘.
2、两个Channel传输数据
tranferTo方法
使用tranferTo方法可以快速的、高效地将一个channel中的数据传输到另一个channel中
但一次只能传输2G的内容.
transferTo 底层使用了零拷贝技术
private static final String TRANSFER_TWO_CHANNEL_INPUT = FILE_PREFIX_NIO + "input.txt";
private static final String TRANSFER_TWO_CHANNEL_OUT = FILE_PREFIX_NIO + "output.txt";
/**
* 使用transferTo方法可以快速、高效地将一个channel中的数据传输到另一个channel中,但一次只能传输2G的内容
*
* transferTo底层使用了零拷贝技术
*/
@Test
public void testTwoChannelTransfer() {
try (FileChannel inputChannel = new FileInputStream(TRANSFER_TWO_CHANNEL_INPUT).getChannel();
FileChannel outChannel = new FileOutputStream(TRANSFER_TWO_CHANNEL_OUT).getChannel();
) {
// 参数:inputChannel的起始位置,传输数据的大小,目的channel
// 返回值为传输的数据的字节数
// transferTo一次只能传输2G的数据
inputChannel.transferTo(0, inputChannel.size(), outChannel);
} catch (Exception e) {
e.printStackTrace();
}
}
当传输的文件大于2G时,需要使用以下方法进行多次传输
/**
* 当传输的文件大于2G时,需要使用以下方法进行多次传输
*/
@Test
public void testTwoChannelTransferV2() {
try (FileChannel inputChannel = new FileInputStream(TRANSFER_TWO_CHANNEL_INPUT).getChannel();
FileChannel outChannel = new FileOutputStream(TRANSFER_TWO_CHANNEL_OUT).getChannel();
) {
long size = inputChannel.size();
long capacity = inputChannel.size();
while (capacity > 0) {
// // transferTo返回值为传输了的字节数
capacity -= inputChannel.transferTo(size - capacity, capacity, outChannel);
}
} catch (Exception e) {
e.printStackTrace();
}
}
3、Path 与 Paths
- Path 用来表示文件路径
- Paths 是用具类, 用来获取Path 实例
Path source = Paths.get("1.txt"); // 相对路径 不带盘符 使用 user.dir 环境变量来定位 1.txt
Path source = Paths.get("d:\\1.txt"); // 绝对路径 代表了 d:\1.txt 反斜杠需要转义
Path source = Paths.get("d:/1.txt"); // 绝对路径 同样代表了 d:\1.txt
Path projects = Paths.get("d:\\data", "projects"); // 代表了 d:\data\projects
- . 代表了当前路径
- … 代表了上一级路径
d:
|- data
|- projects
|- a
|- b
Path path = Paths.get("d:\\data\\projects\\a\\..\\b");
System.out.println(path);
System.out.println(path.normalize()); // 正常化路径 会去除 . 以及 ..
d:\data\projects\a\..\b
d:\data\projects\b
4、Files
查找
检查文件是否存在
Path path = Paths.get("helloword/data.txt");
sout(Files.exists(path));
创建
创建一级目录
Path path = Paths.get("helloword/d1");
Files.createDirectory(path);
- 如果目录已存在,会抛异常 FileAlreadyExistsException
- 不能一次创建多级目录,否则会抛异常 NoSuchFileException
创建多级目录用
Path path = Paths.get("helloword/d1/d2");
Files.createDirectories(path);
拷贝及移动
拷贝文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");
Files.copy(source, target);
如果文件已经存在, 会抛异常 FileAlreadyExistsException
如果希望用source 覆盖掉 target,需要用 StandardCopyOption 来控制.
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
移动文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
- StandardCopyOption.ATOMIC_MOVE 保证文件移动的原子性
删除
删除文件
Path target = Paths.get("helloword/target.txt");
Files.delete(target);
- 如果文件不存在,会抛异常 NoSuchFileException
删除目录
Path target = Paths.get("helloword/d1");
Files.delete(target);
- 如果目录还有内容,会抛异常 DirectoryNotEmptyException
遍历
可以使用Files工具类中的walkFileTree(Path, FileVisitor)方法,其中需要传入两个参数
- Path:文件起始路径
- FileVisitor:文件访问器,使用访问者模式
- 接口的实现类SimpleFileVisitor有四个方法
- preVisitDirectory:访问目录前的操作
- visitFile:访问文件的操作
- visitFileFailed:访问文件失败时的操作
- postVisitDirectory:访问目录后的操作
- 接口的实现类SimpleFileVisitor有四个方法
public class TestWalkFileTree {
public static void main(String[] args) throws IOException {
Path path = Paths.get("F:\\JDK 8");
// 文件目录数目
AtomicInteger dirCount = new AtomicInteger();
// 文件数目
AtomicInteger fileCount = new AtomicInteger();
Files.walkFileTree(path, new SimpleFileVisitor<Path>(){
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
System.out.println("===>"+dir);
// 增加文件目录数
dirCount.incrementAndGet();
return super.preVisitDirectory(dir, attrs);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
System.out.println(file);
// 增加文件数
fileCount.incrementAndGet();
return super.visitFile(file, attrs);
}
});
// 打印数目
System.out.println("文件目录数:"+dirCount.get());
System.out.println("文件数:"+fileCount.get());
}
}
三、网络编程
1、阻塞
- 阻塞模式下,相关方法都会导致线程暂停
- ServerSocketChannel.accept 会在没有连接建立时让线程暂停
- SocketChannel.read 会在通道中没有数据可读时让线程暂停
- 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置
- 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
- 但多线程下,有新的问题,体现在以下方面
- 32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
- 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接
服务端代码
客户端代码
private static final Logger logger = LoggerFactory.getLogger(NetTest.class);
private static final int BLOCKING_SERVER_BUFFER_CAPACITY = 16;
private static final int BLOCKING_SERVER_PORT = 8080;
@Test
public void blockingServer() {
ByteBuffer buffer = ByteBuffer.allocate(BLOCKING_SERVER_BUFFER_CAPACITY);
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(BLOCKING_SERVER_PORT));
// 存放链接的集合
List<SocketChannel> clientChannelList = Lists.newArrayList();
while (true) {
logger.info("before connecting...");
// 没有连接时,会阻塞线程
SocketChannel socketChannel = server.accept();
logger.info("after connecting...");
clientChannelList.add(socketChannel);
for (SocketChannel channel : clientChannelList) {
logger.info("before reading...");
// 处理通道中的数据
// 当通道中没有数据可读时,会阻塞线程
channel.read(buffer);
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
logger.info("after reading...");
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Test
public void blockingClient() {
try (final SocketChannel socketChannel = SocketChannel.open()) {
socketChannel.connect(new InetSocketAddress(LOCAL_HOST, BLOCKING_SERVER_PORT));
logger.info("waiting...");
} catch (Exception e) {
e.printStackTrace();
}
}
运行结果
- 客户端-服务器建立连接前:服务器端因accept阻塞
- 客户端-服务器建立连接后,客户端发送消息前:服务器端因通道为空被阻塞
- 客户端发送数据后,服务器处理通道中的数据。再次进入循环时,再次被accept阻塞
- 之前的客户端再次发送消息**,服务器端因为被accept阻塞**,无法处理之前客户端发送到通道中的信息
此时只有再启动一个客户端,才能进入for channel 此时第一个channel中未处理的事件给处理了.
2、非阻塞
- 可以通过ServerSocketChannel的configureBlocking(false)方法将获得连接设置为非阻塞的。此时若没有连接,accept会返回null
- 可以通过SocketChannel的configureBlocking(false)方法将从通道中读取数据设置为非阻塞的。若此时通道中没有数据可读,read会返回-1
服务器代码如下
public class Server {
public static void main(String[] args) {
// 创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate(16);
// 获得服务器通道
try(ServerSocketChannel server = ServerSocketChannel.open()) {
// 为服务器通道绑定端口
server.bind(new InetSocketAddress(8080));
// 用户存放连接的集合
ArrayList<SocketChannel> channels = new ArrayList<>();
// 循环接收连接
while (true) {
// 设置为非阻塞模式,没有连接时返回null,不会阻塞线程
server.configureBlocking(false);
SocketChannel socketChannel = server.accept();
// 通道不为空时才将连接放入到集合中
if (socketChannel != null) {
System.out.println("after connecting...");
channels.add(socketChannel);
}
// 循环遍历集合中的连接
for(SocketChannel channel : channels) {
// 处理通道中的数据
// 设置为非阻塞模式,若通道中没有数据,会返回0,不会阻塞线程
channel.configureBlocking(false);
int read = channel.read(buffer);
if(read > 0) {
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
System.out.println("after reading");
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
这样写有一个问题,因为设置了非阻塞,会一直执行while(true)中的代码,CPU一直处于忙碌状态,会使得性能变低,所以实际情况中不使用这种方法处理请求.
3、Selector
多路复用
单线程可以配合Selector 完成对多个Channel 可读写事件的监控,这称之为多路复用
- 多路复用仅针对网络IO, 普通文件IO 无法利用多路复用
- 如果不用Selector 的非阻塞模式,线程大部分时间都在做无用功,而Selector 能够保证
- 有可连接事件时才去连接
- 有可读事件才去读取
- 有可写事件才去写入
- 限于网络传输能力, Channel未必时时可写,一旦Channel 可写,会触发selector 的可写事件
4、使用及Accpet事件
要使用Selector实现多路复用,服务端代码如下改进
@Test
public void testSelectorAccept() {
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(SERVER_PORT));
Selector selector = Selector.open();
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
// 就绪事件数量
final int readyEvents = selector.select();
System.out.println("selector ready counts : " + readyEvents);
final Set<SelectionKey> selectionKeys = selector.keys();
final Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
// 判断key的类型
if(key.isAcceptable()) {
// 获得key对应的channel
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
System.out.println("before accepting...");
// 获取连接并处理,而且是必须处理,否则需要取消
SocketChannel socketChannel = channel.accept();
System.out.println("after accepting...");
// 处理完毕后移除
iterator.remove();
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
步骤解析
- 获得选择器Selector
Selector selector = Selector.open()
- 将通道设置为非阻塞模式,并注册到选择器中,并设置感兴趣的事件
- channel 必须工作在非阻塞模式
- FileChannel 没有非阻塞模式, 所以不能 配合Selector 一起使用
- 绑定的事件类型 可以有:
- connect - 客户端链接成功时触发
- accept - 服务器端成功接受连接时触发
- read - 数据可读入时触发,有因为接受能力弱,数据暂不能读入的情况
- write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
- 通通Selector 监听事件,并获得就绪的通道个数,若没有通道就绪,线程就会阻塞.
- 阻塞直到绑定事件发生
int count = selector.select();
- 阻塞直到绑定事件发生,或者超时 (ms)
int count = selector.select(long timeout);
- 不会阻塞,也就是不管有没有事件,立即返回,自己根据返回值检查是否有事件
int count = selector.selectNow();
- 获取就绪事件并得到对应的通道,然后进行处理
// 获取所有事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 使用迭代器遍历事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 判断key的类型,此处为Accept类型
if(key.isAcceptable()) {
// 获得key对应的channel
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
// 获取连接并处理,而且是必须处理,否则需要取消
SocketChannel socketChannel = channel.accept();
// 处理完毕后移除
iterator.remove();
}
}
事件发生后能否不处理
事件发生后,要么处理,要么取消,不能什么都不做,**否则下次该事件仍会触发,**这是因为nio底层使用的是水平触发.
5、Read 事件
- 在Accept事件中,若有客户端与服务端建立了连接,需要将其对应的SocketChannel 设置为非阻塞,并注册到选择器中。
- 添加read事件,触发后续读取操作.
@Test
public void testSelectorAcceptRead() {
ByteBuffer buffer = ByteBuffer.allocate(19);
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(SERVER_PORT));
Selector selector = Selector.open();
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
// 就绪事件数量
final int readyEvents = selector.select();
System.out.println("selector ready counts : " + readyEvents);
final Set<SelectionKey> selectionKeys = selector.keys();
final Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
// 判断key的类型
if (key.isAcceptable()) {
// 获得key对应的channel
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
System.out.println("before accepting...");
// 获取连接并处理,而且是必须处理,否则需要取消
SocketChannel socketChannel = channel.accept();
System.out.println("after accepting...");
// 设置为非阻塞模式,同时将连接的通道也注册到选择其中
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
// 处理完毕后移除
iterator.remove();
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel)key.channel();
logger.info("before reading...");
channel.read(buffer);
logger.info("after reading...");
buffer.flip();
ByteBufferUtil.debugRead(buffer);
buffer.clear();
iterator.remove();
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
删除事件
**当处理完一个事件后,一定要调用迭代器的remove方法移除对应事件,否则会出现错误。**原因如下
- 当调用了 server.register(selector, SelectionKey.OP_ACCEPT) 后,Selector 中维护了一个集合,用于存放SelectionKey以及其对应的通道.
// WindowsSelectorImpl 中的 SelectionKeyImpl数组
private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[8];
public class SelectionKeyImpl extends AbstractSelectionKey {
// Key对应的通道
final SelChImpl channel;
...
}
- 当选择器种的通道对应的事件发生后,selectionKey 会被放入另一个集合中,但是SelectionKey 不会自动移除,所以我们需要在处理完一个事件后,通过迭代器手动移除其中的selectionKey。否则会导致已经处理过的事件再次被处理,就会引发错误.
断开处理
当客户端与服务器之间的连接断开时,会给服务器端发送一个读事件,对异常断开和正常断开需要不同的处理.
- 正常断开
- 正常断开时,服务器端的channel.read(buffer) 方法的返回值为-1,所以当结束到返回值为-1时,需要调用key的cancel方法取消事件,并在取消后移除该事件.
int read = channel.read(buffer);
// 断开连接时,客户端会向服务器发送一个读事件,此时read的返回值为-1
if (read == -1) {
// 取消该事件的处理
key.cancel();
channel.close();
} else {
...
}
iterator.remove();
- 异常断开
- 异常断开时,会抛出IOException 异常,在try-catch的catch 块中捕获异常并调用key 的cancel方法即可.
消息边界
不处理消息边界存在的问题
ByteBuffer buffer = ByteBuffer.allocate(4);
// 解码并打印
System.out.println(StandardCharsets.UTF_8.decode(buffer));
你�
��
这个是因为UTF_8模式下,一个汉字3个字节,此时缓冲区4字节,一次读事件无法处理完通道中的数据,所以一共会触发两次事件.
处理消息边界
传输的文本可能有以下三种情况
- 文本大于缓冲区大小
- 此时需要将缓冲区进行扩容
- 发生半包现象
- 发生粘包现象
解决思路大概有以下三种
- **固定消息长度, ** 数据包大小一样,服务器按预定长度读取,当发送的数据较少时,需要将数据进行填充,直到长度与消息规定长度一致。缺点是浪费带宽.
- 按照分隔符拆分,缺点是效率低,而且很容易造成无拆包.
- TLV 格式。Type类型, Length 长度,Value数据(也就是在消息开头用一些空间存放后面数据的长度),如HTTP请求头中的Content-Type与Content-Length。类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
- Http 1.1 是 TLV 格式
- Http 2.0 是 LTV 格式
下文的消息边界处理方式为第二种:按分隔符拆分
附件与扩容
channel的 register 方法还有第三个参数: 附件, 可以向其中放入一个Object类型的对象,该对象会与登记的Channel 以及其对应的SelectionKey绑定,可以从SelectionKey获取到对应通道的附件.
public final SelectionKey register(Selector sel, int ops, Object att)
可通过SelectionKey的attachment()方法获得附件
ByteBuffer buffer = (ByteBuffer) key.attachment();
我们需要在Accept事件发生后,将通道注册到Selector中时,对每个通道添加一个ByteBuffer附件,让每个通道发生读事件时都使用自己的通道,避免与其他通道发生冲突而导致问题
// 设置为非阻塞模式,同时将连接的通道也注册到选择其中,同时设置附件
socketChannel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
// 添加通道对应的Buffer附件
socketChannel.register(selector, SelectionKey.OP_READ, buffer);
当Channel中的数据大于缓冲区时,需要对缓冲区进行扩容操作。此代码中的扩容的判定方法:Channel调用compact方法后,的position与limit相等,说明缓冲区中的数据并未被读取(容量太小),此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用SelectionKey的attach方法将新的缓冲区作为新的附件放入SelectionKey中
// 如果缓冲区太小,就进行扩容
if (buffer.position() == buffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);
// 将旧buffer中的内容放入新的buffer中
ewBuffer.put(buffer);
// 将新buffer作为附件放到key中
key.attach(newBuffer);
}
改造后的服务器代码如下
/**
* 升级后的考虑断开、数据半包粘包(split)
*/
@Test
public void testSelectorAcceptReadUpgrade() {
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(SERVER_PORT));
Selector selector = Selector.open();
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
// 就绪事件数量
final int readyEvents = selector.select();
System.out.println("selector ready counts : " + readyEvents);
final Set<SelectionKey> selectionKeys = selector.keys();
final Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
// 判断key的类型
if (key.isAcceptable()) {
// 获得key对应的channel
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
System.out.println("before accepting...");
// 获取连接并处理,而且是必须处理,否则需要取消
SocketChannel socketChannel = channel.accept();
System.out.println("after accepting...");
// 设置为非阻塞模式,同时将连接的通道也注册到选择其中
socketChannel.configureBlocking(false);
ByteBuffer curChannelBuffer = ByteBuffer.allocate(BUFFER_CAPACITY);
// 每个通道发生读事件时都使用自己的通道
socketChannel.register(selector, SelectionKey.OP_READ, curChannelBuffer);
// 处理完毕后移除
iterator.remove();
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel)key.channel();
// 通过key获得附件(buffer)
ByteBuffer curBuffer = (ByteBuffer)key.attachment();
logger.info("before reading...");
final int read = channel.read(curBuffer);
if (read == -1) {
key.channel();
channel.close();
} else {
// 通过分隔符来分隔buffer中的数据
ByteBufferTest bufferTest = new ByteBufferTest();
bufferTest.stickPckSplit(curBuffer, SPLIT_STR);
// buffer太小了
if (curBuffer.position() == curBuffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(curBuffer.capacity() * BUFFER_EXPRESSION_FACTOR);
curBuffer.flip();
newBuffer.put(curBuffer);
// 将新buffer放到key中作为附件
key.attach(newBuffer);
}
}
logger.info("after reading...");
iterator.remove();
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
ByteBuffer大小分配
- 每个channel都需要记录可能被切分的消息,因为byteBuffer 不能被多个channel共同使用. 因此需要为每个channel 维护一个独立的byteBuffer
- byteBuffer 不能太大,比如一个byteBuffer 1Mb的话,要支持百万连接就需要1Tb 内存,因此需要设计大小可变的byteBuffer.
- 分配思路可以参考
- 一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能
- 另一个思路是用多个数组组成buffer, 一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗.
6、 write事件
服务器通过Buffer向通道中写入数据时,可能因为通道容量小于Buffer中的数据大小,导致无法一次性将Buffer中的数据全部写入到Channel中,这时便需要分多次写入,具体步骤如下:
- 执行一次写操作,向将buffer中的内容写入到SocketChannel中,然后判断Buffer 中是否还有数据
- 若Buffer 中还有数据,则需要将SocketChannel 注册到Selector中,并关注写事件,同时将未写完的Buffer 作为附件一起放入到SelectionKey中.
int write = socket.write(buffer);
// 通道中可能无法放入缓冲区中的所有数据
if (buffer.hasRemaining()) {
// 注册到Selector中,关注可写事件,并将buffer添加到key的附件中
socket.configureBlocking(false);
socket.register(selector, SelectionKey.OP_WRITE, buffer);
}
- 添加写事件的相关操作 key.isWritable(). 对Buffer再次进行写操作.
- 每次写后需要判断Buffer中是否还有数据 (是否写完)。若写完,需要移除SelectionKey中的buffer附件, 避免占用过多内存,同时还需移除对写事件的关注.
SocketChannel socket = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer)key.attachment();
// 执行写操作
int write = socket.write(buffer);
sout(write); // 字节数
// 如果已经完成了写操作, 需要移除key中的附件, 同时不在对写事件感兴趣
if (!buffer.hasRemaining()) {
key.attach(null);
key.interestOps(0);
}
整体代码如下:
/**
* 升级后的考虑断开、数据半包粘包(split), write写事件
* 因为通道的容量可能小于 buffer的大小, 一次处理不完
*/
@Test
public void testSelectorAcceptReadUpgradeAndWrite() {
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(SERVER_PORT));
Selector selector = Selector.open();
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
// 就绪事件数量
final int readyEvents = selector.select();
System.out.println("selector ready counts : " + readyEvents);
final Set<SelectionKey> selectionKeys = selector.keys();
final Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
// 判断key的类型
if (key.isAcceptable()) {
// 获得key对应的channel
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
System.out.println("before accepting...");
// 获取连接并处理,而且是必须处理,否则需要取消
SocketChannel socketChannel = channel.accept();
System.out.println("after accepting...");
// 写入data
StringBuilder dataBuilder = new StringBuilder();
Stream.iterate(0, s -> s + 1).limit(500000000).forEach(dataBuilder::append);
ByteBuffer buffer = StandardCharsets.UTF_8.encode(dataBuilder.toString());
// 先执行一次Buffer->Channel的写入,如果未写完,就添加一个可写事件
int write = socketChannel.write(buffer);
System.out.println(write);
// 通道中可能无法放入缓冲区中的所有数据
if (buffer.hasRemaining()) {
// 注册到Selector中,关注可写事件,并将buffer添加到key的附件中
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_WRITE, buffer);
}
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel)key.channel();
// 通过key获得附件(buffer)
ByteBuffer curBuffer = (ByteBuffer)key.attachment();
logger.info("before reading...");
final int read = channel.read(curBuffer);
if (read == -1) {
key.channel();
channel.close();
} else {
// 通过分隔符来分隔buffer中的数据
ByteBufferTest bufferTest = new ByteBufferTest();
bufferTest.stickPckSplit(curBuffer, SPLIT_STR);
// buffer太小了
if (curBuffer.position() == curBuffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(curBuffer.capacity() * BUFFER_EXPRESSION_FACTOR);
curBuffer.flip();
newBuffer.put(curBuffer);
// 将新buffer放到key中作为附件
key.attach(newBuffer);
}
}
logger.info("after reading...");
} else if (key.isWritable()) {
SocketChannel socket = (SocketChannel)key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
int write = socket.write(buffer);
System.out.println(write);
// 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣
if (!buffer.hasRemaining()) {
key.attach(null);
key.interestOps(0);
}
}
// 处理完毕后移除
iterator.remove();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
7、优化
多线程优化
充分利用多核CPU,分两组选择器
- 单线程配一个选择器(Boss), 专门处理accept 事件
- 创建cpu核心数的线程 (Worker),** 每个线程配一个选择器,轮流处理read事件**
思路
- 创建一个负责处理accept 事件的Boss 线程,与多个负责处理read 事件的worker线程.
- Boss 线程执行逻辑
- 接受并处理accept 事件,当事件发生后,调用Worker的register(SocketChannel socket)方法,让woker去处理read事件,其中需要根据标识robin 去判断将任务分配给哪个worker.
// 创建固定数量的Worker
Worker[] workers = new Worker[4];
// 用于负载均衡的原子整数
AtomicInteger robin = new AtomicInteger(0);
// 负载均衡,轮询分配Worker
workers[robin.getAndIncrement()% workers.length].register(socket);
- register(SocketChannel socket)方法会通过同步队列完成Boss线程与Worker线程之间的通信,让SocketChannel的注册任务被Worker线程执行。添加任务后需要调用selector.wakeup()来唤醒被阻塞的Selector
public void register(final SocketChannel socket) throws IOException {
// 只启动一次
if (!started) {
// 初始化操作
}
// 向同步队列中添加SocketChannel的注册事件
// 在Worker线程中执行注册事件
queue.add(new Runnable() {
@Override
public void run() {
try {
socket.register(selector, SelectionKey.OP_READ);
} catch (IOException e) {
e.printStackTrace();
}
}
});
// 唤醒被阻塞的Selector
// select类似LockSupport中的park,wakeup的原理类似LockSupport中的unpark
selector.wakeup();
}
- Worker 线程执行的操作
- 从同步队列中获取注册任务,并处理read事件
实现代码
/**
* 多线程版本.
* 一个boss 监听accept
* 多个worker 监听read
*/
@Test
public void threadsServer() {
try (ServerSocketChannel server = ServerSocketChannel.open()) {
Thread.currentThread().setName("boss");
server.bind(new InetSocketAddress(SERVER_PORT));
// 负责轮训检查accept
final Selector boss = Selector.open();
server.configureBlocking(false);
server.register(boss, SelectionKey.OP_ACCEPT);
// 创建固定数据量的worker
Worker[] workers = new Worker[4];
// 用于负载因子的 robin
AtomicInteger robin = new AtomicInteger(0);
for (int index = 0; index < workers.length; index++) {
workers[index] = new Worker("worker-" + index);
}
while (true) {
boss.select();
Set<SelectionKey> selectionKeys = boss.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// BossSelector负责Accept事件
if (key.isAcceptable()) {
// 建立连接
final SocketChannel socketChannel = server.accept();
System.out.println("connected...");
socketChannel.configureBlocking(false);
// socket注册到Worker的Selector中
System.out.println("before read...");
// 负载均衡,轮询分配Worker
workers[robin.getAndIncrement()% workers.length].register(socketChannel);
System.out.println("after read...");
}
iterator.remove();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static final class Worker implements Runnable {
private Thread thread;
private volatile Selector selector;
private String name;
private volatile boolean started = false;
/**
* 同步队列, 用于boss线程和worker线程的通信
*/
private ConcurrentLinkedQueue<Runnable> queue;
public Worker(String name) {
this.name = name;
}
public void register(SocketChannel socket) throws IOException {
// 初始化
if (!started) {
thread = new Thread(this, name);
selector = Selector.open();
queue = new ConcurrentLinkedQueue<>();
thread.start();
started = true;
}
// 向同步队列中添加SocketChannel的注册事件
// 在Worker线程中执行注册事件
queue.add(() -> {
try {
socket.register(selector, SelectionKey.OP_READ);
} catch (ClosedChannelException e) {
e.printStackTrace();
}
});
// 唤醒被阻塞的Selector
// select类似LockSupport中的park,wakeup的原理类似LockSupport中的unpark
selector.wakeup();
}
@Override
public void run() {
while (true) {
try {
// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
// 就绪事件数量
final int select = selector.select();
System.out.println(select);
// 通过同步队列获得任务并运行
final Runnable task = queue.poll();
if (task != null) {
// 获得任务,执行注册操作
task.run();
}
final Set<SelectionKey> selectionKeys = selector.selectedKeys();
final Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
final SelectionKey key = iterator.next();
// Worker只负责Read事件
if (key.isReadable()) {
// 简化处理,省略细节
SocketChannel socketChannel = (SocketChannel)key.channel();
ByteBuffer buffer = ByteBuffer.allocate(16);
socketChannel.read(buffer);
buffer.flip();
ByteBufferUtil.debugAll(buffer);
}
iterator.remove();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
四、NIO与BIO
1、Stream 与 Channel
- stream 不会自动缓冲数据,channel会利用系统提供的发送缓冲区、接受缓冲区(更为底层)
- stream 仅支持阻塞API,channel 同时支持阻塞、非阻塞API,网络channel 可配合selector 实现多路复用
- 二者**均为全双工, **即读写可以同时进行
- 虽然stream 是单向流动的,但是它也是全双工的
2、IO模型
- 同步: 线程自己去获取结果 (一个线程)
- 异步: 线程自己不去获取结果,而是由其它线程返回结果 (至少两个线程)
- 例如: 线程A 调用一个方法后,继续向下走,运行结果由线程B返回
当调用一次 channel.read 或 stream.read 后,会由 用户态 切换到 操作系统内核态来完成真正的数据读取,而读取又分为两个阶段, 分别为:
- 等待数据阶段
- 复制数据阶段
根据UNIX 网络编程 - 卷 I,IO模型主要有以下几种
阻塞 IO
- 用户线程进行read操作时,需要等待操作系统执行实际的read操作,此期间用户线程是被阻塞的,无法执行其他操作
非阻塞IO
- 用户线程在一个循环中一直调用read方法,若内核空间中还没有数据可读,立即返回
- 只是在等待阶段非阻塞
- 用户线程发现内核空间中有数据后,等待内核空间执行复制数据,待复制结束后返回结果
多路复用
Java中通过Selector实现多路复用
- 当没有事件时,调用select方法会被阻塞住
- 一旦有一个或多个事件发生后,就会处理对应的事件,从而实现多路复用
多路复用与阻塞IO的区别
- 阻塞IO模式下,若线程因accept事件被阻塞,发生read事件后,仍需等待accept事件执行完成后,才能去处理read事件
- 多路复用模式下,一个事件发生后,若另一个事件处于阻塞状态,不会影响该事件的执行
异步IO
- 线程1调用方法后立即返回,不会被阻塞也不需要立即获取结果
- 当方法的运行结果出来以后,由线程2将结果返回给线程1
3、零拷贝
零拷贝指的是数据无需拷贝到JVM内存中,同时具有以下三个优点
- 更少的用户态与内核态的切换
- 不利用cpu 计算,减少cpu缓存伪共享
- 零拷贝适合小文件传输
传统 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 程序的用户态切换到内核态,调用操作系统(kernel) 的读能力,将数据读入 **内核缓冲区. **这期间用户线程阻塞,操作系统使用DMA (Direct Memory Access) 来实现文件读,期间也不会使用CPU
DMA 也可理解为硬件单元,用来解放 cpu 完成文件 IO
- 从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区 (即byte[] buf),这期间** CPU 会参与拷贝**,无法利用DMA
- 调用write 方法,这时将数据从用户缓冲区 写入** socket缓冲区**,CPU 会参与拷贝
- 接下来要向网卡写数据,这项能力 Java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU
可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的
- 用户态与内核态的切换发生了3次,这个操作比较重量级
- 数据拷贝发生了 4 次
NIO优化
通过 DirectByteBuf
- ByteBuffer.allocate(10)
- 底层对应 HeapByteBuffer,使用的还是 Java 内存
- ByteBuffer.allocateDirect(10)
- 底层对应DirectByteBuffer,使用的是操作系统内存
大部分步骤与优化前一样,唯有一点: Java 可以使用DirectByteBuffer将堆外内存映射到Jvm 内存中来直接访问使用
- 这块内存不受JVM 垃圾回收的影响,因此内存地址固定,有助于IO 读写
- Java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分为两步
- DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
- 当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用cleaner的clean 方法来释放直接内存
- DirectByteBuffer的释放底层调用的是 Unsafe 的 freeMemory 方法
- 通过专门线程访问引用队列,根据虚引用释放堆外内存
- DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
- 减少了一次数据拷贝,用户态与内核态的切换次数没有减少
进一步优化1
以下两种方式都是零拷贝,即无需将数据拷贝到用户缓冲区中(JVM内存中)
底层采用了 linux 2.1 后提供的 sendFile 方法,Java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
- Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU
- 数据从内核缓冲区传输到 socket 缓冲区,CPU 会参与拷贝
- 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU
这种方法下:
- 数据只发生了三次拷贝
- 只发生了一次用户态与内核态的转换.
进一步优化2
linux 2.4 对上述方法再次进行了优化
- Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU
- 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
- 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 CPU
整个过程仅只发生了1次用户态与内核态的切换,数据拷贝了 2 次
4、AIO
AIO 用来解决数据复制阶段的阻塞问题
- 同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置
- 异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获得结果
异步模型需要底层操作系统(Kernel)提供支持
- Windows 系统通过 IOCP 实现了真正的异步 IO
- Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势