下文所说的 hbase client 版本,如无特指,则皆为 1.2.3。
之前项目中出现堆外内存溢出(排查过程),虽然已经解决了问题,但当时没有深究底层的原理,最近抽空从底层入手,深入研究了 hbase client 读写源码,配合 jmeter 压测特定接口,并使用 mat 等工具分析,最终定位到了 hbase 堆外内存溢出的根本原因,本次就梳理下完整的过程,以及涉及的一些原理,防止以后踩坑。
一.溢出现象
单台服务器刚发布时 java 进程占用3g,以一天5%左右的速度增长,一定时间过后进程占用接近90%,触发服务器报警,而此时 old 区占用在 50%,未触发 CMS GC,而导致堆外内存溢出。
异常堆栈:
<span style="color:rgba(0, 0, 0, 0.75)"><span style="color:#000000"><code class="language-java"><span style="color:#a67f59">-</span>Xms8g <span style="color:#a67f59">-</span>Xmx8g <span style="color:#a67f59">-</span>Xmn3g <span style="color:#a67f59">-</span>Xss512k
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span>MetaspaceSize<span style="color:#a67f59">=</span><span style="color:#986801">256</span>m
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span>MaxMetaspaceSize<span style="color:#a67f59">=</span><span style="color:#986801">512</span>m
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>UseConcMarkSweepGC
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>DisableExplicitGC
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">-</span>UseGCOverheadLimit
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>UseCMSInitiatingOccupancyOnly
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span>CMSInitiatingOccupancyFraction<span style="color:#a67f59">=</span><span style="color:#986801">70</span>
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>CMSParallelRemarkEnabled
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>UseFastAccessorMethods
</code></span></span>
二.排查过程&原理分析
1.初步分析
根据异常堆栈,可以看出是 hbase.write() 分配直接内存导致的堆外内存溢出。而直接内存分配空间不足时,会调用 System.gc(),由于 JVM 参数配置了 -XX:+DisableExplicitGC 禁用了 System.gc(),且 old 区占用才50%,未达到 CMS GC 阈值,因此抛出堆外内存溢出。
2.压测主要接口
因为不能用线上机器做实验,且不能确定是否有其他因素导致溢出。于是在性能环境使用 jmeter 压测应用主要接口,并观察堆外内存占用。
压测后发现堆外内存占用平稳,未出现溢出现象。
3.释放 hbase client 资源
此时将目光放到异常堆栈上,并查看系统封装的 hbase client ,发现使用完 HTable 后未调用 close() 释放资源,于是加上 close() 代码,并上线观察。但仍然出现溢出现象。
4.压测特定功能
虽然不是hbase client使用的问题,但还是有相同的堆栈,说明 hbase 肯定有问题。
查找资料,发现 hbase 官方的 issue 列表里有一个堆外内存溢出的 case (hbase direct memory leak issue)。发现如果使用 jdk 的 HeapByteBuffer,在网络IO时,由于用户空间不能直接访问内核空间,因此会复制一个临时的 DirectByteBuffer 对象进行IO,且用 ThreadLocal 缓存该对象。如果使用多线程进行大数据量的网络IO,则可能导致内存溢出。
5.升级 hbase client 至 2.1.0
找到了泄露的点,那么解决方案就出来了:
不使用 HeapByteBuffer 或复用 DirectByteBuffer : 升级 base client 至 2.x,默认使用 netty
限制 jdk 缓存的堆外内存大小:jdk 升级至 jdk 9
考虑改动成本,将 hbase client 版本升级至 2.1.0,线上运行一段时间,系统稳定,无溢出现象。
6.主要是哪里申请的堆外内存呢?
虽然已经解决了这个问题,但还有几个疑问:
压测脚本是使用 hbase 同时读写,那么到底是读,还是写造成的泄露?还是两者都有泄露?
hbase 读、写的溢出对应的是源码底层的哪一段,或是哪几段逻辑呢?
带着这些疑问,查看了一下 hbase client 读写的源码。
1) hbase 写的源码
主要分为获取 HTable、mutate、flushCommits 三个部分:
由于传入的 totalSize 为写入数据的大小(10M),因此 IOUtil.write() 申请的 DirectByteBuffer 大小为传入的大小(此处 HeapByteBuffer 的 limit 为 10338890,10M左右):
这里的线程池为前面获取 HTable 创建的,核心线程数默认256,那么最大占用堆外内存=256*10=2560M,未达到溢出的量。是不是还有其他地方在分配堆外内存呢?
2) hbase 读的源码
HTable.get() 通过匿名内部类的方式实现了 RetryingCallable
#call() 接口,在 RpcClientImpl#call 方法内被调用,向 hbase 发送读请求之前初始化 socket 连接,并启动 RpcClientImpl.Connection 线程,接收数据。其中发起请求流程和 hbase 写的流程一样:
7.分析性能环境 dump 文件
到这里,可以确定 hbase 写会造成泄露,但目前造成泄露的内存量远大于前面分析的值。查看性能环境的 dump 文件,使用 OQL 语句查看 java.nio.DirectByteBuffer 的数量和大小,发现占据 10M 空间的对象数量有 788个,总内存大约为=10338894*788=7800M:
而根据前面的分析,会持有堆外内存对象引用的只有 hconnection 线程池中的线程。于是仔细的再梳理一遍 hbase 写的链路,发现处理网络 IO 任务的线程池,创建的时候设置了allowCoreThreadTimeOut 为 true,允许核心线程消亡,keepAliveTime 为60s:
8.总结
导致 hbase 堆外内存溢出的主要是下面几个条件共同作用的结果:
- 默认的 RpcClientImpl 中使用了 HeapByteBuffer: 网络IO时数据会复制到堆外内存
- sun.nio.ch.Util 类会缓存堆外内存大小,且使用 ThreadLocal 方式: 引用由线程持有
- 异步写的线程池设置 allowCoreThreadTimeOut 为 true: 导致线程频繁消亡
- 写入的频率
- JVM 未进行 FGC: 已进入 old 区的不可达的线程对象,持有的堆外内存资源无法被回收
关于第四点,单独压测写入接口时,未限制频率,导致堆外内存到 3.5g 左右时,系统就直接进行 FGC 了,由于回收了堆外内存资源,因此未出现堆外内存溢出现象。
完整导致流程描述:hbase 创建的线程池内的线程使用 HeapByteBuffer 存储数据,网络IO前会将数据复制到堆外内存对象 DirectByteBuffer 中;而 jdk 会以 ThreadLocal 的方式缓存该 DirectByteBuffer 对象申请的堆外内存,如果线程不消亡则不释放该内存;同时该线程池允许核心线程消亡,当业务方以一定的频率调用 hbase 写接口时,导致有些线程对象消亡并进入 Old 区;由于未进行 FGC,这些线程对象无法被回收,占用的堆外内存资源也无法被 GC 回收。一段时间后,造成堆外内存溢出。
对象进入 Old 区有很多可能,比如:
- Eden 区空间不足
- 长期存活对象进入 Old 区(线上查看存活年龄配置: jinfo -flag MaxTenuringThreshold = 6)
梳理到这里,那么解决方案就多了几种,比如:
- 配置 RpcClient 实现为 AsyncRpcClient (使用 netty 方式,性能环境已验证)
- 使用 Connection#getTable(tableName, pool) 传入自定义的线程池,设置allowCoreThreadTimeOut 为false,并限制每次写入的大小 (已验证)
- 提高核心线程消亡时间
- 控制 hbase 写入的频率
- 调低FGC 的阈值
- 调大JVM年龄计数器
以上就是完整的流程了,如有疑问可与我交流。
三.工具简介
前面排查堆外内存溢出的过程中,使用了很多工具,主要有:
- jdk 命令行工具: jps, jstat, jmap
- jdk 提供的内存监控工具: jConsole, jVisualVM
- eclipse 提供的内存分析工具: mat
- google 的监控堆外内存工具: gperftools
- 性能压测工具: jmeter
- 查看进程内存: smaps, pmap, gdb
- sun 推出的针对 java 的动态追踪工具: btrace
具体的工具用法就不介绍了,可自行搜索资料。
四.后记
已反馈至 hbase issue:
下文所说的 hbase client 版本,如无特指,则皆为 1.2.3。
之前项目中出现堆外内存溢出(排查过程),虽然已经解决了问题,但当时没有深究底层的原理,最近抽空从底层入手,深入研究了 hbase client 读写源码,配合 jmeter 压测特定接口,并使用 mat 等工具分析,最终定位到了 hbase 堆外内存溢出的根本原因,本次就梳理下完整的过程,以及涉及的一些原理,防止以后踩坑。
一.溢出现象
单台服务器刚发布时 java 进程占用3g,以一天5%左右的速度增长,一定时间过后进程占用接近90%,触发服务器报警,而此时 old 区占用在 50%,未触发 CMS GC,而导致堆外内存溢出。
异常堆栈:
<span style="color:rgba(0, 0, 0, 0.75)"><span style="color:#000000"><code class="language-java"><span style="color:#a67f59">-</span>Xms8g <span style="color:#a67f59">-</span>Xmx8g <span style="color:#a67f59">-</span>Xmn3g <span style="color:#a67f59">-</span>Xss512k
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span>MetaspaceSize<span style="color:#a67f59">=</span><span style="color:#986801">256</span>m
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span>MaxMetaspaceSize<span style="color:#a67f59">=</span><span style="color:#986801">512</span>m
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>UseConcMarkSweepGC
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>DisableExplicitGC
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">-</span>UseGCOverheadLimit
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>UseCMSInitiatingOccupancyOnly
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span>CMSInitiatingOccupancyFraction<span style="color:#a67f59">=</span><span style="color:#986801">70</span>
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>CMSParallelRemarkEnabled
<span style="color:#a67f59">-</span>XX<span style="color:#a67f59">:</span><span style="color:#a67f59">+</span>UseFastAccessorMethods
</code></span></span>
二.排查过程&原理分析
1.初步分析
根据异常堆栈,可以看出是 hbase.write() 分配直接内存导致的堆外内存溢出。而直接内存分配空间不足时,会调用 System.gc(),由于 JVM 参数配置了 -XX:+DisableExplicitGC 禁用了 System.gc(),且 old 区占用才50%,未达到 CMS GC 阈值,因此抛出堆外内存溢出。
2.压测主要接口
因为不能用线上机器做实验,且不能确定是否有其他因素导致溢出。于是在性能环境使用 jmeter 压测应用主要接口,并观察堆外内存占用。
压测后发现堆外内存占用平稳,未出现溢出现象。
3.释放 hbase client 资源
此时将目光放到异常堆栈上,并查看系统封装的 hbase client ,发现使用完 HTable 后未调用 close() 释放资源,于是加上 close() 代码,并上线观察。但仍然出现溢出现象。
4.压测特定功能
虽然不是hbase client使用的问题,但还是有相同的堆栈,说明 hbase 肯定有问题。
查找资料,发现 hbase 官方的 issue 列表里有一个堆外内存溢出的 case (hbase direct memory leak issue)。发现如果使用 jdk 的 HeapByteBuffer,在网络IO时,由于用户空间不能直接访问内核空间,因此会复制一个临时的 DirectByteBuffer 对象进行IO,且用 ThreadLocal 缓存该对象。如果使用多线程进行大数据量的网络IO,则可能导致内存溢出。
5.升级 hbase client 至 2.1.0
找到了泄露的点,那么解决方案就出来了:
不使用 HeapByteBuffer 或复用 DirectByteBuffer : 升级 base client 至 2.x,默认使用 netty
限制 jdk 缓存的堆外内存大小:jdk 升级至 jdk 9
考虑改动成本,将 hbase client 版本升级至 2.1.0,线上运行一段时间,系统稳定,无溢出现象。
6.主要是哪里申请的堆外内存呢?
虽然已经解决了这个问题,但还有几个疑问:
压测脚本是使用 hbase 同时读写,那么到底是读,还是写造成的泄露?还是两者都有泄露?
hbase 读、写的溢出对应的是源码底层的哪一段,或是哪几段逻辑呢?
带着这些疑问,查看了一下 hbase client 读写的源码。
1) hbase 写的源码
主要分为获取 HTable、mutate、flushCommits 三个部分:
由于传入的 totalSize 为写入数据的大小(10M),因此 IOUtil.write() 申请的 DirectByteBuffer 大小为传入的大小(此处 HeapByteBuffer 的 limit 为 10338890,10M左右):
这里的线程池为前面获取 HTable 创建的,核心线程数默认256,那么最大占用堆外内存=256*10=2560M,未达到溢出的量。是不是还有其他地方在分配堆外内存呢?
2) hbase 读的源码
HTable.get() 通过匿名内部类的方式实现了 RetryingCallable
#call() 接口,在 RpcClientImpl#call 方法内被调用,向 hbase 发送读请求之前初始化 socket 连接,并启动 RpcClientImpl.Connection 线程,接收数据。其中发起请求流程和 hbase 写的流程一样:
7.分析性能环境 dump 文件
到这里,可以确定 hbase 写会造成泄露,但目前造成泄露的内存量远大于前面分析的值。查看性能环境的 dump 文件,使用 OQL 语句查看 java.nio.DirectByteBuffer 的数量和大小,发现占据 10M 空间的对象数量有 788个,总内存大约为=10338894*788=7800M:
而根据前面的分析,会持有堆外内存对象引用的只有 hconnection 线程池中的线程。于是仔细的再梳理一遍 hbase 写的链路,发现处理网络 IO 任务的线程池,创建的时候设置了allowCoreThreadTimeOut 为 true,允许核心线程消亡,keepAliveTime 为60s:
8.总结
导致 hbase 堆外内存溢出的主要是下面几个条件共同作用的结果:
- 默认的 RpcClientImpl 中使用了 HeapByteBuffer: 网络IO时数据会复制到堆外内存
- sun.nio.ch.Util 类会缓存堆外内存大小,且使用 ThreadLocal 方式: 引用由线程持有
- 异步写的线程池设置 allowCoreThreadTimeOut 为 true: 导致线程频繁消亡
- 写入的频率
- JVM 未进行 FGC: 已进入 old 区的不可达的线程对象,持有的堆外内存资源无法被回收
关于第四点,单独压测写入接口时,未限制频率,导致堆外内存到 3.5g 左右时,系统就直接进行 FGC 了,由于回收了堆外内存资源,因此未出现堆外内存溢出现象。
完整导致流程描述:hbase 创建的线程池内的线程使用 HeapByteBuffer 存储数据,网络IO前会将数据复制到堆外内存对象 DirectByteBuffer 中;而 jdk 会以 ThreadLocal 的方式缓存该 DirectByteBuffer 对象申请的堆外内存,如果线程不消亡则不释放该内存;同时该线程池允许核心线程消亡,当业务方以一定的频率调用 hbase 写接口时,导致有些线程对象消亡并进入 Old 区;由于未进行 FGC,这些线程对象无法被回收,占用的堆外内存资源也无法被 GC 回收。一段时间后,造成堆外内存溢出。
对象进入 Old 区有很多可能,比如:
- Eden 区空间不足
- 长期存活对象进入 Old 区(线上查看存活年龄配置: jinfo -flag MaxTenuringThreshold = 6)
梳理到这里,那么解决方案就多了几种,比如:
- 配置 RpcClient 实现为 AsyncRpcClient (使用 netty 方式,性能环境已验证)
- 使用 Connection#getTable(tableName, pool) 传入自定义的线程池,设置allowCoreThreadTimeOut 为false,并限制每次写入的大小 (已验证)
- 提高核心线程消亡时间
- 控制 hbase 写入的频率
- 调低FGC 的阈值
- 调大JVM年龄计数器
以上就是完整的流程了,如有疑问可与我交流。
三.工具简介
前面排查堆外内存溢出的过程中,使用了很多工具,主要有:
- jdk 命令行工具: jps, jstat, jmap
- jdk 提供的内存监控工具: jConsole, jVisualVM
- eclipse 提供的内存分析工具: mat
- google 的监控堆外内存工具: gperftools
- 性能压测工具: jmeter
- 查看进程内存: smaps, pmap, gdb
- sun 推出的针对 java 的动态追踪工具: btrace
具体的工具用法就不介绍了,可自行搜索资料。
四.后记
已反馈至 hbase issue: