'2011/06'에 해당되는 글 1건

  1. 2011.06.16 java GC 관련
Study...★/Java2011. 6. 16. 17:21

[Enterprise Java는 거대한 동기화 머신이다] - 재미있는 GC 성능 사례

by 조동욱

Java에서의 Garbage Collection을 흔히 필요악으로 묘사한다. 확실히 GC는 편리한 객체 사용이라는 장점을 제공한다. 하지만 Java에서 발생하는 성능 저하 현상의 8,90%는 GC와 직간접적으로 관련이 있다고 해도 무방할 정도로 문제를 일으키기도 한다.

Garbage Collection은 [맹견]과 같다. 잘 길들이면 행복을 주지만, 자칫 치명적인 상처를 입힐 수도 있다.

GC에서 문제가 발생하는 상황은 매우 다양하다. 더구나 각 시스템마다 Unique한 성능 특징을 가지고 있어 일반적인 해결책이 무용한 경우가 많다.

하지만, 보편적으로 발생하는 몇 가지 사례를 통해 의미있는 통찰력을 얻을 수 있다는 사실은 확실하다. 이런 의미에서 앞으로 몇 차례에 걸쳐 전형적이면서 재밌는 GC 사례와 해결책을 논의해보고자 한다.


1. RMI 사용에 의한 주기적인 Full GC 발생

RMI를 사용할 경우 주기적으로(정확하게 말하면 60초마다) System.gc()를 호출한다.

아래의 GC Dump 예를 보자.

...
211.207: [Full GC 211.207: [ParNew: 123251K->24523K(742784K), 0.2391338 secs] ...
278.167: [Full GC 278.167: [ParNew: 140194K->3468K(742784K), 0.9600674 secs] ...
342.531: [Full GC 342.531: [ParNew: 81241K->646K(742784K), 0.5440857 secs] ...
...

위의 예를 보면 278 - 211 = 67(초), 342 - 278 = 64(초)로 항상 60여 초 단위로 Full GC가 발생하고 있다. 이런 현상이 목격되면 RMI에서 사용하는 Distributed GC에 의한 Explicit GC일 가능성이 대부분이다. RMI 통신을 위해 생성된 객체들을 주기적으로 정리하기 위해 Distributed GC가 작동하게 된다.

아래 2개의 옵션이 Distributed GC에 의한 Explicit GC의 간격을 결정한다.

sun.rmi.dgc.server.gcInterval
sun.rmi.dgc.client.gcInterval

JDK 1.6 이전에는 이 옵션들의 기본값이 60000(ms), 즉 60초였다. 60초마다 한번씩 System.gc()를 호출하게 된다.

System.gc() 호출에 의한 Explicit GC 작업은 Collector의 종류와 무관하게 항상 STW(Stop-The-World) 방식의 Full GC를 유발한다. 심지어 Low Pause Collector(-XX:+UseConcMarkSweepGC)를 사용하더라도 System.gc()는 여전히 STW 방식으로 작동한다. 큰 크기의 Heap을 사용하는 Application에서는 이런 빈번한 Full GC의 호출이 큰 성능 문제를 일으킨다.

기본적인 가이드는 다음과 같다.

1. -XX:+DisableExplicitGC 옵션을 사용해서 System.gc() 콜을 무의미하게 만들어 버린다. 가장 극단적이고 확실한 해결책이다.
2. -Dsun.rmi.dgc.server.gcInterval=3600000 -Dsun.rmi.dgc.client.gcInterval=3600000 (한시간) 과 같이 DGC Interval을 크게 준다.

JDK 1.6 부터는 Low Pause Collector(Concurrent Collector, -XX:+UseConcMarkSweepGC)를 사용할 경우 다음 옵션을 사용할 수 있다.
3. -XX:+ExplicitGCInvokesConcurrent 옵션을 사용해서 System.gc() 콜이 STW(Stop-The-World) 방식이 아닌 Concurrent 방식으로 이루어지도록 한다.


Distributed GC를 논외로 하더라도 Explicit GC는 절대로 남용되어서는 안된다. 메모리를 많이 사용하는 Application들에서 메모리를 좀 더 효율적으로 사용하기 위해 System.gc()를 틈틈이 호출하는 코드를 삽입하는 오류를 흔히 보게 되는데, 굉장히 잘못되고 위험한 방식이다.

Java의 자동화된 GC는 Explicit GC의 호출을 거의 필요로 하지 않는다. 따라서 무의미한 System.gc() 호출이 발생하지 않도록 해야 한다.

PS) 그렇다면, Explicit GC는 완전히 무용한 것일까? 흐음...
생각해 볼 문제다. 다음과 같은 경우에는 유용할 수도 있다.
-XX:+UseConcMarkSweepGC에 의해 활성화되는 Low Pause Collector는 Full GC를 Concurrent하게 진행한다. 이것을 가능하게 하기 위해 Compaction(압축) 작업을 하지 않는다. 따라서 Low Pause Collector에서는 Fragmentation이 발생할 확률이 높다.
Fragmentation이 지나쳐서 필요한 메모리를 확보하지 못하면 STW 방식의 Full GC가 발동되고, 비로소 Compaction 작업이 이루어진다. 웁스!!! 만일 이런 일이 시스템에 로드가 많이 걸린 시점에 발생했다면? Concurrent Collector의 장점이 사라져 버리는 것이다.

Low Pause Collector를 사용하는 환경에서 Fragmentation에 의한 STW Full GC가 자주 발생한다고 판명되면, 시스템의 로드가 적은 시점에 주기적으로 System.gc()를 호출하는 것도 한가지 솔루션이 될 수 있다. 아주 특수한 경우이지만 JVM 옵션으로도 해결이 안되는 경우에는 Workaround가 될 수 있다는 의미이다. 물론 정교한 JVM 옵션 설정으로 이런 문제가 생기지 않도록 하는 것이 최선이겠지만...

 

2. Paging In/Out에 의한 GC 지연

가끔 다음과 같은 현상이 발생하는 경우가 있다.

  • 평소에는 Full GC가 1초안에 끝났다. GC에 의해 Heap 사용량은 200M -> 100M 로 줄었다.
  • 그런데 어제 갑자기 Full GC가 20여초가 걸렸다. Heap 사용량의 변화는 210M -> 100M로 평소와 거의 비슷했다.

도대체 무슨 일이 벌어진 것인가?
정확한 원인을 GC log만을 보고 분석하기란 불가능하다. Full GC가 처리해야할 메모리의 양은 거의 비슷하다. 하지만 Full GC 소요시간은 20배가 늘었다. 논리적으로 가능한 이유들을 열거해 보면...

  • Full GC 당시 CPU Load가 심해서 GC 작업이 지연되었다 <-- CPU 문제
  • Full GC 당시 Memory Load가 심해서 Heap 영역이 Page Out 되었다 <-- Memory 문제
  • JVM 버그다 <-- Bug 데이터베이스 검색!!

다행히(?) 의외로 적지 않은 경우 2번째 이유, 즉 Paging In/Out에 의한 GC 지연이 발생한다.

아래에 실제로 발생한 사례가 있다.

다음과 같이 Low Pause Collector를 사용하는 상황에서...

-XX:+UseConMarkSweepGC -Xms2028m -Xmx2028m

New Generation Collection에 30초, Concurrent Mode가 구동되어서 총 78초가 흐르고 실패하고 말았다.

30263.967: [ParNew: 22279K->3192K(57344K), 30.0028214 secs]
30293.970: [CMS (concurrent mode failure): 1507043K->1045599K(2031616K), 78.8167675 secs]
....

위의 GC Log를 보면 한눈에 비정상적인 상황임을 알 수 있다.

  • 첫째, 57M의 New Generation을 정리하는데 무려 30초가 걸렸다.
  • 둘째, CMS(concurrent mode failure)가 무려 78초가 흐른 후 실패하고 말았다. Concurrent mode failure란 Tenured Space가 꽉 차기 전에 Concurrent Mode의 Collection 작업이 끝나지 않았다는 것을 의미한다.

다행히 이 현상이 발생한 당시의 OS 모니터링 정보에 의해 메모리 부족 현상에 의해 Paging In/Out이 왕성하게 발생한 것을 확인할 수 있었다.

시도한 Workaround는 Java Max Heap Size를 줄이는 것이었고, 더불어 몇몇 JVM Option이 수정되었다. Max Heap Size를 줄인 이유는 Java Process가 사용하는 메모리를 줄임으로써 OS에 메모리의 여유분을 주기 위해서이다.

비록 Java가 메모리 관리를 상당히 자동화하지만, 여전히 OS에 종속된 플랫폼이라는 것을 명심해야 한다. 따라서 OS 레벨의 CPU/Memory/IO 모니터링과 Java Heap Monitoring을 병행해야만 좀 더 합리적인 성능 문제 분석을 수행할 수 있다.

3. Heap 여유 공간이 충분한데도 OOME(OutOfMemoryException)이 발생한다?

간혹 Heap의 여유 공간이 충분한데도 OutOfMemory Error가 나는 경우가 있다. 이러한 상황을 이해하려면 Java Application이 사용하는 메모리가 여러 영역으로 나뉜다는 사실을 이해해야 한다.

Java Application이 사용하는 메모리 영역은 보통 다음과 같이 분류된다.

- Permanent Space: Class 정보를 저장
- Java Heap: Object 정보를 저장
- Native Heap: JNI, Thread Stack, 기타 Native 정보를 저장

우리가 흔히 접하는 Memory 문제는 대부분 Java Heap에서 발생한다. Java Application이 할당하는 오브젝트들이 Java Heap에 거주하기 때문에 가장 많은 메모리를 필요로 하기 때문이다.

하지만 위의 제목처럼 GC Log 등을 통해 모니터링을 해 본 결과 Java Heap의 여유 공간이 충분한데도 OOEM이 난다면? 다음과 같은 세가지 문제를 의심해보아야 한다.

- Permanent Space가 부족하지 않은가?
- Native Heap이 부족하지 않은가?
- 버그가 아닌가? :)

Permanet Space와 Native Heap에서 발생할 수 있는 메모리 부족 현상에 대해서 알아보자.

- Permanent Space의 부족
GC Log를 통해 Permanent Space가 부족한 지의 여부를 간접적으로 판단할 수 있다.
(GC Log에 대한 자세한 내용은 Google 검색....)

대부분의 Application이 기본 크기만으로 필요한 클래스들을 관리할 수 있다. 하지만 복잡한 Application 들, 특히 많은 수의 Servlet/JSP/EJB/Library 들을 로딩하는 Web Application의 경우 좀 더 큰 크기의 Permanent Space를 요구하기도한다.

-verbose:class 옵션을 사용하면 어떤 클래스들이 로딩되는지 확인할 수 있다.

Permanent Space의 부족 현상으로 판명나는 경우에는 -XX:PermSize-XX:MaxPermSize 옵션을 이용해 Permanent Space의 크기를 키워주어야 한다.

- Thread Stack Size가 큰 경우
우리가 Thread를 사용할 때마다 Native Heap에는 Thread가 사용하는 메모리가 할당된다. 시스템마다 기본적으로 할당되는 크기는 다르다.

Thread Stack의 크기를 256K라고 가정해보자. 4개의 Thread는 1M를 사용하고, 400개의 Thread는 무려 100M를 사용한다. 수많은 Thread를 사용하는 대형 시스템에서는 결코 간과할 수 없는 크기가 된다.

Application 모니터링을 통해 현재 생성한(사용 중인 아닌) 전체 Thread의 개수를 파악해야 한다. 만일 지나치게 많은(수백개 ~ 수천개) Thread가 생성되었다면 지나치게 큰 크기의 Native Heap을 사용하고 필연적으로 OOEM을 유발한다.

Thread 개수를 줄이는 가장 기본적인 방법은 Thread Pool을 사용하는 것이다. Weblogic/Jeus/Websphere와 같은 WAS들이 수백~수천의 동시 사용자를 감당할 수 있는 것은 Thread Pool을 잘 사용하기 때문이다. 대량의 Client와 통신을 수행하는 Server Application을 작성하는 경우에는 반드시 Thread Pool 기법을 사용해서 실제 생성되는 Thread의 개수를 줄여야 한다.

또 다른 방법은 Thread Stack Size를 줄이는 것이다. Thread 개수를 줄일 수 없다면 -Xss128K 정도의 작은 값을 부여해서 메모리 사용량을 줄일 수 있다. 단, Stack 크기가 줄어든 만큼 Stack Overflow 에러가 날 확률이 높아진다.

(PS) Application이 사용하는 Thread 개수를 모니터링하는 가장 좋은 방법은? Thread Dump(kill -3)가 가장 손쉽고 확실한 방법이다. JDK 1.5에서 추가된 Platform MBean(MXBean)과 JConsole 또한 좋은 방법이다. JConsole에 대해서는 아래 URL을 참조한다.

http://java.sun.com/developer/technicalArticles/J2SE/jconsole.html

- File나 Socket을 지나치게 많이 오픈하는 경우
File/Socket을 Resource Limit를 초과하여 오픈하는 경우에도 OOEM이 발생한다. 우선 File이나 Socket을 열고 닫지 않은, 이른바 Resource Leak을 의심해봐야 하며 필요한 경우 다음과 같이 File/Socket의 Resource Limit을 키워야 한다.

ulimit -n 10000

- JNI를 사용하는 Library들...
JDK가 제공하는 Library나 3rd Party가 제공하는 Library들 중에서 JNI를 사용하는 경우 Native Heap을 사용한다. Native Heap은 Java Heap과는 전혀 독립적으로 사용된다. 따라서 Java Heap은 여유가 있음에도 OOEM이 발생하게 된다.

Max Heap(-Xmx) 크기를 500M 정도로 지정했는데 OS 모니터링을 통해 본 Java Process의 메모리 크기가 2G라면? 십중 팔구 Native Heap의 크기가 지나치게 커진 것이다. Thread Stack 크기와 더불어 JNI Heap을 의심해보아야 한다.

Oracle OCI JDBC Driver가 JNI를 사용하는 대표적인 3rd Party Library이다. OCI JDBC Driver는 Thin Driver에 비해 성능 면에서 다소 유리하지만 Native Heap 공간을 많이 사용하는 경향이 있다. 따라서 OCI Driver를 사용하는 경우에는 Process의 메모리 크기를 잘 모니터링해야 한다.

한가지 역설적인 것은 Native Heap을 좀더 크게 사용하기 위해서 Java Heap의 크기를 줄여야 하는 경우가 있다는 것이다. 즉, OutOfMemory 부족 현상이 Native Heap에서 발생하는 경우에는 Java Heap의 크기를 줄이는 것이 대안이 될 수 있다. 32bit 환경에서는 하나의 Java 프로세스가 사용할 수 있는 최대 메모리 공간은 2G로 제한된다. 이 공간을 Java Heap이 다 써버리면 Native Heap이 사용될 수 있는 공간이 그만큼 줄어든다. 그만큼 OOEM이 날 확률이 높아진다.

- 노파심에...
시스템의 물리적 메모리가 2G이다. Java Application의 성능을 극대화하기 위해 Max Heap Size를 1.8G 정도 부여할려고 한다. 바람직한가? 그럴 수도 있지만 생각치 못한 역효과가 있을 수 있다. 앞에서 소개한 몇가지 사례를 기억하자.

Java Heap에 지니치게 많은 메모리를 할당함으로써 Permanet Space나 Native Heap의
부족을 초래하게 되고 이로 인해 OOEM이나 Paging In/Out에 의한 성능 저하 현상이 유발될 수 있기 때문이다.

메모리는 항상 필요한 만큼만...
4. 큰 New Generation에 의한 Full GC 발생

다음과 같은 상황을 가정해보자
  • 전체 Heap의 크기는 1.5G이다.
  • New Generation의 크기를 전체 Heap의 1/4 정도로 크게 잡았다(일반적인 가이드)
  • 1G 정도에 육박하는 객체 정보를 메모리에 관리하고자 한다.

위와 같은 Java Application을 구동하면 그 결과는 어떻게 될까?

Full GC가 자주 발생할 확률이 매우 높다. Tenured Space가 큰 메모리 객체(1G)를 계속 보유하고 있기 때문에 New Generation에서 Promotion 되는 객체를 감당하지 못하게 되는데 이 경우 반드시 Full GC가 발생한다. 이런 현상을 Promotion Failure라고 부른다.

Promotion Failure는 Minor GC에서는 항상 발생할 가능성이 있다. Minor GC가 발생할 때 Live Object들은 Survior Space나 Tenured Space로 옮겨지게 되는데, 이 때 Tenured Space에 공간이 부족하면 Full GC가 발생하게 된다.

위의 상황을 다시 정리해보면 Tenured Space에 1G에 달하는 객체들이 상주하고 있다. 이런 상황에서 New Generation의 크기를 크게 하면 Promotion Failure가 발생할 확률이 매우 높아진다. 따라서 이런 경우에는 New Generation의 크기를 작게 주어야 한다.

New Generation 크기의 속성을 잘 이해할 필요가 있다. New Generation의 크기가 크면 Minor GC는 유리해지지만 Full GC에서는 불리해질 확률이 높다는 기본 원리를 염두에 두고 Application 속성에 따라 최적의 크기를 찾을 필요가 있다.

PS) 다행히 New Generation과 Survivor Space의 크기를 JVM에 의해 자동으로 관리된다. -XX:+UseAdaptiveSizePolicy 옵션에 의해 New Generation의 자동화된 크기 관리가 이루어지면 디폴트로 활성화되어 있다. 하지만 간혹 강제적으로 -XX:NewRatio 옵션을 이용해 크기를 설정해야만 문제가 해결되는 경우가 있다.

'Study...★ > Java' 카테고리의 다른 글

IBM JVM 튜닝 - 1  (2) 2011.07.03
java GC 관련  (0) 2011.06.16
[펌] DWR 설명과 사용법  (0) 2011.01.18
각 환경별 Servlet & JNI 정리  (0) 2010.06.24
Posted by 달콤한녀석

댓글을 달아 주세요