關於GC(上):Apache的POI組件導致線上頻繁FullGC問題排查及處理全過程


某線上應用在進行查詢結果導出Excel時,大概率出現持續的FullGC。解決這個問題時,記錄了一下整個的流程,也可以作為一般性的FullGC問題排查指導。
后續review這篇文章的時候,發現排查過程還是不夠詳細,雖然最終解決了問題,但是仍缺少對根因對分析,並且遺漏了一些所需技能對整理。因此根據最近另一個系統類似的fullGC現象做了進一步的分析,對本文進行了一些完善。

1. GC現場查看

1.1 系統指標

需要關注GC發生時,系統的上下文環境,判斷是否有關聯,以及GC對於系統運行的影響情況。

  • CPU占用率
  • 磁盤利用率
  • 系統load,主要有三種統計方式:load1、load5、load15,分別表示1、5、15分鍾內系統的平均負載

1.2 GC日志

1.2.1 准備工作

能看到GC日志的前提是JVM開啟了對應參數。
常用的參數如下(摘自https://www.jianshu.com/p/ba768d8e9fec):

-verbose:gc
在虛擬機發生內存回收時在輸出設備顯示信息,格式如下:[Full GC 268K->168K(1984K), 0.0187390 secs]該參數用來監視虛擬機內存回收的情況。
-XX:+PrintGC
發生垃圾收集時打印簡單的內存回收日志
-XX:+PrintGCDetails
發生垃圾收集時打印詳細的內存回收日志
-XX:+PrintGCTimeStamps
輸出GC的時間戳(以基准時間的形式,如49.459,默認就是這個輸出形式,可以不寫)
-XX:+PrintGCDateStamps
輸出GC的時間戳(以以日期的形式,如2019-03-01T12:57:54.486+0800)
-XX:+PrintHeapAtGC
在進行GC的前后打印出堆的信息,經過個人實驗,這個參數開啟后只會在Minor GC的前后打印堆信息,而老年代CMS的Major GC前后則不會打印,不知道是什么原因。
-Xloggc:../logs/gc.log
日志文件的輸出路徑

1.2.2 GC日志分析

本節同樣參考了https://www.jianshu.com/p/ba768d8e9fec的分析。此處使用的是-XX:+PrintGCDetails詳細回收日志。

1.2.2.1 MinorGC

先看幾條發生FullGC前MinorGC的日志:

2020-04-10T11:45:44.392+0800: 4553306.501: [GC (Allocation Failure) 2020-04-10T11:45:44.393+0800: 4553306.501: [ParNew: 1775637K->28101K(1922432K), 0.0786478 secs] 1894669K
->147135K(5068160K), 0.0790883 secs] [Times: user=0.15 sys=0.00, real=0.07 secs]
2020-04-10T12:07:19.290+0800: 4554601.399: [GC (Allocation Failure) 2020-04-10T12:07:19.291+0800: 4554601.399: [ParNew: 1775578K->174671K(1922432K), 0.2182450 secs] 1894611
K->730454K(5068160K), 0.2186251 secs] [Times: user=0.36 sys=0.16, real=0.22 secs]
2020-04-10T12:07:21.664+0800: 4554603.773: [GC (Allocation Failure) 2020-04-10T12:07:21.664+0800: 4554603.773: [ParNew: 1921986K->174550K(1922432K), 0.5629082 secs] 2477769
K->2448678K(5068160K), 0.5632692 secs] [Times: user=1.38 sys=0.00, real=0.56 secs]

先取第一行來看,本行的構成逐項解釋:

  • 2020-04-10T11:45:44.392+0800: 4553306.501 時間戳
  • [GC (Allocation Failure) 發生GC,原因是新對象分配失敗。這里GC有可能是Full GC,並不能確定是MinorGC。
  • 2020-04-10T11:45:44.393+0800: 4553306.501 又是一個時間戳。注意到和第一個時間戳是有略微差異的
  • [ParNew: 表示使用了ParNew回收器。由於其是用於新生代的,可見是minorGC
  • 921986K->174550K(1922432K), 0.5629082 secs 表示gc前該區域已使用容量->gc后該區域已使用容量(總容量),以及總耗時。
  • 1894669K->147135K(5068160K), 0.0790883 secs] 表示收集前后整個堆的使用情況及總耗時,包含了將對象從新生代轉移到老年代的時間
  • [Times: user=0.15 sys=0.00, real=0.07 secs]分別是用戶態時間、系統態時間、從開始到結束的等待時間(不含內核態時間、多CPU的時間等,可能小於前兩者)

1.2.2.2 CMS FullGC

本文不打算詳細介紹CMS,而是放在后續文章中,這里僅僅闡述一下CMS基本的信息。

  • 首先CMS適用於老年代,而老年代的GC,就是FullGC
  • CMS GC分為四步:
    • 初始標記 ,只標記GC Root能關聯的對象,會Stop the world
    • 並發標記,對GC Root進行tracing
    • 重新標記,修正並發標記期間因用戶繼續運作而變動的對象,會Stop the world
    • 並發清除

以下是CMS FullGC的日志。在20分鍾內,發生了多次,只截取一段。

2020-04-10T12:18:20.049+0800: 4555262.158: [GC (CMS Initial Mark) [1 CMS-initial-mark: 2274128K(3145728K)] 3153915K(5068160K), 0.2236736 secs] [Times: user=0.79 sys=0.04, r
eal=0.23 secs]
2020-04-10T12:18:20.273+0800: 4555262.382: [CMS-concurrent-mark-start]
2020-04-10T12:18:20.600+0800: 4555262.709: [CMS-concurrent-mark: 0.327/0.327 secs] [Times: user=0.24 sys=0.10, real=0.32 secs]
2020-04-10T12:18:20.600+0800: 4555262.709: [CMS-concurrent-preclean-start]
2020-04-10T12:18:20.619+0800: 4555262.728: [CMS-concurrent-preclean: 0.018/0.019 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
2020-04-10T12:18:20.619+0800: 4555262.728: [CMS-concurrent-abortable-preclean-start]
2020-04-10T12:18:20.619+0800: 4555262.728: [CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-04-10T12:18:20.621+0800: 4555262.730: [GC (CMS Final Remark) [YG occupancy: 880462 K (1922432 K)]2020-04-10T12:18:20.621+0800: 4555262.730: [Rescan (parallel) , 0.4159
862 secs]2020-04-10T12:18:21.037+0800: 4555263.146: [weak refs processing, 0.0000913 secs]2020-04-10T12:18:21.037+0800: 4555263.146: [class unloading, 0.2851138 secs]2020-0
4-10T12:18:21.322+0800: 4555263.431: [scrub symbol table, 0.0624047 secs]2020-04-10T12:18:21.385+0800: 4555263.493: [scrub string table, 0.0060674 secs][1 CMS-remark: 22741
28K(3145728K)] 3154591K(5068160K), 0.7700397 secs] [Times: user=1.10 sys=0.44, real=0.77 secs]
2020-04-10T12:18:21.391+0800: 4555263.500: [CMS-concurrent-sweep-start]
2020-04-10T12:18:21.488+0800: 4555263.597: [CMS-concurrent-sweep: 0.097/0.097 secs] [Times: user=0.07 sys=0.04, real=0.10 secs]
2020-04-10T12:18:21.489+0800: 4555263.597: [CMS-concurrent-reset-start]
2020-04-10T12:18:21.495+0800: 4555263.604: [CMS-concurrent-reset: 0.007/0.007 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]

結合CMS的特性,分段來看。和MinorGC中重復的部分不再解釋,如時間戳、消耗時間。

初始標記

2020-04-10T12:18:20.049+0800: 4555262.158: [GC (CMS Initial Mark) [1 CMS-initial-mark: 2274128K(3145728K)] 3153915K(5068160K), 0.2236736 secs] [Times: user=0.79 sys=0.04, r
eal=0.23 secs]
  • [GC (CMS Initial Mark) [1 CMS-initial-mark: 表示CMS初始標記階段
  • 2274128K(3145728K) 老年代容量3145728K,在使用2274128K后觸發GC。這個比例約為72%,是可以通過CMSInitiatingOccupancyFraction參數調節的。本例中實際設置是80%。
  • 3153915K(5068160K) 當前堆的整體使用情況
  • 0.2236736 secs 當前這一步耗時

並發標記

2020-04-10T12:18:20.273+0800: 4555262.382: [CMS-concurrent-mark-start]
2020-04-10T12:18:20.600+0800: 4555262.709: [CMS-concurrent-mark: 0.327/0.327 secs] [Times: user=0.24 sys=0.10, real=0.32 secs]
2020-04-10T12:18:20.600+0800: 4555262.709: [CMS-concurrent-preclean-start]
2020-04-10T12:18:20.619+0800: 4555262.728: [CMS-concurrent-preclean: 0.018/0.019 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
2020-04-10T12:18:20.619+0800: 4555262.728: [CMS-concurrent-abortable-preclean-start]
2020-04-10T12:18:20.619+0800: 4555262.728: [CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  • [CMS-concurrent-mark-start] 並發標記開始
  • 0.327/0.327 sec 持續時間和時鍾時間。該階段內被用戶線程改動的對象會標記為dirty card
  • [CMS-concurrent-preclean-start] 把被標記為Dirty Card的對象以及可達的對象重新遍歷標記,完成后清除Dirty Card標記。另外做一些必要的清掃工作,及final remark階段需要的准備工作
  • [CMS-concurrent-abortable-preclean-start] 並發預清理,這個階段嘗試着去承擔接下來STW的Final Remark階段足夠多的工作,由於這個階段是重復的做相同的事情直到發生aboart的條件(比如:重復的次數、多少量的工作、持續的時間等等)之一才會停止。這個階段很大程度的影響着即將來臨的Final Remark的停頓。

重新標記

這部分原始日志是折疊成一行的,為了便於分析,按時間戳排成了多行。

2020-04-10T12:18:20.621+0800: 4555262.730: [GC (CMS Final Remark) [YG occupancy: 880462 K (1922432 K)]
2020-04-10T12:18:20.621+0800: 4555262.730: [Rescan (parallel) , 0.4159862 secs]
2020-04-10T12:18:21.037+0800: 4555263.146: [weak refs processing, 0.0000913 secs]
2020-04-10T12:18:21.037+0800: 4555263.146: [class unloading, 0.2851138 secs]
2020-04-10T12:18:21.322+0800: 4555263.431: [scrub symbol table, 0.0624047 secs]
2020-04-10T12:18:21.385+0800: 4555263.493: [scrub string table, 0.0060674 secs][1 CMS-remark: 2274128K(3145728K)] 3154591K(5068160K), 0.7700397 secs] [Times: user=1.10 sys=0.44, real=0.77 secs]
  • [GC (CMS Final Remark) 重新標記階段,會STW
  • [YG occupancy: 880462 K (1922432 K)] 新生代占用情況
  • [Rescan (parallel) , 0.4159862 secs] 該階段階段掃描對象總用時,從GC Root開始處理剩余對象
  • [weak refs processing, 0.0000913 secs] 第一個子階段,對弱引用處理耗時
  • [class unloading, 0.2851138 secs] 第二個子階段,卸載無用類耗時
  • [scrub symbol table, 0.0624047 secs] 第三個子階段,清理符號表耗時
  • [scrub string table, 0.0060674 secs] 第四個子階段,清理字符串表耗時
  • [1 CMS-remark: 2274128K(3145728K)] 3154591K(5068160K), 0.7700397 secs] 處理完成后,老年代內存使用情況以及整個堆的使用情況。注意到初始標記時,這兩組大小分別為2274128K(3145728K)]和3153915K(5068160K),在本例中沒有任何變化。

並發清除

2020-04-10T12:18:21.391+0800: 4555263.500: [CMS-concurrent-sweep-start]
2020-04-10T12:18:21.488+0800: 4555263.597: [CMS-concurrent-sweep: 0.097/0.097 secs] [Times: user=0.07 sys=0.04, real=0.10 secs]
2020-04-10T12:18:21.489+0800: 4555263.597: [CMS-concurrent-reset-start]
2020-04-10T12:18:21.495+0800: 4555263.604: [CMS-concurrent-reset: 0.007/0.007 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
  • [CMS-concurrent-sweep-start] 並發清除第一階段,清除沒有標記的無用對象並回收內存,以及耗時
  • [CMS-concurrent-reset-start] 並發清除第二階段,重新設置CMS算法內部的數據結構,准備下一個CMS生命周期的使用。

例子:20分鍾的連續GC

可以看出,CMS完成時,並不能直接知道此時的內存運行情況,那么將這波FullGC日志中每次CMS發生時的內存情況列表,會發現什么呢?
這里只摘了最開始、最后、以及中間的幾次,並且簡化了時間戳的數據。

開始時間戳 老年代已使用容量(K) 老年代總容量(K) 堆已使用容量(K) 堆總容量(K)
2020-04-10T12:18:20.273 2274128 3145728 3153915 5068160
2020-04-10T12:18:39.494 2255208 3145728 3145788 5068160
2020-04-10T12:18:43.091 2254908 3145728 3146625 5068160
.... .... .... .... ....
2020-04-10T12:33:04.495 2251293 3145728 3926804 5068160
2020-04-10T12:33:07.772 2251293 3145728 3929256 5068160
2020-04-10T12:33:07.772 2251293 3145728 3931359 5068160
.... .... .... .... ....
2020-04-10T12:38:17.513 2251240 3145728 4171165 5068160
2020-04-10T12:38:17.513 2251240 3145728 4173301 5068160

可以看出,經過20分鍾,老年代的容量仍然在CMS的閾值附近徘徊,並且不斷增長。最后一次CMS觸發了一條特殊的日志(之前居然看漏了):

2020-04-10T12:38:23.851+0800: 4556465.960: [GC (Allocation Failure) 2020-04-10T12:38:23.851+0800: 4556465.960: [ParNew: 1922192K->1922192K(1922432K), 0.0000324 secs]
2020-04-10T12:38:23.851+0800: 4556465.960: [CMS2020-04-10T12:38:23.914+0800: 4556466.023: [CMS-concurrent-sweep: 0.066/0.070 secs] [Times: user=0.08 sys=0.00, real=0.07 secs]
 (concurrent mode failure): 2251240K->112349K(3145728K), 1.4269429 secs] 4173432K->112349K(5068160K), [Metaspace: 225876K->225876K(1253376K)], 1.4284556 secs] [Times: user=
1.44 sys=0.00, real=1.43 secs]

新生代對象分配失敗,ParNew也滿了。concurrent mode failure出現時虛擬機會啟動后備預案:臨時使用一次SerialOld進行GC。這時,終於把維持在2251240K的老年代降到了112349K,並且把整個堆的4173302K降到了112349K。
注意到這兩組數字,和最后一次CMS的數字是吻合的,同時,整個堆中只有老年代還有對象,占用了112349K。
由於Serial系列回收器是單個線程執行,會觸發長時間的STW。這從時間上也能看出來,用了1.42秒。

1.2.3 concurrent mode failure的原因

直接原因是老年代不夠用了。是什么導致老年代不夠用?情況比較多:(無論是新生代發生晉升而導致)老年代容量不足、老年代容量夠但是碎片過多(標記清除算法的原因)

2. dump文件分析

了解了GC期間的內存區域占用量變化情況還不夠,還需要知道是哪些對象導致了這個問題。
通過生成的heap dump文件,看下發生FullGC時堆內存的分配情況,定位可能出現問題的地方。

2.1 生成dump文件

2.1.1 通過JVM參數自動生成

可以在JVM參數中設置-XX:+ HeapDumpBeforeFullGC參數。
建議動態增加這個參數,直接在線上鏡像中增加一方面是要重新打包發布,另一方面風險比較高

sudo -u admin /opt/taobao/java/bin/jinfo -flag +HeapDumpBeforeFullGC pid
sudo -u admin /opt/taobao/java/bin/jinfo -flag +HeapDumpAfterFullGC pid

也可以用HeapDumpOnOutOfMemoryError這個參數,只在outOfMemoryError發生時才dump。實測只有在fullgc完成時才會產生該文件,fullgc期間看不到。
此外還需要-XX:HeapDumpPath=/home/admin/logs/java.hprof這個參數來指定dump文件存放路徑。
注意:即使開啟該選項,也有可能生成不了dump文件,一個常見原因是堆外內存不足。更多的原因:https://coderbee.net/index.php/jvm/20190905/1919

2.1.2 通過JDK工具生成

2.1.2.1 jmap

先獲取java進程ID,再使用jmap進行dump。
注意,虛擬機上的jmap可能沒有做路徑映射,需要手動選擇jdk路徑下來執行

ps -aux | grep java
jmap -dump:file=test.hprof,format=b XXXX

也可以用jps直接獲取java進程ID。

2.1.2.2 通過jcmd

JDK7后新增的多功能命令,其中jcmd pid GC.heap_dump FILE_NAME的效果和jmap -dump:file=test.hprof,format=b pid一樣。

2.1.2.3 JConsole

可以生成本機或遠程JVM的dump。還有一些其他工具就不詳細介紹了。

2.2 下載dump文件

由於使用的是阿里雲的服務器,可以直接將dump文件上傳到OSS上通過公司內部工具來分析,或通過OSS再下載到本地。可能需要確認bucket是公有讀寫權限。
設置OSSCMD:
操作命令 osscmd config --host=oss-cn-hangzhou-am101.aliyuncs.com --id=** --key=**
創建bucke:osscmd cb 000001
上傳文件:osscmd put 1.txt oss://000001/
下載文件:osscmd get oss://000001/1.txt 1.txt

其他類型的Linux主機可以使用SCP命令,參考:Linux scp命令

2.3 分析工具

通過dump文件來分析fullGC的原因,需要關注哪些類占用內存空間較多、不可到達類等。
由於使用的是公司內部工具Zprofiler和grace,詳細的使用過程這里就不截圖了。一些其他可用的工具和命令(參考Java內存泄漏分析系列之六:JVM Heap Dump(堆轉儲文件)的生成和MAT的使用):

  • jhat, JDK自帶,使用jhat <heap-dump-file>生成網頁,通過瀏覽器訪問http://localhost:7000查看
  • jvisualvm
  • Eclipse Memory Analyzer(MAT)
  • IBM Heap Analyzer

需要注意的是,只看dump文件有時還不能得到結論,因為占用空間大頭的有可能是String、ArrayBlockingList這樣的對象,而且內容可能是null或null對象的集合,無從排查。此時還要結合發生fullgc前后業務系統發生了什么動作來確定。如果有條件的話可以在日常環境或預發環境重現一下。
當然,如果內存中的空間消耗對象是特殊的類,就比較好排查了。

2.4 進一步排查

有時雖然對於是哪個對象觸發fullGC有了一定的猜測,但是對於這個對象是怎么產生的,從dump上不一定能看到,如本例中的大數組。
對於這種情況,可以使用-XX:ArrayAllocationWarningSize=xxx參數(部分jdk支持,可以使用jinfo動態加載)進一步排查,觀察它的產生原因。

3. 分析和改進

具體情況具體分析。

3.1 本次排查的場景

查詢DB中數據->在異步線程中通過poi轉換成Excel->上傳到OSS。

示例代碼:

// 導出代碼中將變量直接作為lambda表達式的值傳入
List<XXData>  data = queryData(request);
SheetDownloadProperty property = sheetDownloadProperties.get(0);
property.setTotalCount(request.getQueryRequest().getPageSize());
property.setPageSize(request.getQueryRequest().getPageSize());
property.setQueryFunction((currentPage, pageSize) ->  data);
// 該組件會在線程池異步調用poi組件轉換為excel、上傳OSS、下載
asyncDownloadService.downloadFile(downloadTask);
private List<XXData> queryData(ExportRequest request) {
    //查詢DB,略
}
// 查詢方法
@FunctionalInterface
public interface PageFunction<T> {

    /**
     * 方法執行
     */
    List<T> apply(Integer currentPage,Integer pageSize);
}

3.2 dump文件分析

通過內部工具可見,fullGC前有三個占據內存較高的ArrayBlockingList,里面有大量的內容為null的Object。

這三個ArrayBlockingList所屬的中間件,雖然本身和業務流程沒有關系,但是仍不能排除嫌疑。

3.3 真實原因

實際上,從dump文件上是無法看出來問題源頭的,真實的原因是poi組件在關閉workbook時,內存無法釋放而產生的fullgc,而不是常見的因為堆空間不足而發生的fullgc。

3.4 嘗試解決

方案1:poi相關解決方案

由於依賴了二方庫poi,這個庫的usermodel模式很容易引起fullGC,同時也懷疑是因為lambda表達式直接傳了變量。
把poi的usermodel改為事件模式(https://my.oschina.net/OutOfMemory/blog/1068972)可以避免這個問題。
但是該功能是一個二次封裝的三方包中的,同時其他引用該組件的應用fullgc頻率並不高,沒有采用這個方案。

方案2:中間件升級

持有大量null對象的中間件版本較低,且新版目前已不再維護,老版本的releas note雖然沒有提到這條bug fix,有一定嫌疑。
該中間件初始化時會創建三個容量為810241024的ArrayBlockingList,和dump文件相符合。
同樣是因為這個中間件是在三方包中封裝,不方便直接該版本,同樣沒有采用這個方案。

方案3:增大堆大小

可以調整metaspace參數來實現,本次想找到代碼中相關的線索來解決,未采用該方案。

方案4:業務代碼修改

仔細觀察了這段代碼在其他系統的的實現,發現其他系統的lambda表達式是匿名方法,而不是直接傳值,即:

property.setQueryFunction((currentPage, pageSize) ->  {
    // 查詢邏輯, 略
);

懷疑是直接傳變量進去導致的垃圾回收問題。更改到這種模式后,觸發下載功能時,連續長時間的fullGC仍然時有發生,沒有解決問題。

方案5:替換垃圾回收器

暫時能確定的原因是,公司中間件本身占用堆內存較多,運行poi增加了GC的頻率。但是由於它們都在二方庫的原因,不方便修改。
此時搜索到stackoverflow有關於poi反復GC的一個問題,和我的情況類似,也是反復GC但是仍然不能釋放內存。有回復建議將GC回收器替換為G1GC,將默認的UseConcMarkSweepGC替換后效果明顯,一次FullGC就可以完成回收釋放,不會反復FullGC,如下圖,20:30前的fullGC是CMS,持續時間長且反復進行;20:30后是替換后第一次觸發excel轉換下載,進行了多次下載,即使發生FullGC也只有1次,大大緩解了之前的問題:

本次暫定只采用方案5。

G1GC在JDK9已替代CMS成為了正式的垃圾回收器,低版本JDK需要手動設置。具體需要設置的JVM參數:

-Xms32m
-Xmx1g
-XX:+UnlockExperimentalVMOptions
-XX:+UseG1GC 
-XX:MaxHeapFreeRatio=15 
-XX:MinHeapFreeRatio=5

注意前兩行一般應用都會設置,不要覆蓋掉。最后兩行需要視情況調整。另外,默認的-XX:+UseConcMarkSweepGC需要去掉。

使用G1GC時需要確認工作線程數是否和預期一致,不要太多,一般來說和CPU核數一致即可。出現非預期數目的原因可能是,鏡像腳本指定核數時,直接按照物理機而不是虛擬機核數來生成。
查看方式是看gc日志:

虛擬機設置核數的dokcker腳本示例:

export CPU_COUNT="$(grep -c 'cpu[0-9][0-9]*' /proc/stat)"

4. 其他

4.1 典型fullGC場景舉例

  • 外部資源未釋放,如將利用tair實現的分布式鎖放在Map中,未做解鎖
  • fastjson的反序列化異常拋出后沒有處理
  • 框架固有缺陷,如本例apache的poi組件,使用usermodel模式做excel導出時,當操作比較頻繁或有其他內存泄漏有可能造成
  • JVM的metaspace設置過小

4.2 core dump和heap dump

core dump是針對線程某一時刻的運行情況的,可以看到執行到哪個類哪個方法哪一行以及執行棧的;heap dump是針對內存某一時刻的分配情況的。

4.3 stackoverflow上關於poi內存占用問題的討論:

簡單摘譯了一些,可以直接看原文。

  1. Java對堆內存分配是懶回收的,如果JVM不想這么做,即使運行Runtime.gc(),也可能什么也不做。sapiensl和Amongalen的回答
  2. 觸發FullGC,並不是因為內存泄漏,僅僅是因為poi占用了太多的內存。Michael的回答

關於G1GC,會在后續文章中研究。

相似的一例:https://blog.csdn.net/CaptainLYF/article/details/86238148


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM