[Java] IntegerCache를 만나봤니?

lee_choonghee·2021년 9월 12일
3

Java

목록 보기
2/2

Integer 래퍼 클래스에는 IntegerCache 클래스가 내부에 존재한다. IntegerCache가 어떻게 동작하는지 알아보고 실제로 캐싱이 되는지 확인해 볼 것이다.

  • [Optional] 이라고 표시된 섹션은 건너뛰어도 읽는데 지장이 없다.
  • 의식의 흐름대로 공부 중이니 제목에 어긋나는 내용도 있을 수 있다.

Integer

일단 Integer가 어떤 클래스인지 정의를 정확히 보고 싶었다.

Integer 클래스는 primitive type인 int를 감싸는 객체이다.
Integer 객체는 타입이 int인 단일 필드를 포함한다.

Java 11 공식 문서

[Optional] 공식 문서에서 필드 확인 ✅

"Integer 객체는 타입이 int인 단일 필드를 포함한다."라는 문장에 꽂혀서 확인해보고자 공식 문서를 찾아보았다. 그런데...

엥? 없는데요? ㅋㅋㅋㅋ. 시작부터 난관이 예상된다. IntelliJ에서 코드를 찾아보니 AdoptOpenJDK 11 기준으로 1062~1067번 줄에서 찾을 수 있었다.

/**
* The value of the {@code Integer}.
*
* @serial
*/
private final int value;

코드에 멀쩡히 살아있음 + 문서용 주석인데, 왜 공식 문서에는 나오지 않는 걸까?

여러 문서 페이지를 돌아다녀보니 public, protected로 선언된 변수들만 Field Summary 섹션에 나온다. "이럴거면 private 변수에 문서용 주석은 왜 달아놓는데?"라는 생각 + "진짜로 private로 선언되어 있으면 문서에 못 들어가나?"에 대한 의문이 떠오르면서 한 단계 더 나아가 보기로 했다.

[Optional] private 변수를 문서에 포함시키기

항상 궁금한걸 해봐야 직성이 풀리지는 않지만, 한 단계 더 나아가는 습관을 만들기 위해 노오오오오오오오력 해본다 💪!

아래의 코드를 작성했다.

public class Main {

    /**
     * public 으로 선언된 인스턴스 변수
     */
    public int num = 1;

    /**
     * protected 으로 선언된 인스턴스 변수
     */
    protected double doubleNum = 1;

    /**
     * package-private 으로 선언된 인스턴스 변수
     */
    boolean isTrue = true;

    /**
     * private 으로 선언된 인스턴스 변수
     */
    private String str = "Hello";
   
    public static void main(String[] args) {}
}

그리고 다음과 같이 javadoc 명령어를 실행한다.

$ javadoc Main.java

생성된 파일들 중 패키지명으로된 폴더들을 따라 들어가서 Main.html열어본다. 그럼 Field Summary가 아래와 같이 나타난다.

javadoc의 디폴트 동작이 public, protected만 나오게 되어있다는 것을 확인했다.

내가 하고싶은 것은 "private 변수도 문서에 껴주시라요" 이기 때문에, javadoc --help로 옵션질(?)을 해보도록 한다.

여담이지만, 어느순간 자동차 카탈로그의 옵션을 읽는 것보다 커맨드라인 명령어의 옵션을 보는 것이 더 쉬워졌다. 자동차 옵션은 무슨 말인지 하나도 모르겠다.

찾았다!

-private
	Show all types and members. For named modules,
    	show all packages and all module details.

보면 쉽지만, 안 찾아보면 평생 모를법한 옵션이다. 적용해보자!

$ javadoc -private Main.java

다시 Main.html을 열어보면

private, package-private 변수가 모두 포함되어 있다!

혹시 여기까지 읽었다면 미안하게 생각한다. 의식의 흐름대로 공부하며 글을 적는 중이다 😜.. 본론으로 돌아가자!

IntegerCache

IntegerCacheprivate 클래스이기 때문에 Integer 문서에 나타나지 않는다. IntelliJ에서 코드를 확인해보면 다음과 같은 설명이 나온다.

Cache to support the object identity semantics of autoboxing for values between -128 and 127 (inclusive) as required by JLS

AdoptOpenJDK 11

무슨 말인지 이해는 되는데 한글로 못 적겠는 그런거 내 맘 알지 😜?

-128과 127 사이의 값을 오토박싱한 객체가 유일함을 캐싱한다.

초 간단 번역

번역 피드백 좀 주십쇼... 🙇🏻‍♂️

코드 살펴보기 🕵🏽‍♀️

이거 구글에 검색해보면 다 나오는거지만 나도 한 번 해보겠다. AdoptOpenJDK 11 버전의 코드를 기준으로 한다.

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

    static {
        // 캐싱 가능한 최대 int 값은 디폴트가 127이다.
        int h = 127;
        // 시스템 프로퍼티 -Djava.lang.Integer.IntegerCache.high 또는
        // JVM 옵션 -XX:AutoBoxCacheMax을 설정한 경우
        // 캐싱 가능한 최대값 설정
        String integerCacheHighPropValue =
            VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // int로 파싱이 불가능하면 무시한다.
            }
        }
        high = h;

        // 캐싱 배열에 Integer 객체 미리 만들어 삽입
        // -128부터 127까지 미리 만들어 놓는다!
        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);

        // 캐싱 가능한 최대 int 값은 127보다 같거나 커야한다.
        // 위의 i = Math.max(i, 127); 코드에서 알아서 걸러준다.
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

어떻게 동작하는지 살펴보았다. 다른 Wrapper 클래스들도 이런 캐싱을 하는지 궁금해졌다.

[Optional] 다른 Wrapper 클래스들의 Cache 클래스

확인을 직접해보니 Boolean, Float, Double 형 빼고 정수형 Wrapper 클래스들은 각자의 Cache 클래스가 있다. 구현 코드를 보니 JVM 옵션으로 최대 캐싱값을 설정하는 곳은 없었다. 그냥 -128~127 사이의 값 고정이다.

다음은 ByteCache 의 구현 코드이다. ShortCache, LongCache 의 구현도 타입만 다를 뿐 모두 같다.

private static class ByteCache {
    private ByteCache(){}

    static final Byte cache[] = new Byte[-(-128) + 127 + 1];

    static {
        for(int i = 0; i < cache.length; i++)
            cache[i] = new Byte((byte)(i - 128));
    }
}

CharacterCache는 구현 코드가 조금 다르다. 근본은 정수 타입이지만 문자를 표현하기 위한 타입이므로 음수값을 캐싱하지 않는다.

private static class CharacterCache {
    private CharacterCache(){}

    static final Character cache[] = new Character[127 + 1];

    static {
        for (int i = 0; i < cache.length; i++)
            cache[i] = new Character((char)i);
    }
}

Integer로 메모리를 터트려보자 💣

갑자기 왠 메모리를 터트려보자? ㅋㅋㅋㅋ 실제로 Integer 객체를 캐싱하는지 알아보려면 같은 값의 Integer 객체 2개를 선언하고 ==로 비교해보면 쉽게 알 수 있다. 하지만 뭔가 밋밋하고 재미가 없어서 메모리를 터트려보려고 한다. 목표는 java.lang.OutOfMemoryError 이다!

가비지 컬렉션 동작 금지! ✋🏻

아무리 무한 루프로 열심히 객체를 생성한다 한들 가비지 컬렉터가 싹 정리해 버리면 소용이 없다. 그래서 가비지 컬렉팅을 금지시켜야 겠다고 생각을 했다. 그래서 열심히 찾아낸 것이 Epsilon GC이다.

Epsilon: A No-Op Garbage Collector

메모리 할당을 처리하지만 회수 메커니즘을 구현하지 않는 GC를 개발해보세요. 사용 가능한 Heap이 소진되면 JVM이 종료됩니다.

OpenJDK JEP 381 - Epsilon GC

와우! 할당은 하지만 회수를 하지 않는 지금 나의 실험에 꼭 필요한 도구이다! 엡실론 가비지 컬렉터를 적용하려면 다음과 같은 옵션을 주면된다.

-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC

Baeldung의 Attila Fejér 선생님(?)은 도대체 이런걸 어떻게 알았는지 정말 신기하다... 🤪

최소 & 최대 메모리 옵션

빠른 폭파를 위해 추가적으로 최소, 최대 메모리 옵션을 똑같이 작게 할당했다.

-Xms128m -Xmx128m

무한 루프를 돌려보자

프로그래밍에서 무한 루프 작성은 위험 덩어리라고 배우지만 OutOfMemoryError가 목표이므로 돌려보도록 하겠다. 돌려돌려 돌림판~!

코드는 정말로 별거 없음 그 자체이다.

public class Main {
    public static void main(String[] args) {
        while(true) {
            // Integer i = 1;
            Integer i = 999;
            System.out.println(i);
        }
    }
}

먼저 Integer 객체의 값을 999로 놓고 돌려본다. -XX:AutoBoxCacheMax 옵션을 따로 주지 않았으니 캐싱이 안될 것 이므로 메모리 폭파를 예상했고 결과는 다음과 같다.

...
999
999
999
Terminating due to java.lang.OutOfMemoryError: Java heap space

굿 👍

다음은 값을 1로 놓고 돌려본다. 캐싱이 되는 범위에 있으므로 프로그램이 계속 살아있음을 예상했고 결과는 다음과 같다.

...
1
1
1
Terminating due to java.lang.OutOfMemoryError: Java heap space

?! 저기요 님은 왜 터지시죠..? 정말 한참 고민했다. 생각해보니 System.out.println(i); 이 코드에서 PrintStream에서 Output Stream들이 할당된다는 것을 깨달았다. Epsilon GC가 메모리 공간 해제를 안 해주니 이것들이 쌓여서 이런 결과가 나왔음을 발견했다.

코드를 다시 수정했다.

while(true) {
    Integer i = 1;
    // Integer i = 999;
}

안 터지고 계속 돌아간다 :D

999의 주석을 해제하고 실행시켜보았다. 안 터지고 계속 돌아간다... 어얽... 터져야 되는데?? 😓 그럼 앞에서 터진 것도 Integer 값이 999인 객체를 계속 생성해서 그런 것이 아니었다는 말이다. 에효효효효효효

5시간의 고민 끝에...

위의 코드를 계속 돌렸다 멈췄다 디버깅했다가 검색했다가 생각했다가를 반복하니 5시간이 흘렀다. 진짜 뜬금없이 옛날에 Visual Studio에서 C++ 코딩을 할 때, 컴파일러 최적화 옵션을 줄 수 있고 기본적으로 최적화를 해주었던게 기억이 났다. 혹시나 하는 마음에 자바 컴파일러도 최적화를 해주는지 찾아보았다.

JIT 컴파일러 최적화 4단계

다음의 코드를 작성하면 JIT 컴파일러가 어떻게 최적화하는지 예제와 함께 매우 간단하게만 알아본다.

class A {
    B b;
    public void foo() {
        y = b.get();
        ...do stuff...
        z = b.get();
        sum = y + z;
    }
}
class B {
    int value;
    final int get() {
        return value;
    }
}

1. Inline final method

1단계를 거치면 b.get() 메소드를 호출하는 대신 바로 값으로 치환한다. 접근 시간을 조금이라도 줄일 수 있다.

public void foo() {
    y = b.value; // 바뀐 부분
    ...do stuff...
    z = b.value; // 바뀐 부분
    sum = y + z;
}

2. Remove redundant loads

b.value 코드에 접근하는 것보다 지역 변수에 접근하게 하여 속도를 빠르게 한다.

public void foo() {
    y = b.value;
    ...do stuff...
    z = y; // 바뀐 부분
    sum = y + z;
}

3. Copy propagation

굳이 z라는 추가적인 변수를 사용할 필요가 없기 때문에 y로 바꿔버렸다.

public void foo() {
    y = b.value;
    ...do stuff...
    y = y; // 바뀐 부분
    sum = y + z;
}

4. Eliminate dead code

y = y불필요한 동작이므로 지워버렸다.

public void foo() {
    y = b.value;
    ...do stuff...
    // 바뀐 부분
    sum = y + z;
}

JIT 컴파일러가 4단계를 거쳐 코드 최적화를 해주는 것을 보았다. 물론 더 깊게 봤으면 하는 아쉬움이 있지만 글이 너무나도 길어질 것 같아 따로 주제를 빼서 해야할 것 같다.

JIT 컴파일러 비활성화 하기

나의 실험 코드 Integer i = 999;가 JIT 컴파일러에 의해 제거되므로 JIT 컴파일러를 비활성화 하기로 마음먹었다. 다음과 같은 옵션을 주면 된다.

-Djava.compiler=NONE

다시 코드를 돌려보자!

while(true) {
    // Integer i = 1;
    Integer i = 999;
}
Terminating due to java.lang.OutOfMemoryError: Java heap space

ㅅㄱ... 드디어 끝났다!

결론

IntegerCache가 동작한다는 것을 내부 구현 코드만 봐도 알 수 있지만 뭔가 색다르게 증명해보고 싶었다. 그 와중에 알게된 것도 많고 깊게 공부하려는 습관을 만드는데 많은 도움이 된 것 같다. 재미있는 시간이었다! 💪

참고한 자료

profile
팔로우 기능 생기면 팔로우 당하고 싶다

0개의 댓글