이펙티브 자바 3판 - 아이템 7. 다 쓴 객체 참조를 해제하라.

김대협·2023년 2월 17일
0

Effective Java 3rd

목록 보기
7/9

아이템 7. 다 쓴 객체 참조를 해제하라.


일반적으로 자바 언어는 Garbage Collector에 의해 자동으로 메모리가 관리되기 때문에
메모리 관리에 부주의한 경우를 많이 볼 수 있다.

우리가 주의해야할 것은 객체의 레퍼런스가 남아있다면 해당 객체는 가비지 컬렉션의 대상이 되지 않는다.
흔히 Stack, Cache, Listner 등과 같이 객체를 쌓아두는 공간에 메모리 누수 문제가 발생할 수 있다.
(Cache, Collection, List, Set, Map, Array 사용 시 메모리 누수 유의)

GC 를 사용하더라도 다음을 참고하여 직접 핸들링하는 방법을 생각해볼 수 있다.
1) 직접 null 대입을 명시적으로 사용
2) 객체 삽입, 삭제 시 직접 핸들링해서 삭제하는 방법
3) LRU 알고리즘을 이용한 삭제
4) 백그라운드 Thread를 이용한 주기적 clean-up
5) 특정한 자료 구조(WeakHashMap 등)를 사용

아래 예시를 통해 직접 살펴보도록 하자.

1) Stack 구현 시 문제점


예시로 작성된 Stack 구현은 정상 동작을 하는 코드지만, 부주의한 메모리 관리가 문제를 일으킨다.
아래 코드는 언젠가 OutOfMemory를 발생시킬 것이다.

이 Stack은 다 쓴 참조(obsolete reference)를 가지고 있어 referenced 하기 때문에 GC 수거의 대상이 되지 않는다.

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    // 문제 발생!! 객체 해제를 진행하지 않고 pop을 진행
    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }

        return elements[--size]; 
    }

    public void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

문제점을 해결하기 위해서는 아래와 같이 명시적인 참조 해제를 진행하여야 한다.

public Object pop() {
    if (size == 0) {
        throw new EmptyStackException();
    }
    Object result = elements[--size];
    elements[size] = null; // 명시적 참조 해제

    return result; 
}

단, 위와 같은 해결 방식이 있기 때문에 모든 객체를 명시적 null 대입을 통해서 해제하는 방식은
코드를 읽기 복잡하게 만들 뿐이며, 무의미한 방식이다.
객체 참조를 명시적으로 null 처리하는 상황은 예외적인 경우여야 한다.

public void test() {
    Object obj = new Obejct();
    //~~~ obj 사용 처리 ~~~~
    obj = null; // 무의미함
} 

2) Cache 구현 시 문제점


캐시 구현 시 메모리가 더 이상 사용하지 않는 순간을 판단하고, 데이터가 필요 없어지는 순간 GC가 해제할 수 있는 데이터로 만들어 주어야 한다.
그렇지만 캐시를 만들 때 엔트리의 유효 기간을 정확히 정의하기에는 어려움이 따른다.

WeakHashmap 사용

키를 참조하는 동안만 엔트리가 살아있는 캐시가 필요한 상황이거나
키가 중요한 경우(키가 유효하지 않으면 값이 무의미 해지는 경우)라면 WeakHashMap을 이용한 캐시 구현을 할 수 있다.

public class CacheRepository<K> {
    private Map<K, Data> cache;

    public CacheRepository() {
        this.cache = new WeakHashMap<>(); // 키가 참조되지 않으면 Map에서 제외.
    }

    public Data getDataByKey( K key ) {
        if ( cache.containsKey( key ) ) {
            return cache.get( key );
        } else {
            Data post = new Data();
            cache.put( key, post );
            return post;
        }
    }

    public Map<K, Data> getCache() {
        return this.cache;
    }
}

Key 값의 타입을 Generic K로 받는 WeakHashMap 구현

WeakHashMap은 Key 값이 더 이상 참조되지 않는 경우 Data가 GC 수거의 대상이 된다.
아래 테스트 코드를 통해 Key 값에 명시적인 null 처리를 통해 정상적 GC가 수행됨을 테스트 코드로 확인할 수 있다.

@Test
@DisplayName("Object Wrapper 클래스를 키로 활용한 해제 테스트")
void cacheForObjectWrapperKey() throws InterruptedException {
    CacheRepository<CacheKey> postRepository = new CacheRepository<>();
    CacheKey key = new CacheKey( 1 );
    postRepository.getDataByKey( key );

    assertFalse( postRepository.getCache().isEmpty() );

    key = null; // 명시적으로 reference를 끊어줘야 정리됨.

    System.gc();
    TimeUnit.SECONDS.sleep( 3 );

    assertTrue( postRepository.getCache().isEmpty(), () -> "repository is not empty." );
}

Wrapper 클래스를 키로 활용한 JUnit 테스트 구현

위에서 Wrapper 클래스를 Key로 활용하여 사용한 이유가 있다.
우리가 흔히 Key 값으로 활용하는 String, Integer 클래스의 경우 일부 값의 JVM 캐싱으로 인해 StrongReference가 남아 있다고 생각해서 지워지지 않는다.

아래 테스트 코드로 키 값의 명시적인 null 처리를 하더라도 해제되지 않음을 확인할 수 있다.

 @Test
@DisplayName("String 클래스를 키로 활용한 해제 테스트")
void cacheForStringKey() throws InterruptedException {
    CacheRepository<String> repository = new CacheRepository<>();
    String key = "str"; // JVM cache 로 인한 해제 불가
    repository.getDataByKey( key );

    assertFalse( repository.getCache().isEmpty() );

    key = null;

    System.gc();
    TimeUnit.SECONDS.sleep( 3 );

    assertTrue( repository.getCache().isEmpty(), () -> "repository is not empty." );
}

@Test
@DisplayName("Integer 클래스를 키로 활용한 해제 테스트")
void cacheForIntegerKey() throws InterruptedException {
    CacheRepository<Integer> repository = new CacheRepository<>();
    Integer key = 127; // ~127 Integer JVM cache로 인한 해제 불가
    repository.getDataByKey( key );

    assertFalse( repository.getCache().isEmpty() );

    key = null;

    System.gc();
    TimeUnit.SECONDS.sleep( 3 );

    assertTrue( repository.getCache().isEmpty(), () -> "repository is not empty." );
}

String, Integer 클래스를 키로 활용한 JUnit 테스트 구현

JUnit 테스트 결과 (String, Integer Key는 해제 되지 않는다.)

primitive int 타입의 Wrapper 클래스인 Integer 클래스의 경우 IntegerCache를 통해 127이하의 값은 캐싱 대상수로 Key 값에 명시적인 null 처리를 진행하더라도 GC 수거 대상이 되지 않는다.

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 {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, 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
        VM.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 k = 0; k < c.length; k++)
                c[k] = new Integer(j++);
            archivedCache = c;
        }
        cache = archivedCache;
        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

Integer 클래스의 캐시 관련 클래스 코드

Background Thread를 활용한 주기적 삭제 방법

적재된 데이터를 유효한 시간까지만 활용하고 삭제 처리하는 방식으로 사용한다.

@Test
@DisplayName("Background Thread를 이용한 주기적 Clean-up")
void cleanUpTest() throws InterruptedException {
    ScheduledExecutorService executorService = Executors.newScheduledThreadPool( 1 );
    CacheRepository<CacheKey> repository = new CacheRepository<>();
    CacheKey cacheKey = new CacheKey( 1 );
    repository.getDataByKey( cacheKey );

    Runnable removeOldCache = () -> {
        System.out.println( "running removeOldCache Task" );
        Map<CacheKey, Data> cache = repository.getCache();
        Set<CacheKey> cacheKeys = cache.keySet();
        Optional<CacheKey> key = cacheKeys.stream().min( Comparator.comparing( CacheKey::getCreated ) );
        key.ifPresent( ( k ) -> {
            System.out.println( "removed " + k );
            cache.remove( k );
        } );
    };

    executorService.scheduleAtFixedRate( removeOldCache, 1, 3, TimeUnit.SECONDS );
    TimeUnit.SECONDS.sleep( 20 );
    executorService.shutdown();
}

백그라운드 Thread를 활용한 주기적 Clean-up 방식


© 2023.1 Written by Boseong Kim.
profile
기록하는 개발자

0개의 댓글