Java提供了四种类型来存储一个整型:Byte,short,int,long。但是如果整数的范围在[0,100000],那么只需要17bits就足够存储了,因为2^17=131072。但是,你不能够选择short来存储,因为short存储[65536,100000]之间的数会溢出。如果你用int来存储,那么每个数至少要浪费15bits的空间,大约47%的内存空间。
Lucene/Solr4.0最值得期待一个改进就是内存效率。经过一些基准测试发现,相对Lucene3.x而言,像Solr或者ElasticSearch这类基于Lucene的应用可以减少2/3的内存使用。Lucene中用到的一项技术就是位压缩(bit-packing).这意味着整型数组的类型从固定大小(8,16,32,64位)4种类型,扩展到了[1-64]位共64种类型。如果用这种方式存储17-bits大小的整型,那么相对int[]而言,会节约47%的内存。
Bit-packing的接口定义如下:
interface Mutable {
longget(int index);
voidset(int index, long value);
int size();
}
在这个接口下面,有4种不同内存效率和运行效率的实现。
1. Direct8, Direct16, Direct32 and Direct64 ,只是 byte[], short[],int[],long[]的一个简单包装,
2. Packed64, 以块连续的方式把数据存储到 64-bits (long类型) 大小的内存块里面。一个数值可能会跨两个内存块。
3. Packed64SingleBLock,看起来像 Packed64,但实际上利用位填充来做代替跨多个块的数据存储。 (每个值最大占用32 bits),
4. Packed8ThreeBlocks和 Packed16ThreeBlocks, 用 3 bytes (每个值24 bits) or 3 shorts (每个值48 bits)来存储数据。
具体的实现可以参看Lucene的源代码。
Direct{8,16,32,64}
这些类的方法直接把操作映射到对应的数组里:
§ Direct8: byte[],
§ Direct16: short[],
§ Direct32: int[],
§ Direct64: long[].
这些类的执行效率会非常高,但是缺点也是相当明显。如果要存储17-bits的数,就需要用Direct32,内存依然浪费了47%。
Packed64
这种实现方式把数值依次存储到64-bits 块中。这种实现方式是最紧凑的,如果要存储100万的17-bits数值,就只需要17*1000000/8 ~= 2MB内存空间。唯一的一个缺陷就是一些数值会跨越两个不同的Blocks。为了提高程序运行效率,在编码实现上就需要一些技巧,比如以不同的偏移量和掩码值更新两个Block.
Packed64SingleBlock
这种实现和Packed64相似,但是一个数值不会跨越多个Block。例如:如果要存储21-bits数值,那么一个64-bits Block只能存储3个数值,剩下的1bit就浪费了(大约有2%的空间损失)。下表是类中已经实现的N-bits数值存储表
Bits per value | Values per block | Padding bits | Space loss |
32 | 2 | 0 | 0% |
21 | 3 | 1 | 2% |
16 | 4 | 0 | 0% |
12 | 5 | 4 | 6% |
10 | 6 | 4 | 6% |
9 | 7 | 1 | 2% |
8 | 8 | 0 | 0% |
7 | 9 | 1 | 2% |
6 | 10 | 4 | 6% |
5 | 12 | 4 | 6% |
4 | 16 | 0 | 0% |
3 | 21 | 1 | 2% |
2 | 32 | 0 | 0% |
1 | 64 | 0 | 0% |
Packed{8,16}ThreeBlocks
这种类型的接口实现是用3-bytes或者3-shorts来存储一个数值。所以,它们只适用于24-bits 或者 48-bits数值。但是其最大值是Integer.MAX_VALUE/3,所以保存这些数值的数组用一个int寻址就可以了。
性能比较
与byte,short,int.,long这些原生的存储方式相比,内存空间利用率有非常大的提高,但是执行时间呢?直觉告诉我们会有一定的差距,但是差距有多大呢?Lucene和核心开发者Adrien Grand经过测试,得出的结论如下:Direct*接口实现比Packed64快3倍,比如Packed64SingleBlock快2倍,然而,有趣的是Packed*ThreeBlocks接口实现几乎跟Direct*一样快。但是当数据大小只有1bit或者2bits时,由于CPU缓存的缘故,Packed64 and Packed64SingleBLock运行效率快很多。
这些实现方式的读写效率如何呢?Lucene 核心开发者 Daniel Lemire作了一个测试,用C++实现的程序,用GNU GCC4.6.2 compiler对程序进行了优化。程序运行在macbook air(Inter core i7)上。他得出了如下的结论:一般而言,bit宽度越小,unpacking(读操作)越快。Packing(写操作)速率在bit-witdh=8或者bit-width=16时会更快一些。如下图:
尽管bit-packing技术可以明显地减少程序中的内存的使用,但是很少有人在程序中使用。这是因为:1、大多数应用中,开发者并不知道每个数值究竟需要多少bits。2、8,16,32,64 bits的数据类型是编程语言内置的,但是bit-packing需要额外的编码处理。但是不管怎样,bit-packing技术在降低内存使用上是十分出色的,特别是读操作上,而且并没有过多地损失性能。
前面的内容算是翻译的吧,反正是别人的东西,我只是用我自己的理解或者仅仅是中文转述了一遍。下面的内容则是根据代码领悟出来的东西。以Packed64SingleBlock4为例,研究Lucene中到底是如何实现bit-packing的。
Packed64SingleBlock4是把一个long类型(64-bit block)以4-bit为单位,分成了16个格子。每个格子可以存储的最大值是15. 其实现的代码如下:
存储的规则是:低对低,高对高:
开始存储4-bit[0]=15时,blocks[0]=0x000000000000000f=15
然后存储4-bit[1]=15时,blocks[0]= 0x00000000000000ff=255
然后存储4-bit[2]=15时,blocks[0]= 0x0000000000000fff=4095
……
然后存储4-bit[15]=15时,blocks[0]=0xffffffffffffffff=-1
然后存储4-bit[16]=15时,blocks[1]= 0x000000000000000f=15
即下标小的在long的低位,下标大的排列的long的高位。
注释后的代码如下:
@Override
publiclongget(int index) {
//定位到blocks中相应的64-bits槽中
//notice: index >>> 4 = index/64
finalint o = index >>> 4;
//定位到64-bits槽相应的格子中,一个64-bits槽只有16个格子
finalint b = index & 15;
//确定格子的偏移位置,(一个格子要占4-bits)
//notice: b<< 2 = b * 4
finalint shift = b << 2;
//通过 blocks[o] >>> shift 去除低位的内容
//通过 &15L 去除高位的内容,这样就正好取得4-bits格子里面的数值
return(blocks[o] >>> shift) & 15L;
}
@Override
publicvoidset(int index, longvalue) {
//与get方法相同
finalint o = index >>> 4;
finalint b = index & 15;
finalint shift = b << 2;
/*
* 15L << shift 表示把清空滑窗移动到对应的格子位置
* blocks[o] & ~(15L << shift) 其实是两步:第一步 ~(15L << shift) 得到的结果就是 000011…1;第二步 blocks[o] & ~(15L << shift)就正好把4-bits格子清空
* value<< shift 表示把数值滑窗移动到对应的格子位置,
由于前面已经清空的格子的内容, (blocks[o] & ~(15L << shift)) | (value << shift) 用“或”操作就正好可以把数值写入到格子中
* */
blocks[o] = (blocks[o] & ~(15L << shift)) | (value << shift);
}
代码关键的地方在于位操作,如果对位操作不了解,那么理解起来就会很困难。
参考博客:
http://blog.jpountz.net/post/25530978824/how-fast-is-bit-packing
http://lemire.me/blog/archives/2012/03/06/how-fast-is-bit-packing/