JVM相關 - StackOverflowError 與 OutOfMemoryError

張雜湊發表於2020-10-12

本文基於 Java 15

StackOverflowError 與 OutOfMemoryError 是兩個老生常談的 Java 錯誤。Java 中的虛擬機器錯誤 VirtualMachineError 包括以下四種:

image

我們比較關心的就是 StackOverflowError 與 OutOfMemoryError,剩下的 InternalError 一般是內部使用錯誤,UnknownError 是虛擬機器發生未知異常,這兩種我們這裡不討論。

虛擬機器規範中的 StackOverflowError 與 OutOfMemoryError

參考 Java 虛擬機器規範官方文件:Run-Time Data Areas,可以知道,在如下情況下,會丟擲這兩種錯誤:

  • 當某次執行緒執行計算時,需要佔用的 Java 虛擬機器棧(Java Virtual Machine Stack)大小,也就是 Java 執行緒棧大小,超過規定大小時,丟擲 StackOverflowError
  • 如果 Java 虛擬機器棧大小可以動態擴容,發生擴容時發現記憶體不足,或者新建Java 虛擬機器棧時發現記憶體不足,丟擲 OutOfMemoryError
  • 當所需要的堆(heap)記憶體大小不足時,丟擲 OutOfMemoryError
  • 當方法區(Method Area)大小不夠分配時,丟擲 OutOfMemoryError
  • 當建立一個類或者介面時,執行時常量區剩餘大小不夠時,丟擲 OutOfMemoryError
  • 本地方法棧(Native Method Stack)大小不足時,丟擲 StackOverflowError
  • 本地方法棧(Native Method Stack)擴容時發現記憶體不足,或者新建本地方法棧發現記憶體不足,丟擲 OutOfMemoryError

Hotspot JVM 的實現

為了進一步搞清楚 StackOverflowError 與 OutOfMemoryError,我們來看具體實現。一般的 JVM 採用的都是官網的 HotSpot JVM,我們這裡就用 Hotspot JVM 的實現來說明。

JVM 記憶體包括什麼

我們一般通過兩個工具 pmap 還有 jcmd 中的 VM.native_memory 命令去檢視 Java 程式記憶體佔用,由於 pmap 命令有點複雜而且很多記憶體對映是 anon 的,這裡採用 jcmd 中的 VM.native_memory 命令,去看一下 JVM 記憶體的每一部分。需要指出的一點是,

如果想了解詳細的 Native Memory Tracking,請參考我的另一篇文章JVM相關 - JVM 記憶體佔用與分析

Native Memory Tracking:

Total: reserved=6308603KB, committed=4822083KB
-                 Java Heap (reserved=4194304KB, committed=4194304KB)
                            (mmap: reserved=4194304KB, committed=4194304KB) 
 
-                     Class (reserved=1161041KB, committed=126673KB)
                            (classes #21662)
                            (  instance classes #20542, array classes #1120)
                            (malloc=3921KB #64030) 
                            (mmap: reserved=1157120KB, committed=122752KB) 
                            (  Metadata:   )
                            (    reserved=108544KB, committed=107520KB)
                            (    used=105411KB)
                            (    free=2109KB)
                            (    waste=0KB =0.00%)
                            (  Class space:)
                            (    reserved=1048576KB, committed=15232KB)
                            (    used=13918KB)
                            (    free=1314KB)
                            (    waste=0KB =0.00%)
 
-                    Thread (reserved=355251KB, committed=86023KB)
                            (thread #673)
                            (stack: reserved=353372KB, committed=84144KB)
                            (malloc=1090KB #4039) 
                            (arena=789KB #1344)
 
-                      Code (reserved=252395KB, committed=69471KB)
                            (malloc=4707KB #17917) 
                            (mmap: reserved=247688KB, committed=64764KB) 
 
-                        GC (reserved=199635KB, committed=199635KB)
                            (malloc=11079KB #29639) 
                            (mmap: reserved=188556KB, committed=188556KB) 
 
-                  Compiler (reserved=2605KB, committed=2605KB)
                            (malloc=2474KB #2357) 
                            (arena=131KB #5)
 
-                  Internal (reserved=3643KB, committed=3643KB)
                            (malloc=3611KB #8683) 
                            (mmap: reserved=32KB, committed=32KB) 
 
-                     Other (reserved=67891KB, committed=67891KB)
                            (malloc=67891KB #2859) 
 
-                    Symbol (reserved=26220KB, committed=26220KB)
                            (malloc=22664KB #292684) 
                            (arena=3556KB #1)
 
-    Native Memory Tracking (reserved=7616KB, committed=7616KB)
                            (malloc=585KB #8238) 
                            (tracking overhead=7031KB)
 
-               Arena Chunk (reserved=10911KB, committed=10911KB)
                            (malloc=10911KB) 
 
-                   Tracing (reserved=25937KB, committed=25937KB)
                            (malloc=25937KB #8666) 
 
-                   Logging (reserved=5KB, committed=5KB)
                            (malloc=5KB #196) 
 
-                 Arguments (reserved=18KB, committed=18KB)
                            (malloc=18KB #486) 
 
-                    Module (reserved=532KB, committed=532KB)
                            (malloc=532KB #3579) 
 
-              Synchronizer (reserved=591KB, committed=591KB)
                            (malloc=591KB #4777) 
 
-                 Safepoint (reserved=8KB, committed=8KB)
                            (mmap: reserved=8KB, committed=8KB) 

這裡的 mmapmalloc 是兩種不同的記憶體申請分配方式,例如:

Internal (reserved=3643KB, committed=3643KB)
                            (malloc=3611KB #8683) 
                            (mmap: reserved=32KB, committed=32KB) 

代表 Internal 一共佔用 3643KB,其中3611KB是通過 malloc 方式,32KB 是通過 mmap 方式。
arena 是通過 malloc 方式分配的記憶體但是程式碼執行完並不釋放,放入 arena chunk 中之後還會繼續使用,參考:MallocInternals

可以看出,Java 程式記憶體包括:

  • Java Heap: 堆記憶體,即-Xmx限制的最大堆大小的記憶體。
  • Class:載入的類與方法資訊,其實就是 metaspace,包含兩部分: 一是 metadata,被-XX:MaxMetaspaceSize限制最大大小,另外是 class space,被-XX:CompressedClassSpaceSize限制最大大小
  • Thread:執行緒與執行緒棧佔用記憶體,每個執行緒棧佔用大小受-Xss限制,但是總大小沒有限制。
  • Code:JIT 即時編譯後(C1 C2 編譯器優化)的程式碼佔用記憶體,受-XX:ReservedCodeCacheSize限制
  • GC:垃圾回收佔用記憶體,例如垃圾回收需要的 CardTable,標記數,區域劃分記錄,還有標記 GC Root 等等,都需要記憶體。這個不受限制,一般不會很大的。
  • Compiler:C1 C2 編譯器本身的程式碼和標記佔用的記憶體,這個不受限制,一般不會很大的
  • Internal:命令列解析,JVMTI 使用的記憶體,這個不受限制,一般不會很大的
  • Symbol: 常量池佔用的大小,字串常量池受-XX:StringTableSize個數限制,總記憶體大小不受限制
  • Native Memory Tracking:記憶體採集本身佔用的記憶體大小,如果沒有開啟採集(那就看不到這個了,哈哈),就不會佔用,這個不受限制,一般不會很大的
  • Arena Chunk:所有通過 arena 方式分配的記憶體,這個不受限制,一般不會很大的
  • Tracing:所有采集佔用的記憶體,如果開啟了 JFR 則主要是 JFR 佔用的記憶體。這個不受限制,一般不會很大的
  • Logging,Arguments,Module,Synchronizer,Safepoint,Other,這些一般我們不會關心。

除了 Native Memory Tracking 記錄的記憶體使用,還有兩種記憶體 Native Memory Tracking 沒有記錄,那就是:

各種 StackOverflowError 與 OutOfMemoryError 場景以及定位方式

1. StackOverflowError

呼叫棧過深,導致執行緒棧佔用大小超過-Xss(或者是-XX:ThreadStackSize)的限制,如果沒指定-Xss,則根據不同系統確定預設最大大小。

確定預設大小的程式碼請參考:

總結起來就是,32 位的系統一般是 512k,64 位的是 1024k

一般報這個錯都是因為遞迴死迴圈,或者呼叫棧真的太深而執行緒棧大小不足,比如那種回撥背壓模型的框架,netty + reactor 這種,一般執行緒棧需要調大一點。

2. OutOfMemoryError: Java heap space

堆記憶體不夠用,無法分配更多記憶體,就會丟擲這個異常。一般這種情況發生後,需要檢視 heap dump,線上應用一般加上-XX: +HeapDumpOnOutOfMemoryErrorOutOfMemoryError發生的時候,進行 heap dump,之後進行分析。

heap dump 檢視工具一般通過 Memory Analyzer (MAT)

image

3. OutOfMemoryError: unable to create native thread

這個在建立太多的執行緒,超過系統配置的極限。如Linux預設允許單個程式可以建立的執行緒數是1024個。

一般報這個錯首先考慮不要建立那麼多執行緒,執行緒池化並池子儘量同業務複用。如果實在要建立那麼多執行緒,則考慮修改伺服器配置:

//檢視限制個數
ulimit -u

//編輯修改
vim /etc/security/limits.d/90-nproc.conf

4. OutOfMemoryError: GC Overhead limit exceeded

預設情況下,並不是等堆記憶體耗盡,才會報 OutOfMemoryError,而是如果 JVM 覺得 GC 效率不高,也會報這個錯誤。

那麼怎麼評價 GC 效率不高呢?來看下原始碼:
呢?來看下原始碼gcOverheadChecker.cpp

void GCOverheadChecker::check_gc_overhead_limit(GCOverheadTester* time_overhead,
                                                GCOverheadTester* space_overhead,
                                                bool is_full_gc,
                                                GCCause::Cause gc_cause,
                                                SoftRefPolicy* soft_ref_policy) {

  // 忽略顯式gc命令,比如System.gc(),或者通過JVMTI命令的gc,或者通過jcmd命令的gc
  if (GCCause::is_user_requested_gc(gc_cause) ||
      GCCause::is_serviceability_requested_gc(gc_cause)) {
    return;
  }

  bool print_gc_overhead_limit_would_be_exceeded = false;
  if (is_full_gc) {
    //如果gc時間過長,並且gc回收的空間還是不多
    //gc時間佔用98%以上為gc時間過長,可以通過 -XX:GCTimeLimit= 配置,參考gc_globals.hpp: GCTimeLimit
    //回收空間小於2%為gc回收空間不多,可以通過  -XX:GCHeapFreeLimit= 配置,參考gc_globals.hpp: GCHeapFreeLimit
    if (time_overhead->is_exceeded() && space_overhead->is_exceeded()) {
      _gc_overhead_limit_count++;
      //如果UseGCOverheadLimit這個狀態位為開啟
      //預設情況下,是開啟的,可以通過啟動引數-XX:-UseGCOverheadLimit關閉,參考:gc_globals.hpp: UseGCOverheadLimit
      if (UseGCOverheadLimit) {
        //如果超過規定次數,這個次數預設不可配置,必須開啟develop編譯jdk才能配置,參考gc_globals.hpp: GCOverheadLimitThreshold
        if (_gc_overhead_limit_count >= GCOverheadLimitThreshold){
          //設定狀態位,準備丟擲OOM
          set_gc_overhead_limit_exceeded(true);
          //清空計數
          reset_gc_overhead_limit_count();
        } else {
          //如果還沒到達次數,但是也快到達的時候,清空所有的軟引用
          bool near_limit = gc_overhead_limit_near();
          if (near_limit) {
            soft_ref_policy->set_should_clear_all_soft_refs(true);
            log_trace(gc, ergo)("Nearing GC overhead limit, will be clearing all SoftReference");
          }
        }
      }
      //需要列印日誌,提示GC效率不高
      print_gc_overhead_limit_would_be_exceeded = true;

    } else {
      // Did not exceed overhead limits
      reset_gc_overhead_limit_count();
    }
  }

  if (UseGCOverheadLimit) {
    if (gc_overhead_limit_exceeded()) {
      log_trace(gc, ergo)("GC is exceeding overhead limit of " UINTX_FORMAT "%%", GCTimeLimit);
      reset_gc_overhead_limit_count();
    } else if (print_gc_overhead_limit_would_be_exceeded) {
      assert(_gc_overhead_limit_count > 0, "Should not be printing");
      log_trace(gc, ergo)("GC would exceed overhead limit of " UINTX_FORMAT "%% %d consecutive time(s)",
                          GCTimeLimit, _gc_overhead_limit_count);
    }
  }
}

預設配置:gc_globals.hpp

product(bool, UseGCOverheadLimit, true,                                   \
          "Use policy to limit of proportion of time spent in GC "          \
          "before an OutOfMemory error is thrown")                          \
                                                                            \
product(uintx, GCTimeLimit, 98,                                           \
      "Limit of the proportion of time spent in GC before "             \
      "an OutOfMemoryError is thrown (used with GCHeapFreeLimit)")      \
      range(0, 100)                                                     \
                                                                        \
product(uintx, GCHeapFreeLimit, 2,                                        \
      "Minimum percentage of free space after a full GC before an "     \
      "OutOfMemoryError is thrown (used with GCTimeLimit)")             \
      range(0, 100)                                                     \
                                                                        \
develop(uintx, GCOverheadLimitThreshold, 5,                               \
      "Number of consecutive collections before gc time limit fires")   \
      range(1, max_uintx)                              

可以總結出:預設情況下,啟用了 UseGCOverheadLimit,連續 5 次,碰到 GC 時間佔比超過 98%,GC 回收的記憶體不足 2% 時,會丟擲這個異常。

5. OutOfMemoryError: direct memory

這個是向系統申請直接記憶體時,如果系統可用記憶體不足,就會丟擲這個異常,對應的原始碼Bits.java

static void reserveMemory(long size, int cap) {
    synchronized (Bits.class) {
        if (!memoryLimitSet && VM.isBooted()) {
            maxMemory = VM.maxDirectMemory();
            memoryLimitSet = true;
        }
        // -XX:MaxDirectMemorySize limits the total capacity rather than the
        // actual memory usage, which will differ when buffers are page
        // aligned.
        if (cap <= maxMemory - totalCapacity) {
            reservedMemory += size;
            totalCapacity += cap;
            count++;
            return;
        }
    }
    System.gc();
    try {
        Thread.sleep(100);
    } catch (InterruptedException x) {
        // Restore interrupt status
        Thread.currentThread().interrupt();
    }
    synchronized (Bits.class) {
        if (totalCapacity + cap > maxMemory)
            throw new OutOfMemoryError("Direct buffer memory");
        reservedMemory += size;
        totalCapacity += cap;
        count++;
    }
}

在 DirectByteBuffer 中,首先向 Bits 類申請額度,Bits 類有一個全域性的 totalCapacity 變數,記錄著全部 DirectByteBuffer 的總大小,每次申請,都先看看是否超限,堆外記憶體的限額預設與堆內記憶體(由 -Xmx 設定)相仿,可用 -XX:MaxDirectMemorySize 重新設定。

如果不指定,該引數的預設值為 Xmx 的值減去1個 Survior 區的值。 如設定啟動引數 -Xmx20M -Xmn10M -XX:SurvivorRatio=8,那麼申請 20M-1M=19M 的DirectMemory
如果已經超限,會主動執行 Sytem.gc(),期待能主動回收一點堆外記憶體。System.gc() 會觸發一個 full gc,當然前提是你沒有顯示的設定 -XX:+DisableExplicitGC 來禁用顯式GC。並且你需要知道,呼叫 System.gc() 並不能夠保證 full gc 馬上就能被執行。然後休眠一百毫秒,看看 totalCapacity 降下來沒有,如果記憶體還是不足,就丟擲 OOM 異常。如果額度被批准,就呼叫大名鼎鼎的sun.misc.Unsafe去分配記憶體,返回記憶體基地址

在發生這種異常時,一般通過 JMX 的java.nio.BufferPool.direct裡面的屬性去監控直接記憶體的變化以及使用(其實就是 BufferPoolMXBean ),來定位問題。

image

6. OutOfMemoryError: map failed

這個是 File MMAP(檔案對映記憶體)時,如果系統記憶體不足,就會丟擲這個異常,對應的原始碼是:

以 Linux 為例:

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
                                     jint prot, jlong off, jlong len)
{
    void *mapAddress = 0;
    jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
    jint fd = fdval(env, fdo);
    int protections = 0;
    int flags = 0;

    if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
        protections = PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
        protections = PROT_WRITE | PROT_READ;
        flags = MAP_SHARED;
    } else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
        protections =  PROT_WRITE | PROT_READ;
        flags = MAP_PRIVATE;
    }
    //呼叫mmap
    mapAddress = mmap64(
        0,                    /* Let OS decide location */
        len,                  /* Number of bytes to map */
        protections,          /* File permissions */
        flags,                /* Changes are shared */
        fd,                   /* File descriptor of mapped file */
        off);                 /* Offset into file */
    //記憶體不足時,丟擲OutOfMemoryError
    if (mapAddress == MAP_FAILED) {
        if (errno == ENOMEM) {
            JNU_ThrowOutOfMemoryError(env, "Map failed");
            return IOS_THROWN;
        }
        return handle(env, -1, "Map failed");
    }

    return ((jlong) (unsigned long) mapAddress);
}

這種情況下,考慮:

  1. 增加系統記憶體
  2. 採用檔案分塊,不要一次 mmap 很大的檔案,也就是減少每次 mmap 檔案的大小

7. OutOfMemoryError: Requested array size exceeds VM limit

當申請的陣列大小超過堆記憶體限制,就會丟擲這個異常。

8. OutOfMemoryError: Metaspace

Metadata 佔用空間超限(參考上面簡述 Java 記憶體構成, class 這一塊 包含兩種,一種是 metadata,一種是 class space),會丟擲這個異常,那麼如何檢視元空間記憶體呢?

可以通過兩個命令,這兩個輸出是一樣的:

  • jmap -clstats
  • jcmd GC.class_stats (這個需要啟動引數: -XX:+UnlockDiagnosticVMOptions)
Index Super InstBytes KlassBytes annotations    CpAll MethodCount Bytecodes MethodAll    ROAll     RWAll     Total ClassName
    1    -1 214348176        504           0        0           0         0         0       24       616       640 [C
    2    -1  71683872        504           0        0           0         0         0       24       616       640 [B
    3    -1  53085688        504           0        0           0         0         0       24       616       640 [Ljava.lang.Object;
    4    -1  28135528        504           0        0           0         0         0       32       616       648 [Ljava.util.HashMap$Node;
    5 17478  12582216       1440           0     7008          64      2681     39040    11232     37248     48480 java.util.ArrayList
 .........
 25255    25         0        528           0      592           3        42       568      448      1448      1896 zipkin2.reporter.metrics.micrometer.MicrometerReporterMetrics$Builder
            472572680   16436464      283592 41813040      225990   8361510  75069552 39924272 101013144 140937416 Total
               335.3%      11.7%        0.2%    29.7%           -      5.9%     53.3%    28.3%     71.7%    100.0%
Index Super InstBytes KlassBytes annotations    CpAll MethodCount Bytecodes MethodAll    ROAll     RWAll     Total ClassName


其中,每個指標的含義如下所示:

  • InstBytes:例項佔用大小
  • KlassBytes:類佔用大小
  • annotations:註解佔用大小
  • CpAll:常量池中佔用大小
  • MethodCount:方法個數
  • Bytecodes:位元組碼大小
  • MethodAll:方法佔用大小
  • ROAll:只讀記憶體中記憶體佔用
  • RWAll:讀寫記憶體中記憶體佔用

9. OutOfMemoryError: Compressed class space

class space 記憶體溢位導致的,和上一個異常類似,需要檢視類資訊統計定位問題。

10. OutOfMemoryError: reason stack_trace_with_native_method

這個發生在 JNI 呼叫中,記憶體不足

相關文章