프로젝트에서 사용 중인 APIM 솔루션의 CPU 사용량이 급증하는 문제가 발생했다.
특히, 운영 시간이 길어질수록 사용량이 계속 증가하는 경향을 보인다.
이 문제의 원인을 파악하기 위해 Heap dump/Thread dump를 분석했다.
이 과정에서 JVM의 내부 구조와 동작 원리를 제대로 이해하는 것이 문제해결에 큰 도움이 될 거라는 생각이 들었다.
그래서 이번 기회에 JVM의 내부 구조와 GC 메커니즘을 스스로 정리하게 되었다.
프로그래밍 언어로 작성된 코드는 컴퓨터(CPU)가 이해할 수 있도록 기계어로 번역하는 과정을 거쳐야 한다. 이 작업을 "컴파일"이라고 한다.
하지만 자바는 일반적인 컴파일 방식과 다르다.
자바 코드는 컴파일을 하면 기계어가 아닌 바이트 코드(.class)로 변환된다.
이 바이트코드는 JVM이 읽어서 실제 기계어로 번역한 뒤 실행된다.
왜 자바는 한 번에 기계어로 번역하지 않는걸까?
여기서 자바의 표어인 "Write Once, Run Anywhere!"(한 번 작성하면 어디에서나 실행할 수 있다)에 그 이유가 있다.
대부분의 프로그래밍 언어는 컴파일 시점에 해당 운영체제와 CPU의 명령어 집합(ISA)에 종속된다.
예를 들어, 윈도우에서 컴파일한 프로그램은 리눅스, 맥OS에서 실행할 수 없다.
이런 불편함을 해결하기위해, 자바는 한 번에 기계어로 번역하는 대신 JVM이 이해할 수 있는 바이트코드를 만든다.
그리고, 각 OS/CPU 환경에 맞게 구현된 JVM이 이 바이트코드를 기계어로 번역해 실행하는 구조를 채택했다.
결국, JVM만 준비되어 있다면 어떤 환경에서도 자바 프로그램을 실행할 수 있게 된 것이다.
JVM은 다음과 같은 절차로 자바 프로그램을 실행한다.
클래스 로더는 JVM 내로 클래스 파일(*.class)을 동적으로 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈이다.
즉, 로드된 바이트 코드(.class)들을 엮어서 JVM의 메모리 영역인 Runtime Data Areas에 배치한다.
클래스를 메모리에 올리는 로딩 기능은 한번에 메모리에 올리지 않고, 어플리케이션에서 필요한 경우 동적으로 메모리에 적재하게 된다.
클래스 로딩은 아래 3단계로 진행된다.
Loading(로드): 클래스 파일을 읽어 JVM 메모리에 로드
Linking(링크):
Initialization(초기화): static 변수 및 클래스 변수들을 초기화
실행 엔진은 런타임 데이터 영역에 있는 바이트코드를 명령어 단위로 해석, 실행한다.
바이트코드는 CPU가 바로 이해할 수 있는 기계어는 아니고, JVM만이 이해할 수 있는 중간 단계의 코드다.
실행 엔진은 인터프리터와 JIT(Just-In-Time) 컴파일러 두 가지 방식을 혼합해서 동작한다.
JVM은 가비지 컬렉터(Garbage Collector, GC)를 이용해 Heap 메모리 영역에서 더 이상 사용하지 않는 객체를 자동으로 회수한다.
C 같은 언어는 개발자가 직접 메모리를 해제해야 하지만, 자바는 GC가 자동으로 메모리를 관리해준다.
덕분에 개발자는 메모리 관리에 신경쓰지 않고 비즈니스 로직에 집중할 수 있다.
JVM Heap 영역은 크게 Young Generation과 Old Generation으로 나뉜다.
해당 APIM 솔루션이 요청마다 객체를 반복적으로 생성하면서도 Connection이나 BufferedReader 같은 자원을 제대로 닫지 않아, Heap에 객체가 누적되고 GC가 정상적으로 회수하지 못하는 현상이 관찰되었다.
이로 인해 GC가 잦아지고, 결과적으로 CPU 사용량이 지속적으로 상승하는 흐름이 점차 뚜렷해졌다.
요약하면 Full GC는 Old 영역이 가득차면 발생하는데, 이때 Old영역의 객체들이 reachable 상태여서 GC가 sweep을 못하는 상황이었다.
자원을 명시적으로 close() 하지 않으면, OOM만 발생할 줄 알았다.
하지만 회수되지 않은 객체들이 Old 영역에 누적되면, Full GC가 반복 수행되면서 CPU 사용량이 급증하는 경우도 있다는 점도 알게 되었다.