불필요한 객체 생성을 피하라
객체 생성의 비용
- 객체를 매번 생성하고 지우는 것은 반복적으로 발생했을 때 큰 비용이 될 수 있다.
- 물론 현대 컴퓨터의 성능이 많이 좋아서 작은 객체는 큰 부담이 되지 않을 수도 있다.
- 계속 같은 내용의 객체를 사용할 것이라면 불변 객체를 만들어놓고 재사용하는 것이 좋다.
불필요한 객체 생성의 예
String 객체의 예
String s = new String("bikini");
위 코드는 안티패턴이다. 결국 bikini
라는 문자열을 사용하고 싶은 건데, 굳이 JVM 문자열 풀에서 가져오지 않을 이유가 없다.
String s = "bikini";
더 간결하면서도 더 옳은 코드이다.
Boolean 객체의 예
Boolean boolean = new Boolean(true);
과연 위 객체는 정말 필요한 걸까? 결국 Boolean
객체란 true
와 false
2가지 상태밖에 존재하지 않는다.
@Deprecated(since="9", forRemoval = true)
public Boolean(boolean value) {
this.value = value;
}
결국 위와 같이 Java 9 버전부터는 @Deprecated
된 것을 볼 수 있다. 객체 생성 대신 true
, false
primitive 타입을 쓰거나 Boolean.TRUE
혹은 Boolean.FALSE
를 이용하면 미리 생성되어 있는 싱글턴 객체를 불러와 사용할 수 있다.
불필요한 객체 생성의 특징은 매번 같은 내용의 객체를 반복해서 사용하면서, 매번 새로 생성하는 경우이다.
정규표현식에서의 불필요한 객체 생성
기존 코드
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.matchs()
메서드는 정규표현식으로 해당 문자열이 내가 원하는 형태인지 확인하는 가장 쉬운 방법이다.
matches()
메서드 사용 중 놓치기 쉬운 부분
public static Pattern compile(String regex) {
return new Pattern(regex, 0);
}
String.matches()
메서드의 구현을 따라가보면, 위처럼Pattern
인스턴스를 생성하는 부분이 있다.- 위 과정에서 생성되는
Pattern
인스턴스는 한번 쓰고 버려져 가비지컬렉션된다. - 정규표현식에 해당되는 유한상태머신을 만들기 때문에 인스턴스 생성 비용이 높다.
- 내부적으로
Pattern
을 생성하는 로직을 밖으로 빼서 재활용하면 객체 생성을 1번만 하고 성능을 높일 수 있다.
개선된 코드로 성능 테스트해보기
package item6;
import org.junit.jupiter.api.Test;
import java.util.regex.Pattern;
public class Item6Test {
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}))");
}
static 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();
}
}
@Test
public void regexTest() {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
boolean iiv = isRomanNumeral("IV");
}
long end = System.currentTimeMillis();
System.out.println("매번 새로 생성 : " + (end - start) + "ms");
}
@Test
public void regexTest2() {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
boolean iiv = RomanNumerals.isRomanNumeral("IV");
}
long end = System.currentTimeMillis();
System.out.println("객체 재활용 : " + (end - start) + "ms");
}
}
regexTest
메서드에서는 매번 객체를 새로 생성해본다.regexTest2
메서드에서는 정적 메서드를 이용하여 생성된 객체를 매번 재활용한다.- 반복 횟수는 동일하게
10000
번이다.
결과
객체 재활용 : 6ms
매번 새로 생성 : 46ms
비록 6ms
와 46ms
로 현실세계에서는 별 것 아닌 시간이지만, 단순 비례로 계산해보면 거의 7.x배 차이가 난다. 동시접속자가 많은 서버라면, 이정도면 꽤나 유의미한 차이일 수 있다.
사실
Pattern
객체를 더 최적화하면, 사용하지 않을 때는 아예 생성되지 않도록 지연초기화를 할 수 있으나 지연 초기화로 코드가 복잡해지는 반면 성능상 이득이 크게 없을 수 있으므로 이는 적용하지 않는다.
언제 객체를 재생성할 필요 없이 재사용해도 될까?
재사용해도 될 때
- 객체가 불변이라면 안심하고 재사용할 수 있다.
재사용을 조심해야 할 때
- 간단히 말하면, 객체 내부의 내용이 변할 수 있을 때다.
- 다른 스레드에 의해 해당 객체가 변해서 동시성 문제가 발생하게 되면 개발자가 원했던 결과와 다른 결과가 나오게 될 것이다.
어댑터
라는 개념에서는 특히 주의해야 한다.어댑터
는 실제 작업을 뒷단 객체에 위임하고 자신은 인터페이스의 역할만 하는 것이다.어댑터
는 사실상View
의 역할을 하기 때문에 실제 객체의 내용이 바뀌면어댑터
가 바라보는 내용도 바뀐다.
간단한 예제
Map
인터페이스의keySet()
은Map
안에 들어있는key
를Set
타입으로 반환한다.- 이 반환받은
Set
을 저장해놓고 재활용해도 될까?- 안된다.
keySet()
이라는 메서드가 매번Set
을 생성한다고 보장할 수 없다.
- 안된다.
keySet()
메서드의 구현을 살펴보자.
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new AbstractSet<K>() {
public Iterator<K> iterator() {
return new Iterator<K>() {
private Iterator<Entry<K,V>> i = entrySet().iterator();
public boolean hasNext() {
return i.hasNext();
}
public K next() {
return i.next().getKey();
}
public void remove() {
i.remove();
}
};
}
public int size() {
return AbstractMap.this.size();
}
public boolean isEmpty() {
return AbstractMap.this.isEmpty();
}
public void clear() {
AbstractMap.this.clear();
}
public boolean contains(Object k) {
return AbstractMap.this.containsKey(k);
}
};
keySet = ks;
}
return ks;
}
ks
라는 변수에 해당 클래스가 가진keySet
필드의 값을 할당한다.ks
가null
이라면AbstractSet
클래스를 이용해 새로운 객체를 넣어 할당한다.- 만들어둔 객체는 재활용할 수 있도록
keySet
필드에 할당해둔다.
ks
가null
이 아니라면keySet
필드의 값을 재활용한다.
Map.keySet()
으로 할당받은 Set
은 어댑터
의 대표적인 예이다. 원본 Map
이 바뀌면 keySet
도 바뀌므로, 항상 인지하고 있어야 한다. 또한, 매번 Map.keySet()
을 하더라도 같은 객체를 반환할 것이다. Map
객체가 이를 재사용하고 있음을 인지하고 코딩하는 것이 좋다.
primitive 오토 박싱
의 함정
primitive 오토 박싱
은 primitive
타입을 객체화 시켜준다. 이로 인해, 일반 객체처럼 null
값을 할당해두는 것도 가능해지고, 제너릭스에 타입으로 줄 수도 있게 된다. 그러나, 이렇게 의도한 박싱
도 있는 반면 나도 모르게 박싱
이 되는 경우도 있다.
파라미터 타입에 의한 오토박싱
public static void unintendedBoxing1(Integer number1, Integer number2) {
System.out.println(number1 + number2);
}
위와 같은 메서드가 있을 때, 일반 int
타입을 넘기더라도 파라미터 타입에 의해 오토 박싱
이 일어난다. 이는 작은 작업에서는 별다른 두각을 나타내지 않지만, 이 메서드를 이용해 대량의 작업을 하면 분명히 그냥 int
타입을 활용하는 것보다 훨씬 느리게 될 것이다.
변수 타입에 의한 오토박싱
private static long unintendedBoxing2() {
Long sum = 0L;
for(long i=0; i<=Integer.MAX_VALUE; i++) {
sum += i;
}
return sum;
}
위는 왜 오토 박싱
이 발생하는 것일까? 그 이유는 모든 합계를 더해줄 sum
을 Long
으로 선언하였기 때문이다.
/**
* The value of the {@code Long}.
*
* @serial
*/
private final long value;
Long
클래스 내부에는 저렇게 final
로 불변 value
를 가지고 있다. 그렇기 때문에 값을 바꾸는 행위는 사실 새로운 Long
객체를 한번 더 생성 해야만 가능한 것이다.
이렇게 의도치 않게, 불필요한 객체를 재생성해 낭비가 발생할 수 있다.
그렇다면 어떻게 코딩을 해야 할까?
primitive
타입을 사용해도 동작에 별다른 지장이 없는 경우엔 무조건 primitive
타입을 사용하자.
객체 생성 자체를 최대한 피해야 할까?
정답은 아니다. JVM에서 작은 객체를 생성하고 회수하는 게 그렇게 큰 일은 아니다.
그러나 몇가지 확실히 객체 생성이 안좋을 때가 있다.
- 첫째, 불필요한 객체 생성일 때
- ex1) 같은 정규표현식
Pattern
객체를 재사용해도 무관할 때 - ex2)
primitive
타입만 사용해도 되는 경우인데, 불필요한오토 박싱
이 일어나 낭비를 줄 때
- ex1) 같은 정규표현식
- 둘째, 부하가 많은 서버 환경일 때
- 이 경우엔 작은 낭비도 사용자들에 의해 큰 낭비가 되어버릴 수 있으니 내가 객체 생성을 새로 하고 있다는 것을 인지하고 있는 것이 좋다.
이후에 나오는 내용 중,
새로운 객체를 만들어야 한다면, 기존 객체를 재사용하지 마라
라는 내용도 있다. 무조건 xx해라. 라는 것은 없고 결국 상황에 맞는 코드를 사용하는 것이 가장 중요하다.
방어적 복사가 필요한 때 기존 객체를 재사용하는 것이 필요없는 객체를 반복 생성하는 것보다 훨씬 피해가 크다는 점을 분명히 인지해두어야 한다.
그렇다면 객체 풀을 만드는 것은 어떨까?
- 요즘 JVM의 가비지 컬렉터는 상당히 최적화가 잘되어서 사용자가 어설프게 만든 객체 풀보다는 훨씬 빠르다.
- 되려 코드의 복잡성만 증가할 수 있다.
'Java > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바, 쉽게 정리하기 - item8. finalizer와 cleaner 사용을 피하라 (0) | 2021.12.27 |
---|---|
이펙티브 자바, 쉽게 정리하기 - item7. 다 쓴 객체 참조를 해제하라 (0) | 2021.12.27 |
이펙티브 자바, 쉽게 정리하기 - item5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) | 2021.12.25 |
이펙티브 자바, 쉽게 정리하기 - item4. 인스턴스화를 막으려면 private 생성자를 사용하라 (0) | 2021.12.25 |
이펙티브 자바, 쉽게 정리하기 - item3. private 생성자나 열거 타입으로 싱글턴임을 보증하라 (0) | 2021.12.24 |