Java는 GC를 통해 메모리를 관리합니다. C언어에서는 free() 메서드를 통해 사용을 마친 객체의 메모리를 명시적으로 반환하지만, Java는 마치
마법처럼 메모리 관리가 이루어집니다. 좋은 개발자라면 추상화 수준이 높을 수록, 기능이 마법같아 보일 수록 그 개념에 대해 더 자세히 공부하고 이해해야합니다. GC는 마법이 아니라 알고리즘입니다. 프로젝트 요구사항에 가장 적합한 GC를 선택하고, 해당 GC가 추구하는 방향을 알고 개발함으로써 성능을 최적화하고, 메모리 누수를 방지해야 합니다.
GC는 JVM의 핵심 구성 요소로써, Heap 영역에서 자바 프로그램이 동적으로 할당한 메모리 중 더 이상 사용하지 않는 객체를 탐지하여 자동으로 할당 해제하고 메모리로 반환하는 메모리 관리 기법입니다. 이를 통해 불필요한 메모리를 계속 사용하는 상태인 메모리 누수를 방지할 수 있고, GC 알고리즘에 의해 자동적으로 수행되므로 개발자의 코딩 실수로 인한 문제와 메모리 관리에 들어가는 비용을 절감해서 비즈니스 로직 개발에 집중할 수 있게 도와줍니다.
GC에서 가장 중요한 개념 중 하나입니다. GC를 수행하기 위해 JVM이 실행중인 자바 프로그램을 일시적으로 중지하는 작업을 의미합니다. STW가 일어나는 동안에는 메모리의 일관성과 안정성 확보를 위해 GC 관련 스레드를 제외한 모든 스레드가 멈추게 되므로 사용자는 서비스를 정상적으로 이용할 수 없게 됩니다. 그래서 뒤에 나올 GC의 버전별 변천사는 주로 STW의 발생 빈도를 줄이거나, STW 수행 시간을 줄이는 방향으로 발전합니다.
Mark Sweep 알고리즘은 GC의 가장 기본적인 알고리즘이며 대부분의 GC는 이 개념을 확장하거나 개선한 방식입니다.
Mark는 Heap 메모리에서 참조되고 있는(Reachable) 객체를 식별하고, 식별된 객체에 mark bit를 1로 설정합니다. 이후 Sweep 단계에서 mark bit가 0인, 더이상 참조되지 않는(Unreachable) 객체를 메모리에서 해제합니다. 빠른 GC 수행 시간이 중요하고 짧은 수명의 객체가 많은 Minor GC에서 주로 사용됩니다. 단점으로는 메모리 단편화가 발생하여 메모리가 연속적이지 않은 상태로 유지된다는 단점이 있습니다.
Mark Sweep Compact는 위의 Mark Sweep 방식에서 Compact 단계를 추가하였습니다. Compact 단계에서는 단편화된 메모리를 연속적으로 재배치합니다. 이를 통해 애플리케이션 장기 실행 시 메모리 단편화로 인해 메모리 효율성이 떨어지는 단점을 개선할 수 있으며 Major GC에서 주로 사용됩니다. Mark Sweep 과 Compact 과정을 이어서 보면 다음과 같습니다.
Young 영역은 1개의 Eden 영역과 2개의 Survivor영역으로 다시 나눠집니다. Eden 영역에는 새로 생성된 객체들이 위치하고, Eden 영역이 가득 찼을 경우 사용되지 않는 객체는 메모리에서 해제하고, 참조되고 있는 객체들을 2개 중 1개의 Survivor 영역으로 이동시킵니다. Eden에서 1개의 Survivor로 보내는 과정을 반복하다가 Survivor 영역이 가득차면, 사용되지 않는 객체를 메모리에서 해제하고, 남은 객체들을 나머지 1개의 Survivor 영역으로 이동시킵니다. 이렇게 1개의 Survivor 영역은 항상 빈 상태를 유지하면서 이 동작을 반복 수행합니다. Minor GC를 수행한 횟수를 age라는 값으로 기록하는데, 특정 시점이 되면 Survivor에 있던 객체를 Old 영역으로 이동시킵니다.
Old 영역은 이렇게 Minor GC가 반복되는 과정에서 계속 살아남은 오랫동안 사용되는 객체들이 저장됩니다. 만약 Old 영역이 임계치에 도달하게 되면 Major GC를 수행합니다. 그리고 Old 영역이 완전히 가득 차거나, Metaspace(Java 8 이후) 영역이 가득 찼을 때는 Full GC가 수행됩니다.
가장 단순한 GC 알고리즘이며, 단일 스레드로 동작합니다. Java 8 이전까지 클라이언트 모드 JVM의 기본값이었으며, Minor GC에서는 Mark-Sweep으로, Major GC에서는 Mark-Sweep-Compact 알고리즘을 사용합니다. 단일 스레드로 동작한다는 특징 때문에 클라이언트 환경의 소규모 애플리케이션, 단일 프로세서 시스템에서 적합합니다. 현대에서는 성능상의 손해가 크고, STW 시간이 길기 때문에 사용하는 것을 지양해야 합니다.
기본적인 처리 과정은 Serial GC와 같지만, 다중 스레드로 GC를 수행하기 때문에 Serial GC보다 빠른 성능과 비교적 짧은 STW 시간을 가집니다. 멀티 코어 시스템에서 처리량이 증가하였고 Java 8 이전까지 서버 모드 JVM에서 기본값이었지만 여전히 STW 시간에서 개선할 점이 남아있다는 단점이 있습니다.
CMS GC는 Parallel GC처럼 여러개의 스레드를 사용합니다. 하지만 기존의 Serial, Parallel GC와는 다르게 Mark-Sweep 알고리즘을 Concurrent하게 수행합니다. 애플리케이션 실행과 동시에 객체 그래프를 순회하며 살아있는 객체를 마킹, 제거하기 때문에 STW 시간이 크게 감소합니다. 하지만 동시 수행으로 인한 CPU, 메모리 자원 소모가 증가하게 되고, Mark-Sweep 방식의 단점인 메모리 단편화로 인해 새로운 객체를 할당할 메모리상의 연속된 빈 공간이 부족한 경우 Concurrent mode failure가 발생할 수 있습니다. 이 때 메모리 단편화를 해결하기 위해 Compact 과정을 수행하는데, 이로 인해 긴 STW 시간이 발생할 수 있는 단점이 있습니다. 이 단점으로 인해 CMS GC는 Java 9부터 deprecated되었고, Java 14에서는 제거되었습니다.
G1 GC는 Java 9부터 기본 GC로 사용되었으며,메모리 단편화로 인해 장기적으로 문제점을 가지고 있는 CMS GC를 대체하기 위해 개발되었습니다. 기존의 GC 알고리즘은 Heap영역을 물리적으로 Young영역과 Old 영역으로 나누어 사용하였지만, G1 GC는 Heap을 균등한 크기의 영역(region)으로 나누고 각 지역을 Young, Old, Humongous 영역 중 하나의 역할을 수행하게 합니다. 아직 할당되지 않은 영역은 Available/Unused로 표시합니다. Humongous 영역은 region 크기의 50% 보다 큰 객체를 저장하는 영역입니다. 이 영역에 대한 최적화가 G1 GC의 성능과 밀접관 연관이 있으며, 주로 대용량 캐시나 큰 배열, 컬렉션, 큰 문자열 객체를 저장하는데 사용되지만 가능한 생성되지 않도록 하는 것이 성능적으로 유리합니다. G1 GC는 기본적으로 Full GC를 피하도록 설계되었으며, -XX:MaxGCPauseMillis=<N> 옵션을 통해 일시 정지 시간 목표(Pause Time Goal)를 설정할 수 있습니다. 일시 정지 시간이 STW를 뜻하며, 이 목표값을 지키기 위해 내부적으로 여러 매개변수를 자동으로 조정하여 처리량과 트레이드 오프를 통해 GC를 수행합니다.
ZGC는 8MB부터 16TB에 이르는 광범위한 Heap 크기에서 효율적으로 작동하도록 설계되었습니다. 이는 소규모 서버부터 대규모 데이터 처리 시스템까지 다양한 환경에 적용할 수 있음을 의미합니다. GC 성능에서 가장 중요한 지표인 STW 시간에 있어 ZGC는크게 개선되었습니다. G1 GC가 일반적으로 100ms 이하를 목표로 하는 반면, ZGC는 훨씬 적은 10ms 미만을 목표로 합니다. ZGC는 Mark-Sweep-Compact의 모든 과정을 애플리케이션과 동시에 수행하며, 리마크 단계를 제거하여 STW 시간을 더욱 단축했습니다. 또한 ZGC는 전체 Heap에 대해 지속적이고 점진적인 압축을 수행하여 메모리 단편화를 효과적으로 방지합니다. G1 GC가 애플리케이션의 동작 패턴을 학습하는 워밍업 시간이 필요한 반면, ZGC는 시작 즉시 일관된 성능을 제공합니다. 이러한 성능 향상의 주요 요인으로는 Region 관리 방식 변경과, 두 가지 주요 알고리즘인 색상 포인터(Colored Pointers) 기술과 로드 배리어(Load Barrier)의 사용으로 가능해졌습니다.
이전 모델인 G1 GC는 일반적으로 1MB에서 32MB 사이의 균일한 크기를 가진 Region을 사용합니다. 하지만 ZGC의 Heap 구조는 small(2MB), medium(32MB), large(N x 2MB) 세 가지 크기의 Region으로 나뉩니다. ZGC의 가변적인 Region 크기는 G1 GC의 고정 크기 Region과 달리 다양한 객체 크기에 더 효율적으로 대응할 수 있게 해줍니다. 특히 ZGC의 large Region은 매우 큰 객체를 위해 동적으로 크기를 조정할 수 있어, 메모리 단편화를 줄이고 더 효율적으로 관리할 수 있습니다. 반면 G1 GC에서는 Region 크기보다 큰 객체를 위해 연속된 여러 Region을 사용해야 하므로, 이 과정에서 STW 시간이 길어지거나 Full GC가 발생할 가능성이 있습니다. 이러한 차이로 인해 ZGC는 다양한 크기의 객체를 포함하는 애플리케이션에서 G1 GC보다 더 효율적인 메모리 관리를 할 수 있습니다.
색상 포인터 기술을 설명하기 전에, 먼저 OS의 메모리 관리 기법에 대해 이해해야 합니다. OS는 32비트와 64비트로 나눠져있습니다. 32비트에서는 메모리를 2^32 = 4GiB까지만 사용할 수 있고, 64비트는 이론적으로 2^64 = 16EiB(엑사바이트)까지 사용할 수 있습니다. 하지만 64비트에서는 사용가능한 메모리 단위가 너무 크기때문에 현실적인 부분을 고려하여 일반적으로 48비트(2^48 = 256TiB)를 사용합니다. 램카드를 약 256TB 까지 사용할 수 있음에도 대부분 개인용 PC에서는 8~64GB 정도의 메모리를 사용하는 것이 대부분이고, 충분합니다.
OS 메모리 관리에 대한 이해를 돕기 위해 사례를 하나 들겠습니다. 옛날 32비트 윈도우에서는 램이 실제로 4기가까지만 인식되었습니다. 그래서 더 큰 메모리를 사용하기 위해서는 64비트 기반으로 OS를 재설치 해야했죠.
[그림10] 32비트 윈도우에서 메모리를 최대 4기가까지만 인식한 모습
![]()
OS 메모리 관리 기법에 대한 이야기를 한 이유는 ZGC가 사용하는 색상 포인터 알고리즘이 64비트 환경에서 동작하기 때문입니다. 따라서 ZGC는 64비트 JVM에서만 동작한다는 제약이 존재합니다. ZGC는 일반적인 64비트 환경과 달리 객체 메모리 관리에 42비트(2^42 = 4TiB)를 사용합니다. 그러면 22bit가 남게 되는데, 이 중 4개 비트를 색상 포인터 알고리즘에사용합니다.!
Marked 0, 1을 통해 마킹을 두 단계로 가져가면서, 애플리케이션과 GC가 동시에 실행되는 동시성을 보장하면서도 일관성있는 Reachable 객체 추적을 통해 STW 시간을 최소화 하면서도 안전하게 GC를 수행합니다.
Finalizable는 객체가 메모리에서 해제되기 전에 정리 작업이 필요한 경우 사용하는 finalize() 메소드를 가진 객체를 표시하는데 사용합니다. finalize() 메서드는 GC 과정을 더 복잡하게 만들고, 실행 시점과 순서를 정확히 예측하기 힘들기 때문에 Java 9부터 deprecated 되고 try-with-resources 구문을 권장하고 있습니다. 그러나 여전히 레거시 프로젝트에서 사용되고 있기 때문에 ZGC에서는 Finalizable 객체를 식별하고, 큐에 담아서 GC가 특별히 관리하도록 하고 있습니다.
Remapped 비트에 대해서는 다음에 나올 로드 배리어에서 자세히 설명하겠습니다.
로드 배리어는 애플리케이션이 Heap에 있는 객체를 참조할 때마다 실행되어 객체의 현재 상태를 확인하고, 필요한 경우 참조를 업데이트하여 일관된 객체 상태를 제공합니다. 예시를 들어보겠습니다.
ZGC 도서관에 오신 것을 환영합니다. 우리 도서관에는 직원 “로드 배리어"씨가 있습니다. 로드 배리어씨는 책들의 위치가 옮겨질 때마다 책이 원래 있던 위치에 Remapped 스티커를 붙입니다. 그리고 손님이 Remapped 스티커가 붙은 곳에 책을 찾으러 오면, 책이 옮겨진 위치로 손님을 안내해드립니다. 모든 손님들이 책의 새로운 위치에 대해 알게되면 Remapped 스티커를 제거합니다. 로드 배리어씨 덕분에 손님들은 책이 정리되는 시간에도 도서관을 이용하실 수 있습니다.
로드 배리어는 JIT 컴파일러를 통해 객체를 참조하는 모든 위치에 로드 배리어 코드를 인라인으로 직접 삽입함으로써 오버헤드를 최소화합니다. 색상 포인터의 Remapped를 통해 객체의 새 위치에 접근할 수 있도록 도와줍니다. 그리고 자주 액세스되는 객체에 대해서는 결과를 캐싱하여 성능을 최적화하고, 탈출분석 기법을 통해 불필요한 로드 배리어를 제거하고 컴파일러 시점에서 추가적인 최적화를 수행합니다.
JIT 컴파일러에서 사용하는 기법 중 하나입니다. 컴파일러가 각 객체의 사용을 추적하여 탈출 여부를 판단합니다.
다음 상황에 해당된다면 탈출한다고 판단합니다.
만약 탈출하지 않는다고 판단되면 객체를 Heap 대신 Stack에 할당하거나, 불필요한 동기화 작업을 제거하거나, 객체의 속성을 개별 변수로 분해하여 더 효율적인 접근이 가능하도록 합니다. 이 과정을 통해 Heap 에 할당되는 객체가 줄어들고 GC 작업량을 감소시켜 메모리를 효율적으로 사용할 수 있도록 하여 애플리케이션 성능의 향상을 기대할 수 있습니다.
public void exampleMethod() {
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(" World");
String result = sb.toString();
System.out.println(result);
}
이 예시 상황에서 StringBuilder 객체는 반환값이 없고, 지역 변수만을 사용하고, 다른 스레드에서 접근할 수 없기 때문에 탈출 분석을 통해 탈출하지 않는다고 판단됩니다. 따라서 컴파일러는 이 로직에서 객체를 Stack에 할당하거나, 직접 문자열 연산으로 대체하는 등 최적화 과정을 수행할 수 있습니다.
Java 애플리케이션 성능을 최적화 하는 방법 중 최후의 수단 중 하나로 GC튜닝을 시도할 수 있습니다. GC 튜닝은 GC의 동작을 조정해서 애플리케이션의 성능을 최적화합니다. 이는 애플리케이션 특성에 맞게 GC 파라미터를 조정하여 메모리 사용 효율성을 높이고, STW 시간을 줄이며, 전체적인 처리량을 개선하는 것이 목표입니다. GC의 동작을 로그로 남겨서 툴을 통해 분석하는 방법과, Heap 메모리의 초기 크기와 최대 크기를 설정하는 방법 등을 사용할 수 있습니다. 애플리케이션 특성에 맞게 GC 알고리즘을 적절하게 잘 선택하는 것이 큰 영향을 미치며, 사실상 GC 튜닝을 통해 얻고자 하는 가장 중요한 목적은 STW 시간을 줄여서 애플리케이션 운영에 지장을 적게 주도록 하는 데 있습니다. 이 방법이 성능 최적화에서 최후의 수단으로 사용되는 이유는 GC의 성능을 높인다는 말은 GC가 수행해야 하는 작업이 많다는 의미이고, 그 말은 애플리케이션에서 불필요한 객체 생성을 줄이거나, String 대신 StringBuffer를 사용한다던가 하는 애플리케이션 단에서 메모리 사용에 대한 튜닝이 우선돼야 하기 때문입니다.
Java의 GC 성능은 JDK 버전이 업그레이드됨에 따라 지속적으로 개선되어 왔습니다. Oracle, Java 플랫폼 그룹의 Stefan Johansson이 벤치마크한 결과를 통해 성능 비교 자료를 간략하게 소개해드리겠습니다.
처리량 측면에서, 모든 GC가 JDK 버전이 올라감에 따라 개선되었습니다. 특히 Parallel, G1 GC가 크게 개선되었습니다.
지연 시간에서도 JDK 버전이 올라가면 성능이 큰 폭으로 개선되는 것을 볼 수 있습니다.
애플리케이션 상황에 따라 GC의 성능이 달라질 수 있지만, ZGC는 대부분의 상황에서 10ms 미만의 STW 시간을 제공하기 때문에 다른 GC에 비해 현저히 낮은 STW를 제공하는 것을 알 수 있습니다.
Java 애플리케이션 개발에서 GC는 메모리 관리의 핵심적인 부분으로써, 깊은 이해가 필요한 영역입니다. Serial GC부터 최신 기술인 ZGC까지 GC 알고리즘은 애플리케이션의 처리량을 높이거나 지연 시간을 줄이는 방향으로 계속 발전하고 있습니다. 특히 ZGC는 색상 포인터와 로드 배리어 기술을 통해 다양한 환경에서 뛰어난 성능을 일관성있게 제공하고, 10ms 미만으로 STW 시간을 크게 단축하였습니다. 하지만 일부 애플리케이션 환경에서는 처리량을 최우선으로 하거나, 레거시 호환성 및 한정된 리소스에서의 안정성을 위해 Parallel, G1 GC를 선택해야 하는 경우도 있을 것입니다. 개발방법론, 프레임워크 등 개발에서 많은 분야들이 그렇듯이 항상 다양한 기술들에 대한 지식을 깊게 이해하고 전략과 상황에 맞게 사용할 수 있는 역량을 기르는 것이 좋은 개발자로 성장하는 방향일 것입니다.