Java에서 final이 붙으면 불변일까?

Photo by 📸 IMYT on Unsplash

Java에서 final이 붙으면 불변일까?

Refrence Type에서의 final과 Primitive Type에서의 final

어느 날 동료 개발자 K에게 불변(immutable)에 대한 질문을 했습니다.

Me: Java에선 Enum 빼고 다 리플렉션으로 값을 수정할 수 있는데, 그럼 엄밀히 말해서 Enum 빼고 불변하다고 볼 수 없는 건가? 자바에서 불변을 얘기할 때 "엄밀히 말하면 아니지만"이라는 사족을 붙여야 해?

K: final 몰라? 필드 변수에 final 붙으면 불변이잖아. final 변수의 값을 어떻게 수정하는데?

저는 이미 선언된 final 키워드라도 런타임에 무효화 시킬 수 있다는 사실을 알고 있었기 때문에 당연히 수정할 수 있다고 생각했는데, 제가 이 얘기를 했음에도 굉장히 굳건한 동료 K의 태도에 제가 혹시 잘못 알고 있는 건지 확인할 필요가 생겼습니다.

💡정말 final 변수는 불변할까?

Once a final variable has been assigned, it always contains the same value.

JLS11을 참고해 보면 final 변수는 값이 한번 할당되면, 항상 같은 값을 유지한다고 합니다. 그런데 제가 알기로 리플렉션을 이용하면 final을 사용하더라도 "재할당"이 가능하다고 알고 있었는데, 틀렸던 걸까요? 다음 코드를 한번 살펴봅시다.

public class Main {

    private static final Integer value = 10;

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        System.out.println(value); // [1]
        setFinalStatic(Main.class.getDeclaredField("value"), 20);
        System.out.println(value); // [2]
    }

    private static void setFinalStatic(Field field, Object newValue) throws NoSuchFieldException, IllegalAccessException {
        field.setAccessible(true);

        // Field 객체의 지역 변수 modifiers를 획득합니다. 
        Field modifiersField = Field.class.getDeclaredField("modifiers");
        modifiersField.setAccessible(true);
        // field.getModifiers()의 Modifier.FINAL 값을 반전시킵니다.
        modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

        field.set(null, newValue);
    }
}

인라인으로 final 키워드를 붙여준 Integer형 value10으로 초기화 되어있습니다. final 키워드를 붙여서 값이 항상 불변하다고 한다면, 리플렉션을 사용하더라도 변하지 않아야겠죠.

그렇다면 main() 메서드의 [1], [2]에서 출력하는 value는 각각 무엇일까요?

Java11에서는 금지된 리플렉션을 썼다며 경고를 띄우긴 하지만, 런타임에 10, 20을 출력해주네요. 값이 바뀐 것을 확인할 수 있습니다. 내부에서는 다음과 같이 동작합니다.

  1. 원시 타입 10, 20은 참조 타입 new Integer(10), new Integer(20)으로 오토박싱 됩니다.

  2. [1]에서 valuenew Integer(10)을 참조하고 있으므로 10을 출력합니다.

  3. 리플렉션이 Integer 타입인 valuenew Integer(20)을 참조하도록 변경합니다.

  4. 결과적으로 [2]에서 valuenew Integer(20)을 참조하므로 20을 출력합니다.

실제로 주소를 비교를 해보면, 참조 값이 변경된 것을 알 수 있습니다.

Object o = value;
System.out.println(System.identityHashCode(o)); // 985922955
System.out.println(System.identityHashCode(value)); // 985922955
System.out.println(o == value); // true
setFinalStatic(Main.class.getDeclaredField("value"), 20);
System.out.println(System.identityHashCode(o)); // 985922955
System.out.println(System.identityHashCode(value)); // 873415566
System.out.println(o == value); // false

근데 왜 "Java11에서"냐고요?

이 코드는 Java17에서 NoSuchFieldException을 발생시킵니다. 언제부터 바뀐 건지는 안 찾아봤고, 이 포스트는 JLS11을 기준으로 작성됐습니다.

어쨌거나 final 값을 수정하는 데에 성공했습니다. 그런데 여기까지 인내심을 갖고 읽으며 태클을 걸고 싶으신 분이 분명 계실거라 생각합니다.

💡그건 참조 타입이니까 그렇고, 원시 타입은 수정 안 돼.

If a final variable holds a reference to an object, then the state of the object may be changed by operations on the object, but the variable will always refer to the same object.

JLS 문서에 의거하여 "참조형은 상태는 수정이 가능하다. 하지만 final 변수가 참조하는 객체는 변경할 수 없다"고 알고 있나요? 그런데 우리는 분명 참조하는 객체를 변경했습니다.

다시 생각해보면, 우리가 값을 변경했던 valueInteger 타입의 참조 자료형이었습니다. 런타임에 final 키워드를 제거해버렸으니 새로운 레퍼런스를 가리키게 하는 것은 당연할 지도 모릅니다.

그렇다면 과연 처음부터 원시 타입으로 초기화한 경우는 어떨까요?

💡놀랍게도 원시 타입 또한 중간에 변경이 됩니다.

Even then, there are a number of complications. If a final field is initialized to a constant expression in the field declaration, changes to the final field may not be observed, since uses of that final field are replaced at compile time with the value of the constant expression.

무슨 말이냐구요? 중간에 수정은 돼요. 다만 다시 상수 값으로 대체됩니다. 그래서 변경 사항을 관찰할 수 없을 뿐입니다. 코드로 살펴봅시다.

public class Main {

    // Integer가 int로 바뀜
    private static final int value = 10;

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        System.out.println(value);
        setFinalStatic(Main.class.getDeclaredField("value"), 20);
        System.out.println(value);
    }

    private static void setFinalStatic(Field field, Object newValue) throws NoSuchFieldException, IllegalAccessException {
        ...
    }
}

처음 봤던 코드에서 레퍼런스 타입을 원시 타입으로 바꾼 코드입니다. 이 경우의 출력값은 어떨까요?

정답은 10, 10입니다. 안 바뀐 거 맞지 않냐구요? 최종적으로는 값이 같지만, 중간에 사실 20으로 바뀌었습니다. javap -c 커맨드로 클래스 파일을 역어셈블하여 한번 확인해 봅시다. 바이트 코드에 대해 궁금하시다면 D2 블로그를 참고해 주세요.

Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1     // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.NoSuchFieldException, java.lang.IllegalAccessException;
    Code:
       0: getstatic     #7     // Field java/lang/System.out:Ljava/io/PrintStream;
       3: bipush        10
       5: invokevirtual #15    // Method java/io/PrintStream.println:(I)V
       8: ldc           #13    // class Main
      10: ldc           #21    // String value
      12: invokevirtual #23    // Method java/lang/Class.getDeclaredField:(Ljava/lang/String;)Ljava/lang/reflect/Field;
      15: bipush        20
      17: invokestatic  #29    // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      20: invokestatic  #35    // Method setFinalStatic:(Ljava/lang/reflect/Field;Ljava/lang/Object;)V
      23: getstatic     #7     // Field java/lang/System.out:Ljava/io/PrintStream;
      26: bipush        10
      28: invokevirtual #15    // Method java/io/PrintStream.println:(I)V
      31: return
}

bipush는 OpCode는 1byte 크기의 정수를 스택에 로드한다는 의미입니다. 그래서 3: bipush 10는 1byte 크기의 10이라는 피연산자를 스택에 넣는 명령이 됩니다. invokevirtual는 메서드를 호출하는 명령어의 OpCode인데, 2byte의 피연산자를 필요로 합니다. 12: invokevirtual #23getDeclaredField() 메서드를 호출하는 명령이 됩니다.

그런데 다음 줄을 보면 15: bipush 20 명령이 있네요. 스택에 있던 값을 다시 20으로 바꾸어버렸습니다! 리플렉션으로 분명히 중간에 스택의 값이 변경되었지만, value는 컴파일 타임의 상수이기 때문에 다시 20으로 대체됩니다.

결론적으로 JVM에 로드된 런타임 시점에서는 변경이 됐었다는 것을 알 수가 없습니다.


🎯정리

  • final 키워드를 붙이더라도 Field의 modifiers를 가져와서 final 선언을 무효화시킬 수 있습니다.

  • 참조 타입은 실제로 위의 방식으로 변경이 가능합니다! 적어도 JDK11까지는요.

  • 원시 타입은 최종적으로는 변경이 되지 않습니다. 불변하다고 표현할 수 있겠네요.


🔖참고