본문 바로가기
Computer Sience/Java

[Effective Java] 아이템6: 불필요한 객체 생성을 피하라

by 제우제우 2024. 10. 26.

Chapter 02: 객체 생성과 파괴

객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법

올바른 객체 생성 방법에 대한 설명

불필요한 객체 생성을 피하라

똑같은 기능의 객체를 새로 만드는 대신 개체 하나를 재사용하는 것이 대부분 적절하다. 

재사용을 하면 성능적인 측면에서 더 빠르다. 

특히 불변 객체는 언제든 재사용할 수 있다.

문자열 객체 생성

public class StringTest {
    public static void main(String[] args) {
        String name1 = "jeu";
        String name2 = "jeu";
        System.out.println(name1 == name2); // true

        String str1 = new String("jeu");
        String str2 = new String("jeu");

        System.out.println(str1 != str2);  // true
        System.out.println(name1 != str1); // true
    }
}

 

name2에서는 name1에 생성된 String 인스턴스를 재사용한다.

사용 측면에서 동일한 리터럴을 가지니 기능적으로 동일하고 재사용도 가능하다.

 

str1, str2는 같은 기능인 "jeu" String 인스턴스를 계속 2번 생성한다. 

이 문장이 반복문이나 빈번하게 호출되는 메소드 안에 있다면 쓸데없는 String 인스턴스가 계속 생기는 것이다.

 

또한 name1, name2 방식을 사용하면 같은 JVM 내에서 이와 똑같은 문자열 리터럴을 사용하는 모든 코드가 

같은 객체를 재사용함이 보장된다. 

이는 문자열 상수 풀(String constant Pool)에 저장되기 때문에 그렇다.

static 팩토리 메소드 사용하기

아이템1에서 나왔던 정적 팩토리 메소드의 장점이다.

정적 팩토리 메소드는 언제나 새로운 인스턴스를 생성하여 반환할 필요 없이 인스턴스 통제를 통해서 원하는 방식으로

불변 인스턴스이든 매번 새로운 인스턴스 생성 & 반환이든 정할 수 있다는 것이다. 

public class BooleanTest {
    public static void main(String[] args) {
        Boolean flag = Boolean.valueOf("true");
        System.out.println(flag == Boolean.TRUE); // true
    }
}

 

Boolean(String) 생성자는 deprecated 되었다. (자바 9)

대신 Boolean.valueOf(String) 같은 정적 팩토리 메소드를 사용할 수 있다. 

해당 팩토리 메소드 valueOf는 Boolean 클래스가 로딩되면서 이른 초기화 해둔 Boolean 객체 TRUE와 FALSE를 static 필드로 가지고 있고 valueOf를 통해 호출하면 새로운 인스턴스를 반환하는게 아닌 미리 생성해둔 인스턴스를 반환한다. 

생성 비용이 비싼 객체 

생성 비용이 비싼 객체가 있다.

이런 생성 비용이 비싸고 이런 비싼 객체가 반복해서 필요하다면 캐시 해서 재사용할 수 있는지 고려하는 것이 좋다.

 

책에서는 정규 표현식을 예시로 들고 있다.

public class RomanNumerals {
    public 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(String) API는 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이다. 

하지만 성능이 중요한 상황에서 반복해서 사용하기엔 적합하지 않다.

 

해당 메소드가 내부에서 만드는 정규표현식용 Pattern 인스턴스는, 한 번 쓰고 버려져서 곧바로 가비지 컬렉션 대상이 된다. 

Pattern 인스턴스는 정규표현식에 해당하는 유한 상태 머신(finite state machine)을 만들기 때문에 인스턴스 생성 비용이 높다. 

 

Pattern 유한 상태 머신(FSM)

FSM은 입력된 문자열의 각 문자를 하나씩 검사하면서 상태를 전이하고, 모든 문자가 주어진 정규표현식 패턴과 일치하는지 확인한다. 상태 전이는 정해진 규칙(정규 표현식)에 따라 이루아진다.

마지막(문자)까지 규칙에 맞는 상태 전이를 완료했을 때 true를 반환한다. 

해당 FSM에는 정규표현식에 기반한 상태와 전이 규칙을 모두 포함하므로, 인스턴스 생성 시 초기 설정 비용이 발생

그래서 이 Pattern 인스턴스를 여러 번 반복해서 생성하는 경우, 성능에 큰 부담이 된다.

// String matches
public boolean matches(String regex) {
    return Pattern.matches(regex, this);
}

// Pattern matches
public static boolean matches(String regex, CharSequence input) {
    Pattern p = Pattern.compile(regex);
    Matcher m = p.matcher(input);
    return m.matches();
}

Pattern 인스턴스 & Matcher 인스턴스 2개  검사 이후 가비지 컬렉션 대상 

 

성능 개선 

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})$");
    public static boolean isRomanNumeral(String s){
        return ROMAN.matcher(s).matches();
    }
}

로마 숫자 정규 표현식을 표현하는 Pattern 인스턴스를 

클래스 로딩 시점에 생성해서 캐싱해두고, 나중에 isRomanNumeral 메소드가 호출될 때마다 이 인스턴스를 재사용한다.

 

이렇게 하면 isRomanNumeral(String s) 빈번하게 호출되는 상황에서 성능을 상당히 끌어올릴 수 있다.

 

개선된 방법의 문제점은 초기화 된 후 이 메소드를 한 번도 호출하지 않으면 ROMAN 필드는 쓸데없이 초기화 된 꼴이다.

즉, 간단하게 설명하면 이른 초기화 방식을 사용했는데 사용하지 않는 케이스이다. 

 

지연 초기화 방식을 사용할 수 있지만 해당 방식은 책에서는 권장하지 않는다. 

지연 초기화는 코드를 복잡하게 만드는데, 성능은 크게 개선되지 않을 때가 많기 때문이다.

 

내가 생각한 지연 초기화 방식 

public class RomanNumerals2 {
    public static boolean isRomanNumeral(String s){
        return RomanHolder.ROMAN.matcher(s).matches();
    }
    private static class RomanHolder{
        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})$");
    }
}

 

해당 방식은 JVM 지연 로딩 특성을 활용하여 구현했는데 isRomanNumeral 메소드가 호출되기 전까지는
내부 클래스인 RomanHolder 클래스가 JVM에 로딩되지 않기 때문에 지연 로딩 방식이다.

RomanNumerals2 클래스가 다른 API를 가지고 있고 만약 이전에 다른 API가 호출되었다면
다른 API가 호출되었기 때문에  isRomanNumeral 메소드 또한 메타 스페이스에 저장되면서 내부 클래스 또한 로딩되는 게 아닐까라는 착각을 할 수 있지만 메타 스페이스에 올라가는 정보는 메소드 시그니처, 메소드 반환 타입, 메소드 매개변수 등의 정보이지 안에 내부 로직은 호출 전까지 로딩되지 않는다.

어댑터 

객체가 불변이라면 재사용해도 안전함이 명백하다.

하지만 어댑터를 생각해 보자.

 

어댑터는 실제 작업은 뒷단 객체에 위임하고, 자신은 제2의 인터페이스 역할읗 해주는 객체다.

어댑터는 뒷단 객체만 관리하면 된다.

그래서 뒷단 객체 하나당 어댑터 하나만씩만 만들어지면 충분하다.

 

KeySet() 

Map 인터페이스의 ketSet() 메소드는 Map 객체 안의 키의 전부를 담은 Set 뷰를 반환한다. 

keySet()을 호출할 때마다 새로운 Set 인스턴스가 만들어지리라고 순진하게 생각할 수도 있지만, 사실은 매번 같은 Set 인스턴스를 반환한다.

public abstract class AbstractMap<K,V> implements Map<K,V> {
    transient Set<K> keySet;
    
    // keySet 메소드 
    public Set<K> keySet() {
        Set<K> ks = keySet;
        if (ks == null) {
            // 과정 생략 
            keySet = ks;
        }
        return ks;
    }    
}
public class MapKeySet {
    public static void main(String[] args) {
        Map<String, Integer> people = new HashMap<>();
        people.put("jeu", 5);
        people.put("zeus", 8);

        Set<String> names1 = people.keySet();
        Set<String> names2 = people.keySet();

        names1.remove("jeu");

        System.out.println(names1 == names2);
        System.out.println(people.size()); // 1
        System.out.println(names1.size()); // 1
        System.out.println(names2.size()); // 1
    }
}

반환된 객체 중 하나를 수정하면 다른 모든 객체가 따라서 바뀐다.

즉, keySet()으로 반환된 Set 내용을 삭제하면 뒷단인 people(Map)또한 삭제된다. 

또한 keySet()으로 반환된 names1, names2 가 같은 Set<String>을 참조하는게 확인되었다. 

keySet()이 뷰 객체를 여러 개 만들어도 상관은 없지만 그럴 필요도 없고 이득이 없다. 

오토 박싱(AutoBoxing)

불필요한 객체를 생성하는 또 다른 방법으로 오토박싱이 있다. 

오토박싱은 프로그래머가 기본 타입과 박싱된 타입을 섞어 쓸 수 있게 해주고 박싱과 언박싱을 자동으로 해준다.

오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려지게 해주지만, 완전히 경계를 없애주는 것은 아니다.

의미상에서는 별다를 것 없지만, 성능상에서는 그렇지 않다. 

public class AutoBoxing {
    public static void main(String[] args) {
        long start = System.nanoTime();
        Long sum = 0L;
        for(long i = 0; i <= Integer.MAX_VALUE; i++){
            sum += i; // Boxing 타입인 Long 타입으로 기본 타입인 long 오토박싱 해서 더한다.
        }
        System.out.println(sum);
        System.out.println(System.nanoTime() - start);
    }
}

sum 변수를 Long으로 선언하면 매번 sum += i 작업을 할 때 박싱해서 더해진다. 

sum 변수를 long으로 선언하면 박싱 작업이 없기 때문에 책에서는 본인의 컴퓨터에서  6.3초에서 0.59초로 빨라진다고 설명하고 있다. 

이는 불필요한 Long 인스턴스가 2^31개 만들어져서 그렇다고 하는데  

 

내 컴퓨터에서는 거의 똑같다...?

정리 

이번 아이템을 "객체 생성은 비싸니 가급적 피해야 한다"라고 오해하면 안 된다.

요즘의 JVM에서 별다른 일을 하지 않는 작은 객체를 생성하고 회수하는 일이 크게 부담되지 않는다.

프로그램의 명확성, 간결성, 기능을 위해 객체를 추가적으로 생성하는 것이라면 일반적으로 좋은 일이다.

 

아주 무거운 객체가 아니라면 객체 풀(pool)을 만들지 말자.

하지만 데이터베이스 연결(커넥션) 같은 경우 워낙 생성 비용이 비싸서 재사용파는 편이 낫다.

ex) 스프링부트 2.0 기본 제공 히카리풀 

 

이번 아이템은 방어적인 복사(Dependency Copying)를 다루는 아이템50과 대조적이다. 

방어적 복사가 필요한 경우인데 객체를 재사용하면 심각한 버그와 보안성에 문제가 생기지만 

객체를 생성하면 단지 스타일과 성능에 영향을 준다.