[Java] (번역)Java Garbage Collector에 대해 알아야 할 모든 것
Updated:
👩🏻💻 Java Garbage Collector의 작동 매커니즘에 대해 살펴봅니다.
이 글은 해외 아티클을 번역한 글입니다. 따라서 문장이 매끄럽지 않은 부분이 있을 수 있습니다. 원본 : Everything You Need to Know About the Java Garbage Collector
Java Garbage Collector는 가장 진보된 garbage collector 중에 하나입니다. 다른 언어들 중에는 이처럼 정교한 컬렉터를 제공하는 것이 거의 없습니다. 이런 성과는 지속적인 개선과 혁신의 20년 이상의 결과입니다. 이 글에서는 그 작동 메커니즘을 깊이 있게 살펴보겠습니다.
Java Memory Model
RAM 메모리는 하드 드라이브 메모리와 비교했을 때 괄목할 만큼 빠르지만, 이 메모리는 매우 제한적이며 다시 시작하면 손실이 발생합니다.
매우 간단히 설명하면 RAM은 격자를 형성하는 셀로 나뉩니다. 각 셀은 0 또는 1이 될 수 있으며, 바이너리로 인코딩된 데이터를 저장합니다. 현실에서 이 격자는 거대합니다. 1 기가바이트는 8,589,934,592 비트입니다.
프로세스의 수명 동안, 해당 프로세스는 사용하는 데이터를 인코딩하기 위해 비트를 조작할 수 있습니다. 여기에는 셀의 메모리 주소에 액세스하여 값을 설정하는 작업이 포함되며, 이는 운영 체제에 대한 호출을 수반할 수 있는 저수준의 작업입니다.
새로운 Java 프로세스가 시작되면, 일정량의 RAM을 할당하고 스스로를 위해 예약합니다. 할당하는 RAM의 양은 Java 가상 머신(JVM)에 의해 결정됩니다. JVM은 이 할당을 동적으로 관리하며 런타임 인수에 의해 제어할 수 있습니다.
Java는 가지고 있는 RAM을 두 가지 주요 공간으로 나눕니다. 하나는 스택 공간이고, 다른 하나는 힙입니다.
스택 공간
스택은 일반적으로 64KB로 제한되는 작은 크기이며, 객체와 기본형(primitives) 대한 참조를 저장하는 용도로만 사용됩니다. primitives는 int, long, double, boolean, char, float, byte와 같은 기본 유형을 나타냅니다. wrapper 유형은 primitives가 아니므로 이를 Integer와 같은 wrapper 유형과 혼동하지 않는 것이 중요합니다.
스택 메모리 세그먼트( stack memory segment)는 동일한 이름을 가진 데이터 구조인 스택(Stack)에서 이름을 따온 것으로, 변수를 후입선출(LIFO) 방식으로 초기화하고 역순으로 해제하기 때문입니다. 할당 해제는 닫는 중괄호에 도달되거나 범위의 끝에 도달할 때마다 발생합니다. 코드베이스의 각 스코프는 하위 스택으로 볼 수 있으며, 메인 메서드가 종료되면 메인 스택에서 역순으로 할당 해제됩니다. 스택 공간은 매우 효율적이고 빠르며 삭제 및 생성 측면에서 오버헤드가 거의 발생하지 않습니다. 이러한 효율성은 스택 할당과 해제에 간단한 포인터 조작이 수반한다는 점에서 비롯됩니다.
힙 공간
힙은 new 키워드를 사용할 때마다 사용됩니다. 이는 새 객체에 대한 메모리를 할당하도록 JVM에 지시합니다.
Java에서 새 객체를 만들면 두 가지 일이 일어납니다. 먼저 객체의 메모리 주소를 저장하는 참조 변수가 스택 공간에 생성됩니다. 그런 다음 객체 자체가 힙 공간에 생성됩니다. 마지막으로 스택의 참조 변수가 힙의 객체에 연결됩니다. Java에서 객체에 대한 참조는 일반적으로 크기가 고정적이며, JVM의 경우 32비트, 64비트입니다. 그러나 객체 자체의 크기는 매우 작은 것부터 기가바이트까지 커질 수 있으며, 특히 배열이나 복잡한 데이터 구조의 경우 더욱 그렇습니다. 힙 할당에는 동적 메모리 할당이 포함되며, JVM은 객체를 수용하기 위해 적절한 크기의 메모리 블록을 검색하고 할당해야 합니다. 따라서 힙 할당은 스택 할당보다 훨씬 느립니다.
Garbage Collection
Java에는 삭제 키워드가 없습니다. 할당된 객체에 대한 메모리를 직접 해제할 수 없습니다. 또한 객체가 범위를 벗어날 때 호출되는 소멸자라는 개념도 없습니다. Java는 메모리를 관리하고 힙을 깔끔하게 유지하기 위해 가비지 컬렉터(GC)에 전적으로 의존합니다.
가비지 컬렉터는 힙의 모든 객체를 추적하는 스레드입니다. 주기적으로 실행되어 수집 대상에 해당하는 개체를 삭제합니다. 간단히 말해, 스택에서 더 이상 도달할 수 없는 개체는 수거 대상이 됩니다. 힙의 다른 객체에 의해서만 참조되는 객체는 여전히 수집 대상입니다. GC 간격은 구성할 수 없으며, JVM에 힌트만 제공할 수 있습니다. 각 GC 스캔을 사이클(GC 주기)이라고 합니다.
전체 힙을 스캔하는 것은 리소스 집약적인 프로세스이며, 이전 버전의 가비지 컬렉터에서는 매 주기마다 이 작업을 수행했습니다. 이러한 작업을 수행하는 동안 JVM이 최대 10초 동안 정지되기도 했습니다. 이는 중요한 서비스에서는 용납할 수 없는 일이었습니다. 따라서 Java 팀은 정지 시간을 최소화하기 위한 현명한 접근 방식을 고안했습니다. 최신 GC는 각 GC 사이클에서 0.5밀리초 미만 동안 VM을 정지시키지만, 1TB 힙 공간의 경우 이 시간은 최대 1ms까지 늘어날 수 있습니다. 자동 메모리 관리와 본질적으로 메모리 누수가 없는 코드를 비롯한 이점을 고려하면 이 성과는 매우 인상적입니다.
이를 달성하는 방법은 힙을 하위 공간으로 더 분할하는 것입니다. 가장 중요한 하위 공간은 다음과 같습니다.
Eden Space : 모든 새로운 오브젝트가 생성되는 곳
Survivor Space : 오브젝트가 첫 번째 GC 사이클에서 살아남으면 이곳으로 이동합니다.
Old Generation Space : 오브젝트는 여러 사이클에서 살아남으면 이곳으로 이동합니다. 이 세대에 도달하는 데 필요한 사이클 수는 힙 크기, 할당률, GC 버전 등의 요인에 따라 동적으로 결정됩니다.
세 개의 공간은 꼭 같지는 않지만 그런 문제는 신경쓰지 않아도 됩니다. 프로그램이 실행되는 동안 새로운 객체들이 생성됩니다. 이들은 모두 eden space로 들어갑니다.
Eden space가 가득 차거나 거의 가득 차면 minor GC 사이클이 트리거됩니다. 이 주기는 Eden space과 Survivor Space만 스캔하고 두 공간에서 수집할 수 있는 물체를 제거하며 살아남은 객체들은 Eden space에서 Survivor Space로 이동시킵니다.
특히 객체 사망률이 높다고 가정할 때 이 주기를 최적화할 수 있습니다. 힙의 일부분만 스캔하지만 여전히 stop-the-world가 동작합니다. 이 프로세스 동안 모든 스레드는 일시 중단되고 GC가 완료될 때까지 기다립니다. 작은 GC 사이클이 끝날 때마다 Eden space 완전히 비워집니다.
다음 minor GC 사이클에서는 Eden space와 Survivor Space 모두에서 생존 객체를 찾아내고 다시 오른쪽으로 이동시킵니다. old space는 전혀 스캔하지 않습니다. 이렇게 함으로써 CPU 시간을 많이 절약할 수 있으므로 수집이 매우 효율적인 이유 중 하나입니다.
Old heap space는 생존자가 여러 주기 동안 살아남은 후에 채워지기 시작합니다. 임계값은 동적이며 구성 및 GC 버전에 따라 달라집니다. 오브젝트가 가장 오른쪽 공간으로 이동하면 주요 GC 주기 동안에만 스캔됩니다. 주요 주기는 메모리가 부족하여 많은 메모리를 확보해야 한다고 JVM이 판단하는 경우에만 트리거됩니다. 이러한 주기는 느리고 정지 시간이 상당히 길어집니다. 이러한 빈도는 JVM의 마법의 일부이므로 미세 조정을 시도할 수 있지만, 대부분의 경우 JVM은 이를 최소화하는 데 능숙합니다.
실제로 Survivor Space에는 S1과 S2라는 두 개의 하위 공간이 있습니다. Eden, S1, S2는 동시에 스캔되며, 각 스캔은 사페이스의 사망률에 최적화되어 있습니다. 오른쪽으로 갈수록 더 적은 수의 스캔을 수행해야 합니다.
GC 활동만 봐도 Java 애플리케이션에 대해 많은 것을 알 수 있습니다. JVM이 할당하는 RAM을 힙 크기라고 하며, 사용된 힙은 현재 사용 중인 힙의 양을 나타냅니다.
이 JVM은 건강하고 정상적으로 작동하는 것으로 간주할 수 있습니다. GC 주기는 1분 이상 간격이 있으므로 메모리가 부족하지 않으며 할당된 150MB 내에서 편안하게 사용할 수 있습니다. 힙 크기가 증가하지 않습니다.
이것은 거의 매초마다 GC 주기가 있습니다. 이는 eden space가 빠르게 채워지고 있음을 나타냅니다. 새로운 오브젝트가 대량으로 생성되고 있으며 빠르게 수집할 수 있는 상태가 됩니다. 아마도 오브젝트를 재사용하기 시작해야 할 것 같습니다.
이 객체는 메모리를 기하급수적으로 늘리고 있습니다. 이는 수집할 수 없는 객체를 생성하고 있음을 나타냅니다. 이러한 객체는 일반적으로 시간이 지남에 따라 계속 증가하는 List와 같은 데이터 구조입니다. 이러한 패턴은 일반적으로 애플리케이션이 스트레스를 받을 때 나타납니다.
결론
GC의 작동 원리를 이해하려고 노력하던 시절에 필요한 글을 쓰려고 노력했습니다. 도움이 되었기를 바랍니다. 제가 놓친 부분이 있거나 오류가 있으면 댓글로 알려주세요.
읽어주셔서 감사합니다!
출처
Everything You Need to Know About the Java Garbage Collector