그렇다면 컴파일 과정에서, final 키워드는 다르게 취급되나요?

네, 컴파일러와 JVM 모두 final을 특별하게 취급하여 일반 변수·메서드·클래스와 다른 최적화를 적용합니다.

1. final 변수 — 컴파일 타임 상수 인라이닝

static final 기본 타입·문자열

public class Config {
    static final int MAX = 100;
    static final String NAME = "hello";
}

public class Main {
    void run() {
        int x = Config.MAX;        // 소스코드에서는 MAX 참조
        String s = Config.NAME;
    }
}
// 컴파일 후 바이트코드 (.class 디컴파일)
public class Main {
    void run() {
        int x = 100;              // MAX가 아니라 100이 직접 삽입됨
        String s = "hello";       // NAME이 아니라 "hello"가 직접 삽입됨
    }
}
컴파일러가 Config.MAX 참조를 100으로 대체
→ 런타임에 Config 클래스를 로딩할 필요조차 없음
→ 필드 접근 비용 완전 제거

인라이닝이 되는 조건

조건인라이닝예시
static final + 리터럴Ostatic final int MAX = 100;
static final + 문자열 리터럴Ostatic final String S = "hi";
static final + 메서드 호출Xstatic final int X = compute();
static final + 객체Xstatic final List L = List.of();
final 인스턴스 변수Xfinal int age;
// 인라이닝 됨
static final int A = 100;                    // 리터럴 → O
static final String B = "hello";             // 문자열 리터럴 → O
static final int C = 10 + 20;               // 컴파일 타임 연산 → O

// 인라이닝 안 됨
static final int D = Integer.parseInt("100"); // 메서드 호출 → X
static final int E = new Random().nextInt();  // 런타임 값 → X

인라이닝의 부작용 — 재컴파일 문제

// 라이브러리
public class Version {
    public static final int CODE = 1;    // v1 배포
}

// 사용하는 코드
public class App {
    void check() {
        if (Version.CODE == 1) { ... }   // 컴파일 시 1로 인라이닝됨
    }
}
Version.CODE를 2로 변경하고 Version만 재컴파일하면?

App.class에는 여전히 1이 박혀 있음
→ App도 함께 재컴파일해야 반영됨
→ 이것이 "바이너리 호환성(Binary Compatibility)" 문제

2. final 메서드 — 가상 호출 제거

일반 메서드 (non-final)

class Animal {
    void speak() { }    // 오버라이딩 가능
}

class Dog extends Animal {
    @Override
    void speak() { }    // 오버라이딩됨
}
animal.speak() 호출 시:

1. animal의 실제 타입 확인 (Animal? Dog?)
2. vtable(가상 메서드 테이블) 조회
3. 실제 타입의 speak() 주소 찾기
4. 해당 메서드 호출

→ 매번 간접 호출 (virtual dispatch)

final 메서드

class Animal {
    final void speak() { }   // 오버라이딩 불가
}
animal.speak() 호출 시:

컴파일러/JVM:
"speak()는 final이니 어떤 하위 클래스도 재정의 불가"
→ vtable 조회 불필요
→ 직접 호출 (또는 인라이닝)

// 컴파일러가 변환하는 효과
animal.speak()
      ↓
speak()의 본문이 호출부에 직접 삽입 (메서드 인라이닝)
호출 방식과정비용
가상 호출 (non-final)타입 확인 → vtable 조회 → 간접 점프높음
직접 호출 (final)주소로 바로 점프낮음
인라이닝 (final)메서드 본문을 호출부에 삽입최소 (호출 비용 제로)

바이트코드 수준 차이

// non-final 메서드 호출
invokevirtual #3  // vtable 조회 필요

// final 메서드 → JVM이 최적화
invokevirtual #3  // 바이트코드는 동일하지만
                   // JIT가 vtable 조회를 생략하고 직접 호출로 변환

3. final 클래스 — 전체 메서드 최적화

final class String {
    // 모든 메서드가 사실상 final
    // → 모든 메서드에 vtable 생략 + 인라이닝 적용 가능
}
final class = 상속 불가
→ 모든 메서드가 오버라이딩 불가
→ 클래스 전체에 final 메서드와 동일한 최적화 적용

4. final 필드 — JMM 메모리 가시성 보장

컴파일러·JVM이 final 필드에 대해 메모리 배리어(Memory Barrier) 를 삽입합니다.

class Holder {
    final int value;

    Holder(int v) {
        this.value = v;
        // ── 여기에 StoreStore 배리어 삽입 ──
        // 생성자 완료 전에 value가 반드시 메모리에 기록됨을 보장
    }
}
[ final 없이 ]
스레드 A: holder = new Holder(42);
스레드 B: holder.value → 0일 수 있음 (생성자 미완료 상태 관측)

[ final 사용 ]
스레드 A: holder = new Holder(42);
           │ 배리어: value = 42 확정 후에만 holder 참조 공개
스레드 B: holder.value → 반드시 42
구분final 없는 필드final 필드
다른 스레드에서 읽기불완전한 값 관측 가능항상 완전한 값 보장
추가 동기화 필요volatile 또는 synchronized 필요불필요
컴파일러 처리일반 writewrite + 메모리 배리어

5. JIT 컴파일러의 final 활용

[ non-final 필드 ]
JIT: "이 값이 런타임에 바뀔 수 있으니 매번 메모리에서 읽어야 해"
→ 레지스터 캐싱 제한

[ final 필드 ]
JIT: "이 값은 절대 안 바뀌니 레지스터에 캐싱해도 안전해"
→ 메모리 접근 횟수 감소
→ 루프 최적화에서 더 공격적으로 활용
final int size = list.size();
for (int i = 0; i < size; i++) {   // JIT가 size를 레지스터에 고정
    process(list.get(i));           // 매 반복마다 size 재조회 불필요
}

최적화 종합 정리

final 대상컴파일러 최적화JVM/JIT 최적화
static final 리터럴상수 인라이닝 (값 직접 삽입)-
final 인스턴스 필드메모리 배리어 삽입레지스터 캐싱, 재조회 생략
final 메서드-vtable 생략, 메서드 인라이닝
final 클래스-모든 메서드에 final 메서드 최적화 적용
final 지역 변수컴파일러가 불변으로 추론JIT가 더 공격적으로 최적화

핵심 정리

final은 단순한 제약 키워드가 아니라 컴파일러와 JVM에게 최적화 힌트를 주는 역할을 합니다. 컴파일 타임에는 상수 인라이닝과 메모리 배리어 삽입, 런타임에는 vtable 생략, 메서드 인라이닝, 레지스터 캐싱 등의 최적화가 적용됩니다. 특히 멀티스레드 환경에서 final 필드의 JMM 가시성 보장은 성능 이상의 정확성 문제이므로, 불변 객체 설계 시 final은 선택이 아닌 필수입니다.

CSLOG에서 보기