본문 바로가기

탐구일지/Java

JVM은 어떻게 플랫폼 독립성과 성능을 동시에 잡았을까?

1. 배경

 

Java를 공부했다면 다들 한 번 쯤 " Write Once, Run Anywhere "라는 개발 철학을 들어봤을 것이다

하지만 문득 이런 생각이 들었다

C나 C++로 작성한 코드도 대부분 환경에서 문제 없이 동작하는데,
왜 Java는 굳이 Bytecode라는 중간 단계를 두면서까지 플랫폼 독립성을 보장하려고 했을까?

 

더 나아가 Bytecode를 기반으로 실행된다는 것은 결국 실행 시점에 코드를 해석해야 한다는 의미인데,

그렇다면 성능은 어떻게 보장할 수 있었을까?

 

이 글에서는 Java의 플랫폼 독립성은 정확히 무엇을 말하는지,

그리고 JVM이 성능을 보장하는 방법은 무엇인지 알아보고자 한다

 

2. 플랫폼 종속

 

앞서 던진 의문을 이해하기 위해서는, 먼저 C/C++과 Java의 실행 방식 차이를 살펴볼 필요가 있다

 

일반적으로 C/C++로 작성한 코드는 다양한 환경에서 문제 없이 동작하는 것처럼 보인다
하지만 이는 동일한 실행 파일이 그대로 동작하는 것이 아니라,
각 플랫폼에 맞게 다시 컴파일되었기 때문에 가능한 것이다.

예를 들어 Windows 환경에서는 .exe 형태의 실행 파일이 생성되고,
Linux 환경에서는 ELF 형태의 실행 파일이 생성된다
이 실행 파일들은 각각 해당 운영체제와 CPU 아키텍처에 맞게 생성된 결과물이기 때문에,

다른 환경에서는 그대로 실행할 수 없다.

 

즉, C/C++는 " Write Once, Run Anywhere "가 아니라,
" Write Once, Compile Everywhere "에 가까운 구조라고 볼 수 있다

 

반면 Java는 .class 형태의 Bytecode로 컴파일되며,

이 Bytecode는 특정 운영체제나 CPU 아키텍처에 종속되지 않는다

그리고 이 코드는 JVM에 의해 실행되며,

JVM이 각 플랫폼에 맞는 방식으로 Bytecode를 해석하고 실행한다

 

결국 두 언어의 차이는 플랫폼 의존성을 해결하는 시점에 있다

C/C++는 컴파일 시점에 플랫폼에 맞는 코드를 생성하고,
Java는 이를 런타임까지 지연시켜 JVM이 처리하도록 설계되어 있다

3. ClassLoader

 

Java에서 작성된 코드는 컴파일을 통해 .class 형태의 Bytecode로 변환되지만,
이 코드가 바로 실행되는 것은 아니다

JVM은 먼저 ClassLoader를 통해 필요한 클래스들을 메모리에 로드한 뒤 실행을 시작한다
이 과정에서 중요한 특징 중 하나는, 모든 클래스를 한 번에 로드하는 것이 아니라
실제로 필요한 시점에 클래스를 로드하는 Lazy Loading 방식이 사용된다는 점이다

 

이러한 방식은 초기 실행 속도를 개선하고,
불필요한 클래스 로딩을 방지하여 메모리 사용을 효율적으로 만든다

 

ClassLoader는 내부적으로 Loading, Linking, Initialization의 3단계로 수행된다

 

 

  1. Loading
    .class 파일을 실제로 로딩하는 과정을 말한다


    - Bootstrap Class Loader: 핵심 Java 라이브러리를 로드
    - Extension Class Loader: 표준 라이브러리를 확장하는 클래스 로드
    - Application Class Loader: 사용자 정의 클래스 로드

    이러한 구조는 상위 ClassLoader부터 순차적으로 탐색하는 방식으로 동작한다
    또한 이 과정은 앞서 말한 바와 같이 필요한 시점에 로드하는 Lazy Loading 방식으로 이루어진다

  2. Linking
    로드된 클래스를 JVM 런타임에 결합하여 실행 가능하게 만드는 과정을 말한다

    - Verify: Bytecode가 JVM 규칙에 맞게 작성되었는지 검증하여 잘못된 코드의 실행을 방지한다
    - Prepare: 정적(static) 변수에 대한 메모리를 할당하고 기본값으로 초기화한다
    - Resolve: 클래스 간의 참조를 실제 메모리 주소로 연결한다

  3. Initialization
    정적 변수에 실제 값이 할당되고, 정적 블록이 실행되면서 클래스의 초기화가 완료된다
    이 단계가 끝나면 해당 클래스는 실제로 사용 가능한 상태가 된다
4. 실행 속도 최적화

 

Bytecode는 기계어로 변환된 코드가 아니기 때문에, 실행 시점에 해석 과정이 필요하다
JVM은 기본적으로 Bytecode를 런타임에 한 줄씩 해석하며 실행하는 인터프리터 방식을 사용한다

 

하지만 이러한 방식은 매번 해석 과정을 거쳐야 하기 때문에 성능 저하를 피하기 어렵다

그렇다면 JVM은 이 문제를 어떻게 해결했을까?

 

이러한 문제를 해결하기 위해 JVM은 JIT(Just-In-Time) 컴파일러를 도입했다

JIT 컴파일러는 프로그램이 실행되는 동안 자주 실행되는 코드(Hot Spot)를 감지하고,

해당 코드를 네이티브 코드로 컴파일한다

이렇게 변환된 코드는 이후 실행 시 더 이상 해석 과정을 거치지 않고 바로 실행되기 때문에,
반복적으로 실행되는 코드의 성능을 크게 향상시킬 수 있다

 

즉, JVM은 처음에는 인터프리터 방식으로 실행을 하지만,

실행 빈도가 높은 코드에 대해서만 선택적으로 최적화를 수행한다

 

5. Code Cache

 

JIT 컴파일러에 의해 네이티브 코드로 변환된 결과는 Code Cache라는 메모리 영역에 저장된다
이렇게 저장된 코드는 이후 동일한 코드가 실행될 때 다시 컴파일 과정을 거치지 않고 재사용된다

이러한 구조로 인해 JVM은 실행 초기에는 상대적으로 느리지만,
시간이 지날수록 점점 더 빠르게 동작하는 특징을 가진다

그렇다면 JVM은 모든 코드를 동일한 수준으로 코드를 최적화할까?

 

그렇지 않다. JVM은 코드의 실행 빈도와 상황에 따라 서로 다른 수준의 최적화를 적용한다


JIT 컴파일러는 크게 두 가지 단계의 컴파일러를 사용한다

  • C1 컴파일러: 비교적 빠르게 컴파일을 수행하여 초기 실행 속도를 빠르게 끌어올리는 역할을 한다
  • C2 컴파일러: 더 많은 분석을 기반으로 고급 최적화를 수행하여 최종적인 실행 성능을 극대화한다

그림에서 나타난 L1 ~ L4는 코드가 점진적으로 최적화되는 단계를 의미한다
초기에 인터프리터를 통해 Bytecode가 실행되는 과정이 L0에 해당한다
이후 실행 빈도가 증가하면 C1 컴파일러에 의해 빠르게 최적화되며, 점진적으로 더 높은 최적화 단계(L1 ~ L3)를 거친다
마지막으로 충분히 실행된 코드는 C2 컴파일러에 의해 가장 높은 수준의 최적화(L4)가 적용된다

 

하지만 Code Cache 역시 무한한 공간이 아니기 때문에,
저장 가능한 네이티브 코드의 양에는 제한이 존재한다

만약 Code Cache가 가득 차게 된다면, 추가적인 JIT 컴파일이 제한되거나 기존 컴파일된 코드가 제거되는 상황이 발생할 수 있다
이 경우 성능이 다시 저하될 수 있기 때문에 적절한 Code Cache 크기 설정이 중요하다

 

이러한 특성으로 인해 JVM 기반 애플리케이션은 실행 초기에는 최적화가 충분히 이루어지지 않은 상태로 동작하게 된다
따라서 실제 서비스 환경에서는 초기 요청을 통해 미리 JIT 컴파일을 유도하는 Warm Up 과정을 거치기도 한다

이를 통해 서비스가 본격적으로 트래픽을 처리하기 전에 최적화된 상태를 확보할 수 있다

 

6. JVM의 또 다른 최적화 방식

 

앞서 살펴본 것처럼 JVM은 JIT 컴파일을 통해 실행 성능을 점진적으로 최적화한다

하지만 이러한 구조는 실행 초기 성능이 낮다는 한계를 가지며,
이를 보완하기 위한 다양한 접근 방식이 등장했다

 

  • Graal JIT
    Graal은 기존 HotSpot JVM의 C2 컴파일러를 대체할 수 있는 고성능 JIT 컴파일러이다

    기존 JIT과 동일하게 런타임에 동작하지만 더 공격적인 최적화를 수행할 수 있으며,
    복잡한 코드에 대해서도 높은 성능을 기대할 수 있다

    하지만 기존 C2 컴파일러와 달리 Graal JIT은 그래프 기반이고, 더 많은 분석이 들어간다
    때문에 Graal JIT는 성능을 위해 CPU와 메모리를 더 많이 사용하게 된다는 트레이드오프가 존재한다

  • Graal AOT(Ahead-Of-Time)
    AOT는 프로그램을 실행하기 전에 Bytecode를 네이티브 코드로 미리 컴파일하여,
    실행 시점에 별도의 JIT 컴파일 과정을 거치지 않도록 한다

    이로 인해 애플리케이션은 실행과 동시에 최적화된 상태로 동작할 수 있으며,
    특히 빠른 시작 속도가 중요한 환경에서 큰 장점을 가진다

    하지만 AOT 방식은 실행 시점의 성능을 확보하는 대신 빌드 단계에서 더 많은 시간이 소요된다

    또한 AOT 방식은 모든 코드를 실행 전에 분석해야 하기 때문에,
    런타임에 동적으로 동작하는 Reflection 기반 코드와는 충돌할 수 있다

    예를 들어 Spring과 같은 프레임워크는 Reflection을 통해 클래스와 메서드를 동적으로 탐색하는데,
    AOT 환경에서는 이러한 정보가 사전에 명시되어야 한다

    이로 인해 추가적인 설정이나 제약이 발생할 수 있다

결국 JVM은 플랫폼 독립성과 성능이라는 두 가지 목표를 동시에 달성하기 위해,
실행 시점의 동적 최적화(JIT)와 사전 최적화(AOT)라는 서로 다른 전략을 발생시켜 왔다

 

7. 마무리하며

 

결국 JVM은 Bytecode라는 중간 단계를 통해 플랫폼 독립성을 확보하면서도,

인터프리터와 JIT, Code Cache를 결합한 구조를 통해 성능 문제를 해결했다

이러한 설계 덕분에 Java는 플랫폼 독립성과 성능이라는 두 가지 상반된 목표를 동시에 달성할 수 있었다