본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 7 - 다 쓴 객체 참조를 해제하라 - 완벽 공략

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

아이템 7 - 다 쓴 객체 참조를 해제하라 - 완벽 공략

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

NullPointerException

우리가 코딩을 하다보면 NullPointerException 을 만나는 경우가 종종 발생한다.

public class MemberShip {
   public String hello() {
      return "hello";
   }
}

MemberShip 클래스를 작성한다.

public class Channel {

   private int numOfSubscribers;

   public Optional<MemberShip> defaultMemberShip() {
      if (this.numOfSubscribers < 2000) {
         return null;
      } else {
         return new MemberShip();
      }
   }
}

Channel 클래스를 작성한다.

위의 경우에는 메서드가 null 을 리턴하는 경우가 있다.

class ChannelTest {
   @Test
   void npe() {
      Channel channel = new Channel();
      MemberShip memberShip = channel.defaultMemberShip();

      memberShip.hello(); // NullPointerException 발생
   }
}

Channel 클래스 사용하는 곳에서 null 값을 체크하지 않는다면 NullPointerException 발생할 수도 있다.

class ChannelTest {
   @Test
   void npe() {
      Channel channel = new Channel();
      MemberShip memberShip = channel.defaultMemberShip();

      if (memberShip != null) {
         memberShip.hello(); 
      }
   }
}

위처럼 null을 체크하여 null이 아닌 경우에만 로직을 실행하도록 코드를 작성하면 된다.

NullPointerException 을 줄이면서 코딩을 할 수 있게끔 api 를 제공하면 좋을 것이다.

api를 만드는 입장에서 생각해보면

  • 예외를 던진다.
  • null 을 리턴한다.

이렇게 2가지가 있을 것이다.

자바 8에서는 Optional 이 추가되었다.

public class Channel {

   private int numOfSubscribers;

   public Optional<MemberShip> defaultMemberShip() {
      if (this.numOfSubscribers < 2000) {
         return Optional.empty();
      } else {
         return Optional.of(new MemberShip());
      }
   }
}

리턴타입이 null 일 수도 있는 경우에는 Optional 로 리턴하는게 좋다.

class ChannelTest {
   @Test
   void npe() {
      Channel channel = new Channel();
      Optional<MemberShip> optional = channel.defaultMemberShip();
      optional.ifPresent(MemberShip::hello);
   }
}

비어있을 수도 있는 객체를 Optional 로 리턴하면 사용하는 쪽에서는

Optional 로 객체를 받아 사용하게 된다.

Optional 에서 객체를 꺼내야 객체를 사용할 수 있게된다.

이렇게 api 를 제공할 때 Optional 을 사용하면 클라이언트에서 발생하는 NullPointerException 을 줄일 수 있다.

class ChannelTest {
   @Test
   void npe() {
      Channel channel = new Channel();
      Optional<MemberShip> optional = channel.defaultMemberShip();
      optional.get().hello();
   }
}

다만 Optional 을 확인하지 않고 객체를 꺼내 사용하면 다른 에러를 만나게 된다.

Optional 을 사용할때는 Optional 에서 제공하는 메서드를 활용해 사용하는게 좋다.

주의할 점으로는

  1. OptionalReturn 타입으로 사용하기 위해 만든 것이기 때문에 다른 곳에서 사용하는 걸 권장하지 않는다.

매개변수 타입으로 Optional 을 사용하는 건 권장하지 않는다.

  1. Optional 로 Collection 을 감싸면 안된다.

List, Set 자체가 그 안이 비어있는지 아닌지에 대한 메서드를 가지고 있다.

때문에 굳이 CollectionOptional 로 감쌀 필요가 없다.

  1. Optional 을 리턴하는 메서드에서는 Optional 을 리턴하도록하자. 즉, null 을 리턴하지 않도록 한다.
  2. 프리미티브 타입을 Optional 로 감싸야한다면 OptionalInt, OptionalLong 등을 사용한다.

WeakHashMap

WeakHashMap 은 더이상 사용하지 않는 객체를 GC할 때 자동으로 삭제해주는 Map 이다.

기존의 mpa 이나 list 는 그안에 들어가 있는 객체들을 GC할 때 비워주지 않는다.

WeakHashMap 은 그 안의 엔트리들을 key 가 강하게 참조되는 곳 이 없으면

GC가 일어날 때 해당 엔트리를 자동으로 삭제해준다.

map 에 넣은 엔트리 중에 value 보다 key 가 중요한 경우 WeakHashMap 을 사용하는게 좋다.

주의할 점은 WeakHashMap 의 key 를 wrapper 타입이나 String 을 사용하게 되면

JVM 내부에 그 값들이 캐싱되기 때문에 계속 참조가 남아있게 된다.

public class PostRepository {

   private Map<Integer, Post> cache;

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

   public Post getPostById(Integer key) {
      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<Integer, Post> getCache() {
      return cache;
   }
}
class PostRepositoryTest {

   @Test
   void cache() throws InterruptedException {
      PostRepository postRepository = new PostRepository();
      Integer key1 = 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()); // 캐시가 비어있음
   }
}

위처럼 WeakHashMap 의 key 로 Integer 를 사용하게 되면

JVM 내부에 자주 사용하는 wrapper 타입의 값이 캐싱 되어있기 때문에 WeakHashMap 의 key 가 지워지지 않는다.

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

위의 코드처럼 List<WeakReference<User>> users; 와 같이 리스트 안에 레퍼런스를 사용하면

WeakReference 는 삭제되지 않는다.

레퍼런스 종류

레퍼런스에는 크게 4가지 종류가 있다.

Strong

this.users = new ArrayList<>(); // = 를 통해 값을 할당하면 Strong 레퍼런스다.

   public List<WeakReference<User>> getUsers() {
      ChatRoom localChat = new ChatRoom();
      return users;
   }

위의 메서드에서 localChat 의 유효범위는 해당 메서드 안에서만 유요하다.

해당 메서드를 벗어나면 메서드 안에서 만들었던 객체들은 더이상 필요하지 않게되어 GC의 대상이 된다.

   public List<WeakReference<User>> getUsers() {
      ChatRoom localChat = new ChatRoom();

      localChat = null;

      return users;
   }

null 을 할당함으로써 Strong 참조를 끊어낼 수 있다.

Soft
public class SoftReferenceExample {
   public static void main(String[] args) throws InterruptedException {
      Object strong = new Object();
      SoftReference<Object> soft = new SoftReference<>(strong);
      strong = null;

      System.gc();
      Thread.sleep(3000L);

      // TODO 거의 안 없어집니다.
      //  왜냐면 메모리가 충분해서.. 굳이 제거할 필요가 없으니까요.
      System.out.println(soft.get());
   }
}

위처럼 new SoftReference<> 를 사용하고 매개변수로 참조할 Strong 레퍼런스를 넣어주면 된다.

Soft 레퍼런스는 더 이상 Strong 레퍼런스가 없고 Soft 레퍼런스만 남아있다면 GC 의 대상이 된다.

중요한 점은 메모리가 필요할 때만 GC 중에 Soft 레퍼런스가 없어진다 는 점이다.

Soft 레퍼런스는 메모리가 추가로 필요할 경우에만 레퍼런스가 없어진다.

Weak
public class WeakReferenceExample {
   public static void main(String[] args) throws InterruptedException {
      Object strong = new Object();
      WeakReference<Object> weak = new WeakReference<>(strong);
      strong = null;

      System.gc();
      Thread.sleep(3000L);

      // TODO 거의 없어집니다.
      //  왜냐면 약하니까(?)...
      System.out.println(weak.get());
   }
}

WeakReference 역시 new WeakReference<> 를 사용하고 매개변수로 참조할 Strong 레퍼런스를 넣어주면 된다.

WeakReference 는 GC 가 일어날 때 무조건 없어진다.

즉, 더 이상 Strong 레퍼런스가 없고 WeakReference 레퍼런스만 남아있다면 GC 가 일어날때 없어지게 된다.

Phantom

Phantom 레퍼런스는 Weak, Soft 레퍼런스와는 다르게 Phantom 레퍼런스Strong 레퍼런스 대신에 남게된다.

public class BigObject {
}

커스텀하게 오브젝트 레퍼런스를 만든다.

import java.lang.ref.PhantomReference;

public class PhantomReferenceExample {
   public static void main(String[] args) throws InterruptedException {
      BigObject strong = new BigObject();
      ReferenceQueue<BigObject> rq = new ReferenceQueue<>();

      PhantomReference<BigObject> phantom = new PhantomReference<>(strong, rq);
      strong = null;

      System.gc();
      Thread.sleep(3000L);

      // TODO 팬텀은 유령이니까..
      //  죽었지만.. 사라지진 않고 큐에 들어갑니다.
      System.out.println(phantom.isEnqueued());

      Reference<? extends BigObject> reference = rq.poll();
      BigObjectReference bigObjectCleaner = (BigObjectReference) reference;
      bigObjectCleaner.cleanUp();
      reference.clear();
   }

}

Phantom 레퍼런스는 ReferenceQueue 가 있어야한다.

Phantom 레퍼런스는 생성자에 Strong 레퍼런스 와 함께 ReferenceQueue 를 넘겨주어야 한다.

Phantom 레퍼런스는 Phantom 레퍼런스만 남은 경우에 GC 가 일어나면 원래 가지고 있던 오브젝트를 정리하고

Phantom 레퍼런스를 ReferenceQueue 에 넣어준다.

나중에 ReferenceQueue 에서 꺼내 Phantom 레퍼런스를 정리할 수 있다.

Phantom 레퍼런스는 자원 정리할때, 언제 무거운 객체가 메모리 해제가 되는지 알고싶을 때 사용한다.

객체가 사라짐과 동시에 Phantom 레퍼런스를 ReferenceQueue 에 들어가기 때문에

어떤 거대한 메모리를 사용하는 객체가 해제되는 시점을 알 수 있다.

import java.lang.ref.PhantomReference;

public class PhantomReferenceExample {
   public static void main(String[] args) throws InterruptedException {
      BigObject strong = new BigObject();
      ReferenceQueue<BigObject> rq = new ReferenceQueue<>();

      PhantomReference<BigObject> phantom = new PhantomReference<>(strong, rq);
      strong = null;

      System.gc();
      Thread.sleep(3000L);

      // TODO 팬텀은 유령이니까..
      //  죽었지만.. 사라지진 않고 큐에 들어갑니다.
      System.out.println(phantom.isEnqueued()); // 큐에 들어갔는지 확인 

      Reference<? extends BigObject> reference = rq.poll();

      // 직접 레퍼런스 비워주는 작업
      reference.clear();
   }
}

직접 레퍼런스를 비워줘야 팬텀 레퍼런스가 사라지게 된다.

자원을 반납하는 용도로 쓰려면

public class BigObjectReference<BigObject> extends PhantomReference<BigObject> {

   public BigObjectReference(BigObject referent, ReferenceQueue<? super BigObject> q) {
      super(referent, q);
   }

   public void cleanUp() {
      // 자원 반납 지점
      System.out.println("clean up");
   }
}

PhantomReference 를 상속받은 BigObjectReference 를 작성한다.

public class PhantomReferenceExample {
   public static void main(String[] args) throws InterruptedException {
      BigObject strong = new BigObject();
      ReferenceQueue<BigObject> rq = new ReferenceQueue<>();

      BigObjectReference<BigObject> phantom = new BigObjectReference<>(strong, rq);
      strong = null;

      System.gc();
      Thread.sleep(3000L);

      // TODO 팬텀은 유령이니까..
      //  죽었지만.. 사라지진 않고 큐에 들어갑니다.
      System.out.println(phantom.isEnqueued());

      Reference<? extends BigObject> reference = rq.poll();
      BigObjectReference bigObjectCleaner = (BigObjectReference) reference;
      bigObjectCleaner.cleanUp(); // 자원 반납 실행

      // 팬텀 레퍼런스 정리
      reference.clear();
   }

}

BigObjectReference 를 사용해 레퍼런스를 만들어 사용한다.


다시 ChatRoom 클래스로 돌아가자.

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

위의 코드를 단순히 생각했을 때 List<WeakReference> 에 넣었던 User 들이

더 이상 참조가 되는 곳이 없을때 List 에서 삭제되는 것 처럼 생각이 되어진다.

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

마치 user1 을 null 로 만들었기 때문에 WeakReference 를 통해 해당 유저가 list 에서 삭제될 것으로 예상되어진다.

하지만 위의 테스트 코드는 통과하지않는다.

WeakReference 를 삭제해주는 기능은 WeakHashMap 에 들어있는 기능이다.

WeakReference 를 이용해 참조하는 객체가 없으면 리스트에서 제거되게 만들고 싶다면

커스텀한 리스트를 만들어야한다.

WeakReferenceSoftReference 로 GC 가 일어날때 객체가 없어지는 것은 불확실성이 크다.

즉, 언제 객체가 사라질지 예측할 수 없다는 것이다.

WeakReferenceSoftReference 가 정말 적절한 경우가 아니라면 권장하지 않는다.


ScheduledThreadPoolExecutor

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      Thread thread = new Thread(new Task());
      thread.start();

      System.out.println(Thread.currentThread() + " hello");
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}

위의 코드처럼 new Thread 를 이용해 별도의 스레드를 생성하고,

Runnable 을 구현해 생성할 스레드에서 할 작업을 작성한다.

만약 여기서 스레드를 많이 만들어야하는 상황이 생긴다면 시스템 리소스를 많이 사용하게 된다.

스레드를 만드는 것 자체가 시스템 자원을 많이 사용한다.

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      for (int i = 0; i < 100; i++) {
         Thread thread = new Thread(new Task());
         thread.start(); 
      }

      System.out.println(Thread.currentThread() + " hello");
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}

만약 쓰레드 100개를 생성해 작업하던 것을 100개의 스레드를 생성할 필요없이 비동기적으로 작업하는 방법이 있다.

쓰레드 풀을 사용하면 된다.

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      ExecutorService service = Executors.newFixedThreadPool(10); // 스레드 10개 생성

      for (int i = 0; i < 100; i++) {
         service.submit(new Task());
      }

      System.out.println(Thread.currentThread() + " hello");

      service.shutdown();
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}

스레드 풀은 ExecutorService 를 이용해 쉽게 생성할 수 있다.

위의 코드에서는 스레드를 10개만 사용하게 된다.

10개의 스레드로 100개의 작업을 진행하는 것이다.

하지만 100개의 스레드로 100개의 작업을 하는 것과는 속도에서 차이가 발생한다.

때문에 스레드 풀의 갯수를 신경써야한다.

쓰레드를 갯수를 조정할 때는 크게 2가지를 신경써야한다.

  • CPU에 집중적인 작업인가.
  • IO에 집중적인 작업인가.

CPU에 집중적인 작업

CPU에 집중적인 작업이라면 아무리 쓰레드를 늘려도 CPU 갯수가 넘어가면 쓰레드가 막히게 된다.

CPU에 집중적인 작업이라면 CPU 갯수만큼만 쓰레드를 생성한다.

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      int cpu = Runtime.getRuntime().availableProcessors(); // cpu 갯수 계산

      ExecutorService service = Executors.newFixedThreadPool(cpu); // 스레드 cpu 갯수만큼 생성

      for (int i = 0; i < 100; i++) {
         service.submit(new Task());
      }

      System.out.println(Thread.currentThread() + " hello");

      service.shutdown();
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}
public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      ExecutorService service = Executors.newCachedThreadPool(); 

      for (int i = 0; i < 100; i++) {
         service.submit(new Task());
      }

      System.out.println(Thread.currentThread() + " hello");

      service.shutdown();
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}

newCachedThreadPool 을 사용할 수도 있다.

newCachedThreadPool 은 기존에 놀고있는 스레드가 있다면 재사용하고, 없다면 새로 스레드를 만들어 사용한다.

그리고 60초 동안 아무런 작업을 하지 않는 스레드가 있다면 삭제시켜주는 기능도 있다.

newFixedThreadPool 은 내부적으로 블록킹 큐 를 사용한다.

블록킹 큐 스레드들 간의 동시성을 지원한다.

newCachedThreadPool 은 작업을 위한 공간이 하나이다.

newCachedThreadPool 은 스레드가 무한정 늘어날 가능성이 있기 때문에 조심히 사용해야한다.

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      ExecutorService service = Executors.newSingleThreadExecutor(); 

      for (int i = 0; i < 100; i++) {
         service.submit(new Task());
      }

      System.out.println(Thread.currentThread() + " hello");

      service.shutdown();
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}

newSingleThreadExecutor 은 스레드를 하나만 생성해 작업을 처리한다.

작업이 100개라면 스레드 하나로 다 처리하게 된다.

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      ExecutorService service = Executors.newScheduledThreadPool(10); 

      for (int i = 0; i < 100; i++) {
         service.submit(new Task());
      }

      System.out.println(Thread.currentThread() + " hello");

      service.shutdown();
   }

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }
}

newScheduledThreadPool 은 순차적으로 작업이 들어온다고 해도 순차적으로 작업을 실행하지 않는다.

newScheduledThreadPool 은 어떤 작업을 주기적으로 실행시키거나, 몇초 뒤에 딜레이 시켜서 실행할 때 사용한다.

IO에 집중적인 작업

IO에 집중적인 작업이라면(DB 에서 값을 가져오거나, 외부 API 를 호출하는 등)

IO 작업을 기다리느라 cpu 의 리소스가 남지만 더이상 작업을 하지 않게된다.

얼마만큼 스레드를 생성해야할지에 대한 정답은 없다.

DB나 네트워크의 지연 시간에 따라 적절한 스레드 풀의 갯수를 조절한다.

IO에 집중적인 작업은 좀 더 많은 수의 스레드를 필요로 한다.

Runnable

   static class Task implements Runnable {

      @Override
      public void run() {
         try {
            Thread.sleep(2000L);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(Thread.currentThread() + " world");
      }
   }

Runnable 같은 경우는 리턴타입이 없다.

작업만 호출하고 끝이다.

하지만 별도의 스레드에서 처리한 값을 받고싶을 때는 어떻게 해야할까?

그래서 생긴게 Callable 이다.

Callable

public class ScheduledThreadPoolExecutor {
   public static void main(String[] args) throws ExecutionException, InterruptedException {
      ExecutorService service = Executors.newScheduledThreadPool(10);

      Future<String> submit = service.submit(new Task()); // Task 에 Thread.sleep(2000L); 이 있어도 블럭킹이 되지 않는다.

      System.out.println(Thread.currentThread() + " hello");

      System.out.println(submit.get()); // 블럭킹 콜

      service.shutdown();
   }


   static class Task implements Callable<String> {

      @Override
      public String call() throws Exception {
         Thread.sleep(2000L);
         return Thread.currentThread() + " world";
      }
   }
}

Callable 을 통해 작업의 결과물을 String 으로 전달받을 수 있다.

submit.get() 을 호출하면 블럭킹 콜을 하게 된다.

즉, submit.get() 이전에 Thread.sleep(2000L); 의 시간이 지났다면 결과를 바로 가져오지만

Thread.sleep(2000L); 의 시간이 지나지 않았다면 Thread.sleep(2000L); 의 시간을 기다리게 된다.

Callable 의 리턴을 Future 로 받을 수 있다.

반응형

댓글