Java/Java Effective

7. JVM(Java Virtual Machine)

mozzi329 2022. 7. 20. 19:42
728x90
 

 

     

    운영체제과 상관없이 작동하는 JVM(Java Virtual Machine)

    📌 JVM(Java Virtual Machine)

    JVM(Java Virtual Machine)이란 자바로 작성한 소스 코드를 해석해 실행하하기 위한 컴파일러 프로그램이다.

    프로그램이 실행되기 위해서는 CPU, 메모리, 각종 입출력 장치 등과 같은 컴퓨터 자원을 프로그램이 할당받아야 한다. 프로그램이 자신이 필요한 컴퓨터 자원을 운영체제에게 주문하면, 운영체제는 가용한 자원을 확인한 다음, 프로그램이 실행되는 데에 필요한 컴퓨터 자원을 프로그램에게 할당해준다. 이 때, 프로그램이 운영체제에게 필요한 컴퓨터 자원을 요청하는 방식이 운영체제마다 다르다. 이것이 바로 프로그래밍 언어가 운영체제에 대해 종속성을 가지게 되는 이유다.

     

    자바는 JVM을 매개해서 운영체제와 소통한다.

    JVM이 자바 프로그램과 운영체제 사이에서 일종의 통역가 역할을 수행한다. 자바는 Windows용 JVM, Mac OS용 JVM, Linux용 JVM이 따로 존재한다. JVM은 자바 소스 코드를 운영 체제에 맞게 변환해 실행시켜준다. 이것이 자바가 운영체제로부터 독립적으로 동작할 수 있는 이유이다.

     

    📌 JVM의 구조

    JVM의 상세 구조

    VM의 내부 구조는 위 그림과 같다. 자바로 소스 코드를 작성하고 실행하면 어떤 일이 일어나는지 위 그림을 통해 가볍게 이해해보자.

     

    1. 자바로 소스 코드를 작성하고 실행하면 먼저 컴파일러가 실행되면서 컴파일이 진행된다.
    2. 컴파일의 결과로 .java 확장자를 가졌던 자바 소스 코드가 .class 확장자를 가진 바이트 코드 파일로 변환된다.
    3. 이후, JVM은 운영 체제로부터 소스 코드 실행에 필요한 메모리를 할당받는다.
      ⌲ 이게 바로 위 그림 상의 런타임 데이터 영역(Rumtime Data Area)이다.
    4. 그 다음에는 클래스 로더(Class Loader)가 바이트 코드 파일을 JVM 내부로 불러들여 런타임 데이터 영역에 적재시킨다.
      ⌲ 자바 소스 코드를 메모리에 로드시키는 것이다.
    5. 로드가 완료되면 이제 실행 엔진(Execution Engine)이 런타임 데이터 영역에 적재된 바이트 코드를 실행시킨다.
      ※ 이 때, 실행 엔진은 두 가지 방식으로 바이트 코드를 실행한다.
      ⌲ 인터프리터(Interpreter)를 통해 코드를 한 줄씩 기계어로 번역하고 실행시키기
      ⌲ JIT Compiler(Just-In-Time Compiler)를 통해 바이트 코드 전체를 기계어로 번역하고 실행시키기
    6. 실행 엔진은 기본적으로 1번의 방법을 통해 바이트 코드를 실행시키다가 특정 바이트 코드가 자주 실행되면 해당 바이트 코드를 JIT Compiler를 통해 실행시킨다.
      ⌲ 즉, 중복적인 바이트 코드가 등장할 때 인터프리터는 매 번 해당 바이트 코드를 해석하고 실행하지만, JIT 컴파일러가 동작하면 한 번에 바이트 코드를 해석하고 실행시킨다.
    7. 심화 학습 자료 JVM

     

    📌 JVM 메모리 구조(Runtime Data Area)

    JVM 메모리 구조

    JVM에 Java 프로그램이 로드되어 실행될 때 특정 값 및 바이트코드, 객체, 변수등과 같은 데이터들이 메모리에 저장되어야 한다. 런타임 데이터 영역이 바로 이러한 정보를 담는 메모리 영역이며, 크게 5가지 영역으로 구분되어 있다.

     

    ✔️ JVM 메모리 구조 설명

    • Method Area : 모든 Thread에 공유 되는 정적인 전역 데이터이다. 클래스나 변수의 정보, Static변수, 상수등이 저장된다.
    • Heap Area : 모든 Thread에 공유되며 인스턴스와 객체가 저장되는 구역이다. (레퍼런스 참조, new 키워드)
    • Stack Area : 각 스레드마다 전용 스택 메모리가 존재한다. 지역변수, 파라메타, 리턴 값등이 저장된다.
    • PC Register : 각 스레드마다 하나씩 생성되며 Program Counter의 역할을 담당한다. (현재 실행중인 메모리 주소 값) 자바에서는 CPU 독립성을 위해 레지스터기반이 아닌 스택기반으로 프로그램 명령이 동작한다.
    • Native Method Stack : 각 스레드마다 하나씩 생성되며 다른 언어의 네이티브 메서드를 호출하기 위한 공간이다. 

     

    ✔️ Stack 영역

    스택은 LIFO(Last In-First Out) 구조의 자료구조이다. 자료구조는 프로그램이 데이터를 저장하는 방식을 의미한다. 

    ※ LIFO(Last In-First Out)이란?

    마지막 입력된 데이터가 가장 먼저 나가는 자료구조를 말한다.

    프링글스와 스택 구조의 유사성

    바로 프링글스의 포장 방식이 완벽한 스택의 형태를 취하고 있다.

    프링글스를 열고 감자칩을 꺼낼 때, 맨 아래에 있는 칩을 먼저 꺼낼 수 있을까?(바닥을 뚫으면 된다) 일반적인 방법으로는 불가능하다. 이처럼 맨 마지막에 들어온 데이터가 가장 먼저 나가는 자료 구조를 스택이라고 하며, LIFO는 이러한 스택의 데이터 입출력 순서를 나타내는 원칙이다.

     

    그렇다면 JVM에서 스택은 어떻게 작동할까?

    자바에서는 메서드가 호출되면 그 메서드를 위한 공간인 Method Frame이 생성된다. 메서드 내부에서 사용하는 다양한 값들이 있는데 참조변수, 매개변수, 지역변수, 리턴값 및 연산시 일어나는 값들이 임시로 저장된다. 이러한 Method Frame은 스택에 호출되는 순서대로 쌓이게 되는데, 메서드의 동작이 완료되면 역순으로 제거된다.

     

    ❗️자바에서의 스택

    • 새로운 메서드가 호출되고 반환될 때 마다 스택의 크기가 달라진다.
    • 스택 내부의 변수는 해당 메서드(=스레드)가 실행되는 동안에만 존재한다.
    • 메소드 실행이 완료되면 자동으로 스택에서 할당 해제된다.
    • 각 스레드가 고유의 스택 메모리를 가지고 있으므로 다른 스레드로부터 안전한 메모리를 가진다.
    • 이러한 스택 구조의 메모리 접근은 힙 메모리에 비해 속도가 빠르다.

     

    ❗️Stack은 왜 빠를까?

    스택은 미리 예약된 메모리이기 때문에 메모리 할당이 따로 필요없으며, 사용한 메모리를 굳이 반납하지 않고 단순히 스택 포인터를 감소시킴으로써 스택 공간을 제한시킨다. 스택 포인터를 감소시키면서 남아있던 데이터는 스택 포인터가 다시 증가할 때 사용했던 공간을 단순히 덮어 쓰는 식으로 사용된다.

    때문에 객체는 속한 영역(scope)을 벗어나면 스택 포인터의 위치가 바뀜에 따라 알아서 사라진다. 따라서 함수를 통해 메모리가 할당되고 해제되는 힙 메모리에 비해 매우 빠르다는 이점을 가지고 있다.

     

    스택에 저장되는 데이터들은 그 크기가 컴파일 타임에 이미 정해지기 때문에 스택은 미리 정해진 크기를 따라 움직이게 된다. 따라서 런타임, 즉 실행 중에 메모리를 계산하거나 판단할 일이 없이 한번에 원하는 메모리를 바로 접근할 수 있기에 힙 메모리 할당보다 훨씬 빠른 이유기도 하다.

     

    하지만 스택은 공간의 크기가 처음에 정해진다.
    때문에 힙 메모리처럼 메모리 크기를 유연하게 연장하기 어렵고, 스택 메모리를 과도하게 사용하다가 스택 프레임이 무수히 중첩되어 메모리를 초과하게 되는 stack overflow가 발생할 수 있다. 

     

    ✔️ Heap 영역

    스택이 컴파일 타임에 크기가 지정되는 정적인 메모리공간이라면, Heap은 런타임에 동적으로 할당하는 메모리이다.

    자바에서 새로운 객체는 항상 힙 공간에 생성되고, 이 객체에 대한 참조 주소는 스택 메모리(변수)에 저장되어 사용된다.

     

    ❗️자바에서의 힙

    • GC의 Young Generation, Tenured(Old) Generation, Permanent Generation을 포함하는 복잡한 메모리 관리 기술에 의해 메모리를 관리, 사용 할 수 있다.
    • 운영체제에게 할당받은 힙 공간이 가득차 메모리가 부족하면 OutOfMemoryError를 발생시킨다.
    • Heap은 스레드로부터 안전하지 않으며 코드를 적절하게 동기화하여 메모리를 보호해야한다.
    • 메모리 공간이 정적이지 않고, 크기가 변할수 있기에 메모리 할당과 접근이 스택보다 느리고 어렵다.
    • 스택은 독립적인 스레드에서 실행되고 스택포인터 값을 변경함으로써 메모리 공간이 자동으로 해제 되지만, Heap에서는 누구나 메모리를 참조 할 수 있고 메모리 공간이 유동적이라 자동으로 할당 해제되지 않는다.
    • 이러한 동적인 메모리 사용은 메모리 단편화가 생길 수 있고, 정기적으로 사용하지 않는 메모리를 찾아 해제해야 한다.

     

    Heap에서 만든 메모리는 모든 스레드에서 참조, 접근 가능하기 때문에 메모리 누수가 발생하기 쉽다. 그래서 메모리 할당&해제 관리에 있어 어려움이 따르는데 이를 JVM의 Gabage Collector가 개발자 대신 관리해준다. (Gabage Collector의 배경)

     

    📌 Gabage Collector

    자바에서 사용하는 메모리를 자동으로 관리하는 프로세스로, 프로그램에서 더 이상 사용하지 않는 객체를 찾아 삭제하거나 제거하여 메모리를 확보하는 것을 의미한다.

     

    아래 코드를 살펴보자.

        Person person = new Person();
        person.setName("김코딩");
        person = null;
        // 가비지 발생
        person = new Person();
        person.setName("박해커");

    위의 예시 첫째 줄에서 참조 변수 person은 Person 클래스의 인스턴스의 주소값을 할당받고, 이어서 “김코딩”이라는 문자열이 person이 가리키는 인스턴스의 name이라는 속성에 할당된다.

     

    그런데, 세 번째 줄에서 참조 변수 person에 null이 할당됨으로써, 기존에 person이 가리키던 인스턴스와 참조변수 person 간의 연결이 끊어졌다.

     

    프로그램이 실행 중일 때 이처럼 아무도 인스턴스를 참조하고 있지 않다면, 더 이상 메모리에 person이 가리키던 인스턴스가 존재해야 할 이유가 없다.

     

    가비지 컬렉터는 이렇게 아무한테도 참조되고 있지 않은 객체 및 변수들을 검색하여 메모리에서 점유를 해제하며, 그럼으로써 메모리 공간을 확보하여 효율적으로 메모리를 사용할 수 있게 해준다.

     

    ✔️ Gabage Collector의 작동 원리

    앞서 Heap 영역에 대한 메모리 관리를 위해 Gabage Collector가 등장했다고 설명했다. JVM의 Heap 영역은 객체는 대부분 일회성이며, 메모리에 남아 있는 기간이 대부분 짧다는 전제로 설계되어 있다. 그렇기 때문에 객체가 얼마나 살아있냐에 따라서 Heap 영역 안에서도 영역을 나누게 되는데 Young, Old영역 이렇게 2가지로 나뉜다.

    힙 영역의 Young과 Old

    Young 영역에서는 새롭게 생성된 객체가 할당되는 곳이고 여기에는 많은 객체가 생성되었다 사라지는 것을 반복한다.

    이 영역에서 활동하는 가비지 컬렉터를 Minor GC라고 부른다.

     

    Old 영역에서는 Young영역에서 상태를 유지하고 살아남은 객체들의 복사가 이루어진다.

    보통 Young 영역보다 크게 할당되고 크기가 큰 만큼 가비지는 적게 발생한다. 이 영역에서 활동하는 가비지 컬렉터를 Major GC라고 부른다.

     

    Young 영역과 Old 영역은 서로 다른 메모리 구조로 되어 있기 때문에, 세부적인 동작 방식은 다르지만 기본적으로 가비지 컬렉션이 실행될때는 다음의 2가지 단계를 따른다.

    • Stop The World
      가비지 컬렉션이 실행될때 가비지 컬렉션을 실행하는 스레드를 제외한 모든 스레드들의 작업은 중단되고, 가비지 정리가 완료되면 재개된다. (스레드 멈춰!)

      Stop The World는 가비지 컬렉션을 실행시키기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다.

    • Mark and Sweep
      Mark는 사용되는 메모리와 사용하지 않는 메모리를 식별하는 작업을 의미하며, Sweep은 Mark단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업을 의미한다.

     

    즉, Stop The World를 통해 모든 작업이 중단되면, 가비지 컬렉션이 모든 변수와 객체를 탐색해서 각각 어떤 객체를 참고하고 있는지 확인한다. 이후, 사용되고 있는 메모리를 식별해서(Mark) 사용되지 않는 메모리는 제거(Sweep)하는 과정을 진행한다.

     

    📌 프로그래밍에서의 메모리 구조

    힙은 낮은 주소에서부터 높은 주소로 메모리를 할당하고 스택은 높은 주소에서 낮은 주소로 메모리를 할당한다.

    ✔️ 메모리 영역

    메모리 구조

    ❗️코드(code) 영역

    • 메모리의 코드(code) 영역은 실행할 프로그램의 코드가 저장되는 영역으로 텍스트(code) 영역이라고도 부른다.
    • CPU는 코드 영역에 저장된 명령어를 하나씩 가져가서 처리하게 된다.

    ❗️데이터(data) 영역

    • 메모리의 데이터(data) 영역은 프로그램의 전역 변수와 정적(static) 변수가 저장되는 영역이다.
    • 데이터 영역은 프로그램의 시작과 함께 할당되며, 프로그램이 종료되면 소멸한다.

    ❗️스택(stack) 영역

    • 메모리의 스택(stack) 영역은 함수의 호출과 관계되는 지역 변수와 매개변수가 저장되는 영역이다.
    • 스택 영역은 함수의 호출과 함께 할당되며, 함수의 호출이 완료되면 소멸한다.
    • 이렇게 스택 영역에 저장되는 함수의 호출 정보를 스택 프레임(stack frame)이라고 한다.
    • 스택 영역은 푸시(push) 동작으로 데이터를 저장하고, 팝(pop) 동작으로 데이터를 인출된다.
      이러한 스택은 후입선출(LIFO, Last-In First-Out) 방식에 따라 동작하므로, 가장 늦게 저장된 데이터가 가장 먼저 인출된다.
    • 스택 영역은 메모리의 높은 주소에서 낮은 주소의 방향으로 할당된다.

    ❗️힙(heap) 영역

    • 메모리의 힙(heap) 영역은 사용자가 직접 관리할 수 있는 '그리고 해야만 하는' 메모리 영역이다.
    • 힙 영역은 사용자에 의해 메모리 공간이 동적으로 할당되고 해제된다.
    • 힙 영역은 메모리의 낮은 주소에서 높은 주소의 방향으로 할당된다.

    ❗️커널(kernel) 영역

    • 시스템 운영에 필요한 메모리를 관리하는 영역으로 운영체제가 관리하는 영역이다.
    • 사용자는 함부로 커널영역에 접근할 수 없다.
    • 스택은 낮은 주소에서 높은 주소로 데이터가 쌓이기 때문에 커널 영역을 침벙하지 않는다.

     

    ✔️ Stack Over Flow / Heap Over Flow

    • Stack Over Flow : 스택이 힙의 영역을 침범하면 발생한다.
    • Hip Over Flow : 힙이 스택의 영역을 침범하면 발생한다.

     

    ✔️ 왜 스택은 높은 주소에서 낮은 주소로 채워질까?

    만약 스택이 낮은 주소에서 높은 주소로 메모리를 할당한다면 신성불가침 영역인 커널 영역(운영체제 영역)을 침범하게 된다. 이러한 이유로 스택은 높은 주소에서 낮은 주소로 할당되어 커널 영역을 침범하지 않으면서 전체적인 영역을 일정하게 차지하게끔 설계되었다.