[이펙티브 자바] 6. 불필요한 객체 생성을 피하라

1. 객체의 재사용

똑같은 객체를 매번 새로 생성하는 것보다 하나를 생성 후 재사용하는 것이 훨씬 효율적이다. 특히 불변 객체는 언제든 재사용이 가능하다. 다음은 객체 생성 시 사용하면 안 되는 극단적인 예이다.

String s = new String("bikini");

보기만 해도 불편한 이 생성방식은 실행될 때마다 String 객체를 새로 생성한다. 이후에 기능적으로는 동일하게 사용되지만 큰 반복문이나 자주 호출되는 메서드 안에 있다면 쓸모없는 인스턴스가 여러 개 생성될 것이다. 개선된 객체 생성 방식을 확인해 보자.

String s = "bikini";

 

이제 익숙한 String 객체 선언 방식이 되었다. 새로운 인스턴스를 매번 만드는 대신 하나의 String 인스턴스 사용하는 방식으로, 이 방식을 사용하면 같은 가상 머신 안에서 이와 같은 문자열 리터럴을 사용하는 모든 코드가 같은 객체를 재사용함이 보장된다. 생성자 대신 정적 팩토리를 제공하는 불변 클래스에서도 정적 팩터리 메서드를 사용해서 불필요한 객체 생성을 피할 수 있다. 예를들어 Boolean 생성자 대신 Boolean.valueOf 팩토리 메서드를 사용하면 호출될 때마다 새로운 객체가 생성되는 것을 방지할 수 있다.

2. 객체의 반복 시 캐싱 사용

불변객체뿐 아니라 가변객체도 사용 중에 변경되지 않는다는 것을 알 수 있다면 재사용이 가능하다. 특히 생성 비용이 비싼 객체의 생성이 반복해서 필요할 경우 캐싱하여 사용 권장한다. 주어진 문자열이 유효한 로마 숫자인지를 확인하는 메서드 예제를 확인해 보자.

static boolean isRomanNumeral(String s) {
    return s.matches("^(>=.)M*(C[MD]|D?C{0,3})" 
            + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

 

기능상으로는 문제가 없는 String.matches를 사용한 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법 중 하나이다. 하지만 빈번한 호출이 있을 경우 성능 측면에서는 적합하지 않다. Pattern 인스턴스는 한번 사용하고 버려 저서 바로 GC대상이 되며 Pattern은 입력받은 정규표현식에 해당하는 유한 상태머신을 만들기 때문에 인스턴스 생성비용이 높다

유한 상태머신 [Finite State Machine (FS)] - 단순히 객체를 생성하는 것이 아니라 정규표현식에 일치하는 문자열을 찾기 위해 문자열을 상태에 따라 순차적으로 처리하는 논리 구조를 생성하기에 계산적으로 복잡하고 리소스를 많이 사용한다. 

성능을 개선하려면 Pattern 인스턴스를 클래스 초기화 과정에서 생성하여 캐싱하고 isRomanNumeral이 호출될 때마다 재사용하는 방법을 적용하면 된다.

public class RomanNumerals {
    private static final Pattern ROMAN = Pattern.compile(
        "^(>=.)M*(C[MD]|D?C{0,3})"
        + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
    static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
    }
}

isRomanNumeral이 자주 호출될 때 성능개선 효과를 볼 수 있으며 ROMAN 필드를 final로 꺼내면서 의미가 더 명확해진다.

 

8글자 기준으로 100,000번 반복 호출 시 거의 10배의 시간 효율을 확인할 수 있었다. (테스트 코드는 깃허브에서 확인 가능)

 

반면 isRomanNumeral 방식의 클래스가 초기화된 후 이 메서드를 한 번도 호출하지 않으면 ROMAN필드는 쓸데없이 초기화된 것이다.

이 경우 isRomanNumeral 메서드가 처음 호출될 때 필드를 초기화하는 지연 초기화로 불필요한 초기화를 없앨 순 있지만 성능개선에 비해 코드복잡도가 올라가서 추천하지 않은 방식이다.

3. 불필요한 객체 생성 (keySet)

객체가 불변이면 재사용하는 것이 항상 안전하지만, 불변인지 명확하지 않은 경우도 있다. 

Map 인터페이스의 keySet 메서드를 확인해 보자. Map인터페이스의 KeySet메서드는 Map 객체 안의 키를 Set뷰로 반환한다.  뷰를 통해 원본 'Map'에서 키를 제거하는 등, 반환된 Set인스턴스가 가변이더라도 모든 반환된 'Set' 인스턴스는 동일한 'Map' 인스턴스를 대변하기 때문에 기능적으로 정확히 일치한다. 반환된 'Set'을 통해 원본 'Map'의 키를 수정하거나 제거하면, 이변경 사항이 'Map'에 그대로 반영되어 참조하는 모든 뷰에 영향을 미치기에 반환한 객체 중 하나를 수정하면 다른 모든 객체가 따라서 바뀌게 된다.

 

따라서 'keySet' 메서드를 여러 번 호출하여 여러 개의 'Set' 뷰를 생성해도 기능상 문제는 없지만, 실질적으로 볼 수 있는 효과가 없다. 모든 'Set' 뷰는 원본 'Map'을 참조하기에 여러뷰를 생성하는 것보다 단일 뷰를 재사용하는 것이 효율적이다.

4. 오토박싱

불필요한 객체를 만들어내는 또 다른 방식으로는 오토박싱이 있다. 자바에서 기본 타입과 박싱 된 기본타입을 섞어 쓸 때 자동으로 상호변환해 주는 기능이다. 다만 오토박싱은 기본 타입과 박싱 된 타입의 구분을 흐리게 해 주지만 아얘 구분을 없애는 것은 아니다.

 

다음 예제는 모든 양의 정수의 총합을 구하는 메서드로 int는 충분히 크지 않아 long을 사용 중이다. 기능 상으로는 동일하지만 성능에서는 오토박싱을 사용함으로써 성능에서 큰 차이를 보이고 있다.

3-1. 오토박싱 사용

private static long sumLong()   {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++) {
        sum += i;
    }
    return sum;
}

3-2. 기본 타입사용

private static long sumlong()   {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++) {
        sum += i;
    }
    return sum;
}

오토박싱을 사용한 예제를 보면 sum 변수를 Long으로 선언하여 불필요한 Long인스턴스가 2^31개나 생성된다. 실제로 두 메서드의 실행 시간을 비교해 보면 아주 큰 차이가 남을 확인할 수 있다.

 

그래서 박싱 된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의해야 한다. 

4. 주의

객체의 재사용이 효율적임을 강조했지만, 이펙티브 자바에서는 무조건적인 재사용이 효율적이지 않은 상황도 공유하고 있다. 프로그램의 명확성, 간결성, 기능을 위한 객체 생성은 일반적으로 좋지만 단순히 재사용성을 높이기 위해 객체 pool을 생성하는 것은 효율적이지 못하다. 데이터베이스 연결 같은 경우 생성 비용이 워낙 비싸기에 객체 pool(집합)을 생성하여 재사용하는 것이 효율적이지만,  일반적으로 자체 객체 풀은 객체를 풀에서 반환하는 추가로직이 필요하기에 코드의 명확성과 간결성이 떨어진다. 또한 사용하지 않는 개체들이 메모리에 남아있게 되어 메모리 사용량이 증가하게 된다. 또한 JVM GC가 상당히 최적화되어 있어 가벼운 객체를 효율적으로 관리하고 메모리를 회수하는데 최적화되어있기에, 가벼운 객체의 경우는 직접풀을 관리하는 것보다 새로 생성하는 것이 효율적일 수 있다.

5. 정리

  • 불변이 보장된 객체의 경우 재사용을 항상 고려
  • 무거운 객체의 반복 생성 시 캐싱을 사용
  • 불필요한 객체 생성을 주의
  • 의도치 않은 오토박싱이 숨어들지 않도록 주의
  • 데이터베이스 연결 같이 생성 비용이 아주 큰 경우를 제외하고는 객체 풀을 사용하여 객체를 재사용하는 것보다 새로 생성하는 것이 효율적

 

예제 및 성능 테스트 코드

https://github.com/junhkang/effective-java-summary/tree/master/src/main/java/org/example/ch01/item06/codes