[JVM] JVM 내에서 카드테이블과 사전장벽, 사후장벽이란?

2026. 1. 1. 20:53·CS/JVM

기억집합 (카드테이블)

https://dev-dx2d2y-log.tistory.com/162

 

[JVM] GC의 객체회수과정은 어떻게 일어나는가? - 마크-스윕, 마크-카피, 마크-컴팩트

https://dev-dx2d2y-log.tistory.com/163 [Java] JVM 끝까지 파헤치기 독서 #5 - 가비지 컬렉터의 알고리즘 이론https://dev-dx2d2y-log.tistory.com/138 [CS] JVM 밑바닥까지 파헤치기 독서 #1 - 자바 런타임 메모리 영역C와 C+

dev-dx2d2y-log.tistory.com

[JVM] GC의 객체회수과정은 어떻게 일어나는가? - 마크-스윕, 마크-카피, 마크-컴팩트

힙 메모리 영역에서 구세대의 GC루트를 전부 스캔하는 것을 방지하고자 구세대와 신세대 간 참조관계를 기억해두기 위해서 기억집합을 사용한다고했다. 

 

기억집합이란 구세대에서 신세대를 가리키는 포인터들을 기록하는 추상적 데이터 구조다.

class RememberedSet {
    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

//위 코드는 의사코드입니다.

가장 기본적인 기억집합을 나타내자면 단순히 신세대에서 구세대로 가는 포인터들의 개수만큼의 크기를 가지는 Object 배열로 나타낼 수 있을 것이다. 이 방식도 가능은하지만, 우리는 그 포인터가 무엇을 가리키는지, 무엇이 가리키는지는 몰라도된다. 단지 구세대 특정 영역이 신세대를 참조하고 있는지만을 알면된다. 그래서 몇 가지 더 나은 선택지들이 있다.

 

카드 정밀도

레코드 하나가 메모리 블록 하나에 매핑된다.

여기서 레코드란 관리용 메타데이터를 나타내는 주로 1바이트 또는 1비트의 공간을 나타내고, 메모리 블록이란 힙 메모리의 특정 일부분을 나타낸다. 즉, 특정 레코드가 마킹되어 있다면, 해당 메모리 블록에 세대 간 참조를 지닌 객체가 존재한다.

 

객체 정밀도

레코드 하나가 객체 하나에 매핑된다. 특정 레코드가 마킹되면, 해당 객체에 세대 간 참조가 있는 필드가 존재한다.

 

워드 정밀도

레코드 하나가 메모리의 워드 하나에 매핑된다. '워드'란 CPU가 처리하는 메모리의 크기를 뜻한다. 32비트 기기에서는 32비트, 64비트 기기에서는 64비트가 워드이 길이가된다. 특정 레코드가 마킹되어있다면, 해당 메모리 워드가 세대 간 포인터다.

 

첫 번째 방식인 카드 정밀도가 가장 널리 쓰이는 방식이며, 카드 정밀도로 기억집합을 구현한 것을 카드 테이블이라고 칭한다. 기억집합은 추상적인 데이터구조, 그것을 구체화한 것이 카드 테이블이다.

 

 카드 테이블을 구현하는 가장 간단한 형태는 바이트배열이다. (현대 컴퓨터의 최소처리속도는 주로 바이트다. 비트만을 따로 다루려면 몇 가지 명령어가 추가로 필요한데, 이러면 바이트배열보다 처리속도가 낮다.) 그리고 이 바이트배열에는 세대 간 참조가 일어나는지 확인하는 바이트가 위치한다.

 

 

좀 더 자세히 알아보자면

카드 정밀도에서 메모리 블럭 한 개는 보통 2의 n제곱 바이트의 크기를 가지며, 핫스팟 가상머신에서는 512바이트의 크기를 가진다. 그리고 이 메모리 블럭 하나를 카드 페이지라 칭한다. 즉, 힙메모리 전체를 512바이트의 카드 페이지 여러 개로 나눈다.

 

byte[] CARD_TABLE[ HEAP_SIZE / CARD_PAGE_COUNT ]
//의사코드

이렇게되면 카드페이지의 수만큼의 길이를 가지는 바이트배열이 카드테이블로 지정된다.

카드 테이블의 각 요소들은 카드 페이지를 가리킨다.

만약 카드테이블로 관리하는 메모리의 시작주소가 0x0000이라면, 카드테이블의 원소 0, 1, 2는 각각 메모리주소 0x0000~0x01FF (0~511), 0x200~0x03FF(512~1023), 0400~0x5FF(1024~1535)에 대응한다.

 

만약 특정 메모리에서 세대 간 참조가 발생한 경우

CARD_TABLE[this address >> 9] = 1;

로 표시한다. >> n 연산자는 2의 n승으로 나누는 것과 같은 결과를 갖는다. 즉, 주소를 512로 나눈 것과 같은 값을 갖는다. 그리고 그 값은 카드테이블에서 해당 메모리가 속한 카드페이지의 요소의 번호와 대응한다. 그래서 이 값을 통해 카드테이블에서 원하는 카드페이지에 대응되는 요소에 접근하여 그 값을 1로 바꿀 수 있다.

 

이처럼 카드페이지에 있는 객체에서 세대 간 참조가 일어나고있다면 그 카드 페이지에 대응되는 카드테이블의 요소를 1로 바꾼다. 이 때 그 요소는 '더럽혀졌다'고 표현한다. 객체 회수 시에는 더럽혀진 요소만 확인하면되므로 구세대 전체를 스캔하는 것보다 어느 메모리에서 세대 간 참조가 일어나는지 쉽게 알 수 있다.

 

여기서 세대 간 참조가 이루어지는 구세대 객체는 GC루트에 추가된다.

 

카드테이블은 주로 구세대 객체들이 신세대 객체를 참조하기 시작할 때 기록된다. 그리고 나중에 신세대 객체가 구세대로 승격되면 신세대 객체가 구세대로 복사되고, 기존 구세대 객체에 저장된 참조값이 변경된다.

다만 카드테이블은 다시 재정립할 필요가 없는데, GC 도중에 dirty 처리된 카드테이블을 기억한 다음에 GC를 진행한 후 dirty 처리된 카드페이지에 접근해서 세대 간 참조 여부를 다시 확인한 다음에 dirty 처리하면서 새로이 카드테이블을 처리하기 시작한다.


쓰기 장벽

좋다. 이제는 기억집합을 어떻게 구현하는지에 대해서 알게되었다. GC루트의 스캔범위를 줄이는데 성공했다. 그런데 문제는 "언제 기록하는가?", 그러니까 "세대 간 참조가 일어나는 경우 해당 카드테이블을 어떻게 갱신하는가?"에 있다.

 

가상머신에서 바이트코드를 해석할 때에는 문제가되지 않는다. 그냥 바이트코드 몇 줄을 추가하면 되니까

진짜 문제는 JIT컴파일된 뒤의 코드다. 컴파일 후에는 순수한 기계어가 남는데, 여기에 개입해서 대입 연산 시 카드테이블을 갱신하려면 기계어로 코딩해야한다.


읽기 장벽

쓰기 장벽에 대해서 알아보기 전에 읽기 장벽이란 것도 있는데, 읽기 장벽은 동시 비순차 실행 (concurrent out-of-order execution) 문제를 해결하기 위한 메모리 장벽 기술이다.

 

말이 어려운데,

'비순차 실행'이란 컴파일러나 CPU의 최적화과정에서 명령어의 실행순서가 바뀌는 경우를 비순차 실행이라고 한다. 즉, 이 비순차 실행 문제를 해결하기 위해 사용되는 메모리 장벽을 사용하는 기술을 읽기 장벽이라고 칭한다.


쓰기장벽이 뭔가요?

반면 쓰기 장벽은 '참조 타입 필드 대입'시에 끼어는 AOP 어스팩트(aspect)에 비유할 수 있다.

 

우선은 AOP와 어스팩트라는 것부터 모르기 때문에 (그리고 상당히 어렵기 때문에).. 간단하게 알아보면

AOP(관점 지향 프로그래밍)에서는 '어라운드 어드바이스'라는 개념이 존재하는데, 이는 대상이 실행되기 직전과 직후에 추가적인 작업을 진행할 수 있도록해주는 역할을 한다. 깊게까지는 알아보지 않을 것이지만 대충 바이트코드에서 메서드 호출 부분을 가로채서 원하는 작업을 진행한 뒤에 어드바이서가 기존 메서드를 호출하는 방식으로 진행된다.

 

JVM 내부에서는 쓰기 장벽이 이와 비슷한 역할을 수행한다. 대입 전 쓰기 장벽을 사전, 대입 후 쓰기 장벽을 사후 쓰기장벽이라고 칭한다.

void oop_field_store(oop* field, oop new_value) {
    *field = new_value;		//대입
    post_write_barrier(field, new_value);	//카드 테이블 갱신
}
//간소화된 예시

위처럼 세대 간 참조의 대입이 이뤄지는 곳에서 대입이 이루어지고, 카드테이블을 갱신하는 것을 하나의 메서드로 처리한다. 그러니까, 대입만 하는 것이 아니라, 대입하기 전이나 후에 카드 테이블을 갱신하는 작업을 같이 수행하게되는 것이다. 마치 어라운드 어드바이스가 한 역할을 수행하기 전이나 후에 다른 역할을 끼워넣는 것처럼.

 

위의 예시는 사후 쓰기 장벽에 대한 예시로, 요즘의 컬렉터들은 대부분 사전 쓰기 장벽을 이용한다.


사후장벽 vs 사전장벽

예전에는 가비지컬렉션이 시작되면 모든 스레드들이 정지해서 사후장벽을 사용해도 별 문제가 없었으나 G1 컬렉터 등장 이후 사용자스레드와 동시에 돌아가는 가비지컬렉터가 등장하면서 가비지컬렉터가 참조관계를 추적하는 도중에 참조관계가 변경될 수 있게되었다.

 

SATB(이러한 환경에서 구동되는 가비지컬렉터)의 특징은 "GC 시작 시점에서 살아있는 객체는 이번 GC에서는 살아있다고 친다."라는 것이다. 즉, GC 시작 후에 참조해제된 객체들이 있더라도 그 객체들은 이번 GC에서는 회수대상으로 삼지 않는다는 뜻. 왜냐하면 사용자 스레드와 GC가 동시에 실행되면 사용자스레드가 지속적으로 참조를 바꾸기 때문에 이 참조를 실시간으로 반영하려면 영원히 GC가 실행되지 못한다. 그래서 "GC 시작시점에서 살아있는 객체들은 이번 GC에서는 살아있다고 칠 것."으로 가정한다.

 

 


SATB에서의 사후장벽과 사전장벽의 차이, 그리고 그 본질

사후장벽, 그 중에서도 객체의 해제과정에 집중해보면..

사후장벽에서 객체 참조를 해제하면 객체 참조를 해제하고 끝낸다. 따라서 이 부분에서는 SATB의 주된 특징인 "GC 시작 시점에서 살아있는 객체는 이번 GC에서는 살아있다고친다."를 살리지 못한다. 어떤 객체가 사라졌는지 관심이 없기 때문.

 

그럼에도 불구하고 이론적인 방법이 하나 있는데, GC가 아직 확인하지 못한 객체에서 참조해제가 일어났다면 그 변경사항을 GC에게 실시간으로 알리는 것이다. 여기서 의문이 생기는데,

 

- 그냥 변경사항을 실시간으로 GC에게 알리지말고 나중에 GC가 바뀐 값을 기준으로 하나씩 처리하면 되지 않나?

-> 이것이야말로 위험한 판단인데, 아직 확인하지 않은 객체에서 특정 객체의 참조가 해제되고, 이미 확인한 객체에서 이전에 참조가 해제된 객체를 참조하기 시작됐다면, GC는 이를 반영하지 못하고 이 '특정 객체'의 메모리 영역을 회수하는, 그러니까 살아있는 객체를 죽이는 동시성 문제가 발생한다.

 

그렇다면 매번 실시간으로 참조해제된 객체를 파악해야하는데, 그럼 그걸 실시간으로 파악하느라 GC가 돌아가지 못한다. 실패!

 

 

 

반면 사전장벽에서 객체 참조를 해제하면 객체 참조를 해제하고 어떤 객체가 해제되었는지 따로 기록해둔다. 이를 통해서 SATB의 특징을 살릴 수 있다.

 

그럼 여기서 의문점 하나 더.

"사후장벽에서는 왜 어떤 객체가 해제되었는지 따로 기록해두지 않는가?"라는 질문이 생긴다. 여기서 사후장벽과 사전장벽의 본질적인 구분 기준이 드러나는데, 사실 사전장벽과 사후장벽을 구분짓는 본질적인 기준은 "객체 참조가 해제된 경우, 어떤 객체가 해제되었는지 따로 기록한다."이다. 즉, 아무리 사후장벽에서 어떤 객체가 해제되었는지 기록해둔다면, 이는 "사후장벽의 탈을 쓴 사전장벽" 이라고 칭한다.

 

GPT에 따르면 ""사전 / 사후"라는 이름이 사고를 심각하게 오염시켜 왔다"라고한다.


암튼 이 쓰기장벽을 적용하면 가상머신은 추가로 실행할 명령어 (구세대에서 참조가 생겨났으면 카드 테이블에 이를 반영하고, 사전장벽의 경우 객체참조가 해제된 경우 이를 기록해두는 것)를 추가한다. 물론 이렇게 몇 가지 연산을 더 수행해봤자 구세대 전체를 스캔하는 것보다는 효율적이다.


거짓공유(false sharing)

낮은 수준에서 동시성을 다룰 때 고려해야하는 문제로, 현대적인 CPU 캐시 시스템은 데이터를 캐시라인 단위로 관리한다. 캐시는 CPU에서 사용하는 메모리의 크기보다 아주 작아서 여러 개의 캐시들을 하나로 묶은 '캐시라인'으로 캐시를 관리한다.

 

문제는 여러 스레드가 서로 다른 변수를 수정하는 과정에서 우연히도 같은 캐시라인에 변수들이 저장되어있다면 스레드들이 서로의 변수에 영향을 주어 성능을 떨어뜨릴 수 있다. 실제로는 공유하고 있지 않지만 마치 공유하는 것처럼 서로 영향을 준다고하여 거짓 공유라고 칭한다.

 

만약 스레드A가 캐시라인에서 값을 수정한 후, 스레드B가 우연히도 A과 같은 캐시라인에 접근해서 값을 수정한 경우, A가 수정한 캐시라인은 무효화된다. 그러면 A는 캐시가 무효화되었으므로 직접 메모리까지 가서 값을 가져와야한다. 즉, 캐시의 이점을 하나도 살리지 못하고 성능만 저하된다.

 

이러한 거짓공유문제는 카드테이블에서도 발생할 수 있는데, 카드테이블은 힙메모리를 512바이트 (핫스팟 가상머신에서) 를 가진 카드페이지 여러 개로 나눈 후, 카드테이블의 각 요소에 각 카드페이지를 매핑시킨다.

만약 카드테이블의 18번째 요소에 dirty 체크를 하고, 17번째 요소에 또 체크를하고, 18번째 요소에서 다시 세대 간 참조가 발생해 dirty 체크를하려고할 때, 만약 둘이 같은 캐시라인에 캐시를 저장 중이라면 17번째 요소의 캐시값이 무효화되고 거짓 공유문제가 발생한다.

 

이러한 거짓공유문제는 쓰기장벽을 카드테이블을 확인하여 원소가 더렵혀지지 않았을 때만 더럽히는 방식으로 거짓공유 문제를 해결할 수 있다.

 

if (CARD_TABLE [this address >> 9] != 1)
    CARD_TABLE [this address >> 9] = 1;

이렇게. 이미 dirtry 체크가 되어있다면 굳이 또 변경을 수행하지 않아 거짓공유를 방지한다.

이러면 "17번재 요소에 체크하는 과정에서도 거짓공유문제가 발생할 수 있지 않나?"라는 의문이 드는데, 이 과정이 필수적인 로직인데다 조건부 카드 테이블 갱신을 통해서 단 한 번만 일어날 로직이므로 한 번 정도는 눈감아주는 편이다.


이번에는 기억집합과 카드테이블의 요소에 대해서 정의해봤다.

 

0. JVM은 힙메모리를 특정크기(주로 2의 N승 바이트, 핫스팟에서는 512바이트)로 이루어진 여러 부분공간으로 나눈다. 그리고 '카드테이블'의 각 요소에 쪼개진 부분공간을 각각 매핑시킨다. 부분공간 하나를 카드페이지라고 한다.

 

1. 구세대에서 신세대로의 참조가 발생한 경우 힙메모리의 어느 카드페이지에서 세대 간 참조가 발생했는지 확인하고 카드테이블에서 해당 카드페이지에 매핑되는 카드테이블에 dirty 표시를 한다.

 

2. 이후 GC가 시작되면 가비지컬렉터는 GC루트를 파악하면서 카드테이블에 dirty 표시된 부분에 매핑되는 카드페이지를 확인해 세대 간 참조가 진행 중인 구세대 객체를 GC루트로 추가한다. 그리고 각 dirty 표시된 카드페이지를 기억해둔다.

 

3. GC가 끝나고, 신세대 객체 일부가 구세대 객체로 승격된다. 승격되면 객체는 힙메모리의 구세대영역으로 복사되고, 기존에 해당 객체를 참조하던 구세대 객체의 참조값도 수정된다.

 

4. 3번까지의 과정이 완전히 끝나면 카드테이블의 값을 0으로 초기화시킨다. 그리고 기억해둔 dirty 표시된 카드페이지들에 접근해 여전히 세대 간 참조가 일어나고 있는지 확인한다. 여전히 세대 간 참조가 일어나고 있는 경우 다시 해당 카드테이블에 dirty 표시를한다. 신세대 객체가 구세대 객체로 승격되어 더 이상 세대 간 참조가 이루어지지 않은 카드테이블은 dirty 표시를 하지 않는다.

 

의 과정을 통해서 기억집합과 카드테이블이 실행된다.


한편, 가비지컬렉터가 모든 사용자스레드를 멈춰두고 진행하던 것에서 다른 사용자스레드와 병렬로 처리될 수 있게되면서 가비지컬렉터가 실행되는 도중에 객체의 참조관계가 변하는 경우도 고려해야한다. 모든 객체 참조가 변하는 경우를 실시간으로 반영하면 GC가 시작될 수 없으므로 GC는 "GC가 시작할 때 살아있던 객체들은 GC 도중에 객체참조가 해제되어도 이번 GC에서는 메모리를 회수하지 않는다."라는 조건이 추가되었다.

 

이를 위해서 GC가 시작된 후 참조가 해제된 객체들이 어떤 객체인지 참조가 해제될 때 기록해놔야한다. 현대의 GC는 대부분 이런 방식을 채택한다. 이 방식은 사전쓰기장벽이라 칭한다. 반대로 기록해두지 않으면 사후쓰기장벽이라 칭한다. 과거 가비지컬렉터는 모든 스레드를 멈춰뒀기 때문에 객체 관계의 변동이 발생하지 않아 굳이 참조해제된 객체를 기록하지 않아도 문제가 없었지만, 현재는 사전쓰기가 필수적이다.


음.. 책에서도 상당히 벗어난 내용이고 다른 블로그에서도 잘 정의되지 않은 내용들이라 GPT와 제미나이 붙잡고 겨우겨우 작성해냈다. 그래도 상당히 재밌다. 솔직히 2~3시간동안 GPT와 제미나이 괴롭히다가 사전장벽과 사후장벽의 본질적인 차이를 알아냈을 때는ㄷㄷ..

 

https://www.oracle.com/technical-resources/articles/java/g1gc.html

 

Garbage First Garbage Collector Tuning

Learn about how to adapt and tune the G1 GC for evaluation, analysis and performance. The Garbage First Garbage Collector (G1 GC) is the low-pause, server-style generational garbage collector for Java HotSpot VM. The G1 GC uses concurrent and parallel phas

www.oracle.com

추가로 오라클 공식 문서에서 이와 비슷한 내용을 찾을 수 있었다.

 

AI를 지양하려고해도 국내 블로그 글에서는 이를 다루고 있는 경우가 적다. 꼬리에 꼬리를 무는 "왜?"를 통해 본질적인 순수한 기술에 접근하는 것이 아니라 책에 정의된 내용만 단순히 적고 넘어가는 경우가 부지기수였다. 이런 내용에서, 그리고 앞으로 더욱 심화될 내용에서도 AI를 좀 검색도구로 사용해야할 듯하다.

'CS > JVM' 카테고리의 다른 글

[JVM] JVM 끝까지 파헤치기 독서 #6 - 가비지컬렉션 심화탐구  (1) 2026.01.05
[JVM] 객체를 언제, 어떻게 표시할 것인가?  (0) 2026.01.05
[JVM] 가비지컬렉터는 어디에서 실행되어야하는가? - 안전지점(Safe Region)  (0) 2025.12.27
[JVM] OopMap으로 참조체인 내 속한 객체 찾기  (0) 2025.12.27
[JVM] JVM 끝까지 파헤치기 독서 #5 - 가비지 컬렉터의 알고리즘 이론  (0) 2025.12.26
'CS/JVM' 카테고리의 다른 글
  • [JVM] JVM 끝까지 파헤치기 독서 #6 - 가비지컬렉션 심화탐구
  • [JVM] 객체를 언제, 어떻게 표시할 것인가?
  • [JVM] 가비지컬렉터는 어디에서 실행되어야하는가? - 안전지점(Safe Region)
  • [JVM] OopMap으로 참조체인 내 속한 객체 찾기
Radiata
Radiata
개발을 합니다.
  • Radiata
    DDD
    Radiata
  • 전체
    오늘
    어제
    • 분류 전체보기 (211) N
      • 신년사 (3)
        • 2025년 (2)
        • 2026년 (1)
      • CS (59) N
        • JVM (12)
        • 백엔드 (20) N
        • 언어구현 (1)
        • 객체지향 (1)
        • 논리회로 (5)
        • 컴퓨터구조 (9)
        • 데이터베이스 (1)
        • 컴퓨터 네트워크 (10)
      • 언어공부 (64)
        • Java | Kotlin (48)
        • JavaScript | TypeScript (9)
        • C | C++ (6)
      • 개인 프로젝트 (11)
        • [2025] Happy2SendingMails (3)
        • [2026] 골든리포트! (8)
        • [2026] 순수자바로 개발하기 (0)
        • 기타 이것저것 (0)
      • 팀 프로젝트 (29)
        • [2025][GDG]홍대 맛집 아카이빙 프로젝트 (29)
      • 알고리즘 (13)
        • 백준풀이기록 (11)
      • 놀이터 (0)
      • 에러 수정일지 (2)
      • 고찰 (24)
        • CEOS 23기 회고록 (2)
  • 블로그 메뉴

    • CS
    • 언어공부
    • 개인 프로젝트
    • 팀 프로젝트
    • 알고리즘
    • 고찰
    • 신년사
    • 컬러잇 개발블로그
  • 링크

    • 컬러잇 개발블로그
  • 공지사항

  • 인기 글

  • 태그

    144
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
Radiata
[JVM] JVM 내에서 카드테이블과 사전장벽, 사후장벽이란?
상단으로

티스토리툴바