作者:星巴刻
作为 Java Nio 的一个基础部分,其提供的 java.nio.ByteBuffer 不易被正确使用简直让人无语,无人愿意为它辩白。ByteBuffer 本质只是 byte 数组的封装,但是与 byte 数组相比起来,要理解好,需要耗费点脑力。本文尝试用一种新的结构来解释 ByteBuffer,用以加速正确、轻松地掌握 ByteBuffer 的使用,希望通过本文的铺垫再去看 ByteBuffer 的注释、源代码以及应用代码,能掌握一种操控感。
一、工作区
ByteBuffer 虽然是 byte 数组的封装,但是应用程序如何使用数组是受约束的,极少直接通过指定数组下标的方式使用 ByteBuffer。
在此引入「工作区」的概念,用来助力理解。
工作区是一个两边伸缩变动长度的区域,它的最左边是始点(用 position 表示),右边是它的终点(用 limit 表示)。现在请用你的右手掌挡住工作区的终点(limit 处),左手掌从工作区的左边 position 处往右压。这个过程中,右边将保持静止(淡定的静止),随着左手掌往右动,有节奏地一动一停地往右,就像脉冲一样的节奏往右。这样,整个工作区将越来越小,position 位置渐渐地向 limit 点靠拢,直至两只手掌合在一起,此时 position 点和 limit 点重合。
要是觉得这个动作有点幼稚,那就对了,说明完全掌握 ByteBuffer 其实也没有很大难度。
工作区的大小,可由 limit - position 来表示,这也就是 ByteBuffer.remaining() 的实现
二、完成区 & 禁区
在工作区的左右两侧另外分别有 1 个区域:工作区的左侧是完成区,右侧是禁区,如下图。
这样整个 ByteBuffer 3 个区的结构就构建完毕了。这 3 个区先后顺序是固定的,但大小是变化的。3个区的宽度大小,最小可为 0,最大可为 capacity 大。3 个区看起来还是很乱,请不要被这个影响,一定要把注意力优先投资到两个手掌之间的工作区,这样已经足够。现在竖起双手掌,由于手是可以动的,所以左手表示的 position 以及右手表示的 limit 是可以变动的,特别是左手变动是最频繁。随着动作,工作区大小产生了变化,自然而然地也带动了左边完成区以及右边禁区的变化。
三、读/写操作
当对 ByteBuffer 进行操作时,所有操作都是在工作区上完成的!
进行 get() 时,每一次 get() 的调用,工作区中的 position 位置的字节被读出来,随后工作区的始点向右运动一个位置。随着不断地 get(),工作区的始点一点一点地往右运动,越变越小。// 手势做起来哈,右手掌不动,左手掌往右手掌的方向动,get 一次,动一次。尽量多做几遍
同理的,进行 put() 时,每一次 put() 调用,字节都写入到工作区的 position 位置中,随后工作区的始点向右运动一个位置。随着不断地 put(),工作区的始点一点一点地往右运动,越变越小。// put 和 get 的手势完全一样
一旦工作区大小变为 0 了,读写操作就不能再进行了,禁区是不可用于读写操作的。如果强行继续读取或写入,ByteBuffer 将分别抛出 BufferUnderflowException 或 BufferOverflowException 异常。
四、reset() 回到原先设置的 mark 处
随着 ByteBuffer 不断地工作,工作区始点逐渐往右运动,工作区越变越小。此时如果要重读刚才读取的内容,或者覆盖原先写入的内容,就可以调用 reset() 方法来满足这个需求,将工作区的始点拉回之前设置的 mark 点。
reset() 操作必须和 mark() 操作结合使用。调用 mark() 时候,ByteBuffer 会把当时工作区的始点记录下来(用 mark 表示这个位置),
调用 reset() 方法并不会把 mark 标识清除,后续可以多次使用。如果之前没有 mark() 过或者 mark 标识被 rewind()、flip()、clear() 这些操作清理过,调用 reset() 没有意义,ByteBuffer 会抛出异常。此时如果要回到某个点,建议直接使用 ByteBuffer.position(int) 搞定,所谓调用 position(int) 的本质也就是应用程序自己来维护 mark 记录,这也是一个好办法。
注意:reset 方法不是把缓冲区的字节设置为 0。
练习:如何用手势来模拟 reset() 操作呢?其实非常简单,保持右手掌不懂,左手掌向左稍微挪动几步。
五、rewind() 倒带重来
英文单词 rewind 有重倒的意思。调用 rewind() 就是把工作区的始点拉到 0 处,使得接下来的工作区从 ByteBuffer 的最开始处工作。这个有啥用呢?想来想去可能在「复读」这个场景比较有用:
当一个 ByteBuffer 要写到多个输出源的时候可以用得上:写入到第一个输出源后,完成区变大,工作区变小,通过调用 rewind() ,把工作区的始点拉到 ByteBuffer 最开始的地方,这样就可以重新从读取刚才已经读取的字节了。
在 ByteBuffer下 rewind() 就是 position(0)。所以,实际使用起来,直接使用 position(0) 可能更容易理解?另外一个区别点, position(int) 方法在 ByteBuffer 上,没在 Buffer 上。
练习:如何用手势来模拟 rewind() 操作呢?保持右手掌不懂,左手掌向左伸直移动到最大的可能就是了。// reset() 和 rewind() 在手势上的区分就是看左手伸的多少,到之前标记的是 reset(),伸到尽头的是 rewind()
四、flip() 翻转工作区
英文单词 flip 的意思有翻、转的意思,比如海狮在沙滩上玩耍翻来翻去,调皮的同学在地上做个腾空翻等等类似的意思。
把 flip 用在 ByteBuffer 上,主要是用来表达一个动机:对 ByteBuffer 完成写入的工作后,要开始从它里面读取信息。ByteBuffer要求,当对它从写入到读取的变化,需要应用程序来告知 ByteBuffer 提前做一些内部翻转工作,flip() 方法充当这个作用,由应用程序来调用。
现在深入到 flip() 内部。当程序不断把数据写到 ByteBuffer,完成区 将越来越大,充满了刚刚写入的数据,此时如果要将写入的数据读取出来,根据 ByteBuffer 的哲学,就需要先把这块完成区区域设置为 工作区 才能在这片区域上工作,按应用程序的预期完成任务。把完成区完全设置为工作区的操作工程中要注意 3 个细节就是:(1)新的工作区的终点就是原来完成区的终点、原来工作区的始点;(2)新的工作区的始点在最左边,因此新的工作区和旧的工作区大小没有任何关系,所以两者大小也不相等。(3) 旧的工作区变成现在新的工作区的右边了,所以它成为禁区的一部分。
flip() 这个方法是 ByteBuffer 的关键方法,重点记住这个方法吧。
练习:竖起两手的手掌,两只手中间代表的是 flip() 之前的工作区。然后两只手掌一起往左运动运动。左手掌拉伸到最左边的尽头,右手掌变动原来左手掌的位置。
六、clear() 全部变为工作区
clear() 把缓冲区全部变为工作区,工作区最大也不过如此了。clear() 操作是唯一一个把禁区变为工作区一部分的操作。可见 clear() 目的,就是让 ByteBuffer 有最大的工作空间去容纳一会进来的字节。显然,当 ByteBuffer 的信息全部被用来后,准备要从输入源中读出新的信息写入 ByteBuffer 时,要调用 clear()。
注意:clear 方法不是把缓冲区的字节设置为 0。
练习:竖起两手的手掌,然后分别向两边拉伸到尽头!
七、总结
用工作区的概念及其图解、手势的方式来理解 ByteBuffer,是本文的创新点。借助两手手掌模拟工作区,并演示 get、put、reset、rewind、position(i)、flip、clear 操作对手掌位置的影响可以有效地理解和记忆。这种办法对其他人有没有用我不清楚,反正自己是用上了,也轻松了许多。
如果以上有助于理解,接下来可以直接看下 java.nio.Buffer 的 Java Doc ,看看是否可以清晰一些,这个过程也是一次「思维加固」。
2017-11-23