持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第12天,点击查看活动详情
Java NIO-Channel
2.1、Channel概述
Channel取”通道“的意思,可以通过它读取和写入数据,它支持读、写或者同时读写,是全双工的。
它不同于流,流是单向的,只能在一个方向上流动(一个流必须是InputStream或OutputStream的一个子类)
NIO 中通过 Channel 封装了对数据源的操作,通过 Channel 用户可以操作数据源,但又不必关心数据源的具体物理结构。这个数据源可能是多种的。比如,可以是文件,也可以是网络 socket。
Channel 用于在字节缓冲区和位于通道另一侧的实体(通常是一个文件或套接字)之间有效地传输数据。
Channel依赖 与缓冲区进行实现。
Channel源码:
public interface Channel extends Closeable {
/**
* 告诉这个频道是否打开。
* 返回:
true当且仅当此通道是开放的
*/
public boolean isOpen();
/**
* 关闭此频道。
* 关闭通道后,任何进一步尝试对其调用 I/O 操作都将导致抛出ClosedChannelException 。
* 如果此通道已关闭,则调用此方法无效。
* 可以随时调用此方法。 但是,如果某个其他线程已经调用了它,则另一个调用将阻塞,直到第一次调用完成,之后它将无效地返回。
* 抛出:
IOException – 如果发生 I/O 错误
*/
public void close() throws IOException;
}
与缓冲区不同,通道 API 主要由接口指定。不同的操作系统上通道实现(ChannelImplementation)会有根本性的差异,所以通道 API 仅仅描述了可以做什么。因此很自然地,通道实现经常使用操作系统的本地代码。
Channel是一个对象,可以通过它读取和写入数据。所有数据都通过 Buffer 对象来处理。用户永远不会将字节直接写入通道中,而是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
网络IO的形式:
2.2、Channel的四个实现
Channel有四个实现,分别对应了文件IO、UDP和TCP网络IO(包含客户端和服务器端)
2.3、FileChannel介绍和示例
FileChannel主要操作文件,对应文件IO
先简单了解一下Buffer的操作:
-
将数据写入缓冲区
-
调用buffer.flip() 反转读写模式
调换这个buffer的当前位置,并且设置当前位置是0。说的意思就是:将缓存字节数组的指针设置为数组的开始序列即数组下标0。这样就可以从buffer开头,对该buffer进行遍历(读取)了。
-
从缓冲区读取数据
-
调用buffer.clear()或 buffer.compact()清除缓冲区内容
FileChannel简单使用示例:
/**
* 使用FileChannel读取数据到Buffer中
* 步骤说明:
* 1、创建FileChannel
* 1.1、通过流创建或者RandomAccessFile创建,二者只是打开文件的方式不同
* 2、创建Buffer
* 3、写入Buffer
* @param fileName
*/
public static void read2Buffer(String fileName) throws Exception {
// 1、创建FileChannel
// // 通过流获取FileChannel
// FileInputStream fin = new FileInputStream(fileName);
// FileChannel finChannel = fin.getChannel();
// 使用RandomAccessFile读写文件,支持随机读写,传入参数路径名和模式,
RandomAccessFile raf = new RandomAccessFile(FileChannelDemo.fileName, "rw");
// 获取FileChannel对象,也可以通过流获取
FileChannel fileChannel = raf.getChannel();
// 2、创建一个大小为1024的字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
// 3、将FileChannel中的内容读到buf中,返回值是读取内容的擦汗高难度
int bytesRead = fileChannel.read(buf);
// 读取长度为-1时表明数据读取完毕
while (bytesRead != -1) {
System.out.println("读取了:" + bytesRead + "长度的的文件内容");
// 调换这个buffer的当前位置,并且设置当前位置是0。说的意思就是:将缓存字节数组的指针设置为数组的开始序列即数组下标0。这样就可以从buffer开头,对该buffer进行遍历(读取)了。
buf.flip();
// 如果缓冲区中有内容则输出
while (buf.hasRemaining()) {
// 这里是一个字节一个字节的输出
System.out.println((char)buf.get());
}
// 清空缓冲区
buf.clear();
// 再次从FilChannel中读取数据到缓冲区中
bytesRead = fileChannel.read(buf);
}
// 关闭文件
raf.close();
// 关闭通道
fileChannel.close();
}
2.4、FileChannel操作详解
打开FileChannel:
使用FileChannel读取数据之前需要先创建一个FileChannel实例,但我们无法直接创建,需要通过InputStream、OutputStream或者RandomAccessFile打开一个文件,然后使用getChannel方法获得。
// 1、创建FileChannel
// // 通过流获取FileChannel
// FileInputStream fin = new FileInputStream(fileName);
// FileChannel finChannel = fin.getChannel();
// 使用RandomAccessFile读写文件,支持随机读写,传入参数路径名和模式,
RandomAccessFile raf = new RandomAccessFile(FileChannelDemo.fileName, "rw");
// 获取FileChannel对象,也可以通过流获取
FileChannel fileChannel = raf.getChannel();
从FileChannel中读取数据:
创建一个ByteBuffer实例,通过FileChannel的read(Buffer)方法将数据读取到这个缓冲区中,返回值为读取的数据长度(字节为单位),为-1时表示读取结束。
// 2、创建一个大小为1024的字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
// 3、将FileChannel中的内容读到buf中,返回值是读取内容的擦汗高难度
int bytesRead = fileChannel.read(buf);
向FileChannel写入数据:
同样需要一个Buffer实例,使用FileChannel的write(buffer)方法将数据写到FileChannel中。
注意FileChannel.write0是在 while 循环中调用的。因为无法保证 write0方法一次能向FileChannel 写入多少字节,因此需要重复调用 write()方法,直到 Buffer 中已经没有尚未写入通道的字节。
代码示例:
/**
* 向Channel中写数据,然后写入到文件中
* 步骤说明:
* 1、创建FileChannel
* 2、创建Buffer
* 3、将要写入的内容写入Buffer
* 4、反转buffer读写模式flip(),从Buffer中将数据写入到FileChannel
*
* @param fileName 写入文件的文件名
*/
public static void write2Channel(String fileName) throws Exception {
// 1、创建FileChannel
// // 通过流获取FileChannel
// FileInputStream fin = new FileInputStream(fileName);
// FileChannel finChannel = fin.getChannel();
// 使用RandomAccessFile读写文件,支持随机读写,传入参数路径名和模式,
RandomAccessFile raf = new RandomAccessFile(fileName, "rw");
// 获取FileChannel对象,也可以通过流获取
FileChannel fileChannel = raf.getChannel();
// 2、创建一个大小为1024的字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
// 要写入的内容
String content = "hello, FileChannel";
// 清空缓冲区
buf.clear();
// 3、将要写入的内容写入到缓冲区
buf.put(content.getBytes(StandardCharsets.UTF_8));
// 调换这个buffer的当前位置,并且设置当前位置是0。说的意思就是:将缓存字节数组的指针设置为数组的开始序列即数组下标0。这样就可以从buffer开头,对该buffer进行遍历(读取)了。
buf.flip();
// 4、将缓冲区的内容写入到FileChannel,进而写入到文件中
while (buf.hasRemaining()) {
fileChannel.write(buf);
}
// 关闭文件以及Channel
raf.close();
fileChannel.close();
}
写文件结果:
FileChannel其他重要方法:
FileChannel.position(int pos)方法:
FileChannel.size()方法
FileChannel.truncate(int length)方法:
FileChannel.force()方法:
FileChannel的transferTo和transferFrom方法:
tansferFrom:
/**
* 测试FileChannel的transferFrom方法,将一个通道的数据传输到另一个数据。
* 步骤说明:
* 1、先建立两个通道,一个from通道,一个to通道
* 2、使用transferFrom进行转化,将源通道的数据传输到另一个通道中
*/
public static void transferChannel() throws Exception {
// 建立源通道
RandomAccessFile raf1 = new RandomAccessFile(FileChannelDemo.inputFileName, "r");
FileChannel fromChannel = raf1.getChannel();
// 建立新的通道
RandomAccessFile raf2 = new RandomAccessFile(FileChannelDemo.toFromFileName, "rw");
FileChannel raf2Channel = raf2.getChannel();
// 定义转化开始的位置和大小
long position = 0;
long count = fromChannel.size();
raf2Channel.transferFrom(fromChannel, 0, count);
// 关闭文件和通道
raf1.close();
raf2.close();
fromChannel.close();
raf2Channel.close();
System.out.println("转化完毕!");
}
转化结果:fromChannel 中的所有内容都传送到了raf2Channel中
transferTo方法和transferFrom用法很相似,只是这里是使用to将当前通道中的指定长度内容注入到指定通道中:
/**
* 测试FileChannel的transferTo方法,将一个通道的数据传输到另一个数据。
* 步骤说明:
* 1、先建立两个通道,一个from通道,一个to通道
* 2、使用transferFrom进行转化,将源通道的数据传输到另一个通道中
*/
public static void transferToChannel() throws Exception {
// 建立源通道
RandomAccessFile raf1 = new RandomAccessFile(FileChannelDemo.inputFileName, "r");
FileChannel fromChannel = raf1.getChannel();
// 建立新的通道
RandomAccessFile raf2 = new RandomAccessFile(FileChannelDemo.toFromFileName, "rw");
FileChannel raf2Channel = raf2.getChannel();
// 定义转化开始的位置和大小
long position = 0;
long count = fromChannel.size();
// 截取源通道fromChannel中的内容送入指定通道raf2Channel中
fromChannel.transferTo(position, count, raf2Channel);
// 关闭文件和通道
raf1.close();
raf2.close();
fromChannel.close();
raf2Channel.close();
System.out.println("转化完毕!");
}
转化结果:
2.5、Socket通道操作
这一部分内容的操作步骤和传统的Socket、ServerSocket很相似,API也类似,只是原理不同。
这里常用的读写操作,处理DatagramChannel无连接操作(不包括connect)使用send和receive之外,其他的都是用read和write
**传统的Socket:**原来的Socket连接进行传输数据时,一个线程管理一个Socket连接,当Socket连接过多时,会导致系统负载能力过大,影响用户体验。
新的Socket:新的 socket 通道类可以运行非阻塞模式并且是可选择的,可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性。没有必要再为每个 socket 连接使用一个线程,也避免了管理大量线程所需的上下文交换开销。
1、借助新的 NIO 类,一个或几个线程就可以管理成百上千的活动 socket 连接并且只有很少甚至可能没有性能损失。所有的 socket 通道类(DatagramChannel、SocketChannel和ServerSocketChannel)都继承了位于java.nio.channels.spi包中的AbstractSelectableChannel。这意味着我们可以用一个Selector对象来执行socket 通道的就绪选择(readiness selection)
2、请注意 DatagramChannel和SocketChannel实现定义读和写功能的接口而ServerSocketChannel 不实现。ServerSocketChannel 负责监听传入的连接和创建新的 SocketChannel 对象,它本身从不传输数据。
2.5.1 ServerSocketChannel
ServerSocketChannel是一个基于通道的socket监听器,和ServerSocket作用类似,不过它增强了通道语义,能在非阻塞模式下运行。
使用方式和ServerSocket类似,SocketChannel与Socket对等,ServerSocketChannel监听端口,使用accept方法获取SocketChannel对象。
ServerSocketChannel没有bind方法,绑定地址、端口这些信息需要通过获得ServerSocketChannel上的socket(这里实际上应该是ServerSocket对象,不过方法名是socket),然后再调用bind方法。
代码示例:
/**
* ServerSocketChannel的简单使用,包括创建
* 步骤说明:
* 1、使用静态方法open打开ServerSocketChannel通道,创建ServerSocketChannel对象
* 2、从通道上获得socket对象,然后绑定地址和端口信息,设置阻塞/非阻塞模式
* 3、循环监听通道(accept方法),如果接收到通道中的SocketChannel客户端信息,就进行处理,接收不到(accept方法返回null)则打印提示信息
*
*/
public static void serverSocketChannelSimpleUse() throws Exception {
// 封装一个信息到缓冲区中
ByteBuffer byteBuffer = ByteBuffer.wrap("Hello,ServerSocketChannel".getBytes(StandardCharsets.UTF_8));
// 设置一个端口号
int port = 8888;
// 1、打开一个ServerSocketChannel通道,就是获得一个ServerSocketChannel对象
ServerSocketChannel ssc = ServerSocketChannel.open();
// 2、获得socket对象并绑定信息
ssc.socket().bind(new InetSocketAddress(port));
// 3、循环监听通道
while (true) {
System.out.println("正在监听通道···");
// 获得客户端SocketChannel
SocketChannel sc = ssc.accept();
// 非阻塞状态下,如果监听不到客户端的信息,则会返回一个null
if (sc == null) {
// 打印提示信息
System.out.println("null\t没有客户端连接传入");
// 休眠两秒
Thread.sleep(2000);
}else {
System.out.println("客户端连接来自" + sc.socket().getRemoteSocketAddress());
// 指针回到起始位置,因为之前已经写入了数据,指针变化,所以这里重新将缓冲区的指针提到起始位置
byteBuffer.rewind();
// 将缓冲区的数据写入客户端通道
sc.write(byteBuffer);
// 客户端通道关闭
sc.close();
}
}
}
2.5.2 SocketChannel
SocketChannel的作用:
-
SocketChanne是用来连接Socket套接字
-
SocketChannel主要用途用来处理网络I/O的通道
-
SocketChannel是基于TCP 连接传输
-
SocketChannel 实现了可选择通道,可以被多路复用的
SocketChannel特征:
获取参数:
代码示例:
/**
* SocketChannel的简单实用,包括创建SocketChannel、建立连接等操作。
* 步骤说明:
* 1、使用open静态方法建立连接(相当于传统Socket方法new一个实例,再进行连接相同),有两种方式
* 1.1、使用open方法时传入地址、端口等参数
* 1.2、使用open方法时,不传入参数,再使用得到的SocketChannel实例调用connect方法。
* 2、(可选)检验连接状态
* 3、(可选)设置阻塞/非阻塞模式
* 4、读写数据
*/
public static void socketChannelSimpleUse() throws Exception {
// 1、创建连接
// 方式一:直接给open方法传递参数
// SocketChannel sc = SocketChannel.open("www.baidu.com", 80));
// 方式二:通过open方法获得SocketChannel实例,再使用connect进行连接
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("www.baidu.com", 80));
// 2、设置非阻塞
sc.configureBlocking(false);
// 3、读写数据
ByteBuffer buffer = ByteBuffer.allocate(32);
sc.read(buffer);
sc.close();
System.out.println("数据读取完毕");
}
2.5.3 DatagramChannel
DatagramChannel是面向无连接协议UDP/IP的,每个数据包都是一个自包含的实体,包含自己地址+端口号和目标主机的目的地址+端口号,可以发送数据包到不同的地址,也可以接受来自其他地址的数据包,不区分客户端和服务器端。详细描述如下:
代码示例:
/**
* @author ZhangChaojie
* @Description: TODO(DatagramChannel功能测试)
* @date 2021/12/5 11:25
*/
public class DatagramChannelDemo {
/**
* DatagramChannel的简单使用,包括创建DatagramChannel、传输数据、建立连接等
* 步骤说明:
* 1、使用open方法创建DatagramChannel,这里设置端口等信息的方式同ServerSocketChannel类似,需要获得socket之后使用bind方法
* 2、传输数据
* 2.1、接收数据,需要监听指定端口号,绑定改端口号,然后使用receive方法接收其他主机发送过来的数据包实体
* 2.1.1、定义一个缓冲区,receive(buffer)将数据读到缓冲区中,再通过缓冲区做后续操作
* 2.2、发送数据,将需要发送的封装,包括数据内容和地址+端口号,使用send发送,自动打包成一个数据包实体
* <p>
* DatagramChannel也能建立连接,指定机器使用connect建立连接,这个建立连接是为了向指定服务器上通过read和write方法传输数据。
*/
// 发送数据
@Test
public void sendDatagram() throws Exception {
// 1、创建DatagramChannel
DatagramChannel dc = DatagramChannel.open();
// 2、设置一个目的地址
InetSocketAddress sendAddress = new InetSocketAddress(Inet4Address.getLocalHost(), 10086);
// 循环发送数据
while (true) {
// 3、将发送数据读进缓冲区
ByteBuffer buffer = ByteBuffer.wrap("我是发送端,张三".getBytes(StandardCharsets.UTF_8));
// 4、send方法发送数据到指定地址
dc.send(buffer, sendAddress);
System.out.println("数据已发送");
// 休眠两秒
TimeUnit.SECONDS.sleep(2);
}
}
// 接收数据
@Test
public void receiveDatagram() throws Exception {
// 1、创建DatagramChannel
DatagramChannel dc = DatagramChannel.open();
// 监听地址
InetSocketAddress receiveAddress = new InetSocketAddress(10086);
// 2、绑定这个地址,获取这个地址上发送过的数据
dc.socket().bind(receiveAddress);
// 3、定义一个接受数据的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 循环接收数据,这个循环实际上是一直再监听端口
while (true) {
// 接收数据,返回的是socket套接字连接地址,数据在buffer中
SocketAddress socketAddress = dc.receive(buffer);
// 修改buffer中指针位置
buffer.flip();
System.out.println("socket套接字地址:" + socketAddress.toString());
System.out.println("接收到的数据:" + Charset.forName("UTF-8").decode(buffer));
}
}
// connect连接测试
@Test
public void connectTest() throws Exception {
// 1、创建DatagramChannel
DatagramChannel connCahnnel = DatagramChannel.open();
// 监听地址
InetSocketAddress inetAddress = new InetSocketAddress(10086);
// 2、绑定这个地址,获取这个地址上发送过的数据,为了监听这个端口才绑定
connCahnnel.socket().bind(inetAddress);
// 连接
connCahnnel.connect(new InetSocketAddress("127.0.0.1", 10086));
// 发送数据给connect连接的主机
connCahnnel.write(ByteBuffer.wrap("我是发送端,张三".getBytes(StandardCharsets.UTF_8)));
// 读取数据的缓冲区
ByteBuffer readBuffer = ByteBuffer.allocate(1024);
// 读数据
while (true) {
readBuffer.clear();
connCahnnel.read(readBuffer);
readBuffer.flip();
System.out.println(Charset.forName("UTF-8").decode(readBuffer));
}
}
public static void main(String[] args) throws Exception {
// // 发送数据
// sendDatagram();
// // 接收数据
// receiveDatagram();
}
}
2.6、分散和聚集(Scatter&Gather)
2.6.1、分散
原理描述:
具体操作:
2.6.1、聚集
原理描述:
具体操作:
buffers数组是write()方法的入参,write()方法会按照 buffer在数组中的顺序,将数据写入到channel,注意只有position和limit之间的数据才会被写入。因此,如果一个buffer的容量为 128byte,但是仅仅包含58byte的数据,那么这58byte的数据将被写入到channel中。因此与Scattering Reads相反,Gathering Writes能较好的处理动态消息。