JVM 시리즈 - 최적화 사례 분석 및 실전
최적화 사례 분석 및 실전
대용량 메모리 기기 대상 배포 전략
관리자는 -Xmx 와 -Xms 매개 변수를 지정해서 자바 힙 크기를 12GB 로 고정했다. 그러나, 서버 실행 효율이 기대 이하였다. 원인은 가비지 컬렉션에 있었다. 패러렐 컬렉터를 사용하고 있었는데, 패러렐 컬렉터는 일시 정지 시간보다 처치량에 중점을 두었기에 12G 라는 힙 메모리를 GC 하기 위해서 14초 가량을 일시 정지하게 된 것이다.
웹 페이지를 직렬화하기 위해서 거대 객체를 구세대에 만들고, 이는 금방 가득차기에 이를 GC 하기 위해서 stop the world 가 발생할 수 밖에 없었다. 응답성을 개선하기 위해서 메모리 폭을 늘렸기에 오히려 문제가 생긴 것이다.
그러나 이 사례만을 바탕으로 패러렐 GC 의 사용성이 좋지 않다고 판단해서는 안된다. 전체 GC 의 빈도를 가능한 낮게, 사용자의 사용 도중에는 발생하지 않도록 처리하고, 매일 새벽에 전체 GC 를 수행하거나 애플리케이션 서버를 재시작하도록 스케줄링하는 것으로 서버의 가용 메모리를 안전하게 유지할 수 있을 것이다.
만약, 단일 가상 머신으로 거대 메모리를 관리할 계획이라면 다음 잠재 문제를 고려해야 한다.
- 힙 메모리의 거대 블록을 수거하는 문제는 G1 컬렉터의 등장으로 해결됐다. ZGC, 셰넌도어까지 사당히 성숙되어 상황이 개선됐다.
- 64 비트 자바 가상 머신에서는 대용량 메모리를 사용할 수 있지만, 압축 포인터나 프로세스 캐시 라인 용량 같은 요인으로 인해서 동일 버전의 32 비트 가상 머신보다 더 느리다.
- 애플리케이션을 모니터링 하기 위해서, 힙 메모리가 커질수록 힙 덤프 스냅샷은 무거워지며 분석이 어려워진다.
- 같은 프로그램이라도 32 비트 가상 머신보다 64 비트 가상 머신에서 메모리를 많이 사용한다.
이러한 문제로 인해서, 여러 가상 머신을 클러스터 형태로 배포하는 방식을 선택하는 시스템 관리자도 많다. 같은 물리 머신에서 서버 프로세스를 여러 개 띄우고 각각 서로 다른 포트를 할당한다. 앞단에 부하 분산기를 두어 리버스 프록시 방식으로 요청을 분배할 수 있다.
그러나 이 방법에도 다음과 같은 문제가 발생할 수 있음을 알아야 한다.
- 노드들이 전역 자원(대표적으로 디스크)을 두고 경쟁한다.
- 연결 풀과 같은 자원 풀을 효율적으로 활용하기 어렵다. 중앙화된 JNDI 로 해결할 수 있다지만 복잡한 구성과 성능 비용이 발생한다.
- 해시 맵이나 키-값 캐시 등의 로컬 캐시를 많이 이용하는 애플리케이션이라면 논리 클러스터 방식에는 메모리가 낭비된다. 캐시를 노드마다 두기 때문이다. 중앙화된 캐시를 고려해보자.
클러스터 간 동기화로 인한 메모리 오버플로
JBossCache 로 글로벌 캐시를 구축한 경우에 발생한 문제다. JBossCache 는 클러스터 사이에 데이터 통신에 JGroups 라는 개념을 사용하는데 이는 데이터 패킷을 보내고 받는데 필요한 다양한 필수 특성을 자유롭게 조합한다.
각 노드에서 데이터를 잘 수신했는지 확인할 때까지 데이터는 메모리에 보관되어야 한다. 그런데 서비스를 이용하는 과정에서, 네트워크가 필요한 데이터를 소화할 만큼 대역폭을 유지하지 못하는 경우가 발생하고, 이로 인해서 소화되지 못한 데이터가 메모리에 남아있다가 오버플로를 일으키는 것이다.
이 경우는 아키텍쳐가 문제를 일으킨 것이며, JBossCache 에서 논의된 메모리 오버플로 문제인 것이다. 아키텍쳐를 구성할 때는 이러한 제약 사항도 고려해야 한다.
힙 메모리 부족으로 인한 오버플로 오류
32 비트 시스템에서 활용할 수 있는 최대 합은 1.6G 인데, 오버 플로가 발생하는 문제였다. 가비지 컬렉션이 자주 발생하지 않고, 각 메모리 영역이 모두 안정적이었음에도 오버플로가 나타나는 것은 이상한 일이었다.
Jstat 으로 화면을 살펴본 결과 다이렉트 메모리가 문제를 일으킨 것을 확인할 수 있었다. 다이렉트 메모리도 가비지 컬렉션의 대상이지만, 이 영역은 힙과 달리 공간이 부족해도 카비지 컬렉션을 능동적으로 수행할 수 없다. 다이렉트 메모리를 이용하는 NIO 연산을 많이 수행했기에 해당 메모리에서 오버플로 에러가 발생한 것이다.
이 문제를 해결하기 위해서 다이렉트 메모리의 사이즈를 조정할 수 있다.(–XX:MaxDirectMemorySize 매개 변수 활용)
부적절한 데이터 구조로 인한 메모리 과소비
데이터를 분석하기 위해서 100만개 이상의 HashMap 객체를 만들어 냈다. 이로 인해서 마이너 GC 가 100만개가 넘는 객체를 검사하느라 일시 정지가 500 밀리초로 늘어났다. 마이너 GC 후에도 신세대의 객체 대부분이 살아있기에 이는 복사 알고리즘에 치명적이다.
이 문제를 해결하기 위해서는 데이터 구조를 바꾸는 것이 근본적인 해결책이다. HashMap 은 키와 값에 해당하는 long 정수 2개만을 사용한다. long 정수 2개를 담기 위해서는 Long(24바이트 * 2) + Entry(32 바이트) + HashMap 안의 참조(8바이트)까지 총 88ㅂ자이트를 사용하는데, 이중 필요한 데이터는 정작 16바이트에 불과하다.
이런 비효율을 개선하기 위해서 데이터 타입의 변경을 고려해보자.
윈도우 가상 메모리로 인한 일시 정지
1분간 가비지 컬렉션이 진행되면서 stop the world 가 발생했다. 가비지 컬렉션 자체보다, 준비 단계에서 실제 시작까지 시간이 많이 소요됐다. 디스크에서 사용되는 스왑 메모리의 데이터를 메모리로 불러와서 GC 를 해야하기에 시간이 오래 걸리는 문제였다.