추상적으로 다가왔던 내용을 구체화하여 정리한다.
Q. JVM 클래스 로더가 “읽는다”는 것
내 컴퓨터에 저장되어 있는 바이트코드로 된 .class 파일을 읽는다. 컴퓨터 입장에서 .class 파일은 하드디스크에 “010100…“과 같은 0과 1로 이루어진 바이너리 코드로 저장되어 있다.
클래스 로더는 이 바이너리 코드 데이터를 스트림으로 읽어 메서드 영역에 C++ 구조체 형태로 저장한다. 메서드 영역에 저장된 정보를 바탕으로, 힙 영역에 java.lang.Class 타입의 객체를 생성한다.
이 객체에는 메서드 영역의 실제 메타데이터를 가리키는 포인터가 주입되어 있어, getName(), getMethods() 같은 메서드를 호출하면 포인터를 타고 메서드 영역으로 넘어가서 정보를 읽어 반환한다.
최초 로딩 시에는 동기화(Synchronization) 블록을 통해 동시에 여러 스레드가 요청해도 딱 한 번만 로딩하고 객체를 생성하도록 제어한다. 따라서 User.class를 코드상에서 100번 호출해도, 힙 영역에 있는 동일한 주소의 java.lang.Class 객체 하나만을 계속해서 반환하게 된다.
class User {
private String name;
public int age;
}
public class Main {
public static void main(String[] args) {
// 1. Class 객체 획득 (힙 영역의 객체 참조)
Class<User> user = User.class;
// 2. 클래스 이름 조회
System.out.println("Class Name: " + user.getName());
// 3. 필드 정보 조회 (메서드 영역의 필드 참조)
System.out.println("\n[Fields]");
Field[] fields = user.getDeclaredFields();
System.out.println(java.util.Arrays.toString(fields));
// 4. 메서드 정보 조회 (메서드 영역의 메서드 참조)
System.out.println("\n[Methods]");
Method[] methods = user.getDeclaredMethods();
System.out.println(java.util.Arrays.toString(methods));
}
}
Class Name: User
[Fields]
[private int User.age, private java.lang.String User.name]
[Methods]
[public void User.printInfo()]
Process finished with exit code 0
Q. PermGen이 사라지고 Metaspace 전환된 이유
JVM 힙 메모리의 일부인 PermGen(Permanent Generation) 영역에는 .class 파일을 로딩한 메타데이터와 정적 변수 등이 저장된다. 로딩된 클래스 정보는 애플리케이션이 종료될 때까지 영구적으로 남아있을 것이라는 가정 때문에 Permanent Generation으로 불린다.
물리적으로 PermGen은 일반적인 힙 공간인 Young Generation, Old Generation과 연속된 메모리 공간에 위치한다.
PermGen은 JVM 구동 시 -XX:MaxPermSize로 설정한 고정된 크기를 할당받는다. 이곳에는 클래스 로더가 읽어들인 바이트코드를 기반으로 생성된 클래스 이름, 접근 제어자, 필드 정보, 메서드 바이트코드, 그리고 정적 변수 등이 저장된다.
이름이 '영구적'이라고 해서 가비지 컬렉션(GC)이 전혀 발생하지 않는 것은 아니다. 과거에는 HotDeploy 배포 방식을 사용했다. 거대한 톰캣 하나(JVM 프로세스 1개)가 24시간 떠있는 상태에서 내부의 ROOT.war 파일만 새 버전으로 갈아끼우는 방식이다. 이때 PermGen 영역에는 기존 버전의 클래스들이 남아있는 문제가 있다. 위 배포 방식과 연결되는 문제로, 개발자가 설정만 바꿔서 스프링 컨텍스트만 리프레시하는 경우에도 PermGen 영역에 프록시 객체들이 쌓이는 문제가 있다.
이곳의 메모리를 정리하려면 힙 전체를 멈추는 Full GC가 수행되어야 하며, 해당 클래스를 로딩한 클래스 로더(Class Loader) 자체가 참조를 잃어야만 내부의 메타데이터가 언로드(Unload)된다. 그렇지 않으면 GC의 대상이 되지 못하고 계속 남게 되어 OOM을 유발하는 주 원인이 되었다.
과거 톰캣(Tomcat) 환경에서 빈번했던 PermGen 누수 상황은 Hot Deploy(재기동 없는 재배포)이다.
WAS(Tomcat)는 스레드 풀을 관리하며, 이 스레드들은 애플리케이션(War)이 재배포되어도 사라지지 않고 계속 살아있다. 반면 애플리케이션은 재배포될 때마다 새로운 ClassLoader를 생성한다. 이때 ThreadLocal.remove()가 호출되지 않는 실수가 발생하면, 스레드들이 기존 버전의 객체를 계속 참조하게 된다. 이로 인해 GC의 대상이 되지 않아 PermGen이 가득 차고 결국 OOM이 발생한다.
결국 Java 8에서 이 영역을 제거하고 Native Memory를 사용하는 Metaspace로 구조를 변경하는 계기가 되었다.
