제리의 배움 기록

[Java] String + 연산 최적화 본문

자바

[Java] String + 연산 최적화

제리92 2021. 12. 2. 20:14

JDK 5 이전 버전에서는 String + 연산시, 매 연산마다 String 객체가 생성되는 비효율이 있었습니다.

String str1 = "a";
String str2 = str1+"b"+"c";

// => str1 객체 생성, str1+"b" 객체 생성, str1+"b"+"c" 객체 생성

그래서 아래와 같이 StringBuilder(혹은 StringBuffer)를 사용하여 중간단계 객체가 생성되지 않도록 권장되었었습니다.

String str1="abc"
String str2 = new StringBuilder(str1).append("b").append("c");

 

JDK 5 버전 부터 성능 향상을 목적으로 컴파일시 StringBuilder 혹은 StringBuffer로 변환되도록 변경되었습니다.

참고
JDK 5 docs - String

The Java language provides special support for the string concatenation operator ( + ), and for conversion of other objects to strings. String concatenation is implemented through the StringBuilder(or StringBuffer) class and its append method

StringBuilder(혹은 StringBuffer)을 이용하여 문자열을 더하면 + 연산을 하였을때보다 객체 생성비용과 메모리 사용을 줄일수 있었지만 완전히 문제를 해결한 것은 아니었습니다.

1)첫번째로는 StringBuilder와 StringBuffer는 생성자로 별도로 설정해주지 않으면 초기 capacity가 16 characters로 셋팅되는데 이는 적은 용량이여서 재할당으로 인한 추가 비용이 발생하기 쉬웠습니다.

참고
JDK 11 docs - StringBuffer
JDK 11 docs - StringBuilder

2)두번째로는 loop와 같은 상황에서 StringBuilder 객체가 계속해서 생성되는 일이 발생하게 됩니다.

 

JDK 9 버전 부터는 이를 개선하고자 대신에 StringConcatFactory.makeConcatWithConstants을 도입하였습니다.

jdk 9 docs - StringConcatFactory 참고

예시 코드를 작성하고 JDK8 버전과 JDK11 버전에서 각각 컴파일하여 바이트코드를 비교해 보겠습니다.

아래와 같이 String 배열을 선언하고, 반복문으로 + 연산을 수행하는 코드를 작성합니다.

public class RepeatMain {

    public void stringRepeat() {
        String[] strs = {"a", "b", "c"};
        String str = "";
        for (int i = 0; i < strs.length; ++i) {
            str += strs[i];
        }
    }
}

JDK8 버전과 JDK 11버전으로 각각 컴파일후 바이트코드를 아래와 같이 비교해보았습니다.
(컴파일 후, Intellij에서는 shift를 두번 누르면 뜨는 다이얼로그에서 show bytecode를 검색하여 볼 수 있습니다.)

L5 부분을 보시면 반복문 안에서 String의 + 연산을 JDK 8버전과 11버전에서 각각 다르게 처리하고 있는 것을 확인할 수 있습니다.

StringConcatFactory

public static CallSite makeConcatWithConstants​(MethodHandles.Lookup lookup,
                                               String name,
                                               MethodType concatType,
                                               String recipe,
                                               Object... constants)
                                        throws StringConcatException

StringConcatFactory는 JDK9 에서 도입된 것으로 makeConcatWithConstants 메서드에서 문자열 연결에 사용할 전략을 부트스트랩이 런타임에 MethodHandle로 전달해줍니다.

아래 6가지 전략 중에 MH_INLINE_SIZE_EXACT가 기본 전략입니다.

이 전략은, 1)전달된 arguments를 연결하여 byte 배열로 생성한 후, String의 private 생성자(JDK private API)를 이용하여 copy 없이 byte 배열을 전달하여 String객체를 반환합니다.

Djava.lang.invoke.stringConcat=<strategyName> 시스템 설정 값으로 전략을 변경할 수도 있습니다.

JDK 11 - StringConcatFactory.java 중 발췌

 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
    }

JDK 11 - StringConcatFactory.java

* <p>This strategy replicates what StringBuilders are doing: it builds the
     * byte[] array on its own and passes that byte[] array to String
     * constructor. This strategy requires access to some private APIs in JDK,
     * most notably, the read-only Integer/Long.stringSize methods that measure
     * the character length of the integers, and the private String constructor
     * that accepts byte[] arrays without copying. While this strategy assumes a
     * particular implementation details for String, this opens the door for
     * building a very optimal concatenation sequence. This is the only strategy
     * that requires porting if there are private JDK changes occur.

전체 바이트 코드

  • jdk8로 빌드한 바이트 코드
// class version 52.0 (52)
// access flags 0x21
public class RepeatMain {

  // compiled from: RepeatMain.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LRepeatMain; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public stringRepeat()V
   L0
    LINENUMBER 4 L0
    ICONST_3
    ANEWARRAY java/lang/String
    DUP
    ICONST_0
    LDC "a"
    AASTORE
    DUP
    ICONST_1
    LDC "b"
    AASTORE
    DUP
    ICONST_2
    LDC "c"
    AASTORE
    ASTORE 1
   L1
    LINENUMBER 5 L1
    LDC ""
    ASTORE 2
   L2
    LINENUMBER 6 L2
    ICONST_0
    ISTORE 3
   L3
   FRAME APPEND [[Ljava/lang/String; java/lang/String I]
    ILOAD 3
    ALOAD 1
    ARRAYLENGTH
    IF_ICMPGE L4
   L5
    LINENUMBER 7 L5
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    ALOAD 2
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ALOAD 1
    ILOAD 3
    AALOAD
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    ASTORE 2
   L6
    LINENUMBER 6 L6
    IINC 3 1
    GOTO L3
   L4
    LINENUMBER 9 L4
   FRAME CHOP 1
    RETURN
   L7
    LOCALVARIABLE i I L3 L4 3
    LOCALVARIABLE this LRepeatMain; L0 L7 0
    LOCALVARIABLE strs [Ljava/lang/String; L1 L7 1
    LOCALVARIABLE str Ljava/lang/String; L2 L7 2
    MAXSTACK = 4
    MAXLOCALS = 4
}
  • jdk11로 빌드한 바이트 코드
// class version 55.0 (55)
// access flags 0x21
public class RepeatMain {

  // compiled from: RepeatMain.java
  // access flags 0x19
  public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LRepeatMain; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public stringRepeat()V
   L0
    LINENUMBER 4 L0
    ICONST_3
    ANEWARRAY java/lang/String
    DUP
    ICONST_0
    LDC "a"
    AASTORE
    DUP
    ICONST_1
    LDC "b"
    AASTORE
    DUP
    ICONST_2
    LDC "c"
    AASTORE
    ASTORE 1
   L1
    LINENUMBER 5 L1
    LDC ""
    ASTORE 2
   L2
    LINENUMBER 6 L2
    ICONST_0
    ISTORE 3
   L3
   FRAME APPEND [[Ljava/lang/String; java/lang/String I]
    ILOAD 3
    ALOAD 1
    ARRAYLENGTH
    IF_ICMPGE L4
   L5
    LINENUMBER 7 L5
    ALOAD 2
    ALOAD 1
    ILOAD 3
    AALOAD
    INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;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:
      "\u0001\u0001"
    ]
    ASTORE 2
   L6
    LINENUMBER 6 L6
    IINC 3 1
    GOTO L3
   L4
    LINENUMBER 9 L4
   FRAME CHOP 1
    RETURN
   L7
    LOCALVARIABLE i I L3 L4 3
    LOCALVARIABLE this LRepeatMain; L0 L7 0
    LOCALVARIABLE strs [Ljava/lang/String; L1 L7 1
    LOCALVARIABLE str Ljava/lang/String; L2 L7 2
    MAXSTACK = 4
    MAXLOCALS = 4
}

[참고]

JEP 280 Indify String Concatenation
string-concate-dynamic

Comments