JDK 버전 별 String '+' 연산

ifi9·2023년 3월 6일
0

JDK 9 이전

StringBuilder, StringBuffer

String '+' 연산을 할 때면 새로운 객체가 생성되기 때문에 좋지 않으므로, 이를 해결하기 위해 가변성을 가지는 StringBuilderStringBuffer를 사용하면 성능 또한 좋다는 글을 많이 봤었다.

String str = "a";
str = str + "b" + "c";

이와 같은 단순 '+'연산보다는

String str = "a";
String sb = new StringBuilder(str).append("b").toString();

StringBuilder 혹은 StringBuffer를 쓰라는 내용들이었다.
관련된 내용의 글들을 보다 보니 JDK 버전 올라가면서 자동으로 변환해 준다더라하는 내용을 본 적이 있었는데 그때는 별다른 궁금증 없이 넘어갔었다. 그러다가 최근에 문득 기억나서 이야기를 하게 되었는데 좀 더 자세히 알아보고 싶어져서 찾아보게 되었다.

JDK 5 ~

String Concatenation Operator +
JDK 5부터 컴파일 시에 String '+' 연산이 StringBuilder 혹은 StringBuffer로 자동 변환이 되기 시작하였다고 한다. 관련 docs를 확인하고 싶었는데 oracle docs가 7 미만은 Releases 내역들이 안 보이게 된 건지 해서 직접 보지는 못했다.

우선 JDK 8 버전으로 아래의 코드를 컴파일을 하고 어떻게 변환이 되는지 확인을 해보았다.

String str = "a";
str = str + "b" + "c";

StringBuilder 객체가 생성되며 "bc"가 더해지는 것이 확인 가능하다.
StringBuilder 객체가 생성되는 것을 보고 반복문을 실행하면 어떨까 싶어서 아래 소스 코드도 확인해 보았다.

String str = "a";
for (int i = 0; i < 20; i++) {
	str = str + "1";
    str = str + "2";
}

컴파일 된 소스의 일부분이다. 매번 새로운 StringBuilder 객체가 생성이 되는 아쉬운 점이 있다는 것을 알 수 있었다.

JDK 9

makeConcatWithConstants()

JDK 9부터는 위의 아쉬운 점을 개선하기 위해 StringConcatFactory.classmakeConcatWithConstants()를 사용하기 시작했다.

다시 똑같은 코드로 확인을 해본다면 이러하다. JDK 11을 사용하여 컴파일 하였다.

INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;)Ljava/lang/String; [
	// handle kind 0x6 : INVOKESTATIC
    	java/lang/invoke/StringConcatFactory.makeConcatWithConstants(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
	// arguments:
	"\u0001123"
]

캡처가 너무 작게 보여서 컴파일 된 코드도 긁어왔다. 단순 '+' 연산과 반복문 모두 StringBuilder 객체 생성 대신 makeConcatWithConstants() 메서드가 사용되고 있다.

StringConcatFactory

위 클래스에는 총 6개의 전략이 존재하며, 이 중에서 MH_INLINE_SIZED_EXACT가 default 값으로 되어있다. 아래는 StringConcatFactory.class의 소스 코드 일부이다.

// StringConcatFactory.class 

...

private enum Strategy {
	/**
    * Bytecode generator, calling into {@link java.lang.StringBuilder}.
    */
    BC_SB,

	/**
    * Bytecode generator, calling into {@link java.lang.StringBuilder};
    * but trying to estimate the required storage.
    */
    BC_SB_SIZED,

	/**
    * Bytecode generator, calling into {@link java.lang.StringBuilder};
    * but computing the required storage exactly.
    */
    BC_SB_SIZED_EXACT,

	/**
    * MethodHandle-based generator, that in the end calls into {@link java.lang.StringBuilder}.
    * This strategy also tries to estimate the required storage.
    */
    MH_SB_SIZED,

	/**
    * MethodHandle-based generator, that in the end calls into {@link java.lang.StringBuilder}.
    * This strategy also estimate the required storage exactly.
    */
    MH_SB_SIZED_EXACT,

	/**
    * MethodHandle-based generator, that constructs its own byte[] array from
    * the arguments. It computes the required storage exactly.
    */
    MH_INLINE_SIZED_EXACT
}

...

private static MethodHandle generate(Lookup lookup, String className, MethodType mt, Recipe recipe) throws StringConcatException {
	try {
    	switch (STRATEGY) {
        	case BC_SB:
            	return BytecodeStringBuilderStrategy.generate(lookup, className, mt, recipe, Mode.DEFAULT);
            case BC_SB_SIZED:
            	return BytecodeStringBuilderStrategy.generate(lookup, className, mt, recipe, Mode.SIZED);
            case BC_SB_SIZED_EXACT:
            	return BytecodeStringBuilderStrategy.generate(lookup, className, mt, recipe, Mode.SIZED_EXACT);
            case MH_SB_SIZED:
            	return MethodHandleStringBuilderStrategy.generate(mt, recipe, Mode.SIZED);
            case MH_SB_SIZED_EXACT:
            	return MethodHandleStringBuilderStrategy.generate(mt, recipe, Mode.SIZED_EXACT);
            case MH_INLINE_SIZED_EXACT:
            	return MethodHandleInlineCopyStrategy.generate(mt, recipe);
            default:
            	throw new StringConcatException("Concatenation strategy " + STRATEGY + " is not implemented");
        }
    } catch (Error | StringConcatException e) {
        // Pass through any error or existing StringConcatException
        throw e;
    } catch (Throwable t) {
        throw new StringConcatException("Generator failed", t);
    }
}

...

각각의 6가지 전략들을 BytecodeStringBuilderStrategy, MethodHandleStringBuilderStrategy, MethodHandleInlineCopyStrategy라는 핸들러들에 알맞은 것에 매핑 시킨다.

BC_SB_XXXMH_SB_XXX들은 StringBuilder를 사용하는 방식들인 반면에 default 전략인 MH_INLINE_SIZED_EXACT는 필요한 저장 공간을 미리 계산하고, JDK private API를 이용하여 copy를 하지 않고 byte[]을 전달하여 객체를 반환하는 방식이다.

이러한 전략들은 VM Arguments(options) 값으로 Djava.lang.invoke.stringConcat=<strategyName>을 정하여 전략을 변경할 수 있다. (JDK 15 이전까지만 가능)

JDK 15

변경점으로는 Strategy가 제거되며 기존 Default 전략이었던 MH_INLINE_SIZED_EXACT 하나로만 되게 바뀌었다. 그래서 위 VM options 선택도 못 하게 되었다.
자세한 것은 OpenJDK 15 commit 내역에서 확인이 가능하다.

디컴파일

자바 바이트 코드로 보는 것보다 디컴파일을 하여 자바 소스 코드로 본다면 더 직관적이어서 좋겠다라는 피드백과 함께 링크를 받았었다.
미처 생각을 못 했던 부분이라 흥미롭고 궁금해서 해보게 되었는데 링크와는 다른 결과를 확인할 수 있었다.

// str = str + "b" + "c";
public class StringOperationSample {
    public StringOperationSample() {
    }

    public static void main(String[] args) {
        String str = "a";
        str = str + "bc";
    }
}

// 반복문
public class StringOperationSample {
    public StringOperationSample() {
    }

    public static void main(String[] args) {
        String str = "a";

        for(int i = 0; i < 20; ++i) {
            str = str + "1";
            str = str + "2";
        }

    }
}

JDK 8, JDK 11 모두 위의 소스 코드 내용이 똑같은 결과가 나와서 링크에서 보았던 소스 코드 간의 차이는 확인이 불가능했다.
이유로는 디컴파일러마다 알고리즘의 차이, 버전의 차이 등으로 인해서 이러한 차이를 보인 듯하다. 그리고 링크에서 사용된 JAD라는 디컴파일러는 오래전부터 개발이 중단된 것이라고 한다.
그래도 JAD로 디컴파일을 해보니 링크와 같은 결과를 얻을 수는 있었다. 하지만 지원 중단되어서 그 이후 JDK 버전들은 확인 불가능하다는 점도 있고, 최신 버전의 Intellij Decompiler나 JD-Core에 비해 최적화 성능도 떨어질 것이라고 생각되니 굳이 다음부터는 사용을 하지 않을 듯하다.

내가 사용 중인 디컴파일러로는 처음 기대했던 StringBuilder의 사용은 디컴파일 시에는 확인이 불가능하였지만, 실제 필요로 인해 디컴파일을 하게 된다면 더 깔끔한 코드로 동작 방식을 비교적 쉽게 확인이 가능하겠다는 것은 알 수 있었다.

그래서 어떤 것을 사용할 건데?

솔직히 뭐가 더 좋을지는 모르겠다. 그렇지만 내 생각으로는 이러하다. 여태 가독성에 대한 중요성을 여러 번 들어왔다.
그래서 많은 문자열을 결합해야 할 경우에는 StringBuilder 혹은 StringBuffer를 사용하는 것이 좋을 것 같다. 그중에서 멀티 스레드 환경이라면 두 개 중에 StringBuffer를 택하여 사용할 듯하다.
물론 짧은 문자열이라면 단순 '+' 연산하는 것이 편리하고 효율적일 것이니 그대로 사용할 것이다.

0개의 댓글