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

Bobby·2022년 7월 6일
0

이펙티브 자바

목록 보기
7/7
post-thumbnail

자바는 가비지 컬렉터가 참조하지 않는 객체들 다 알아서 회수 해주는데요!!?

하지만! 몇몇 경우 메모리 누수가 발생 할 수 있으니 조심해야 한다.

  • 객체 안에서 배열, 리스트, 맵 등등 컬렉션 안에 데이터를 담고 있는 경우에는 메모리 누수를 주의해야 한다.

메모리 누수 해결하는 방법

1. Null 처리

스택을 간단히 구현한 코드이다.
메모리 누수가 일어나는 위치는 어디일까?

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;
    }

    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }

    /**
     * 원소를 위한 공간을 적어도 하나 이상 확보한다.
     * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
     */
    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}
  • 특별한 문제는 없어 보이지만 메모리 누수 문제가 숨어있다.
  • 이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하 될 것이다.
  • 상대적으로 드문 경우이긴 하지만 심할 때는 디스크 페이징이나 OutOfMemoryError 가 발생되기도 한다.
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }
  • 메모리 누수는 여기에서 발생한다.
  • 스택 객체가 pop() 메소드를 실행하여 꺼내진 객체들의 다 쓴 참조를 여전히 가지고 있기 때문에 가비지 컬렉터가 회수해 가지 않는다.
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }
  • 꺼낸 후 해당 참조를 해제해 주면 간단히 해결 할 수 있다.
  • 다 쓴 참조를 null 처리 하면 null 처리한 참조를 실수로 사용하려고 할 경우 NullPointerException 이 발생하여 오류를 발견할 수 있는 이점도 생긴다.
  • 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다.
  • 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(Scope) 밖으로 밀어내는 것이다. (클래스 변수, 메소드 변수, ..)

2. WeakHashMap

  • 캐시 역시 메모리 누수를 일으키는 주범이다.
  • 객체 참조를 캐시에 넣고 나서, 그 객체를 다 쓴 후에도 계속 놔두는 경우가 발생 할 수 있다.
  • 이런 경우 WeakHashMap 을 사용 할 수 있다.
  • WeakHashMap 은 맵 내부에서 참조하는 것 외에 다른 곳에서 참조하지 않으면 가비지 컬렉션의 대상이 된다.

게시글 리스트를 가져오는 API 에 캐시를 사용하는 상황을 생각해보자.

public class Post {

    private Integer id;

    private String title;

    private String content;
    
    // getter, setter
}
public class PostRepository {

    private Map<CacheKey, Post> cache;

    public PostRepository() {
        this.cache = new WeakHashMap<>(); // 1
        // this.cache = new HashMap<>();  // 2
    }

    public Post getPostById(Integer id) {
        CacheKey key = new CacheKey(id);
        if (cache.containsKey(key)) {
            return cache.get(key);
        } else {
            Post post = new Post();
            cache.put(key, post);
            return post;
        }
    }

    public Map<CacheKey, Post> getCache() {
        return cache;
    }
}
public class CacheKey {

    private Integer value;

    private LocalDateTime created;

    public CacheKey(Integer value) {
        this.value = value;
        this.created = LocalDateTime.now();
    }
    
    // getter
}

Test

@Test
void cache() throws InterruptedException {
	PostRepository postRepository = new PostRepository();
	postRepository.getPostById(1);

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

	// run gc
	System.out.println("run gc");
	System.gc();
	System.out.println("wait");
	Thread.sleep(3000L);

	assertTrue(postRepository.getCache().isEmpty());
}
  1. HashMap 사용

  2. WeakHashMap 사용
    - 캐시(Map<CacheKey, Post> cache)의 키가 다른 곳에서 참조하는 곳이 없으므로 가비지 컬렉션 대상이 된다.

3. 주기적인 참조 해제

  • 백그라운드 쓰레드를 활용하여 주기적으로 참조를 해제 할 수 있다.
@Test
void backgroundThread() throws InterruptedException {
	ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
    PostRepository postRepository = new PostRepository();
    postRepository.getPostById(1);

	// 제일 오래된 캐시 삭제
	Runnable removeOldCache = () -> {
   		System.out.println("running removeOldCache task");
    	Map<CacheKey, Post> cache = postRepository.getCache();
    	Set<CacheKey> cacheKeys = cache.keySet();
    	Optional<CacheKey> key = cacheKeys.stream().min(Comparator.comparing(CacheKey::getCreated));
    	key.ifPresent((k) -> {
    		System.out.println("removing " + k);
    		cache.remove(k);
    	});
    };

	System.out.println("The time is : " + new Date());
	
    // 3초에 한번 쓰레드 실행
	executor.scheduleAtFixedRate(removeOldCache, 1, 3, TimeUnit.SECONDS); 

	Thread.sleep(20000L);

	executor.shutdown();
}

핵심정리

  • 메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다. 이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다. 그래서 이런 종류의 문는 예방법을 익혀두는 것이 매우 중요하다.
profile
물흐르듯 개발하다 대박나기

0개의 댓글