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

2022. 5. 18. 15:15JAVA/Effective Java

1. 불변 객체의 재사용

똑같은 기능의 객체를 매번 생성하기 보다는 객체 하나를 재사용하는 편이 날을때가 많습니다. 특히 불변 객체는 언제든지 재사용할 수 있습니다. 

String s1 = "hello"; // 권장하는 방법, 하나의 String 인스턴스를 사용함	
String s2 = "hello";
String s3 = new String("hello") // 따라하지 말것, 쓸데없는 인스턴스를 생성함

위 코드를 그림으로 표현하면 다음과 같습니다.

위 그림을 보면 s1과 s2는 String Pool에 있는 hello 문자열 리터럴을 가리키고 s3 같은 경우는 인스턴스를 새로 생성했기 때문에 String pool 영역이 아닌 Heap 영역에 생성됩니다. 따라서 s3과 같이 인스턴스를 생성하지 말고 String Pool에 리터럴을 생성 또는 참조하는 것이 더 효율적입니다. 

 

여기서 주목할점은 s1, s2, s3을 비교시 s1과 s2는 같은 객체이고 s3는 다른 객체이기 때문에 비교연산시 다르게 나옵니다. 다음은 문자열 리터럴 객체와 String 생성자로 생성한 객체를 비교한 예제입니다.

	@Test
	void StringTest() {
		String s1 = "hello";	// heap.StringPool에 있는 hello를 가리킴
		String s2 = "hello";	// heap.StringPool에 있는 hello를 가리킴
		String s3 = new String("hello"); // heap 영역에 String 인스턴스를 생성함
		String s4 = new String("hello"); // heap 영역에 String 인스턴스를 생성함
		String s5 = new String("hello").intern(); // heap 영역에 생성한 String 인스턴스를 StringPool에 등록
		
		
		assertTrue(s1 == s2);
		assertFalse(s3 == s4);
		assertFalse(s1 == s3);
		assertTrue(s1 == s5);
		
	}

 

2. 생성 비용이 비싼 객체

생성 비용이 비싼 객체는 캐싱하여 재사용하기를 권합니다. 예를 들어 주어진 문자열이 유요한 로마 숫자인지를 확인하는 메서드를 작성한다고 가정합니다. 다음은 정규표현식을 활용한 예제입니다.

public static boolean isRomanNumberalSlow(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.matches는 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서는 반복해서 사용하기엔 적합하지 않습니다. 왜냐하면 String.matches 메서드 내부에서 만드는 정규표현식용 Pattern 인스턴스는, 한번 사용하고 버려져서 곧바로 가비지 컬렉션의 대상이 되기 때문입니다. Pattern 인스턴스는 입력받은 정규표현식에 해당하는 유한 상태 머신을 생성하기 때문에 인스턴스 생성 비용이 높기 때문에 String.matches 메서드를 호출하여 검사하면 매우 비효율적입니다.

 

위 문제를 해결하기 위해서는 정규표현식을 표현하는 불변 객체인 Pattern 인스턴스를 클래스 초기화 과정에서 직접 생성해 캐싱해두고 나중에 메서드가 호출될때마다 이 인스턴스를 재사용하면 됩니다.

	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 isRomanNumberalFast(String s) {
		return ROMAN.matcher(s).matches();
	}

 

두가지 방식의 처리 속도 결과

	int numReps = 1000000;
	
	// 수행속도 : 3.419s
	@Test
	void isRomanNumberalSlowTest() {
		String s = "MCMLXXVI";
		boolean b = false;
		for(int j = 0; j < numReps; j++) {
			b ^= RomanNumberals.isRomanNumberalSlow(s); 
		}
	}
	
	// 수행속도 : 0.530s
	@Test
	void isRomanNumberalFastTest() {
		String s = "MCMLXXVI";
		boolean b = false;
		for(int j = 0; j < numReps; j++) {
			b ^= RomanNumberals.isRomanNumberalFast(s); 
		}
	}
  • String.matches 메서드를 호출하여 처리한 결과 : 3.419s
  • Pattern 인스턴스를 재사용하여 처리한 결과 : 0.530s

 

3. 오토박싱

불필요한 객체를 만들어내는 또다른 예시로 오토박싱(auto boxing)을 들수 있습니다. 오토박싱은 프로그래머가 기본 타입과 박싱된 기본 타입을 섞어 쓸때 자동으로 상호 변환해주는 기술입니다. 하지만 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아닙니다.

 

다음 코드는 모든 양의 정수의 총합을 구하는 메서드입니다.

	public static long sum() {
		Long sum = 0L;	// 기본타입이 아닌 Long 클래스
		
		for(long i = 0; i <= Integer.MAX_VALUE; i++) {
			sum += i;	// sum이 unboxing되어 i와 연산이 되고 연산후에 Autoboxing되어 Long 타입으로 변환됨
						// i = 1; sum = 0L;
						// sum = sum + 1;
						// sum = sum.intValue() + 1;	// unboxing
						// sum = 2;
						// sum = new Long(2); AutoBoxing
		}
		return sum;
	}

sum 변수는 Long 클래스 타입(박싱 타입)이기 때문에 기본 타입과 연산시 unboxing과 autoboxing이 되기 때문에 연산 과정에서 인스턴스가 생성되어 다시 저장됩니다. 위 메서드의 수행결과 11초가 경과됩니다. 하지만 Long 클래스 타입을 long 기본 데이터 타입으로 변경하면 1초정도로 개선됩니다.

 

위 예제를 통하여 박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의해야 합니다.

 

References

source code : https://github.com/yonghwankim-dev/effective_java/tree/master/src/role6
[도서] effective java
Java에서 String과 new String()의 차이는?
[Effective-Java] Item6 - 불필요한 객체 생성을 피하라