自 2014 年發佈以來, JDK 8 一直都是相當熱門的 JDK 版本。其原因就是對底層數據結構、JVM 性能以及開發體驗做了重大升級,得到了開發人員的認可。但距離 JDK 8 發佈已經過去了 9 年,那麼這 9 年的時間,JDK 做了哪些升級?是否有新的重大特性值得我們嘗試?能否解決一些我們現在... ...
前言
自 2014 年發佈以來, JDK 8 一直都是相當熱門的 JDK 版本。其原因就是對底層數據結構、JVM 性能以及開發體驗做了重大升級,得到了開發人員的認可。但距離 JDK 8 發佈已經過去了 9 年,那麼這 9 年的時間,JDK 做了哪些升級?是否有新的重大特性值得我們嘗試?能否解決一些我們現在苦惱的問題?帶著這份疑問,我們進行了 JDK 版本的調研與嘗試。
新特性一覽
現如今的 JDK 發佈節奏變快,每次新出一個版本,我們就會感嘆一下:我還在用 JDK 8,現在都 JDK 9、10、11 …… 21 了?然後就會瞅瞅又多了哪些新特性。有一些新特性很香,但考慮一番還是決定放棄升級。主要原因除了新增特性對我們來說改變不大以外,最重要的就是 JDK 9 帶來的模塊化(JEP 200),導致我們升級十分困難。
模塊化的本意是將 JDK 劃分為一組模塊,這些模塊可以在編譯時、構建時和運行時組合成各種配置,主要目標是使實現更容易擴展到小型設備,提高安全性和可維護性,並提高應用程式性能。但付出的代價非常大,最直觀的影響就是,一些 JDK 內部類不能訪問了。
但是除此之外,並沒有太多阻塞升級的問題,後續版本都是一些很香的特性:
- G1 (JEP 248、JEP 307、JEP 344、JEP 345、JEP 346),提供一個支持指定暫停時間、NUMA 感知記憶體分配的高性能垃圾回收器
- ZGC (JEP 333、JEP 376、JEP 377),一個支持 NUMA,暫停時間不應超過 1ms 的垃圾回收器
- 併發 API 更新(JEP 266),提供 publish-subscribe 框架,支持響應式流發佈 - 訂閱框架的介面,以及 CompletableFuture 的進一步完善
- 集合工廠方法(JEP 269),類似 Guava,支持快速創建有初始元素的集合
- 新版 HTTP 客戶端(JEP 321),一個現代化、支持非同步、WebSocket、響應式流的 JDK 內置 API
- 空指針 NPE 直接給出異常方法位置(JEP 358),以前只給代碼行數,不告訴哪個方法,一行多個方法的寫法一但出現空指針,全靠程式員上下文分析推理
- instanceof 的模式匹配(JEP 394),判斷類型後再也不用強轉了
- 數據記錄類(JEP 395),一個標準的值聚合類,幫助程式員專註於對不可變數據進行建模,實現數據驅動
- Switch 表達式語法改進(JEP 361),改變 Switch 又臭又長,易於出錯的現狀
- 文本塊(JEP 378),支持二維文本塊,而不是像現在一樣通過 + 號自行拼接
- 密封類(JEP 409),提供一種限制進行擴展的語法,超類應該可以被廣泛訪問(因為它代表了用戶的重要抽象),但不能廣泛擴展(因為它的子類應該僅限於作者已知的子類)
- 以及一些未提到的底層數據結構優化,JVM 性能提升……
這麼多的優點,恰好能解決我們當前遇到的一些問題,因此我們決定進行 JDK 升級。
升級
升級應用評估
首先自然是要考慮要將哪些應用進行升級。我們根據以下條件進行應用篩選:
- 第一,也是最重要的一點,此系統可以通過升級,解決現有問題與瓶頸
- 第二,有完備的機制能夠進行快速回歸與驗證,如完備的單元測試,自動化測試覆蓋能力,便捷的生產壓測能力等,底層的升級一定要做好完備的驗證
- 第三,技術債務一定要少,不至於在升級過程中遇到一些必須解決的技術債,給升級增加難度
- 第四,負責升級的人對這個系統都很瞭解,除核心業務邏輯外,還能夠瞭解引入了哪些中間件與依賴,使用了中間件的哪些功能,中間件升級後,大量不相容的改動是否對現有系統造成影響
最終我們選取了一個結算頁、收銀台展示無券支付營銷的應用進行升級。此應用特點如下:
- 作為核心鏈路的應用之一,介面響應時間要求很高,GC 是其耗時抖動的瓶頸之一
- 業務正在進行快速迭代發展,隨著降本增效策略的落地,營銷策略進一步精細化,營銷種類、數量、範圍進一步增加,給系統性能帶來更大的挑戰
- 日常流量不低,整點存在突發流量,並且需要承接大促流量
- 核心鏈路覆蓋了單元測試,測試環境具備自動化回歸能力,預發、生產支持常態化壓測與生產流量回放
- 非 Web 應用,僅使用各個中間件的基礎功能,升級出現不相容的問題小
- 維護了 3 年,經歷過多次重構,歷史問題較少,幾乎沒有技術債務
針對以上特點,此應用很適合進行 JDK 17 升級。此應用基於 JDK 8,SpringBoot 2.0.8,除常見外部基礎組件外,還使用以下公司內部中間件:UMP、SGM、DUCC、CDS、JMQ、JSF、R2M。
升級效果
可以先看下我們升級後壓測的效果:
純計算代碼不再受 GC 影響
升級前
升級後
版本 | 吞吐量 | 平均耗時 | 最大耗時 |
---|---|---|---|
JDK 8 G1 | 99.966% | 35.7ms | 120ms |
JDK 17 ZGC | 99.999% | 0.0254ms | 0.106ms |
升級後吞吐量幾乎不受影響(甚至提升0.01%),GC 平均耗時下降1405 倍,GC 最大耗時下降1132 倍
升級步驟
升級 JDK 編譯版本
首先自然是修改 maven 中指定的 JDK 版本,可以先升級到 JDK 11,同時修改 maven 編譯插件
<java.version>11</java.version>
<maven-compiler-plugin.version>3.8.1</maven-compiler-plugin.version>
<maven-source-plugin.version>3.2.1</maven-source-plugin.version>
<maven-javadoc-plugin.version>3.3.2</maven-javadoc-plugin.version>
<maven-surefire-plugin.version>2.22.2</maven-surefire-plugin.version>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>${java.version}</release>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
引入缺少的依賴
然後就可以進行本地編譯了,此時會暴露一些很簡單的問題,比如找不到包、類等等。原因就是 JDK 11 移除了 Java EE and CORBA 的模塊,需要手動引入。
<!-- JAVAX -->
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.0.2</version>
</dependency>
升級外部中間件
解決了編譯找不到類的問題,接下來就該升級依賴的外部中間件了。對於我們的應用來說,也就是升級 SpringBoot 的版本。支持 JDK 17 的版本是 Spring 5.3,對應 SpringBoot 2.5。
在這裡我建議升級至 SpringBoot 2.7,從 2.5 升級至 2.7 幾乎沒有需要改動的地方,同時高版本的 SprngBoot 所約定的依賴,對 JDK 17 的支持也更好。
建議進行大版本逐個升級,比如我們從 2.0 升級至 2.1。每升一個版本,就要仔細觀察依賴版本的變化,掌握每個依賴升級的情況。SpringBoot 的升級其實意味著把所有開源組件約定版本進行大版本升級,介面棄用,破壞性相容更新較多,需要一一鑒別。
下麵以升級 Spring Boot 2.1 為例,說明我們升級的步驟:
-
首先閱讀 Spring Boot 2.1 做了哪些和我們有關的配置改動
-
禁用了同 Bean 覆蓋,開啟需要指定
spring.main.allow-bean-definition-overriding
為true
-
然後閱讀 Spring Boot 2.1 升級了哪些我們用到的依賴
-
Spring 升級至 5.1
-
首先閱讀 Spring 5.1 做了哪些和我們有關的配置改動
- 無影響
-
然後閱讀 Spring 5.1 升級了哪些我們用到的依賴
-
ASM 7.0
- 同理,閱讀升級影響(這種底層依賴的底層依賴,如果僅 ASM 在使用,則無需關心)
-
CGLIB 3.2
- 同理,閱讀升級影響(這種底層依賴的底層依賴,如果僅 ASM 在使用,則無需關心)
-
-
最後閱讀 Spring 5.1 棄用了哪些和我們有關的配置與依賴
- 無影響
-
-
Lombok 升級至 1.18
- 閱讀改動影響,1.18 Lombok 預設情況下將不再生成私有無參構造函數。可以通過在
lombok.config
配置文件中設置lombok.noArgsConstructor.extraPrivate=true
來啟用它
- 閱讀改動影響,1.18 Lombok 預設情況下將不再生成私有無參構造函數。可以通過在
-
Hibernate 升級至 5.3
- 閱讀改動影響,對我們項目無影響
-
JUnit 升級至 5.2
- 閱讀改動影響,需要 Surefire 插件升級至
2.21.0
及以上
- 閱讀改動影響,需要 Surefire 插件升級至
-
-
最後閱讀 Spring Boot 2.1 棄用了哪些和我們有關的配置與依賴
至此,Spring Boot 2.1 升級完畢。接下來分析一次依賴樹變化,和升級前的依賴樹進行比較,查看依賴變化範圍是否全部已知可控。完成後進行 Spring Boot 2.2 的升級。
以下為我們需要註意的升級事項,僅供參考:
-
可以先升級到 JDK 11,一邊啟動一邊驗證。但不要在 JDK 11 使用 ZGC,ZGC 的堆預留與可用堆的比例太大,有時會導致 OOM
-
代碼中存在同 Bean,啟動時 Springboot 2.0 會自動進行覆蓋,高版本開啟覆蓋,需要指定
spring.main.allow-bean-definition-overriding
為true
-
Spring Boot 2.2 預設的單元測試 Junit 升級至 5,Junit 4 的單元測試建議進行升級,改動不大
-
Spring Boot 2.4 不再支持 Junit 4 的單元測試,如果需要可以手動引入 Vintage 引擎
-
Spring Boot 2.4 配置文件處理邏輯變更,註意閱讀更新日誌
-
Spring Boot 2.6 預設禁用 Bean 迴圈依賴,可以通過將
spring.main.allow-circular-references
設置為true
開啟 -
Spring Boot 2.7 自動配置註冊文件變更,
spring.factories
中的內容需要移動至META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件下 -
spring-boot-properties-migrator
可以識別棄用的屬性,可以考慮使用 -
Spring Framework 5.2 需要 Jackson 2.9.7+,註意閱讀更新日誌
-
Spring Framework 5.2 註解檢索演算法重構,所有自定義註釋都必須使用
@Retention(RetentionPolicy.RUNTIME)
進行註釋,以便 Spring 能夠找到它們 -
Spring Framework 5.3 修改了很多東西,但都與我們的應用無關,請關註更新日誌
-
ASM 僅單元測試 Mock 在使用,無需特殊關註,做好 JUnit 升級相容即可
-
CGLIB 大版本升級以相容位元組碼版本為主,關註好變更日誌即可
-
Lombok 即使是小版本升級,也會有破壞性更新,需要仔細閱讀每個版本的更新日誌,建議少用 Lombok
-
Hibernate 沒有太大的破壞性更新,關註好變更日誌即可
-
JUnit 升級主要關註大版本變更,如 4 升 5,小版本沒有特別大的破壞性更新,並且是單元測試使用的依賴,可以放心升級或者不升級
-
Jackson 2.11,對
java.util.Date
和java.util.Calendar
預設格式進行了更改,註意查看更新日誌進行相容 -
註意位元組碼增強相關依賴的升級
-
註意本地緩存升級
-
註意 Netty 升級,關註更新日誌
升級內部中間件
內部中間件升級較為簡單,主要是關註 JMQ、JSF 版本。其中 JSF 依賴的 Netty 和 Javassist 等都需要升級,Netty 版本較低會有記憶體泄漏問題。
我們使用的依賴版本
給大家參考下我們升級後的依賴版本
<properties>
<!-- 基礎組件版本 Start -->
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven-compiler-plugin.version>3.11.0</maven-compiler-plugin.version>
<maven-surefire-plugin.version>2.22.2</maven-surefire-plugin.version>
<jacoco-maven-plugin-version>0.8.10</jacoco-maven-plugin-version>
<maven-assembly-plugin-version>2.4.1</maven-assembly-plugin-version>
<maven-dependency-plugin-version>3.1.0</maven-dependency-plugin-version>
<profiles.dir>src/main/profiles</profiles.dir>
<springboot-version>2.7.13</springboot-version>
<log4j2.version>2.18.0-jdsec.rc2</log4j2.version>
<hibernate-validator.version>5.2.4.Final</hibernate-validator.version>
<collections-version>3.2.2</collections-version>
<collections4.version>4.4</collections4.version>
<netty.old.version>3.9.0.Final</netty.old.version>
<netty.version>4.1.36.Final</netty.version>
<javassist-version>3.29.2-GA</javassist-version>
<guava.version>23.0</guava.version>
<mysql-connector-java.version>5.1.29</mysql-connector-java.version>
<jmh-version>1.36</jmh-version>
<caffeine-version>3.1.6</caffeine-version>
<fastjson-version>1.2.83-jdsec.rc1</fastjson-version>
<fastjson2-version>2.0.35</fastjson2-version>
<roaringBitmap.version>0.9.44</roaringBitmap.version>
<disruptor.version>3.4.4</disruptor.version>
<jaxb-impl.version>2.3.8</jaxb-impl.version>
<jaxb-core.version>2.3.0.1</jaxb-core.version>
<activation.version>1.1.1</activation.version>
<!-- 基礎組件版本 End -->
<!-- 京東中間件版本 Start -->
<ump-version>20221231.1</ump-version>
<ducc.version>1.0.20</ducc.version>
<jdcds-driver-alg-version>2.21.1</jdcds-driver-alg-version>
<jdcds-driver-version>3.8.3</jdcds-driver-version>
<jmq.version>2.3.3-RC2</jmq.version>
<jsf.version>1.7.6-HOTFIX-T2</jsf.version>
<r2m.version>3.3.4</r2m.version>
<!-- 京東中間件版本 End -->
</properties>
JVM 啟動參數升級
遠程 DEBUG 參數有所變化:
JAVA_DEBUG_OPTS=" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000 "
列印 GC 日誌參數的變化,我們在預發環境開啟了日誌進行觀察:
JAVA_GC_LOG_OPTS=" -Xlog:gc*:file=/export/logs/gc.log:time,tid,tags:filecount=10:filesize=10m "
使用了 ZGC 的部分 JVM 參數:
JAVA_MEM_OPTS=" -server -Xmx12g -Xms12g -XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=256m -XX:MaxDirectMemorySize=2048m -XX:+UseZGC -XX:ZAllocationSpikeTolerance=3 -XX:ParallelGCThreads=8 -XX:CICompilerCount=3 -XX:-RestrictContended -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/logs "
內部依賴需要訪問 JDK 模塊,如 UMP、JSF、蟲洞、MyBatis、DUCC、R2M、SGM:
if [[ "$JAVA_VERSION" -ge 11 ]]; then
SGM_OPTS="${SGM_OPTS} --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens java.management/sun.management=ALL-UNNAMED --add-opens java.management/java.lang.management=ALL-UNNAMED " UMP_OPT=" --add-opens java.base/sun.net.util=ALL-UNNAMED "
JSF_OPTS=" --add-opens java.base/sun.util.calendar=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED"
WORMHOLE_OPT=" --add-opens java.base/sun.security.action=ALL-UNNAMED "
MB_OPTS=" --add-opens java.base/java.lang=ALL-UNNAMED "
DUC_OPT=" --add-opens java.base/java.net=ALL-UNNAMED "
R2M_OPT=" --add-opens java.base/java.time=ALL-UNNAMED "
fi
啟動後完整的啟動參數如下:
-javaagent:/export/package/sgm-probe-java/sgm-probe-5.9.5-product/sgm-agent-5.9.5.jar -Dsgm.server.address=http://sgm.jdfin.local -Dsgm.app.name=market-reduction-center -Dsgm.agent.sink.http.connection.requestTimeout=2000 -Dsgm.agent.sink.http.connection.connectTimeout=2000 -Dsgm.agent.sink.http.minAlive=1 -Dsgm.agent.virgo.address=10.24.216.198:8999,10.223.182.52:8999,10.25.217.95:8999 -Dsgm.agent.zone=m6 -Dsgm.agent.group=m6-discount -Dsgm.agent.tenant=jdjr -Dsgm.deployment.platform=jdt-jdos --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED --add-opens=java.management/sun.management=ALL-UNNAMED --add-opens=java.management/java.lang.management=ALL-UNNAMED -DJDOS_DATACENTER=JXQ -Ddeploy.app.name=jdos_kj_market-reduction-center -Ddeploy.app.id=30005051 -Ddeploy.instance.id=0 -Ddeploy.instance.name=server -Djava.awt.headless=true -Djava.net.preferIPv4Stack=true -Djava.util.Arrays.useLegacyMergeSort=true -Dog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector -Dlog4j2.AsyncQueueFullPolicy=Discard -Xmx12g -Xms12g -XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=256m -XX:MaxDirectMemorySize=2048m -XX:+UseZGC -XX:ZAllocationSpikeTolerance=3 -XX:ParallelGCThreads=8 -XX:CICompilerCount=3 -XX:-RestrictContended -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/logs --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/sun.util.calendar=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.security.action=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED -Dloader.path=/export/package/jdos_kj_market-reduction-center/conf
系統驗證
系統可以成功啟動後,就可以進行功能驗證。有幾個驗證重點與方法:
-
首先可以通過單元測試快速進行系統全面回歸,避免出現 JDK API、中間件 API 變更導致的業務異常
-
部署到測試環境,驗證各個中間件是否正常,如 DUCC 開關下發,MQ 收發,JSF 介面調用等等,系統中所有用到的中間件都需要一一驗證
-
然後可以開始進行核心業務的驗證,這時候可以利用測試同學的測試自動化能力加人工補充場景,快速進行核心業務回歸。其中研發需要觀察系統被調用時的所有異常日誌,包括警告,明確每條日誌產生的原因
-
驗證完成後,可以部署到聯調環境,利用外部同事聯調時的請求進一步進行驗證
-
充分在測試環境觀察後,部署至預發環境,利用外部同事聯調時的請求進一步進行驗證,併進行常態化壓測,驗證優化效果與瓶頸
-
經過預髮長時間驗證,沒有問題後,部署一臺生產,通過回放生產流量進一步進行驗證
-
回放流量無異常後,開始承接生產流量,按介面開量,進行若幹周的觀察
-
逐步切量,直到全量上線
GC 調優
ZGC 介紹
如圖所示,ZGC 的定位是一個最大暫停時間小於 1ms,且能夠處理大小從 8MB 到 16TB 的堆,並且易於調優的垃圾回收器。ZGC 只有三個 STW 階段,具體流程網上有大量類似文章,這裡不做詳細介紹。
優化方向
目前我們的應用日常使用 G1 約 30ms 的 GC 停頓時間,不到 1 分鐘就會觸發一次,大促時頻率更高,暫停時間更長,導致介面性能波動較大。隨著業務發展,為了優化系統我們大量應用了本地緩存,導致存活對象較多。ZGC 暫停時間不隨堆、活動集或根集大小而增加,且極低的 GC 時間正是我們需要的特性,因此決定使用 ZGC。
ZGC 作為一個現代化 GC,沒有必要做過多的優化,預設配置已經可以解決 99.9% 的場景。但是我們的應用會承接大促流量,根據觀察,瞬時流量激增時 GC 時機較晚,因此應對突發流量是我們 ZGC 調優的一個目標,其他屬性不做任何調整。
優化措施
ZGC 的一個優化措施就是足夠大的堆,一般來說,給 ZGC 的記憶體越多越好,但我們也沒必要浪費,通過壓測觀察 GC 日誌,取得一個合適的值即可。我們只要保證:
-
堆可以容納應用程式產生的實時垃圾
-
堆中有足夠的空間,以便在 GC 運行時,為新的垃圾分配提供空間
因此,我們將機器升級成 8C 16G 配置,觀察 GC 日誌根據應用情況調整記憶體占用配置,最終設定為-Xmx12g -Xms12g -XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=256m -XX:MaxDirectMemorySize=2048m
,提升 ZGC 的效果。
剩下的其他優化措施則視情況而定,可以調整觸發 GC 的時機,也可以改為基於固定時間間隔觸發 GC。
我們略微提升了觸發時機,-XX:ZAllocationSpikeTolerance=3
(預設為 2)應對突發流量。
CICompilerCount ParallelGCThreads
一個是提升 JIT 編譯速度,一個是垃圾收集器並行階段使用的線程數,根據實際情況略微增加,犧牲一點點 CPU 使用率,提升下效率。
另外還可以開啟Large Pages
進一步提升性能。這一步我們沒有做,因為現在部署方式為一臺物理機 Docker 混部署。開啟需要修改內核,影響宿主機的其他鏡像。
總結
至此,調優完成,目前我們已線上上跑了一個多月,每周都有三次常態化壓測,一切正常。
以上升級心得分享給大家,希望對各位有所幫助。
作者:京東科技 張天賜
來源:京東雲開發者社區