Java ThreadPoolExecutor

오늘은 java의 ThreadPoolExecutor에 대해서 알아보도록 하자. 많은 내용은 아니지만 기본적으로 사용하려면 알아두어야 할 것들만 알아보자.

ThreadPoolExecutor은 클래스명 그대로 스레드풀을 편하게 관리해주는 클래스이다. ThreadPoolExecutor의 최상위 인터페이스는 Executor 이며 그에 따른 구현체들은 아주 많으니 문서를 살펴보는 것이 좋을 것 같다. 그 중 가장 많이 사용될 만한 클래스가 ThreadPoolExecutor라 오늘은 해당 클래스에 대해 살펴보고 나중에 기회가 된다면.. ForkJoinPool에 대해서도 알아보도록 하자.

ThreadPoolExecutor

ThreadPoolExecutor은 4개의 생성자가 있다. 비슷비슷해서 눈이 아프다. 일단 아래의 코드는 ThreadPoolExecutor의 생성자이다.

ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue)
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler)
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory)
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

공통적으로 corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue 가 존재하면 실제로 이 다섯가지의 파라미터가 가장 중요한 역할을 한다. 나머지가 중요하지 않다는걸 의미한건 아니다.

일단 하나씩 살펴보자.
– corePoolSize : 풀 사이즈를 의미한다. 최초 생성되는 스레드 사이즈이며 해당 사이즈로 스레드가 유지된다. 해당 Job의 맞게 적절히 선택해야 한다. 많다고 성능이 잘나오는 것도 아니고 적다고 안나오는 것도 아니다. 충분히 테스트하면서 적절한 개수를 선택해야 한다.
– maximumPoolSize : 해당 풀에 최대로 유지할 수 있는 개수를 의미한다. 이 역시 Job에 맞게 적절히 선택해야 한다.
– keepAliveTime : corePoolSize보다 스레드가 많아졌을 경우 maximumPoolSize까지 스레드가 생성이 되는데 keepAliveTime 시간만큼 유지했다가 다시 corePoolSize 로 유지되는 시간을 의미한다. (그렇다고 무조건 maximumPoolSize까지 생성되는 건 아니다.)
– unit : keepAliveTime 의 시간 단위를 의미한다.
– workQueue : corePoolSize보다 스레드가 많아졌을 경우, 남는 스레드가 없을 경우 해당 큐에 담는다.

해당 파라미터가 어떤 역할을 하는지 알아봤다.

여기서 조금 더 알아볼게 있는데 바로 maximumPoolSizeworkQueue 파라미터이다. 필자가 위에서 그렇다고 무조건 maximumPoolSize까지 생성되는 건 아니다.를 주목하자.

실제로 corePoolSize가 스레드 개수보다 많다고 해서 maximumPoolSize 개수 까지 바로 생성하지 않는다. 그 전에 큐(workQueue)에 담고 대기한다. (구현체마다 다르지만, 일반적으로) 그리고 나서 workQueue에도 담을 공간이 부족하다면 그때 maximumPoolSize 까지 스레드를 늘려 작업을 한다. 그 후 keepAliveTime에 도달하면 다시 corePoolSize 로 유지 된다.

여기서 잠시 코드를 보자.

public static void main(String[] args) throws Exception {
    LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(9);
    ThreadPoolExecutor executorService = new ThreadPoolExecutor(1, 3 ,3, SECONDS, queue);
    for (int i = 0; i < 10; i++) {
        executorService.execute(new Task());
    }
    executorService.awaitTermination(5, SECONDS);
    executorService.shutdown();
}

private static class Task implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println(Thread.currentThread().getName());
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
        }
    }
}

위와 같은 코드가 있다고 가정해보자. corePoolSize는 1, maximumPoolSize는 3, 그리고 9개의 큐가 있다고 가정해보자. 그리고 나서 10개의 스레드를 만들어 돌려보면 어떻게 될까?

다시 해당 옵션에 대해 보면 1개의 core size와 9개의 큐가 있으니 1개의 잡이 실행되며 9개는 큐에 대기 상태가 된다. 그래서 1초에 하나씩 Thread Name이 출력 된다.

이번엔 큐 사이즈를 8개로 줄여보자.

LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(8);

그렇다면 어떻게 실행될까? corePoolSize 는 1개와 8개의 큐가 있어 maximumPoolSize 동작한다. 1개 잡과 8개가 대기하므로 1개의 스레드가 추가되어 1초에 2개씩 Thread Name이 출력 된다.

이벤엔 6개로 줄여보자.

LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(6);

이번엔 1개의 잡이 실행되고 큐에 6개가 쌓여 대기하여 maximumPoolSize 만큼 스레드가 증가하나 1개의 스레드가 갈곳이 없어 에러가 발생한다.

Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task Example$Task@50040f0c rejected from java.util.concurrent.ThreadPoolExecutor@2dda6444[Running, pool size = 3, active threads = 3, queued tasks = 6, completed tasks = 0]
    at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
    at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
    at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)

이왕 한김에 keepAliveTime도 테스트를 해보자. 해당 테스트는 대략적인 테스트이므로 정확하지 않을 수 있다.

public static void main(String[] args) throws Exception {
    LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(7);
    ThreadPoolExecutor executorService = new ThreadPoolExecutor(1, 3 ,3, SECONDS, queue);
    for (int i = 0; i < 10; i++) {
        executorService.execute(new Task());
    }
    SECONDS.sleep(7);
    for (int i = 0; i < 5; i++) {
        executorService.execute(new Task());
    }
    executorService.awaitTermination(5, SECONDS);
    executorService.shutdown();
}

keepAliveTime 를 3초로 주고 테스틀을 해보자. 처음에는 10개 스레드를 3개의 코어 돌리니 대략 4초가 걸리고 중간에 7초 정도 대기를 타고 있으니 3초가 조금 넘는 시간이 될 것 같다.
위의 코드를 테스트를 해보면 두번째 execute 에서는 다시 1초에 한개의 Thread Name이 출력 되는 걸 볼 수 있다.

SECONDS.sleep(6)로 주면 3초 이전에 다시 execute를 하기에 maximumPoolSize로 스레드가 실행되는 걸 알 수 있다.

이정도만 알아도 충분히 ThreadPoolExecutor을 사용할 수 있을 것 같다.

몇가지 참고

Executors

Executors.newSingleThreadExecutor()
Executors.newFixedThreadPool()
Executors.newCachedThreadPool()
Executors.newWorkStealingPool()

해당 factory 메서드로 Executor등을 만들 수 있다. 해당 건은 다른 블로그를 참고했으면 좋겠다. 필자는 없다……..

Spring

Spring을 사용한다면 ThreadPoolTaskExecutor를 살펴보는 것도 좋다. 내부 구현은 ThreadPoolExecutor로 구현되어 있다. ThreadPoolExecutor 보다 조금 더 간편하며, 추가적인 return 타입도 있다.

@Bean
public Executor threadPoolTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setQueueCapacity(100);
    executor.setMaxPoolSize(30);
    executor.set...
    return executor;
}

LinkedBlockingQueue vs SynchronousQueue

LinkedBlockingQueueSynchronousQueue는 BlockingQueue의 구현체들이다. 다른점이 있다면 SynchronousQueue 버퍼공간이 존재 하지 않는다. 그래서 스레드가 넘칠 경우 에러가 발생한다.
만약 대기 큐를 쓰고 싶다면 LinkedBlockingQueue 구현체를 사용해야 하며 동적으로 스레드를 만들고 싶다면 SynchronousQueue 를 이용해야 한다.

Spring 의 ThreadPoolTaskExecutorqueueCapacity 0 보다 크다면 LinkedBlockingQueue로 그렇지 않다면 SynchronousQueue으로 BlockingQueue 의 구현체를 설정한다.

protected BlockingQueue<Runnable> createQueue(int queueCapacity) {
    if (queueCapacity > 0) {
        return new LinkedBlockingQueue<>(queueCapacity);
    }
    else {
        return new SynchronousQueue<>();
    }
}

submit, execute

ThreadPoolExecutor에는 많은 메서드가 있지만 submit, execute 메서드의 차이는 return 이 되냐 되지 않는냐의 차이 이다. 실제 구현은 동일하다. (submit 은 execute를 호출한다)
또한 submit 에는 Runnable 파라미터 타입과 Callable 타입이 존재하는데 Callable 타입은 checked exception 을 throws 하고 Runnable는 그렇지 않다.

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

오늘 이렇게 Java ThreadPoolExecutor에 대해서 알아봤다. 많은 내용은 아니였지만 그래도 조금이나마 이해를 하며 사용하는 것이 좋아 작성해봤다.

그럼 오늘은 이만!!

junit5 ParameterResolver

오늘은 junit5의 기본적인 사용법만 살펴보자. 예전에 릴리즈 되기 전에 여기에 대충 사용법만 포스팅한적이 있었다. 아주 junit5 의 기본적인 내용만 살펴봤으니 좀 더 많은 내용은 문서를 통해서 확인하면 더 좋을 듯 싶다. 많이 바뀐 내용은 없는 듯 하니 추가할 내용은 없을 것 같다. 기본적으로 class가 public이 아니여도 되고, test 메서드도 public이 아니고 package private 이여도 된다는 것은 동일하다. 아주아주 기본적인 사용법은 예전에 살펴본 내용이므로 생략하자. 그렇게 어려운 내용은 아니니 한번씩 해보면 좋을 것 같다.

ExtendWith

Junit5 에 추가된 어노테이션중에 하나이다. 어노테이션명 그대로 ExtendWith은 뭔가를 확장 시킬 수 있는 그런 어노테이션이다. 실제로 코드는 아래와 같다.

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Repeatable(Extensions.class)
@API(status = STABLE, since = "5.0")
public @interface ExtendWith {

    Class<? extends Extension>[] value();

}

별 다른 내용은 없고 value에는 Extension를 상속한 클래스만 가능하다. 실제로 Extension 인터페이스는 딱히 구현할 것도 없는 마커 인터페이스 이다. Extension를 사용하는 인터페이스도 많으니 한번씩 살펴보도록 하고 오늘할 내용은 ParameterResolver라는 인터페이스를 사용하는법을 알아보도록 하자. ParameterResolver 인터페이스는 파라미터를 컨트롤 할 수 있는 그런 인터페이스이다. 실제로 junit4에서는 무조건 기본생성자가 있어야 하며 test 메서드는 파라미터가 없어야 했었다. 하지만 junit5 부터는 기본생성자뿐만 아니라 test 메서드에도 파라미터가 있어도 된다. 하지만 그 타입에 맞게 구현은 해줘야 한다. 그게 바로 ParameterResolver 인터페이스이다.

public interface ParameterResolver extends Extension {

    boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
            throws ParameterResolutionException;

    Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
            throws ParameterResolutionException;
}

ParameterResolver 인터페이스는 다음과 같은 형태이다. supportsParameter 지원가능한 파라미터 타입을 검사하고 resolveParameter메서드는 실제 지원가능한 타입을 조작하는 그런 메서드라고 생각하면 된다. 뭔가 spring과 비슷하다. 약간 비슷할 수 밖에 없는게 junit5 를 개발한 개발자가 spring 팀에도 속해있기 때문이다. 그래서 비슷한 구석이 있을 수 있다.

한번 아주간단하게 구현을 해보자.

class UserInfoParameterResolver implements ParameterResolver {

  @Override
  public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
    return (parameterContext.getParameter().getType() == UserInfo.class);
  }

  @Override
  public UserInfo resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
    return new UserInfo("wonwoo", "wonwoo@test.com");
  }
}

ParameterResolver를 구현한 구현체이다. 간단하게 어떤 동작을 하는지 보는 것이기 때문에 의미 있는 코드는 아니니 참고하기 바란다.

class UserInfo {
  private final String name;
  private final String email;

  UserInfo(String name, String email) {
    this.name = name;
    this.email = email;
  }

  public String getEmail() {
    return email;
  }

  public String getName() {
    return name;
  }
}

다음은 UserInfo를 담는 Object이다. 간단하므로 자세한 내용은 생략한다. 어떻게 사용하는지 한번 살펴보자.

@ExtendWith(UserInfoParameterResolver.class)
class NestedUserInfoTest {

  private final UserInfo userInfo;

  NestedUserInfoTest(UserInfo userInfo) {
    this.userInfo = userInfo;
  }

  @Test
  void user_info_test() {
    assertEquals(userInfo.getName(), "wonwoo");
    assertEquals(userInfo.getEmail(), "wonwoo@test.com");
  }
}

위와 같이 기본 생성자가 없어도 테스트는 통과 한다. 아래와 같이 test 메서드에 파라미터로 UserInfo 타입을 받아도 동일한 결과를 얻을 수 있다.

@ExtendWith(UserInfoParameterResolver.class)
class UserInfoTest {

  @Test
  void user_info_test(UserInfo userInfo) {
    assertEquals(userInfo.getName(), "wonwoo");
    assertEquals(userInfo.getEmail(), "wonwoo@test.com");
  }
}

또한 spring과 비슷하다고 느낀점이 메타 어노테이션도 지원한다. 아래와 같이 특정한 어노테이션을 만든 후에 ExtendWith를 사용해도 된다.

@ExtendWith(UserInfoParameterResolver.class)
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UserInfoExtension {
}

위와 같이 메타 어노테이션을 만들었다면 다음과 같이도 테스트를 할 수 있다.

@UserInfoExtension
class UserInfoTest {

  @Test
  void user_info_test(UserInfo userInfo) {
    assertEquals(userInfo.getName(), "wonwoo");
    assertEquals(userInfo.getEmail(), "wonwoo@test.com");
  }
}

아주 심플하게 테스트를 할 수 있어서 좋은 것 같다. 오늘은 이정도면 기본적인 개념도 좀 더 알 듯 싶고 다음시간에는 좀 더 활용할 수 있도록 Spring 혹은 mockito를 이용해서 테스트를 할 수 있도록 해보자.

물론 전체 소스도 여기에 있다. 좀 더 많은 소스가 있으니 차근차근 살펴봐도 될 듯 싶다.

java의 몇가지 이야기

오늘은 뭘 할려고 하는건 아니고 자바와 관련해서 몇가지 이야기를 해보려고 한다. 많이 중요한 내용은 아니지만(물론 알면 좋지만) 다시 처음으로 돌아가는 의미로 하는 것이다. 맨날 쉽지 않은(?) 이야기만 하다보니 처음으로 돌아가자는 의미로 몇가지 살펴보도록 하자.

Integer 오토박싱

int를 오토박싱 할 때 자바 컴파일러는 내부적으로 Integer.valueOf() 메서드를 호출한다.

public static void main(String[] args) {
  Integer i = 10;
}

이 경우 자바 컴파일러는 Integer.valueOf() 메서드를 호출해서 오토박싱을 한다. 정말로 그렇게 되는지 바이트코드를 살펴보자.

public static main([Ljava/lang/String;)V
L0
 LINENUMBER 9 L0
 BIPUSH 10
 INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
 ASTORE 1
 L1
 LINENUMBER 10 L1
 RETURN
 L2
 LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
 LOCALVARIABLE i Ljava/lang/Integer; L1 L2 1
 MAXSTACK = 1
 MAXLOCALS = 2

5번째 줄을 보면 java/lang/Integer.valueOf 를 호출하는 것을 알 수 있다. 물론 다른 Number 타입들도 {Number}.valueOf() 메서드를 동일하게 호출한다.

Integer Identity

동일한 값 Integer로 Identity 비교시에 항상 동일한 결과를 리턴하는 것은 아니다.

public static void main(String[] args) {
  Integer a = 100;
  Integer b = 100;
  System.out.println(a == b);
}

a 와 b 각각 100이라는 숫자를 넣고 Identity 비교를 해보자. 이 때 비교시에은 true를 리턴하지만 아래의 경우에는 false를 리턴한다.

public static void main(String[] args) {
  Integer c = 200;
  Integer d = 200;
  System.out.println(c == d);
}

위 코드가 false를 리턴하는 이유는 Integer의 내부에 -128 부터 127까지 캐싱처리를 하여 동일한 값으로 판단한다. -129 이하 이거나 128 이상일 경우에는 동일성의 결과가 다를 수 있다. 만약에 그 캐싱의 값들을 늘리고 싶다면 java.lang.Integer.IntegerCache.high 속성을 사용해서 늘리거나 줄일 수 있다. 그래도 버그를 줄이기 위해 오토박싱한 Integer는 equals를 이용해서 비교하도록 하자!

Iterable과 for-each Loop

자바의 Iterable 인터페이스를 구현하면 for-each Loop를 사용할 수 있다. 자바의 컬렉션 최상위 인터페이스인 Iterable은 for-each Loop를 사용할 수 있도록 해준다. 구현할건 아니고 되는지만 확인해보도록 하자.

public class ExampleIterable<T> implements Iterable<T>{

  @Override
  public Iterator<T> iterator() {
    return null;
  }
}

위와 같이 Iterable를 구현했으면 아래와 같이 for each를 사용할 수 있다.

public static void main(String[] args) {
  ExampleIterable<String> example = new ExampleIterable<>();
  for(String e : example){

  }

}

forEach와 Iterator

Iterator는 forEach보다 느리지 않다. 실제로 예전에 이런 이야기가 있었는데 아무 근거 없는 이야기이다. 실제 자바 컴파일러는 forEach Loop문을 Iterator로 바꾼다. 실제로 어떻게 되는지 바이트코드로 살펴보자.

public static void main(String[] args) {

  List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

  Iterator<Integer> iterator = numbers.iterator();
  while (iterator.hasNext()) {
   System.out.println(iterator.next());
  }
}

위의 코드는 Iterator를 사용해서 순차적으로 element를 가져오는 코드이다. 위의 코드를 바이트코드로 살펴보자.

...
L1
 LINENUMBER 18 L1
 ALOAD 1
 INVOKEINTERFACE java/util/List.iterator ()Ljava/util/Iterator;
 ASTORE 2
L2
 LINENUMBER 19 L2
 FRAME APPEND [java/util/List java/util/Iterator]
 ALOAD 2
 INVOKEINTERFACE java/util/Iterator.hasNext ()Z
 IFEQ L3
L4
 LINENUMBER 20 L4
 GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
 ALOAD 2
 INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object;
 INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
 GOTO L2
...

바이트코드가 조금 길어서 위 아래 생략하고 중요한 부분만 살펴보면 위와 같다. 그럼 forEach Loop을 사용해보자.

public static void main(String[] args) {

  List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

  for (Integer i : numbers) {
    System.out.println(i);
  }

}

우리가 흔히 쓰는 forEach Loop문이다. 이것 또한 바이트코드를 살펴보면 다음과 같다.

L1
  LINENUMBER 18 L1
  ALOAD 1
  INVOKEINTERFACE java/util/List.iterator ()Ljava/util/Iterator;
  ASTORE 2
L2
  FRAME APPEND [java/util/List java/util/Iterator]
  ALOAD 2
  INVOKEINTERFACE java/util/Iterator.hasNext ()Z
  IFEQ L3
  ALOAD 2
  INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object;
  CHECKCAST java/lang/Integer
  ASTORE 3
L4
  LINENUMBER 19 L4
  GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
  ALOAD 3
  INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V

내부적으로는 Iterator를 사용해서 element 가져오는 것을 확인 할 수 있다. 그래도 코드를 작성할 때 특별한 경우가 아니라면 Iterator보다는 forEach문을 사용하는게 낫다.

Stream과 Iterable의 forEach

위는 일반 forEach Loop문을 이야기 한거지만 지금 이야기할 것은 java8의 나온 forEach() 메서드를 말한다. Stream의 forEach 메서드와 collection의 forEach 메서드는 다르다. 예로 List의 forEach 문은 자바의 최상위 인터페이스인 Iterable 의 default 메서드로 구현되어 있다. 그래서 우리는 List의 forEach문만 사용한다면 다음과 같이 코드를 작성할 필요는 없다.

public static void main(String[] args) {

  List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
  numbers.stream().forEach(System.out::println);

}

위 처럼 굳이 Stream으로 만들 필요 없이 바로 forEach문을 사용하면 된다. Stream을 만드는 비용도 있으니 더욱 위의 코드처럼 사용하지는 말아야 한다.

public static void main(String[] args) {

  List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
  numbers.forEach(System.out::println);

}

String intern

객체화된 String을 상수로 변경하기 위해 intern() 메서드를 권장하지 않는다. 그냥 String 리터럴을 사용해라. 쓸때 없이 객체형 String을 만들필요는 더더욱 없고 그걸 또한 상수로 변경하기 위해 intern() 메서드를 호출하는 것은 더욱 권장하지 않는다. 예로 String을 객체화 할 때도 비용이 들지만 String pool에서도 해당 String을 찾느라 시간이 더욱걸린다.

public static void main(String[] args) {

  long startTime = System.currentTimeMillis();
  for ( int i = 0 ; i < 10000000; i++ ) {
    String str = "wonwoo";
  }
  System.out.println(System.currentTimeMillis() - startTime);

  long startTime1 = System.currentTimeMillis();
  for ( int i = 0 ; i < 10000000; i++ ) {
    new String("wonwoo");
  }
  System.out.println(System.currentTimeMillis() - startTime1);

  long startTime2 = System.currentTimeMillis();
  for ( int i = 0 ; i < 10000000; i++ ) {
    new String("wonwoo").intern();
  }
  System.out.println(System.currentTimeMillis() - startTime2);
}

String 리터럴과 new String(), new String().intern() 을 각각 천만번 돌려봤다. 결과는 다음과 같다.

6
9
2612

위는 정확한 벤치마킹은 아니지만 어느정도 비슷할 듯 싶다. 실제로 new String().intern()을 사용한게 제일 느리다. 근데 String 리터럴과 new String()은 그렇게 많은 차이는 보이지 않았다. 그래도 굳이 객체화된 String을 만들 필요는 없다.

이렇게 오늘은 java와 관련된 이야기를 몇가지 해봤다. 아직 java에 대해서 모르는게 많다. 한 언어를 완벽하게 소화하는 건 쉬운 일이 아니다. 완벽하게 하지는 못해도 어느정도 깊이는 알아야 되지 않을까 싶다.