
1. 서론
파이썬을 주로 사용하던 입장에서 자바를 언젠가는 꼭 공부해야겠다는 생각을 하고는 했다. 물론 나의 게으름과 귀찮음으로 인해 그다지 큰 진도를 나가지 못해 아쉬움을 느끼던 와중, 이번에는 꼭 자바를 공부해야겠다는 결심을 했다. 하지만 어떻게 공부해야 할지, 그리고 무엇을 공부해야 할지 모르겠던 와중 한 번 내가 주로 사용하던 파이썬을 자바와 주로 비교하면서 한 번 공부를 해 나가야겠다는 생각을 했다.
오늘은 자바의 메모리 구조(JVM)와 그것이 어떻게 작동하는지를 파이썬의 메모리 구조와 그 작동방식과 비교하면서 진행해 나가보려고 한다.
2. 자바의 메모리 구조

2-1. JVM의 Class Loader
자바의 경우 Java 소스 코드가 읽힌 뒤 이를 Java 컴파일러가 Java 바이트 코드로 변환한다. 그리고 이 바이트 코드를 JVM에서 읽어 들인 뒤 실행하게 된다. 이 때 자바 컴파일러에 의해 바이트 코드 형태가 된 파일은. class확장자를 가진 클래스 파일이 된다. 그리고 위에서 나오는 JVM의 Class Loader는 이러한 바이트 코드를 동적으로 로드(Loading)하고 링크(Linking)한 뒤 초기화(Initialization)한다.
- 로딩(Loading)
- 바이트 코드(*.class)를 메서드 영역에 저장한다.
- 각각의 바이트 코드는 JVM에 의해 메서드 영역에 다음과 같은 정보를 저장한다.
- 로드한 클래스를 비롯한 부모 클래스의 정보
- 클래스 파일과 함께 Class, Interface, Enum과의 관련 여부 및 변수나 메서드 등의 정보
- 클래스 로딩 시점 - 클래스 인스턴스 생성, 정적 변수 사용(final 키워드가 아닌 경우), 정적 메서드 호출
- 클래스 로딩이 되지 않는 경우 - 클래스에 접근하지 않는 경우, 클래스의 정적 변수 사용(final 키워드의 경우)
- 링크(Linking)
- 검증 : 읽은 클래스가 자바의 언어 명세와 함께 JVM 명세에 명시된 대로 구성되어 있는지 검사
- 준비 : 클래스의 필요한 메모리를 할당, 클래스에 정의된 필드, 메소드와 인터페이스를 나타내는 데이터 구조 준비
- 분석 : Symbolic 메모리 참조를 메소드 영역에 있는 실제 참조로 교체
- 초기화(Initialization)
- static 필드들이 설정된 값으로, 클래스 변수들을 적절한 값으로 초기화.
- 초기화 시점 - 클래스의 인스턴스 생성, 정적 변수 사용(final 키워드가 아닌 경우), 정적 메서드 호출
- 진행 순서 - 1. 정적 블록 / 2. 정적 변수 / 3. 생성자
이 때 클래스의 로딩은 한 번만 수행되고, 그때 한 번만 초기화를 수행한다. 이는 여러 스레드가 생성된 상황에서 스레드가 동시에 같은 코드 영역에 접근하거나 데이터를 공유할 때에도 올바른 실행 결과를 보장하는 스레드 세이프(thread-safe) 속성을 의미하게 된다.
여기서 다른 글들을 참고하면서 헷갈렸던 것이 초기화 단계에서는 thread-safe 하다고 하는데 이 thread-safe를 이용해서 싱글톤 패턴을 thread-safe 하게 한다는 것이었다. 살짝 이해가 되지 않았다. JVM에서 초기화 단계 후에 안전하게 보장하는데 왜 싱글톤 패턴으로 다시 안전하게 만드는 것일까?라는 의문이 들었다. 이런 의문을 해결하기 위해 이것저것 찾아보다 보니 다음과 같이 결론이 나왔다.
클래스 초기화의 thread-safe는 JVM이 클래스 초기화 절차 전체에 락을 걸어서 static 초기화를 한 번만 수행하게 보장하지만 일반 인스턴스의 경우 메서드 안의 if-check, new, assignment가 일반 코드이므로 개발자가 동기화하지 않으면 경쟁 상태 발생한다는 것이다.
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
위의 코드는 private static final Singleton INSTANCE = new Singleton();가 핵심인데, 이 코드는 클래스 초기화 단계에서 실행된다. 만약 이 상태에서 다음과 같은 코드를 실행한다고 보자. 여러 스레드에서 Singleton.getInstance()를 호출하는 것이다.
Thread t1 = new Thread(() -> Singleton.getInstance());
Thread t2 = new Thread(() -> Singleton.getInstance());
내부적으로는 Thread-1과 Thread-2가 클래스 초기화를 시도하지만 JVM은 클래스 초기화를 동시에 수행하지 못하게 막는다. 만약 Thread-1이 INSTANCE = new Singletone()을 실행해 초기화를 한다면 Thread-2는 이미 초기화된 INSTANCE를 사용하게 된다. 이때 thread-safe 하다는 의미는 private static final Singleton INSTANCE = new Singleton(), 이 초기화 코드는 클래스당 한 번만 실행된다는 것이며 동시에 여러 스레드에서 클래스 초기화를 시작할 수 없고, 다른 스레드가 초기화 완료 전인 객체에 접근할 수 없다는 것이다.
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
하지만 위의 코드를 보자, instance = new LazySingleton();가 객체를 생성하는데 이 때 이 코드가 사용되는 곳이 일반적인 ㅜ메서드 실행 중에 수행된다. 즉 클래스 초기화 단계에서 실행되지 않는다. 클래스 초기화 상태에서는 private static LazySingleton instance;의 코드가 초기화되지만, instance에는 null로 초기화된다. 즉 이 경우는 JVM에서 보장하는 스레드 세이프(thread-safe)가 아니다.
2-2. JVM의 Execution Engine
위의 Class Loader에 의해 메모리에 적재된 바이트 코드를 기계어로 변경해 명령어 단위로 실행하는 역할을 한다. 이 때 파이썬처럼 명령어를 하나하나 실행하는 Interpreter(인터프리터) 방식이 있고 JIT(Just-In-Time) 컴파일러를 사용하는 방식이 있다. 인터프리터처럼 한 줄 한 줄씩 읽으면서 바이트 코드를 실행하다가, 컴파일 임계치(Compile Threshold)를 넘으면 JIT 컴파일러가 메서드가 자주 사용되는지 체크하는 방식으로 컴파일 임계치를 사용한다. 이 임계치를 초과하면 JIT 컴파일이 트리거 되어서 기계어를 캐싱하고 인터프리터가 매번 기계어로 컴파일하는 것이 아닌 미리 캐싱된 기계어를 사용하게 된다.
2-3. JVM의 Garbage Collector
Garbage Collector의 경우 참조되지 않은 객체들을 탐색 후 삭제 - 메모리에서 할당을 해제 - 하며 이렇게 삭제된 객체의 메모리를 반환한다. 특히 이는 JVM의 Runtime Data Area의 Heap Area하고 큰 연관이 있어서 나중에 Runtime Data Area의 Heap 부분하고 같이 설명하려고 한다.
2-4. JVM의 Runtime Data Area

JVM의 Runtime Data Area는 실제로 클래스 파일이 적재되는 곳이다. JVM이 운영체제로부터 자바를 실행하기 위한 데이터와 명령어를 저장하기 위해 할당받는 메모리 공간이다. 위의 그림에서는 조금 복잡하게 그려져 있지만, 크게 각 Thread 별로 할당된 PC Register, Stack, Native Method Stack 영역과 스레드와 별개로 모든 스레드가 공유하는 Method Area, Heap으로 이루어져 있다.
2-4-1. PC Register
PC Register의 경우 간단히 말해 각 스레드마다 현재 수행 중인 JVM 명령어의 주소를 저장하고 관리하여 추적을 할 수 있게 해주는 공간이다. 운영체제의 PC Register와 유사한 역할을 하지만 CPU와는 별개로 JVM에서 관리를 수행한다. 만약 실행했던 메서드가 native - native 메서드로 System.currentTimeMillis();같이 Java 바이트코드가 아닌 JVM 또는 OS 레벨에서 C/C++ 코드 등으로 구현되어 있으면, 이를 undefined로 기록한다. JVM명세서에도 native 메서드 실행 중인 경우 PC Register의 값은 정의되지 않는다고 설명한다.
2-4-2. Stack
스택영역은 지역 변수, 매개 변수, 메서드의 리턴 값, 연산에 사용되는 임시 데이터 등등이 저장되는 곳으로 스택에 저장되는 데이터들은 프레임 구조로 저장된다. 이 역시도 각각의 스레드 별로 생성되며 메서드 호출 시 생성되었다가 메서드 종료시 소멸된다. 이때 이 Stack영역은 위의 PC Register에서 설명한 Java 바이트코드로 이루어진 메서드가 쌓인다.
2-4-3. Native Method Stack
위의 native 메서드를 사용한 경우에는 native 메서드를 실행하기 위한 영역이 별도로 필요하고 이는 곧 Native Method Stack에 저장된다. 예를들어 다음과 같은 코드가 있다고 해보자.
public class Main {
public static void main(String[] args) {
long now = System.currentTimeMillis();
System.out.println(now);
}
}
위의 코드에서 main(String[] args)은 Java 메서드이다. 따라서 이것이 아래에서 설명할 Stack에 쌓이고 stack frame이 만들어진다. 그리고 PC Register는 main(String[] args)의 바이트코드 위치를 가리킨다. 그런데 System.currentTimeMillis()를 호출하면 내부적으로 native 메서드로 연결된다.
Java Virtual Machine Stack
└─ main() frame
└─ currentTimeMillis() 호출 지점에서 대기
Native Method Stack
└─currentTiumeMillis() native frame
2-4-4. Method Area
메서드 영역은 클래스 수준의 정보를 저장하는 영역으로 이곳에는 클래스의 메서드 정보, 정적 변수와 같은 데이터가 저장된다. Constant Pool에는 문자 상수, 타입, 필드,객체 참조 정보가 저장된다. 참고로 찾아보니 이 메서드 영역은 JVM의 벤더마다 다르게 구현되어 있고 그중 Oracle인 경우에는 JVM JDK 7까지는 메서드 영역은 Permanent Generation(PermGen)이라고 불렸지만, 이 PermGen의 경우 JDK 8부터는 Metatspace로 완전히 대체되었다고 한다.
2-4-5. Heap

위에서 봤다 싶이, Garbage Collector의 경우 heap과 관련이 깊은 부분이라 여기서 heap의 구조와 함께 같이 GC를 같이 설명해 나가려고 한다. 이러한 JVM의 heap영역은 참조 데이터가 저장되는 공간으로 GC의 대상이 되는 공간이다. heap의 경우 처음 설계될 때부터 2가지 전제 - Weak Generational Htpothesis - 로 설계되었다. 먼저 대부분의 객체는 금방 접근 불가능한 - 즉 어떠한 참조도 받지 않는 낙동강 오리알 신세 - 상태가 된다. 두 번째로 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다. 따라서 이러한 설계 기조를 바탕으로 객체의 생존 기간에 따라 물리적으로 heap영역을 나누고 Young과 Old, 두 가지로 설계했다.
- Young Geneartion
- 새로이 생성된 객체가 할당되는 영역이며 대부분의 객체가 금방 접근 불가능한 상태가 되어서 Young Generation에서 생성되었다가 사자린다.
- 그리고 이 Young Generation의 경우 Eden, Survivor 0, Survivor 1이라는 3가지 영역으로 나뉜다.
- Eden - new를 통해 새로 생성된 객체가 위치하며, 여기서 GC로부터 살아남은 객체들은 Survivor 영역으로 간다.
- Survivor 영역 - 최소 1번 이상 GC의 활동에서 살아남은 객체가 존재하는 영역으로 특별히 Survivor 0, 1에서 둘 중 하나는 무조건 비어 있어야 한다.
- 이 영역에서 이루어지는 Garbage Collection을 Minor GC라고 부른다.
- 새로이 생성된 객체가 할당되는 영역이며 대부분의 객체가 금방 접근 불가능한 상태가 되어서 Young Generation에서 생성되었다가 사자린다.
- Old Generation
- 위의 Young Generation에서 살아남은, 즉 참조 상태미여 접근 가능한 상태를 유지한 객체가 복사되는 영역이다.
- Young Generation보다 크게 할당되고 영역의 크기가 큰 대신 GC는 덜 발생한다.
- 이 영역에서 수행되는 Garbage Collection을 Major GC 혹은 Full GC라고 부른다.
번외) Permanent
Permanent의 경우에는 직역하면 영구적인 세대인데, 생성된 객체들의 주소값이 저장된 공간이다. Class Loader에 의해 로드되는 클래스나 메서드 등에 대한 메타 데이터가 저장되는 영역이다. Java 7 까지는 heap 영역에 존재했지만, Java 8 부터는 Native Method Stack에 편입된다.
2-4-5-1. Minor GC 과정

Minor GC의 경우 새로이 생성된 객체의 경우 전부 Eden으로 명명된 공간으로 할당된다. 만약 Eden공간이 전부 꽉 찼다면 Minor GC가 이루어진다. 만약 이 경우 참조되고 있는 객체가 있거나 죽지 않은 객체가 있다면 Survivor 0나 Survivor 1으로 이동하게 된다. 위의 사진에서는 먼저 S0공간으로 움직이게 된다. 그 이후 S0 이외의 영역의 객체들을 삭제한다. 그 이후 Eden과 S0에서 기준치 이상으로 객체가 메모리에 할당되어 있으면 Eden과 S0에서 다시 Minor GC를 실행하여 이를 전부 Survivor 1으로 옮긴다. 이때 살아남은 모든 객체들은 age값이 1씩 증가하는데 이는 Survivor 영역에서 객체가 살아남은 횟수를 의미한다. 이 age값을 기준으로 Old영역으로 옮길지 말지 결정한다.
이때 중요한 것이 S0나 S1 중 하나는 무조건 비어있어야 한다. 이에 대한 이유로 디스크 조각 모음을 생각하면 편하다. 데이터 - 여기서는 메모리에 할당된 객체 - 가 흩어져서 저장되는 파편화가 이루어지면 GC가 행해질 때 데이터를 읽는 속도가 느려지게 된다. 따라서 모든 객체 데이터를 한 곳에 모음으로써 디스크 조각 모음을 통해 순차 읽기 및 쓰기 속도를 올리는 것처럼 더 빠르게 GC를 수행할 수 있게 된다.
2-4-5-1. Major GC 과정
Old영역에 특정 허용치 이상의 할당된 메모리를 사용중이면 자연스럽게 일어난다. 이때 Old 영역의 모든 객체들을 검사하여 참조되지 않는 객체들을 한꺼번에 삭제하는 Major GC를 수행한다. 하지만 이 Old Generation은 Young Generation보다 상대적으로 큰 공간을 가지고 있기 때문에 많은 시간이 걸리게 된다.
이러한 GC의 경우에는 가장 큰 문제인 Stop-The-World(STW)문제가 생기게 된다. GC를 실행하기 위해 JVM이 프로그램을 멈추는 현상으로 GC가 작동하는 사이에 GC와 관련된 스레드를 제외한 모든 스레드를 멈추게 하여 서비스 이용에 차질이 생길 수 있다. Major GC의 경우에는 Minor GC보다 오랜 시간이 걸리기 때문에 이를 염두하고 개발을 해야 한다.
이러한 GC의 경우 여러 알고리즘이 있는데, Serial GC, Parallel GC, Parallel Old GC, CMS GC, G1 GC, Shenandoah GC, ZGC 등이 있다. 이러한 알고리즘의 경우는 나중에 파이썬과 비교하면서 어떤지 살펴보려고 한다.
3. 파이썬의 메모리 구조
자바에서는 class를 기준으로 정보가 저장되고 class내부에 있는 메서드나 변수등이 저장되지만, 파이썬은 다르다. 파이썬은 모든 것이 객체다 - In Python, Everything is an Object. Java에서 Class와 변수들이 다르게 다루어지지만 파이썬에서는 class도 a = 1이라는 코드의 변수 a와 똑같이 취급된다. 귀도 반 로섬(Guido van Rossum)은 The Python Lanugage Reference(2012)에서 다음과 같이 - "파이썬에서의 객체는 데이터의 추상화이다"라고 말한 적이 있다. 이를 생각해 보면, 파이썬에서 모든 데이터는 객체나 객체들 관의 관계로서 표현될 수 있다는 것이다. 이는 불리안(boolean), 정수(integer), 실수(float), 문자열(string), 그 외 등등 심지어 함수(function)까지도, 모든 것이 객체로서 실행된다.

파이썬도 Java와 같이 실행하는 - 컴파일하고 인터프리터를 사용하는 - 방식은 비슷하다. Python 소스코드가 있으면 이를 컴파일러가 컴파일한다. 이때 컴파일하는 파일의 경우 import가 된 모듈만 컴파일한다. 이 후에 이 컴파일된 바이트 코드를 Python Virtual Machine이 이와 연결된 Libaray Module을 연결시켜 실행시킨다. 이 때 Java와 다르게 파이썬은 CPython이라는 C언어로 만들어진 구현체를 가지고 있다. 쉽게 말해 Java에서 컴파일러와 JVM, Execution Engine 같은 것이 전부 포함되어 있는 프로그램이 있고 이를 Java언어가 아닌 C언어로 구현한 것이라고 보면 된다.
3-1. CPython에서의 메모리 할당
x = 10
y = 10
z = "Korea"
위의 코드가 있다고 해보자. 앞에서 말했다시피 파이썬에서는 모든 것이 객체이다.
이때 파이썬에서 불변 데이터와 그렇지 않은 데이터가 중요해진다.
불변 데이터에는 숫자(integer, float), 문자열(string), 튜플(tuple)이다. 그 반대에는 리스트(list), 사전(dictionary), 그리고 셋(집합 혹은 set)이 있다. 간단하게 불변데이터에 대해 비유를 하자면, 마치 볼 수만 있는 혹은 참조만 할 수 있는 데이터라 보면 된다. 수정은 불가능한 데이터이다. 이를 파이썬의 객체 개념까지 추가하면, 객체를 참조 및 불러오기, 할당만 가능하며 이 데이터 객체 자체는 수정이 불가능하다. value는 수정 불가능하다. 여기에 수정이 가능한 개념이 추가되면 변화가 가능한 데이터가 된다.
그렇다면 이 불변 데이터에 대한 취급이 중요한 이유는 무엇일까?
일단 불변 데이터 - 불변 객체 - 의 경우는 변화 가능한 데이터보다 접근이 쉽다. 하지만 이 불변 데이터의 표면상의 수정은 Python에서 결국 또 다른 하나의 객체를 생성하기에 새로운 메모리 공간에 무조건 할당해야 한다. 하지만 그렇지 않은 데이터는 쉽게 수정이 가능하며 또 다른 복사본을 만들 필요가 없다. 그렇다면 이러한 메모리 차지를 하는 불변 데이터는 좋지 않은 것일까? 다르게 생각하면 디버깅에 좀 더 유리할 수 있는 면이 있다. 값이 바뀌지 않기에 객체의 상태를 쉽게 추적할 수 있게 해 준다.
위의 코드를 다시 보면 메모리 상에서 다음과 같이 표시할 수 있다.

만약 위의 코드에서 x += 1을 하면 어떻게 될까? 기존 값을 업데이트하지 않고 현재 메모리 상에 11이 없으니 새로이 11 객체를 만들어 준다. 왜냐하면 앞에서 언급했던 것처럼 immutable 즉 수정이 불가능한 객체이기 때문에 새로 만들어줄 수밖에 없다. 만약 계속해서 1을 더해줘서 100까지 만들면 어떻게 될까? 이를 C언어의 느낌으로 보면 다음과 같다.
x = 10 # malloc(x)
while x <= 100:
# malloc(x+1)
# free(x)
x = x + 1
만약 이를 C언어로 표현하면 malloc(x+1)을 통해 새로이 값을 할당하고 기존에 있던 값은 free(x)를 통해 메모리에서 해제한다. 정말로 CPython에서 위의 작업을 계속해서 반복하지는 않는다. 이는 파이썬의 메모리 관리하고도 관련이 깊다.
3-2. CPython에서의 메모리 관리

위의 C언어로 표현한 코드처럼 빈번한 메모리의 동적할당과 해제를 방지하기 위해 CPython은 메모리 할당자의 계층을 정의하였다. 위에서부터 아래로 내려가보면 다음과 같다.
- 특정 객체 할당자(Object-specific Allocators) : 특정한 데이터 타입을 위한 특정한 메모리 할당자
- Python의 객체 할당자(Object Allocator) : 512바이트 이하의 객체를 위한 할당자
- Raw 메모리 할당자(Raw Memory Allocator) : 512바이트보다 큰 객체를 위한 할당자
- 일반적인 목적의 할당자(General Purpose Allocator) : CPython의 malloc 메서드
일반화하기는 어렵지만, 위에서 밑으로 갈수록 권한이 강해지고 운영체제에 가깝게 작동하며 메모리를 다루는 용량도 커진다고 보면 된다.
특정 객체 할당자(Object-specific Allocators)의 경우 그림에 파이썬 타입이 있는 것처럼 간단한 데이터 타입을 가진 객체들마다 각각 메모리 관리 정책을 가져간다. int객체와 float객체가 다른 방식으로 실행된다고 보면 된다.
Python의 객체 할당자(Object Allocator)의 경우 512바이트 이하의 작은 객체를 할당할 때 사용되며 만약 이보다 큰 경우에는 Raw 메모리 할당자(Raw Memory Allocator)에다가 바로 요청한다. 이 객체 할당자는 pymalloc이라고도 불린다. 만약 작은 객체가 메모리를 요구하면 그 객체만을 위해 단순히 메모리를 할당하는 것이 아닌 객체 할당자(Object Allocator)는 운영체제에 큰 블록을 요청한다. 이 큰 블록을 나중에 작은 객체들을 위해 할당된다.
이 방식을 통해 앞에서 봤던 malloc을 여러 번 호출하지 않아도 되게 된다. 이 큰 블록을 Arena라고 하며 이 Arena의 경우 256KB의 사이즈를 가지고 있다.
특정 객체 할당자(Object-specific Allocators)와 Python의 객체 할당자(Object Allocator)의 경우 Raw 메모리 할당자(Raw Memory Allocator)가 파이썬 프로세스에 이미 할당한 메모리 위에서 작동한다. 즉 운영체제에다가 직접적으로 메모리를 요구하지 않고 앞에서 보았던 Private Heap위에서 작동한다. 만약 이 둘이 메모리를 더 필요로 한다면 Raw 메모리 할당자(Raw Memory Allocator)에 요청한 뒤 이 할당자가 일반적인 목적의 할당자(General Purpose Allocator)하고 소통하며 메모리를 할당해 준다.
Raw 메모리 할당자(Raw Memory Allocator)는 일반적인 목적의 할당자(General Purpose Allocator)의 추상화 계층으로 만약 파이썬 프로세스가 메모리를 필요로 하면 이 할당자가 일반적인 목적의 할당자(General Purpose Allocator)와 상호작용하여 필요한 메모리를 공급해 준다.

위에서 말한 Arena를 효율적으로 사용하기 위해 CPython은 이 Arena를 Pool로 나누어서 사용한다. 그리고 이 Pool이 다시 나누어져서 Block이 된다. Block은 가장 작은 단위로 객체에 할당될 수 있으며 객체 하나당 하나의 블록에 할당될 수 있다. 블록의 사이즈는 8의 배수만큼 커지며 최종적으로 512바이트 까지 커질 수 있다. 각 블록의 사이즈는 Size Class라고 불리고 8의 n승의 사이즈인 경우 n-1의 클래스 인덱스를 가진다.
그리고 이런 Block을 가지고 있는 Pool은 하나의 Size Class로 이루어져 있는 Block으로 되어있다. Pool의 크기는 가상 메모리 페이지와 사이즈가 같다. 이 Pool은 다음과 같은 상태를 가질 수 있다.
- Used : 할당에 사용할 수 있는 block을 가졌다면 used상태이다.
- Full : Pool에 있는 모든 Block이 사용되었다면 full상태이다.
- Empty : 모든 Block이 사용가능하다면 empty상태이며 이 경우 블록에 대한 Size Class가 따로 설정되어 있지 않으며 어느 Size Class의 Block을 사용할 수 있다.
/* Pool for small blocks. */
struct pool_header {
union { block *_padding;
uint count; } ref; /* number of allocated blocks */
block *freeblock; /* pool's free list head */
struct pool_header *nextpool; /* next pool of this size class */
struct pool_header *prevpool; /* previous pool "" */
uint arenaindex; /* index into arenas of base adr */
uint szidx; /* block size class index */
uint nextoffset; /* bytes to virgin block */
uint maxnextoffset; /* largest valid nextoffset */
};
위의 코드는 Pool을 정의한 CPython의 C언어 구조체이다. 여기서 szidx가 Size Class이며 arenaindex는 이 Pool이 어느 Arena에 속해있는지 알려주며 nextpool과 prevpool은 각각 Pool들이 연결된 Linked List라는 것을 알게 해 준다.
아무리 Pool자체가 비어 있는 상태 - 즉 전부 free인 상태 여도, CPython은 이를 운영체제에 반환하지 않고 파이썬 프로세스는 이를 계속해서 새로운 객체에 할당하는 데 사용한다. 메모리를 운영체제에 반환하는 경우는 Arena 레벨에서 이루어진다. 그래서 CPython은 정말로 필요할 때만 Arena를 생성한다.
3-3. CPython의 Generational Garbage Collection
앞의 사진에서 봤다시피 Heap에 있는 객체의 Reference Count(참조 횟수)가 가장 중요한 포인트가 된다. 하지만 이런 참조의 경우 자기 자신을 참조하거나 Circular Reference같이 서로가 서로를 참조하여 참조 횟수를 영원히 0으로 만들지 못하는 경쟁상태로 만들기도 한다. 이를 해결하기 위해 Generational Garbage Collection이 사용된다. 객체와 이러한 순환참조의 경우 시간이 드는 작업이지만 이 GC는 실시간으로 이를 스캔하지 않는다. 주기적으로 트리거를 받아 실행된다. Java와 마찬가지로 트리거가 된 경우 모든 작업이 멈추며 STW(Stop-The-World) 상태가 되어 GC가 작동한다. 따라서 CPython은 이런 작동을 자주 하지 않게 하기 위해 세대별 GC를 사용하며 이 세대는 0, 1, 2의 세 가지로 나뉜다. 새로 생성된 객체는 0으로 분류되며 GC의 과정에서 살아남을수록 상위 단계로 가게 된다. 자바의 Young Generation, Old Generation과 비슷하게 세대가 올라갈수록 GC를 실행하는 비중이 낮아진다. 이 세대에 분류된 모든 객체들의 상위 루트를 찾아가면서 만약 루트가 없는 경우면 순환참조라 파악하고 할당을 해제하는 방식으로 작동한다. 그리고 각각의 세대는 임계치(threashold)가 존재하며 이는 기본 값을 사용하거나 상황에 맞게 사용할 수 있다. 임계치를 확인하는 코드는 다음과 같다.
static PyObject *
gc_get_threshold_impl(PyObject *module)
/*[clinic end generated code: output=7902bc9f41ecbbd8 input=286d79918034d6e6]*/
{
GCState *gcstate = get_gc_state();
#ifndef Py_GIL_DISABLED
return Py_BuildValue("(iii)",
gcstate->generations[0].threshold,
gcstate->generations[1].threshold,
gcstate->generations[2].threshold);
#else
return Py_BuildValue("(iii)",
gcstate->young.threshold,
gcstate->old[0].threshold,
gcstate->old[1].threshold);
#endif
}
위의 코드의 Py_GIL_DISABLED가 정의되어 있지 않다면, 즉 일반적인 Python GIL이 적용되어 있는 경우이다. 파이썬 3.13.0부터 도입되었을 것이며 CPython에서 GIL을 해제하여 빌드한 경우에 생기는 것이다. 즉 일반적인 경우에는 세대가 0, 1, 2로 구분되어 사용된다. 하지만 신기한 점은 만약 이 구현체가 GIL을 해제하여 빌드한 경우이다.
typedef struct _gc_runtime_state GCState;
struct _gc_runtime_state {
int enabled;
int debug;
#ifndef Py_GIL_DISABLED
struct gc_generation generations[NUM_GENERATIONS];
#else
struct gc_generation young;
struct gc_generation old[2];
#endif
struct gc_generation permanent_generation;
struct gc_stats *generation_stats;
int collecting;
_PyInterpreterFrame *frame;
PyObject *garbage;
PyObject *callbacks;
Py_ssize_t heap_size;
Py_ssize_t long_lived_total;
Py_ssize_t long_lived_pending;
#ifdef Py_GIL_DISABLED
int freeze_active;
#else
PyGC_Head *generation0;
#endif
};
위의 코드는 임계치를 가져오는 get_gc_state();코드에서 리턴값으로 가지는 구조체이다. 특이한 점은 위의 세대별 분류가 기존에는 0,1,2였다면 이번에는 young과 두 단계의 old가 추가된 것이다. 이는 CPython의 GIL을 선택적으로 해제하자고 제안한 "PEP 703 – Making the Global Interpreter Lock Optional in CPython"에서 나타나는 부분이다. 이 부분이 나온 PEP 703의 일부분을 번역하면 다음과 같다.
이 PEP는 CPython이 GIL 없이 빌드될 때, 세대별 순환 가비지 컬렉터를 비세대형 가비지 컬렉터로 전환하는 것을 제안한다. 이는 하나의 세대, 즉 “old generation”만 존재하는 것과 같다. 이 변경이 제안된 이유는 두 가지다.
순환 가비지 컬렉션은 young generation만 대상으로 하더라도, 프로그램 내 다른 스레드들을 일시 중지해야 한다. 저자는 young generation의 빈번한 컬렉션이 멀티스레드 프로그램에서 효율적인 확장을 저해할 수 있다고 우려한다. 이것이 old generation보다 young generation에서 문제가 되는 이유는, young generation의 컬렉션은 고정된 할당 횟수 이후에 수행되는 반면, older generation의 컬렉션은 힙에 존재하는 live object 수에 비례해 스케줄링되기 때문이다.
또한 GIL이 없는 환경에서는 각 세대에 속한 객체들을 효율적으로 추적하기가 어렵다. 예를 들어, 현재 CPython은 각 세대의 객체들을 linked list로 관리한다. CPython이 이 설계를 유지한다면, 그 linked list들은 thread-safe 하게 만들어져야 한다. 그런데 이를 효율적으로 수행하는 방법은 명확하지 않다.
세대별 가비지 컬렉션은 다른 여러 언어 런타임에서는 효과적으로 사용된다. 예를 들어 Java HotSpot의 여러 가비지 컬렉터 구현은 여러 세대를 사용한다. 이러한 런타임에서 young generation은 자주 처리량 측면의 이점을 제공한다. young generation에 속한 객체 중 상당수가 보통 “죽은” 상태이기 때문에, GC는 수행한 작업량에 비해 많은 양의 메모리를 회수할 수 있다. 예를 들어, 여러 Java 벤치마크에서는 “young” 객체의 90% 이상이 일반적으로 컬렉션 된다는 결과를 보여준다.
이러한 현상은 흔히 “weak generational hypothesis”라고 불린다. 즉, 대부분의 객체는 생성된 지 얼마 되지 않아 죽는다는 관찰이다.
하지만 CPython에서는 참조 카운팅을 사용하기 때문에 이 패턴이 반대로 나타난다. 대부분의 객체가 여전히 일찍 죽기는 하지만, 이들은 참조 카운트가 0이 되는 시점에 즉시 수거된다. 가비지 컬렉션 사이클까지 살아남은 객체들은 대체로 계속 살아 있을 가능성이 높다.
이 차이 때문에, 세대별 컬렉션은 다른 여러 언어 런타임에서만큼 CPython에서는 효과적이지 않다.
즉, 기존에는 generation을 0,1,2로 구분한 것이 그저 Old만 존재하는 것처럼 보이며 이는 young과 old로 구분하는 비 세대형 GC를 제안한 것이다. 그 이유로는 Stop-The-World로 인한 멀티스레딩 상태에서의 영향을 줄이기 위한 것이다. 아무튼 갑작스럽게 다른 이야기로 새어나간 것 같다. 정리하자면 파이썬에서는 GC를 할 때 세대별로 일반적인 경우에는 0,1,2세대같이 구분하여 메모리의 해제를 수행한다는 것을 알 수 있다.
4. 파이썬과 자바와의 차이점
4-1. Java의 JVM과 Python의 CPython의 큰 차이
Java는. java를 컴파일한 뒤 이를. class의 바이트코드로 만들고 JVM이 이를 실행한다.
- Java source → javac →. class bytecode → JVM 실행
Python도 소스코드를 컴파일한 뒤 여기서 생성한 바이트코드를 Python Virtual Machine이 실행한다.
- Python source → compile → code object /. pyc bytecode → Python VM 실행
즉 둘 다 바이트코드를 실행하는 가상 머신이 있다. 다만 Python은 JVM처럼 이를 저장하는 Runtime Data Area와 같은 구조가 없다.
4-2. JVM Runtime Data Area와 CPython 구조 비교
| CPython | JVM에서의 역할 | Java JVM의 요소 |
| Object(객체) - function, variable 등등 | 클래스 메타데이터, 메서드 정보, constant pool | Method Area |
| Heap, Private Heap | 객체 인스턴스 저장 | Heap |
| Call Stack, Value Stack | 메서드 호출 프레임, 지역 변수 등등 | Java Stack |
| Stack frame의 instruction pointer | 현재 실행중인 바이트 코드의 위치 | PC Regster |
| C stack, C extension call stack | native 메서드(외부 API) 호출용 스택 | Native Method Stack |
특히 Java의 Method Area가 된다면 어떻게 되는지 코드로 비교해 보자면 다음과 같다.
class User:
count = 0
def hello(self):
print("hi")
위의 코드는 파이썬에서 호출하면 print(User)를 하면 <class '__main__. User'>와 같이 출력되고 이는 User 클래스 자체가 하나의 객체라는 것을 알 수 있다. 이를 Java에 대응한 코드로 비슷하게 바꿔 보면 다음과 같다.
class User {
static int count = 0;
void hello() {
System.out.println("hi");
}
}
User 클래스 메타데이터, 필드 정보, 메서드 정보, static 변수, constant pool과 같은 정보가 Method Area에서 관리된다. 파이썬에서는 Java의 Method Area처럼 별도 영역에 클래스 메타데이터를 저장한다기보다는, Python에서는 클래스 자체가 heap 위의 객체이고, 그 내부 dictionary에 속성과 메서드가 들어간다. 이는 곧 모든 것이 객체다라는 파이썬의 설계철학에서 나오는 차이라고도 볼 수 있다.
4-3. Java 객체 필드와 Python 인스턴스 변수 비교
class User {
String name;
}
User u = new User();
u.name = "Kim";
class User:
pass
u = User()
u.name = "Kim"
위의 두 코드를 비교해 보자. 첫 번째가 자바코드이고 두 번째가 파이썬 코드이다. 파이썬에서는 인스턴스의 변수도 객체의 __dict__에 저장된다. 이는 동적 타입을 가지고 있는 파이썬의 설계로 인한 것으로 Java는 클래스 설계 시 필드가 정해지지만 파이썬은 런타임에 인스턴스 속성을 추가할 수 있다. 즉 이를 정리하자면 Java는 클래스 메타데이터, static 필드, constant pool 등을 Method Area 모델로 설명할 수 있지만, Python은 클래스, 함수, 모듈, 코드도 모두 객체로 취급하고 이로 인해 대부분 heap 위의 객체와 namespace dictionary로 관리한다.
4-4. Garbage Collection의 차이
Java JVM은 객체 생존 여부를 tracing GC가 판단하고 reachability graph를 따라가며 살아 있는 객체를 찾고, 나머지를 수거한다. 반면에 CPython은 기본적으로 reference counting이다. 참조 횟수가 0이 되면 즉시 해제되고 순환 참조는 별도의 cyclic GC가 보조적으로 처리한다. Java는 도달 가능한지를 여부로 판단하고 Python은 참조 횟수가 몇 번인지 그리고 순환 참조인지 여부로 결정한다.
그러면 이런 생각이 든다. Java는 순환참조가 발생하지 않는가? 하지만 가장 큰 차이점인 파이썬은 참조 횟수로 판별하는 반면, Java는 GC Root, 즉 현재 실행 중인 스레드의 stack local variable, static field, JVM 내부에서 참조 중인 객체 등과 같이 판별되는 기준이 다르다.
class Node {
Node other;
}
Node a = new Node();
Node b = new Node();
a.other = b;
b.other = a;
a = null;
b = null;
위의 코드를 보면 객체끼리는 서로 참조하고 있다. 하지만 순환 참조 여부와 무관하게 GC Root에서 도달 불가능하면 수거 대상이 되므로 순환 참조가 발생하든 말든 아무 문제가 되지 않는 것이다. 이 점에서 Java GC는 CPython의 단순 reference counting보다 순환 구조에 강하다.
4-4. Garbage Collection으로 인한 메모리 누수 차이
static List<Object> cache = new ArrayList<>();
void add(Object obj) {
cache.add(obj);
}
만약 위의 코드에서 객체를 계속 cache변수에 넣고서 제거하지 않으면 static field는 List를 참조하고 이는 Object를 참조하게 되어 GC Root인 static field에서 시작되어 계속해서 참조 - reachable 하기에 수거 대상이 되지 않는다. 즉 계속해서 메모리에 남게 된다. 파이썬도 이와 비슷하다, 특히 전역 객체에서 이러한 문제가 많이 발생한다.
cache = []
def add(obj):
cache.append(obj)
위의 코드에서 cache는 전역변수로 계속해서 살아있기 때문에 이 전역 변수 리스트가 계속 객체를 참조하면 참조 횟수가 0이 되지 않아 메모리 누수가 발생한다. 즉 큰 차이는 Java의 경우 GC Root에 접근되는지 Python은 참조 횟수가 0이 아닌지에 대한 것이라고 보면 될 것 같다.
4-5. Garbage Collection 실행 시의 STW(Stop-The-World)의 경우
Java는 앞에서 말한 다양한 알고리즘(G1, ZGC, Shenandoah, Parallel GC, Serial GC)을 통해 최대한 스레드가 멈추는 시간을 줄이려고 한다. CPython의 경우 참조 횟수가 0이 되는 순간 스레드에서 즉시 해제한다. 하지만 순환 참조를 탐지하는 경우에는 일시적인 작업 중단이 발생할 수 있다. 그리고 가장 큰 문제는 GIL이다. 즉 Java는 멀티 스레드를 통해 병렬 실행을 하고 이를 위해 GC도 동시성과 병렬성을 위한 설계로 접근하지만, CPython은 GIL로 인해 Python 바이트코드는 한 번에 한 스레드 중심으로 실행된다.
결국 파이썬과 Java는 놓인 상황자체가 다르다.
정리하자면 Java JVM은 GC Root에서 도달 가능한 객체를 살리고, 도달 불가능한 객체를 tracing GC가 수거한다. 이때 순환 참조는 문제가 되지 않는다. 실제 해제 시점은 GC 정책에 따라 비결정적이다. 반면 CPython 객체마다 reference count를 기준으로 한다. 이 count가 0이 되면 즉시 해제한다. 순환 참조는 참조 횟수만으로 못 잡기 때문에 cyclic GC가 별도로 처리한다고 보면 좋다.
5. 타입에서는 어떨까? 정적 타입과 동적 타입
JVM / Java는 정적 타입 언어를 실행하기 위한 VM이며 변수, 필드 및 메서드 시그니처의 타입이 컴파일 시점에 결정되며 런타임에는 그 타입 정보를 바탕으로 검증, 최적화 및 동적 디스패치를 수행한다. 반면 CPython / Python은 동적 타입 언어를 실행하기 위한 VM이며 변수 이름에는 타입이 없고, 객체에 타입이 있고 이를 위해 연산 시점마다 실제 객체의 타입을 확인해서 동작을 결정한다. 한마디로 Java는 “변수의 타입”이 중요하고, Python은 “객체의 타입”이 중요하다.
"Java source → javac 타입 검사 → bytecode 생성 → JVM 실행"과 같은 실행 순서에서 컴파일 시점에서 변수에 입력한 타입으로 결정된다. 이는 객체도 똑같다. 하지만 파이썬은 변수에 타입이 없다. 객체와 이를 담는 변수는 따로 구별되어 다루어지기 때문이다. 이를 단적으로 보여주는 코드가 다음과 같다.
Object x = "hello";
System.out.println(x.length()); // 컴파일 에러
Object x = "hello";
System.out.println(((String) x).length());
위의 코드에서 파이썬이라면 당연하게 객체 중심으로 이루어져서 length라는 메서드가 호출이 가능하겠지만, Java 컴파일러는 x를 Object 타입으로 본다. 즉 Object에는 length()가 없고 이에 따라 컴파일 에러가 난다. Java에서 이를 해결하기 위해 캐스팅이 필요하다. 즉 정적 타입은 Object이지만 실제 런타임 객체 타입은 String이라고 알려주는 것이다.
Java는 타입 오류를 컴파일 시점에 많이 잡고 Python 타입 오류를 실행 시점에 만나게 된다. 즉 대응할 수 있는 시점 자체가 다르다.
이러한 강타입과 약타입의 차이는 JIT Compile방식에서도 나타난다. 물론 파이썬도 이를 지원하기는 하나, 따로 설정을 해줘야 하니, 근본적으로는 CPython의 경우에는 없다고 보는 편이 낫다. Java의 경우 정적 타입 덕분에 JVM JIT가 최적화하기 좋다.
int add(int a, int b) {
return a + b;
}
JIT는 코드를 CPU의 정수 덧셈으로 강하게 최적화할 수 있다. 물론 Java도 다형성이 있으므로 런타임 프로파일링을 사용하지만, 파이썬의 동적 타입으로 인한 매 연산마다 많은 확인이 필요한 것과는 다르다. 파이썬의 단순한 코드 "c = a + b"의 경우 런타임에서 "a 객체 로드, b 객체 로드, a의 타입 확인, + 연산 슬롯 찾기, b와 호환되는지 확인, 결과 객체 생성, refcount 조정"과 같은 작업을 거치게 된다.
