Java와 JVM 기본 개념

정윤호·2023년 10월 17일

Java와 JVM 기본 개념

프로그래밍에 처음 입문했을 때에는 자바라는 언어가 그렇게 매력적으로 다가오진 않았다.
아무래도 프로그래밍 입문자 입장에서 봤을 때, 편이성 측면에서 동적 타입 언어인 Python에 비해 코드를 이해하고 사용하기에는 조금 더 불편하면서도, 성능 측면에서는 C나 C++에 비해 좋지 않아 애매한 포지션에 있는 언어라고 느꼈던 것 같다.

현재는 백엔드 개발에 있어서 Java를 주로 사용하고 있다. 현재 일하고 있는 회사에서 개발하고 있는 제품이 Java로 개발되어 있어서 Java 프로그래밍을 시작하게 되었지만 공부를 하면 할수록 Java만의 매력을 크게 느끼고 있다. 4년 전에 실리콘밸리에서 인턴쉽 프로그램을 하고 있을 때, 당시 LinkedIn Staff Data Engineer로 계셨던 멘토분께서 AI나 Data Science같은 유행에 휩쓸리지말고 CS와 개발 펀드멘탈을 먼저 키우고 나서 하고 싶은 것을 하라고 조언해주셨던 적이 있다. 그러면서 자료구조와 알고리즘을 공부할 때에는 C를 이용해서 깊게 해보고, OOP를 공부할 때에는 Java와 Spring을 깊게 해보라고 하셨는데 지금은 그게 어떤 의미인지를 조금은 알 것 같다.

여러 프로그래밍 언어 중에서도 Java는 OOP 개념에 가장 충실한 언어 중 하나라고 생각하며, 백엔드 개발자라면 한 번쯤은 깊게 공부해볼만한 가치가 있다고 생각한다.

흔히 알려진 Java의 특징은 크게 다음과 같다.

  • Java Virtual Machine 기반에서 작동하는 OOP 언어
  • Garbage Collection -> C/C++와는 다르게 개발자의 메모리 관리 및 책임 이슈를 구조적으로 제거
  • OS(혹은 Platform)에 대한 의존성이 없음
  • 정적 타입 언어
  • 컴파일 언어, 인터프리터 언어 특징을 모두 가짐 -> 하이브리드 언어

여기서 가장 큰 특징은 JVM 기반 언어라는 점이다.
사실 Java의 특징이나 장, 단점들은 대부분 JVM과 관련이 있다고 볼 수 있다.

Java 프로그램의 경우, 이를 실행하게 되면 OS 레벨과 App 레벨 사이에 존재하는 JVM이 실행되면서 OS로부터 필요한 메모리를 할당받고 그 위에서 Java 프로그램이 실행된다. 그 과정을 간단하게 풀어서 설명하자면,
1. 컴파일 타임에 javac(Java 컴파일러)가 자바 소스 코드인 .java 파일을 바이트 코드로 쓰여진 .class 파일, 즉 JVM을 위한 기계어로 변환한다.
2. 런타임에 JVM의 인터프리터가 동적으로 바이트 코드를 OS 혹은 Platform에 맞는 기계어로 변환해서 실행한다.

Java는 WORA(Write Once Run Anywhere) 철학을 가지고 개발되었는데 이것이 가능한 이유는 JVM이 런타임에 바이트 코드를 OS나 CPU 아키텍처에 맞는 기계어로 바꿔주기 때문이다. 그렇기 때문에 Java 프로그램의 경우 특정 OS나 하드웨어에 대한 의존성 없이 Java 코드로 작성되어 있으면 동일하게 실행이 가능하여 이식성이 높다는 장점이 있다.
또한 JVM은 Garbage Collection 기능을 제공하기 때문에 개발자의 메모리 관리 및 책임 이슈를 구조적으로 제거하여, C나 C++와는 달리 개발자가 코드 레벨에서 메모리 관리에 대해 직접 관여할 필요없이 비즈니스 로직에만 집중할 수 있도록 해준다.
그리고 Java는 소스코드를 JVM 수준에서 이해가능한 바이트 코드로 컴파일하는 과정을 거치는데, 이 과정에서 변수의 타입이 결정되기 때문에 동적 타입 언어에 비해 실행 속도가 빠르며 타입 에러로 한 문제점을 초기에 발견할 수 있어 타입의 안정성도 높은 등 정적 타입 언어의 장점을 가지고 있다.

Java는 JVM 기반에서 동작한다는 점으로 인해 여러 가지 장점들이 존재하지만, 반대로 JVM 기반에서 동작하기 때문에 여러 가지 고려해야할 사항들이 있다.

개발자는 기능 개발 뿐만 아니라 개발한 어플리케이션을 잘 운영하는 것이 중요한데, Java 어플리케이션을 운영하면서 발생하는 다양한 장애 상황을 해결하기 위해서는 기본적으로 JVM 구조에 대해 제대로 이해하는 것이 중요하다.

Java 어플리케이션은 JVM 기반에서 동작하기 때문에 Garbage Collector에 의해 메모리가 관리된다. 이 떄 메모리 누수가 발생할 수도 있는데 이러한 경우 JVM Heap 덤프 분석을 통해 트러블 슈팅을 하게 된다. 또한 Garbage Collection이 발생할 때는 GC 스레드를 제외한 모든 스레드가 멈추기 때문에 이로 인해 서버의 응답 속도가 느려질 수 있기 때문에 최악의 경우 GC를 튜닝하기도 한다. 뿐만 아니라 JVM에서 인지할 수 없어 Garbage Collector로 관리되지 않는 Native Memory 영역에서 누수가 발생할 수도 있다. 이 경우에도 JVM 레벨에서 예상되는 원인들을 먼저 추적하고 Linux OS 프로세스 레벨에서 트러블 슈팅하는 것이 현실적인 해결 방향이기 때문에 JVM 구조에 대한 이해는 필수적이다. 그렇기 때문에 Java 개발자라면 JVM에 대해 깊게 하는 것이 중요하다.

JVM 구조는 다음과 같다.

JVM은 크게 총 3 가지 구성 요소로 이루어져 있다.

  • Class Loader
  • Runtime Data Area
  • Execution Engine

Class Loader

Class Loader는 런타임에 클래스 파일을 동적으로 메모리에 로드하는 역할을 담당한다. 이러한 과정은 Loading, Linking, Initialization 각 단계를 거치면서 진행되며, 클래스가 최초로 참조되는 시점에 일어나게 된다.

Loading

  • Java 바이트 코드인 .class을 JVM Runtime Data Area의 Method Area에 로드
  • JVM Runtime Data Area의 Method Area에 다음 정보들을 저장
    • 로드된 클래스의 FQCN(클래스가 속한 패키지명을 모두 포함한 이름)
    • 로드된 클래스와 관련된 Class, Interface, Enum
    • 메서드와 변수
  • 로드할 클래스가 여럿이면 Main() 메서드를 포함하는 클래스(Entry Point)를 우선 로드
  • 종류
    • Bootstrap Class Loader
      • 자바에서 기본적으로 제공하는 API 등과 같은 표준 JDK 클래스들을 로드
      • JVM 시작 시 가장 최초로 실행되는 Class Loader로 다른 모든 ClassLoader 의 부모가 되는 ClassLoader
      • C/C++와 같은 Native 언어로 구현되어 있음
    • Extension Class Loader
      • 확장 라이브러리 클래스 로드
    • Application Class Loader
      • Java 프로그램 실행 시 지정한 클래스 경로(-classpath, -cp)에 있는 클래스 로드
      • 일반적으로 개발자가 작성한 .class 파일 로드

Linking

  • 로드된 클래스나 인터페이스, 그리고 해당 클래스의 부모 클래스나 인터페이스, 요소 타입을 검증, 준비, 해석하는 단계
  • Linking은 다음 3 단계로 구성
    • Verification(검증): 로드된 클래스가 JVM 명세에 맞게 잘 구성되어 있는지 .class 파일에 대한 정합성 검사
    • Preparation(준비): 클래스가 필요로 하는 메모리를 할당하고 기본값으로 초기화(생성자 호출 전)
    • Resolution(해석): 클래스의 Runtime Constant Pool 내 모든 Symbolic Reference를 Direct Reference로 대체

Initialization

  • 로드된 클래스가 실제 값으로 초기화되는 단계
    • 정적 변수에 0이 아닌 초기 값을 기술할 경우 실제 값으로 초기화
    • 생성자 호출
  • 클래스 생성자를 호출하여 정적 필드에 0이 아닌 초기 값을 기술할 경우 실제 값으로 초기화

Runtime Data Area

Runtime Data Area는 JVM이 프로그램을 수행하기 위해 OS로부터 할당받는 메모리 영역이다. Runtime Data Area는 목적에 따라 5개의 영역으로 나뉜다.

Method Area

프로그램 실행 중 특정 클래스가 사용되면 해당 클래스의 .class 파일을 읽고 분석한 뒤 해당 클래스 코드에 대한 정보를 Method Area에 저장한다.

  • Type Information
    • Class, Interface 등
  • Runtime Constant Pool
  • Field Information
  • Method Information
  • Class Variable

Heap Area

사용자가 관리하는 클래스 인스턴스들이 저장되는 공간으로, 런타임에 객체를 new 연산을 통해 동적으로 생성하면 해당 인스턴스가 Heap 영역의 메모리에 할당되어 사용된다.

  • Garbage Collection의 관리 대상이 되는 영역
    • CPU, Memory는 유한 자원인데, 프로세스가 이를 무한정으로 사용하다보면 장애(지연, 정지) 발생 가능성 -> Heap은 어플리케이션 운영과 밀접하게 관련있는 영역 -> Heap 덤프 분석 필수
  • 멀티스레드 환경에서 Heap 영역은 모든 스레드가 공유 -> Thread-safe 하지 않음
    • 동기화 필수
    • Immutable한 객체 이용

Stack Area

스레드 제어를 위해 사용되는 영역으로 새로운 스레드가 생성될 때마다 이에 대응되는 Stack이 생성된다. 각 스레드에서 메서드가 호출되면 메서드와 메서드 정보는 해당 Stack에 쌓이게 되며 메서드 호출이 종료 될때 Stack에서 제거 된다.

  • Heap과 다르게 각 스레드마다 별도의 Stack을 가짐 -> Thread-safe
  • 지역변수, 피연산자, 스택 프레임 데이터 등 세 가지 요소로 구성
    • JVM은 Stack based VM
    • 연산의 중간 결과도 Stack에 저장
    • Stack의 최대 크기는 컴파일 타임에 결정(바이트 코드로 변환하는 시점)
    • 메서드에 대한 모든 심볼 정보 및 예외 처리 관련 catch 블록 정보 등은 스택 프레임 데이터 영역 사용

PC Register

JVM은 Stack 기반으로 동작하는 Stack 기반 VM으로, Stack에서 Operand를 뽑아내서 이를 별도의 메모리 공간에 저장해서 명령을 처리하는 방식을 취하는데 이 때 사용되는 메모리 공간을 PC Register라고 한다. PC Register는 스레드가 생성될 때마다 생기는 공간으로 스레드가 어떤 명령을 실행하게 될지에 대한 정보를 기록한다.

  • 스레드마다 별도 문맥을 가질 수 있도록 개별 PC Register를 가짐
  • 바이트 코드로 된 명령어들이 있고 그것에 대한 index가 존재 -> 다음 명령어 index를 저장하고 있는 것이 PC Register

Native Method Stack

  • Java 외에 C/C++ 같은 Native 언어로 개발된 메서드를 지원하기 위한 Stack 영역
  • 각 스레드마다 별도의 Native Method Stack을 가짐
  • JNI(Java Native Interface)
    • 바이트 코드 실행 자체는 CPU가 연산해줘야 하는데 이를 위해서 OS와 인터페이스하기 위해 있는 것이 JNI
    • 이를 이용해 Native 코드 직접적 실행 가능
    • JVM에 추가 기능 갖게할 수 있음

Execution Engine

메모리에 로드된 바이트 코드를 실행하는 Runtime Module로 Interpreter 방식과 JIT Compiler 방식을 혼합해서 사용한다.

Interpreter

  • 바이트 코드를 실제 환경에 맞는 기계어로 변환하여 실행하도록 함
  • 반복 호출되는 메소드를 매번 인터프리트해야 하기 때문에 비효율적 -> JIT Compiler로 보완

JIT(Just In Time) Compiler

  • 인터프리터의 문제를 해결하기 위해 도입된 것으로 인터프리트 과정에서 성능 최적화 담당
  • 코드 전체에서 반복되어 호출되는 메서드는 JIT Compiler에 의해 기계어로 변환되어 Native Method Stack에 저장됨 -> 변환된 기계어는 Interpreter에 의해 실행되지 않고 기계어 상태로 즉시 실행

Garbage Collector

  • Heap 영역에서 거의 사용되지 않거나 오래된 인스턴스를 식별하고 해당 인스턴스가 사용하고 있는 메모리를 회수하는 자동화된 메모리 관리 체계
  • 개발자는 프로그래밍 시에 메모리 관리에 관한 코드를 직접 작성하지 않고 비즈니스 로직에 집중할 수 있게 해줌과 동시에 메모리의 효율적인 사용을 가능하게 해주는 기능이 Garbage Collection
  • GC 수행 시 프로그램 일시 정지(stop-the-world)
    • 트래픽이 많이 발생하는 서비스의 경우 GC 튜닝 필요

위의 개념들을 바탕으로 Java 프로그램이 개발되고 실행되는 과정을 JVM 구성 요소들로 표현하면 다음과 같다.

Reference

profile
Grind Hard, Shine Hard

0개의 댓글