JVM的記憶體管理和垃圾回收

追尋北極發表於2017-11-22

文章參考了幾篇博文,但由於原博文都存在一點點問題,因此自己寫一篇總結,原博文在結尾給出。歡迎就jvm提出自己的疑問,共同探討學習。

   本文主要是基於Sun JDK 1.6 Garbage Collector(作者:畢玄)的整理與總結,ppt下載地址:to-do

  1、Java虛擬機器執行時的資料區

                

                              (圖片來自深入java虛擬機器2.2節)

詳細一點的關係:

備註:在Hotspot中本地方法棧和JVM方法棧是同一個,因此也可用-Xss控制

1)、程式計數器是一塊較小的記憶體空間,它的作用可以看作是當前執行緒所執行的位元組碼或行號指示器。此區域沒有OutOfMemoryError.

2)Java虛擬機器棧是執行緒私有的,它的生命週期與執行緒相同。每個執行緒執行每個方法的時候都會在棧中申請一個棧幀,每個棧幀包括區域性變數區和運算元棧,用於存放此次方法呼叫過程中的臨時變數、引數和中間結果

3)、本地方法棧是用於支援native方法的執行,儲存了每個native方法呼叫的狀態,為虛擬機器使用到的native方法服務

4)、方法區:它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝。會出現OutOfMemoryError。當增強的類越多,就需要越大的方法區來保證動態生成的class可以載入到記憶體(CGLIB等都是通過動態生成位元組碼來生成class)。HotSpot虛擬機器使用永久代來實現方法區,但兩者並不等價(對於其他實現來說)。JVM用持久代(Permanet Generation)來存放方法區,可通過-XX:PermSize和-XX:MaxPermSize來指定最小值和最大值

5)、堆:所有通過new建立的物件的記憶體都在堆中分配,其大小可以通過-Xmx和-Xms來控制。堆被劃分為新生代和舊生代,新生代又被進一步劃分為Eden和Survivor區,最後Survivor由From Space和To Space組成,結構圖如下所示:

新生代(New Generation)使用-Xmn進行設定,而Eden和S0的比例使用-XX:SurvivorRatio進行設定,S0和S1是相等的。新生代區域這麼劃分是因為新生代的收集演算法為標記複製演算法,每次Minor GC時Eden和S0(或S1)都會把標記的物件拷貝到S1(S0),注意S0和S1是等價的,這樣一次Young GC後留下來的物件age加1。

備註:通常將對新生代進行的回收稱為Minor GC;對舊生代進行的回收稱為Major GC,但由於Major GC除併發GC外均需對整個堆進行掃描和回收,因此又稱為Full GC。

老年代:用於存放新生代中經過多次垃圾回收仍然存活的物件,也有可能是新生代分配不了記憶體的大物件會直接進入老年代。

注:從上圖可以看出,整個堆大小=年輕代大小 + 老年代大小,而永久代是不包括在堆中的。

2、常用的記憶體區域調節引數

-Xms:初始堆大小,預設為實體記憶體的1/64(<1GB);預設(MinHeapFreeRatio引數可以調整)空餘堆記憶體小於40%時,JVM就會增大堆直到-Xmx的最大限制

-Xmx:最大堆大小,預設(MaxHeapFreeRatio引數可以調整)空餘堆記憶體大於70%時,JVM會減少堆直到 -Xms的最小限制

-Xmn:新生代的記憶體空間大小,注意:此處的大小是(eden+ 2 survivor space)。與jmap -heap中顯示的New gen是不同的。
在保證堆大小不變的情況下,增大新生代後,將會減小老生代大小。此值對系統效能影響較大,Sun官方推薦配置為整個堆的3/8。

-XX:SurvivorRatio:新生代中Eden區域與Survivor區域的容量比值,預設值為8。兩個Survivor區與一個Eden區的比值為2:8,一個Survivor區佔整個年輕代的1/10。

-Xss:每個執行緒的堆疊大小。JDK5.0以後每個執行緒堆疊大小為1M,以前每個執行緒堆疊大小為256K。應根據應用的執行緒所需記憶體大小進行適當調整。在相同實體記憶體下,減小這個值能生成更多的執行緒。但是作業系統對一個程式內的執行緒數還是有限制的,不能無限生成,經驗值在3000~5000左右。一般小的應用, 如果棧不是很深, 應該是128k夠用的,大的應用建議使用256k。這個選項對效能影響比較大,需要嚴格的測試。和threadstacksize選項解釋很類似,官方文件似乎沒有解釋,在論壇中有這樣一句話:"-Xss is translated in a VM flag named ThreadStackSize”一般設定這個值就可以了。

-XX:PermSize:設定永久代(perm gen)初始值。預設值為實體記憶體的1/64。

-XX:MaxPermSize:設定持久代最大值。實體記憶體的1/4。

3、記憶體分配方法

1)堆上分配   2)棧上分配  3)堆外分配(DirectByteBuffer或直接使用Unsafe.allocateMemory,但不推薦這種方式)

4、監控方法

1)系統程式執行時可通過jstat –gcutil來檢視堆中各個記憶體區域的變化以及GC的工作狀態; 
2)啟動時可新增-XX:+PrintGCDetails  –Xloggc:<file>輸出到日誌檔案來檢視GC的狀況; 
3)jmap –heap可用於檢視各個記憶體空間的大小;


5)GC收集器彙總

圖注:連線的代表可以配合使用的收集器

一、新生代可用GC

   1)序列GC(Serial):client模式下預設GC方式,也可通過-XX:+UseSerialGC來強制指定;預設情況下eden、s0、s1的大小通過-XX:SurvivorRatio來控制,預設為8,含義為eden:s0的比例,啟動後可通過jmap –heap [pid]來檢視。

      預設情況下,僅在TLAB或eden上分配,只有兩種情況下會在老生代分配: 
      1、需要分配的記憶體大小超過eden space大小; 
      2、在配置了PretenureSizeThreshold的情況下,物件大小大於此值。

    預設情況下,觸發Minor GC時:之前Minor GC晉級到old的平均大小<老生代的剩餘空間<eden+from Survivor的使用空間。當HandlePromotionFailure為true,則僅觸發minor gc;如為false,則觸發full GC。

    預設情況下,新生代物件晉升到老生代的規則:

    1、經歷多次minor gc仍存活的物件,可通過以下引數來控制:以MaxTenuringThreshold值為準,預設為15。
    2、to space放不下的,直接放入老生代;

  2)ParNew:CMS GC時預設採用,也可採用-XX:+UseParNewGC強制指定;垃圾回收的時候採用多執行緒的方式。

 3)Parallel Scavenge:server模式下預設的GC方式,也可採用-XX:+UseParallelGC強制指定;eden、s0、s1的大小可通過-XX:SurvivorRatio來控制,但預設情況下
以-XX:InitialSurivivorRatio為準,此值預設為8,代表的為新生代大小 : s0,這點要特別注意。

     預設情況下,當TLAB、eden上分配都失敗時,判斷需要分配的記憶體大小是否 >= eden space的一半大小,如是就直接在老生代上分配;

 預設情況下的垃圾回收規則:

      1、在回收前PS GC會先檢測之前每次PS GC時,晉升到老生代的平均大小是否大於老生代的剩餘空間,如大於則直接觸發full GC;
      2、在回收後,也會按照上面的規則進行檢測。

   預設情況下的新生代物件晉升到老生代的規則:
     1、經歷多次minor gc仍存活的物件,可通過以下引數來控制:AlwaysTenure,預設false,表示只要minor GC時存活,就晉升到老生代;NeverTenure,預設false,表示永不晉升到老生代;上面兩個都沒設定的情冴下,如UseAdaptiveSizePolicy,啟動時以InitialTenuringThreshold值作為存活次數的閾值,在每次ps gc後會動態調整,如不使用UseAdaptiveSizePolicy,則以MaxTenuringThreshold為準。
     2、to space放不下的,直接放入老生代。在回收後,如UseAdaptiveSizePolicy,PS GC會根據執行狀態動態調整eden、to以及TenuringThreshold的大小。如果不希望動態調整可設定-XX:-UseAdaptiveSizePolicy。如希望跟蹤每次的變化情況,可在啟勱引數上增加: PrintAdaptiveSizePolicy。

二、老生代可用GC

1、序列GC(Serial):client方式下預設GC方式,可通過-XX:+UseSerialGC強制指定。

    觸發機制彙總:
   1)old gen空間不足;
   2)perm gen空間不足;
   3)minor gc時的悲觀策略;
   4)minor GC後在eden上分配記憶體仍然失敗;
   5)執行heap dump時;
   6)外部呼叫System.gc,可通過-XX:+DisableExplicitGC來禁止。

2、Parallel Old:可通過-XX:+UseParallelOldGC強制指定。

3、CMS:可通過-XX:+UseConcMarkSweepGC來強制指定。併發的執行緒數預設為:( 並行GC執行緒數+3)/4,也可通過ParallelCMSThreads指定。

    觸發機制:
    1、當老生代空間的使用到達一定比率時觸發;

     Hotspot V 1.6中預設為65%,可通過PrintCMSInitiationStatistics(此引數在V 1.5中不能用)來檢視這個值到底是多少;

    可通過CMSInitiatingOccupancyFraction來強制指定,預設值並不是賦值在了這個值上,是根據如下公式計算出來的: ((100 - MinHeapFreeRatio) +(double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)/ 100.0;

    其中,MinHeapFreeRatio預設值: 40   CMSTriggerRatio預設值: 80。

   2、當perm gen採用CMS收集且空間使用到一定比率時觸發;

     perm gen採用CMS收集需設定:-XX:+CMSClassUnloadingEnabled   Hotspot V 1.6中預設為65%;

     可通過CMSInitiatingPermOccupancyFraction來強制指定,同樣,它是根據如下公式計算出來的:((100 - MinHeapFreeRatio) +(double)(CMSTriggerPermRatio* MinHeapFreeRatio) / 100.0)/ 100.0;

     其中,MinHeapFreeRatio預設值: 40    CMSTriggerPermRatio預設值: 80。

   3、Hotspot根據成本計算決定是否需要執行CMS GC;

       可通過-XX:+UseCMSInitiatingOccupancyOnly來去掉這個動態執行的策略。
  4、外部呼叫了System.gc,且設定了ExplicitGCInvokesConcurrent;需要注意,在hotspot 6中,在這種情況下如應用同時使用了NIO,可能會出現bug。

注意:

1)、引數-XX:+UseParallelGC,使用Parallel Scavenge +  Serial Old的收集器組合進行回收

2)、新生代使用標記複製演算法,老年代使用併發標記清除(CMS)或標記-壓縮(記憶體向一端移動)。

     而收集器型別是  年輕: 序列 並行,老年: 序列 並行 併發 

3)、收集器的並行和併發的區別:並行是GC執行緒有多個, 但在執行GC執行緒時 ,使用者執行緒是阻塞的;

      併發收集器 ,是大部分階段使用者執行緒和GC執行緒都在執行,這裡說的大部分是因為CMS在初始標記和重新標記階段仍會發生STW(Stop The World)的時候

6、GC組合

1)預設GC組合

image

2)可選的GC組合

image

7、GC監測

1)gc的日誌拿下來後可使用GCLogViewer或gchisto進行分析。

jstat –gcutil [pid] [intervel] [count]
-verbose:gc // 可以輔助輸出一些詳細的GC資訊;
-XX:+PrintGCDetails // 輸出GC詳細資訊;
-XX:+PrintGCApplicationStoppedTime // 輸出GC造成應用暫停的時間
-XX:+PrintGCDateStamps // GC發生的時間資訊;
-XX:+PrintHeapAtGC // 在GC前後輸出堆中各個區域的大小;
-Xloggc:[file] // 將GC資訊輸出到單獨的檔案中,建議都加上,這個消耗不大,而且對查問題和調優有很大的幫助。

2)圖形化的情況下可直接用jvisualvm進行分析。

3)檢視記憶體的消耗狀況

   a.長期消耗,可以直接dump,然後MAT(記憶體分析工具)檢視即可

   b.短期消耗,圖形介面情況下,可使用jvisualvm的memory profiler或jprofiler。

8、系統調優方法

步驟:1、評估現狀 2、設定目標 3、嘗試調優 4、衡量調優 5、細微調整

設定目標:

1)降低Full GC的執行頻率?
2)降低Full GC的消耗時間?
3)降低Full GC所造成的應用停頓時間?
4)降低Minor GC執行頻率?
5)降低Minor GC消耗時間?
例如某系統的GC調優目標:降低Full GC執行頻率的同時,儘可能降低minor GC的執行頻率、消耗時間以及GC對應用造成的停頓時間。

衡量調優:

1、衡量工具
1)列印GC日誌資訊:

-XX:+PrintGCDetails 
–XX:+PrintGCApplicationStoppedTime 
-Xloggc: {檔名}
-XX:+PrintGCTimeStamps

2)jmap:(由於每個版本jvm的預設值可能會有改變,建議還是用jmap首先觀察下目前每個代的記憶體大小、GC方式 
3)執行狀況監測工具:jstat、jvisualvm、sar 、gclogviewer

2、應收集的資訊
1)minor gc的執行頻率;full gc的執行頻率,每次GC耗時多少?
2)高峰期什麼狀況?
3)minor gc回收的效果如何?survivor的消耗狀況如何,每次有多少物件會進入老生代?
4)full gc回收的效果如何?(簡單的memory leak判斷方法)
5)系統的load、cpu消耗、qps or tps、響應時間

QPS每秒查詢率:是對一個特定的查詢伺服器在規定時間內所處理流量多少的衡量標準。在因特網上,作為域名伺服器的機器效能經常用每秒查詢率來衡量。對應fetches/sec,即每秒的響應請求數,也即是最大吞吐能力。
TPS(Transaction Per Second):每秒鐘系統能夠處理的交易或事務的數量。

嘗試調優:

注意Java RMI的定時GC觸發機制,可通過:

-XX:+DisableExplicitGC   來禁止或通過
-Dsun.rmi.dgc.server.gcInterval=3600000來控制觸發的時間。

1)降低Full GC執行頻率 – 通常瓶頸
老生代本身佔用的記憶體空間就一直偏高,所以只要稍微放點物件到老生代,就full GC了;
通常原因:系統快取的東西太多;
例如:使用oracle 10g驅動時preparedstatement cache太大;
查詢辦法:現執行Dump然後再進行MAT分析;

(1)Minor GC後總是有物件不斷的進入老生代,導致老生代不斷的滿
通常原因:Survivor太小了
系統表現:系統響應太慢、請求量太大、每次請求分配的記憶體太多、分配的物件太大...
查詢辦法:分析兩次minor GC之間到底哪些地方分配了記憶體;
利用jstat觀察Survivor的消耗狀況,-XX:PrintHeapAtGC,輸出GC前後的詳細資訊;
對於系統響應慢可以採用系統優化,不是GC優化的內容;

(2)老生代的記憶體佔用一直偏高
調優方法:

① 擴大老生代的大小(減少新生代的大小或調大heap的 大小);
減少new注意對minor gc的影響並且同時有可能造成full gc還是嚴重;
調大heap注意full gc的時間的延長,cpu夠強悍嘛,os是32 bit的嗎?
② 程式優化(去掉一些不必要的快取)

(3)Minor GC後總是有物件不斷的進入老生代
前提:這些進入老生代的物件在full GC時大部分都會被回收
調優方法:
① 降低Minor GC的執行頻率;
② 讓物件儘量在Minor GC中就被回收掉:增大Eden區、增大survivor、增大TenuringThreshold;注意這些可能會造成minor gc執行頻繁;
③ 切換成CMS GC:老生代還沒有滿就回收掉,從而降低Full GC觸發的可能性;
④ 程式優化:提升響應速度、降低每次請求分配的記憶體、

(4)降低單次Full GC的執行時間

通常原因:老生代太大了...
調優方法:1)是並行GC嗎?   2)升級CPU  3)減小Heap或老生代

(5)降低Minor GC執行頻率
通常原因:每次請求分配的記憶體多、請求量大
通常辦法:1)擴大heap、擴大新生代、擴大eden。注意點:降低每次請求分配的記憶體;橫向增加機器的數量分擔請求的數量。

(6)降低Minor GC執行時間
通常原因:新生代太大了,響應速度太慢了,導致每次Minor GC時存活的物件多
通常辦法:1)減小點新生代吧;2)增加CPU的數量、升級CPU的配置;加快系統的響應速度

細微調整:

首先需要了解以下情況:

① 當響應速度下降到多少或請求量上漲到多少時,系統會宕掉?

② 引數調整後系統多久會執行一次Minor GC,多久會執行一次Full GC,高峰期會如何?

需要計算的量:

①每次請求平均需要分配多少記憶體?系統的平均響應時間是多少呢?請求量是多少、多常時間執行一次Minor GC、Full GC?

②現有引數下,應該是多久一次Minor GC、Full GC,對比真實狀況,做一定的調整;

必殺技:提升響應速度、降低每次請求分配的記憶體?

9、系統調優舉例

     現象:1、系統響應速度大概為100ms;2、當系統QPS增長到40時,機器每隔5秒就執行一次minor gc,每隔3分鐘就執行一次full gc,並且很快就一直full GC了;4、每次Full gc後舊生代大概會消耗400M,有點多了。

     解決方案:解決Full GC次數過多的問題

    (1)降低響應時間或請求次數,這個需要重構,比較麻煩;——這個是終極方法,往往能夠順利的解決問題,因為大部分的問題均是由程式自身造成的。

    (2)減少老生代記憶體的消耗,比較靠譜;——可以通過分析Dump檔案(jmap dump),並利用MAT查詢記憶體消耗的原因,從而發現程式中造成老生代記憶體消耗的原因。

    (3)減少每次請求的記憶體的消耗,貌似比較靠譜;——這個是海市蜃樓,沒有太好的辦法。

    (4)降低GC造成的應用暫停的時間——可以採用CMS GS垃圾回收器。引數設定如下:

-Xms1536m -Xmx1536m -Xmn700m -XX:SurvivorRatio=7 
-XX:+UseConcMarkSweepGC 
-XX:+UseCMSCompactAtFullCollection
-XX:CMSMaxAbortablePrecleanTime=1000 
-XX:+CMSClassUnloadingEnabled 
-XX:+UseCMSInitiatingOccupancyOnly 
-XX:+DisableExplicitGC

    (5)減少每次minor gc晉升到old的物件。可選方法:1) 調大新生代。2)調大Survivor。3)調大TenuringThreshold。

      調大Survivor:當前採用PS GC,Survivor space會被動態調整。由於調整幅度很小,導致了經常有物件直接轉移到了老生代;

      於是禁止Survivor區的動態調整了,-XX:-UseAdaptiveSizePolicy,並計算Survivor Space需要的大小,於是繼續觀察,並做微調…。最終將Full GC推遲到2小時1次。

10、垃圾回收的實現原理

      記憶體回收的實現方法:1)引用計數:不適合複雜物件的引用關係,尤其是迴圈依賴的場景。2)有向圖Tracing:適合於複雜物件的引用關係場景,Hotspot採用這種。常用演算法:Copying、Mark-Sweep、Mark-Compact。

      Hotspot從root set開始掃描有引用的物件並對Reference型別的物件進行特殊處理。
      以下是Root Set的列表:1)當前正在執行的執行緒;2)全域性/靜態變數;3)JVM Handles;4)JNI 【 Java Native Interface 】Handles;

   另外:minor GC只掃描新生代,當老生代的物件引用了新生代的物件時,會採用如下的處理方式:在給物件賦引用時,會經過一個write barrier的過程,以便檢查是否有老生代引用新生代物件的情況,如有則記錄到remember set中。

    並在minor gc時,remember set指向的新生代物件也作為root set。

     新生代序列GC(Serial Copying):

     新生代序列GC(Serial Copying)完整記憶體的分配策略:

     1)首先在TLAB(本地執行緒分配緩衝區)上嘗試分配;
     2)檢查是否需要在新生代上分配,如需要分配的大小小於PretenureSizeThreshold,則在eden區上進行分配,分配成功則返回;分配失敗則繼續;
     3)檢查是否需要嘗試在老生代上分配,如需要,則遍歷所有代並檢查是否可在該代上分配,如可以則進行分配;如不需要在老生代上嘗試分配,則繼續;
     4)根據策略決定執行新生代GC或Full GC,執行full gc時不清除soft Ref;
     5)如需要分配的大小大於PretenureSizeThreshold,嘗試在老生代上分配,否則嘗試在新生代上分配;
     6)嘗試擴大堆並分配;
     7)執行full gc,並清除所有soft Ref,按步驟5繼續嘗試分配。  

     新生代序列GC(Serial Copying)完整記憶體回收策略
     1)檢查to是否為空,不為空返回false;
     2)檢查老生代剩餘空間是否大於當前eden+from已用的大小,如大於則返回true,

          如小於且HandlePromotionFailure為true,則檢查剩餘空間是否大於之前每次minor gc晉級到老生代的平均大小,如大於返回true,如小於返回false。
     3)如上面的結果為false,則執行full gc;如上面的結果為true,執行下面的步驟;
     4)掃描引用關係,將活的物件copy到to space,如物件在minor gc中的存活次數超過tenuring_threshold或分配失敗,則往老生代複製,

         如仍然複製失敗,則取決於HandlePromotionFailure,如不需要處理,直接丟擲OOM,並退出vm,如需處理,則保持這些新生代物件不動;

    新生代可用GC-PS

    完整記憶體分配策略
    1)先在TLAB上分配,分配失敗則直接在eden上分配;
    2)當eden上分配失敗時,檢查需要分配的大小是否 >= eden space的一半,如是,則直接在老生代分配;
    3)如分配仍然失敗,且gc已超過頻率,則丟擲OOM;
    4)進入基本分配策略失敗的模式;
    5)執行PS GC,在eden上分配;
    6)執行非最大壓縮的full gc,在eden上分配;
    7)在舊生代上分配;
    8)執行最大壓縮full gc,在eden上分配;
    9)在舊生代上分配;
    10)如還失敗,回到2。

   最悲慘的情況,分配觸發多次PS GC和多次Full GC,直到OOM。

   完整記憶體回收策略
   1)如gc所執行的時間超過,直接結束;
   2)先呼叫invoke_nopolicy
       2.1 先檢查是不是要嘗試scavenge;
       2.1.1 to space必須為空,如不為空,則返回false;
       2.1.2 獲取之前所有minor gc晉級到old的平均大小,並對比目前eden+from已使用的大小,取更小的一個值,如老生代剩餘空間小於此值,則返回false,如大於則返回true;
       2.2 如不需要嘗試scavenge,則返回false,否則繼續;
       2.3 多執行緒掃描活的物件,並基亍copying演算法回收,回收時相應的晉升物件到舊生代;
       2.4 如UseAdaptiveSizePolicy,那麼重新計算to space和tenuringThreshold的值,並調整。
   3)如invoke_nopolicy返回的是false,或之前所有minor gc晉級到老生代的平均大小 > 舊生代的剩餘空間,那麼繼續下面的步驟,否則結束;
   4)如UseParallelOldGC,則執行PSParallelCompact,如不是UseParallelOldGC,則執行PSMarkSweep。

    老生代並行CMS GC:

    優缺點:

    1) 大部分時候和應用併發進行,因此只會造成很短的暫停時間;
    2)浮動垃圾,沒辦法,所以記憶體空間要稍微大一點;
    3)記憶體碎片,-XX:+UseCMSCompactAtFullCollection 來解決;
    4) 爭搶CPU,這GC方式就這樣;
    5)多次remark,所以總的gc時間會比並行的長;
    6)記憶體分配,free list方式,so效能稍差,對minor GC會有一點影響;
    7)和應用併發,有可能分配和回收同時,產生競爭,引入了鎖,JVM分配優先。

11、TLAB的解釋

     堆內的物件資料是各個執行緒所共享的,所以當在堆內建立新的物件時,就需要進行鎖操作。鎖操作是比較耗時,因此JVM為每個線在堆上分配了一塊“自留地”——TLAB(全稱是Thread Local Allocation Buffer),位於堆記憶體的新生代,也就是Eden區。每個執行緒在建立新的物件時,會首先嚐試在自己的TLAB裡進行分配,如果成功就返回,失敗了再到共享的Eden區裡去申請空間。線上程自己的TLAB區域建立物件失敗一般有兩個原因:一是物件太大,二是自己的TLAB區剩餘空間不夠。通常預設的TLAB區域大小是Eden區域的1%,當然也可以手工進行調整,對應的JVM引數是-XX:TLABWasteTargetPercent。

參考文獻:

1、Sun JDK 1.6 GC(Garbage Collector)  作者:畢玄

2、原博文地址:http://www.blogjava.net/chhbjh/archive/2012/01/28/368936.html


相關文章