日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

常见的GC算法(GC的背景与原理)

發布時間:2024/1/8 编程问答 36 豆豆
生活随笔 收集整理的這篇文章主要介紹了 常见的GC算法(GC的背景与原理) 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

常見的GC算法(GC的背景與原理)

GC 是英文詞匯Garbage Collection的縮寫,中文一般直譯為 “垃圾收集”。當然也會說 “垃圾回收”。

三種垃圾收集器實現(Paraller/CMS/G1)

手動內存管理

之前有C/C++編程經驗、或者了解計算機原理的同學,會很容易理解 “內存分配” 和 “內存釋放” 和兩個概念。

計算機程序在執行過程中,需要有地方來存放輸入參數、中間變量,以及運算結果,之前的文章有提到,我們知道這些會存放到棧內存中。

像C/C++的編程,需要使用完資源后,手動調用清除內存操作,這類歸屬于手動內存管理。

弊端就是:代碼經手人多了,很可能不清楚哪些是需要清理的,造成管理混亂。

引用計數法

GC 垃圾收集器就像倉庫部門,負責分配內存,負責追蹤這些內存的使用情況,并在合適的時候進行釋放。

但是,如果業務變得更復雜。倉庫之間需要協同工作,有了依賴關系之后。

這時候單純的引用計數就會出問題,循環依賴的倉庫/對象沒辦法回收,就像數據庫的死鎖一樣讓人討厭,你沒法讓它自己變成0。

這種情況在計算機中叫做 “內存泄漏”, 該釋放的沒釋放,該回收的沒回收。

如果依賴關系更復雜,計算機的內存資源很可能用滿,或者說不夠用,內存不夠用則稱為 “內尺寸溢出”。

這樣我們知道了引用計數法的一些缺陷,有沒有辦法解決呢?辦法總比困難多,我找個人專門來排查循環計數行了吧,一個不夠就兩個…但如果倉庫成千上萬,或者上億呢?還是能解決的,最多不就是慢點嘛。

  • 第一代自動垃圾回收算法,使用的是引用計數。針對每個對象,只需要記住被引用的次數,當引用計數變為0時,這個對象就可以被安全地回收了,著名的示例是C++的共享指針;
  • 第二代自動垃圾回收算法,被稱為 “引用追蹤”,JVM 使用的各種垃圾回收算法都是基于引用追蹤方式的算法。

標記清除算法(Mark and Sweep)

前面講了引用計數里需要查找所有的對象計數和對象之間的引用關系。那么如何來查找鎖有對象,怎么來做標記呢?

為了遍歷所有對象,JVM明確定義了什么是對象的可達性。

有一類很明確具體的對象,稱為垃圾回收根元素,包括:

  • 局部變量(Local variables)
  • 活動線程(Active threads)
  • 靜態域(Static fields)
  • 其他對象

JVM 使用標記—清除算法,來跟蹤所有的可達對象(包括存活對象),確保所有不可達對象占用的內尺寸都能被重用。其中包含兩步:

  • Marking(標記):遍歷所有的可達對象,并在本地內存中分門別類記下。
  • Sweeping(清除):這一步保證了,不可達對象所占用的內存,在之后進行內存分配時可以重用。

JVM中包含了多種GC算法,如Parallel Scavenge(并行清除)Parallel Mark + Coyp(并行標記+復制)CMS,他們在實現上略有不同,但理論上都采用了以上兩個步驟。

標記清除算法最重要的優勢,就是不再因為循環引用而導致內存泄漏:

**標記—清除(Mark and Sweep)**是最經典的垃圾回收算法。

而這種處理方式不好的地方在于:垃圾回收過程中,需要暫停應用程序的所有線程。假如不暫停,則對象間的引用關系會一直不停地發生變化,那樣就沒法進行統計了。這種情況就做STW停頓(Stop The World pause 全線暫停),讓應用程序暫時停止,讓JVM進行內存清理工作。

碎片整理

每次執行清除(Sweeping),JVM 都必須保證不可達對象占用的內存能被回收重用。這時候,就像是擺滿棋子的圍棋盤上,一部分位置上棋子被拿掉而產生了一些零散的空位置。但這(最終)有可能會產生內存碎片(類似于磁盤碎片),進而引發兩個問題:

  • 寫入操作越來越耗時,因為尋找一塊足夠大的空閑內存會變得困難(棋盤上沒有一整片的空地方);
  • 在創建新對象時,JVM 在連續的塊中分配內存。如果碎片問題很嚴重,直至沒有空閑片段能存放下新創建的對象,就會發生內存分配錯誤(allocation error)。

要避免這類問題,JVM 必須確保碎片問題不失控。因此在垃圾收集過程中,不僅僅是標記和清除,還需要執行“內存碎片整理”過程。這個過程讓所有可達對象(reachable objects)依次排列,以消除(或減少)碎片。就像是我們把棋盤上剩余的棋子都聚集到一起,留出來足夠大的空余區域。示意圖如下所示:

說明

JVM 中的引用是一個抽象的概念,如果 GC 移動某個對象,就會修改(棧和堆中)所有指向該對象的引用。

移動/拷貝/提升/壓縮一般來說是一個 STW 的過程,所以修改對象引用是一個安全的行為。但要更新所有的引用,可能會影響應用程序的性能。

分代假設

我們前面提到過,執行垃圾收集需要停止整個應用。很明顯,對象越多則收集所有垃圾消耗的時間就越長。但可不可以只處理一個較小的內存區域呢?為了探究這種可能性,研究人員發現,程序中的大多數可回收的內存可歸為兩類:

  • 大部分對象很快就不再使用,生命周期較短;
  • 還有一部分不會立即無用,但也不會持續太長時間。

這些觀測形成了 弱代假設(Weak Generational Hypothesis),即我們可以根據對象的不同特點,把對象進行分類。基于這一假設,VM 中的內存被分為年輕代(Young Generation)和老年代(Old Generation)。老年代有時候也稱為年老區(Tenured)。

拆分為這樣兩個可清理的單獨區域,我們就可以根據對象的不同特點,允許采用不同的算法來大幅提高 GC 的性能。

天下沒有免費的午餐,所以這種方法也不是沒有任何問題。例如,在不同分代中的對象可能會互相引用,在收集某一個分代時就會成為“事實上的”GC root。

當然,要著重強調的是,分代假設并不適用于所有程序。因為分代 GC 算法專門針對“要么死得快”、“否則活得長”這類特征的對象來進行優化,此時 JVM 管理那種存活時間半長不長的對象就顯得非常尷尬了。

內存池劃分

堆內存中的內存池劃分也是類似的,不太容易理解的地方在于各個內存池中的垃圾收集是如何運行的。請注意:不同的 GC 算法在實現細節上可能會有所不同,但和本章所介紹的相關概念都是一致的。

新生代(Eden Space)

Eden Space,也叫伊甸區,是內存中的一個區域,用來分配新創建的對象。通常會有多個線程同時創建多個對象,所以 Eden 區被劃分為多個 線程本地分配緩沖區(Thread Local Allocation Buffer,簡稱 TLAB)。通過這種緩沖區劃分,大部分對象直接由 JVM 在對應線程的 TLAB 中分配,避免與其他線程的同步操作。

如果 TLAB 中沒有足夠的內存空間,就會在共享 Eden 區(shared Eden space)之中分配。如果共享 Eden 區也沒有足夠的空間,就會觸發一次 年輕代 GC 來釋放內存空間。如果 GC 之后 Eden 區依然沒有足夠的空閑內存區域,則對象就會被分配到老年代空間(Old Generation)。

當 Eden 區進行垃圾收集時,GC 將所有從 root 可達的對象過一遍,并標記為存活對象。

我們曾指出,對象間可能會有跨代的引用,所以需要一種方法來標記從其他分代中指向 Eden 的所有引用。這樣做又會遭遇各個分代之間一遍又一遍的引用。JVM 在實現時采用了一些絕招:卡片標記(card-marking)。從本質上講,JVM 只需要記住 Eden 區中“臟”對象的粗略位置,可能有老年代的對象引用指向這部分區間。更多細節請參考:Nitsan 的博客。

標記階段完成后,Eden 區中所有存活的對象都會被復制到存活區(Survivor spaces)里面。整個 Eden 區就可以被認為是空的,然后就能用來分配新對象。這種方法稱為“標記—復制”(Mark and Copy):存活的對象被標記,然后復制到一個存活區(注意,是復制,而不是移動)。

讀者可以考慮,為什么是復制不是移動?

存活區(Survivor Spaces)

Eden 區的旁邊是兩個存活區(Survivor Spaces),稱為 from 空間和 to 空間。需要著重強調的的是,任意時刻總有一個存活區是空的(empty)。

空的那個存活區用于在下一次年輕代 GC 時存放收集的對象。年輕代中所有的存活對象(包括 Eden 區和非空的那個“from”存活區)都會被復制到 ”to“ 存活區。GC 過程完成后,“to”區有對象,而“from”區里沒有對象。兩者的角色進行正好切換,from 變成 to,to 變成 from。

存活的對象會在兩個存活區之間復制多次,直到某些對象的存活 時間達到一定的閥值。分代理論假設,存活超過一定時間的對象很可能會繼續存活更長時間。

這類“年老”的對象因此被提升(promoted)到老年代。提升的時候,存活區的對象不再是復制到另一個存活區,而是遷移到老年代,并在老年代一直駐留,直到變為不可達對象。

為了確定一個對象是否“足夠老”,可以被提升(Promotion)到老年代,GC 模塊跟蹤記錄每個存活區對象存活的次數。每次分代 GC 完成后,存活對象的年齡就會增長。當年齡超過提升閾值(tenuring threshold),就會被提升到老年代區域。

具體的提升閾值由 JVM 動態調整,但也可以用參數 -XX:+MaxTenuringThreshold 來指定上限。如果設置 -XX:+MaxTenuringThreshold=0 ,則 GC 時存活對象不在存活區之間復制,直接提升到老年代。現代 JVM 中這個閾值默認設置為 15 個 GC 周期。這也是 HotSpot JVM 中允許的最大值。

如果存活區空間不夠存放年輕代中的存活對象,提升(Promotion)也可能更早地進行。

老年代(Old Gen)

老年代的 GC 實現要復雜得多。老年代內存空間通常會更大,里面的對象是垃圾的概率也更小。

老年代 GC 發生的頻率比年輕代小很多。同時,因為預期老年代中的對象大部分是存活的,所以不再使用標記和復制(Mark and Copy)算法。而是采用移動對象的方式來實現最小化內存碎片。老年代空間的清理算法通常是建立在不同的基礎上的。原則上,會執行以下這些步驟:

  • 通過標志位(marked bit),標記所有通過 GC roots 可達的對象;
  • 刪除所有不可達對象;
  • 整理老年代空間中的內容,方法是將所有的存活對象復制,從老年代空間開始的地方依次存放。

通過上面的描述可知,老年代 GC 必須明確地進行整理,以避免內存碎片過多。

永久代(Perm Gen)

在 Java 8 之前有一個特殊的空間,稱為“永久代”(Permanent Generation)。這是存儲元數據(metadata)的地方,比如 class 信息等。此外,這個區域中也保存有其他的數據和信息,包括內部化的字符串(internalized strings)等等。

實際上這給 Java 開發者造成了很多麻煩,因為很難去計算這塊區域到底需要占用多少內存空間。預測失敗導致的結果就是產生 java.lang.OutOfMemoryError: Permgen space 這種形式的錯誤。除非 OutOfMemoryError 確實是內存泄漏導致的,否則就只能增加 permgen 的大小,例如下面的示例,就是設置 perm gen 最大空間為 256 MB:

-XX:MaxPermSize=256m 復制

元數據區(Metaspace)

既然估算元數據所需空間那么復雜,Java 8 直接刪除了永久代(Permanent Generation),改用 Metaspace。從此以后,Java 中很多雜七雜八的東西都放置到普通的堆內存里。

當然,像類定義(class definitions)之類的信息會被加載到 Metaspace 中。元數據區位于本地內存(native memory),不再影響到普通的 Java 對象。默認情況下,Metaspace 的大小只受限于 Java 進程可用的本地內存。這樣程序就不再因為多加載了幾個類/JAR 包就導致 java.lang.OutOfMemoryError: Permgen space.。注意,這種不受限制的空間也不是沒有代價的 —— 如果 Metaspace 失控,則可能會導致嚴重影響程序性能的內存交換(swapping),或者導致本地內存分配失敗。

如果需要避免這種最壞情況,那么可以通過下面這樣的方式來限制 Metaspace 的大小,如 256 MB:

-XX:MaxMetaspaceSize=256m 復制

垃圾收集

各種垃圾收集器的實現細節雖然并不相同,但總體而言,垃圾收集器都專注于兩件事情:

  • 查找所有存活對象
  • 拋棄其他的部分,即死對象,不再使用的對象。

第一步,記錄(census)所有的存活對象,在垃圾收集中有一個叫做 標記(Marking) 的過程專門干這件事。

標記可達對象(Marking Reachable Objects)

現代 JVM 中所有的 GC 算法,第一步都是找出所有存活的對象。下面的示意圖對此做了最好的詮釋:

首先,有一些特定的對象被指定為 Garbage Collection Roots(GC 根元素)。包括:

  • 當前正在執行的方法里的局部變量和輸入參數
  • 活動線程(Active threads)
  • 內存中所有類的靜態字段(static field)
  • JNI 引用

其次,GC 遍歷(traverses)內存中整體的對象關系圖(object graph),從 GC 根元素開始掃描,到直接引用,以及其他對象(通過對象的屬性域)。所有 GC 訪問到的對象都被標記(marked) 為存活對象。

存活對象在上圖中用藍色表示。標記階段完成后,所有存活對象都被標記了。而其他對象(上圖中灰色的數據結構)就是從 GC 根元素不可達的,也就是說程序不能再使用這些不可達的對象(unreachable object)。這樣的對象被認為是垃圾,GC 會在接下來的階段中清除他們。

在標記階段有幾個需要注意的地方:在標記階段,需要暫停所有應用線程,以遍歷所有對象的引用關系。因為不暫停就沒法跟蹤一直在變化的引用關系圖。這種情景叫做 Stop The World pause全線停頓),而可以安全地暫停線程的點叫做安全點(safe point),然后,JVM 就可以專心執行清理工作。安全點可能有多種因素觸發,當前,GC 是觸發安全點最常見的原因。

此階段暫停的時間,與堆內存大小,對象的總數沒有直接關系,而是由存活對象(alive objects)的數量來決定。所以增加堆內存的大小并不會直接影響標記階段占用的時間。

標記 階段完成后,GC 進行下一步操作,刪除不可達對象。

刪除不可達對象(Removing Unused Objects)

各種 GC 算法在刪除不可達對象時略有不同,但總體可分為三類:清除(sweeping)、整理(compacting)和復制(copying)。[下一小節] 將詳細講解這些算法。

清除(Sweeping)

**Mark and Sweep(標記—清除)**算法的概念非常簡單:直接忽略所有的垃圾。也就是說在標記階段完成后,所有不可達對象占用的內存空間,都被認為是空閑的,因此可以用來分配新對象。

這種算法需要使用空閑表(free-list),來記錄所有的空閑區域,以及每個區域的大小。維護空閑表增加了對象分配時的開銷。此外還存在另一個弱點 —— 明明還有很多空閑內存,卻可能沒有一個區域的大小能夠存放需要分配的對象,從而導致分配失敗(在 Java 中就是 OutOfMemoryError)。

整理(Compacting)

標記—清除—整理算法(Mark-Sweep-Compact),將所有被標記的對象(存活對象),遷移到內存空間的起始處,消除了“標記—清除算法”的缺點。

相應的缺點就是 GC 暫停時間會增加,因為需要將所有對象復制到另一個地方,然后修改指向這些對象的引用。

此算法的優勢也很明顯,碎片整理之后,分配新對象就很簡單,只需要通過指針碰撞(pointer bumping)即可。使用這種算法,內存空間剩余的容量一直是清楚的,不會再導致內存碎片問題。

復制(Copying)

**標記—復制算法(Mark and Copy)**和“標記—整理算法”(Mark and Compact)十分相似:兩者都會移動所有存活的對象。區別在于,“標記—復制算法”是將內存移動到另外一個空間:存活區。“標記—復制方法”的優點在于:標記和復制可以同時進行。缺點則是需要一個額外的內存區間,來存放所有的存活對象。

總結

以上是生活随笔為你收集整理的常见的GC算法(GC的背景与原理)的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。