개발자는 기록이 답이다

[Java] String 덧셈 연산의 컴파일 최적화에 대해 알아보자 본문

언어/Java

[Java] String 덧셈 연산의 컴파일 최적화에 대해 알아보자

slow-walker 2024. 1. 12. 15:04

 

자바의 신 1권 15장 String에 대해 공부하면 아래와 같은 문장이 나온다.

JDK 5이상에서는 여러분들이 String의 더하기 연산을 할 경우, 컴파일 할때 자동으로 해당 연산을 StringBuilder로 변환해준다.
따라서, 일일이 더하는 작업을 변환해 줄 필요는 없으나, for루프와 같이 반복 연산을 할 때에는 자동으로 변환을 해주지 않으므로 꼭 StringBuilder가 필요하다.

 

이게 무슨 말인지 한 번 소스코드를 컴파일하고 바이트코드를 확인해보자.

 

 

1. String은 불변 객체

 

컴파일 최적화에 대해 알아보기 전에 간단하게 String의 배경지식을 알고 있어야 한다.

자바 문서를 보면 String은 상수라서, 객체가 생성된 이후에는 값이 변하지 않는다고 써져있는 걸 볼 수있다

 * Strings are constant; their values cannot be changed after they
 * are created. String buffers support mutable strings.
 * Because String objects are immutable they can be shared.
 
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

 

 

엄밀히 말하면 문자열이 직접 변수에 저장되는게 아니라,  문자열은 String 객체로 생성되고 변수는 String 객체를 참조한다.

아래 코드에서 "자바" 가 문자열 객체이고, hobby 변수에서는 해당 문자열 객체를 참조하는 것이다.

String hobby = "자바";

 

다시 말해서, hobby 변수는 스택 영역에 생성되고, 문자열 리터럴인 "자바"는 힙 영역에 String 객체로 생성된다.

그리고 hobby 변수에는 String 객체의 번지 값이 저장된다.

 

혼자공부하는 자바 5-1

 

그리고 리터럴로 선언된 문자열은 String Constant Pool에 저장되고, new 연산자를 사용했을 경우 Heap영역에 객체가 저장된다.

이와 관련해서 포스팅을 여기에 해놨으니 잘 모르면 다시 참고해보자.

2023.08.21 - [언어/Java] - Java, String의 constant pool과 Heap의 차이

 

2. 리터럴 선언이랑 생성자 선언이 다르게 동작할까?

 

아래 소스코드를 바이트코드로 변환해서 봐보자. ( JDK 1.8 버전으로 테스트 했다 )

public class Test {
    public static void main(String[] args) {
        String a = "a";
        String b = "b";
        String result = a+b;
    }
}

 

바이트코드는 javac명령어를 통하지 않고도 인텔리제이의 [view탭 → show bytecode] 을 통해 확인할 수 있다.

 

 

아래 명령어들은 opCode로서 JVM내에서 사용하는 명령어이고, 오라클 공식 문서에 더 자세한 설명이 나와있다.

바이트코드를 해석하는 방법은 아래와 같다.

 

  • 첫번째 라인 ( String a = "a"; )
    • LDC : 상수 풀(Constant Pool)에서 문자열 "a"를 스택에 로드
    • ASTORE 1: 스택의 값을 로컬 변수 1 (a)에 저장
  • 두번째 라인 ( String b = "b"; )
    • LDC : 상수 풀(Constant Pool)에서 문자열 "b"를 로드
    • ASTORE 1: 스택의 값을 로컬 변수 2(b)에 저장

  • 세번째 라인 ( String result = a+b; )
    • NEW java/lang/StringBuilder: 새로운 StringBuilder 인스턴스를 생성
    • DUP: 생성된 StringBuilder 인스턴스를 스택에 복제
    • INVOKESPECIAL java/lang/StringBuilder.<init> ()V: StringBuilder 클래스의 기본 생성자를 호출하여 초기화하는 명령어(INVOKESPECIAL 명령은 클래스의 private 메소드 또는 생성자를 호출할 때 사용한다)
    • ALOAD 1: 로컬 변수 1 (a)의 값을 스택에 로드
    • INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;:
      StringBuilder append 메소드를 호출하여 로드한 값인 "a"를 추가
    • ALOAD 1: 로컬 변수 2 (b)의 값을 스택에 로드
    • INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;:
      다시 
      append 메소드를 호출하여 로드한 값을 추가
    • INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;: 
      StringBuilder 인스턴스의 toString 메소드를 호출하여 최종 문자열을 생성
    • ASTORE 3: 최종 문자열을 로컬 변수 3 (result)에 저장

그렇다면 리터럴이 아닌 new연산자를 통해 생성자로 만든 String 객체는 어떻게 될까?

public class Test {
    public static void main(String[] args) {
        String a = new String("a");
        String b = new String("b");
        String result = a+b;
    }
}

 

아래 사진을 보면 리터럴로 선언했을때는 바로 상수풀에 문자열이 로드되었는데,

생성자를 사용할때는 NEW java/lang/String을 통해 String 객체를 먼저 생성하는 것을 볼 수 있다.

  • NEW java/lang/String: String 클래스의 새로운 인스턴스를 생성하는 명령어
    "a"와 "b" 문자열 리터럴을 갖는 새로운 String 인스턴스를 생성
  • DUP: 생성된 String 인스턴스를 스택에 복제
  • INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V: String 클래스의 생성자를 호출하여 초기화
  • ASTORE 1 및 ASTORE 2: 생성된 String 인스턴스를 로컬 변수 1 (a) 및 로컬 변수 2 (b)에 저장

리터럴과 생성자 선언의 차이는 저장되는 공간의 차이일 뿐,

두 방식 모두 다 덧셈 연산할때 StringBuilder객체가 자동으로 생겨서, append()메소드를 호출해서 결합해주는 컴파일러 최적화가 일어나는 것을 볼 수 있다.

 

3. 그렇다면 항상 StringBuilder로 컴파일러 최적화가 되는 걸까?

 

결론부터 말하자면 아니다.

 

다른 블로그를 참고했을 때, 한줄 선언인지 2줄 이상 선언인지에 따라 다르다고 되어있는데, 내 생각은 좀 다르다.

StringBuilder를 사용해서 컴파일러 최적화가 되는 기준은 "변수에 할당했는지" 여부에 달렸다.

 

아래처럼 리터럴로 모두 덧셈 연산을 한다고 한다면, 두 식 모두 각각 StringBuilder의 append가 4번 호출되는걸까?

// 한 줄 선언

public class Test {
    public static void main(String[] args) {
        String a = "a" + "b" + "C" + "d";
        String b = "A" + "B" + "c" + "D";
    }
}

// 두 줄 선언
public class Test {
    public static void main(String[] args) {
        String a = "a" + "b" + "C" + "d";
        String b = "A"
                + "B"
                + "c" + "D" + "!";
    }
}

 

아래 사진을 보면 몇 줄로 리터럴 선언을 하던간에 StringBuilder가 전혀 사용되지 않은 것을 알 수 있다.

 

String a = "a" + "b" + "C" + "d";

 

"좌측항"은 변수 a이고, "우측항"은 문자열 연산이다.

 

여기서 StringBuilder와 관련된 컴파일러 최적화가 아닌 다른 최적화가 또 일어난다.

 

컴파일러가 이미 우측항에 있는 리터럴들을 연산과정에서 결합하는 최적화를 하는 것이다.

즉, "a" + "b" + "C" + "d"는 컴파일 시에 "abCd"로 최적화된다.

 

이렇게 최적화된 결과("abCd"문자열)은 상수 풀에 저장되고, LDC 명령어를 통해 상수 풀에 있는 값을 스택에 로드한다.

그리고 ASTORE명령어를 사용하여 스택에 로드된 "abCd"문자열"을 로컬 변수 a에 저장한다.

 

그렇다면 new 연산자는 어떨지 시험해보자.

// new 연산자만

public class Test {
    public static void main(String[] args) {
        String a = new String("a") + new String("b") + new String("C") + new String("d");
        String b = "A" + "B" + "c" + "D";
    }
}

// 리터럴 + new 연산자

public class Test {
    public static void main(String[] args) {
        String a = new String("a") + "b" + new String("c") +"D";
    }
}

 

사진을 보면 new 연산자를 사용하는 순간 append를 4번 호출하는게 눈에 보일 것이다.

 

4. 버전 별로 다른 컴파일 최적화

String에 대한 최적화 변경은 JDK5, JDK9버전에 각각 변경되었다.

흔히 덧셈 최적화를 이야기 하는 것은 1.5 버전에서 신규 업데이트 된 StringBuilder 최적화이다.

 

해당 내용은 이 문서 보면 확인할 수 있다. 

java 9버전부터는 StringConcatnateFactory 방식으로 변경되었는데 꽤 복잡한 최적화 방식이다.

JEP에서 확인할 수 있으며, 코드레벨로 꽤 자세하게 나오니 시간내서 읽어보자.

 

바이트코드는 어떻게 나오는지 한번 확인해보자. ( JDK 17버전을 테스트 )

 

바이트코드를 보면 알 수 있듯이, StringBuilder라는 글자가 전혀 보이지 않고

StringConcatFactory 클래스의 makeConcatWithConstants 메서드를 호출하는 것을 볼 수 있다.

 

일반적인 Java 문자열 최적화를 이야기할 때에는 StringConcatFactory가 아닌 StringBuilder 최적화를 얘기하는게 좋다.

물론 InvokeDynamic 방식에 대한 설명을 능숙하게 할 수 있다면 상관없지만 말이다.

 

5. 결론

자바 String타입으로 덧셈 연산하는 과정의 컴파일 최적화하는 것에 다시 문장으로 정리해보자.


▶ 리터럴로 선언하던, 생성자로 선언하던 변수에 할당한 이후에 덧셈 연산을 할 경우

상수풀에 로드된 값들을 새로운 Stirng 객체 생성 없이 StringBuilder클래스의 append메소드로 연결해주는 최적화가 이뤄진다.

 

 변수에 할당하기 전 리터럴로 덧셈 연산을 한다면

이미 컴파일러가 연산하는 과정에서 문자를 결합하는 최적화가 이뤄진 후에 상수풀에 로드된다.
 
추가적으로 자바 버전별로 덧셈 연산에서의 과정이 다른데 JDK 5버전에서는 StringBuilder로, JDK9버전 부터는 StringConcatFactory 클래스의 makeConcatWithConstants메서드를 사용한다.

 

 

참고한 블로그 : 

 

[조금 더 깊은 Java] String 과 String Constant Pool

[조금 더 깊은 Java] Java Bytecode 를 알아보자 (자바를 컴파일하면 어떤 일이 일어날까?)

String은 항상 StringBuilder로 변환될까?

[Java] String과 String 연결의 최적화

[Java] String + 연산 최적화

[JAVA] String 과 그 메모리에 대한 고찰