본문 바로가기
카테고리 없음

이펙티브 자바 아이템 7 - 다 쓴 객체 참조를 해제하라 - 핵심 정리

by 개발인생 2022. 10. 4.
반응형

아이템 7 - 다 쓴 객체 참조를 해제하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

모든 경우에 다 쓴 객체 참조를 해제하는 것은 아니다.

Book spring = new Book();

위에서 spring 이라는 변수는 Book을 참조하고 있는 것이다.

어떤 객체에 대한 레퍼런스가 남아있다면 해당 객체는 가비지 컬렉션의 대상이 되지 않는다는 걸 의미한다.

만약 Book 을 다 사용했다면

spring = null;

이렇게 null 을 할당함으로써 참조를 해제한다.

참조를 해제하는 건 예외적인 경우이지만 의외로 흔하다.

가비지 컬렉터가 있더라도 메모리 누수가 발생할 수 있기 때문이다.

책에서는 총 3가지의 경우에 대해 설명하고 있다.

Stack

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

   public static void main(String[] args) {
      Stack stack = new Stack();
      for (String arg : args)
         stack.push(arg);

      while (true)
         System.err.println(stack.pop());
   }
}

위의 코드에서는 스택에서 값을 빼도 배열에서 값이 사라지지 않는다.

위의 스택을 계속 사용하다보면 메모리가 계속 쌓이게되 에러가 발생하게 된다.

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

   /**
    * 원소를 위한 공간을 적어도 하나 이상 확보한다.
    * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
    */
   private void ensureCapacity() {
      if (elements.length == size)
         elements = Arrays.copyOf(elements, 2 * size + 1);
   }

   // 코드 7-2 제대로 구현한 pop 메서드 (37쪽)
   public Object pop() {
      if (size == 0)
         throw new EmptyStackException();
      Object result = elements[--size];
      elements[size] = null; // 다 쓴 참조 해제
      return result;
   }

   public static void main(String[] args) {
      Stack stack = new Stack();
      for (String arg : args)
         stack.push(arg);

      while (true)
         System.err.println(stack.pop());
   }

}

위처럼 pop 메서드를 수정한다.

스택에서 값을 빼낼 때 빼낸 위치에 있는 객체의 참조를 해제한다.

pop 을 통해 가져간 클라이언트 쪽에서도 꺼내간 객체를 다 사용한 뒤 메서드가 끝나면

pop 을 했던 오브젝트의 생명주기도 끝나게 된다.

가비지 컬렉션의 대상이 되지 않는 경우는

배열, List, Set, Map 등이 있다.

이렇게 컬렉션에 뭔가를 쌓아놓는 경우 에는 항상 객체 참조에 대해 염두해서 사용해야한다.


Cache

import java.util.HashMap;

public class PostRepository {

   private Map<CacheKey, Post> cache;

   public PostRepository() {
      this.cache = new HashMap<>();
   }

   public Post getPostById(Integer id) {
      CacheKey key = new CacheKey(id);
      if (cache.containsKey(key)) {
         return cache.get(key);
      } else {
         // TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
         Post post = new Post();
         cache.put(key, post);
         return post;
      }
   }

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

위의 코드는 포스트를 조회할 때마다 cache 가 계속 쌓이게 된다.

이럴때 사용할 수 있는 방법 중 하나가 WeakHashMap 을 사용하는 것이다.

WeakHashMapWeakReference 를 key 로 가지는 맵이다.

Reference 에는 Strong, Soft, Weak, Phantom 으로 총 4가지가 있다.

WeakHashMap 은 key 가 더이상 참조가 되지 않으면(WeakReference 제외) 그 key 와 value 를 가비지 컬렉션시 삭제하게 된다.

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

      assertFalse(postRepository.getCache().isEmpty()); // 캐시는 비어있지 않음

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

      assertTrue(postRepository.getCache().isEmpty()); // 캐시는 비어있지 않음
   }
}   

주의할 점은 테스트 코드에서 System.gc(); 를 한다고 해서 바로 gc가 일어난다고 보장할 수는 없다는 것이다.

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

      assertFalse(postRepository.getCache().isEmpty()); // 캐시는 비어있지 않음

      p1 = null;

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

      assertTrue(postRepository.getCache().isEmpty()); // 캐시는 비어있지 않음
   }
}   

테스트 코드에서 p1 변수를 null로 만들어도 cache 는 비워지지 않는다.

import java.util.HashMap;
import java.util.WeakHashMap;

public class PostRepository {

   private Map<CacheKey, Post> cache;

   public PostRepository() {
      this.cache = new WeakHashMap<>();
   }

   public Post getPostById(Integer id) {
      CacheKey key = new CacheKey(id);
      if (cache.containsKey(key)) {
         return cache.get(key);
      } else {
         // TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
         Post post = new Post();
         cache.put(key, post);
         return post;
      }
   }

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

WeakHashMap 으로 변경했다.

이렇게 변경하면 WeakHashMap 에서 참조하고 있던 key 가 더이상 참조되지 않으면

캐시가 비워지게 된다.

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

      assertFalse(postRepository.getCache().isEmpty()); // 캐시가 있음

      key1 = null;

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

      assertTrue(postRepository.getCache().isEmpty()); // 캐시가 비어있음
   }
}   

이 방법 외에 직접 캐시에서 값이 오고갈때마다 확인하고 직접 관리해주는 방법이 있다.

예를 들어 LRU 캐시 구현이 있다.

LRU 캐시 는 가장 최근의 몇개의 캐시만 가지고 있는 자료구조 이다.

다른 방법으로는 backgroundThread 를 이용해 백그라운드에서 캐시를 관리하는 방법이 있다.

import java.util.HashMap;
import java.util.WeakHashMap;

public class PostRepository {

   private Map<CacheKey, Post> cache;

   public PostRepository() {
      this.cache = new HashMap<>();
   }

   public Post getPostById(Integer id) {
      CacheKey key = new CacheKey(id);
      if (cache.containsKey(key)) {
         return cache.get(key);
      } else {
         // TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
         Post post = new Post();
         cache.put(key, post);
         return post;
      }
   }

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

PostRepository 에서 캐시 관리를 HashMap 을 통해 하도록 다시 원복하자.

class PostRepositoryTest {

   @Test
   void backgroundThread() throws InterruptedException {
      ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
      PostRepository postRepository = new PostRepository();
      CacheKey key1 = new CacheKey(1);
      postRepository.getPostById(key1);

      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());

      executor.scheduleAtFixedRate(removeOldCache,
            1, 3, TimeUnit.SECONDS);

      Thread.sleep(20000L);

      executor.shutdown();
   }
}   

주기적으로 캐시를 정리하는 작업을 ScheduledExecutorService 를 통해 백그라운드 스레드로 돌리는 것이다.


listener

다음 예로는 Listener 가 있다.

public class User {

   public void receive(String message) {
      System.out.println(message);
   }
}

User 클래스를 생성한다.

public class ChatRoom {

   private List<WeakReference<User>> users;

   public ChatRoom() {
      this.users = new ArrayList<>();
   }

   public void addUser(User user) {
      this.users.add(new WeakReference<>(user));
   }

   public void sendMessage(String message) {
      users.forEach(wr -> Objects.requireNonNull(wr.get()).receive(message));
   }

   public List<WeakReference<User>> getUsers() {
      return users;
   }
}

ChatRoom 클래스를 작성한다.

리스너, 콜백 도 어딘가에 리스트를 만들어 놓고 리스너나 콜백을 담아두게 된다.

그래서 특정 이벤트가 발생했을 때 리스너나 콜백을 실행하게 된다.

여기서도 리스너나 콜백을 제거해주는 기능 이 없다면 메모리 누수가 발생하게 된다.

class ChatRoomTest {
   @Test
   void charRoom() throws InterruptedException {
      ChatRoom chatRoom = new ChatRoom();
      User user1 = new User();
      User user2 = new User();

      chatRoom.addUser(user1);
      chatRoom.addUser(user2);

      chatRoom.sendMessage("hello");

      //user1 = null;

      System.gc();

      Thread.sleep(5000L);

      List<WeakReference<User>> users = chatRoom.getUsers();
      assertTrue(users.size() == 1);
   }
}

WeakReference 를 사용하면 더 이상 참조가 되지 않기 떄문에 User 가 제거가 된다.

주의할 점은 절대로 private List<WeakReference<User>> users; 이렇게 사용하면 안 된다는 점 이다.

올바른 사용법이 아니다.


여기까지 메모리를 관리해야하는 경우

  • 스택
  • 캐시
  • 리스너, 콜백

을 살펴봤고 해결방법으로

  • 직접 null 호출
  • 백그라운드 스레드를 이요해 주기적으로 clean 해주는 방법
  • 적절한 자료구조 사용
  • 명시적으로 직접 제거

이렇게 4가지를 살펴봤다.

반응형

댓글