기존 객체를 재사용해야 한다면 새로운 객체를 만들지 마라.
📌 기존의 인스턴스를 재사용하자
String 객체를 예시로 들어보자.
String s = new String("Java");
String을 new로 생성하면 매번 새로운 객체를 생성하게 된다.
해당 인스턴스는 기능적으로 완전히 똑같기 때문에, 위의 코드가 반복문이나 빈번히 호출되는 메서드 안에 있다면 쓸데없는 String 인스턴스가 여러 개 만들어질 수 있다.
String s = "Java";
반면에 이 코드는 새로운 인스턴스를 만들지 않고 기존의 리터럴 문자열을 참조하게 된다.
같은 가상 머신 안에서는 모든 코드가 같은 객체를 재사용하는 것이 보장된다. (Item1에서 언급한 String pool의 플라이웨이트 패턴, 같은 내용의 String 객체가 선언되면 기존의 객체를 참조한다.)
📌 정적 팩터리 메서드 사용하기
생성자 대신 정적 팩터리 메서드를 사용하는 불변 클래스에서는 불필요한 객체 생성을 피할 수 있다.
생성자는 호출될 때마다 새로운 객체를 만들지만, 팩터리 메서드는 그렇지 않다.
더 나아가 가변 객체일지라도 사용 중 변경이 되지 않음을 알고 있다면 재사용이 가능하다.
Boolean true1 = Boolean.valueOf("true");
Boolean true2 = Boolean.valueOf("true");
System.out.println(true1 == true2); // true
이러한 이유로 Boolean(String) 생성자는 자바 9에서 deprecated API로 지정되었다.
📌 Caching
생성 비용이 비싼 객체들을 반복해서 사용해야하는 경우가 있다면 캐싱(Caching)하여 재사용하는 것을 고려하라.
주어진 문자열이 유요한 로마 숫자인지 확인하는 예시를 보자.
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 Class에 구현되어 있는 matches 메서드
public boolean matches(String regex) {
return Pattern.matches(regex, this);
}
String.matches 내부에서 만드는 정규표현식용 Pattern 인스턴스는 한 번 쓰고 버려져 곧바로 가비지 컬렉션 대상이 된다.
Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신(finite state machine)을 만드므로 인스턴스 생성 비용이 크다.
✒️ 유한 상태 머신(finite state machine)과 Pattern
상태와 상태간의 전이(transition)을 이용하여 동작하는 추상화된 모델링 도구다.
상태를 기반으로 처리하기 때문에 한 번에 한 개의 상태만 처리된다는 특징이 있다.
만약 상태 값이 변경되면 현재 상태에 대한 종료와 다른 상태로의 변환을 처리한다.
우선, Pattern 클래스는 Matcher 객체를 생성하기 위해 정규 표현식(regular expression)을 컴파일한다.
정규 표현식은 문자열 패턴 매칭을 위한 강력한 도구이지만, 그만큼 복잡한 구조와 파싱 작업을 요구한다.
따라서 정규 표현식이 복잡하거나 길어지는 경우 Pattern 객체 생성 비용 또한 높아진다.
Pattern 클래스는 정규 표현식의 컴파일 결과를 캐싱한다. 동일한 정규 표현식을 사용한다면 이전에 생성된 Pattern 객체를 재사용하여 인스턴스 생성 비용을 줄이는 효과를 가져온다.
하지만 캐싱 기능 또한 메모리 공간을 차지하므로, 불필요한 Pattern 객체가 계속해서 캐시되는 경우 메모리 누수(memory leak)의 원인이 된다.
늘 같은 Pattern이 필요하다는 것이 보장이 된다면, 정규 표현식을 표현하는 불변의 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();
}
}
성능도 좋아지고, 코드도 명확해지기 때문에 재사용이 필요한 인스턴스라면 static final로 캐싱을 해두는 것이 좋다.
물론 이 방식의 문제점은 정작 RomanNumerals 클래스를 사용하지 않는 다면 ROMAN 필드는 쓸데없이 초기화 되는 것이라 생각하여 지연 초기화(lazy initialization, Item 83)를 고려할 수도 있겠지만
지연 초기화는 코드를 복잡하게 만들 뿐 아니라 성능이 크게 개선되지 않는다.
📌 재사용이 직관적이지 않은 경우
위의 경우에선 객체가 불변하므로 재사용해도 안전함이 보장되었고, 실제로 스니펫은 보다 명확하고 직관적인 코드로 바뀌었다.
하지만 반대의 케이스가 있는데, 어댑터(View라고도 한다)를 생각해보자.
어댑터는 인터페이스를 통해서 실제 로직이 구현된 객체로 연결해주는 역할만 수행하므로 뒷단 객체 하나당 어댑터 하나만 만들어지면 충분하다.
예를 들어 Map 인터페이스의 keySet 메서드는 Map 객체 안의 키 전부를 담은 Set 뷰를 반환한다.
그렇다면 KeySet은 호출할 때마다 새로운 Set 인스턴스를 반환할까?
Map에 값이 수정될 수 있으니, Set 또한 가변적인 데이터이기 때문에?
public class Foo {
public static void main(String[] args) {
Map<String, Object> map = new HashMap<>();
map.put("Hello", "World");
Set<String> set1 = map.keySet();
Set<String> set2 = map.keySet();
System.out.println(set1.size()); // 1
System.out.println(set2.size()); // 1
set1.remove("Hello");
System.out.println(set1.size()); // 0
System.out.println(set2.size()); // 0
}
}
실제로는 그렇지 않다.
반환된 Set 인스턴스가 설령 가변적이라 하더라도 기능상 모두 동일하게 Map 인스턴스를 대변하고 있다.
따라서 KeySet이 뷰 객체를 여러개 만들 이유도 없고 이득도 없기 때문에 실상은 하나의 인스턴스를 참조하고 있는 것이다.
같은 인스턴스를 대변하는 여러 개의 인스턴스를 생성하지 말자.
📌 불필요한 객체를 만들어내는 오토박싱(auto boxing)
auto-boxing은 기본 타입과 박싱된 기본 타입을 자동으로 상호 변환해주는 기술이다.
여기까지는 자바 기초만 배운 사람들도 모두 알고 있겠지만, 이 기술의 한계점은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주기는 하지만, 완전히 없애주는 것은 아니라는 것이다.
private static long sum(){
Long sum = 0L;
for (long i = 0; i< Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
위의 코드는 실제로 동작하기는 하지만 문제점이 있다. sum 변수를 long이 아닌 Long으로 선언했다는 점에 있다.
long 타입인 i가 Long 타입인 sum에 더해지기 위해 int의 MAX_VALUE인 2^31개(2,147,483,647)의 Long 인스턴스가 생성되었었다.
sum을 long으로 바꿔주는 것만으로 훨씬 더 빠른 속도로 개선시킬 수 있다.
박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토 박싱이 숨어들지 않도록 주의하자.
❌ 오해하지 말자!
이번 아이템은 객체 생성은 비싸니 피하라는 주제가 아니다.
오히려 최근 JVM의 GC는 최적화가 잘 되어 있어 별다른 일을 하지 않는 작은 객체를 생성하는 일고 회수하는 일에 부담이 되지 않는다.
경우에 따라서는 객체를 추가를 생성하는 것은 프로그램의 명확성, 간결성, 기능에 개선이 된다.
그렇다고 단순히 객체 생성을 피하기 위해 자신만의 객체 풀(pool)을 만들지 말자.
DB 커넥션 같은 생성 비용이 매우 비싸서 재사용하는 편이 나은 경우가 존재하긴 하지만
일반적으로 자체 객체 풀은 코드를 헷갈리게 하고 메모리 사용량을 늘리고 성능을 떨어뜨린다.
JVM의 GC는 전문가들을 갈아넣어서 개발했기 때문에 대부분의 커스텀 객체 풀보다 훨씬 빠르게 동작한다.
✒️ 재사용 VS 방어적 복사
Item6. 기존 객체를 재사용해야 한다면 새로운 객체를 만들지 마라.
Item50. 새로운 객체를 만들어야 한다면 기존 객체를 재사용하지 마라.
이 둘은 대조적인 내용을 다루는데 Item50은 버그나 보안 구멍으로 이어져 문제가 발생했을 때의 피해가 훨씬 클 때의 이야기이다. (Item6은 단순히 코드 형태와 성능의 문제지만, Item50으로 인해 겪는 피해는 훨씬 심각해진다.)
따라서 경우에 따라 객체를 재사용하는 것이 옳은지에 대한 trade-off를 고려해야만 한다.