컬렉션이 비었으면 null을 반환하는 코드를 보자.
private final List<Cheese> cheesesInStock = new ArrayList<>();
/**
* @return 매장 안의 모든 치즈 목록 반환, 재고가 없다면 null 반환
*/
public List<Cheese> getCheeses(){
return cheesesInStock.isEmpty() ? null : new ArrayList<>(cheesesInStock);
}
재고가 없다고 해서 특별히 취급할 이유는 없지만 이렇게 null을 반환하면 클라이언트는 이 null을 처리하는 코드를 추가로 작성해야한다.
Shop shop = new Shop();
List<Cheese> cheeses = shop.getCheeses();
if(cheeses != null && cheeses.contains(Cheese.STILTON)){
System.out.println(Cheese.STILTON + "재고 존재!");
}
컬렉션이나 배열 같은 컨테이너가 비었을 때 null을 반환하는 메소드를 사용하면 이처럼 방어코드를 작성해줘야 한다.
방어 코드를 놓치면 오류가 발생할 것이고, 객체가 0개일 가능성이 거의 없다면 한참 뒤에야 오류가 발생하게 된다.
아래 개선된 코드로 보면 알겠지만, null을 반환하려면 반환하는 쪽에서도 이 상황을 특별히 취급해서 코드가 더 복잡해진다.
물론 빈 컨테이너를 할당하는 데도 비용이 드니 null을 반환하는 것이 나을수도 있지만, 성능 저하의 주범이라 확인되지 않는 한 성능 차이는 신경 쓸 수준도 안될뿐더러, 빈 컬렉션과 배열은 굳이 새로 할당하지 않고도 반환할 수 있다. 대부분에서는 아래와 같이 사용할 수 있다.
public List<Cheese> getCheeses(){
return new ArrayList<>(cheesesInStock);
}
null을 반환할 때보다 코드도 단순화될 수 있다. 가능성은 작지만 사용 패턴에 따라 빈 컬렉션 할당이 성능을 눈에 띄게 떨어뜨릴 수 있다.
이럴 땐 매번 똑같은 빈 불변 컬렉션을 반환하면 된다. (Collection.emptyList, Collection.emptySet, Collection.emptyMap...)
주석에도 나와있듯, 이렇게 만들어진 컬렉션들은 불변이고 static이기 때문에 공유해서 사용한다.
다양한 empty 메소드들이 존재하니 원하는 걸 선택해서 사용하면 된다. 단, 이 역시 최적화에 해당하니 꼭 필요한 때만 사용하고 수정 전, 후의 성능을 측정해 실제로 성능이 개선되는지 확인해야한다.
public List<Cheese> getCheesesEmptyCollections(){
return cheesesInStock.isEmpty() ? Collections.emptyList() : new ArrayList<>(cheesesInStock);
}
그리고 배열을 쓸 때도 마찬가지다. null을 반환하지 말고 길이가 0인 배열을 반환하면된다. 보통은 단순히 정확한 길이의 배열을 반환하기만 하면 되고 그 길이가 0일 수도 있을 뿐이다. 아래 코드에서 toArray 메소드에 건넨 길이 0짜리 배열은 반환 타입(아래 소스의 경우 Cheese[])을 알려주는 역할을 한다. 만약 이 방식이 성능을 떨어뜨릴 것 같다면 길이 0짜리 배열을 미리 선언해두고 매번 그 배열을 반환하면 된다. 길이 0인 배열은 모두 불변이니까!
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
// 길이 0 배열 반환
public Cheese[] getCheesesArray(){
return cheesesInStock.toArray(new Cheese[0]);
}
// 빈 배열을 매번 할당하지 않고 반환
public Cheese[] getCheesesEmptyArray(){
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
그런데 toArray 메소드는 어떻게 배열을 반환하는 걸까? 구현체인 ArrayList에서 알아보았다.
먼저, 매개변수 없는 toArray는 단지 Object[]로 만들어서 반환할 뿐이었다.
매개변수가 있는 toArray는, 매개변수로 넘어온 배열의 크기가 담으려는 list보다 작다면 새로운 T타입 배열을 만들어 List의 크기만큼 할당해서 반환한다. 즉, 위의 코드들처럼 길이가 0인 배열을 넘겨도, 실제 리스트의 크기가 크면 새로운 배열로 만들어져 반환될 뿐이다.
만약 리스트의 크기가 0이라면 EMPTY_CHEESE_ARRA를 반환할 뿐인거다. 만약 배열 a가 충분히 크다면, a 안에 원소를 담아 반환한다. 리스트의 크기가 5고 배열의 크기가 10이라면, 배열의 4번 인덱스까지 채우고, 5번 인덱스를 null로 만들어서 반환하는 것. 실제 배열의 크기는 내부 파라미터로 전달한 10으로 유지되고 메모리를 사용하지 않기위해 5번 인덱스에 null을 준 것이다.
단순히 성능을 개선할 목적이라면 toArray에 넘기는 배열을 미리 할당하진 말자. 오히려 성능이 떨어진다는 연구 결과가 있다. 그래서 테스트를 진행했고 List<Integer>에 0~1억까지 담아서 toArray(), toArray(new Integer[0]), toArray(new Integer[size])를 호출했다.
(heap 영역에서 OutOfMemoryError가 발생할 수 있다)
public static void main(String[] args) {
List<Integer> collect = IntStream.rangeClosed(0, 100000000).boxed().collect(Collectors.toList());
long before = System.currentTimeMillis();
collect.toArray();
times(before);
before = System.currentTimeMillis();
collect.toArray(new Integer[0]);
times(before);
before = System.currentTimeMillis();
collect.toArray(new Integer[collect.size()]);
times(before);
}
// 수행시간 코드 출처: https://hijuworld.tistory.com/2
public static void times(long beforeTime){
long afterTime = System.currentTimeMillis();
long secDiffTime = (afterTime - beforeTime);
System.out.println("시간차이(m) : "+secDiffTime);
}
각각의 결과는 다음과 같다.
각 메소드와 파라미터에 따라 성능저하가 발생했음을 알 수 있다. 그리고 Intellij IDE에서는 size를 넣는 것을 안티패턴으로 여긴다.
실제 수행결과의 요약본은 StackOverflow에서 알아볼 수 있다. jmh에 BenchMark로 테스트한 결과다. [ StackOverFlow 참고 ]
이에 대한 좀더 자세한 연구 결과는 shipilev에서 확인할 수 있다. [ shipilev 연구결과 참고 ]
shipilev의 연구결과에 따라 각 메소드와 파라미터별로 수행시간, 즉 성능의 차이는 StackOverFlow에도 나와 있지만 자세히 보자.
견해:
위에도 사진은 있지만, toArray()와 toArray(T[] a) 메소드의 내부를 다시 살펴보자.
만약! List의 size가 1억인 경우, toArray()와, toArray(new Type[0])은 Arrays.copyOf를 호출함을 알 수 있다. 다중정의된 메소드를 호출하지만, 차이점은 Object[]로 반환을 하는가, 매개변수로 주는 T[]로 반환하는가의 차이다. 결국 근본적으로는 Arrays.copyOf의 3개 매개변수로 전달된다. toArray()도 2개 매개변수를 호출하지만 최종적으론 3개 매개변수를 호출한다. 그리고 toArray(new Type[size])는 new Type[size]로 할당했기 때문에 곧바로 System.arraycopy를 이용한다.
그렇다면 각 메소드에서 큰 영향을 미치는 것은
toArray(): new Object[length]
toArray(new Type[0]): Array.newInstance(Type,lengh)
toArray(new Type[size]): new Type[size], size()
이렇게 정리해볼 수 있을 것이다. 연구 결과를 봤을 때, Object를 이용하는 toArray()는 벡터화된 복사를 한다는 것이고 성능이 가장 좋다고 나온다. 그러면 파라미터가 있는 toArray(T[] a)에서 Array.newInstance와 new Type[size]의 시간이 얼마나 차이가 나냐가 달렸다.
그래서 다음과 같이 테스트 해봤다.
private static final Integer HUNDRED_MILLION = 100_000_000;
public static Integer[] newInteger(){
return new Integer[HUNDRED_MILLION];
}
public static Integer[] instanceInteger(){
return (Integer[])Array.newInstance(Integer.class,HUNDRED_MILLION);
}
public static void main(String[] args) {
List<Integer> collect = IntStream.rangeClosed(0, HUNDRED_MILLION).boxed().collect(Collectors.toList());
long before = System.currentTimeMillis();
instanceInteger();
times(before);
before = System.currentTimeMillis();
newInteger();
times(before);
}
public static void times(long beforeTime){
long afterTime = System.currentTimeMillis();
long secDiffTime = (afterTime - beforeTime);
System.out.println("시간차이(m) : "+secDiffTime);
}
결과는 다음과 같다.
new Type[size] 형식이 Array.newInstance보다 느리다는 것을 알 수 있다. toArray(new Type[size])는 내부 size를 지정하기 위해서 사용할 때 list의 size 메소드도 호출하기 때문에 복합적으로 많은 성능 저하가 발생하는 것 같다. (물론 size 호출 메소드가 바로 연산없이 반환해주긴 하지만?)
사실 가장 중요한건 JVM이 어떤 매커니즘으로 toArray()의 new Object[]는 벡터화된 복사를 하는가, 어떻게 new를 사용한 배열 생성보다 내부적으로 native를 이용하는 Array.newInstace가 더 빠른가를 알아야 하는데 그 부분에 대해서 이해가 정확하게 되진 않아 결과 위주로 그렇구나하고 이해할 수 밖에 없었다.😭
정리
null이 아닌 빈 배열이나 컬렉션을 반환하자. null을 반환하는 API는 사용하기 어렵고 오류 처리 코드도 늘어나며 성능이 좋지도 않다.
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
'Reading > Effective Java' 카테고리의 다른 글
[Effective-Java] Item 57.지역변수의 범위를 최소화하라 (0) | 2021.11.14 |
---|---|
[Effective-Java] Item 55.옵셔널 반환은 신중히 하라 (0) | 2021.11.14 |
[Effective-Java] Item 53. 가변인수는 신중히 사용하라 (0) | 2021.11.08 |
[Effective-Java] Item 52. 다중정의는 신중히 사용하라 (0) | 2021.11.08 |
[Effective-Java] Item 51. 메소드 시그니처를 신중히 설계하라 (0) | 2021.11.08 |