G1的基本概念(G1原始碼分析和調優讀書筆記)

qq_43811135發表於2020-10-23

G1的基本概念

分割槽

分割槽(Heap Region, HR)或稱堆分割槽,是G1堆和作業系統互動的最小管理單位。
G1的分割槽型別大致可以分為四類:

1.自由分割槽

2.新生代分割槽

3.大物件分割槽

4.老生代分割槽

其中新生代分割槽又可以分為Eden和Survivor;大物件分割槽又可以分為:大物件頭分割槽和大物件連續分割槽。

堆分割槽預設大小計算方式 ↓

// 判斷是否是設定過堆分割槽大小,如果有則使用;
//沒有,則根據初始記憶體和最大分配記憶體,獲得平均值,並根據HR的個數得到分割槽的大小,和分割槽的下限比較,取兩者的最大值。
void HeapRegion::setup_heap_region_size(size_t initial_heap_size, size_t max_heap_size) {
  uintx region_size = G1HeapRegionSize;
  if (FLAG_IS_DEFAULT(G1HeapRegionSize)) {
    size_t average_heap_size = (initial_heap_size + max_heap_size) / 2;
    region_size = MAX2(average_heap_size / TARGET_REGION_NUMBER,
                       (uintx) MIN_REGION_SIZE);
  }

  //對region_size按2的冪次對齊,並且保證其落在上下限範圍內。
  int region_size_log = log2_long((jlong) region_size);
  // Recalculate the region size to make sure it's a power of
  // 2. This means that region_size is the largest power of 2 that's
  // <= what we've calculated so far.
  region_size = ((uintx)1 << region_size_log);

//確保region_size落在[1MB,32MB]之間
  // Now make sure that we don't go over or under our limits.
  if (region_size < MIN_REGION_SIZE) {
    region_size = MIN_REGION_SIZE;
  } else if (region_size > MAX_REGION_SIZE) {
    region_size = MAX_REGION_SIZE;
  }
  
  
  // 根據region_size 計算一些變數,比如卡表大小
  // And recalculate the log.
  region_size_log = log2_long((jlong) region_size);


  // Now, set up the globals.
  guarantee(LogOfHRGrainBytes == 0, "we should only set it once");
  LogOfHRGrainBytes = region_size_log;

  guarantee(LogOfHRGrainWords == 0, "we should only set it once");
  LogOfHRGrainWords = LogOfHRGrainBytes - LogHeapWordSize;

  guarantee(GrainBytes == 0, "we should only set it once");
  // The cast to int is safe, given that we've bounded region_size by
  // MIN_REGION_SIZE and MAX_REGION_SIZE.
  GrainBytes = (size_t)region_size;

  guarantee(GrainWords == 0, "we should only set it once");
  GrainWords = GrainBytes >> LogHeapWordSize;
  guarantee((size_t) 1 << LogOfHRGrainWords == GrainWords, "sanity");

  guarantee(CardsPerRegion == 0, "we should only set it once");
  CardsPerRegion = GrainBytes >> CardTableModRefBS::card_shift;

按照預設值計算,G1可以管理的最大記憶體為
2048 X 32MB =64GB。假設設定xms=32G,xmx=128G,則每個堆分割槽的大小為32M,分割槽個數動態變化範圍從1024到4096個。

region_size的一半以上的大物件直接進入老生代。

新生代大小

新生代大小指的是新生代記憶體空間的大小,前面提到的G1新生代大小按分割槽組織,首先需要計算整個新生代的大小。

如果G1推斷出最大值和最小值相等,那麼說明新生代不會動態變化,即代表G1在後續對新生代垃圾回收的時候可能不滿足期望停頓的時間。

//初始化新生代大小引數,根據不同的jvm引數判斷計算新生代大小,供後續使用。
G1YoungGenSizer::G1YoungGenSizer() : _sizer_kind(SizerDefaults), _adaptive_size(true),
        _min_desired_young_length(0), _max_desired_young_length(0)    {
//如果設定了NewRatio且同時設定NewSize或MaxNewSize的情況下,則NewRatio被忽略        
  if (FLAG_IS_CMDLINE(NewRatio)) {
    if (FLAG_IS_CMDLINE(NewSize) || FLAG_IS_CMDLINE(MaxNewSize)) {
      warning("-XX:NewSize and -XX:MaxNewSize override -XX:NewRatio");
    } else {
      _sizer_kind = SizerNewRatio;
      _adaptive_size = false;
      return;
    }
  }

//引數傳遞有問題,最小值大於最大值
  if (NewSize > MaxNewSize) {
    if (FLAG_IS_CMDLINE(MaxNewSize)) {
      warning("NewSize (" SIZE_FORMAT "k) is greater than the MaxNewSize (" SIZE_FORMAT "k). "
              "A new max generation size of " SIZE_FORMAT "k will be used.",
              NewSize/K, MaxNewSize/K, NewSize/K);
    }
    MaxNewSize = NewSize;
  }
  
  //根據引數計算分割槽個數
  if (FLAG_IS_CMDLINE(NewSize)) {
    _min_desired_young_length = MAX2((uint) (NewSize / HeapRegion::GrainBytes),
                                     1U);
    if (FLAG_IS_CMDLINE(MaxNewSize)) {
      _max_desired_young_length =
                             MAX2((uint) (MaxNewSize / HeapRegion::GrainBytes),
                                  1U);
      _sizer_kind = SizerMaxAndNewSize;
      _adaptive_size = _min_desired_young_length == _max_desired_young_length;
    } else {
      _sizer_kind = SizerNewSizeOnly;
    }
  } else if (FLAG_IS_CMDLINE(MaxNewSize)) {
    _max_desired_young_length =
                             MAX2((uint) (MaxNewSize / HeapRegion::GrainBytes),
                                  1U);
    _sizer_kind = SizerMaxNewSizeOnly;
  }
}

//使用G1NewSizePercent來計算新生代的最小值
uint G1YoungGenSizer::calculate_default_min_length(uint new_number_of_heap_regions) {
  uint default_value = (new_number_of_heap_regions * G1NewSizePercent) / 100;
  return MAX2(1U, default_value);
}

//使用G1MaxNewSizePercent來計算新生代的最大值
uint G1YoungGenSizer::calculate_default_max_length(uint new_number_of_heap_regions) {
  uint default_value = (new_number_of_heap_regions * G1MaxNewSizePercent) / 100;
  return MAX2(1U, default_value);
}

//這裡根據不同的引數輸入來計算大小
//recalculate_min_max_young_length在初始化時被呼叫,在堆空間改變時也會被呼叫
void G1YoungGenSizer::recalculate_min_max_young_length(uint number_of_heap_regions, uint* min_young_length, uint* max_young_length) {
  assert(number_of_heap_regions > 0, "Heap must be initialized");

  switch (_sizer_kind) {
    case SizerDefaults:
      *min_young_length = calculate_default_min_length(number_of_heap_regions);
      *max_young_length = calculate_default_max_length(number_of_heap_regions);
      break;
    case SizerNewSizeOnly:
      *max_young_length = calculate_default_max_length(number_of_heap_regions);
      *max_young_length = MAX2(*min_young_length, *max_young_length);
      break;
    case SizerMaxNewSizeOnly:
      *min_young_length = calculate_default_min_length(number_of_heap_regions);
      *min_young_length = MIN2(*min_young_length, *max_young_length);
      break;
    case SizerMaxAndNewSize:
      // Do nothing. Values set on the command line, don't update them at runtime.
      break;
    case SizerNewRatio:
      *min_young_length = number_of_heap_regions / (NewRatio + 1);
      *max_young_length = *min_young_length;
      break;
    default:
      ShouldNotReachHere();
  }

另一個問題,分配新的分割槽時何時擴充,一次擴充多少記憶體?
G1是自適應擴充空間的。

引數-XX:GCTimeRatio表示GC與應用耗費時間比,G1中預設為9,計算方式為_gc_overhead_perc = 100.0x(1.0/(1.0+GCTimeRatio)),即G1 GC時間與應用時間佔比不超過10%時不需要動態擴充。

size_t G1CollectorPolicy::expansion_amount() {
//根據歷史資訊獲取平均GC時間
  double recent_gc_overhead = recent_avg_pause_time_ratio() * 100.0;
  double threshold = _gc_overhead_perc;
  //G1 GC時間與應用時間佔比超過閾值才需要動態擴充套件
  //這個閾值的值為10% 上文提過計算方式
  if (recent_gc_overhead > threshold) {
    // We will double the existing space, or take
    // G1ExpandByPercentOfAvailable % of the available expansion
    // space, whichever is smaller, bounded below by a minimum
    // expansion (unless that's all that's left.)
    const size_t min_expand_bytes = 1*M;
    size_t reserved_bytes = _g1->max_capacity();
    size_t committed_bytes = _g1->capacity();
    size_t uncommitted_bytes = reserved_bytes - committed_bytes;
    size_t expand_bytes;
    size_t expand_bytes_via_pct =
      uncommitted_bytes * G1ExpandByPercentOfAvailable / 100;
    expand_bytes = MIN2(expand_bytes_via_pct, committed_bytes);
    expand_bytes = MAX2(expand_bytes, min_expand_bytes);
    expand_bytes = MIN2(expand_bytes, uncommitted_bytes);

  ......
  } else {
    return 0;
  }
}
//G1記憶體擴充時間書後面部分會介紹

G1停頓預測模型

G1是一個響應優先的GC演算法,使用者可以設定期望停頓時間由引數MaxGCPauseMills控制,預設值為200ms。
G1會在這個目標停頓時間內完成垃圾回收的工作。

G1使用停頓預測模型來滿足期望,預測邏輯基於衰減平均值和衰減標準差。

卡表和點陣圖

GC最早引入卡表是為了對記憶體的引用關係做標記,從而根據引用關係快速遍歷活躍物件。

可以藉助點陣圖的方式,記錄記憶體塊之間的引用關係。用一個位來描述一個字,我們只需要判定點陣圖裡面的位是否有1,有的話則認為發生了引用。



以位為粒度的點陣圖能準確描述每一個字的引用關係,但是包含資訊太少,只能描述兩個狀態:引用和未被引用。但是如果增加一個位元組來描述狀態,則點陣圖需要256kb的空間,這個數字太大,開銷佔了25%。所以一個可能的做法是點陣圖不再描述一個字,而是一個區域,JVM使用512位元組作為單位,用一個位元組描述512位元組的引用關係。

G1中還使用了bitmap,用bitmap可以描述一個分割槽對另外一個分割槽的引用情況,也可以描述記憶體分配的情況。

併發標記時也使用了bitmap來描述物件的分配情況。

物件頭

java程式碼首先被翻譯成位元組碼(bytecode),在JVM執行時才能確定要執行函式的地址,如何實現java的多型呼叫,最直觀的想法是把java物件對映成C++物件或者封裝成C++物件,比如增加一個額外的物件頭,裡面指向一個物件,而這個物件儲存了java程式碼的地址。


所以JVM設計了物件的資料結構來描述java物件,這個結構分為三塊區域:物件頭 、例項資料和對齊填充


而我們剛才提到的類似虛指標的東西就可以放在物件頭中,而JVM設計者還利用物件頭來描述更多資訊,物件的鎖資訊、GC標記資訊等。

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
  
  //靜態變數用於快速訪問BarrierSet
  static BarrierSet* _bs;

1.標記資訊


第一部分標記資訊位於MarkOop。

以下三種情況時要儲存物件頭:
1.使用了偏向鎖,並且偏向鎖被設定了
2.物件被加鎖了
3.物件被設定了hash_code

2.後設資料資訊


第二部分後設資料資訊欄位指向的是Klass物件(Klass物件是後設資料物件,如Instance Klass 描述java物件的類結構),這個欄位也和垃圾回收有關係。

記憶體分配和管理

JVM通過作業系統的系統呼叫進行記憶體的申請,典型的就是mmap。

mmap使用PAGE_SIZE為單位來進行對映,而記憶體也只能以頁為單位進行對映,若要對映非PAGE_SIZE整數倍的地址範圍,要先進行記憶體對齊,強行對映。


作業系統對記憶體的分配管理典型的分為兩個階段:
保留和提交。

保留階段告知系統從某一地址開始到後面的dwSize大小的連續虛擬記憶體需要供程式使用,程式其他分配記憶體的操作不得使用這段記憶體;

提交階段將虛擬地址對映到對應的真實實體地址中,這樣這塊記憶體就可以正常使用。


JVM常見物件型別


ResourceObj:執行緒有一個資源空間,一般ResourceObj都位於這裡。定義資源空間的目的是對JVM其他功能的支援,如CFG、在C1/C2優化時可能需要訪問執行時資訊(這些資訊可以儲存線上程的資源區)。


StackObj:棧物件,宣告的物件使用棧管理。其例項物件並不提供任何功能,且禁止New/Delete操作。物件分配線上程棧中,或者使用自定義的棧容器進行管理。


ValueObj:值物件,該物件在堆物件需要進行巢狀時使用,簡單地說就是物件分配的位置和宿主物件(即擁有)是一樣的。


AllStatic: 靜態物件,全域性物件,只有一個。值得一提的是C++初始化沒有通過規範保證,可能會有兩個靜態物件相互依賴的問題,初始化時可能會出錯。JVM中很多靜態物件初始化都是顯示呼叫靜態初始化函式。


MetaspaceObj: 元物件,比如InstanceKlass這樣的後設資料就是元物件。


CHeapObj:
這是堆空間的物件,由new/delete/free/malloc管理。其中包含的內容很多,比如java物件、InstanceOop(後面提到的G1物件分配出來的物件)。除了Java物件,還有其他的物件也在堆中。

  mtNone              = 0x0000,  // undefined
  mtClass             = 0x0100,  // JVM中java類
  mtThread            = 0x0200,  // JVM中執行緒物件
  mtThreadStack       = 0x0300,
  mtCode              = 0x0400,  // JVM中生成的編譯程式碼
  mtGC                = 0x0500,  // GC的記憶體
  mtCompiler          = 0x0600,  // 編譯器使用的記憶體
  mtInternal          = 0x0700,  // JVM中內部使用的型別,不屬於上述型別。
                                
  mtOther             = 0x0800,  // 不是由JVM使用的記憶體
  mtSymbol            = 0x0900,  //符號表使用記憶體
  mtNMT               = 0x0A00,  // mNMT使用記憶體
  mtChunk             = 0x0B00,  // chunk用於快取
  mtJavaHeap          = 0x0C00,  // Java 堆
  mtClassShared       = 0x0D00,  // 共享類資料
  mtTest              = 0x0E00,  // Test type for verifying NMT
  mtTracing           = 0x0F00,  // memory used for Tracing
  mt_number_of_types  = 0x000F,  // number of memory types (mtDontTrack
                                 // is not included as validate type)
  mtDontTrack         = 0x0F00,  // memory we do not or cannot track
  mt_masks            = 0x7F00,

執行緒

圖片.png

JVM執行緒圖 如上

JavaThread:就是要執行Java程式碼的執行緒,比如Java程式碼的啟動會建立一個JavaThread執行;對於Java程式碼的啟動,可以通過JNI_CreateJavaVM來建立一個JavaThread,而對於一般的Java執行緒,都是呼叫java.lang.thread中的start方法,這個方法通過JNI呼叫建立JavaThread物件,完成真正的執行緒建立。


CompilerThread:執行JIT的執行緒。


WatcherThread:執行週期性任務,JVM裡面有很多週期性任務,例如記憶體管理中對小物件使用了ChunkPool,而這種管理需要週期性的清理動作Cleaner;JVM中記憶體抽樣任務MemProf?ilerTask等都是週期性任務。


NameThread:是JVM內部使用的執行緒,分類如圖2-1所示。


VMThread:JVM執行GC的同步執行緒,這個是JVM最關鍵的執行緒之一,主要是用於處理垃圾回收。簡單地說,所有的垃圾回收操作都是從VMThread觸發的,如果是多執行緒回收,則啟動多個執行緒,如果是單執行緒回收,則使用VMThread進行。

VMThread提供了一個佇列,任何要執行GC的操作都實現了VM_GC_Operation,在JavaThread中執行VMThread::execute(VM_GC_Operation)把GC操作放入到佇列中,然後再用VMThread的run方法輪詢這個佇列就可以了。


當這個佇列有內容的時候它就開始嘗試進入安全點,然後執行相應的GC任務,完成GC任務後會退出安全點

ConcurrentGCThread:併發執行GC任務的執行緒,比如G1中的ConcurrentMark
Thread和ConcurrentG1RefineThread,分別處理併發標記和併發Refine,這兩個執行緒將在混合垃圾收集和新生代垃圾回收中介紹。


WorkerThread

工作執行緒,在G1中使用了FlexibleWorkGang,這個執行緒是並行執行的(個數一般和CPU個數相關),所以可以認為這是一個執行緒池。

執行緒池裡面的執行緒是為了執行任務(在G1中是G1ParTask),也就是做GC工作的地方。VMThread會觸發這些任務的排程執行(其實是把G1ParTask放入到這些工作執行緒中,然後由工作執行緒進行排程)。

JVM執行緒狀態

//新建立執行緒
case NEW        
: return "NEW";
//可執行或者正在執行
case RUNNABLE                 : return "RUNNABLE";
//呼叫Thread.sleep()進入睡眠
case SLEEPING                 : return "TIMED_WAITING (sleeping)";
//呼叫Object.wait()進入等待
case IN_OBJECT_WAIT           : return "WAITING (on object monitor)";
//呼叫Object.wait(long)進入等待且有過期時間
case IN_OBJECT_WAIT_TIMED     : return "TIMED_WAITING (on object monitor)";
//JVM內部呼叫LockSupport.park()進入等待
case PARKED                   : return "WAITING (parking)";
//JVM內部呼叫LockSupport.park()進入等待,且有過期時間
case PARKED_TIMED             : return "TIMED_WAITING (parking)";
//進入一個同步塊
case BLOCKED_ON_MONITOR_ENTER : return "BLOCKED (on object monitor)";
//終止
case TERMINATED               : return "TERMINATED";
default                       : return "UNKNOWN";

作業系統的執行緒狀態:

  ALLOCATED,                    // 分配了但未初始化
  INITIALIZED,                  // 初始化完未啟動
  RUNNABLE,                     //  已經啟動並可被執行或者正在執行
  MONITOR_WAIT,                 // 等待一個Monitor
  CONDVAR_WAIT,                 // 等待一個條件變數
  OBJECT_WAIT,                  // 通過呼叫Object.wait()等待物件
  BREAKPOINTED,                 //調式狀態
  SLEEPING,                     // 通過Thread.sleep()進入睡眠
  ZOMBIE                        // 殭屍狀態,等待回收
  

棧幀

棧幀(frame)線上程執行時和執行過程中用於儲存執行緒的上下文資料,JVM設計了棧幀,這是垃圾回收中國最重要的根,棧幀的結構在不同的CPU中並不相同,在x86中程式碼如下所示:

  _pc = NULL;//程式計數器,指向下一個要執行的程式碼地址
  _sp = NULL;//棧頂指標
  _unextended_sp = NULL;//異常棧頂指標
  _fp = NULL;//棧底指標
  _cb = NULL;//程式碼塊的地址
  _deopt_state = unknown;//這個欄位描述從編譯程式碼到解釋程式碼反優化的狀態

棧幀也和GC密切相關,在GC過程中,通常第一步就是遍歷根,Java執行緒棧幀就是根元素之一,遍歷整個棧幀的方式是通過StackFrameStream,其中封裝了一個next指標,其原理和上述的程式碼一樣通過sender來獲得呼叫者的棧幀。

我們將Java的棧幀來作為根遍歷堆,對物件進行標記並收集垃圾。

控制程式碼

執行緒不但可以執行java程式碼,也可以執行原生程式碼(JVM裡的程式碼)。JVM沒有區分Java棧和本地方法棧,如果通過棧進行處理則必須要區分這兩種情況。


JVM設計了handleArea,這是一塊執行緒的資源區,在這個區域分配控制程式碼並管理所有的控制程式碼,如果函式還在呼叫中,那麼控制程式碼有效,控制程式碼關聯的物件也就是活躍物件。

為了管理控制程式碼的生命週期,引入了HandleMark,通常HandleMark分配在棧上,在建立HandleMark的時候標記handleArea物件有效,在HandleMark物件析構的時候從HandleArea中刪除物件的引用。

在HandleMark中標記Chunk的地址,這個就是找到當前本地方法程式碼中活躍的控制程式碼,因此也就可以找到對應的活躍的OOP物件。下面是HandleMark的建構函式和解構函式,它們的主要工作就是構建控制程式碼連結串列,程式碼如下所示:
圖片.png

相關文章