JVM调优:卡表(CardTable)简介
我們知道,JVM在進行垃圾收集時,需要先標記所有可達對象,然后再清除不可達對象,釋放內存空間。那么,如何快速的找到所有可達對象呢?
最簡單粗暴的實現(xiàn),就是每次進行垃圾收集時,都對整個堆中的所有對象進行掃描,找到所有存活對象。邏輯是簡單,但性能比較差。
簡單粗暴的實現(xiàn)方式,通常都是不可取的。那JVM是如何實現(xiàn)快速標記可達對象的?
答案是GC Roots。
GC Roots是垃圾收集器尋找可達對象的起點,通過這些起始引用,可以快速的遍歷出存活對象。GC Roots最常見的是靜態(tài)引用和堆棧的局部引用變量。然而,這不是我們這講的重點:)
現(xiàn)代JVM,堆空間通常被劃分為新生代和老年代。由于新生代的垃圾收集通常很頻繁,如果老年代對象引用了新生代的對象,那么,需要跟蹤從老年代到新生代的所有引用,從而避免每次YGC時掃描整個老年代,減少開銷。
對于HotSpot JVM,使用了卡標記(Card Marking)技術來解決老年代到新生代的引用問題。具體是,使用卡表(Card Table)和寫屏障(Write Barrier)來進行標記并加快對GC Roots的掃描。
卡表(Card Table)
基于卡表(Card Table)的設計,通常將堆空間劃分為一系列2次冪大小的卡頁(Card Page)。
卡表(Card Table),用于標記卡頁的狀態(tài),每個卡表項對應一個卡頁。
HotSpot JVM的卡頁(Card Page)大小為512字節(jié),卡表(Card Table)被實現(xiàn)為一個簡單的字節(jié)數組,即卡表的每個標記項為1個字節(jié)。
當對一個對象引用進行寫操作時(對象引用改變),寫屏障邏輯將會標記對象所在的卡頁為dirty。
OpenJDK/Oracle 1.6/1.7/1.8 JVM默認的卡標記簡化邏輯如下:
CARD_TABLE [this address >> 9] = 0;首先,計算對象引用所在卡頁的卡表索引號。將地址右移9位,相當于用地址除以512(2的9次方)。可以這么理解,假設卡表卡頁的起始地址為0,那么卡表項0、1、2對應的卡頁起始地址分別為0、512、1024(卡表項索引號乘以卡頁512字節(jié))。
其次,通過卡表索引號,設置對應卡標識為dirty。
?
?
?
帶來的2個問題
1.無條件寫屏障帶來的性能開銷
每次對引用的更新,無論是否更新了老年代對新生代對象的引用,都會進行一次寫屏障操作。顯然,這會增加一些額外的開銷。但是,與YGC時掃描整個老年代相比較,這個開銷就低得多了。
不過,在高并發(fā)環(huán)境下,寫屏障又帶來了虛共享(false sharing)問題。
2.高并發(fā)下虛共享帶來的性能開銷
在高并發(fā)情況下,頻繁的寫屏障很容易發(fā)生虛共享(false sharing),從而帶來性能開銷。
假設CPU緩存行大小為64字節(jié),由于一個卡表項占1個字節(jié),這意味著,64個卡表項將共享同一個緩存行。
HotSpot每個卡頁為512字節(jié),那么一個緩存行將對應64個卡頁一共64*512=32KB。
如果不同線程對對象引用的更新操作,恰好位于同一個32KB區(qū)域內,這將導致同時更新卡表的同一個緩存行,從而造成緩存行的寫回、無效化或者同步操作,間接影響程序性能。
一個簡單的解決方案,就是不采用無條件的寫屏障,而是先檢查卡表標記,只有當該卡表項未被標記過才將其標記為dirty。
這就是JDK 7中引入的解決方法,引入了一個新的JVM參數-XX:+UseCondCardMark,在執(zhí)行寫屏障之前,先簡單的做一下判斷。如果卡頁已被標識過,則不再進行標識。
簡單理解如下:
if (CARD_TABLE [this address >> 9] != 0)CARD_TABLE [this address >> 9] = 0;與原來的實現(xiàn)相比,只是簡單的增加了一個判斷操作。
雖然開啟-XX:+UseCondCardMark之后多了一些判斷開銷,但是卻可以避免在高并發(fā)情況下可能發(fā)生的并發(fā)寫卡表問題。通過減少并發(fā)寫操作,進而避免出現(xiàn)虛共享問題(false sharing)。
也用于CMS GC
CMS在并發(fā)標記階段,應用線程和GC線程是并發(fā)執(zhí)行的,因此可能產生新的對象或對象關系發(fā)生變化,例如:
- 新生代的對象晉升到老年代;
- 直接在老年代分配對象;
- 老年代對象的引用關系發(fā)生變更;
- 等等。
對于這些對象,需要重新標記以防止被遺漏。為了提高重新標記的效率,并發(fā)標記階段會把這些發(fā)生變化的對象所在的Card標識為Dirty,這樣后續(xù)階段就只需要掃描這些Dirty Card的對象,從而避免掃描整個老年代。
參見:Java之CMS GC的7個階段
https://ezlippi.com/blog/2018/01/jvm-card-table-turning.html
網上關于JVM調優(yōu)的文章很多,這篇文章主要介紹JVM里Card Table的作用。我們知道JVM GC可以分為MinorGC、MajorGC和FullGC,對于Mirnor GC來講它的耗時主要由兩個因素決定:
Java虛擬機用了一個叫做CardTable(卡表)的數據結構來標記老年代的某一塊內存區(qū)域中的對象是否持有新生代對象的引用,卡表的數量取決于老年代的大小和每張卡對應的內存大小,每張卡在卡表中對應一個比特位,當老年代中的某個對象持有了新生代對象的引用時,JVM就把這個對象對應的Card所在的位置標記為dirty(bit位設置為1),這樣在Minor GC時就不用掃描整個老年代,而是掃描Card為Dirty對應的那些內存區(qū)域。
這樣子可以提高效率減少MinorGC的停頓時間。
在JVM中,一個Card的大小是512字節(jié),在多個線程并行收集時,JVM通過ParGCCardsPerStrideChunk參數設置每個線程每次掃描的Card數量,默認是256,相當于是把老年代分成許多strides,每個線程每次掃描一個stride,每個stride大小為512*256 = 128K,如果你的老年代大小為4G,那總共有4G/128K=32K個Strides。多線程在掃描這么多的strides時就涉及到調度和分配的問題,stride數量太多就會導致線程在stride之間切換的開銷增加,進而導致GC暫停時間增長。因此JVM提供了ParGCCardsPerStrideChunk這個參數來配置每個stride對應的card數量,這個數量要根據實際的業(yè)務場景進行調優(yōu),網上一般流傳3個魔術數字:32768、4K和8K。
-XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=4096這個值不能設置的太大,因為GC線程需要掃描這個stride中老年代對象持有的新生代對象的引用,如果只有少量引用新生代的對象那就導致浪費了很多時間在根本不需要掃描的對象上。
參考文檔:
參考
psy-lob-saw.blogspot.com/2014/10/the…
blogs.oracle.com/dave/false-…
bibliography.selflanguage.org/_static/wri…
www.memorymanagement.org/glossary/b.…
www.memorymanagement.org/glossary/w.…
docs.oracle.com/cd/E19205-0…
ifeve.com/falsesharin…
------------------------------------
總結
以上是生活随笔為你收集整理的JVM调优:卡表(CardTable)简介的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Flutter Card、ListTit
- 下一篇: 淘宝视频的跨模态检索