JVM에서 예외(Exceptions)는 Throwable 클래스나 그 하위 클래스의 인스턴스로 나타낸다. 예외가 발생하면 예외가 발생한 지점에서 즉시 nonlocal한 제어 전송이 발생한다.
대부분의 예외는 해당 스레드의 작업으로 인해 동기적으로 발생한다. 그에 반해 비동기적 예외는 프로그램 실행 중 어느 지점에서든 발생할 수 있다. 자바 가상 머신은 세 가지 이유로 예외를 던진다:
athrow 명령어(§athrow)가 실행되었을 때
JVM에 의해 동기적으로 감지된 비정상 실행 조건. 이러한 예외는 프로그램의 임의의 지점에서 발생하는 것이 아니라, 예외를 가능한 결과로 지정하는 명령어의 실행 후에 동기적으로 발생한다.
ex) 예를 들어 배열의 범위를 벗어나는 인덱싱과 같은 경우, 프로그램의 일부를 로드하거나 링크하는 중에 오류가 발생한 경우.
어떤 자원의 제한을 초과하는 경우, 예를 들어 메모리 사용이 너무 많은 경우.
athrow의 특징은 다음과 같다.
실행 시점: athrow 명령어는 실행 중에 예외를 발생시키기 위해 사용된다. 이 명령어가 실행되면 현재 메서드의 실행이 중단되고 예외 처리 메커니즘이 활성화된다.
예외 객체 전달: athrow는 특정한 예외 객체를 명시적으로 전달한다. 이 예외 객체는 Throwable 클래스 또는 그 하위 클래스의 인스턴스여야 한다.
코드 흐름 제어: athrow 명령어를 만나면 프로그램의 제어 흐름이 현재 위치에서 해당 예외를 처리할 수 있는 곳으로 전환된다. 이는 예외를 던지는 메서드에서 호출 스택을 따라 거슬러 올라가면서 예외 처리 블록을 찾는 과정을 의미한다.
예외 처리: athrow 명령어로 발생한 예외는 호출 스택의 상위 메서드에서 try-catch 블록이나 예외를 던지는 메서드로 전달되어 적절하게 처리된다.
JVM에서 예외 처리는 예외 핸들러(Exception handler)에 의해 다뤄지며 핸들러는 예외인 코드를 지정한다.
예외가 발생하면 핸들러는 JVM에서 지정된 코드의 매서드에 알맞은 예외 핸들러를 찾으며 존재할 경우 지정한 예외 코드로 분기한다.
예외 핸들러와 매치가 되지 않는 경우에는 현재 매서드 호출을 강제 종료한다.
현재 메서드 호출의 피연산자 스택 및 로컬 변수는 폐기되고, 해당 프레임은 pop 이 되어 호출 메서드의 프레임을 복원한다. 그런 다음 예외는 호출자의 프레임 컨텍스트에서 다시 던져지며, 메서드 호출 체인을 따라 계속 진행된다.
만약 메서드 호출 체인의 맨 위까지 적절한 예외 핸들러를 찾을 수 없다면, 예외가 발생한 스레드의 실행이 종료됩니다. 스레드 종료 전에 발생한 예외는 다음 규칙에 따라 처리됩니다:
스레드에 uncaught 예외 핸들러가 설정되어 있으면 해당 핸들러가 실행됩니다.
그렇지 않은 경우, 스레드의 부모인 ThreadGroup에 대해 uncaughtException 메서드가 호출됩니다. ThreadGroup 및 그 부모 ThreadGroup이 uncaughtException을 오버라이드하지 않은 경우, 기본 핸들러의 uncaughtException 메서드가 호출됩니다.
만약 메서드 호출 체인의 맨 위까지 적절한 예외 핸들러를 찾을 수 없다면, 예외가 발생한 스레드의 실행이 종료됩니다. 스레드 종료 전에 발생한 예외는 다음 규칙에 따라 처리됩니다:
스레드에 uncaught 예외 핸들러가 설정되어 있으면 해당 핸들러가 실행된다.
그렇지 않은 경우, 스레드의 부모인 ThreadGroup에 대해 uncaughtException 메서드가 호출된다. ThreadGroup 및 그 부모 ThreadGroup이 uncaughtException을 오버라이드하지 않은 경우, 기본 핸들러의 uncaughtException 메서드가 호출된다.
실행시 예외가 발생하면 JVM은 다음의 표에서 현재 매서드에 맞는 예외 핸들러를 찾는다.
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
여기서
1. Start PC (시작 프로그램 카운터): 예외 핸들러의 적용 범위가 시작되는 프로그램 카운터(PC)의 값
End PC (끝 프로그램 카운터): 예외 핸들러의 적용 범위가 끝나는 프로그램 카운터(PC)의 값
Handler PC (핸들러 프로그램 카운터): 예외가 발생했을 때 처리 코드의 시작 지점을 가리키는 프로그램 카운터(PC)의 값
Catch Type (캐치 타입): 처리 가능한 예외의 유형을 나타내는 Constant Pool의 인덱스 또는 특정 예외 유형을 나타내는 특수 값
Java 프로그래밍 언어의 예외 처리 수단은 주로 컴파일러와의 협력을 통해서만 구현된다. 클래스 파일이 다른 수단으로 생성될 때, JVM가 일관된 검색 절차로 해결한다.
JVM에서 명령어(operation)은 1바이트 이상의 opcode로 이루어져 있다. 해당 작업에서 이용되는 피연산자(operand)는 0개 이상이다. 많은 명령어가 피연산자가 0개로 이루어져 있다.
do {
원자적으로 프로그램 카운터(pc)를 계산하고 pc에서 opcode를 가져옴;
만약 오퍼랜드가 있다면 오퍼랜드를 가져옴;
opcode에 대한 작업 실행;
} while (할 일이 남아있는 동안);
JVM의 인터프리터의 내부 루프는 위와 같은 과정을 가진다.
피연산자의 크기는 opcode에 의해서 결정되며 피연산자가 1byte 이상인 경우
high-order byte가 먼저 나오도록 big-endian 순서로 저장된다.
바이트코드 명령어 스트림은 1 바이트에 정렬되어 있으나, 각 명령어는 1바이트 간격으로 시작한다. 하지만 lookupswitch 및 tableswitch 명령어는 내부적으로 일부 피연산자를 4바이트 간격에 맞추기 위해 패딩이 추가된다.
JVM 명령어 세트의 대부분의 명령어는 수행하는 작업에 대한 Type 정보를 인코딩한다. 예를 들어 iload 명령어는 int여야 하는 로컬 변수의 내용을 오퍼랜드 스택에 로드한다. fload 명령어(§fload)는 float 값에 대해 동일한 작업을 수행한다. 이 두 명령어는 구현이 동일할 수 있지만 고유한 타입의 opcode를 가지고 있다.
대부분의 타입화된 명령어에서는 명령어 type이 명령어 mnemonic에 명시적으로 나타난다.
잠깐!!!
"Mnemonic"은 어떤 개념, 명령 또는 기억을 쉽게 기억하거나 식별할 수 있도록 만들어진 단어 또는 구문이다. 특히 컴퓨터 과학 분야에서 "mnemonic"은 어셈블리어나 기계어 명령어 세트에서 각 명령어를 쉽게 인식하고 기억하기 위해 사용되는 단어나 약어를 말한다.
JVM의 1byte인 opcode의 type은 operation set의 구조를 type에 맞게 강제한다.
JVM의 명령어 세트는 특정 작업에 대해 일부 타입 지원을 제공한다. 즉 명령어 세트는 서로 독립이 아니다.
필요에 따라 지원되지 않는 데이터 타입과 지원되는 데이터 타입 간에 변환하는 데 별도의 명령어를 사용해서 가성비 있게 활용한다는 뜻

위 표는 JVM의 명령어 세트를 타입별로 나타낸것이다. 표에서 제일 왼쪽 열의 T는 타입을 그냥 기호로 나타낸것이다. 타입이 byte면 T대신에 b만 쓰면 된다.
표에 빈칸은 해당 타입에서 그 명령어를 지원하지 않는다는 뜻이다.
표에서 정수 타입인 byte, char, short의 빈칸이 많고 boolean 타입은 존재하지 않는데 컴파일러는 byte 및 short 타입의 리터럴 값을 로드할 때, 컴파일 타임이나 런타임에서 이 값을 int 타입으로 확장하는 JVM 명령어를 사용하여 인코딩한다.
boolean 및 char또한 마찬가지로 컴파일러에서 JVM에 의해 int 타입으로 인코딩 된다.

다음 표에서 왼쪽 actual type은 JVM에 의해서 Computational Type으로 인코딩되어서 계산된다.
로드(load) 및 스토어(store) 명령어는 Java 가상 머신 프레임의 로컬 변수와 오퍼랜드 스택 간에 값을 전송한다:
로컬 변수에서 오퍼랜드 스택으로 값 로드: iload, iload, lload, lload, fload, fload, dload, dload, aload, aload.
오퍼랜드 스택에서 로컬 변수로 값을 저장: istore, istore, lstore, lstore, fstore, fstore, dstore, dstore, astore, astore.
오퍼랜드 스택에 상수 로드: bipush, sipush, ldc, ldcw, ldc2_w, aconst_null, iconst_m1, iconst, lconst, fconst, dconst_.
더 넓은 인덱스를 사용하여 더 많은 로컬 변수에 액세스하거나 더 큰 즉시 피연산자에 액세스: wide.
객체의 필드 및 배열의 요소에 액세스하는 명령어도 데이터를 오퍼랜드 스택과 전송한다.
브라켓(<>) 사이의 값은 패밀리라고 패밀리가 어떤 타입인지 명시하는 역할을 한다.
JVM의 산술 명령(arithmetic instructions)은 주로 피연산자 스택의 두 값에 대한 함수로 결과를 계산하고 그 결과를 다시 피연산자 스택에 넣는다.
산술 명령은 정수 타입과 부동 소수 타입의 값을 계산하며 byte, short, char, boolean에 대해선 직접적인 지원이 없다. 이러한 타입은 int형의 operation을 통해 계산한다.
JVM은 정수 데이터 유형에 대한 연산 중 오버플로우를 나타내지 않는다. 예외를 throw할 수 있는 유일한 정수 연산은 정수 나눗셈 명령어 (idiv 및 ldiv) 및 정수 나머지 명령어 (irem 및 lrem)이며, 만약 나누는 수가 0이면 ArithmeticException을 throw한다.
JVM은 부동실수형 역시 오버플로우를 나타내지 않는다. 오버플로우 대신 부호가 있는 무한대를 생산한다. 언더플로우 경우에도 언더플로우 대신 부호가 있는 0으로 대신한다.
JVM은 타입 변환을 통해서 숫자 type간의 변환을 허용한다.
자바 가상 머신은 다음과 같은 확장 숫자 변환을 직접 지원한다:
• int에서 long, float 또는 double로
• long에서 float 또는 double로
• float에서 double로
대부분의 확장 숫자 변환은 숫자 값의 손실이 없다. 근데 그러나 int에서 float로, 또는 long에서 float로, 또는 long에서 double로 확장되는 변환은 최하위 비트를 잃을 수 있다. 반올림을 통해서 값을 근사시켜 버리기 때문!
정밀도 손실이 발생할 수 있어도 확장 숫자 변환은 결코 JVM에서 런타임 예외를 throw하지 않는다 (IEEE 754 부동 소수점 예외와 혼동하면 안 됨).
JVM은 다음과 같은 narrowing numeric conversion 을 직접 지원한다:
• int에서 byte, short 또는 char로
• long에서 int로
• float에서 int 또는 long으로
• double에서 int, long 또는 float으로
narrowing numeric conversion은 변환하고자 하는 타입의 비트가 n이면 기존의 값을 n비트만 남기고 n비트 이하의 값들은 다 버린다. 정밀도 때문에 최종 계산의 결과의 부호가 바뀔 수 있는 사실에 유의하자.
만약 부동 소수점 값이 NaN이라면, 변환 결과는 int 또는 long 값 0이다.
그렇지 않고 만약 부동 소수점 값이 무한대가 아니라면, 부동 소수점 값은 정수 값 V로 반올림되며, 반올림 정책은 round toward zero이다. 이때 두 가지 경우가 있다:
T가 long이고 이 정수 값이 long으로 나타낼 수 있다면 결과는 long 값 V이다.
T가 int 유형이고 이 정수 값이 int로 나타낼 수 있다면 결과는 int 값 V이다.
그렇지 않으면:
값이 너무 작아서(큰 크기의 음수 값 또는 음의 무한대) 결과는 int 또는 long 유형의 표현 가능한 최솟값이다.
값이 너무 크다면(큰 크기의 양수 값 또는 양의 무한대) 결과는 int 또는 long 유형의 표현 가능한 최댓값이다.
double에서 float으로의 narrowing numeric conversion은 IEEE 754에 따라 동작한다. 결과는 round to nearest 반올림 정책을 사용하여 정확하게 반올림된다. float으로 표현할 수 있는 값보다 작은 경우에는 float 유형의 양수 또는 음수 0으로 변환되고, float으로 표현할 수 있는 값보다 큰 경우에는 양수 또는 음수 무한대로 변환된다. double NaN은 항상 float NaN으로 변환된다.
클래스 인스턴스와 배열 모두 객체이지만, JVM은 클래스 인스턴스와 배열을 만들고 조작할 때 각각 다른 명령어 세트를 사용한다:
• 새로운 클래스 인스턴스 생성: new.
• 새로운 배열 생성: newarray, anewarray, multianewarray.
• 클래스의 필드(클래스 변수로 알려진 정적 필드) 및 클래스 인스턴스의 필드(인스턴스 변수로 알려진 비정적 필드)에 액세스: getstatic, putstatic, getfield, putfield.
• 배열 구성 요소를 오퍼랜드 스택에 로드: baload, caload, saload, iaload, laload, faload, daload, aaload.
• 오퍼랜드 스택의 값을 배열 구성 요소로 저장: bastore, castore, sastore, iastore, lastore, fastore, dastore, aastore.
• 배열의 길이 가져오기: arraylength.
• 클래스 인스턴스 또는 배열의 속성 확인: instanceof, checkcast.
피연산자 스택을 직접 조작하기 위한 여러 명령어가 제공된다: pop, pop2, dup, dup2, dup_x1, dup2_x1, dup_x2, dup2_x2, swap
제어 전송 명령은 조건부 또는 무조건적으로 JVM을 제어 전송 명령을 통해 분기를 처리한다. 이 명령들은 다음과 같다:
• 조건부 분기: ifeq, ifne, iflt, ifle, ifgt, ifge, ifnull, ifnonnull, if_icmpeq,
if_icmpne, if_icmplt, if_icmple, if_icmpgt if_icmpge, if_acmpeq, if_acmpne.
• 복합 조건부 분기: tableswitch, lookupswitch.
• 무조건적인 분기: goto, goto_w, jsr, jsr_w, ret.
JVM은 int 및 참조 유형의 데이터와 비교에 따라 조건적으로 분기하는 명령어 세트와 널 참조를 테스트하는 별도의 조건 분기 명령어 세트를 가지고 있다. 이로써 null에 대한 구체적인 값을 지정할 필요가 없다.
boolean, byte, char 및 short 유형의 데이터 간 비교에 따른 조건 분기는 int 비교 명령어를 사용하여 수행된다. long, float 또는 double 유형의 데이터 간 비교에 따른 조건 분기는 데이터를 비교하고 비교의 int 결과를 생성하는 명령어로 시작된다. 그 후에 int 비교 명령어가 이 결과를 테스트하고 조건 분기를 수행한다. int 비교에 중점을 둔 결과로 JVM은 int 유형에 대한 다양한 조건 분기 명령어를 제공한다.
모든 int 조건부 제어 전송 명령은 부호 있는 비교를 수행한다.
다음 5개의 명령어가 매서드를 호출한다:
invokevirtual: 인스턴스 메서드를 호출하며, 호출 대상 객체의 가상 타입에 따라 메서드를 디스패치한다.
잠깐!!
가상 타입(Virtual Type)은 객체 지향 프로그래밍에서 다형성을 구현하는 데 사용되는 개념이다. 이는 메소드 호출 시 메소드가 실제로 호출될 객체의 타입이 아니라 해당 객체가 실제로 참조하고 있는 타입을 기반으로 결정된다는 아이디이다.
예를 들어서
class Animal {
void makeSound() {
System.out.println("Animal sound");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Woof");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meow");
}
}
public class Main {
public static void main(String[] args) {
Animal animal = new Dog(); // 가상 타입 적용
animal.makeSound(); // Dog 클래스의 makeSound()이 호출됨
}
}
이 예제에서 Animal animal = new Dog();에서 animal 변수의 타입은 Animal이지만, 실제 객체는 Dog이다. 이때 makeSound() 메소드를 호출하면 실제 객체의 타입에 따라 Dog 클래스의 makeSound() 메소드가 호출되는데, 이것이 가상 타입의 동적 메소드 디스패치이다.
아무튼 나머지 4개는
invokeinterface: 인터페이스 메서드를 호출하며, 특정 런타임 객체에서 구현된 메서드를 찾아 적절한 메서드를 호출한다.
invokespecial: 특별한 처리가 필요한 인스턴스 메서드를 호출한다. 이는 인스턴스 초기화 메서드 또는 현재 클래스나 상위 타입의 메서드를 호출할 때 사용된다.
invokestatic: 특정한 클래스에 속한 정적(클래스) 메서드를 호출한다.
invokedynamic: invokedynamic 명령어와 연결된 호출 사이트 객체의 대상 메서드를 호출한다. 호출 사이트 객체는 invokedynamic 명령어가 처음 실행되기 전에 Java 가상 머신에 의해 부트스트랩 메서드를 실행하여 바인딩된 특정 렉시컬 발생을 가지게 된다.
여기서 잠깐!!
렉시컬 발생은 변수의 스코프를 해당 변수가 코드 상에서 어디에 위치하고 있는지에 따라 정의한다는 개념이다.
매서드 반환(method return)은 다음과 같은 명령어는 타입별로 나뉘며 다음과 같다.
ireturn: boolean, byte, char, short, 또는 int 타입의 값을 반환하는 데 사용된다.
lreturn: long 타입의 값을 반환하는 데 사용된다.
freturn: float 타입의 값을 반환하는 데 사용된다.
dreturn: double 타입의 값을 반환하는 데 사용된다.
areturn: 객체 참조(Reference)를 반환하는 데 사용된다.
프로그래머가 명시적으로 athrow 명령어를 사용하여 예외를 발생시킬 수 있다.
JVM 명령어들은 비정상적인 상태를 감지하면 예외를 발생시킬 수 있다.
JVM은 단일 동기화 구조(Synchronization), 즉 모니터를 통해 메서드 및 메서드 내의 명령어 시퀀스의 동기화를 지원한다. 메서드 수준의 동기화는 메서드 호출 및 반환의 일부로 암시적으로 수행된다.
동기화된 메서드는 실행 시간 상수 풀의 method_info 구조에서 ACC_SYNCHRONIZED 플래그로 식별되며, 메서드 호출 명령어에서 확인된다. ACC_SYNCHRONIZED가 설정된 메서드를 호출할 때 실행 스레드는 모니터에 진입하고 메서드 자체를 호출한 후 메서드 호출이 정상적으로 완료되든 예외가 발생하든 모니터에서 나온다. 실행 스레드가 모니터를 소유하는 동안 다른 스레드는 모니터에 진입할 수 없다. 동기화된 메서드의 호출 중에 예외가 발생하고 동기화된 메서드가 예외를 처리하지 않으면 해당 메서드의 모니터는 예외가 동기화된 메서드에서 다시 예외처리 되기 전에 자동으로 나간다.
명령어 시퀀스의 동기화는 일반적으로 자바 프로그래밍 언어의 동기화 블록을 인코딩하는 데 사용된다. JVM은 이러한 언어 구조를 지원하기 위해 monitorenter 및 monitorexit 명령어를 제공한다. 동기화 블록의 적절한 구현은 JVM을 대상으로 하는 컴파일러의 협력이 필요하다.
구조화된 락(Structured Locking)은 메서드 호출 중에 주어진 모니터에 대한 모든 나가기가 해당 모니터에 대한 이전 진입과 일치하는 상황이다. JVM에 제출된 모든 코드가 구조화된 락을 수행할 것이라는 보장이 없으므로 JVM의 구현은 구조화된 락을 보장하는 다음 두 규칙을 강제할 수 있지만 필수적이지는 않다. 여기서 T는 스레드이고 M은 모니터
T가 메서드 호출 중에 M에서 수행한 monitor entry의 수는 메서드 호출이 정상적으로 완료되든 예외가 발생하든 상관없이 T가 M에서 수행한 monitor exit의 수와 일치해야 합니다.
자바 가상 머신이 동기화된 메서드를 호출할 때 자동으로 수행되는 monitor entry 및 exit은 호출 메서드의 호출 중에 발생한 것으로 간주된다.
JVM은 자바 SE 플랫폼의 클래스 라이브러리를 구현하기 위한 충분한 지원을 제공한다. 자바 라이브러리의 일부 클래스는 자바 가상 머신의 협력 없이는 구현할 수 없다. 특히 자바 가상 머신의 특별한 지원이 필요한 클래스는 다음과 같다:
Reflection: java.lang.reflect 패키지의 클래스 및 Class 클래스와 같이 클래스로드 및 클래스 또는 인터페이스의 생성을 지원하는 클래스. ex) ClassLoader 클래스가 있다.
클래스 또는 인터페이스의 링크 및 초기화: 위에서 언급한 예제 클래스들도 이 범주에 속한다.
보안: java.security 패키지의 클래스 및 SecurityManager와 같은 다른 클래스.
멀티스레딩: Thread 클래스와 같은 클래스.
약한 참조: java.lang.ref 패키지의 클래스들.
2장에서 JVM의 class file format과 그 안의 명령어들을 이용해서 사용자들은 본인의 구현에 참고할 수 있다. 2장의 내용들은 꼭 지켜야 되는 법칙이라고 생각하지말고 개념적인 청사진으로 생각하여 개인적인 구현을 해야된다.
명확히 정의된 가상 머신과 객체 파일 형식의 존재가 구현자의 창의성을 크게 제한할 필요는 없다. 자바 가상 머신은 많은 다양한 구현을 지원하도록 설계되었으며, 구현 사이의 호환성을 유지하면서 새로운 흥미로운 해결책을 제공한다.