우리가 작성하는 자바 소스코드(.java)는 자바 컴파일러에 의해 자바 바이트 코드(.class)로 변환된다.
바이트 코드는 기계어가 아니기 때문에, CPU와 OS에 알맞는 기계어로 변환되어야 실행될 수 있다. JVM은 바이트 코드를 알맞는 기계어로 해석하여 자바를 CPU와 OS로부터 독립하여 실행할 수 있게 해준다. 이는 JAVA의 표어인 WORA(Write Once Run Anywhere)를 실현할 수 있게 해주는 핵심이다.
스택 기반의 가상 머신
인텔 x86 아키텍쳐나 ARM 아키텍쳐와 같은 하드웨어가 레지스터 기반인데 반해 JVM은 스택 기반으로 동작한다.
스택 기반 모델은 하드웨어에 대해 직접 다루지 않기에 다양한 하드웨어에서 가상 머신을 쉽게 구현할 수 있다.
스택 기반 모델과 레지스터 기반 모델의 장단점에 대해 잘 서술되어 있는 글이 있다. ([Android] JVM의 스택 기반 모델 vs DVM의 레지스터 기반 모델)
심볼릭 레퍼런스
기본 자료형(primitive data type)을 제외한 모든 타입(클래스와 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 통해 참조한다.
Class 파일은 실행 시에 Link할 수 있도록 심볼릭 레퍼런스만을 가진다. Runtime 시점에 실제 물리적인 주소로 대체되는 작업인 Dynamic Linking이 일어나게 된다.
가비지 컬렉션
클래스 인스턴스는 사용자 코드에 의해 명시적으로 생성되고 가비지 컬렉터에 의해 자동으로 파괴된다.
기본 자료형을 명확하게 정의하여 플랫폼 독립성 보장
C/C++ 등의 전통적인 언어는 플랫폼에 따라 int 형의 크기가 변한다. JVM은 기본 자료형을 명확하게 정의하여 호환성을 유지하고 플랫폼 독립성을 보장한다.
네트워크 바이트 오더
CPU가 메모리에 데이터를 저장할 때 어느 순서로 저장하는가에 따라 리틀 엔디안과 빅 엔디안으로 나뉜다. 때문에 서로 다른 플랫폼 간 네트워크 데이터 전송을 할 때는 엔디안 방식에 주의를 해야하며, 바이트 오더를 빅 엔디안에 맞춰 전송해야 한다.
JVM은 네트워크 바이트 오더(빅 엔디안)을 사용하여 CPU 아키텍쳐와 무관하게 플랫폼 독립성을 유지할 수 있다.
리틀 엔디안은 메모리의 첫 주소에 하위 데이터(데이터의 맨 오른쪽)부터 저장하고, 빅 엔디안은 메모리의 첫 주소에 상위 데이터(데이터의 맨 왼쪽)부터 저장한다.
int a = 0x12345678
리틀엔디안 = 0x78 0x56 0x34 0x12
빅엔디안 = 0x12 0x34 0x56 0x78
JVM은 크게 Class Loader, Runtime Data Area, Execution Engine으로 구성되어 있다.
RunTime 시점에 클래스를 로딩하게 해주며, 클래스의 인스턴스를 생성하면 클래스 로더를 통해 메모리에 로드하게 된다.
자바 컴파일러가 소스코드를 컴파일하면 *.class 파일(바이트코드)을 Runtime Data Area에 적재하는 역할을 한다.
클래스 로더는 클래스 로딩을 위해 다음과 같은 절차를 거친다.
1. 로딩 : 찾은 파일을 Runtime Data Area에 로드한다.
2. 검증 : 읽어 들인 클래스가 제대로 구성되어있는지 검사한다.
3. 준비 : 클래스가 필요로 하는 메모리를 할당하고, 클래스에 정의된 필드, 메소드, 인터페이스들을 나타내는 데이터 구조를 준비한다.
4. 분석 : 클래스의 상수 풀 내 모든 심볼릭 레퍼런스들을 다이렉트 레퍼런스로 변경한다.
5. 초기화 : 클래스 변수들을 적절한 값으로 초기화한다.
JVM이 프로그램을 수행하기 위해 OS로부터 별도로 할당받은 메모리 공간을 말하며, Class Loader에서 준비한 데이터들을 보관하는 저장소이다.
Heap, Method Area, Runtime Constant Pool은 모든 스레드가 공유하는 영역이며, 나머지 영역은 스레드마다 차지하는 영역이다.
PC Register : 각 스레드마다 하나씩 존재하며, 스레드가 시작될 때 생성된다. 스레드가 어떤 명령어로 실행되어야 할 지에 대한 기록을 하는 부분으로, 현재 수행중인 JVM 명령의 주소를 가진다.
JVM Stack(Stack Area) : 각 스레드마다 하나씩 존재하며, 스레드가 시작될 때 생성된다. 메소드를 호출할 때마다 프레임을 push하고, 메소드가 종료되면 pop한다.
메소드 정보, 지역변수, 매개변수, 연산 중 발생하는 임시 데이터를 저장하고, 기본(Primitive) 타입 변수는 여기에 직접 값을 저장한다. 참조 타입 변수는 Heap 영역이나 메소드 영역에 참조를 가진다. Heap 영역에 있는 객체 중 여기서 참조할 수 없는 경우 GC의 대상이 된다.
(예외를 printStackTrace() 하면 표시되는 스택 프레임들의 정체이다)
Native Method Stack : 자바 외의 언어로 작성된 네이티브 코드를 위한 스택이다.
Method Area(Class Area, Static Area) : JVM이 시작될 때 생성되며, JVM이 읽어들인 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드와 메소드 정보, 클래스 변수(static 변수), 메소드의 바이트 코드 등을 보관한다.
간단하게 말하면 자바 바이트코드의 모든 데이터가 올라가는 영역이다. 전역 변수, 메소드 정보, static 자료형이 붙은 필드와 메소드 등이 포함되어 있다.
(이 때문에 전역 변수/메소드를 어느 스레드에서든지 접근 가능한 것)
Runtime Constant Pool : 각 클래스와 인터페이스의 상수 뿐만 아니라, 메소드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블. 어떤 메소드나 필드를 참조할 때 JVM은 이 풀을 통해 해당 메소드나 필드의 실제 메모리 상 주소를 찾아서 접근한다. JVM의 동작에서 핵심적인 역할을 수행한다.
Heap : 동적으로 생성이 된 객체를 저장하는 영역이다. Method 영역에서 참조한 값을 바탕으로 새로운 객체를 생성 할 시에 이곳에 저장된다. 객체가 다른 영역에서 참조되지 않는다면 GC의 대상이 된다.
자바 메모리에 대한 추가자료
Stack 영역에는 기본(Primitive) 타입 변수는 그대로 저장되고, 참조 타입 변수는 Heap에 참조를 가진다.
클래스와 같은 객체의 경우에도 Heap에 참조를 가지게 된다. 아래 그림은 List이기 때문에 Heap 내에서 참조가 되는 것을 볼 수 있다.
Class Loader를 통해 JVM 내 런타임 데이터 영역에 배치된 바이트 코드(*.class)를 실행한다. 바이트 코드를 명령어 단위로 읽어서 실행한다.
Execution Engine에는 인터프리터, JIT 컴파일러, GC가 포함된다.
인터프리터 : 바이트 코드를 명령어 단위로 읽어 기계어로 변환한다.
JIT 컴파일러 : 바이트 코드에서 반복되는 코드 부분을 기계어로 캐싱해둔다.
GC(가비지 컬렉터) : 더 이상 참조되지 않는 객체를 모아 메모리 정리를 한다.
자바는 JVM 위에서만 실행(해석)될 수 있기 때문에, C++과 같은 네이티브 언어에 비해 속도가 느리다. 자바는 JIT 컴파일러로 이러한 단점을 극복하려 했다.
예를 들어 HelloWorld.java 소스 코드를 생성했다면.
1. 자바 컴파일러가 HelloWorld.java를 바이트 코드로 변환하여 HelloWorld.class를 생성한다.
2. JVM에서 각 운영체제에 맞는 기계어로 번역해 전달한다.
인터프리터를 사용하여 바이트코드를 기계어로 해석하는 것이 느린데,
여기서 JIT 컴파일러는 아래와 같은 과정을 거쳐 속도를 개선시킨다.
JIT 컴파일러는 같은 코드를 매번 해석하지 않고 실행할 때 컴파일하면서 해당 기계어 코드를 캐싱해버린다. 이후에는 바뀐 부분만 컴파일하고 나머지는 캐싱된 기계어 코드를 전달해준다. 이렇게 하여 느린 인터프리터의 실행 속도를 개선할 수 있다.
그러나 JIT가 얼마나 개선되던간에, 자바는 C++보다 느릴 수 밖에 없다고 한다. (왜 항상 자바Java는 C++보다 느린가?)
자바가 느릴 수 밖에 없는 원인 중 몇개를 알아보자면
자바는 원시 자료형만 Stack에 저장하고, 모든 객체를 Heap에 할당하는 점.
자바는 형변환이 모두 동적으로 이루어지는데 이것들이 모두 비용이 높다는 점.
GC가 메모리 관리의 편리함을 제공하지만, 메모리 사용량을 증가시킨다는 점이 있다.
C, C++에서는 OS 레벨의 메모리에 프로그래머가 직접 접근하기 때문에 메모리 할당 해제를 명시적으로 하여야 한다. 그렇지 않으면 메모리 누수가 일어나게 되고, 현재 실행중인 프로그램에서 메모리 누수가 발생하면 다른 프로그램에도 영향을 끼칠 수 있다.
자바는 JVM을 통해 간접적으로 접근한다. JVM은 객체가 필요해지지 않는 시점에서 알아서 메모리 할당을 해제하여 메모리를 확보한다. JVM은 OS에게 요청한 사이즈 만큼의 메모리를 할당받아 실행된다. 할당받은 이상의 메모리를 사용하게 되면 자동으로 종료되며, 프로세스에서 메모리 누수가 발생하더라도 현재 실행중인 프로세스만 종료되게 된다.
자바는 가비지 컬렉션에 다음과 같은 규칙을 적용한다.
Heap 영역의 객체 중 Stack 에서 도달 불가능한(Unreachable) 객체들은 가비지 컬렉션의 대상이 된다.
도달 불가능한 객체를 어떻게 판별하는지 간단한 예제를 통해 알아보면
String url = "https://";
url += "yaboong.github.io";
System.out.println(url);
위 코드에서 첫 번째 라인을 실행하면 Stack과 Heap는 다음과 같이 된다.
두 번째 라인에서 url에 문자열을 추가하면 다음과 같이 변한다.
이 시점에서 Heap에 있는 "https://" 라는 문자열을 참조할 수 있는 변수가 없으므로 도달할 수 없는 객체가 된다.
따라서 GC에 의해 메모리 해제가 이루어지게 된다.
Stop-the-world는 GC 실행을 위해 JVM이 애플리케이션 실행을 멈추는 것이다. GC가 실행되면 GC 스레드를 제외한 나머지 스레드들이 멈추고, GC가 완료되면 나머지 스레드들이 다시 동작한다.
대개 GC 튜닝이란 이 Stop-the-world 시간을 줄이는 것을 말한다.
System.gc()를 호출하여 명시적으로 가비지 컬렉션이 일어나도록 코드를 작성할 수 있다. 하지만 GC를 제외한 모든 스레드가 중단되기 때문에 System.gc()를 코드단에서 호출하는 일은 없어야 한다.
GC의 과정을 Mark and sweep이라고도 한다. GC가 스택의 모든 변수를 스캔하면서 각각이 어떤 객체를 참조하고 있는지 찾는 과정이 Mark이고, Mark되지 않은 객체들을 Heap에서 제거하는 과정이 Sweep이다.
실제로는 Garbage가 아닌 객체를 Mark 하고, 그 외의 것은 모두 Heap에서 지운다. Garbage Collector라는 이름과 다르게 쓰레기가 아닌 것을 마킹하는 점이 아이러하지만, 이 때문에 Heap에 쓰레기만 가득한 경우 즉각적으로 제거과정이 수행된다.
GC가 어떻게 동작하는지에 대해 잘 설명된 포스팅이 있으니 관심이 있다면 참조해보기 바란다.
Java 의 GC는 어떻게 동작하나?
How Garbage Collection Works
JVM
https://medium.com/@lazysoul/jvm-%EC%9D%B4%EB%9E%80-c142b01571f2
https://dailyheumsi.tistory.com/196
https://velog.io/@dnjscksdn98/Java-What-is-JVM
https://swiftymind.tistory.com/78
https://stophyun.tistory.com/37
https://joochang.tistory.com/86
https://re-build.tistory.com/2
리틀 엔디안과 빅 엔디안
https://duzi077.tistory.com/201
가비지 컬렉터
https://yaboong.github.io/java/2018/06/09/java-garbage-collection/
https://velog.io/@litien/%EA%B0%80%EB%B9%84%EC%A7%80-%EC%BB%AC%EB%A0%89%ED%84%B0GC
JAVA는 왜 C++보다 느린가
https://sungpi.postach.io/post/wae-hangsang-jabajavaneun-c-boda-neuringa
JAVA JIT 컴파일러
https://medium.com/@ahn428/java-jit-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC-c7d068e29f45
자바 메모리 관리
https://yaboong.github.io/java/2018/05/26/java-memory-management/