Buffer
上一篇文章简单说了Channel
和Buffer
的关系,还有Channel
的使用。这篇就要讲Buffer的一些细节了。
Buffer
顾名思义,缓冲区,即内存中一块存放数据的地方。同时提供一些api供我们读写这块内存。
常用的实现类是ByteBuffer
,IntBuffer
等(可以猜到还有什么ShortBuffer
,CharBuffer
)
我们就拿ByteBuffer
来说,其余的举一反三即可
ByteBuffer结构
既然以Byte
开头,说明他的存储是以字节为单位。注意,
作为一个Buffer
都会有以下性质:
capacity
容量position
位置limit
限制位置- 分为读写模式,按需切换
- 非线程安全的
结合下图就能明白,基本结构类似于带指针的数组
既然带指针,很容易就联想到指针用来描述读写位置。
对于处于写模式的ByteBuffer
,position
代表写入的位置,比如我第一次写一个字节后,position
就会后移一位,指向下一次写入的位置。而limit
则等于capacity
,因为最多写满就没空间写了。
对于处于读模式的 ByteBuffer
,position
代表读取位置,比如我当前ByteBuffer
没有被读过,我读一个字节后position
向后移一位,代表下次读从position
开始,相当于对读取位置的记录,而limit
此时就不是和容量一致了,而是当前ByteBuffer
中有多少字节,limit
指向最后一个字节的位置,后面不能读,因为后面没数据。
上面仅通过文字叙述了ByteBuffer
指针的变动,在下面代码示例后会画图展示使用过程中中的指针变化。
下面给出一些常见方法的使用
//获取channel
SocketChannel channel = SocketChannel.open();
channel.connect(new InetSocketAddress("http://google.com", 80));
//给ByteBuffer分配空间
ByteBuffer bf = ByteBuffer.allocate(16);
//写入数据
//ByteBuffer默认为写模式,不需要切换
//方法1:channel.read(),从sokcet读出数据写到ByteBuffer中
int readBytes = channel.read(bf);//方法返回读取字节数
//方法2:直接调用ByteBuffer自己的put()方法
bf.put((byte)66);
//想读取数据要切换为读模式
bf.filp();
//读取数据
//同样两个方法
//方法1:channel.write(),从ByteBuffer中读数据写到channel中
int writeBytes = channel.write(bf);//返回本次读取字节数
//方法2:直接调用ByteBuffer自己的get()方法
byte x = bf.get();
//前面提到过,读时position指针会变化,就是在调用get方法时
//那么我们读完指针走到头想想重读一遍怎么办?使用下面的rewind方法
//rewind方法把position指针重置为0
bf.rewind();
//切换为写模式有两个方法
bf.clear();
bf.compact();
//clear方法会把position指针直接置为0,不管之前读的时候读没读完,意味着从头开始写
//compact方法会根据postion和limit把剩下没读的数据移到前面,然后position放在未读数据部分的后一位,相当于保留未读部分继续写
//mark与reset
bf.mark();
bf.reset();
//mark方法记录当前position位置
//reset方法将position重置会mark方法记录的position位置
//ByteBuffer与字符串互转
//这块课上讲的,走神了又听了一遍,decode和encode顾名思义编码和解码。
//把字符串编码成UTF_8编码存入ByteBuffer
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好");
ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好");
debug(buffer1);
debug(buffer2);
//ByteBuffer中的UTF_8解码为对应字符
CharBuffer buffer3 = StandardCharsets.UTF_8.decode(buffer1);
System.out.println(buffer3.getClass());
System.out.println(buffer3.toString());
指针变化图这块偷个懒,把满老师课上的图拿出来。
写模式下,position
是写入位置,limit
等于容量,下图表示写入了 4 个字节后的状态
flip
动作发生后,position
切换为读取位置,limit
切换为读取限制
读取 4 个字节后,状态
clear
动作发生后,状态
compact
方法,是把未读完的部分向前压缩,然后切换至写模式
ByteBuffer的分散读和集中写
分散读,可以理解为将一个缓冲区或文件的内数据,按容量分块读到不同的Buffer
中。
集中写,多个Buffer
的数据写到一个缓冲区或文件中
//这里直接抄了老师的代码
//分散读
//这里是根路径helloword文件夹下的txt文件
try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) {
FileChannel channel = file.getChannel();
//分配空间
ByteBuffer a = ByteBuffer.allocate(3);
ByteBuffer b = ByteBuffer.allocate(3);
ByteBuffer c = ByteBuffer.allocate(5);
//分散读到三个ByteBuffer里,直接read方法传一个ByteBuffer数组
channel.read(new ByteBuffer[]{a, b, c});
//查看读取结果
a.flip();
b.flip();
c.flip();
debug(a);
debug(b);
debug(c);
} catch (IOException e) {
e.printStackTrace();
}
//集中写
try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) {
FileChannel channel = file.getChannel();
ByteBuffer d = ByteBuffer.allocate(4);
ByteBuffer e = ByteBuffer.allocate(4);
channel.position(11);
d.put(new byte[]{'f', 'o', 'u', 'r'});
e.put(new byte[]{'f', 'i', 'v', 'e'});
d.flip();
e.flip();
debug(d);
debug(e);
//集中写到channel里,直接write方法传一个ByteBuffer数组
channel.write(new ByteBuffer[]{d, e});
} catch (IOException e) {
e.printStackTrace();
}
TCP粘包与处理
经典的TCP
粘包半包问题
我们使用SocketChannel
就基于TCP
连接,当然有可能出现粘包半包问题,这里简单讲一下。
首先我们都知道TCP
是面向字节流的,且有滑动窗口机制,当接受方窗口较大,发送方第一条消息较小,不足以填满发送窗口(发送方窗口一般小于等于接收方返回的报文所携带的接收窗口大小),此时TCP
会等待下条消息,直到填满发送窗口,再去发送,即把多条消息或者说请求合并为一条发送,并且TCP
不会对消息之间做间隔,因为TCP
面向流,感知不到每次请求,只能感知到字节数据。所以需要我们手动给每条消息划分边界。这就是粘包。
同样情况下的半包是,当发送方窗口不足以容纳整条消息,就会将超出大小的部分放在之后的TCP
报文中发送,并且这种分割TCP
是不会做提醒的,同理因为它是面向字节流的协议,只处理数据流,是不区分不同请求的,可能我们从高层次看可以分清第一个包第二个包,但是TCP
处理时这些数据仅仅是一个一个字节,是一视同仁的。所以就出现一个请求被拆分为两个或多个TCP
包,也就是半包。我们同样需要对消息划分边界来保证正确处理半包数据。
既然这样,我们在接收到数据时就按字节流处理,读字节流直到读到我们定义的边界,就拆分出一条消息,完成分包。
三种方法:
- 固定请求大小(缺乏灵活性,且单个消息很小会做填充,很浪费性能)
- 特殊字符作为边界(例如以
\n
做终结符,读到表示一个\n
消息读完。但是很明显,消息内含有终结符就会出现读一半的情况,需要对终结符转义处理) - 自定义消息结构 (每条消息头部都是一个长度,表示该消息的字节数,自然可以通过这个长度分包)
具体的解决方案可以参考HTTP
的解决方案
在网上找了一个简洁的描述,来自这篇文章https://blog.csdn.net/weixin_42002747/article/details/124334361
HTTP
报文格式如下:
HTTP
请求报文格式
1)请求行:以\r\n
结束;
2)请求头:以\r\n
结束;
3)\r\n
;
3)数据;
HTTP
响应报文格式
1)响应行:以\r\n
结束;
2)响应头:以\r\n
结束;
3)\r\n
;
4)数据;
1)遇到第一个\r\n
表示读取请求行或响应行结束;
2) 遇到\r\n\r\n
表示读取请求头或响应头结束;
3)对于body数据,根据请求头或响应头的Content-Length
字段可以得知body
长度,读对应长度即可。还有个什么chunked
协议也可以,不过我不太了解这个。
可以看到,HTTP
利用后两种方法巧妙的解决了问题。以请求为例,请求行和请求头内部字段一般不会有\r\n
,所以不需要做转义处理,而对于数据部分,我们则直接通过请求头中的Content-Length
来找到该条消息体的末尾。
粘包半包处理练习
满老师课上的示例,这里直接贴上来。
有点像算法题,字符串分割,哈哈。
网络上有多条数据发送给服务端,数据之间使用 \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) {
//切换读模式
source.flip();
//获取数据长度
int oldLimit = source.limit();
//遍历ByteBuffer可读的每一个位置
for (int i = 0; i < oldLimit; i++) {
//读到分隔符
if (source.get(i) == '\n') {
System.out.println(i);
//position是当前读取消息的起点,i+1是终点,作差为该消息的长度,声明对应大小的ByteBuffer
ByteBuffer target = ByteBuffer.allocate(i + 1 - source.position());
//读取到target 并打印
// 0 ~ limit
source.limit(i + 1);
target.put(source); // 从source 读,向 target 写
debugAll(target);
source.limit(oldLimit);
}
}
source.compact();
}
ByteBuffer使用直接内存
这个是在后面讲零拷贝才提到的,涉及到ByteBuffer
,干脆放到这了。
ByteBuffer.allocate(10)
——>HeapByteBuffer
使用的还是 java 内存ByteBuffer.allocateDirect(10)
——>DirectByteBuffer
使用的是操作系统内存
使用直接内存减少了一次数据拷贝,用户态与内核态的切换次数没有减少,原理是直接内存也处于内核中,那么可以通过虚拟内存映射,直接把直接内存映射为对应读取的内核缓冲区,减少了这次拷贝。
这块不理解没关系,简单提一下,后面文章还会详细讲并且继续优化为零拷贝。
Channel与Buffer通信过程简单总结
仅仅是一个最简单的服务端和客户端,实际使用会涉及到Selector,这里仅做一个单线程通信。
首先想通信就要建立连接
客户端发出连接请求
服务端处理连接请求(ServerSocketChannel处理,并且要设置监听端口)
客户端发送消息
服务端接收并读取
满老师的非阻塞部分的示例代码
//服务端
// 0. ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 非阻塞模式
// 2. 绑定监听端口
ssc.bind(new InetSocketAddress(8080));
// 3. 连接集合
List<SocketChannel> channels = new ArrayList<>();
//处理连接请求,遍历SocketChannel接收数据
while (true) {
// 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
SocketChannel sc = ssc.accept(); // 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null
if (sc != null) {
log.debug("connected... {}", sc);
sc.configureBlocking(false); // 非阻塞模式
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);
}
}
}
//客户端 写数据
SocketChannel sc = SocketChannel.open();
sc.connect(new InetSocketAddress("localhost", 8080));
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.put("asdasdsasd");
sc.write(buffer);
当然实际的服务器与客户端不会这样简单,仅仅是客户端写,服务器读。
这里仅说明一下使用流程。
下篇会有Selector版本的使用流程,结合上Selector多路复用提高通信效率。
结语
写博客真的挺累的,这又写了两个半小时,但是感觉确实很专注。写的慢主要还是有些东西不懂需要查阅,还有些以前学过的需要复习回顾一下,所以写的很慢。感觉还是有收获的,复习才学到的,复习很久之前学到的,还能解锁一下新知识,因为有些东西想深入写一下就得去学一下。
感觉保证日更一篇吧,毕竟还要学东西,不能花自己一半乃至全部的时间写博客,反而不学东西了,那就舍本逐末了。
如果觉得这篇博客写的不错,可以给小弟点个赞。
如果想继续追更netty可以点个关注。
最后感谢您的耐心阅读,欢迎批评指正,下篇博客见。