目录
1. 三大组件
1.1 Channel & Buffer
1.2 Selector
2. ByteBuffer
2.1 ByteBuffer 正确使用姿势(重点)
2.2 ByteBuffer 结构(重点)
2.3 ByteBuffer 常见方法
2.4练习
3. 文件编程(了解)
3.1 FileChannel
获取
读取
写入
关闭
位置
大小
强制写入
3.2 两个 Channel 传输数据
3.3 Path
3.4 Files(有重点,拷贝,遍历文件)
拷贝文件(要求掌握)
移动文件:
删除文件:
删除目录:
遍历目录文件(要求掌握)
删除多级目录:
拷贝多级目录:
4. 网络编程
4.1 非阻塞 vs 阻塞(概念要熟练)
阻塞
非阻塞
多路复用(概念要求掌握)
4.2 Selector
创建
绑定 Channel 事件
监听 Channel 事件
select 何时不阻塞
4.3 处理 accept 事件
4.4 处理 read 事件
为何要 iter.remove()? (重点)
cancel 的作用(有坑)
不处理边界产生的问题
处理消息的边界
ByteBuffer 大小分配
4.5 处理 write 事件
一次无法写完例子
write 为何要取消
4.6 UDP
5. NIO vs BIO
5.1 stream vs channel
5.2 IO 模型(重点)
5.3 零拷贝(重点)
传统 IO 问题
NIO 优化
NIO的全称是:non-blocking io 非阻塞 IO ;
channel 有一点类似于 stream,它就是读写数据的双向通道,可以从 channel 将数据读入 buffer,也可以将 buffer 的数据写入 channel,而之前的 stream 要么是输入,要么是输出,channel 比 stream 更为底层;
常见的 Channel 有
FileChannel
DatagramChannel
SocketChannel (服务器和客户端都可以使用)
ServerSocketChannel(专用于服务器)
buffer 则用来缓冲读写数据,常见的 buffer 有
ByteBuffer(最为常用)
MappedByteBuffer
DirectByteBuffer
HeapByteBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer
CharBuffer
下面结合服务器的设计演化来理解它的用途:
1.0:多线程版设计:
这种设计方式的缺点也很明显:
内存占用高
线程上下文切换成本高
只适合连接数少的场景
2.0线程池版设计:
缺点:
阻塞模式下,线程仅能处理一个 socket 连接
仅适合短连接场景 (之前的Tomcat使用过线程池版,不过后来Tomcat不再使用该方式了)
3.0 selector 版设计:
selector 的作用就是配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景;
调用 selector 的 select() 会阻塞直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理;
开发环境准备:创建一个空的maven模块,导入下面的依赖:
io.netty
netty-all
4.1.39.Final
org.projectlombok
lombok
1.16.18
com.google.code.gson
gson
2.8.5
com.google.guava
guava
19.0
ch.qos.logback
logback-classic
1.2.3
com.google.protobuf
protobuf-java
3.11.3
日志文件的配置:
%date{HH:mm:ss} [%-5level] [%thread] %logger{17} - %m%n
有一普通文本文件 data.txt,内容为: 1234567890abcd
使用 FileChannel 来读取文件内容:
@Slf4j
public class ChannelDemo1 {
public static void main(String[] args) {
//获取文件读写通道 FileChannel
//1.通过输入,输出流获取 2.通过RandomAccessFile
try(FileChannel channel = new FileInputStream("E:\\netty_learn\\study\\src\\main\\resources\\data.txt").getChannel()) {
//准备缓存区 allocate:分配
ByteBuffer buffer = ByteBuffer.allocate(10);
//把buffer切换到读模式
while (true){
//从channel中读取数据,把读取到的数据写入到buffer
int len = channel.read(buffer);
log.debug("读取到的字节数{}",len);
if (len == -1){ //没有内容了
break;
}
//输出内容
buffer.flip(); //切换到读模式
while (buffer.hasRemaining()){ //是否还有剩余未读数据
//无参数的get是一次读取一个
byte b = buffer.get();
log.debug("实际字节{}",(char)b);
}
buffer.clear();//切换到写模式
}
}catch (IOException e){
e.printStackTrace();
}
}
}
15:53:50 [DEBUG] [main] n.TestByteBuffer - 读取到的字节数10
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节1
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节2
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节3
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节4
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节5
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节6
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节7
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节8
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节9
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节0
15:53:50 [DEBUG] [main] n.TestByteBuffer - 读取到的字节数3
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节a
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节b
15:53:50 [DEBUG] [main] n.TestByteBuffer - 实际字节c
15:53:50 [DEBUG] [main] n.TestByteBuffer - 读取到的字节数-1
向 buffer 写入数据,例如调用 channel.read(buffer)
调用 flip() 切换至读模式 如果你不切换到这个读模式那么你是读取不到数据的
从 buffer 读取数据,例如调用 buffer.get()
调用 clear() 或 compact() 切换至写模式 如果你不切换到写模式的话,下一次循环你是消费不到数据的
重复 1~4 步骤
ByteBuffer 有以下重要属性
capacity buffer里面的容量
position 读写指针
limit 读写限制
一开始:
写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态
flip 动作发生后,切换到读模式,position ,limit 指针会发生变化,position 切换为读取位置,limit 切换为读取限制:
读取 4 个字节后,状态:
clear 动作发生后(代表从头开始写),状态:
compact (压缩,从上次未读完的地方开始继续读)方法,是把未读完的部分向前压缩,然后切换至写模式 :
Buffer 是非线程安全的 ;
分配空间:
可以使用 allocate 方法为 ByteBuffer 分配空间,其它 buffer 类也有该方法
Bytebuffer buf = ByteBuffer.allocate(16); //这个容量是固定的,不能动态的调整
向 buffer 写入数据:
有两种办法
调用 channel 的 read 方法
调用 buffer 自己的 put 方法
int readBytes = channel.read(buf);
或者是:
buf.put((byte)127); //写入的数据要是byte或者是byte数组
从 buffer 读取数据:
同样有两种办法
调用 channel 的 write 方法
调用 buffer 自己的 get 方法
int writeBytes = channel.write(buf);
或者是:
byte b = buf.get(); //这个get方法如果是无参的,那么就是一个一个的从缓存区中读
get 方法会让 position 读指针向后走,如果想重复读取数据
可以调用 rewind 方法将 position 重新置为 0
或者调用 get(int i) 方法获取索引 i 的内容,它不会移动读指针
mark 和 reset:
mark 是在读取时,做一个标记,即使 position 改变,只要调用 reset 就能回到 mark 的位置
注意
rewind 和 flip 都会清除 mark 位置
字符串与 ByteBuffer 互转:
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好");
ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好");
debug(buffer1);
debug(buffer2);
//不过在转换之前得先把这个buffer切换到读模式,才能把buffer里面的数据转换成字符串
CharBuffer buffer3 = StandardCharsets.UTF_8.decode(buffer1);
System.out.println(buffer3.getClass());
System.out.println(buffer3.toString());
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| e4 bd a0 e5 a5 bd |...... |
+--------+-------------------------------------------------+----------------+
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| e4 bd a0 e5 a5 bd |...... |
+--------+-------------------------------------------------+----------------+
class java.nio.HeapCharBuffer
你好
网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔 但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为
Hello,world\n
I'm zhangsan\n
How are you?\n
变成了下面的两个 byteBuffer (黏包,半包)
Hello,world\nI'm zhangsan\nHo
w are you?\n
现在要求你编写程序,将错乱的数据恢复成原始的按 \n 分隔的数据
public static void main(String[] args) {
ByteBuffer source = ByteBuffer.allocate(32);
// 11 24
source.put("Hello,world\nI'm zhangsan\nHo".getBytes());
split(source);
source.put("w are you?\nhaha!\n".getBytes());
split(source);
}
private static void split(ByteBuffer source) {
//切换到读模式 才能从buffer中读取数据
source.flip();
int oldLimit = source.limit();
for (int i = 0; i < oldLimit; i++) {
if (source.get(i) == '\n') {
//length表示每条消息的长度
int length = i + 1 - source.position()
//将完整的消息存入到新的buffer中
ByteBuffer target = ByteBuffer.allocate();
// 从source 读,向 target 写
for(int j = 0;j
实际开发中是不需要这样操作的,这个是比较底层了,以后netty会帮我们完成的;不过这种思想要了解;
不过这个方法是有比较多的缺点的,比如要一直寻找 /n的下标,而且里面还使用了双重for循环...........
FileChannel 工作模式:FileChannel 只能工作在阻塞模式下;
不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法
通过 FileInputStream 获取的 channel 只能读
通过 FileOutputStream 获取的 channel 只能写
通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定
会从 channel 读取数据填充 ByteBuffer,返回值表示读到了多少字节,-1 表示到达了文件的末尾;
会从 channel 读取数据填充 ByteBuffer,返回值表示读到了多少字节,-1 表示到达了文件的末尾
写入的正确姿势如下, SocketChannel Channel是有写入数据的限制的!!!
ByteBuffer buffer = ...;
buffer.put(...); // 存入数据
buffer.flip(); // 切换读模式
while(buffer.hasRemaining()) {
channel.write(buffer);
}
在 while 中调用 channel.write 是因为 write 方法并不能保证一次将 buffer 中的内容全部写入 channel;
channel 必须关闭,不过调用了 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 close 方法会间接地调用 channel 的 close 方法;
获取当前位置:
long pos = channel.position();
设置当前位置:
long newPos = ...;
channel.position(newPos);
设置当前位置时,如果设置为文件的末尾
这时读取会返回 -1
这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞(00)
使用 size 方法获取文件的大小;
操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘。可以调用 force(true) 方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘
String FROM = "helloword/data.txt";
String TO = "helloword/to.txt";
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
FileChannel to = new FileOutputStream(TO).getChannel();
) {
//把from文件的内容拷贝到to文件 使用transferTo拷贝效率高,底层会利用操作系统的零拷贝进行优化
from.transferTo(0, from.size(), to); //from.size()拷贝多少大小的数据
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("transferTo 用时:" + (end - start) / 1000_000.0);
这个transferTo一次最多只能传输2g数据!
transferTo 用时:8.2011
超过 2g 大小的文件传输:会分多次进行传输;
public class TestFileChannelTransferTo {
public static void main(String[] args) {
try (
FileChannel from = new FileInputStream("data.txt").getChannel();
FileChannel to = new FileOutputStream("to.txt").getChannel();
) {
// 效率高,底层会利用操作系统的零拷贝进行优化
long size = from.size();
// left 变量代表还剩余多少字节
for (long left = size; left > 0; ) {
System.out.println("position:" + (size - left) + " left:" + left);
left -= from.transferTo((size - left), left, to);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
jdk7 引入了 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
检查文件是否存在:
Path path = Paths.get("helloword/data.txt");
System.out.println(Files.exists(path));
创建一级目录:
Path path = Paths.get("helloword/d1");
Files.createDirectory(path);
如果目录已存在,会抛异常 FileAlreadyExistsException
不能一次创建多级目录,否则会抛异常 NoSuchFileException
创建多级目录用:
Path path = Paths.get("helloword/d1/d2");
Files.createDirectories(path);
效率和使用transferTo差不多,以后拷贝文件用这两个就差不多了;
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/data.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
jdk1.7后提供了相关的api来遍历文件目录,以前我们遍历文件目录需要自己递归,现在已经有封装好的api了:
public static void main(String[] args) throws IOException {
Path path = Paths.get("D:\\java\\java11\\jdk-11.0.4_windows-x64_bin\\jdk-11.0.4\\bin");
AtomicInteger dirCount = new AtomicInteger();
AtomicInteger fileCount = new AtomicInteger();
//path表示起始目录 Files.walkFileTree帮我们把遍历给完成了
Files.walkFileTree(path, new SimpleFileVisitor(){
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
//打印遍历到的文件夹
System.out.println(dir);
//对文件夹遍历的次数进行累加
dirCount.incrementAndGet(); //这里不能使用count++,因为匿名内部类引用外部的局部变量,那么这个被引用的局部变量是相当于加了final修饰
//这个return的结果不要该,否则会导致遍历断掉,直接在return上面写自己的逻辑就行
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); // 2 windows对打开的文件是不计数的
System.out.println(fileCount); // 121
}
统计 jar 的数目 访问者模式可以覆盖前面重写的方法:
Path path = Paths.get("C:\\Program Files\\Java\\jdk1.8.0_91");
AtomicInteger fileCount = new AtomicInteger();
Files.walkFileTree(path, new SimpleFileVisitor(){
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
if (file.toFile().getName().endsWith(".jar")) {
fileCount.incrementAndGet();
}
return super.visitFile(file, attrs);
}
});
System.out.println(fileCount); // 724
Path path = Paths.get("d:\\a");
Files.walkFileTree(path, new SimpleFileVisitor(){
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file); //先删除文件
return super.visitFile(file, attrs);
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
Files.delete(dir); //再删除文件夹
return super.postVisitDirectory(dir, exc);
}
});
删除是危险操作,确保要递归删除的文件夹没有重要内容 因为程序删除不走回收站
long start = System.currentTimeMillis();
String source = "D:\\Snipaste-1.16.2-x64";
String target = "D:\\Snipaste-1.16.2-x64aaa";
Files.walk(Paths.get(source)).forEach(path -> {
try {
String targetName = path.toString().replace(source, target);
// 是目录
if (Files.isDirectory(path)) {
Files.createDirectory(Paths.get(targetName));
}
// 是普通文件
else if (Files.isRegularFile(path)) {
//把原始文件拷贝到目的文件
Files.copy(path, Paths.get(targetName));
}
} catch (IOException e) {
e.printStackTrace();
}
});
long end = System.currentTimeMillis();
System.out.println(end - start);
阻塞模式下,相关方法都会导致线程暂停
ServerSocketChannel.accept 会在没有连接建立时让线程暂停
SocketChannel.read 会在没有数据可读时让线程暂停
阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置
单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
但多线程下,有新的问题,体现在以下方面
32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接
服务器端:
// 使用 nio 来理解阻塞模式, 单线程
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
// 2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
// 3. 连接集合
List channels = new ArrayList<>();
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
log.debug("connecting...");
SocketChannel sc = ssc.accept(); // 阻塞方法,线程停止运行
log.debug("connected... {}", sc);
channels.add(sc);
for (SocketChannel channel : channels) {
// 5. 接收客户端发送的数据
log.debug("before read... {}", channel);
channel.read(buffer); // 阻塞方法,线程停止运行 等待客户端发来数据,接收到数据才能继续允许
buffer.flip();
debugRead(buffer);
buffer.clear();
log.debug("after read...{}", channel);
}
}
客户端:
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
System.out.println("waiting...");
非阻塞模式下,相关方法都会不会让线程暂停
在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行
SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu(基于这个缺点,我们在具体的开发中就会很少用这种非阻塞模式)
数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)
服务器端,客户端代码不变:
// 使用 nio 来理解非阻塞模式, 单线程
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 切换到非阻塞模式 影响的是accept方法
// 2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
// 3. 连接集合
List channels = new ArrayList<>();
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
SocketChannel sc = ssc.accept(); // 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null
if (sc != null) { //连接不为null才把连接加入到集合中存储
log.debug("connected... {}", sc);
sc.configureBlocking(false); // 非阻塞模式 影响的后面的read方法
channels.add(sc);
}
for (SocketChannel channel : channels) {
// 5. 接收客户端发送的数据
int read = channel.read(buffer);// 非阻塞,线程仍然会继续运行,如果没有读到数据,read 返回 0
if (read > 0) {
buffer.flip();
debugRead(buffer);
buffer.clear();
log.debug("after read...{}", channel);
}
}
}
单线程可以配合 Selector 完成对多个 Channel 可读写事件的监控,这称之为多路复用,没有活干的时候,线程会进入阻塞;
多路复用仅针对网络 IO、普通文件 IO 没法利用多路复用
如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证
有可连接事件时才去连接
有可读事件才去读取
有可写事件才去写入
限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件
好处
一个线程配合 selector 就可以监控多个 channel 的事件,事件发生线程才去处理。避免非阻塞模式下所做无用功
让这个线程能够被充分利用
节约了线程的数量
减少了线程上下文切换
Selector selector = Selector.open();
也称之为注册事件,绑定的事件 selector 才会关心;
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, 绑定事件); //这个方法的返回值,我们可以通过返回值来知道是哪个channel事件
key.interestOps(SelectionKey.OP_ACCEPT); //表示key只关注accept事件
channel 必须工作在非阻塞模式
FileChannel 没有非阻塞模式,因此不能配合 selector 一起使用
绑定的事件类型可以有
connect - 客户端连接成功时触发
accept - 服务器端成功接受连接时触发
read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少 channel 发生了事件
方法1,阻塞直到绑定事件发生:
int count = selector.select();
方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms)
int count = selector.select(long timeout);
方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件
int count = selector.selectNow();
事件发生时
客户端发起连接请求,会触发 accept 事件
客户端发送数据过来,客户端正常、异常关闭时,都会触发 read 事件,另外如果发送的数据大于 buffer 缓冲区,会触发多次读取事件
channel 可写,会触发 write 事件
在 linux 下 nio bug 发生时
调用 selector.wakeup()
调用 selector.close()
selector 所在线程 interrupt
客户端代码为:
public class Client {
public static void main(String[] args) {
try (Socket socket = new Socket("localhost", 8080)) {
System.out.println(socket);
socket.getOutputStream().write("world".getBytes());
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}
服务器端代码为:
@Slf4j
public class ChannelDemo6 {
public static void main(String[] args) {
try (ServerSocketChannel channel = ServerSocketChannel.open()) {
channel.bind(new InetSocketAddress(8080));
System.out.println(channel);
//创建selector,可以用来管理多个channel 在未处理事件的时候,它不会阻塞,事件发生后要么处理要么取消,不能置之不理
Selector selector = Selector.open();
channel.configureBlocking(false);
//建立selector和channel的联系(注册)
channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
//没有事件发生,线程会阻塞等待,有事件,线程才会恢复运行
int count = selector.select();
// int count = selector.selectNow();
log.debug("select count: {}", count);
// if(count <= 0) {
// continue;
// }
// 获取所有事件 selectedKeys()可以获取所有发生的事件
Set keys = selector.selectedKeys();
// 遍历所有事件,逐一处理
Iterator iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 判断事件类型
if (key.isAcceptable()) {
ServerSocketChannel c = (ServerSocketChannel) key.channel();
// 必须处理 建立连接
SocketChannel sc = c.accept();
log.debug("{}", sc);
}
// 处理完毕,必须将事件移除
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
事件发生后能否不处理:
事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发;
@Slf4j
public class ChannelDemo6 {
public static void main(String[] args) {
try (ServerSocketChannel channel = ServerSocketChannel.open()) {
channel.bind(new InetSocketAddress(8080));
System.out.println(channel);
Selector selector = Selector.open();
channel.configureBlocking(false);
channel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
int count = selector.select();
// int count = selector.selectNow();
log.debug("select count: {}", count);
// if(count <= 0) {
// continue;
// }
// 获取所有事件 selector里面是所有key的集合 selectedKeys里面是发生了的事件的集合
Set keys = selector.selectedKeys();
// 遍历所有事件,逐一处理
Iterator iter = keys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 判断事件类型
if (key.isAcceptable()) {
ServerSocketChannel c = (ServerSocketChannel) key.channel();
// 必须处理
SocketChannel sc = c.accept();
//必须切换到非阻塞模式
sc.configureBlocking(false);
//注册时间
sc.register(selector, SelectionKey.OP_READ);
log.debug("连接已建立: {}", sc);
//处理读取事件
} else if (key.isReadable()) {
try{
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(128);
int read = sc.read(buffer); //这里会有一个异常,如果客户端强制关闭客户端连接,那么服务器会抛出一个异常导致服务器挂掉!!!
if(read == -1) { //客户端正常断开,服务器也会出问题,所以也需要把相关的事件给移除
key.cancel();
sc.close();
} else {
buffer.flip();
debug(buffer);
}
}catch(IOException e){
e.printStackTrace();
//客户端断开连接后,需要把这个客户端的可以取消(从selector的keys集合里面真正的删除key,否则会导致服务器挂掉)
key.cancel();
}
}
// 处理完毕,必须将事件移除,否则下次多事件来了会报空指针
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
因为 select 在事件发生后,就会将相关的 key 放入 selectedKeys(帮我们创建的) 集合,但不会在处理完后从 selectedKeys 集合中移除,需要我们自己编码删除。例如
第一次触发了 ssckey 上的 accept 事件,没有移除 ssckey
第二次触发了 sckey 上的 read 事件,但这时 selectedKeys 中还有上次的 ssckey ,在处理时因为没有真正的 serverSocket 连上了,就会导致空指针异常
客户端只要断开了与服务器的连接都需要把相关的key给移除,这样才可以保证服务器的正常运行;
cancel 会取消注册在 selector 上的 channel,并从 keys 集合中删除 key 后续不会再监听事件
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss=new ServerSocket(9000);
while (true) {
Socket s = ss.accept();
InputStream in = s.getInputStream();
// 这里这么写,有没有问题
byte[] arr = new byte[4];
while(true) {
int read = in.read(arr);
// 这里这么写,有没有问题
if(read == -1) {
break;
}
System.out.println(new String(arr, 0, read));
}
}
}
}
public class Client {
public static void main(String[] args) throws IOException {
Socket max = new Socket("localhost", 9000);
OutputStream out = max.getOutputStream();
out.write("hello".getBytes());
out.write("world".getBytes());
out.write("你好".getBytes());
max.close();
}
}
输出结果:
hell
owor
ld�
�好 //在默认的utf-8中一个中文是3个字节,那么 你好 这个消息就是6个字节,但是我们的缓存区一次只能读取4个字节,所以就出现了一个中文出现了乱码,一个是正常输出 这就是消息边界问题
一种思路是固定消息长度,数据包大小一样,服务器按预定长度读取,缺点是浪费带宽
另一种思路是按分隔符拆分,缺点是效率低
TLV 格式,即 Type 类型、Length 长度、Value 数据,类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
Http 1.1 是 TLV 格式
Http 2.0 是 LTV 格式
private static void split(ByteBuffer source) {
source.flip();
for (int i = 0; i < source.limit(); i++) {
// 找到一条完整消息
if (source.get(i) == '\n') {
int length = i + 1 - source.position();
// 把这条完整消息存入新的 ByteBuffer
ByteBuffer target = ByteBuffer.allocate(length);
// 从 source 读,向 target 写
for (int j = 0; j < length; j++) {
target.put(source.get());
}
debugAll(target);
}
}
source.compact(); // 0123456789abcdef position 16 limit 16
}
public static void main(String[] args) throws IOException {
// 1. 创建 selector, 管理多个 channel
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
// 2. 建立 selector 和 channel 的联系(注册)
// SelectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
SelectionKey sscKey = ssc.register(selector, 0, null);
// key 只关注 accept 事件
sscKey.interestOps(SelectionKey.OP_ACCEPT);
log.debug("sscKey:{}", sscKey);
ssc.bind(new InetSocketAddress(8080));
while (true) {
// 3. select 方法, 没有事件发生,线程阻塞,有事件,线程才会恢复运行
// select 在事件未处理时,它不会阻塞, 事件发生后要么处理,要么取消,不能置之不理
selector.select();
// 4. 处理事件, selectedKeys 内部包含了所有发生的事件
Iterator iter = selector.selectedKeys().iterator(); // accept, read
while (iter.hasNext()) {
SelectionKey key = iter.next();
// 处理key 时,要从 selectedKeys 集合中删除,否则下次处理就会有问题
iter.remove();
log.debug("key: {}", key);
// 5. 区分事件类型
if (key.isAcceptable()) { // 如果是 accept
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16); // attachment
// 将一个 byteBuffer 作为附件关联到 selectionKey 上
SelectionKey scKey = sc.register(selector, 0, buffer);
scKey.interestOps(SelectionKey.OP_READ);
log.debug("{}", sc);
log.debug("scKey:{}", scKey);
} else if (key.isReadable()) { // 如果是 read
try {
SocketChannel channel = (SocketChannel) key.channel(); // 拿到触发事件的channel
// 获取 selectionKey 上关联的附件, 我们分析出如果buffer的空间比接收到的消息要小很多,那么出现乱码或者是粘包问题,所以我们这里使用扩容,但是扩容的话那就要保证这个变量不是局部变量,但是也不能把这个buffer作为全局共享变量,如果作为全局共享变量就会导致其他channel的buffer都和这个一样大,会导致具体消息与buffer的大小不匹配,所以最好就是使每个channel有自己独立的buffer,互不干扰;
//拿到key所关联度附件 不过要在注册channel的时候就要把这个附件和key给关联起来了,只要SelectionKey存在这个buffer就会存在
ByteBuffer buffer = (ByteBuffer) key.attachment();
int read = channel.read(buffer); // 如果是正常断开,read 的方法的返回值是 -1
if(read == -1) {
key.cancel();
} else {
//拆分消息
split(buffer);
// 需要扩容
if (buffer.position() == buffer.limit()) {
ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
//切换到读模式,因为此时需要从旧的buffer中读取数据
buffer.flip();
newBuffer.put(buffer); // 把旧的内容放到新的buffer里面,这是需要从旧的buffer里面读,然后写到新的buffer里面
key.attach(newBuffer); //把key和扩容后的buffer相关联
}
}
} catch (IOException e) {
e.printStackTrace();
key.cancel(); // 因为客户端断开了,因此需要将 key 取消(从 selector 的 keys 集合中真正删除 key)
}
}
}
}
}
客户端:
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
SocketAddress address = sc.getLocalAddress();
// sc.write(Charset.defaultCharset().encode("hello\nworld\n"));
sc.write(Charset.defaultCharset().encode("0123\n456789abcdef"));
sc.write(Charset.defaultCharset().encode("0123456789abcdef3333\n"));
System.in.read();
上面的代码是比较偏底层的,netty都已经封装好了,并且更加灵活和省内存;
tcp编程都要考虑消息边界的问题;
每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer
ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer
一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能,参考实现 Java Resizable Array
另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗
非阻塞模式下,无法保证把 buffer 中所有数据都写入 channel,因此需要追踪 write 方法的返回值(代表实际写入字节数)
用 selector 监听所有 channel 的可写事件,每个 channel 都需要一个 key 来跟踪 buffer,但这样又会导致占用内存过多,就有两阶段策略
当消息处理器第一次写入消息时,才将 channel 注册到 selector 上
selector 检查 channel 上的可写事件,如果所有的数据写完了,就取消 channel 的注册
如果不取消,会每次可写均会触发 write 事件
public class WriteServer {
public static void main(String[] args) throws IOException {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
Selector selector = Selector.open();
//把channel注册到selector,可以在这里直接注册
//Selector就是通过SelectionKey这个集合来监听Channel对什么事件感兴趣的,所以register()方法返回一个SelectinKey对象,通过这个对象完成后续的一些偏底层的操作。
ssc.register(selector, SelectionKey.OP_ACCEPT);
while(true) {
selector.select();
Iterator iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isAcceptable()) {
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
//这个sckey和key弄得有点晕啊。。。。。
SelectionKey sckey = sc.register(selector, SelectionKey.OP_READ);
// 1. 向客户端发送内容
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3000000; i++) {
sb.append("a");
}
ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
//返回值代表实际的写入值
int write = sc.write(buffer);
// 3. write 表示实际写了多少字节
System.out.println("实际写入字节:" + write);
// 4. 如果有剩余未读字节,才需要关注写事件
if (buffer.hasRemaining()) {
// read 1 write 4
// 在原有关注事件的基础上,多关注 写事件 为了防止把之前关注的事件给覆盖
sckey.interestOps(sckey.interestOps() + SelectionKey.OP_WRITE);
// 把 没写完的buffer 作为附件加入 sckey
sckey.attach(buffer);
}
//表示可以写了
} else if (key.isWritable()) {
//通过key把上次没写完的buffer重新拿出来
ByteBuffer buffer = (ByteBuffer) key.attachment();
SocketChannel sc = (SocketChannel) key.channel();
int write = sc.write(buffer);
System.out.println("实际写入字节:" + write);
if (!buffer.hasRemaining()) {
// 写完了 把关注的写事件给清除
key.interestOps(key.interestOps() - SelectionKey.OP_WRITE);
key.attach(null);
}
}
}
}
}
}
public class WriteClient {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
SocketChannel sc = SocketChannel.open();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_CONNECT | SelectionKey.OP_READ);
sc.connect(new InetSocketAddress("localhost", 8080));
int count = 0;
while (true) {
selector.select();
Iterator iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove();
if (key.isConnectable()) {
System.out.println(sc.finishConnect());
} else if (key.isReadable()) {
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
count += sc.read(buffer);
buffer.clear();
System.out.println(count);
}
}
}
}
}
只要向 channel 发送数据时,socket 缓冲区可写,这个事件会频繁触发,因此应当只在 socket 缓冲区写不下时再关注可写事件,数据写完之后再取消关注
UDP 是无连接的,client 发送数据不会管 server 是否开启
server 这边的 receive 方法会将接收到的数据存入 byte buffer,但如果数据报文超过 buffer 大小,多出来的数据会被默默抛弃
public class UdpServer {
public static void main(String[] args) {
try (DatagramChannel channel = DatagramChannel.open()) {
channel.socket().bind(new InetSocketAddress(9999));
System.out.println("waiting...");
ByteBuffer buffer = ByteBuffer.allocate(32);
channel.receive(buffer);
buffer.flip();
debug(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
运行客户端:
public class UdpClient {
public static void main(String[] args) {
try (DatagramChannel channel = DatagramChannel.open()) {
ByteBuffer buffer = StandardCharsets.UTF_8.encode("hello");
InetSocketAddress address = new InetSocketAddress("localhost", 9999);
channel.send(buffer, address);
} catch (Exception e) {
e.printStackTrace();
}
}
}
stream 不会自动缓冲数据,channel 会利用系统提供的发送缓冲区、接收缓冲区(更为底层)
stream 仅支持阻塞 API,channel 同时支持阻塞、非阻塞 API,网络 channel 可配合 selector 实现多路复用
二者均为全双工,即读写可以同时进行
同步阻塞、同步非阻塞、同步多路复用、异步阻塞(没有此情况)、异步非阻塞
同步:线程自己去获取结果(一个线程)
异步:线程自己不去获取结果,而是由其它线程送结果(至少两个线程)
当调用一次 channel.read 或 stream.read 后(因为Java里面的read方法是不能读取网络上的数据的),会切换至操作系统内核态来完成真正数据读取(读取完成后又会切换到用户空间),而读取又分为两个阶段,分别为:
等待数据阶段 (等待操作系统把数据读取到内存中)
复制数据阶段
阻塞 IO(在等待数据的过程用户线程被阻塞)
非阻塞 IO(读取数据的时候如果没有数据,会立即返回,不会阻塞,默认返回的是0,如果有数据的话那就会进入数据的复制阶段,这个阶段还是会阻塞的)
多路复用:
多路复用关键是在selector,在开始的时候不是先调用read方法,而是先调用selector的select方法,先阻塞主,然后看有没有相关的事件,如果有相关的新事件,那么内核就会告诉用户线程有新事件发生了,发生了之后用户线程就可以通过selectorKey拿到相关的channel,然后通过channel去调用read, 在read的期间如果要复制数据那么还是会阻塞; 所以多路复用会有两个地方会阻塞,一个是selector的时候,一个是read复制数据的时候; 多路复用的优势在于,你在等待的时候还可以继续去干其他的事情,一个selector可以处理多个事件;
信号驱动(不太常用)
异步 IO
IO中的同步和异步理解(和多线程中的同步和异步不太一样):
同步:线程自己去获取结果(一个线程)
异步:线程自己不去获取结果,而是由其他线程送结果(至少两个线程)
阻塞 IO vs 多路复用(属于同步)
传统的 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 方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,cpu 会参与拷贝
接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的
用户态与内核态的切换发生了 3 次,这个操作比较重量级
数据拷贝了共 4 次
通过 DirectByteBuf
ByteBuffer.allocate(10) HeapByteBuffer 使用的还是 java 内存
ByteBuffer.allocateDirect(10) DirectByteBuffer 使用的是操作系统内存
大部分步骤与优化前相同,不再赘述。唯有一点:java 可以使用 DirectByteBuf 将堆外内存映射到 jvm 内存中来直接访问使用
这块内存不受 jvm 垃圾回收的影响,因此内存地址固定,有助于 IO 读写
java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步
DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列
通过专门线程访问引用队列,根据虚引用释放堆外内存
减少了一次数据拷贝,用户态与内核态的切换次数没有减少
进一步优化(底层采用了 linux 2.1 后提供的 sendFile 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据:
java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
可以看到
只发生了一次用户态与内核态的切换
数据拷贝了 3 次
进一步优化(linux 2.4)
java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 cpu
整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是不会拷贝重复数据到 jvm 内存中(不需要把数据复制到Java的内存中了,就相当于减少了两次Java和操作系统的切换,复制的次数拷贝也大大减少了,并不是真正的0拷贝),零拷贝的优点有
更少的用户态与内核态的切换
不利用 cpu 计算,减少 cpu 缓存伪共享
零拷贝适合小文件传输(因为内核缓冲区的大小是有限的)
【本博客是基于在B站中学习黑马教育的netty网络编程的学习笔记记录】