본문 바로가기

Reading/Effective Java

[Effective-Java] Item 6. 불필요한 객체 생성을 피하라

문자열 객체 재사용과 정적 팩토리 메소드 


똑같은 기능의 객체를 매번 생성하기 보다는 객체 하나를 재사용하는 게 대부분 적절하고, 불변 객체는 언제든 재사용할 수 있다.

 

    @Test
    @DisplayName("리터럴과 새로운 인스턴스")
    public void instanceTest() throws Exception {
    	// 매번 새로운 인스턴스를 만든다. 사용X
        String s = new String("k____tae");
        Boolean b = new Boolean("true");
        // 매번 같은 인스턴스를 반환하는게 보장. 리터럴과 정적 팩토리 메소드
        String s2 = "k____tae";
        Boolean b2 = Boolean.valueOf("true");

        assertNotSame(s,s2);
        assertNotSame(s,"k____tae");
        assertSame(s2,"k____tae");
        assertNotSame(b,b2);
        assertNotSame(b,Boolean.TRUE);
        assertSame(b2,Boolean.TRUE);
    }

 

위 코드로 예를 들 수 있다. 매번 새로운 인스턴스를 만드는건 자바9에서 사용 자제(deprecated)로 지정되어있다. Boolean은 정적 팩토리 메서드를 이용해서 같은 객체를 반환하고, String은 가상 머신 안에서 매번 똑같은 문자열 리터럴을 사용해 같은 객체임이 보장된다.

불변 객체뿐만 아니라 가변 객체라 해도 사용중에 변경되지 않음을 안다면 재사용 할 수 있다.

 

String literal 관련 링크: https://medium.com/@joongwon/string-%EC%9D%98-%EB%A9%94%EB%AA%A8%EB%A6%AC%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0-57af94cbb6bc

 

생성비용이 비싼 객체


만드는데 메모리나 시간이 오래 걸리는 객체, 즉 비싼 객체가 반복해서 필요하다면 캐싱하여 재사용하자.

정규표현식을 예로 보자. 문자열이 로마 숫자를 표현하는지 확인하는 두가지 방식의 코드가 있다.

 

    // 코드 6-1 성능을 훨씬 더 끌어올릴 수 있다!
    static boolean isRomanNumeralSlow(String s) {
        return s.matches("^(?=.)M*(C[MD]|D?C{0,3})" + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
    }

    // 코드 6-2 값비싼 객체를 재사용해 성능을 개선한다.
    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})$");
    
    // 코드 6-2
    static boolean isRomanNumeralFast(String s) {
        return ROMAN.matcher(s).matches();
    }

 

코드 6-1의 문제점은 String.matches 메소드를 사용한다는 것.

정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서 반복해 사용하기 적합하지않다.

 

matches 내부에서 만드는 Pattern 인스턴스는 한 번 쓰고 버려져서 곧바로 GC대상이 된다. Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신(finite state machine)을 만들기 때문에 인스턴스 생성 비용이 높다. matches는 내부적으로 Pattern 객체를 만들어 쓰고, 한 번 쓰고 버려져서 곧바로 GC대상이 된다. Pattern 객체를 만들려면 정규 표현식에 해당하는 유한 상태 머신(finite state machine)으로 컴파일 하는 과정때문에 인스턴스 생성 비용이 높다.

 

유한 상태 머신: https://ko.wikipedia.org/wiki/%EC%9C%A0%ED%95%9C_%EC%83%81%ED%83%9C_%EA%B8%B0%EA%B3%84

 

그래서 6-2처럼 정규표현식을 표현하는 Pattern 객체를 초기화 과정에서 미리 캐싱해두고,  isRomanNumeralFast 메소드를

호출할 때마다 이 인스턴스를 재사용한다. 물론 isRomanNumeralFast 메소드를 호출하지 않으면 필요없이 만든셈이다.

지연초기화로 불필요한 초기화를 없앨수는 있지만 코드가 복잡한만큼 성능은 크게 개선되지 않기에 권하지는 않는다.

 

 

어댑터


객체가 불변이라면 재사용해도 안전함이 명백하다. 하지만 덜 명확하거나 직관에 반대되는 상황도 있다. 

 

어댑터(뷰): 실제 작업은 뒷단 객체에 위임하고 자신은 제2의 인터페이스 역할을 해주는 객체

즉 어떤 한 구현체를 어떤 다른 인터페이스에 맞게끔 감싸주는 역할을 하는 중간 객체이다.

뒷단 객체만 관리하면 되니 객체 하나당 어댑터 하나만 재사용하면 된다.

 

    @Test
    @DisplayName("map의 keyset은 매번 같은 객체를 반환한다.")
    public void 맵_테스트() throws Exception {
        Map<String, Integer> menu = new HashMap<>();
        menu.put("Burger", 8);
        menu.put("Pizza", 9);

        // map 객체 안의 Key값을 Set으로 전체 반환. 매번 같은 Set 인스턴스를 반환한다.
        Set<String> names1 = menu.keySet();
        Set<String> names2 = menu.keySet();

        // names1를 수정하면 모두가 똑같은 Map 인스턴스를 대변하기 때문에 menu, names2의 객체상태도 변경된다.
        names1.remove("Burger");
        assertSame(names1,names2);
        assertEquals(names1.size(),1);
        assertEquals(names1.size(),names2.size());
        assertEquals(menu.size(),names2.size());
    }

 

names1을 지웠는데 menu와 names2도 객체상태가 변경되니까 이런 유사방법은 모두가 알면 괜찮지만

혼란을 야기할 수 있어, 값을 복사해오는게 나을수도 있는데, 잘 모르겠다.

 

오토박싱


오토박싱: 기본 primitive 타입을 래퍼(Wrapper) 클래스의 객체로 변환하는 것

언박싱: 래퍼(Wrapper) 클래스의 객체를 기본 primitive 타입으로 변환하는 것

 

오토박싱은 기본타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해주는 기술이다.

기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니다. 성능상의 차이가 난다.

 

    @Test
    @DisplayName("오토박싱이 성능을 저하한다.")
    public void 오토박싱() throws Exception {
        // 오토박싱
        long start = System.currentTimeMillis();
        Long sum = 0l;
        for (long i = 0 ; i <= Integer.MAX_VALUE ; i++) {
            sum += i;
        }
        // 6187ms
        System.out.println(System.currentTimeMillis() - start);

        // 기본 primitive
        start = System.currentTimeMillis();
        long sum2 = 0l;
        for (long i = 0 ; i <= Integer.MAX_VALUE ; i++) {
            sum2 += i;
        }
        // 669ms
        System.out.println(System.currentTimeMillis() - start);
    }

 

속도가 무려 10배 차이나는 것을 알 수 있다. 불필요한 Long 인스턴스가 2^31개 만들어진다.

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

이번 아이템으로 인해 객체를 만드는 것은 비싸며 가급적이면 피해야 한다는 오해를 해서는 안된다.

요즘 JVM은 큰 무리가 없지만 프로그램의 명확성, 간결성, 기능을 위해서 객체를 추가로 생성하는 것이면 일반적으로 좋은 일이다.

 

 

나만의 객체 풀(pool)


반대로 아주 무거운 객체가 아니라면 단순히 객체 생성을 피하고자 나만의 객체 풀(pool)을 만들지 말자.

데이터베이스 연결같은 경우 생성 비용이 워낙 비싸니 재사용하는 편이 낫다.

하지만 일반적으로는 자체 객체 풀은 코드를 헷갈리게 만들고, 메모리 사용량을 늘리고 성능을 떨어뜨린다.

요즘 JVM의 GC의 상당한 최적화로 가벼운 객체용을 다룰 떄는 직접 만든 객체 풀보다 훨씬 빠르다.

 

이번 아이템은 방어적 복사(아이템 50)과 대조적이고, 기존 객체를 재사용해야 한다면 새 객체를 만들지 말라는 것.

아이템 50은 새 객체를 만들어야 한다면 기존 객체를 재사용하지 말라는 것.

 

필요없는 객체를 반복 생성했을때의 피해 < 방어적 복사가 필요한 상황에서 객체 재사용의 피해 

 

불필요한 객체 생성은 코드 형태와 성능에 영향을 끼치지만,

방어적 복사(Depensive copying)에 실패하면 심각한 버그와 보안성에 문제가 생긴다.

방어적 복사 참조: http://www.javapractices.com/topic/TopicAction.do?Id=15 

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.