最近在忙hadoop-common项目的国密算法适配,其中有涉及到一个基本问题引起我的注意,就是bytebuffer和byte[]的转化,网上的资料太繁杂,而且大多数感觉没讲清楚,我在这里做下整理。而且想到面试的时候的一道经典面试题就是如何设计缓存,正好借着这个机会一起归总一下。这篇应该门槛不高,不过也需要一些java nio和缓存的基础,所以放到后台里面了。
一,什么是ByteBuffer?
关于缓存
首先简单提一下什么是缓存,有这么一个例子:
从硬盘disk到内存memory之间有很长一段路要走,我们要把数据从disk搬到memory的消耗会很大,这严重影响了计算机的性能
disk ------------------------------------------------------> memory
这个时候,如果想提高效率,就要在中途设计一个缓存buffer来减少搬运的消耗,需要的话我可以直接从buffer中取,像这样
disk------------------------------------->buffer----------->memory
当然,这只是一个简单的模型,具体Java多线程的场景缓存的用法会更复杂一些,这里不再赘述。
Java为8种类型都提供了自己的buffer类型,这里讲最常用的ByteBuffer,ByteBuffer在java.nio这个包中继承了buffer类。学习java最好的方式是源码,我们就从源码入手来剖析ByteBuffer到底是什么,
ByteBuffer种类
首先,ByteBuffer分为direct直接读写buffer和non-direct非直接读写的buffer。了解这个其实和ByteBuffer的底层架构有关系:
fig.1 ByteBuffer继承结构
这里边的HeapByteBuffer是在jvm堆上面的一个buffer,底层的本质是一个数组,由于内容维护在jvm里,所以把内容写进buffer里速度会快些;并且,可以更容易回收,外设读取jvm堆里的数据时,不是直接读取的,而是把jvm里的数据读到一个内存块里,再在这个块里读取的;
而ByteBuffer也可以选择支持通过JNI从本机代码创建直接字节缓冲区,DirectByteBuffer的底层的数据其实是维护在操作系统的内存中,而不是jvm里,DirectByteBuffer里可以通过将文件的区域直接映射到内存中来创建,使用directByteBuffer跟外设(IO设备)打交道时会快很多,可以省去读取内存块这一步,实现零拷贝。
ByteBuffer可以通过isDirect()方法返回值来判定是否是direct型的。
这里省略讲解Java基于ByteBuffer的大小端转换来访问二进制数据。
ByteBuffer属性及构造方法
// Creates a new buffer with the given mark, position, limit, capacity,
// backing array, and array offset
//
ByteBuffer(int mark, int pos, int lim, int cap, // package-private
byte[] hb, int offset)
{
super(mark, pos, lim, cap);
this.hb = hb;
this.offset = offset;
}
// Creates a new buffer with the given mark, position, limit, and capacity
//
ByteBuffer(int mark, int pos, int lim, int cap) { // package-private
this(mark, pos, lim, cap, null, 0);
}
byte[] buff //buff即内部用于缓存的数组。
position //position类似于读写指针,表示当前读(写)到什么位置
mark //为某一读过的位置做标记,便于某些时候回退到该位置。可以用于mark()/reset()
capacity //初始化时候的容量。Capacity在读写模式下都是固定的,就是我们分配的缓冲大小。
limit //当写数据到buffer中时,limit一般和capacity相等,在读模式下表示最多能读多少数据,此时和缓存中的实际数据大小相同。
offset //当前数据的偏移
基本操作
put
写模式下,往buffer里写一个字节,并把postion移动一位。写模式下,一般limit与capacity相等。
flip
写完数据,需要开始读的时候,将postion复位到0,并将limit设为当前postion。也就是说调用flip之后,读写指针指到缓存头部,并且设置了最多只能读出之前写入的数据长度(而不是整个缓存的容量大小)。
get
从buffer里读一个字节,并把postion移动一位。上限是limit,即写入数据的最后位置。
clear
将position置为0,并不清除buffer内容。
常规方法
ByteBuffer allocate(int capacity) 创建一个指定capacity的ByteBuffer。
ByteBuffer allocateDirect(int capacity) 创建一个direct的ByteBuffer,这样的ByteBuffer在参与IO操作时性能会更好
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length) 把一个byte数组或byte数组的一部分包装成ByteBuffer。新buffer的capacity是array的长度,offset=postion,mark未定义,limit和原定义一样
byte get(int index)
ByteBuffer put(byte b)
int getInt() 从ByteBuffer中读出当前position标志的下4个字节组成一个int值。
ByteBuffer putInt(int value) 写入一个int值到ByteBuffer中。get和put其他类型的以此类推
特殊方法
Buffer clear() 把position设为0,把limit设为capacity,一般在把数据写入Buffer前调用。
Buffer flip() 把limit设为当前position,把position设为0,一般在从Buffer读出数据前调用。
Buffer rewind() 把position设为0,limit不变,一般在把数据重写入Buffer前调用。
compact() 将 position 与 limit之间的数据复制到buffer的开始位置,复制后 position = limit -position,limit = capacity, 但如 果position 与limit 之间没有数据的话发,就不会进行复制。
mark() & reset() 通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。
同时ByteBuffer还提供了如下所示的链式调用以及和其他类型buffer转换的接口
bb.putInt(0xCAFEBABE).putShort(3).putShort(45);
二,ByteBuffer到底怎么和byte[]互转?
如果第一节的东西领会了,就知道我们现在面对的这个问题其实就是一个调用buffer读写的问题,解决方法也显而易见。拿密码的加解密做例子:
// byte[]转化成ByteBuffer
public ByteBuffer encodeValue(byte[] value) {
ByteBuffer byteBuffer = ByteBuffer.wrap(value);
return byteBuffer;
}
// ByteBuffer转化成byte[]
public byte[] decodeValue(ByteBuffer bytes) {
int len = bytes.limit() - bytes.position();
byte[] bytes1 = new byte[len];
bytes.get(bytes1);
return bytes1;
}
byte[]转化成ByteBuffer,直接使用ByteBuffer封装好的wrap方法;Buffer转成byte[],先确定好我们需要的数组长度,然后调用get方法。
又上边的ByteBuffer转byte[],我们可以再思考一个问题,怎么判断两个ByteBuffer中的值相等?
ByteBuffer中的equals方法是这么写的:
/* <p> A byte buffer is not equal to any other type of object. </p>
*
* @param ob The object to which this buffer is to be compared
*
* @return <tt>true</tt> if, and only if, this buffer is equal to the
* given object
*/
public boolean equals(Object ob) {
if (this == ob)
return true;
if (!(ob instanceof ByteBuffer))
return false;
ByteBuffer that = (ByteBuffer)ob;
if (this.remaining() != that.remaining())
return false;
int p = this.position();
for (int i = this.limit() - 1, j = that.limit() - 1; i >= p; i--, j--)
if (!equals(this.get(i), that.get(j)))
return false;
return true;
}
其实就是通过position和limit指针,比较缓存remaining元素是否相等,把remaining部分的byte一个一个取出比较判断。
由此我的问题解决了,操作buffer类的对象时要想到buffer是怎么读怎么写,怎么调用方法的,才能合理正确的使用和应对buffer带来的问题,不能只看方法名自己去臆想。
三,如何设计缓存?
那么,我们再把问题升华一下,看了这么多我们应该如何设计缓存呢?
起初我的想法是设计缓存=选择合理的数据结构执行读写操作。了解过Java实现的ByteBuffer之后感觉了解要更深刻而去buffer设计绝不止于此。我们在这抽取关键信息,整理一下设计怎么设计缓存的思路。
1. 首先我们要选择合适的缓存属性和构造方法
为了配合读写位置的控制,要有读写指针,通常的实现是有一个读指针,有一个写指针。每次要解决它们相遇问题重新调整或抛异常。现在我们可以仿造java的buffer进行读写的切换,最简单构造一个position指针和limit指针就好了。
2. 然后我们要满足buffer的基本需求就是要实现数据的读写,落到实现上就是get/put方法
数据可以存在内存中的数据结构,你可以直接调用数据结构封装好的get和put方法。如果能力可以的话,可以仿造java的direct buffer和JVM buffer实现底层一点,粘个例子:
public ByteBuffer get(byte[] dst, int offset, int length) {
checkBounds(offset, length, dst.length);
if (length > remaining())
throw new BufferUnderflowException();
System.arraycopy(hb, ix(position()), dst, offset, length);
position(position() + length);
return this;
}
不过一般的话,你也要考虑读写过程中的问题,比如检查边界,调整position,抛出异常等。
3. 这还没完,有了读写方法,还要考虑读写的切换,ByteBuffer的主体结构是
allocate -> 分配capacity(可以使用构造方法)
flip -> 写换读 limit=position position=0
rewind -> 读换写 position = 0
clear -> 指针归位
以上就可以完成一个简单的缓存设计了。
参考资料:
1. https://docs.oracle.com/javase/8/docs/api/java/nio/ByteBuffer.html