<aside> <img src="/icons/target_gray.svg" alt="/icons/target_gray.svg" width="40px" />
반복적이고 불필요한 객체 생성을 피하면서 자원을 절약해야 한다.
</aside>
문자열 객체 생성의 경우
String s = new String(”java”); //항상 새로운 객체를 만들게 된다.
String s = "java"; //개선된 버전, 하나의 String 인스턴스를 재사용한다.
//대량의 문자열 처리(로그)의 극단적인 예시(억지)
public class UserService {
public void processUserData(List<String> userNames) {
for (String userName : userNames) {
String logMessage = new String("Processing user: " + userName);
System.out.println(logMessage); // 매번 새로운 String 객체를 생성
}
}
}
public class UserService {
public void processUserData(List<String> userNames) {
for (String userName : userNames) {
String logMessage = "Processing user: " + userName;
System.out.println(logMessage); // 상수 풀에서 문자열을 가져옴
}
}
}
public class UserService {
public void processUserData(List<String> userNames) {
StringBuilder logBuilder = new StringBuilder();
for (String userName : userNames) {
logBuilder.append("Processing user: ").append(userName).append("\\n");
}
System.out.println(logBuilder.toString()); //StringBuilder 사용(가변 객체)
}
}
정적 팩토리 메서드 사용하여 불필요한 객체 생성 피하자.
ex) Boolean(String)
→ Boolean.valueOf(String)
재사용 빈도가 높고 생성 비용이 비싼 객체는 캐싱하여 재사용하자.
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
은 입력받은 정규표현식에 해당하는 유한 상태 머신을 만들기 때문에 인스턴스 생성 비용이 높은 메서드이기 때문이다.
만약 늘 같은 Pattern이 필요함이 보장되고 재사용 빈도가 높다면 아래와 같이 상수(static final
)로 초기에 캐싱해놓고 재사용할 수 있다.
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();
}
}
이렇게 개선하면 해당 메서드가 빈번히 호출되는 상황에서 성능을 끌어올릴 수 있다.(해당 예시에서 성능 향상은 약 6.5배)
따라서 생성 비용이 비싼 객체라면 "캐싱" 방식을 고려해야 한다.
객체가 불변이라면 재사용해도 안전함이 명백하다. 어댑터 패턴이 실제 작업은 뒷단 객체에 위임하고, 자신은 제 2의 인터페이스 역할을 해주는 객체이다. 그러기에 어댑터 패턴에서는 뒷단 객체만 관리해주면 된다.
이를 keySet()
메서드의 예시와 연결하면, Map
이 변하지 않는 한, keySet()
이 반환하는 객체도 불변처럼 동작하므로 재사용이 안전하다는 것을 알 수 있다.
의도치 않은 오토박싱이 숨어들지 않도록 주의하자. 오토박싱(auto boxing)은 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해주는 기술이다. 의미상으로는 별다를 것 없지만 성능에서는 그렇지 않다.
//모든 양의 정수의 총합을 구하는 메서드, int는 충분히 크지 않으니 long을 사용해 계산.
private static long sum() {
Long sum = 0L;
for(long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
위 코드는 sum변수를 long
이 아닌 Long
으로 사용해서 불필요한 Long
인스턴스가 약 2^31 개나 만들어졌다.
Long으로 선언된 변수를 long으로만 바꿔줘도 성능이 약 10배 정도 빨라진다.
래퍼 클래스보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자.
오해 금지 ”객체 생성은 비싸니 피해야 한다”로 오해하면 안 된다. 특히나 요즘의 JVM에서는 별다른 일을 하지 않는 작은 객체를 생성하고 회수하는 일이 크게 부담되지 않는다. 프로그램의 명확성, 간결성, 기능을 위해 객체를 추가로 생성하는 것이라면 일반적으로 좋은 일이다.
그렇다고 단순히 객체 생성을 피하기 위해 자신만의 객체 풀(pool)을 만들지는 말자. DB 커넥션 같은 경우 생성 비용이 워낙 비싸니 재사용 하는 편이 낫지만, 일반적으로 자체 객체 풀은 코드를 헷갈리게 하고, 메모리 사용량을 늘리고, 성능을 떨어뜨린다. 요즘 JVM의 GC는 상당히 잘 최적화 되어서, 가벼운 객체를 다룰 때는 직접 만든 객체 풀보다 훨씬 빠르다.
가장 최선은 상황에 맞춰 객체를 재사용할지, 방어적 복사를 통해 재사용을 안 할지 선택하는 것인데, 방어적 복사가 필요한 상황에서 객체를 재사용했을 때의 피해가, 필요 없는 개체를 반복 생성했을 때의 피해보다 훨씬 크다는 사실을 기억하자. 방어적 복사에 실패하면 언제 터져 나올지 모르는 버그와 보안구멍으로 이어지지만, 불필요한 객체 생성은 그저 코드 형태와 성능에만 영향을 준다.
자바의 CG는 유용하면서도 메모리 관리에 신경쓰지 않아도 된다는 오해를 일으킨다. 이러한 오해는 곧 ‘메모리 누수’로 이어지는데, 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하된다. 드물지만 심한 경우에는 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료되기도 한다. 예시를 살펴보자.
자기 메모리를 직접 관리하는 클래스
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[Size++] = e;
}
public Object pop() {
if(size == 0)
throw new EmptyStackException();
return elements[--size];
}
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if(elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
스택이 커졌다가 줄어들 때 스택에서 꺼낸 객체들을 가비지 컬렉터가 회수하지 않는다.
이 스택이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다. 스택에 계속 쌓다가 많이 빼내도 스택이 차지하고 있는 메모리는 줄어들지 않는다.
가용한 범위(유의미한 값들을 갖고 있는 부분)는 elements
배열의 인덱스가 size
보다 작은 부분이고, 그 값보다 큰 부분에 있는 값들은 필요없이 메모리를 차지하고 있는 것이다.
해법은 간단하다. 참조를 다 썼을 때 null
처리(참조 해제)하면 된다.
public Object pop() {
if(size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
각 해당 원소의 참조가 더이상 필요 없어지는 시점(Stack에서 꺼낼 때, 사용이 완료됨)에 null
로 설정하여 다음 GC가 발생할 때 레퍼런스가 정리되게 한다.
또 다른 이점으로는 만약 null
처리한 참조를 실수로 사용하려 할 때 프로그램이 NullPointerException
을 던지며 종료할 수 있다.(잘못된 일을 수행하는 것보다는 낫다)
객체 참조를 null
처리하는 일은 예외적인 경우여야 한다.
그렇다고 모든 객체를 지저분하게 null처리하기 보다는 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이 가장 좋은 참조 해제 방법이다.
변수의 범위를 가능한 최소가 되게 정의했다면(item 57) 이 일은 자연스럽게 이뤄진다.
//pop()안에서만 형성되어 있으므로 scope 밖으로 나가면
//무의미한 레퍼런스 변수가 되기 때문에 GC에 의해 정리가 된다.
Object pop() {
Object age = 24;
...
//age = null;
}
그렇다면 null 처리는 언제 해야 할까?
메모리를 직접 관리할 때, Stack
구현체처럼 elements
라는 배열을 관리하는 경우에 GC는 어떤 객체가 필요 없는 객체인지 알 수 없으므로, 해당 레퍼런스를 null
로 만들어 GC한테 필요없는 객체들이라고 알려줘야 한다.
캐시 역시 메모리 누수를 일으키는 주범이다.
캐시를 비우는 것을 잊기 쉽다. 여러 가지 해결책이 있지만, 캐시의 키에 대한 레퍼런스가 캐시 밖에서 필요 없어지면 해당 엔트리를 캐시에서 자동으로 비워주는 WeakHashMap
을 쓸 수 있다.
//캐시 구현의 안 좋은 예 - 객체를 다 쓴 뒤로도 key를 정리하지 않음.
public class CacheSample {
public static void main(String[] args) {
Object key = new Object();
Object value = new Object();
Map<Object, List> cache = new HashMap<>();
cache.put(key, value);
...
}
}
//key의 사용이 없어지더라도 cache가 key의 레퍼런스를 가지고 있으므로
//GC의 대상이 될 수 없다.
//캐시 외부에서 key를참조하는 동안만 엔트리가 살아있는 캐시가 필요하다면 WeakHashMap을 이용한다.
//다 쓴 엔트리는 그 즉시 자동으로 제거된다. 단, WeakHashMap은 이런 상황에서만 유용하다.
public class CacheSample {
public static void main(String[] args) {
Object key = new Object();
Object value = new Object();
Map<Object, List> cache = new WeakHashMap<>();
cache.put(key, value);
...
}
}
캐시 값이 무의미해진다면 자동으로 처리해주는 WeakHashMap
은 key 값을 모두 Weak 레퍼런스로 감싸 hard reference가 없어지면 GC의 대상이 된다.
WeakHashMap
을 사용할 때 key 레퍼런스가 쓸모 없어졌다면, (key - value) 엔트리를 GC의 대상이 되도록해 캐시에서 자동으로 비워준다.
또는 시간이 지나면 캐시값이 의미가 없어지는 경우에 백그라운드 쓰레드를 사용하거나 (ScheduledThreadPoolExecutor
), 새로운 엔트리를 추가할 때 부가적인 작업으로 기존 캐시를 비우는 일을 할 것이다. (LinkedHashMap
클래스는 removeEldestEntry
라는 메서드를 제공한다.)
리스너(listener) 혹은 콜백(callback).
등록만 하고 해지하지 않는다면 콜백은 계속 쌓여갈 것이다.
이럴 때 콜백을 약한 참조(weak reference)로 저장하면 GC가 즉시 수거해간다.
예를 들어 WeakHashMap
에 키로 저장하면 된다.