Java bytecode(자바 바이트코드)
자바 컴파일러는 C/C++ 등의 컴파일러처럼 고수준 언어를 기계어, 즉 직접적인 CPU 명령으로 변환하는 것이 아니라, 개발자가 이해하는 자바 언어를 JVM이 이해하는 자바 바이트코드로 번역한다.
자바 바이트코드(Java bytecode)는 자바 가상 머신이 실행하는 명령어의 형태이다. 각각의 바이트코드는 1바이트로 구성되지만 몇 개의 파라미터가 사용되는 경우가 있어 총 몇 바이트로 구성되는 경우가 있다. 256개의 명령코드 모두가 사용되지는 않는다.
자바 프로그래머가 자바 바이트코드를 꼭 인지하거나 이해할 필요는 없다. 하지만 IBM의 developerWorks journal에서 제안했듯이 "바이트코드를 이해하고 자바 컴파일러에 의해 바이트코드가 어떻게 생성될 것인지를 이해하는 것은 C나 C++ 프로그래머가 어셈블리어를 이해하는 것과 같다" 라고 볼 수 있다.
예시
for (int i = 2; i < 1000; i++) {
for (int j = 2; j < i; j++) {
if (i % j == 0)
continue outer;
}
System.out.println (i);
}자바 컴파일러는 위의 자바 코드를 아래와 같은 바이트 코드로 번역한다:
0: iconst_2
1: istore_1
2: iload_1
3: sipush 1000
6: if_icmpge 44
9: iconst_2
10: istore_2
11: iload_2
12: iload_1
13: if_icmpge 31
16: iload_1
17: iload_2
18: irem
19: ifne 25
22: goto 38
25: iinc 2, 1
28: goto 11
31: getstatic #84; // Field java/lang/System.out:Ljava/io/PrintStream;
34: iload_1
35: invokevirtual #85; // Method java/io/PrintStream.println:(I)V
38: iinc 1, 1
41: goto 2
44: return
JAR
JAR(Java Archive, 자바 아카이브)는 여러개의 자바 클래스 파일과, 클래스들이 이용하는 관련 리소스(텍스트, 그림 등) 및 메타데이터를 하나의 파일로 모아서 자바 플랫폼에 응용 소프트웨어나 라이브러리를 배포하기 위한 소프트웨어 패키지 파일 포맷이다. JAR 파일은 실제로 ZIP 파일 포맷으로 이루어진 압축 파일로서, 파일 확장자는 .jar이다.
자바 가상 머신(Java Virtual Machine, JVM)은 자바 바이트코드를 실행할 수 있는 주체이다. 자바 바이트코드는 플랫폼에 독립적이며 모든 자바 가상 머신은 자바 가상 머신 규격에 정의된 대로 자바 바이트코드를 실행한다. 따라서 표준 자바 API까지 동일한 동작을 하도록 구현한 상태에서는 이론적으로 모든 자바 프로그램은 CPU나 운영 체제의 종류와 무관하게 동일하게 동작할 것을 보장한다. 크게
Class Loader,Runtime Data Area,Execution Engine로 구성되어 있다.
- 가상 머신이란 프로그램을 실행하기 위해 물리적 머신(즉, 컴퓨터)와 유사한 머신을 소프트웨어로 구현한 것을 말한다고 할 수 있다.
- JVM의 역할은 자바 애플리케이션을 클래스 로더(Class Loader)를 통해 읽어 들여서 자바 API와 함께 실행하는 것입니다.
- 운영체제별로 프로그램을 실행하고 관리하는 방법이 다르기 때문에 JVM은 운영체제에 종속적이다.
- 따라서 JVM을 운영체제에 맞게 설치해야한다.
- 자바는 썬 마이크로시스템스가 개발했지만, JVM 명세(The Java Virtual Machine Specification)를 따르기만 하면 어떤 벤더든 JVM을 개발하여 제공할 수 있다
- 대표적인 오라클 핫스팟 JVM 외에도 IBM JVM을 비롯한 다양한 JVM이 존재한다.
- 스택 기반의 가상 머신
- 대표적인 컴퓨터 아키텍처인 인텔 x86 아키텍처나 ARM 아키텍처와 같은 하드웨어가 레지스터 기반으로 동작하는 데 비해 JVM은 스택 기반으로 동작한다.
- 심볼릭 레퍼런스
- 기본 자료형(primitive data type)을 제외한 모든 타입(클래스와 인터페이스)을 명시적인 메모리 주소 기반의 레퍼런스가 아니라 심볼릭 레퍼런스를 통해 참조한다.
- 심볼릭 레퍼런스는 실제 물리적 메모리 위치를 가리키는 참조가 아닌 논리적 참조를 뜻한다.
- 가비지 컬렉션(garbage collection)
- 클래스 인스턴스는 사용자 코드에 의해 명시적으로 생성되고 가비지 컬렉션에 의해 자동으로 파괴된다.
- JVM은 크게
Class Loader,Runtime Data Area,Execution Engine로 구성되어 있다.
- 자바는 동적 로드, 즉 컴파일타임이 아니라 런타임에 클래스를 처음으로 참조할 때 해당 클래스를 로드하고 링크하는 특징이 있다.
- 이 동적 로드를 담당하는 부분이 JVM의 클래스 로더이며 컴파일된 자바 바이트코드를 런타임 데이터 영역(Runtime Data Areas)에 로드한다.
- 클래스 로더가 아직 로드되지 않은 클래스를 찾으면, 다음 그림과 같은 과정을 거쳐 클래스를 로드하고 링크하고 초기화한다.
Loading,Linking,Initalization단계를 거친다.
-
자바 파일을 컴파일해서 만든 class파일을 메모리에 로드하는 역할을 한다.
-
아래와 같이 3가지 종류의 클래스 로더가 있다.
-
BOOTSTRAP Class Loader
- JVM을 기동할 때 생성되며, Object 클래스들을 비롯하여 자바 API들을 로드한다.
- <JAVA_HOME>/jre/lib 디렉터리에 위치한 핵심 자바 JDK class를 로드한다.
- 다른 클래스 로더와 달리 자바가 아니라 네이티브 코드로 구현되어 있다.
-
EXTENSION Class Loader
- 기본 자바 API를 제외한 확장 클래스들을 로드한다. 다양한 보안 확장 기능 등을 여기에서 로드하게 된다.
- <JAVA_HOME>/jre/lib/ext에 위치한 class를 로드한다.
-
APPLICATION Class Loader
- 사용자가 지정한 $CLASSPATH 내의 클래스들을 로드한다.
- CLASSPATH ENV variable로 명시된 위치에서 jar 와 class를 로드한다.
- CLASSPATH, -cp
- 3가지 단계를 거친다
-
Verification 단계
- 읽어 들인 클래스가 자바 언어 명세(Java Language Specification) 및 JVM 명세에 명시된 대로 잘 구성되어 있는지 검사한다.
-
Preparation 단계
- Static 변수가 메모리에 할당되고 default value로 초기화한다.
- 클래스가 필요로 하는 메모리를 할당하고, 클래스에서 정의된 필드, 메서드, 인터페이스들을 나타내는 데이터 구조를 준비한다.
-
Resolution 단계
- 클래스의 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경한다.
- 클래스 변수들을 적절한 값으로 초기화한다
- Static 변수에 실제 값이 할당된다.
- static initalizer를 실행한다.
- 런타임 데이터 영역은 JVM이라는 프로그램이 운영체제 위에서 실행되면서 할당받는 메모리 영역이다.
- 런타임 데이터 영역은 6개의 영역으로 나눌 수 있다.
- 6개의 영역 중 일부는 스레드마다 별도로 존재하고 일부는 스레드 끼리 공유해서 사용한다.
- 별도 존재: PC 레지스터(PC Register), JVM 스택(JVM Stack), 네이티브 메서드 스택(Native Method Stack)
- 공유: 힙(Heap), 메서드 영역(Method Area), 런타임 상수 풀(Runtime Constant Pool)
- PC(Program Counter) 레지스터는 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다.
- PC 레지스터는 현재 수행 중인 JVM 명령의 주소를 갖는다.
- PC는 사실상 메소드 영역의 주소를 가리킨다
- JVM 스택은 각 스레드마다 하나씩 존재하며 스레드가 시작될 때 생성된다.
- 스택 프레임(Stack Frame)이라는 구조체를 저장하는 스택으로, JVM은 오직 JVM 스택에 스택 프레임을 추가하고(push) 제거하는(pop) 동작만 수행한다.
- 메소드를 호출 할 때마다 프레임을 추가하고 메소드가 종료되면 해당 프레임을 제거하는 동작을 수행한다.
- 예외 발생 시 printStackTrace() 등의 메서드로 보여주는 Stack Trace의 각 라인은 하나의 스택 프레임을 표현한다.
-
JVM 내에서 메서드가 수행될 때마다 하나의 스택 프레임이 생성되어 해당 스레드의 JVM 스택에 추가되고 메서드가 종료되면 스택 프레임이 제거된다.
-
스택 프레임의 구성
- 지역 변수 배열(Local Variable Array)
- 피연산자 스택(Operand Stack)
- 현재 실행 중인 메서드가 속한 클래스의 런타임 상수 풀에 대한 레퍼런스를 갖는다.
- Return value
-
지역 변수 배열, 피연산자 스택의 크기는 컴파일 시에 결정되기 때문에 스택 프레임의 크기도 메서드에 따라 크기가 고정된다.
- 따라서 가변 길이의 객체와 배열을 스택 프레임에 저장할 수 없다.
this에 대한 참조와 Method의 Parameter Value 와 Local Variable을 저장한다- static method는 첫 번째 슬롯부터 메소드의 파라미터가 시작
- instance method의 경우 첫 번째 슬롯에 this에 대한 참조값이 있다.
- 기본 타입 변수와 참조 타입 변수가 추가(push)되거나 제거(pop)된다.(stack 구조)
- 변수가 이 영역에 생성되는 시점은 변수에 최초로 값이 저장될 때이다
- 기본 타입 변수는 스택 영역에 직접 값을 가지고 있지만 참조 타입 변수는 값이 아니라 힙 영역이나 메소드 영역의 객체의 주소를 갖는다.
- Operand Stack 은 JVM 의 작업 공간이다 .
- JVM 이 연산에 필요한 데이터와 연산 결과를 Operand Stack에 넣고 처리한다 .
- 작동 방식은 하나의 Instruction이 연산을 위해 Operand Stack에 값을 넣으면 다음 Instruction에서는 이 값을 빼서 사용하게 된다 .
- 연산의 결과 역시 Operand Stack에 저장된다 . Operand Stack 역시 Array 로 구성되어 있으며 Stack의 구조로 Push, Pop 작업을 수행한다 .
- 자바 외의 언어로 작성된 네이티브 코드를 위한 스택이다.
- 즉, JNI(Java Native Interface)를 통해 호출하는 C/C++ 등의 코드를 수행하기 위한 스택으로, 언어에 맞게 C 스택이나 C++ 스택이 생성된다.
- 논리적으론 힙의 한 부분이지만 물리적으론 떨어져 있다.
- 메서드 영역은 모든 스레드가 공유하는 영역으로 JVM이 시작될 때 생성된다.
- JVM이 읽어 들인 각각의 클래스와 인터페이스에 대한 런타임 상수 풀, 필드 데이터, 메소드 데이터, 메소드 코드, 생성자 코드 등을 보관한다.
- 메서드 영역은 JVM 벤더마다 다양한 형태로 구현할 수 있으며, 오라클 핫스팟 JVM(HotSpot JVM)에서는 흔히 Permanent Area, 혹은 Permanent Generation(PermGen)이라고 불린다.
- 메서드 영역에 대한 가비지 컬렉션은 JVM 벤더의 선택 사항이다.
- 클래스 파일 포맷에서 constant pool 테이블에 해당하는 영역이다.
- 메서드 영역에 포함되는 영역이긴 하지만, JVM 동작에서 가장 핵심적인 역할을 수행하는 곳이기 때문에 JVM 명세에서도 따로 중요하게 기술한다.
- 각 클래스와 인터페이스의 상수뿐만 아니라, 메서드와 필드에 대한 모든 레퍼런스까지 담고 있는 테이블이다.
- 즉, 어떤 메서드나 필드를 참조할 때 JVM은 런타임 상수 풀을 통해 해당 메서드나 필드의 실제 메모리상 주소를 찾아서 참조한다.
- 쓰레드간 공유가 가능하고 객체와 배열이 생성되는 영역이다.
- 힙 영역에 생성된 객체와 배열은 JVM 스택 영역의 변수나 다른 객체의 필드에서 참조한다.
- 참조하는 변수나 필드가 없다면 의미 없는 객체가 되기 때문에 JVM은 가비지 컬렉션을 실행해 힙 영역에서 제거한다.
- JVM 성능 등의 이슈에서 가장 많이 언급되는 공간이다.
- 힙 구성 방식이나 가비지 컬렉션 방법 등은 JVM 벤더의 재량이다.
힙의 구조
- 클래스 로더를 통해 JVM 내의 런타임 데이터 영역에 배치된 바이트코드는 실행 엔진에 의해 실행된다.
- 실행 엔진은 자바 바이트코드를 명령어 단위로 읽어서 실행한다.
- CPU가 기계 명령어을 하나씩 실행하는 것과 비슷하다.
- 바이트코드의 각 명령어는 1바이트짜리 OpCode와 추가 피연산자로 이루어져 있으며, 실행 엔진은 하나의 OpCode를 가져와서 피연산자와 함께 작업을 수행한 다음, 다음 OpCode를 수행하는 식으로 동작한다.
- 자바 바이트코드는 기계가 바로 수행할 수 있는 언어보다는 비교적 인간이 보기 편한 형태로 기술된 것이다.
- 그래서 실행 엔진은 이와 같은 바이트코드를 실제로 JVM 내부에서 기계가 실행할 수 있는 형태로 변경하며, 그 방식은 Interpreter 또는 JIT Compiler를 사용하는 방법 두 가지가 있다.
Execution Engine의 구조
- 바이트코드 명령어를 하나씩 읽어서 해석하고 실행한다.
Interpreter의 단점
- 하나씩 해석하고 실행하기 때문에 바이트코드 하나하나의 해석은 빠른 대신 인터프리팅 결과의 실행은 느리다는 단점을 가지고 있다.
- 같은 메소드가 여러번 호출될 때 마다 바이트코드를 중복적으로 변환하고 실행하는 과정을 거친다.
- 전통적인 입장에서 컴퓨터 프로그램을 만드는 방법은 두 가지가 있다
- 인터프리트 방식: 실행 중 프로그래밍 언어를 읽어가면서 해당 기능에 대응하는 기계어 코드를 실행한다
- 정적 컴파일 방식: 실행하기 전에 프로그램 코드를 기계어로 번역한다.
- 인터프리터의 단점을 보완하기 위해 도입된 것이 JIT 컴파일러이다.
- 인터프리터 방식으로 실행하다가 적절한 시점에 바이트코드 전체를 컴파일하여 네이티브 코드로 변경하고, 이후에는 해당 메서드를 더 이상 인터프리팅하지 않고 네이티브 코드로 직접 실행하는 방식이다.
- 네이티브 코드를 실행하는 것이 하나씩 인터프리팅하는 것보다 빠르고, 네이티브 코드는 캐시에 보관하기 때문에 한 번 컴파일된 코드는 계속 빠르게 수행되게 된다.
- JIT 컴파일러가 컴파일하는 과정은 바이트코드를 하나씩 인터프리팅하는 것보다 훨씬 오래 걸린다.
- 만약 한 번만 실행되는 코드라면 컴파일하지 않고 인터프리팅하는 것이 훨씬 유리하다.
- JIT 컴파일러를 사용하는 JVM들은 내부적으로 해당 메서드가 얼마나 자주 수행되는지 체크하고, 일정 정도를 넘을 때에만 컴파일을 수행한다.
참조
- https://d2.naver.com/helloworld/1230
- https://d2.naver.com/helloworld/1329
- https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4
- Java Performance Fundamental"(김한도, 서울, 엑셈, 2009) 읽기를 추천





