Java/Java 를 파헤쳐보자

[Java 파헤쳐보기] JVM을 파헤쳐보자

동구름이 2023. 12. 1. 21:51

JVM과 자바의 메모리 영역에 대한 학습을 정리했다.

 

 

우선 전반적인 실행 과정을 간단히 살펴보고, 하나씩 깊게 살펴보자!

 

 

1. Java 프로그램의 전반적인 실행 과정

 

가장 먼저, 자바 컴파일러Source Code (.java) 파일을 Java Compiler를 통해 기계어 파일인 Byte Code(.Class)로 변환한다. 

 

 그럼 변환된 기계어 파일을 JVM의 클래스 로더가 JVM 메모리 영역으로 가져온다. 클래스 로더는 말 그대로 Class 파일을 불러와서 메모리에 저장하는 역할을 수행한다.

 

이제 이렇게 JVM에 로딩된 바이트 코드들을 Execution Engine이 명령어 단위로 읽어서 실행한다.  위 실행 과정 중에서 JVM은 필요에 따라 가비지 컬렉션, 스레드 동기화 등으로 메모리와 자원을 관리하게 된다.

 

 

2. 왜 자바 컴파일러를 통해 바이트 코드로 변환할까?

자바의 특징을 알면 자연스레 해결되는 질문이다. 자바의 가장 큰 특징은 플랫폼에 종속되지 않는다는 것이다. JVM(자바 가상 머신)이 이것을 가능하게 하고, JVM이 이해할 수 있는 언어인 바이트 코드로 변환하는 것이다.

 

 

여기서 잠깐 JVM이 등장하게 된 배경을 한번 살펴보자

 

(1) JVM의 등장 배경

우선 다른 언어에서의 동작 방식을 잠깐 비교해보자

 

 C/C++같은 경우에는 동일한 플랫폼에서 컴파일과 실행을 한다면 프로그램은 아무 문제없이 동작한다. 하지만 만약 플랫폼이 달라지면 프로그램이 동작하지 않는다는 문제가 있다.

 

 그래서 C/C++에서는 크로스 컴파일이라는, 타겟 플랫폼의 환경에 맞게 컴파일을 달리 해야하는 번거로운 문제가 발생한다.

 

출처 : https://blog.cordelia273.space/16

 

 그래서 제작한 소스코드를 각각 운영체제에 맞는 컴파일러로 컴파일해서 실행시켜야만 한다. 그렇게 되면 프로그램을 윈도우, 맥, 리눅스 버전을 만들기 위해 각각의 컴파일러로 3번 컴파일을 해줘야하는 불편함이 생긴다.

 

 

 

 Java는 이런 문제를 JVM을 통해 해결한 것이다. JVM은 말 그대로, 컴퓨터에서 자바 코드를 실행하는 가상 Machine이다.

 

 Java가 처음 만들어질 시기에는 네트워크가 발전하는 시기였다. 모든 디바이스가 네트워크로 연결되는 시기에, 디바이스마다 운영체제나 하드웨어가 달라 플랫폼에 의존하지 않는 것을 목표로 언어를 설계한 것이다.

 

 물론 JVM은 타겟 플랫폼에 의존한다! (Windows용 JVM, Mac용 JVM, Linux용 JVM 등이 구분). 그래서 사용자들은 그 바이트 코드를 자신의 운영체제에 맞게 설치된 JVM 위에서 실행시켜야한다.

 

 

 

 

3. 자바 코드가 실행되는 과정

 여기서 주의해야 할 점이 한 가지 있다. 자바 컴파일러를 통해 컴파일된 결과라고 해서, C나 C++이 컴파일 해서 생성되는 기계어와 동일하게 생각해서는 안된다. 앞서 말했듯이, 자바 컴파일러는 JVM이 이해할 수 있는 바이트 코드로 변환해준다.

(바이트 코드가 어떻게 생겼는지는 다음 포스팅에서 간단하게 다루어 보겠다..!)

 

결국 헷갈라지 말아야할 것은, 자바에서의 기계어는 JVM 내부에서 생성하고 실행하게 된다는 것이다!

 

 

위 그림을 통해 다시 설명하자면, 자바 컴파일러가 자바 클래스 파일을 JVM이 이해할 수 있는 언어로 만드는 것이고, JVM은 내부 인터프리터와 컴파일러를 통해 어셈블리어로 변경하는 과정을 거친다.

 

 

(1) 자바의 어셈블리어 변환 과정 (다른 언어와 비교해서)

위 설명을 보충하기 위해 여기서 조금만 더 깊게 들어가보자. 다른 언어들은 어떤 과정을 거쳐 어셈블리어로 바뀌게 될까?

 

Java 뿐 아니라 C/C++같은 모든 언어들은 아래와 같은 컴파일 과정을 거친다. 소스 코드가 어셈블리어로 바뀌는 과정이다.

 

 컴파일러에도 프론트엔드와 백엔드를 나누어 볼 수 있다.

 

 컴파일러의 프론트엔드는 소스 코드를 분석해 의미를 파악한다. 소스코드가 해당 언어로 올바르게 작성되었는지를 확인하고, 백엔드로 넘겨주기 위해 AST와 같은 추상 트리로 표현하고, 중앙 표현 IR로 번역하는 과정을 거친다. 

 

 위 내용까지는 여기서 이해하지 않더라도, 결국은 여기까지는 의미를 파악하는 부분이기에 플랫폼과는 의존적이지 않은 부분이라는 것만 알아두면 된다.

 

 

 하지만, 백엔드에서는 중앙계층 표현을 실제 어셈블리언어로 변경해야한다. 그리고 이 어셈블리언어는 운영체제, 기기에 의존한다. 그래서 컴파일러의 백엔드는 윈도우형, 리눅스형 등으로 구분되어지게 된다.

 

 다른 C/C++언어같은 경우에는 모든 과정을 컴파일러가 진행한다. 반면 자바는 컴파일러의 프론트엔드는 Javac가 하고, 백엔드는 JVM이 함으로써 역할이 분리되어 있는 것이다!

 

 

 

4.  JVM의 실행 엔진 (Execution Engine)

그럼 이어서 JVM이 바이트코드를 어셈블리어로 바꾸는 과정을 살펴보자. 이 과정은 JVM의 실행 엔진에서 담당한다.

 

그림에서도 이미 보이지만, JVM의 실행 엔진은 인터프리터와 JIT 컴파일러 두 가지를 사용한다. 어쩌다 두 가지 방법을 사용하게 된 걸까?

 

 

(1) 인터프리터

가장 처음에는 JVM의 실행 엔진은 인터프리터만 사용했다고 한다. 이유는 위에서 언급한 자바의 철학인 플랫폼에 종속되지 않는 특성과 연관되어 있다. 

 

인터프리터와 달리 컴파일러는 한번에 전체 소스 코드를 읽고, 실행 가능한 파일인 바이너리 코드를 만든다. 그런데 이 파일은 플랫폼에 종속적이기 때문에, 자바의 철학과는 맞지 않았다.

 

그래서 자바 인터프리터는 바이트 코드를 한 줄씩 읽어 기계어로 번역해 실행했고, 자바의 플랫폼에 종속되지 않는 철학을 유지할 수 있었다.

 

 

(2) JIT 컴파일러

하지만 위의 과정을 보면, 자바는 상당히 느린 언어일 수 밖에 없다. 우선 소스 코드를 JVM이 읽을 수 있는 바이트 코드 변경해야하고, 또 바이트 코드를 컴파일러보다 느린 인터프리터로 실행하기 때문이다.

 

 이런 문제를 개선하기 위해 JIT 컴파일러가 도입되었다. 자바의 JVM에서 인터프리터만으로는 성능 한계가 있었기 때문이다.

 

JIT(Just-In-Time) 컴파일러는 실행 시점에 기계어 코드를 생성하면서, 해당 코드가 컴파일 대상이 되면 그 코드를 컴파일하고 캐싱한다.  코드가 실행되는 과정에 실시간으로 일어나기 때문에 Just-In-Time 컴파일러라고 불린다. 

 

그래서 기계어로 변환된 코드는 캐시에 저장되어 있기 때문에 재사용을 해도 다시 컴파일을 할 필요가 없게된다.

 

 

즉 정리하자면 JIT 컴파일러는 실행 과정에서 컴파일을 할 수 있게 만들어진 컴파일러이다. 실행 전에 컴파일을 하게 되면 플랫폼에 종속적이게 되고, 시간이 오래 걸리는 컴파일러의 단점을 보완한 것이다.

 

 

 

5. JVM의 Runtime Data Area (Memory Area)

이제 JVM의 메모리 영역을 살펴보자!

 

자바 프로그램이 실행되면 JVM(자바 가상 머신)은 OS로부터 메모리를 할당 받고, 그 메모리를 용도에 따른 여러 영역으로 관리하게 된다.

 

(여기서 JVM의 Garbage Collector 는 Runtime Data Area(JVM memory)의 Heap 영역에 생성된 객체들 중 참조되지 않는 객체들을 메모리에서 소멸시키는 역할을 수행한다. 가비지 컬렉터에 관한 내용도 깊게 다루어볼 것이 많기 때문에, 다음 포스팅에서 따로 다루도록 하겠다.)

 

 

 Runtime Data Area (Memory Area) JVM이 프로그램(Java Bytecode)을 수행하기 위해 운영체제로부터 할당받은 메모리 공간이다. 즉, 자바 애플리케이션을 실행할 때 사용되는 데이터들이 저장되는 메모리 공간을 말한다.

 

 

 

구조를 쉽게 나타낸 그림은 아래와 같다.

https://byeongyeon.tistory.com/46

 

 JVM의 메모리 공간(Runtime Data Area)는 크게 Method(Static) 영역, Stack 영역, Heap 영역으로 구분이 되고, 데이터 타입(자료형)에 따라 각 영역에 나눠져 할당된다.

 

 

출처 : https://velog.io/@ddangle/Java-%EB%9F%B0%ED%83%80%EC%9E%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%98%81%EC%97%ADRuntime-Data-Area%EC%97%90-%EB%8C%80%ED%95%B4

 

 이 중에 Method Area와 Heap은 모든 스레드가 공유한다. 나머지는 스레드 마다 하나씩 존재합니다. 이를 구분해서 메모리 영역을 정리해보자.

 

 

(1) Method Area

메서드 영역은 클래스 로더가 클래스 파일을 읽어오면 클래스 정보를 파싱해 저장한다. 

 

 런타임 상수 풀, 멤버 변수(필드), 클래스 변수(Static 변수), 상수(final), 생성자(constructor)와 메소드(method) 등이 저장된다. Runtime Constant Pool 에는 말 그대로 '상수' 정보가 저장되는 공간이고, 모든 스레드에서 정보가 공유된다.

 

 Method 영역에 있는 데이터는 어느 곳에서나 접근이 가능하고, 이 곳의 데이터는 프로그램의 시작부터 종료까지 메모리에 남아있다.

그래서 static 메모리에 있는 데이터들은 프로그램이 종료될 때까지 어디서든 사용이 가능하다.

 

(2) Heap

 쉽게 말해, 프로그램을 실행하며 생성한 모든 객체가 저장되는 공간이다. JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이다.

 

그래서 new 연산자로 생성된 객체(인스턴스), Array와 같은 동적으로 생성된 데이터가 저장된다.

 

 

 Heap 영역은 Garbage Collector가 처리하지 않는 한, 메모리가 호출이 끝나더라도 삭제되지 않고 유지된다. 그러다 어떤 참조 변수도 Heap 영역에 있는 인스턴스를 참조하지 않게 된다면, Garbage Collector에 의해 메모리에서 청소된다.

 

 

 단, Heap 영역에 있는 오브젝트들을 가리키는 레퍼런스 변수는 Stack 영역에 저장된다.

 

 

https://www.slideshare.net/laxmanpuri71/java-byte-code-virtual-machine

(3) PC(Program Counter) Register

 스레드가 어느 명령어를 처리하고 있는지 그 주소를 등록하는 부분이다. 각 스레드는 메서드를 실행하고 있는데, PC는 그 메서드의 몇 번째 줄을 실행해야하는지 나타낸다. 즉, JVM이 실행하고 있는 현재 위치를 저장하는 역할을 수행한다.

 

 이 공간은 스레드가 생기면서 생성된.

 

(4) Stack

 스택은 스레드 별로 하나씩 존재하는데, 스택 프레임은 메서드가 호출될때마다 생성되고, 메서드 실행이 끝나면 스택에서 pop되어 제거된다. (스택 프레임에 대한 내용은 추후 포스팅에서 다루도록 하겠다.)

 

 그림에서 가장 높이 위치하는 스택 프레임이 메인 메서드로 생각할 수 있다. 그럼 그 아래의 메서드들은 메인 메서드 안에서 호출한 내부의 메서드이다. Stack 영역은 LIFO(Last In First Out), 즉 나중에 들어온 데이터가 먼저 나가는 특성을 가지기 때문에 메서드 호출에 적합하다.

 

 

 메소드 내에서 정의하는 기본 자료형에 해당되는 지역변수, 메소드의 매개변수와 같이 잠시 사용되고 필요가 없어지는 데이터가 저장되는 공간이다. primitive 타입의 데이터(int, double, byte, long, boolean 등) 에 해당되는 지역변수, 매개 변수 데이터 값이 저장 메소드가 호출 될 때 메모리에 할당되고 종료되면 메모리에서 사라지게 된다.

 

만약, 지역변수 이지만 Reference Type일 경우에는 Heap 에 저장된 데이터의 주소값을 Stack 에 저장해서 사용한다.

 

 

(5) 피연산자와 또 다시 자바의 특성

 여기서 한 가지 의문점이 생길 수 있다. 보통 연산의 피연산자들은 CPU의 Register에 저장을 하는데 왜 Stack에다가 저장을 하는 것일까?

 

 이것은 자바 JVM이 처음에 가졌던, 플랫폼에 종속되지 않는다는 목표와 관련이 있다. Register는 운영체제와 기기마다 그 크기를 특정할 수가 없다. 그래서 Register의 몇 번을 쓰자라는 것이 구체적인 부분인 것이고, 이것은 운영체제와 기기에 종속되는 것이다. 그래서 조금 구현이 복잡해도 Stack을 통해 연산을 구현한 것이다! 

 

 

 

(5) Native Method Stack

Java 가 아닌 다른 언어 (C, C++) 로 구성된 메소드를 실행이 필요할 때 사용되는 공간이다.

 

JVM이 성능 향상을 위해 Bytecode가 아닌 다른 언어를 컴파일해 사용할 때가 있는데, 그럴 때 사용된다고만 이해하면 될 것 같다.

 

 

 

 

 

 

 

 

 

6. 선언된 위치에 따른 Java 변수의 종류

추가로 각각 영역에 어떤 정보가 저장되는지를 쉽게 이해하기 위해서, 자바에서의 선언 위치에 따른 변수 종류를 알아보겠습니다.

 

 

변수는 크게 네 종류로 변수의 선언된 위치에 따라서 클래스 변수, 인스턴스 변수, 지역 변수, 매개 변수로 나뉩니다. 

출처 : https://velog.io/@ddangle/Java-%EB%9F%B0%ED%83%80%EC%9E%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%98%81%EC%97%ADRuntime-Data-Area%EC%97%90-%EB%8C%80%ED%95%B4

 

 

쉽게 코드에서의 변수를 예시로 살펴보면 다음과 같습니다.

public class VariableExample {

    // 클래스 변수
    static int classVariable = 10;

    // 인스턴스 변수
    int instanceVariable;

    // 생성자
    public VariableExample(int instanceVariable) {
        // 인스턴스 변수
        this.instanceVariable = instanceVariable;
    }

    // 메서드
    public void methodWithParameters(int parameter) {
        // 매개변수
        int localVariable = parameter + this.instanceVariable;
    }

    public static void main(String[] args) {
        // 지역변수
        int localVariable = 20;

        // 인스턴스 생성 및 인스턴스 변수 사용
        VariableExample obj = new VariableExample(30);

        // 메서드 호출 및 매개변수, 지역변수 사용
        obj.methodWithParameters(localVariable);
    }
}

 

 

 

 

 

 

참고자료

[10분 테코톡] 🎅무민의 JVM Stack & Heap
https://www.youtube.com/watch?v=UzaGOXKVhwU&list=LL&index=2&t=489s

[별별 개발 용어] 크로스 플랫폼(Cross Platform)이란?
https://blog.cordelia273.space/16

그림으로 보는 자바 코드의 메모리 영역(스택,힙)
https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EA%B7%B8%EB%A6%BC%EC%9C%BC%EB%A1%9C-%EB%B3%B4%EB%8A%94-%EC%9E%90%EB%B0%94-%EC%BD%94%EB%93%9C%EC%9D%98-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%98%81%EC%97%AD%EC%8A%A4%ED%83%9D-%ED%9E%99


https://hyeinisfree.tistory.com/26

https://esoog.tistory.com/entry/%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%ACcompiler-%EC%9D%B8%ED%84%B0%ED%94%84%EB%A6%AC%ED%84%B0interpreter

자바 컴파일 과정 & JVM 내부 구조
https://velog.io/@minseojo/Java-%EC%9E%90%EB%B0%94-%EC%BB%B4%ED%8C%8C%EC%9D%BC-%EA%B3%BC%EC%A0%95-JVM-%EB%82%B4%EB%B6%80-%EA%B5%AC%EC%A1%B0

https://velog.io/@ddangle/Java-%EB%9F%B0%ED%83%80%EC%9E%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%98%81%EC%97%ADRuntime-Data-Area%EC%97%90-%EB%8C%80%ED%95%B4

https://velog.io/@ilov1112/%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC-CH3.-Sementic-analyser