記一次線上JVM原生系統內存泄露排查

問題報告

一日運維報告某應用JVM進程被OOM kill
[日期] 10-15-108-158 kernel: Out of memory: Kill process 23537 (java) score 846 or sacrifice child [日期] 10-15-108-158 kernel: Killed process 23537, UID 501, (java) total-vm:6458012kB, anon-rss:3364716kB, file-rss:112kB

可以看出進程是在虛擬內存用到6.46G左右,駐留內存用到3.36G左右被OOM kill掉的。
##問題分析

應用概況

該JVM是一個大數據接口程序,供後臺報表應用查詢一些預計算生成的各種數據,Linux虛擬機配置了6GB內存。應用使用了大量的第三方庫連接MySQL,Kylin,Druid和ES等。同時JVM啓動參數配置了XX:+HeapDumpOnOutOfMemoryError選項。

初步分析

Java堆內存,MetaSpace等使用情況

從運維Zabbix系統中可以查出此JVM運行幾周的堆內存和GC情況相當正常,並無堆內存泄露的情況。也並無hprof後綴的heap dump生成。
同時Meta Space也無任何泄露跡象。
監控記錄顯示Java線程數目也正常,並未發生線程數量劇增導致過量使用Thread Stack內存導致的泄露。

Java堆外內存分析

接着懷疑Java應用中是不是有第三方庫使用了DirectByteBuffer操作堆外內存導致的泄露。但因爲並未監控JVM堆外內存的使用情況,所以需要重新採集。
於是在重啓的JVM啓動參數中加入-XX:NativeMemoryTracking=detail,並用crontab每小時運行jcmd {PID} VM.native_memory detail > nmt.$PID.`date '+%Y-%m-%d.%H:%M:%S'`.log
來記錄JVM管轄內存的變化。也運行
pmap -x $PID > pmapx.$PID.`date '+%Y-%m-%d.%H:%M:%S'`.log
來記錄操作系統進程內存段分佈情況。

經記錄,發現Native Memory Tracking(NMT)記錄的JVM管理的內存情況均正常,包括Internal部分反映的堆外內存使用情況均正常,如下所示:
- Internal (reserved=13043KB, committed=13043KB) (malloc=13011KB #19159) (mmap: reserved=32KB, committed=32KB)
既然JVM管理的內存均正常,那就只能懷疑是進程的原生內存發生了泄露。

進程原生內存分析(Native Memory)

通過上一個步驟每小時記錄的pmap內存使用和分段情況可以看出,進程使用的虛擬內存和駐留(RSS)內存不斷地增加,一週虛擬內存增加了2GB多。

total kB         3174032  657612  637292
total kB         3174032  681068  660744
total kB         3174032  707084  686760
total kB         3175060  732216  711884
total kB         3175060  766008  745676
.......省略幾萬字
total kB         5006796 3231676 3218728
total kB         5072332 3214132 3201180
total kB         5137868 3217816 3204860
total kB         5203404 3220000 3207032

在最新的pmap輸出中,可以發現大概有70多個加起來是65536kB(64MB)的內存塊,隨着時間推移越來越多,最後導致內存不夠。

00007f756c000000    1636    1540    1540 rw---    [ anon ]
00007f756c199000   63900       0       0 -----    [ anon ]
00007f7574000000    2552    1720    1720 rw---    [ anon ]
00007f757427e000   62984       0       0 -----    [ anon ]
00007f7578000000    2024    1900    1900 rw---    [ anon ]
00007f75781fa000   63512       0       0 -----    [ anon ]
00007f757c000000   14468   14436   14436 rw---    [ anon ]
00007f757ce21000   51068       0       0 -----    [ anon ]
00007f7580000000    4452    4420    4420 rw---    [ anon ]
00007f7580459000   61084       0       0 -----    [ anon ]
00007f7584000000   22908   22852   22852 rw---    [ anon ]
00007f758565f000   42628       0       0 -----    [ anon ]
00007f7588000000   51776   51584   51584 rw---    [ anon ]
00007f758b290000   13760       0       0 -----    [ anon ]
00007f758c000000   54680   54620   54620 rw---    [ anon ]
00007f758f566000   10856       0       0 -----    [ anon ]
00007f7590000000   44616   44544   44544 rw---    [ anon ]
00007f7592b92000   20920       0       0 -----    [ anon ]
00007f7594000000   65512   65484   65484 rw---    [ anon ]

而且這些64M的內存空間都不在Native Memory Tracking輸出的地址段內,不屬於JVM管理的內存。

原生內存詳細分析

內存內容分析

使用gdb 打印出這些內存區域的內容,起始地址使用pmap輸出的內存塊地址起始值,結束地址將起始地址加上想輸出的內存區大小。
gdb --batch --pid {PID} -ex "dump memory native_memory.dump 0x00007f7588000000 0x00007f7588001A40" 注意起始和終止地址要加上0x表示16進制
查看native_memory.dump文件,發現是

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

driver.name=Kylin JDBC Driver
driver.version=0.1
product.name=Kylin
product.version=0.1
jdbc.compliant=true
driver.version.major=0
driver.version.minor=8
database.version.major=0
database.version.minor=8
build.timestamp=20140918-2017ication Authority - G21:08U
                                                        1(c) 1998 VeriSign, Inc. - For authorized use only10U
280801235959Z01                                                                                            VeriSign Trust Network0
                 0    UUS10U
VeriSign, Inc.1<0:U
                   3Class 1 Public Primary Certification Authority - G21:08U
                                                                            1(c) 1998 VeriSign, Inc. - For authorized use only10U

使用腳本統計多個64M內存塊,發現90%都是類似的內容,那基本上可以定位是所使用的Kylin第三方庫引入的內存泄露。由於原生內存一般是通過malloc來申請內存,所以想通過malloc的profile工具來定位具體的內存分配模塊。

引入jemalloc

jemalloc的jeprof功能可以定位到原生內存的分配代碼棧。通過以下步驟引入jeprof。

  • wget https://github.com/jemalloc/jemalloc/releases/download/5.1.0/jemalloc-5.1.0.tar.bz2
  • tar xvf jemalloc-5.1.0.tar.bz2
  • cd jemalloc-5.1.0
  • ./configure --enable-prof --prefix=/usr/local
  • make
  • sudo make install
  • 在啓動腳本中加入export MALLOC_CONF=“prof:true,lg_prof_sample:1,lg_prof_interval:26,prof_prefix:/{YOUR INTENDED FOLDER}/jeprof.out” to run.sh
  • 同時在java命令前加上 LD_PRELOAD="/usr/local/lib/libjemalloc.so", 變成LD_PRELOAD="/usr/local/lib/libjemalloc.so" java -Xmx… -Xms…
  • 啓動JVM
  • 在Linux上,cd /{YOUR INTENDED FOLDER}/,
  • 執行 jeprof --show_bytes --dot which java jeprof.out.xxxx.heap > leak_test.dot ,
    jeprof.out.xxxx.heap是最新的jemalloc生成的內存統計分析.
  • 下載leak_test.dot 到本地folder {somepath},
  • 下載graphviz(https://www.graphviz.org/download/)
  • 執行dot -Tpng {somepath}/leak_test.dot -o {somepath}/leak_test.png

原生內存泄露
可以看到大部分泄露是來自於java.util.zip.Inflater.inflateBytes

java.util.zip.Inflater.inflateBytes調用棧跟蹤

使用阿里巴巴的https://alibaba.github.io/arthas/ 工具,attach到JVM,然後使用stack命令,可以看到以下調用棧。

$ options unsafe true
NAME    BEFORE-VALUE  AFTER-VALUE
-----------------------------------
unsafe  false         true
$ stack java.util.zip.Inflater inflate
Press Q or Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:2) cost in 254 ms.
ts=2019-03-24 10:20:58;thread_name=main;id=1;is_daemon=false;priority=5;TCCL=sun.misc.Launcher$AppClassLoader@18b4aac2
    @java.util.zip.InflaterInputStream.read()
        at java.io.FilterInputStream.read(FilterInputStream.java:133)
        at java.io.FilterInputStream.read(FilterInputStream.java:107)
        at java.util.Properties$LineReader.readLine(Properties.java:435)
        at java.util.Properties.load0(Properties.java:353)
        at java.util.Properties.load(Properties.java:341)
        at org.apache.kylin.jdbc.shaded.org.apache.calcite.avatica.DriverVersion.load(DriverVersion.java:104) 
        at org.apache.kylin.jdbc.Driver.createDriverVersion(Driver.java:88)
        at org.apache.kylin.jdbc.shaded.org.apache.calcite.avatica.UnregisteredDriver.<init>(UnregisteredDriver.java:56) 
        at org.apache.kylin.jdbc.Driver.<init>(Driver.java:70)
        at sun.reflect.GeneratedConstructorAccessor14.newInstance(null:-1)
        at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
        at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
        at java.lang.Class.newInstance(Class.java:442)
        at xxxx.util.KylinUtil.getConnection(KylinUtil.java:18)

最後發現是寫業務代碼的同事非常錯誤地重複地在獲取連接時新建Driver對象所導致的。
Driver driver = (Driver) Class.forName("org.apache.kylin.jdbc.Driver").newInstance();
但進一步分析,就算是重複調用也不應該有內存泄露,接着分析Kylin JDBC Driver的代碼。

Kylin JDBC Driver內存泄露分析

通過查看源代碼,Kylin使用了https://github.com/apache/calcite-avatica 來實現JDBC Driver,Avatica中的代碼塊引入了內存泄露。

try {
      final InputStream inStream =
          driverClass.getClassLoader().getResourceAsStream(resourceName);	          
//      inStream沒有close

重複調用這段代碼放大了內存泄露的規模。已經給開源項目提了issue和pull request,下個版本應該會修復這個問題。

結論

這次內存泄露使用多種工具,最後定位到了JVM原生內存的泄露根源。 技術人員碰到每一次問題,都是一次學習的機會,要沉下心來,剝絲去繭,一步步嘗試去觸及問題的本質,去享受雲開見月明那最後一刻的喜悅。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章