JVM需要申请一块内存用于进程的使用,类、对象、方法等数据均保存在JVM堆栈也就是申请的这块内存之中,JVM也会负责帮我们管理和回收再利用这块内存。
相对的,堆外内存就是直接调用系统malloc分配的内存,这部分内存不属于JVM直接管理,也不受JVM最大内存的限制,通过引用指向这段内存。
堆外内存可以带来更小的GC压力,更少的内存碎片,以及零拷贝方面的性能优化。Hbase在读写路径上都进行了堆外内存的优化。
目前Hbase的堆外内存主要应用在4部分:MSLAB,Bucket cache,RPC,HDFS读写数据。
但是堆外内存需程序主动管理和释放。所以我们需要了解Hbase堆外内存的使用和配置,以尽可能少的减少堆外内存配置不当带来的问题。
Hbase内存管理整体架构下面主要详细解释Hbase的堆外内存使用部分,按图中的标号分别描述。
Hbase堆外内存分配器Hbase的堆外内存分两种类型,一种是Netty的NIO ByteBuffer,一种是Java的DirectByteBuffer。(测试性能后者较好)
堆外内存分配器分为3种:
Hbase自定义的仿Netty的堆外内存分配器ByteBuffAllocator,采用引用计数归零后重复利用的机制;
直接用JVM分配的一大块DirectByteBuffer,内部按一定的大小分页,采用淘汰后再重复利用的机制;
采用Netty堆外内存池PooledByteBufAllocator.DEFAULT分配的NIO ByteBuffer,由Netty管理;
1、Hbase自定义的ByteBuffAllocator目前是RS级别的内存分配器,返回包装了的Java ByteBuffer内存的分配池,分配NIO堆外内存和堆内内存。
如需申请堆外内存块,会优先从池子中拿,池子中不够时创建,已有内存块数量达到最大时,就是申请堆内的内存了。
1.1 应用目前使用ByteBuffAllocator分配内存的地方有两处:
1、从HFile读出data block和RAM cache中的block。每个data block会尽可能存储在一个堆外内存块中,以高效利用hadoop native lib的checksum。
而不足最小存储堆外内存大小的数据,会在堆内分配。如果表自定义的data block大小大于这里定义的堆外内存块大小,则每个data block可能占用多个堆外内存块和一部分堆内内存。
2、RPC请求response中的数据。读请求过滤出需要读取的cell block后,会用这里的堆外内存块存储数据,所以,这里的堆外内存其实是支持NIO的,
Netty的OutputChannel在返回数据时,不需要复制,可以直接读取这部分堆外内。
3、如果使用SimpleRPCServer,RPC请求request中的数据会也由ByteBuffAllocator分配内存存储。
1.2 引用计数这里分配的堆外内存,在两个地方会增加引用计数:
创建call;
创建WalEntry;
相应的,堆外内存的生命周期基本与server端处理RPC call及刷wal的生命周期一致。如果没设堆外bucket cache,Call结束后及wal写入文件后,会释放所有内存引用,将内存块重新放到池子中。
1.3 相关配置hbase.server.allocator.buffer.size:堆外内存块大小。默认是65KB(64KB table设定block大小 + delta,delta取决于最后一条数据的大小)
hbase.server.allocator.max.buffer.count:最大可分配堆外内存块的数量。
hbase.server.allocator.minimal.allocate.size:用最小可分配堆外内存数据的大小。默认为堆外内存块的1/6。
2、Memstore ChunkPoolHbase基于LSM树模型实现,所有的数据写入操作首先会顺序写入日志HLog,再写入MemStore,当MemStore中数据大小超过阈值之后再将这些数据批量写入磁盘,生成一个新的HFile文件。
MemStore从本质上来看就是一块缓存,可以称为写缓存。先说说memstore在堆上的问题,和由此引发的它的进化。
众所周知在Java系统中,大内存系统总会面临GC问题,MemStore本身会占用大量内存,因此GC的问题不可避免。
不仅如此,Hbase中MemStore工作模式的特殊性更会引起严重的内存碎片,存在大量内存碎片会导致系统看起来似乎还有很多空间,但实际上这些空间都是一些非常小的碎片,已经分配不出一块完整的可用内存,这时会触发长时间的Full GC。
为了优化这种内存碎片可能导致的Full GC,Hbase借鉴了线程本地分配缓存(Thread-Local Allocation Buffer,TLAB)的内存管理方式,通过顺序化分配内存、内存数据分块等特性使得内存碎片更加粗粒度,有效改善Full GC情况。具体实现步骤如下:
1)每个MemStore会实例化得到一个MemStoreLAB对象。
2)MemStoreLAB会申请一个2M大小的Chunk数组,同时维护一个Chunk偏移量,该偏移量初始值为0。
3)当一个KeyValue值插入MemStore后,MemStoreLAB会首先通过KeyValue.getBuffer()取得data数组,并将data数组复制到Chunk数组中,之后再将Chunk偏移量往前移动data.length。
4)当前Chunk满了之后,申请一个新的Chunk。
这种内存管理方式,使得flush之后残留的内存碎片更加粗粒度,极大降低Full GC的触发频率。但是频繁的申请Chunk容易造成新生代Eden区满掉,触发YGC。于是重复利用Chunk的思想应运而生,
即使用Chunk pool来管理所有未被引用的Chunk,这些Chunk就不会再被JVM当作垃圾回收。如果需要申请新的Chunk来存储KeyValue,首先从Chunk Pool中获取,如果能够获取得到就重复利用,否则就重新申请一个新的Chunk。
而Chunk pool的大小=hbase.hregion.memstore.chunkpool.maxsize([0,1])* Memstore Size。这里Memstore Size就可以选择配置成堆外或堆内的大小。
[图片上传失败…(image-45a47b-1645000372947)]
2.1 相关配置 hbase.regionserver.offheap.global.memstore.size:这个值为大于0的值开启堆外,表示RegionServer中所有MemStore可以使用的堆外内存总大小。 2.2 引用计数数据在写入memstore chunk之前,有两种存储方式:
ByteBuffAllocator分配的堆外/堆内内存;
Netty分配的堆外内存。
这两种堆外内存的数据,在RPC call结束之前都不会被释放,而拷贝到chunk的过程处于RPC call处理的过程中,所以拷贝之前,并不需要额外增加对堆外内存的引用。
3、Bucket cacheHbase 2.0.0之后,默认采用两级缓存CombinedBlockCache。将Index/Bloom block放在堆内缓存(LRUBlockCache层),而将data block放入BucketCache层。
BucketCache通过配置可以工作在三种模式下:heap,offheap和file。heap模式表示这些Bucket是从JVM Heap中申请,offheap模式使用DirectByteBuffer技术实现堆外内存存储管理,而file模式使用类似SSD的高速缓存文件存储数据块。
3.1 基础知识Bucket cache属于一种block cache,只缓存解码后的data block。而Hbase中Block分为四种类型:Data Block,Index Block,Bloom Block和meta Block。
其中Data Block用于存储实际数据,通常情况下每个Data Block可以存放多条KeyValue数据对;Index Block和Bloom Block都用于优化随机读的查找路径,其中Index Block通过存储索引数据加快数据查找,而Bloom Block通过一定算法可以过滤掉部分一定不存在待查KeyValue的数据文件,减少不必要的IO操作;meta Block主要存储整个HFile的元数据。
3.2 堆外内存管理Bucket cache是用来存储data block,用BucketAllocator管理分配内存,直接使用JVM DirectByteBuffer分配,内存块的单元是2MB。
无论工作在那种模式下,BucketCache都在初始化的时候申请14个带有固定大小标签的Bucket,一种Bucket存储一种指定BlockSize的数据块,而且即使在某一种Bucket空间不足的情况下,系统也会从其他Bucket空间借用内存使用,不会出现内存使用率低的情况。
[图片上传失败…(image-cd4b30-1645000372947)]
但是data block在进入bucket cache之前,是存在于RAM cache的,同样也是有可能在堆外内存中。因为RAM cache的内存是上面第1部分的ByteBuffAllocator分配管理的(它分配的内存有可能在堆外,也可能在堆内)。
然后由单独的bucket cache writer定期去处理RAM cache队列,将block拷贝进bucket cache。
3.3 Block缓存写入、读取流程下图是block写入缓存以及从缓存中读取block的流程示意图,图中主要包括5个模块,其中RAMCache是一个存储blockkey和block对应关系的HashMap;WriteThead是整个block写入的中心枢纽,主要负责异步的写入block到内存空间;IOEngine是具体的内存管理模块,主要实现将block数据写入对应地址的内存空间;BackingMap也是一个HashMap,用来存储blockKey与对应物理内存偏移量的映射关系,用来根据blockkey定位具体的block;其中绿线表示cache block流程,蓝色虚线表示get block流程。
[图片上传失败…(image-3cb218-1645000372947)]
3.4 引用计数接上面,使用了堆外的bucket cache后,在data block被写入bucket cache之前,RAM cache中会保留一个堆外内存的引用;写入bucket cache之后,由ByteBuffAllocator分配的堆外内存即可释放。
而bucket cache堆外内存的释放,由LRU触发data block淘汰时调用。
3.5 相关配置hbase.bucketcache.ioengine:使用的存储介质,可选值为heap, offheap, file。不设置的话,默认为offheap。
hbase.bucketcache.combinedcache.enabled:是否打开组合模式(CombinedBlockCache),默认是true
hbase.bucketcache.size:BucketCache所占的大小,如果设置为0.0-1.0 ,则代表占堆内存的百分比; 如果大于1,则代表实际的BucketCache的大小,单位为MB。 默认值为0.0,即关闭BucketCache
hbase.bucketcache.bucket.sizes:定义所有Block种类,默认是14种,默认值为(4+1)K、(8+1)K、(16+1)K、(32+1)K、(40+1)K、(48+1)K、(56+1)K、(64+1)K、(96+1)K … (512+1)K
4、Netty NIO ByteBufferHbase中有直接使用Netty分配的堆外内存,主要在3个地方:
使用NettyRPCServer,RPC请求传入的数据会直接存在Netty的堆外内存中;
FanoutOutputStream在把数据向HDFS DN channel写入之前,需要申请Netty的堆外内存,来暂存序列化后的数据;
大的Cell如果无法拷贝进入memstore chunk,也会一直存在Netty的堆外内存中;(这部分怀疑有问题,并没有加引用,但是目前线上没出现这类问题)
这部分Netty的NIO ByteBuffer大小需用系统变量配置,一般采用默认值,但最好预留足够宽松的大小,一般预留堆外内存总量的1/3。
5、其他short-circuit reading(读数据不经过DataNode,客户端直接读文件),RegionServer上的DFSclient会分配direct memory buffers。
DFSClient会使用的内存大小并不容易量化;它是由打开的HFile文件数量 × hbase.dfs.client.read.shortcircuit.buffer.size 决定。
总结在启动JVM时,确保 -XX:MaxDirectMemorySize 的设置(在hbase-env.sh)考虑到了offheap BlockCache、offheap memstore size,DFS读写的使用量,以及RPC端的ByteBuffPool的最大总和大小。
Direct memory 的大小应该比 offheap BlockCache + max ByteBufferPool + max offheap memstore size的大小更大。