GC 와 메모리 할당 내부를 자세히 알아야 하는 이유가 무엇일까.
메모리 오버플로 누수 문제를 해결하거나, 높은 동시성을 달성할 때 GC 가 문제가 될때
자동화된 GC 모니터링을 통해 이를 조율할 수 있어야 하기 때문이다.
프로그램 카운터, 가상머신 스택, 네이티브 메서드 스택은 스레드와 함께 생성되고 소멸된다.
또한 스택 영역의 메모리 크기는 클래스가 만들어질 때 결정된다.
따라서 스택 부분은 메모리 할당과 회수에 대해 큰 고민을 하지 않아도 된다.
하지만 힙 또는 메서드 영역은 그 크기와 개수가 런타임에 결정된다. 런타임에 결정되는 인터페이스의 실제 타입에 따라, 입력된 인자가 실행되는 조건 분기에 따라 등등 프로그램이 힙 또는 메서드 영역에 생성하는 객체의 실제 타입, 크기, 개수들이 런타임에 결정된다.
가비지 컬렉터가 하는 가비지 컬렉션은 이렇게 불확실한 힙과 메서드 영역의 메모리 할당과 회수를 해준다. GC 라고 하면 우리는 힙과 메서드 만 생각하면 된다.
카운터를 만들고, 객체를 참조하는 곳이 생기면 1 더한다.
참조하는 곳이 사라지면 1 뺀다.
카운터가 0이면 사용하지 않는 객체라 판단한다.
이 방식은 순환참조시 문제가 있다.
objA.field = objB
objB.field = objA
objA = null
objB = null
이 코드에서 실제 objA, objB 객체 필드는 서로를 참조하고 있어 두 객체 모두 카운트가 1이지만
objA, objB 변수에 들어있던 객체 참조는 해제되어 두 객체 모두 외부에서 접근할 수 없는 쓰레기 객체이지만 서로를 참조하고 있어 정리되지 못하고 메모리 누수가 발생한다.
죽은 객체의 판단이 간단하다는 장점도 있다. 파이썬같은 경우 이러한 순환참조를 감지하고 해결하는 메커니즘을 도입하면서 참조 카운팅 방식을 사용한다.
자바를 포함한 주류 언어에서 쓰는 죽은 객체 판단 방식이다.
도달 가능성 분석은 객체의 참조를 트리처럼 나타낸다.
트리의 루트 노드, 모든 참조의 상위에 있는 객체를 GC Root 라 한다.
그리고 GC Root 로부터 파생되는 참조들을 참조 체인이라 한다.
도달 가능성 분석은 특정 객체가 GC Root 까지 이어져 있는지를 판단한다.
객체의 참조 체인을 따라 갔을때 GC Root 까지 도달할 수 없다면 회수 대상 객체이다.
참조 타입 데이터에 저장된 값이 다른 메모리 조각의 시작 주소를 뜻한다면, 이 참조 데이터를 해당 메모리 조각이나 객체를 참조한다고 말한다.
Object obj = new Object(); 처럼 코드에서 참조를 할당하는 것을 말한다.
강한 참조가 남은 객체는 가비지 컬렉터가 회수하지 않는다.
SoftReference 클래스로 만들 수 있다.
유용하지만 필수는 아닌 객체로, 부드러운 참조만 남은 객체라면 메모리 오버플로가 발생하기 직전 메모리를 회수한다.
WeakReference 클래스로 만들 수 있다.
약한 참조만 남은 객체는 다음 가비지 컬렉션때 회수된다.
가비지 컬렉션은 자주 일어나므로, 강한 참조가 모두 없어지면
PhantomReference 클래스로 만들 수 있다.
객체 수명에 아무런 영향을 주지 않는 가장 약한 참조다.
이 참조의 거의 유일한 목적은 대상 객체가 회수될 때 알림을 받기 위해서다.
사실 Strong Reference 는 우리가 개발하며 자연스레 쓰고 있다.
weak, soft 는 대부분 evict 를 GC 에 맡기는 캐싱을 구현할 때 쓰인다.
하지만 soft 의 경우 남발하면 메모리가 다 차는 시점에 많은 양의 GC 를 해야 해서 성능문제가 생긴다.
weak 의 경우 시스템에 요청이 많아져서 객체를 많이 생성하게 되면 GC 를 많이 하게 되어 캐시 evict 가 더 자주 발생하게 된다. 정작 필요할 때 캐시가 없어버릴 수 있다.
즉 WeakReference, SoftReference 는 캐시로 쓰기에는 evict 가 너무 어려운 GC 에 의존한다는게 문제다. 그냥 redis 나 캐시 시스템을 쓰는게 나아보인다.
반면에 우리가 구현하는게 아니라 라이브러리에 이미 쓰인 곳들은 많다. 우리는 이 내용을 이해하기 위해서 여러 참조 유형에 대해 잘 알고 있어야 한다.
예를 들어 ThreadLocalMap 의 Entry 는 WeakReference 를 참조하고 있다.
ThreadLocal 은 실행되고 있는 스레드 전용 공간이다. ThreadLocalMap 의 Entry 는 ThreadLocal 을 Key 로 하는 Map 처럼 구현이 되어 있다. 스레드 전용 공간이기 때문에, ThreadLocal 은 해당 스레드가 종료되거나 더이상 참조하는 곳이 없다면 메모리 누수를 막기 위해 정리되어야 한다.
만약 Map 의 Key 로 쓰이는 ThreadLocal 이 강한 참조라면, 아무데도 ThreadLocal 을 쓰지 않지만 Map 의 Key 로 참조한다는 이유로 ThreadLocal 이 메모리 회수되지 못한다. 다만, 스레드가 끝난다면 이 또한 문제가 없다. 문제가 되는 상황은 Spring 의 Tomcat 처럼 스레드풀이다. 이런 경우 스레드는 죽지 않고 재사용된다. 만약 강한 참조라면 스레드가 죽지 않고 재사용되며 ThreadLocal 객체가 계속해서 쌓일 것이다. 따라서 WeakReference 로 참조되어 있다.
이와 별개로 ThreadLocal 을 쓸때는 set(null) 이 아닌 remove() 를 호출해주어야 한다. set(null) 은 map 의 value 참조만 해제하고 key 는 해제하지 않는다. 더욱이 ThreadLocal 변수는 개념적으로 객체가 아니라 스레드당 만들어지는 것이므로 인스턴스 변수가 아닌 static 으로 만들게 된다. 애초에 코드상 ThreadLocal 에 대한 강한 참조를 가지고 있어서 사실 WeakReference 는 안전장치 느낌이다. remove() 를 꼭 호출하여 Entry 자체를 정리하는게 좋다.
하다못해 set(null) 이라도 한거면 다행이다. ThreadLocal 의 value 가 객체타입이고 GC 되지 못하는 상황이면 해당 객체타입의 클래스를 로딩하는 클래스로더조차 정리되지 못해 MetaSpace 에도 누수가 생길 수 있다.
또 이와 별개로 ThreadLocal 은 정말 조심해서 사용해야 하는 객체다. 메모리 누수가 발생하기 쉽기에, ThreadLocal 을 사용하는 독립적인 객체로 랩핑하고 사용하는게 좋다. 특정 케이스 에서는 ThreadLocal 에 중첩클래스로 구현한 아주 작은 객체를 값으로 넣었는데 문제가 생겼다고 한다. 중첩 클래스는 this 참조를 암묵적으로 가지고 있고, 이 때문에 작은 객체만을 ThreadLocal 에 넣었다고 생각했으나 엄청난 메모리를 소모했다고 한다.
finalizer() 로 객체의 수명을 조절할 수 있는 방법이 있긴 하다.
도달 가능성 분석으로 GC Root 와의 참조체인을 찾지 못한 객체중 finalizer() 를 실행해야 하는 객체는 F-Queue 로 이동 후 finalizer() 메서드를 실행한 뒤 메모리를 회수한다.
만약 이 finalizer() 구현에서 새로운 참조를 만든다면 GC 되지 않을 수 있다.
하지만 finalizer() 는 JDK9 부터 depreacted 된 상태이다. 코드에서 GC 를 컨트롤하는건 불확실성이 크다.
finalizer 는 없는 기능이라 생각해도 무방하다.
JVM Spec 에는 메서드 영역의 회수를 필수로 지정하지 않았다.
힙 영역은 GC 한번에 70% 이상의 메모리공간을 회수하는 반면, 메서드 영역은 이전의 이름이 Permanent 인 만큼 회수 조건이 까다로워 회수 효율이 높지 않다.
더 이상 사용하지 않는 상수는 GC 시점에 상수풀에서 회수될 수 있다.
String 의 경우 해당 리터럴을 참조하는 String 객체가 없고, 해당 리터럴을 사용하는 코드가 없다하면 회수할 수 있다.
다음 세가지 조건을 동시에 만족하는 클래스는 언로딩(회수) 될 수 있다.
-Xnoclassgc : 클래스 gc 비활성화
-verbose:class [class] : 클래스 로딩/언로딩 정보 로깅
-Xlog:class+load=info -Xlog:class+unload=info : 클래스 로딩/언로딩 로그 나눠서 설정할때
동적 프록시, 리플랙션, CGLIB 과 같은 바이트코드 프레임워크는 동적으로 클래스 정보를 만들어내기 때문에 필요에 따라 클래스 언로딩이 필요하다.
많은 JVM 에서는 생긴지 얼마 안된 객체와 GC 에서 오래 살아남은 객체를 따로의 영역으로 구분해서 관리한다. 위 가설에 의한 설계이다.
영역을 구분된 객체들은 GC 를 구분해서 할 수 있다. 생긴지 얼마 안된 객체 영역은 자주 GC 를 실행하고, 오래 살아남은 객체 영역은 GC 를 덜 실행한다. 이렇게 하면 자주 실행하는 객체 공간을 제한하여 시간/공간 효율적으로 GC 를 진행할 수 있다.

최초의 Serial Garbage Collector 는 이러한 세대 단위 컬렉션 이론을 반영하여 힙의 세대를 위 그림과 같이 구분했다.
G1 과 Parallel 을 제외하면 기본적으로 위와 같은 배열을 가진다. Parallel GC 는 Survivor 가 한개다. 왜 그런지는 이유는 찾지 못했다. G1 GC 는 세대 구분을 사용하긴 하지만 Region 단위의 특수한 가비지 컬렉션을 진행하므로 세대 배치가 위 그람과 다르다.
추가적으로 ZGC, 셰넌도어는 세대 구분을 사용하지 않는 non-generation 으로 시작했으며, 이후 세대 구분 ZGC, 셰넌도어를 제공하기 시작했다.
Young Gen 이 Old Gen 처럼 하나가 아닌 Eden, Survivor 로 나뉜 아키텍쳐는
Mark-Copy와 관련있으며 이 글 뒤에서 설명하겠다.
객체가 생성되면 일단 eden 영역에 생성되며, eden 영역의 GC 에서 살아남으면 S0/S1 라 표기된 Survivor 영역으로 이동한다. GC 가 진행되며 Survivor 영역에서 살아남은 객체는 S0, S1 을 왔다갔다 한다. 그러다 오래 살아남은 객체가 있다면 Old Generation 으로 이동한다.
Minor GC : Young Generation 만 GC.Major GC : Old Generation 만 GC. 단 Old Gen 만 회수하는 Major GC 를 실행하는 Garbage Collector 는 CMS 밖에 없다. 혼합 GC : Young Gen 전체와 Old Gen 일부를 GC. G1 만 지원.Full GC : 전체 Heap 영역 대상 GC.영역별로 객체는 생존 특성이 다르다.
가비지 컬렉터가 가비지 컬렉션하는 방식에는 크게 Mark-Sweep, Mark-Copy, Mark-Compact 방식이 있는데 이 방식을 영역별로 다르게 적용할 수 있다. 예를 들면, 같은 Jvm 시스템에서 Young Gen 은 Mark-Sweep 으로, Old Gen 은 Mark-Compact 로 GC 할 수 있다.
실제로 이전의 GC 들은 Young/Old 영역에 다른 가비지 컬렉터를 적용하였다. 비교적 최신의 가비지 컬렉터들은 Young/Old 구분이 없어지기도 하였지만, 최근에는 또 다시 세대 구분 가비지 컬렉션을 지원하는 컬렉터를 만드는 만큼 이 사실은 알아둬야 한다.
-Xmx3g -Xms1g : 힙 영역 전체의 최소, 최대를 지정한다. 보통 같게 설정한다.-Xmn : Young Generation 영역의 크기를 설정한다. 이 값은 시작 크기이자 최대 크기다.-XX:NewSize : Young Gen 의 init 크기를 지정한다.-XX:MaxNewSize : Young Gen 의 max 크기를 지정한다. NewSize + MaxNewSize = Xmn-XX:NewRatio=N : Young : Old 비율이다. 2라면 Young : Old = 2 :1-XX:SurvivorRatio=N : Young Gen 에서 Eden : S0 : S1 의 비율이다. 8이라면 eden : s0 : s1 = 8 : 1 : 1영역을 나누었다 해서 영역 하나만을 대상으로 GC 를 실행할 순 없다.
Old Gen 의 객체가 Young Gen 의 객체를 참조하고 있다고 가정하자. 하지만 Young Gen 만 보았을때는 객체에 대한 참조가 없다. 이 때 Young Gen 의 객체를 죽이면, Old Gen 의 객체는 문제가 생긴다. 반대도 가능하다.
이처럼 객체는 세대간 참조 가 있을 수 있으므로, 영역 하나만 보고 GC 할 수 없다.
Young Gen 의 객체를 GC 하고 싶어도 결국 Old Gen 의 객체까지 참조체인을 봐야한다.
그렇다면 Full GC 만 하는것일까? 여기서 세번째 가설이 등장한다.
세대간 참조 가설(intergeneration reference hypothesis) : 세대 간 참조의 개수는 같은 세대 안에서의 참조보다 훨씬 적다.
Old Gen 의 객체가 Young Gen 의 참조를 가지고 있다면, Old Gen 은 GC 가 잘 안되므로 시간이 지나면 Young Gen 의 객체 또한 Old Gen 으로 옮겨져 세대 간 참조가 없어진다.
따라서 세대 간 참조의 수는 아주 적을 것이며, 이를 위한 Full GC 는 비용 효율 측면에서 매우 손해다.
이를 위해 기억 집합과 카드테이블이라는 방법으로 적은 양의 세대간 참조를 효율적으로 해결하는데, 이는 다음번에 알아보겠다.
가비지 컬렉션을 하는 방법에는 크게 3가지 방법이 있다.
여러 가비지 컬렉터들은 기반한 가비지 컬렉션 알고리즘에 따라 사용되는 영역이 다르다.
예를 들어 파뉴 컬렉터는 마크-카피 알고리즘 기반이며 Young Gen 영역 컬렉터이다. 반면 Parallel-Old 컬렉터는 마크-컴펙트 알고리즘 기반이며 Old Gen 영역 컬렉터다.
이 두 Collector 를 조합해서 쓸 수 있다.
물론 세대 구분에만 이런 알고리즘이 쓰이는건 아니다. 셰넌도어의 Non-Generation 에서는 Mark-Compact 로 메모리를 회수한다.
여러 컬렉터에 대해서는 다음번에 알아보고, 이번에는 세가지 알고리즘에 대해 알아보자.
기본적인 GC 알고리즘이다.
Root GC 로의 참조체인이 없는 객체를 Mark 한 뒤 메모리를 회수하는 Sweep을 진행한다.
단점은 두가지다.
살아남은 부분의 나머지 를 알아야 하기 때문이다. 메모리 자체의 크기가 커질 수록 실행 효율이 떨어진다.
마크 카피 알고리즘은 마크 스윕의 단점을 보완한다.
mark-sweep 에 비하여 장점은 다음과 같다.
단점은 다음과 같다.
위 언급한 것 처럼 Young Generation 에서는 Mark-Copy 알고리즘이 유용하다.
그럼 Young Generation 을 Garbage Collection 하는 Collector 들은 Young Generation 을 1:1 반반으로 나눠야 할까? 절반을 사용한다는 단점을 보완할 수 없을까.
이 단점을 보완하기 위해서는 약한 세대 가설 중에서도 특히 대부분의 객체는 첫번째 Minor GC 에서 모두 죽는다 라는 가정이 필요하다.
이 가정은 이미 많은 연구에 의해 증명되었다.
위 가정이 사실이라면, 첫 Minor GC 이후 생존 객체를 옮길 공간은 아주 작아도 괜찮다. 이 공간이 Survivor 영역이다.

이러한 과정에서 첫번째 Minor GC 에 의해 처음 Survivor 영역으로 이동하는 객체 는 매우 적은 양 이고 이후 S0 <> S1 을 왔다갔다 할 객체도 적을 것이다.
따라서 Survivor 영역은 크기가 작아도 된다.
HotSpot 에서 -XX:SurvivorRatio=N 옵션을 통해 Survivor 영역의 크기를 지정할 수 있으며 기본값은 Eden : Survivor0 : Survivor1 = 8 : 1 : 1 이다. 단순 1:1 에서 낭비하는 영역이 50% 인 것에 비해 낭비하는 영역이 10% 로 적은 것임을 알 수 있다.
또한 Eden 영역의 크기가 커지게 되고, 이는 객체 할당을 위해 TLAB 를 포함해서 충분한 공간을 사용할 수 있어서 할당 효율도 높아진다.
책에서는 이러한 전략을 Andrew Appel 에 의한 아펠 스타일이라 한다. 이 논문 에서 처음 제안한 것 같은데... 논문 너무 어려워.
위의 Eden-Survivor 구조는 Young Generation 객체들이 첫번째 Minor GC 시 대부분의 객체가 죽음을 전제로 한다.
하지만 모든 경우 그렇지 않다. Survivor 를 작게 잡았는데 실제로 해보니 Survivor 를 초과한 객체가 살아남는 경우도 충분히 있을 수 있다.
이런 경우를 대비해 Minor GC 에서 살아남은 생존자 객체를 Survivor 가 다 수용하지 못할 경우 Old Gen 으로 일부 이동시킬 수 있으며, 이를 Handle Promte 라 한다.
마크-카피 알고리즘은 생존 객체가 많을수록 복사 비용이 커져 비효율적이라 Old Gen 을 정리하는 알고리즘으로는 적합하지 않다.
또한 Old Gen 에서는 객체가 잘 죽지 않아 마크-카피 알고리즘에서 GC 를 진행해도 남는 공간이 많이 생기지 않으며, 이 때 Young Gen 으로부터 이동된 객체도 있을 것이기에 핸들 승격 공간에 대해서도 따로 마련해야 한다.
Old Gen 에서 마크-스윕의 단편화를 해결하는 방법이 마크-컴팩트 알고리즘이다.

여러 블로그([1], [2]..) 에서 mark-sweep-compact 를 언급한다. 하지만 내가 찾아봤을땐 mark-sweep-compact 라는 알고리즘을 Oracle 이나 jdk 문서에서 언급한 곳은 없었다. compact 시점에 살아있는 객체를 죽은 객체 자리에 이동시켜야 한다면 그냥 덮어쓰면 되는 것이기 때문에 굳이 sweep 을 해야하나 싶기도 하다. 따라서 나는 mark-sweep-compact 라는건 일단 없다고 생각 할 것이다.
살아남는 객체가 많은 Old Gen 에서는 2, 3 의 과정이 오래걸릴 수 있다.
더욱이, 객체를 이동하고 참조를 수정하는 작업은 유저 애플리케이션의 동작이 멈춘 상태에서 일어나야 한다. 유저의 잘못된 참조를 막기 위해서다.
유저 애플리케이션의 동작이 멈춘 상태에서 이러한 과정이 진행되는 것을 Stop The World 라 하며 긴 시간이 소요되는 작업이다.
위에서 언급한 대로 Mark-Compact 에는 Stop The World 라는 명확한 단점이 있다.
따라서 살아남는 객체가 많은 Old Gen 에서는 사용하기 부담스러울 수 있다.
하지만 그렇다고 살아남은 객체를 이동시키지 않는다면 Mark-Sweep 의 단점인 파편화가 발생한다.
두 방식의 기준은 처리량과 지연시간