[Java] Java Wrapper Class Cache 와 인스턴스 캐싱

Jinn·2023년 11월 17일

java

목록 보기
1/1

객체 Caching 을 생각하게된 계기

해당 글은 특정한 숫자 범위의 값을 처리하는 요구사항들에서, 이를 객체지향 생활 체조 원칙의 "모든 원시값과 문자열을 포장한다" 라는 원칙에 따라 변경하는 과정에서 출발하게 되었습니다.

유저가 1~5점까지 평가한 총 점수의 평균을 구하는 기능이 있다고 해봅시다.

public int averageScore(List<Integer> scores) {
	// 점수가 1~5점인지 확인하는 validate 로직
    // 실제 평균을 구하는 로직
}

해당 함수에서 점수의 평균을 구한다는 함수의 이름에서 점수 자체에 대한 검증이 일어나 "평균을 구하는 함수"에서 "각 점수의 유효성을 검증" 한다는 부분이 부자연스럽게 느껴졌습니다.

또한 이러한 점수를 가져다 쓰는 모든 곳에서 항상 점수가 1~5점인지 검증해야하는 과정에서 점수의 개념 자체를 원시 타입으로 집착하며 생긴 문제라 생각하여 객체지향 생활 체조 원칙의 "모든 원시값과 문자열을 포장한다" 라는 원칙을 지키고자 점수 자체의 값 객체를 만들고자 했습니다.

위와 같은 이유로 아래와 같은 객체를 만들었습니다.

public record Score(
        int score
) {

    private static final int MIN_SCORE = 1;
    private static final int MAX_SCORE = 5;

    public Score {
        validateScoreRange(score);
    }

    private void validateScoreRange(int score) {
        // 점수가 1~5인지 validate
    }
}

Score 객체를 만들고 보니 메모리 성능에 대한 의문이 들었습니다.
만약, n개의 항목에 점수를 준다고 하면 실제로 n개의 Score가 heap 메모리에 저장되기 때문이었습니다.

하지만 List<Integer> 또한 Wrapping Type으로 결국 메모리 성능이 동일하지 않을까? 라는 생각과 동시에 설마 기본 Wrapping Type 이 과연 이런 성능 고려가 되어있지 않을까 하고 무작정 살펴본 결과, IntegerCache 라는 Class를 확인하고 이를 바탕으로 1~5까지의 점수를 캐싱하고자 생각하게 되었습니다.

Integer Cache Class

Primitive Type의 int가 Wrapping Type에 Integer로 Boxing 될 때, 사용되는 valueOf 메소드를 살펴보면 다음과 같은 코드를 발견할 수 있습니다.

    @IntrinsicCandidate
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

위 코드를 잘 살펴보면 받아온 i 라는 값이 IntergerCache가 가지는 최소값과 최대값 사이에 값이라면 미리 캐시된 값을 던져주고 그렇지 않으면 새로운 Integer 인스턴스를 생성해 반환하고 있습니다.

IntergerCache 바로 위 docs 를 보면 다음과 같은 내용을 확인할 수 있습니다.

    /**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * The cache is initialized on first usage.  The size of the cache
     * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
     * During VM initialization, java.lang.Integer.IntegerCache.high property
     * may be set and saved in the private system properties in the
     * jdk.internal.misc.VM class.
     *
     * WARNING: The cache is archived with CDS and reloaded from the shared
     * archive at runtime. The archived cache (Integer[]) and Integer objects
     * reside in the closed archive heap regions. Care should be taken when
     * changing the implementation and the cache array should not be assigned
     * with new Integer object(s) after initialization.
     */

JVM에 -XX:AutoBoxCacheMax=<size> 옵션을 통해 범위를 변경할 수 있지만 기본적으로 -128~127까지 값을 캐싱합니다.

왜 해당 범위를 캐싱하는지는 valueOf 메소드 위 주석을 확인하면 알 수 있습니다.

Returns an Integer instance representing the specified int value. If a new Integer instance is not required, this method should generally be used in preference to the constructor Integer(int), as this method is likely to yield significantly better space and time performance by caching frequently requested values. This method will always cache values in the range -128 to 127, inclusive, and may cache other values outside of this range.

정리하자면 자주 사용되는 값이라 이를 캐싱해서 사용하고있다는 것을 확인할 수 있습니다.

파레토 법칙을 그래프로 나타낸 Long Tail 현상 그래프에서 볼 수 있듯 상위 20%가 전체 결과의 80%를 차지하는 것인데, 실제 자주 사용되는 -128~127값을 캐싱한다면 매번 자주 사용하는 값으로 초기화 할 필요 없이 캐시해둔 값을 반환하여 메모리를 아낄 수 있습니다.

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

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    h = Math.max(parseInt(integerCacheHighPropValue), 127);
                    // Maximum array size is Integer.MAX_VALUE
                    h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // If the property cannot be parsed into an int, ignore it.
                }
            }
            high = h;

            // Load IntegerCache.archivedCache from archive, if possible
            CDS.initializeFromArchive(IntegerCache.class);
            int size = (high - low) + 1;

            // Use the archived cache if it exists and is large enough
            if (archivedCache == null || size > archivedCache.length) {
                Integer[] c = new Integer[size];
                int j = low;
                for(int i = 0; i < c.length; i++) {
                    c[i] = new Integer(j++);
                }
                archivedCache = c;
            }
            cache = archivedCache;
            // range [-128, 127] must be interned (JLS7 5.1.7)
            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

실제 IntegerCache 내부를 살펴보면 static block 으로 메모리에 로드되는 처음에 초기화 되는 것을 확인할 수 있습니다.

자주 사용될 인스턴스 캐싱하기

실제 IntegerCache 를 참고하여 구현한 결과는 다음과 같습니다.

public class Score {

    private static final int MIN_SCORE = 1;
    private static final int MAX_SCORE = 5;
    private static List<Score> CACHE;

    private final int score;

    static {
        CACHE = IntStream.rangeClosed(MIN_SCORE, MAX_SCORE)
                .mapToObj(Score::new)
                .toList();
    }

    private Score(int score) {
        validateScoreRange(score);
        this.score = score;
    }

    private void validateScoreRange(int score) {
        // score가 1~5 인지 validate
    }

    public static Score valueOf(int score) {
        Score cachedValue = CACHE.get(score);
        if (Objects.nonNull(cachedValue)) {
            return cachedValue;
        }
        return new Score(score);
    }
}

static block 에서 미리 1~5점의 점수를 초기화하고 실제로 valueOf로 값을 만드는 경우, 캐싱된 값이 있다면 반환하고, 그렇지 않으면 새로운 인스턴스를 반환합니다.

해당 과정에서 항상 메모리를 할당해 반환하던 경우와 달리, 항상 미리 선언된 메모리에서 꺼내 사용하기 때문에 메모리를 아끼도록 구현했습니다.

마무리하며

우아한 테크코스 6기 프리코스에서 Lotto 미션을 진행하며 해당 과정과 동일한 고민을 했습니다. 반복되는 요구사항에 비슷한 고민을 하다 결국 생각을 정리하기 위해 글을 작성하게 되었습니다.
과제 요구사항에서는 List로 구현되어있었기 때문에 LottoNumber를 Wrapping 하지 않고 이를 구현했습니다.
실제 원칙에 따라 초기에 구현을 했을 때는 성능적 관점에서 볼 수 있었다면 오히려 이후 프리코스 과제에서는 Wrapping 하지 않고 유지보수 하는 과정에서 Wrapping의 여부에 대한 차이를 알 수 있던 것 같습니다.
실제 구현 과정에서 느낀 객체지향 생활체조 원칙을 정리해보면 좋겠다는 생각을 하게 되었습니다

0개의 댓글