当前位置: 首页 > 科技观察

玩转ByteBuffer

时间:2023-03-18 12:29:59 科技观察

本文转载自微信公众号《SH的全栈笔记》,作者SH的全栈笔记。转载本文请联系SH全栈笔记公众号。为什么要讲Buffer首先,为什么要单独讲一个小的Buffer呢?或者,Buffer在哪里使用?比如我们从磁盘中读取一个文件,并不是直接从磁盘中读取,而是先将磁盘中的数据复制到内核缓冲区中,然后再将数据从内核缓冲区中复制到用户缓冲区中,看起来是这样的图中:从磁盘读取文件,然后再比如我们写文件到磁盘的时候,并不是直接把数据写到磁盘。取而代之的是,数据从用户缓冲区写入内核缓冲区,操作系统一有机会就将其闪存到磁盘。图和上图差不多,就不画了,自己看懂。再比如,当服务端收到客户端发送的数据时,并没有直接发送到用户态的Buffer中。而是先从网卡复制到内核态的Buffer,再从内核态的Buffer复制到用户态的Buffer。那么为什么要打扰呢?抄抄抄,首先我们用排除法排除这个好玩的。Buffer的目的是减少与设备(如磁盘)交互的频率。在之前的博客中也提到过“磁盘的读写是一个非常昂贵的操作”。那么成本在哪里呢?简单的说,与设备的交互(比如与磁盘的IO)会被设计成中断操作系统。中断需要保存上一个进程的运行上下文。中断后需要恢复上下文,还涉及到内核态和用户态的切换,一般都是比较耗时的操作。看到这里,如果你对操作系统不熟悉,可能会有些摸不着头脑。比如:什么是用户态,什么是内核态?大家可以看看我之前写的这篇文章?Buffer。我们通过Java中NIO包中实现的Buffer来给大家讲解。Buffer的实现有7种,包括了Java实现的所有数据类型。Buffer的类型(一)本文中我们使用的是ByteBuffer,其常用的方法有:putgetfliprewindmarkresetclear接下来我们将通过实例来了解这些方法。Putput是将数据写入到ByteBuffer中,它有很多重载的实现:}我们可以直接传入ByteBuffer对象,也可以直接传入原始字节数组,也可以指定写入的偏移量和长度等。看一个具体的例子:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s','h'});}为了让大家更直观的看到ByteBuffer的内部情况,我整理成图的形式.上面的代码运行后,缓冲区的内部是这样的:put当你尝试使用System.out.println(buffer)打印变量buffer时,你会看到这样的结果:java.nio.HeapByteBuffer[pos=2lim=16cap=16]图片和控制台中有position和limit变量。容量大家可以理解,就是我们创建这个ByteBuffer的大小16。至于另外两个变量,相信大家也可以从图中看出,position变量指向了下一次要写入的下标。上面的代码我们只写了2个字节,所以position指向2,这个limit就比较有意思了,这个在后面的使用中会结合例子来讨??论。getget是从ByteBuffer中获取数据。publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s','h'});System.out.println(buffer.get());}如果运行上面的代码后,你会发现打印出来的结果是0,并不是我们期望的s的ASCII码115。首先,让我告诉你结论。这符合预期,这个时候应该取不到值。我们看一下get的源码:)thrownewBufferUnderflowException();//这里的位置会向后移动一位position=p+1;returnp;}当前位置为2,限制为16,所以nextGetIndex计算出来的值为变量p的值2,又是ix,即2+0=2,其中偏移值默认为0。所以简单来说就是最终会把下标为2的数据取出来,如下图所示。所以我们当然拿不到数据。但是这里需要注意的是,调用get方法虽然没有获取到任何数据,但是会让position指针向后移动。换句话说,采取了立场。如果连续多次调用这种get之后再调用put方法写入数据,会导致部分位置未分配。例如,假设我们运行以下代码:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s','h'});buffer.get();buffer.get();buffer.get();buffer.get();buffer.put(newbyte[]{'e'});}数据会变成如下图,位置会向后移动。你可能会问,如果真的需要获取数据怎么办?在这种情况下,我可以这样得到它:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s'});System.out.println(buffer.get(0));//115}传入我们要获取的下标,可以直接获取,不会导致位置后移。看到这里,你更加糊涂了。如果你合着了get(),你就不能用了?你还必须给出一个索引。这就需要讲到另一种方法flip。flip废话不多说,先看例子:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s','h'});//爪哇。nio.HeapByteBuffer[pos=2lim=16cap=16]buffer.flip();System.out.println(buffer);//java.nio.HeapByteBuffer[pos=0lim=2cap=16]}有趣的事情发生了,调用之后flip,位置由2变为0,limit由16变为2,这个词是“翻转”的意思。我个人的理解是,如果把之前保存的东西全部翻一遍,会发现position变成了0,limit变成了2,这个范围只是一个取值范围。.接下来就更有意思了:.out.println((char)buffer.get());//sSystem.out.println((char)buffer.get());//h}调用flip后,之前得不到的get()实际上它有效。结合get中给出的源码,不难分析。由于位置变为0,最终计算结果为0,同时位置向后移动一位。终于到了这里,可以了解到Buffer有两种状态,分别是:读模式和写模式。刚刚创建的ByteBuffer处于写入模式。我们可以通过调用flip将ByteBuffer切换到读取模式。但需要注意的是,这里所说的读写模式只是一个逻辑概念。比如调用flip切换到所谓的write模式后,仍然可以调用put方法向ByteBuffer写入数据。publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s','h'});buffer.flip();buffer.put(newbyte[]{'e'});}这里的put操作还是成功的,但是你会发现最后写入的e覆盖了之前的数据,现在ByteBuffer的值变成了eh而不是sh。flip_put所以你现在应该能理解readmode和writemode更多的意思应该是:一个方便你读的模式一个方便你写的模式如果大于等于值的限制,程序将抛出BufferUnderflowException。这个从前面get的源码也可以看出。rewindrewind也可以理解为读模式运行的命令,举个例子:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'s','h'});buffer.flip();System.out.println((char)buffer.get());//sSystem.out.println((char)buffer.get());//h//从头开始??读取buffer.rewind();System.out.println((char)buffer.get());//sSystem.out.println((char)buffer.get());//h}所以从头调用开始读取就是返回位置到下标0的位置,它的源码也很简单:publicfinalBufferrewind(){position=0;mark=-1;returnthis;}rewind只是简单的把position赋值为0,而mark赋值为-1.那么这个标记是什么?这就是我们接下来要讲的方法。publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'a','b','c','d'});//切换到读取模式buffer.flip();System.out.println((char)buffer.get());//aSystem.out.println((char)buffer.get());//b//记住当前位置buffer.mark();System.out.println((char)buffer.get());//cSystem.out.println((char)buffer.get());//d//重新设置位置到的位置标记buffer.reset();System.out.println((char)buffer.get());//cSystem.out.println((char)buffer.get());//d}可以看出,当position等于2时,我们调用mark来记住position的位置。然后遍历所有数据。然后调用reset让position回到2的位置,我们继续调用get,又可以打印出cd了。clearclear表面上是清除缓冲区的意思,其实不然,看这个:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'a','b','c','d'});}put之后buffer的情况是这样的。在我们调用clear之后,缓冲区将如下所示。所以,你可以理解为调用clear之后,只是切换到write模式,因为此时向其中写入数据会覆盖之前写入的数据,相当于起到了clear的作用。另一个例子:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put(newbyte[]{'a','b','c','d'});buffer.clear();buffer.put(newbyte[]{'s','h'});}可以看出,运行后buffer中的数据变成了shcd,后面写入的数据覆盖了前面的数据。除了clear可以切换到writemode,还有一个方法可以切换,就是本文compact的最后一个方法。compact先一句话给出compact的功能:将未读数据移动到Buffer头部,并切换到写入模式,代码如下:publicstaticvoidmain(String[]args){ByteBufferbuffer=ByteBuffer.allocate(16);buffer.put("abcd".getBytes(StandardCharsets.UTF_8));//切换到读取模式buffer.flip();System.out.println((char)buffer.get());//a//不会将读取的数据移动到缓冲区的第一部分buffer.compact();//此时缓冲区的数据会变成bcdd}运行翻转后,缓冲区的状态应该没问题:在运行flip和compact之后发生了什么?简单的说就是两件事:把position移动到对应的位置,把未读数据移动到buffer的头部。信件是什么?让我举一个例子;例如未读数据为bcd,则位置为3;如果未读数据为cd,则position为2。所以你发现position的值是未读数据的长度。从buffer内部实现机制来看,所有在position-limit范围内的数据都被认为是未读数据。因此,运行compact后,buffer是这样的:运行compact后,limit为16,因为compact使buffer进入了所谓的write模式。还有一些其他的EOF方法没有在这里列出。有兴趣的可以自己玩,理解起来没有难度。以后可能会专门写Channel和Selector。毕竟Java的nio三剑客,有兴趣的可以关注一下。