隨著轉轉業務規模的不斷增長,我們的搜索推薦服務正在面臨嚴峻的垃圾回收(Garbage Colletion, GC)帶來的服務接口耗時毛刺問題。
我們當前所使用的JDK1.8版本中的CMS和G1收集器,在應對請求高峰時均不理想,經常出現的停頓問題直接影響了服務的可用性及用戶體驗。
我們面臨的核心挑戰是:
為此,我們計劃通過升級JDK版本來實現GC問題的改善。JDK新版本帶來了如ZGC、Shenandoah等新一代GC算法,它們能夠提供極低的GC停頓時間,有望解決我們的在線服務目前的GC毛刺問題。
我們的升級目標是利用新版本JDK中的新GC算法,將搜索推薦服務的GC停頓時間降低90%以上,保證高流量服務的可用性和吞吐量,進一步提升用戶體驗。
我們選擇將JDK版本升級到JDK17,主要原因有:
具體來看,此次JDK 17的升級在語法上帶來了以下幾個值得注意的新特性:
類型推斷
從JDK10版本開始,引入了局部變量類型推斷(Local Variable Type Inference)功能,它可以讓我們在聲明局部變量時省略變量類型,而由編譯器根據變量初始化的值自動推斷出類型。
// 傳統變量聲明方法String str = "hello";// 使用類型推斷的變量聲明方法var str = "hello";
Stream API的增強
JDK新版本對Stream API進行了一些增強,主要有:
takeWhile和dropWhile會對流中每個元素逐一校驗,遇到第一個不符合條件的元素終止,takeWhile返回終止位置前面的所有元素,而dropWhile則返回包含終止位置后面的所有元素。它們功能雖然與filter類似,區別是前者并非對整個流進行校驗,可以提升過濾效率,但需要注意流內元素的順序。
var list = List.of(1,2,3,4,5,6);// 輸出:1,2;list.stream().takeWhile(n -> n < 3).forEach(System.out::println); // 輸出:3,4,5,6list.stream().dropWhile(n -> n < 3).forEach(System.out::println);
iterate可以生成一個無限的流,它在JDK9之前需要limit()等操作來配合終止,否則將無限遞歸下去。在JDK9中iterate新增了一個重載方法,現在支持使用條件來終止,它在語法上更簡潔,也提供了更多的靈活性。
// 輸出:1,2,4,8,...,512,1024Stream.iterate(1, n -> n <= 1024, n -> n * 2).forEach(System.out::println);
集合API新增方法
操作集合將更加方便,如可以更加簡潔的創建List和Map,但需要注意這種方式創建的集合均是不可變的。
var list = List.of(1, 2, 3, 4, 5);var map1 = Map.of("a", 1, "b", 2);var map2 = Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2));
同時新增了多種Collector方法,如可以通過groupingBy新增的重載方法實現多級分組,假設Product類有Cate、Brand、Model成員,則可以做如下多層分組收集:
// 按Cate、Brand分組,收集Model列表List<Product> products = ...;Map<Cate, Map<Brand, List<Model>>> result = products.stream() .collect(Collectors.groupingBy(Product::getCate, Collectors.groupingBy(Product::getBrand, Collectors.mapping(Product::getModel, Collectors.toList()))));
Swtich新語法
JDK12開始,switch語句增加了新的語法形式,允許使用更靈活的表達式匹配,并可以返回值,提升了代碼的簡潔性。
int month = ...;String days = switch(month) { case 1, 3, 5, 7, 8, 10, 12 -> "31 days"; case 4, 6, 9, 11 -> "30 days"; case 2 -> "28 or 29 days";}
文本塊
JDK13開始提供了一種新的字符串格式,用戶可以選擇用三個雙引號(""")作為字符串開頭及結尾,直接編寫多行文本,它為JSON、SQL等格式的字符串編寫提升了簡潔性和便利性。
String textBlock = """ This is a text block spanning multiple lines. """;
Record類型
JDK14中新增的Record提供了更簡潔的語法來生成只用于數據存儲的類,并自動生成訪問方法、equals和hashCode比較方法以及toString方法,它可以在類內部或方法內部生成。它相比class類更輕量簡潔,相比Pair、Triple等組合類Record的語義上更加明確、代碼可讀性更強。
void someMethod() { record Product(long id, String category); Product product = new Product(101L, "phone"); long productId = product.getId(); String productCategory = product.getCategory();}
JDK14版本引入了模式匹配新語法,避免了冗余的類型轉換語句。
Object obj = ...;if (obj instanceof String s) { System.out.println("String: " + s.length());} else if (obj instanceof Integer i) { System.out.println("Integer: " + i);} else { System.out.println("Unknown object");}
JDK17版本后,新的模式匹配方式也可以在Switch語句中使用了。
Object obj = ...;switch(obj) { case String s -> System.out.println("String: " + s.length()); case Integer i -> System.out.println("Integer: " + i); default -> System.out.println("Unknown object");}
密封類(Sealed Class)是JDK15引入的新特性,當使用sealed關鍵字修飾一個抽象類時,表示這個抽象類只允許指定的類來繼承實現。
如下ProductField類只允許Cate、Brand、Model類繼承,這種特性避免了意料外的類型擴展,提升了類型安全性。
sealed abstract class ProductField permits Cate, Brand, Model { //...}
此外,JDK新版本還有向量API等新特性和諸多改進等待我們探索發現。
ZGC介紹
ZGC在JDK11作為實驗性的GC算法被引入時,最初的設計目標是實現10毫秒以內的最大停頓時間。在過去一段時間里,ZGC經過JDK版本的數次迭代,在JDK15中被宣布為可用于生產,目前據官方介紹已經可以實現亞毫秒級的最大停頓時間,且停頓時間不隨堆內存、存活對象集合或GCRoot集合大小的增加而增加,它可以處理從8MB到16TB的大范圍堆內存。
在官方介紹里,ZGC是并發的、基于區域的(Region-based)、壓縮的(Compacting)、NUMA感知(NUMA-aware)的垃圾回收器。它主要使用了染色指針(Colored Pointor)和讀屏障(Load Barriers)技術,并在新一代的JDK21版本中實現了分代回收,它的主要工作是在用戶線程工作執行時完成的,這大大降低了GC對應用響應時間的影響。
使用如下JVM啟動參數可以快速應用ZGC:
-XX:+UseZGC
Shenandoah GC介紹
Shenandoah GC是一種全新的低延遲垃圾收集器,在JDK 8的部分版本可用,從JDK 11版本正式引入,它通過讀寫屏障和并發標記技術,可以極大縮短GC時的應用程序停頓。
相比CMS、G1等算法,其停頓時間更短,支持超大內存,非常適合對響應時間敏感類型的服務。由于Shenandoah使用了讀寫屏障技術,雖然可能導致吞吐量略降,但總體來說是更有效的GC算法之一。
使用如下參數可以快速應用Shenandoah GC:
-XX:+UseShenandoahGC
JDK版本升級無需做太多代碼改動,但要平滑過渡到新版本,也需要做充分準備和規劃。本節將分享我們升級到JDK17的具體步驟,在此過程中遇到的問題及解決方法,以及對ZGC相關問題的分析。
安裝JDK17
我們在本地測試時選擇了Eclipse Temurin Build版本,根據官網介紹它是由基于OpenJDK的開源Java SE產生的構建版本,這里根據開發環境的機器配置下載并安裝了jdk-17.0.7+7 macos aarch64版本。
在使用IntelliJ Idea開發環境時,可以在文件--項目結構配置中,將SDK選項調整到剛剛安裝的JDK版本。
圖片
由于我們的項目是Maven項目,需要選擇POM文件,修改Maven的編譯插件的source和target配置到17。
<properties> <jdk.version>17</jdk.version></properties>...<plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>${jdk.version}</source> <target>${jdk.version}</target> </configuration> </plugin></plugins>
本地編譯測試通過后,意味著可以到測試環境進行部署和驗證了,驗證內容包括全場景的功能驗證、DIFF驗證、壓力性能測試等等(由于部署功能是由公司其他系統提供,不展開敘述)。
升級JDK17后,JVM啟動參數需要調整,一些舊參數被廢棄,同時增加新的參數,我們用于測試環境部署的參數為:
-Xms6g -Xmx6g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xss256k -XX:+UseZGC -XX:ParallelGCThreads=12
生產環境部署及效果數據回收
升級JDK后,回收線上服務的效果數據至關重要,我們主要關注服務延時表現、GC暫停表現以及內存消耗表現。
我們選擇服務集群的50%節點來部署JDK17,另外50%節點保持JDK8不變作為對照組,分配節點時,保持各組的節點機器配置情況一致,將實驗變量控制在僅JDK版本的切換上。
同時將服務訪問的延時信息、GC暫停信息、堆內存使用信息上報到日志收集系統。由于前者的日志規模龐大,為了獲取更精確的統計信息,通過上報到大數據平臺并使用HiveSql分析,后兩者則通過上報Promethues監控平臺來實現實時信息收集。
在實際編譯和部署的過程中,還可能會遇到各種各樣的問題,下面我們對遇到的問題及解決方法做了一些梳理。
以下為編譯期間遇到的一些問題:
非法字符引發的異常
Maven編譯期間遇見如下報錯:
[ERROR] Internal error: java.lang.IllegalArgumentException: Malformed /uxxxx encoding. -> [Help 1]
問題原因:這是Maven在加載一些配置文件時遇到了不兼容的編碼字符導致的。本地Maven倉庫路徑下的resolver-status.properties文件中存在格式不正確的unicode編碼字符,這些字符在JDK 17的字符串處理方式下無法解析。
解決方法:使用以下命令,遞歸刪除本地倉庫下所有的resolver-status.properties文件:
find ~/.m2/ -name resolver-status.properties -delete
包不存在引發的異常
編譯器期間提示包不存在:
import javafx.util.Pair
問題原因:javafx等包在JDK新版本中被默認移除。
解決方法:可以使用apache.commons提供的Pair類替代,也可以手動引入被移除的依賴,其他被移除的類也可以通過類似的方法解決。
以下為部署期間遇到的問題:
JVM參數引發的異常
啟動階段可能遇到類似如下問題:
Unrecognized VM option 'UseGCLogFileRotation'Error: Could not create the Java Virtual Machine.Error: A fatal exception has occurred. Program will exit.
問題原因:部分JVM參數在新版本不再兼容,導致不能識別。
解決方法:從啟動參數里將不兼容的參數移除即可,同時尋找替代參數。
反射訪問引發的異常
如以下日志所示,我們在初始化apollo配置中心組件時遇到了啟動異常,從異常描述看是程序反射訪問期間引起的。
java.lang.IllegalArgumentException: Cannot instantiate interface org.springframework.context.ApplicationContextInitializer : com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer ... at com.bj58.spat.scf.server.bootstrap.Main.main(Main.java:27) [zzscf.server-2.7.12.jar:?]Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer]: Constructor threw exception; nested exception is com.ctrip.framework.apollo.exceptions.ApolloConfigException: [ARCH_APOLLO_CLIENT]Unable to load instance for com.ctrip.framework.apollo.spring.config.ConfigPropertySourceFactory! at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:154) ~[spring-beans-4.3.12.RELEASE.jar:4.3.12.RELEASE] at org.springframework.boot.SpringApplication.createSpringFactoriesInstances(SpringApplication.java:409) ~[spring-boot-1.5.8.RELEASE.jar:1.5.8.RELEASE] ... 8 more...Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @5e265ba4 at java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354) ~[?:?] at java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297) ~[?:?] at java.lang.reflect.Method.checkCanSetAccessible(Method.java:199) ~[?:?] at java.lang.reflect.Method.setAccessible(Method.java:193) ~[?:?]...
問題原因:新版本JDK引入了模塊訪問控制,跨模塊時無法簡單的直接通過反射訪問了,上述異常是想要通過反射訪問Java內部模塊而拋出的
解決方法:對于此類問題,可以通過臨時增加如下啟動參數解決,也可以查閱依賴包的新版本,了解它們是否已對JDK新版本做出了適配
--add-opens java.base/java.lang=ALL-UNNAMED
java.base/java.lang是本次異常需要用到的模塊參數,在解決此類異常時,需要根據實際要訪問的模塊名進行調整,以下為我們收集的一些啟動參數,可以按需增加啟動參數配置。
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/jdk.internal.access=ALL-UNNAMED --add-opens java.base/jdk.internal.misc=ALL-UNNAMED
注解類型被默認移除引發的異常
啟動過程中,發現拋出了如下空指針異常
Caused by: java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null at java.base/java.URLEncoder.encode(URLEncoder.java:224) at java.base/java.URLEncoder.encode(URLEncoder.java:196) at com.bj58.zhuanzhuan.arch.service.manager.sdk.client.CallPermissionService.initUri(CallPermissionService.java:192) at com.bj58.zhuanzhuan.arch.service.manager.sdk.client.CallPermissionService.<init>(CallPermissionService.java:72) at com.bj58.spat.scf.server.filter.BlackKeyRequestFilter.afterPropertiesSet(BlackKeyRequestFilter.java:120) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1687) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1624) ... 15 more
問題原因:分析調用鏈路后發現,問題發生在一個上下文類,它的init方法是通過@PostConstruct注解觸發執行的,該注解在JDK新版本中被默認移除了,導致init方法未能執行
解決方法:短期可以通過手動引入以下依賴方式解決,長期同樣可以查閱依賴包維護方的更新日志,或與維護方進行溝通,將依賴更新到已適配版本。
<dependency> <groupId>javax.annotation</groupId> <artifactId>javax.annotation-api</artifactId> <version>1.3.2</version></dependency>
此外我們在發布到生產環境后,還遇到了以下問題:
ZGC的虛擬內存申請的疑問
我們的服務升級JDK并在實際生產環境部署后,同物理節點的其他服務曾出現了一次短暫的資源耗盡異常,當時我們懷疑導致問題原因之一是ZGC申請了過多的虛擬內存。
針對ZGC申請過多虛擬內存問題,我們經過排查發現,這并不是JDK17存在的問題,而是由ZGC自身的實現機制所導致的。ZGC通過染色指針和多重映射技術來實現高吞吐低延遲的GC。
為了實現染色指針,ZGC需要使用地址高位額外的bit來記錄對象狀態,所以需要的虛擬內存空間遠高于實際堆大小。此外,以目前JDK17版本,它還會為不同狀態的對象分配獨立的虛擬內存,以實現并發回收,具體來說需要為remapped、marked0、marked1三種狀態申請三份獨立虛擬內存空間。
以4TB堆為例,ZGC需要4bit用于染色,所以需要4TB * 2^4 = 128TB虛擬內存。它還會為每種染色狀態各自申請128TB空間。所以4TB堆最終會申請128TB * 3約等于384TB的虛擬內存。
本例中我們的服務實際使用6GB堆,通過與內存工具Native Memory Tracking輸出結果比對,發現跟公式計算結果一致,ZGC申請了約300GB的虛擬內存,符合其技術實現的需要。
所以結論是ZGC申請虛擬內存并非JDK問題,是其特有的技術實現方式導致。
以下是我們在轉轉的通用推薦服務升級過程中,持續對比三個全天收集到的效果數據,我們設立50%節點升級到JDK17作為實驗組,另50%節點不升級作為對照組。
首先看下服務的整體耗時數據,如下圖所示,可以看到該服務升級JDK17后tp999及tp9999時間有顯著降低。
圖片
通過新版本GC算法的引入,服務處理請求的尾部延時情況得到了改善,響應時間的毛刺問題明顯減輕。
下表為詳細數據:
指標/版本 | JDK8 | JDK17 | 降幅 |
AVG耗時 | 22ms | 22ms | 持平 |
TP50耗時 | 11ms | 11ms | 持平 |
TP90耗時 | 57ms | 57ms | 持平 |
TP99耗時 | 149ms | 148ms | 0.67% |
TP999耗時 | 249ms | 242ms | 2.81% |
TP9999耗時 | 601ms | 458ms | 23.78% |
在分節點的指標對比上,我們發現應用JDK17的節點在tp999和tp9999這兩個高延遲分位數指標上的表現更加平穩。
如下圖所示,相比保持JDK8的對照組節點,升級到JDK17的實驗組節點,其tp999和tp9999指標的變化曲線更加平坦。
節點TP999耗時對比
節點TP9999耗時對比
對于GC數據,我們收集了服務晚間4小時JDK8和JDK17版本的GC停頓數據。JDK8統計了其Young GC的暫停時間,而JDK 17統計了ZGC Pause時間。
從下表可以明顯看出,使用JDK17的ZGC算法后,GC停頓時長大幅減少。JDK8下YGC每分鐘平均暫停時間為221ms,而JDK 17下的ZGC只有0.37ms,降幅高達99.83%。
指標/版本 | JDK8 | JDK17 | 降幅 |
統計口徑 | YGC時間 | ZGC Pause時間 | - |
總時長 | 106250ms | 221ms | 99.67% |
每分鐘平均時長 | 355ms | 0.37ms | 99.83% |
停頓時間的降低不僅提高了服務的可用性,也使系統吞吐量獲得大幅提升。
從下表統計數據可以看出,使用JDK 17后,相同堆空間配置下,實際堆內存占用有所降低,堆空間的利用效率得到提高。
在同為6G堆大小情況下,JDK 8堆占用平均為2.92G,占比48.7%;而JDK 17堆占用平均減少至2.42G,占比降至40.3%。堆內存占用比降低了17.2%。
這表明在不改變堆區設置的前提下,JDK 17可以提高堆空間的利用效率,降低內存占用,為系統留出更多可用內存空間,從而提高系統穩定性。
指標/版本 | JDK8 | JDK17 | 降幅 |
堆空間申請 | 6G | 6G | - |
每分鐘平均堆占用 | 2.922G | 2.419G | 17.20% |
每分鐘平均堆占用比 | 48.70% | 40.32% | 17.20% |
另外ZGC提供了-XX:SoftMaxHeapSize參數,用于彈性調節堆空間的最大值,當堆大小未超出設定值時可以釋放更多空閑內存。
截止至發文,服務已成功部署應用JDK17并平穩運行一月有余。通過本次升級,我們獲得了顯著的GC停頓時間和內存占用率的改善效果,有效解決了服務GC問題,進而降低了服務高分位延遲指標,充分驗證了JDK17新版本GC算法的優勢。
同時,我們也積累了語法改進、升級中跨部門協調、問題排查等方面的寶貴經驗。升級過程中遇到了服務穩定性問題,也讓我們意識到需要對新特性有更深入的理解,平穩地應用到生產環境。
后續我們將繼續關注JDK新版本的特性改進,并逐步將搜索推薦核心服務完全升級到JDK17新版本,以獲得更好的開發體驗和服務運行效果。
關于作者
曾祥瑞,轉轉搜索推薦研發工程師
銳意進取,勇于試驗,與時俱進。
本文鏈接:http://www.tebozhan.com/showinfo-26-11831-0.html解決GC毛刺問題——轉轉搜索推薦服務JDK17升級實踐
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com