심심해서 자바 내용을 하나씩 찾아보다가 흥미로운 깃허브 내용을 하나 발견했다.
https://gist.github.com/benelog/b81b4434fb8f2220cd0e900be1634753
String 최적화 JDK 1.5
String 최적화 JDK 1.5. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
위 내용을 간단하게 요약하면 JDK 1.5 ~ 1.8 까지는 String 연산을 진행할 때 컴파일 시 StringBuilder를 생성해서 진행하고 JDK 9부터는 InvokeDynamic을 사용한다고 한다.
InvokeDynamic이 무슨 소리인지 우선 모르겠으니 JDK 1.8은 String 연산을 어떻게 처리하는지 확인해 보자.
a, b, c 세 개의 String 객체를 만들고 msg1, msg2, msg3를 각각 만들었다.
String a = "a";
String b = "b";
String c = "c";
String msg1 = a + b + c;
System.out.println(msg1);
내가 예상하기로는 아래처럼 각각의 문자열로 치환돼서 String 객체가 만들어진다고 예상했다.
String a = "a";
String b = "b";
String c = "c";
String msg1 = "a" + "b" + "c";
System.out.println(msg1);
위처럼 동작하면 String 객체인 "ab", "abc"가 만들어져서 메모리적으로 매우 비효율 적이라고 생각이 들었다.
그런데 바이트코드를 까보니 아래처럼 동작하더라.
L3에 해당하는 부분이 msg1 변수를 의미한다. 이때 NEW java/lang/StringBuilder를 통해 StringBuilder를 생성하고 append로 이어 붙인 뒤 toString을 통해 스트링으로 변환하는 것을 확인할 수 있다.
자바 코드로 변환시키면 아래의 모양을 갖추게 된다.
String msg1 = new StringBuilder().append(a).append(b).append(c).toString()
이미 자바는 StringBuilder를 통해 최적화를 알아서 진행하고 있었던 것이다. 만약에 동일한 명령을 반복한다면 어떻게 할까?
각 msg 변수마다 new StringBuilder를 계속 만들어서 사용하는 것을 확인할 수 있다. String 객체를 계속 생성하는 대신 StringBuilder를 계속 생성해서 문자열을 연결하는 것을 확인할 수 있다.
결국 반복이 일어나게 된다면 문자열 직접 연산은 좋지 않다는 판단이 들었다.
그러면 JDK 9부터는 이 방법이 어떻게 달라졌다는 소리일까?
기존의 StringBuilder를 통한 연결에서 StringConcatFactory의 makeConcatWithConstants 메서드로 옮긴 것을 확인할 수 있다.
makeConcatWithConstants 메서드는 아래와 같이 생겼다.
public static CallSite makeConcatWithConstants(MethodHandles.Lookup lookup,
String name,
MethodType concatType,
String recipe,
Object... constants) throws StringConcatException {
if (DEBUG) {
System.out.println("StringConcatFactory " + STRATEGY + " is here for " + concatType + ", {" + recipe + "}, " + Arrays.toString(constants));
}
return doStringConcat(lookup, name, concatType, false, recipe, constants);
}
우선 DEBUG가 false라면 doStringConcat 메서드를 동작시킨다.
private static CallSite doStringConcat(MethodHandles.Lookup lookup,
String name,
MethodType concatType,
boolean generateRecipe,
String recipe,
Object... constants) throws StringConcatException {
Objects.requireNonNull(lookup, "Lookup is null");
Objects.requireNonNull(name, "Name is null");
Objects.requireNonNull(concatType, "Concat type is null");
Objects.requireNonNull(constants, "Constants are null");
for (Object o : constants) {
Objects.requireNonNull(o, "Cannot accept null constants");
}
...
...
...
}
return new ConstantCallSite(mh.asType(concatType));
}
이후 new ConstantCallSite -> invokeStatic -> delegate -> invokeExact_MT -> makeSite -> linkToTargetMethod 등등... 수많은 메서드를 타고 생성 절차를 거치게 된다. (해당 부분에 대해 디버깅을 해봤는데 아래처럼 param을 이어 붙인다?라는 것 말고는 이해하기 어려운 내부 로직이 되게 많았다.)
어느 정도 디버깅을 통해 알아보려고 했지만 결국 세부 내용은 이해하지 못했다.
하지만 JDK 9부터는 invokedynamic을 통해 문자열을 동적으로 최적화해서 진행하게 된다는 것은 명확하다.
JDK 1.5 ~ 1.8 까지는 StringBuilder를 통해 컴파일 시에 고정적으로 처리했지만, JDK 9 이상부터는 invokedynamic을 통해 동적으로 적절한 핸들러를 찾고 문자열을 처리하게 된다.
또한 invokedynamic을 사용하기 때문에 람다에 대응할 수 있다는 장점을 가지고 있다.
그러면 JDK 1.8과 JDK 11에서 문자열을 반복해서 더했을 때 시간 차이는 얼마나 나는지 측정해 봤다.
JDK 1.8
public static void main(String[] args) throws IOException {
Long startTime = System.nanoTime();
String a = "1";
String b = "";
for(int i=0; i<100000; i++){
b += a;
}
Long endTime = System.nanoTime();
long elapsedTime = (endTime - startTime) / 1_000_000;
System.out.println("걸린 시간 : " + elapsedTime + " ms");
}
JDK 11
거의 시간은 7배가 줄은 것을 확인할 수 있다.
근데 한 가지 확실한 것은 문자열 연산을 반복해서 진행한다면 StringBuilder를 하나 만들어두고 사용하는 것이 훨씬 빠르다는 것이다.
아래처럼 StringBuilder를 하나 만들어두고 반복문을 돌리게 되면
StringBuilder sb = new StringBuilder();
Long startTime = System.nanoTime();
String a = "1";
for(int i=0; i<100000; i++){
sb.append(a);
}
굉장히 속도가 빠른 것을 확인할 수 있다.
결국 정리하면 자바에서 문자열 연산에 대해 최적화를 알아서 진행하고 있지만 최대한 지양하는 편이 좋다고 생각한다.
문자열 연산이 필요한 경우 StringBuilder나 StringBuffer를 통해 상황에 맞게 처리하는 게 좋다고 생각한다.
'CS지식' 카테고리의 다른 글
자바에서 가시성을 보장하는 volatile (0) | 2024.08.24 |
---|---|
스프링 시큐리티에서 세션 중복을 검사하는 방법 (0) | 2024.08.15 |
CSRF 공격을 막기 위한 CSRF 토큰 (0) | 2024.07.31 |
spring-oauth-client 라이브러리의 동작 흐름 정리 (0) | 2024.06.27 |