Java 혁신의 숨은 공신, JVM의 구조와 동작 원리

개발장·2021년 8월 16일
0

Java의 탄생

1995년, Java가 세상에 공개됐다. Java가 처음 세상에 공개된 그 날부터 지금까지 Java는 현대 프로그래밍 언어 전반에 큰 영향을 끼치고 있다. Java가 이후 최신 소프트웨어 개발에 대한 기준이 될 혁신적인 개념들을 내장하고 있었기 때문이다. Java의 어떤 부분이 어떻게 혁신적이길래 2020년대인 지금까지 영향을 끼치고 있을까. 이를 소개하자면 간단하게 소개할 수 있겠지만, Java에 왜 그런 특징들이 생겼는지까지 이해하려면 Java와 떼려야 뗄 수 없는 JVM에 대해서도 알아야 한다. 왜냐하면 JVM이 동작하는 원리가 Java의 특징을 정의하고, 그 특징들이 현대 프로그래밍 언어들에 큰 영향을 주었기 때문이다. 이에 대해 알게 된다면, 자연스럽게 JVM이 존재하는 이유 또한 알 수 있고, 더 나아가 JVM에 자세한 부분까지 이해하는 데 큰 도움이 될 것이다.

먼저 위에서 언급한 (당시 기준) 혁신적인 개념이 된 Java의 특징들을 소개하도록 하겠다.

Java의 첫 번째 큰 특징은 플랫폼(OS) 독립적인 개발이 가능하다는 것이다. 플랫폼 독립적이라는 건, 구동되는 환경에 구애받지 않고 실행될 수 있도록 하고 다양한 환경 간 호환성을 유지한다는 뜻이다. 예를 들어 C 계열 언어로 작성된 프로그램들은 Windows 환경에서 빌드됐다면 그 프로그램을 그대로 macOS나 linux로 가져가서 실행할 수 없다. 그러나 Java로 작성된 프로그램은 플랫폼에 맞는 JVM만 설치되어 있다면 문제 없이 동작한다.

두 번째 큰 특징으로는, 자동으로 메모리를 관리해준다는 것이다. Java 이전의 엔지니어들이 프로그램 메모리를 스스로 관리했다면, Java 이후의 엔지니어들은 자동으로 메모리를 관리해주는 Garbage Collection을 사용하게 됐다. 이 두 특징이 1995년 당시 혁신적인 개념으로 통했으며, 이 때부터 이 두 개념을 가지고 있는 현대 프로그래밍 언어들이 등장하기 시작했다.

그런데 이 두 혁신적 개념을 가능케 해준 숨은 공신이 있었으니, 그것이 바로 JVM(Java Virtual Machine)이다.

JVM

JVM은 Java Virtual Machine의 줄임말이다. 그렇다면 자바 가상 머신이라는 건데, 가상 머신(Virtual Machine)이라는 건  프로그램을 실행하기 위해 필요한 물리적 기계를 소프트웨어로 구현한 것을 일컫는다. 여기까지 생각이 닿았다면, JVM은 Java 소스 파일(또는 Java Application)을 실행하기 위한 프로그램이라는 것을 유추할 수 있을 것이다. 그렇다면 Java를 실행하기 위한 프로그램이 JVM이라는 건데, JVM이 어떻게 Java 소스 파일을 실행하는지 간략히 그 과정을 설명해보도록 하겠다.

Java 소스 파일이 OS 위에서 실행되기까지의 과정은 다음과 같다. 먼저, javac(Java 컴파일러)가 .java 파일을 .class 파일로 변환시켜 준다. 여기서 .class 파일은 JAVA Byte Code라고 하며 Byte Code는 기계어가 아니기 때문에 OS가 이해할 수 없다. 이 때 JVM이 OS가 Byte Code를 이해할 수 있도록 해석해주는 역할을 한다. 즉, 컴파일러는 Byte Code를 만들고, 이 코드가 OS에 따라 각기 다르게 해석되기 때문에 .java 파일은 OS에 맞는 JVM이 설치되어 있다면 어느 OS에서라도, 어느 기기에서라도 실행될 수 있게 되는 것이다. 위에서 언급했던 혁신적 개념 첫 번째가 이 과정을 통해 구현되는 것이다.

JVM의 구조

그렇다면 JVM이 OS가 Byte Code를 이해할 수 있도록 해석하는 과정은 어떤 방식으로 진행되는지 JVM의 구조와 함께 살펴보도록 하자.

JAVA 프로그램의 실행과정 및 JVM의 구조 (이미지 출처: preamtree)


위에서도 말했듯 JAVA 소스 파일(.java)이 Java 컴파일러에 의해서 바이트코드로 변환된 뒤, JVM의 클래스 로더(Class Loader)에 의해 로드된다. 클래스 로더는 이렇게 런타임 중에 동적으로 저장된 클래스를 JVM 위에 탑재하고, 사용하지 않는 클래스를 메모리에서 삭제하는 역할을 한다.

그리고 이렇게 JVM에 로딩된 클래스의 바이트코드를 실행 엔진(Execution Engine)이 해석하여 바이너리 코드로 변환한다. 바이트코드는 비교적 인간이 보기 편한 형태로 기술되어 있기 때문에, 실행 엔진이 이를 기계가 수행할 수 있는 형태로 해석하는 역할을 한다.

실행 엔진의 해석 방식

실행 엔진이 바이트코드를 해석할 때 어떤 방식으로 해석하게 될까? Java가 개발된 뒤 초기 기간에는 인터프리터 방식으로 바이트코드를 해석했다. 인터프리터 방식은 바이트코드를 명령어 단위로 한 줄 씩 읽어서 수행하는 방식이었는데, 이 방식이 속도가 느리다는 인터프리터 언어의 단점을 그대로 계승하고 있었기 때문에 이 단점을 보완하기 위해 새로운 해석 방식이 도입되게 된다. 이것이 자바만의 그 유명한 JIT(Just-In-Time) 방식이다.

JIT 방식은 바이트코드를 인터프리터 방식으로 해석하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드(Native Code)로 변경하고, 이후에는 더 이상 인터프리터 방식으로 해석하지 않고 네이티브 코드로 직접 실행하는 방식이다. 여기서 네이티브 코드라는 것은, CPU와 운영체제(OS)가 직접 실행할 수 있는 코드를 의미한다. 그리고, 적절한 시점이라는 것은, 로딩된 바이트코드 전체에 JIT 방식을 적용하면 그만큼 큰 시간 비용이 발생하기 때문에 JVM이 내부적으로 특정 메소드가 얼마나 자주 실행되는지 체크해서, 일정 정도를 넘을 경우에만 적용한다는 것이다.

Runtime Data Area

런타임 데이터 공간(Runtime Data Area)는 JVM이 OS로부터 메모리를 할당받은 공간을 말한다. 

런타임 데이터 공간 (이미지 출처: asfirstalways)


위 그림은 런타임 데이터 공간의 모습을 나타낸 그림이다. 스레드(스레드에 대한 자세한 설명은 이곳을 참조)가 시작될 때, 각 스레드마다 PC 레지스터 영역, 스택 영역, Native Method Stack이 존재한다.

PC 레지스터 영역

위에서도 말했듯 각 스레드마다 하나씩 생성되는 영역이다. 현재 수행 중인 JVM 명령의 주소값을 저장한다. 여기서 JVM 명령이라는 것은, 스레드가 어떤 부분을 어떤 명령으로 실행해야할 지 결정하는 부분을 말한다.

스택 영역

메소드 안에서만 사용되는 매개변수, 지역변수, 리턴값 등 지역적인 값이나 스레드나 메소드 그 자체에 대한 정보가 저장되는 영역이다. 메소드가 호출될 때 LIFO(Last in First out) 방식으로 하나씩 생성되어 스택 영역이라고 부른다. 메소드 실행이 완료될 때마다 이 스택에서 해당 메소드의 값을 pop하여 하나씩 지워가는 방식으로 동작한다.

Native Method Stack

다른 프로그램들처럼 커널이 독자적으로 Java 프로그램을 실행하는 공간이다. C, C++ 등 다른 언어의 메소드 호출을 위해 할당되는 구역 언어에 맞게 Stack이 형성되는 영역이다. 쉽게 말해 기계어로 번역된 프로그램이 실행되는 곳이다.

이상이 스레드가 시작될 때마다 각 스레드마다 생성되는 영역이고, 이하는 하나의 Java 프로그램이 시작될 때 모든 스레드가 공유하는 영역이다.

메소드 영역

메소드 영역은 클래스 영역이나 스태틱 영역이라고도 부른다. 왜 그러냐면, 이 영역에 메소드 정보, 클래스 정보, static으로 선언된 변수 정보, 상수 정보가 담기기 때문이다. 우리가 Java 코드를 작성할 때, 클래스 정보가 처음 메모리 공간에 올라갈 때 초기화하고자 하는 대상들에 대한 정보들이 담긴다. Java 코드를 작성해 본 사람은 static으로 선언된 변수는 항상 이미 그 정보가 JVM 내에서 다른 변수들보다 우선하여 저장된다는 것을 알고 있을 것이다. Java 프로그램은 main 메소드부터 호출하여 연속된 메소드 호출로 전체적인 프로그램의 흐름을 이어가기 때문에, 거의 대부분의 바이트코드가 이 영역에 저장된다. 변수와 메소드의 이름, (리턴) 데이터 타입, 접근 제어자, 형식 등등 정말 거의 대부분의 정보가 저장된다.

힙 영역

힙 영역은 위 메소드 영역과는 다르게, 객체를 저장하는 가상 메모리 공간이다. 마찬가지로 이해하기 편하게 설명하자면, 우리가 Java 코드를 작성할 때 new 명령어를 사용해서 생성한 모든 인스턴스와 객체들이 이 영역에 저장된다. 그리고 이 글 초반에서 언급했던 Java의 아주 중요한 두 번째 특징인 "자동으로 메모리 관리"를 담당한 Garbage Collection Issue가 이 영역에서 일어난다.

결론

그러면 지금까지 우리가 작성한 Java 소스코드가 어떻게 컴파일되고, 실행되는지를 알아봤고, 그 과정에서 JVM이 어떤 역할을 하는지 구조 전체를 파악하며 이해했다. 지금까지 단순히 이러한 정보의 나열만을 진행했다면, 이 정보들을 바탕으로 JVM이 Java에 어떤 특징을 만들었는지 요약 및 정리하여 결론을 도출해보고자 한다.

  • JVM은 OS로부터 메모리를 할당받아 스스로 메모리 관리를 하고, 컴파일 후 생성된 파일을 바로 적재하므로 프로그램의 생성과 실행 모든 과정에 관여한다.
  • JVM은 바이트코드를 구동하고 있는 플랫폼에 맞추어 바이너리 코드로 해석하기 때문에, 어느 플랫폼에서도 실행될 수 있으며 이는 플랫폼 독립적인 개발을 가능하게 해준다는 것을 의미한다.
  • Java 컴파일러가 바이트코드를 만들 때 클래스 단위로 생성하기 때문에, 프로그램의 일부가 수정되더라도 전체를 컴파일할 필요가 없다.

이상이 JVM의 구동 방식에 의해 생기는 Java만의 특징이다.

+첨언) 위 특징들로 인해 JVM 기반의 프로젝트에서는 여러 언어를 섞어 써도 오류가 발생하지 않는다. 실제로 JVM 기반으로 개발된 언어인 Kotlin, Scala 등을 프로젝트 내에 섞어서 프로젝트를 개발하는 경우도 있다.

+첨언2) 위에서 언급한 특징 중 두번째 특징에서 주의해야 할 점은, JVM이 플랫폼 종속적이기 때문에 개발자로 하여금 플랫폼 독립적인 개발을 가능하게 해준다는 점이다. JVM은 OS에 따라 내부 해석 방식이 달라지므로 JVM 그 자체는 플랫폼 독립적이 아니고 플랫폼 종속적이다.

References

ITWorld Korea - "JVM이란 무엇인가" 자바 가상 머신 이해하기
Preamtree의 행복로그
::Devlog::
Naver D2 - Hello World - JVM Internal

이미지 출처

Preamtree
asfirstalways

profile
https://github.com/raejoonee

0개의 댓글