1. ByteBuffer 详解
1.1 ByteBuffer 是抽象类
有两个实现类
- HeapByteBuffer
- 在JVM内的堆内存中 -> 读写操作效率低 会受到GC的影响
- MappedByteBuffer(DirectByteBuffer)
- 在OS内存中 -> 读写操作效率高 不会受到GC影响
- 如果不主动释放 会造成内存泄漏
1.2 ByteBuffer的创建
- ByteBuffer.allocate(10)
- 直接分配内存,分配10个字节的内存空间。
- ByteBuffer.allocateDirect(10)
- 直接分配内存,分配10个字节的内存空间。
- ByteBuffer.wrap(byte[] b)
- 将数组b包装成一个新的ByteBuffer。
注意:ByteBuffer一旦被分配了空间就不能再被修改 也就是不会自动扩容
1.3 ByteBuffer的特点
- 缓冲区,可以理解为一个数组,可以存放多个数据。
- 缓冲区可以存放基本数据类型,也可以存放引用数据类型。
- 缓冲区可以进行读写,可以指定读写的起始 位置,可以指定读写的长度。
- 缓冲区可以进行排序。
1.4 ByteBuffer 的 API
- put() 写入数据
- get() 读取数据
- flip() 切换读写模式
- rewind() 重置读写位置
- clear() 清空缓冲区
- mark() 标记读写位置
- reset() 重置读写位置
- compact() 压缩缓冲区
- equals() 判断两个缓冲区是否相等
- compareTo() 比较两个缓冲区
1.5 核心结构
一个Buffer中包含三个属性:position、limit、capacity。
- position:表示当前的位置,初始值为0。buffer当前缓存的下标,在读取操作时记录读到了那个位置,在写操作时记录写到了那个位置。从0开始,每读取一次,下标+1
- limit:表示当前缓冲区的上限,初始值和capacity值相同。读写限制,在读操作时,设置了你能读多少字节的数据,在写操作时,设置了你还能写多少字节的数据
- capacity:表示缓冲区的容量,初始值和创建时指定的容量相同。 buffer的容量,类似于数组的size
2. ByteBuffer 的 读写模式
2.1 写模式
写入Buffer数据之前要设置写模式
- 新创建的Buffer自动是写模式
- 调用了clear,compact方法
2.2 读模式
读取Buffer数据之前要设置读模式
调用flip方法
// buffer中写入数据[写模式 创建一个bytebuffer ,clear(),compact()]
// 1. channel的read方法
channel.read(buffer)
// 2. buffer的put方法
buffer.put(byte);
buffer.put((byte)'a');
buffer.put(byte[]);
// 从buffer中读出数据
1. channel的write方法
2. buffer的get方法 //每调用一次get方法会影响,position的位置。
3. rewind方法(手风琴),可以将postion重置成0 ,用于复读数据。
4. mark&reset方法,通过mark方法进行标记(position),通过reset方法跳回标记,从新执行.
5. get(i) 方法,获取特定position上的数据,但是不会对position的位置产生影响。
ByteBuffer 对字符串操作示例
// ByteBuffer buffer = Charset.forName("UTF-8").encode("hello");
// 1、encode方法自动 把字符串按照字符集编码后,存储在ByteBuffer.
// 2、自动把ByteBuffer设置成读模式,且不能手工调用flip方法。
ByteBuffer buffer = StandardCharsets.UTF_8.encode("hello");
while (buffer.hasRemaining()) {
System.out.println("buffer.get() = " + (char) buffer.get());
}
buffer.clear();
// Buffer中的数据转换成字符串
ByteBuffer buffer = ByteBuffer.allocate(10); // 新建ByteBuffer 默认为写模式
buffer.put("world".getBytes());
buffer.flip(); // 切换读模式
CharBuffer result = StandardCharsets.UTF_8.decode(buffer);
System.out.println("result.toString() = " + result.toString());
3.粘包与半包
3.1 粘包
粘包是指发送方在发送数据时,多个小包被合并为一个大包发送到接收方,导致接收方无法准确区分每个小包的边界。
就是一条消息没有边界粘连在一起 hello world!你好世界!
正常消息应该是
hello world!
你好世界!
两句话粘连在了一起
所以我们自己区分消息的边界,在消息结尾加上自定义分隔符,比如\n _end都可以
在Netty中也有这样的实现,不过Netty已经帮我们实现好了多种解决粘包半包的方法
3.2 半包
半包是指发送方在发送数据时,一个大包被拆分为多个小包发送到接收方,导致接收方无法完整地接收到每个小包。这种情况可能发生在网络传输过程中发生了分割、丢包或其他错误的情况下。接收方在接收到不完整的数据时,需要进行特定的处理才能确保完整地接收到每个小包。
How are you?\n I am
fine!Thank you!\n
一句话被拆分得不完整,导致出现半包现象。
为了后续的学习,我们自己用NIO来封装一下
public static void main ( String[] args ) {
ByteBuffer byteBuffer = ByteBuffer.allocate(50);
byteBuffer.put("hello world!\n你好世界!\n".getBytes()); //粘包
doLineSplit(byteBuffer);
byteBuffer.put("how are you?\n i m".getBytes());
doLineSplit(byteBuffer);
byteBuffer.put(" fine, think you\n".getBytes()); // 半包
doLineSplit(byteBuffer);
}
// ByteBuffer接受的数据 \n
private static void doLineSplit(ByteBuffer buffer) {
buffer.flip();
for (int i = 0; i < buffer.limit(); i++) {
if (buffer.get(i) == '\n') {
int length = i + 1 - buffer.position();
ByteBuffer target = ByteBuffer.allocate(length);
for (int j = 0; j < length; j++) {
target.put(buffer.get());
}
//截取工作完成
target.flip();
System.out.println("StandardCharsets.UTF_8.decode(target).toString() = " + StandardCharsets.UTF_8.decode(target).toString());
}
}
buffer.compact();
}
buffer.compact();进行数据处理,把第一次没有读取完的数据,向前移动和后面的内容进行整合。
就比如读完了how are you?\n 一条消息还剩i m没有读完,就把i m往前移,后面读到的数据fine, think you\n加到i m后面,就能得到一条完整的消息。
4. NIO的开发使用
4.1 文件操作
使用 channel 写入数据到文件
public static void main ( String[] args ) throws IOException {
//1 获得Channel FileOutputStream, RandomAccessFile
FileChannel channel = new FileOutputStream("data1").getChannel();
//2 获得Buffer
ByteBuffer buffer = Charset.forName("UTF-8").encode("hello world");
//3write
channel.write(buffer);
}
4.2 网络编程
服务端接收请求通过 -> ServerSocketChannel
进行实际通信是 -> SocketChannel
先来写一下NIO的客户端
连接服务端 向服务端写入数据
public class MyClient {
public static void main(String[] args) throws IOException {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(8000));
System.out.println("---------");
socketChannel.write(StandardCharsets.UTF_8.encode("hello world\n"));
}
}
服务端
public class MyServer {
/**
* 通过这版代码 证明了 服务器端 存在2种阻塞
* 1. 连接阻塞 ----> accept方法存在阻塞---> ServerSocketChannel阻塞。
* 2. IO阻塞 ----> channel的read方法存在阻塞---> SocketChannel阻塞。
* @param args
* @throws Exception
*/
public static void main(String[] args) throws Exception {
//1. 创建ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//2. 设置服务端的监听端口:---》client通过网络进行访问 ip:port http://localhost:8989
serverSocketChannel.socket().bind(new InetSocketAddress(8000));
List<SocketChannel> channelList = new ArrayList<>();
ByteBuffer buffer = ByteBuffer.allocate(20);
//3. 接收client的连接
while ( true ){
//4. SocketChannle 代表 服务端与Client链接的一个通道
System.out.println("等待连接服务器...");
SocketChannel socketChannel = serverSocketChannel.accept();//阻塞 程序等待client
System.out.println("服务器已经连接..."+socketChannel);
channelList.add(socketChannel);
//5. client与服务端 通信过程 NIO代码
for (SocketChannel channel : channelList) {
System.out.println("开始实际的数据通信....");
channel.read(buffer);//阻塞 对应的IO通信的阻塞
buffer.flip();
CharBuffer decode = Charset.forName("UTF-8").decode(buffer);
System.out.println("decode.toString() = " + decode.toString());
buffer.clear();
System.out.println("通信已经结束....");
}
}
}
}
可以看到服务端一直在死循环监听客户端的连接,一直在空转,非常的消耗CPU的性能,可能会打满CPU,该怎么办呢?
这时候就需要引入Selector了。
4.3 Selector
定义:
Selector(选择器)是Java NIO(New IO)库中的一个关键组件,用于实现非阻塞IO(Non-blocking IO)。Selector允许单个线程同时监听多个通道(Channels),并在这些通道就绪时进行处理,从而实现高效的IO操作。
概念:
- 选择器(Selector):
选择器是Selector类的实例,用于管理一组通道的就绪状态。它允许单个线程同时监听多个通道,并在通道就绪时进行处理。通过使用选择器,可以实现非阻塞IO,避免了每个通道都需要一个线程进行阻塞等待的情况。 - 注册通道(Registering Channels):
通过调用通道的register()方法将通道注册到选择器上,以便选择器能够监听该通道的就绪状态。可以指定感兴趣的操作类型(如读、写、连接、接收)来告诉选择器监听哪些事件。 - 选择操作(Selecting Operations):
选择器提供了select()方法,用于阻塞地等待通道就绪事件。当至少一个通道就绪时,该方法会返回,然后可以通过调用选择器的其他方法(如selectedKeys())获取就绪通道的集合,进一步处理这些就绪事件。 - 就绪通道集合(Selected Channel Set):
当选择器中的通道就绪时,可以通过调用选择器的selectedKeys()方法获取就绪通道的集合,它返回一个SelectionKey对象的集合。SelectionKey包含了就绪通道的信息,可以通过它来获取通道、操作类型等相关信息。 - 多路复用(Multiplexing):
Selector使用操作系统提供的多路复用机制来实现高效的IO操作。在大多数操作系统中,多路复用使用了一些底层的系统调用(如select、poll、epoll等)来监听多个IO事件,从而避免了阻塞式IO操作带来的性能问题。
下面我们来改造一下服务端。
public class MyServer2 {
public static void main ( String[] args ) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(8000));
serverSocketChannel.configureBlocking(false);//Selector 只有在非阻塞的情况下 才可以使用。
//引入监管者
Selector selector = Selector.open();//1. 工厂,2. 单例
//监管者 管理谁? selector.xxxx(ssc); //管理者 ssc ---> Accept
SelectionKey selectionKey = serverSocketChannel.register(selector, 0, null);
// selector监控 SSC ACCEPT
// selector
// keys --> HashSet
// register注册 ssc
selectionKey.interestOps(SelectionKey.OP_ACCEPT);
System.out.println("MyServler2.main");
//监控
while ( true ) {
selector.select();//等待.只有监控到了 有实际的连接 或者 读写操作 ,才会处理。
//对应的 有ACCEPT状态的SSC 和 READ WRITE状态的 SC 存起来
// SelectionsKeys HashSet
System.out.println("-------------------------");
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while ( iterator.hasNext() ) {//ServerSocketChannel ScoketChannel
SelectionKey key = iterator.next();
//用完之后 就要把他从SelectedKeys集合中删除掉。 ServerSocketChannel---SelectedKeys删除 ,后续 SSC建立新的连接
iterator.remove();
if ( key.isAcceptable() ) {
//serverSocketChannel.accept();
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
//监控sc状态 ---> keys
SelectionKey sckey = sc.register(selector, 0, null);
sckey.interestOps(SelectionKey.OP_READ);
} else if ( key.isReadable() ) {
try {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(5);
int read = sc.read(buffer);
if ( read == - 1 ) {
key.cancel();
} else {
buffer.flip();
System.out.println("Charset.defaultCharset().decode(buffer).toString() = " + Charset.defaultCharset().decode(buffer).toString());
}
} catch ( IOException e ) {
e.printStackTrace();
key.cancel();
}
}
}
}
}
}
前面写了处理粘包半包的,咱们是不是得用上,再加上对缓冲区的扩容,(小声)这些在Netty都帮我们自动实现了。
private static void doLineSplit ( ByteBuffer buffer ) {
buffer.flip();
for ( int i = 0; i < buffer.limit(); i++ ) {
if ( buffer.get(i) == '\n' ) {
int length = i + 1 - buffer.position();
ByteBuffer target = ByteBuffer.allocate(length);
for ( int j = 0; j < length; j++ ) {
target.put(buffer.get());
}
//截取工作完成
target.flip();
System.out.println("StandardCharsets.UTF_8.decode(target).toString() = " + StandardCharsets.UTF_8.decode(target).toString());
}
}
buffer.compact();
}
public static void main ( String[] args ) throws IOException {
----------------------------省略部分,完整代码请参考上文-----------
if ( key.isAcceptable() ) {
//serverSocketChannel.accept();
ServerSocketChannel channel = (ServerSocketChannel) key.channel();
SocketChannel sc = channel.accept();
sc.configureBlocking(false);
//监控sc状态 ---> keys
ByteBuffer buffer = ByteBuffer.allocate(7);
SelectionKey sckey = sc.register(selector, 0, buffer); // channel 绑定 buffer
sckey.interestOps(SelectionKey.OP_READ);
} else if ( key.isReadable() ) {
try {
SocketChannel sc = (SocketChannel) key.channel();
// ByteBuffer buffer = ByteBuffer.allocate(7); // 创建了新的 bytebuffer
ByteBuffer buffer = (ByteBuffer) key.attachment();// 拿到绑定的buffer
int read = sc.read(buffer);
if ( read == - 1 ) {
key.cancel();
} else {
// buffer.flip();
//System.out.println("Charset.defaultCharset().decode(buffer).toString() = " + Charset.defaultCharset().decode(buffer).toString());
// 处理半包粘包
doLineSplit(buffer);
if ( buffer.position() == buffer.limit() ) {
// 容量不够了 缓冲区该扩容了
// 怎么扩容 ---》 空间扩大 2.旧的缓冲区 复制到新的缓冲区
ByteBuffer newByteBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
buffer.flip();
newByteBuffer.put(buffer.get());
// 3. 绑定新的缓冲区
key.attach(newByteBuffer);
}
}
} catch ( IOException e ) {
e.printStackTrace();
key.cancel();
}
}
}
那么客户端可以向服务端发送数据,服务端接收数据,那么客户端可不可以接收服务端发送的数据?
答案是肯定的。
// 客户端接收服务端数据
public class MyClient1 {
public static void main ( String[] args ) throws IOException {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress(8000));
int read = 0;
while ( true ){
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
read += socketChannel.read(buffer);
System.out.println("read = " + read);
buffer.clear();
}
}
}
// 服务端写出数据
public class MyServer5 {
public static void main ( String[] args ) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(8000));
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while ( true ) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while ( iterator.hasNext() ) {
SelectionKey sscKey = iterator.next();
iterator.remove();
if ( sscKey.isAcceptable() ) {
SocketChannel sc = serverSocketChannel.accept();
sc.configureBlocking(false);
SelectionKey scKey = sc.register(selector, SelectionKey.OP_READ);
// 准备数据
StringBuilder sb = new StringBuilder();
for ( int i = 0; i < 200000; i++ ) {
sb.append("s");
}
// NIO buffer 存数据 channel 写
ByteBuffer buffer = Charset.defaultCharset().encode(sb.toString());
int write = sc.write(buffer);
System.out.println("write = " + write);
if ( buffer.hasRemaining() ){
// 为了当前的SocketChannel 增加 write监听
// read + write 监听
scKey.interestOps(scKey.interestOps() + SelectionKey.OP_WRITE);
// 把剩余数据的buffer传过去
scKey.attach(buffer);
}
} else if ( sscKey.isWritable() ){
//
SocketChannel sc = (SocketChannel) sscKey.attachment();
ByteBuffer buffer = (ByteBuffer) sscKey.attachment();
int write = sc.write(buffer);
System.out.println("write = " + write);
if ( !buffer.hasRemaining() ){
sscKey.attach(null);
sscKey.interestOps(sscKey.interestOps() - SelectionKey.OP_WRITE);
}
}
}
}
}
}
至此NIO网络编程写完了,可以看出使用NIO编程有点繁琐,还要注意内存的回收,这更加体现了了Netty在网络编程的简易和便捷性,后续我们开始探讨怎么用Netty开发网络编程,敬请期待!
吐槽:掘金的代码块怎么没有格式化 不太好用
码文不易,给个赞支持一下吧。
文章参考:孙帅suns Netty应用