String

Posted by yunki kim on February 26, 2022

  Java의 String은 두 가지 방법으로 생성할 수 있다. 하나는 new를 통한 생성이고 하나는 리터럴을 통한 생성이다.

1
2
String str1 = "hello"// 리터럴을 사용한 생성
String str2 = new String("hello"); // new를 통한 생성
cs

  이 두 방식은 겉보기에는 같지만 리터럴은 String 값이 Heap 메모리 내의 Constant Pool에 저장되어 재사용 된다는 차이가 있다. 따라서 두 방식으로 할당한 같은 문자열들을 비교하면 new 연산은 주소값이 다르기 때문에 다음과 같은 결과를 얻는다.

1
2
3
4
5
6
7
8
9
10
11
12
// 리터럴을 사용한 선언
String str1 = "hello";
String str2 = "hello";
// String Constant Pool에 있는 같은 "hello"를
// 가르킨다.
System.out.println(str1 == str2); // true
 
// new를 사용한 선언
String str1 = new String("hello");
String str2 = new String("hello");
// str1과 str2는 서로 다른 객체다.
System.out.println(str1 == str2); // false
cs

  new 연산을 사용해 생성한 String 객체는 같은 값이 String Pool에 존재해도 Heap 영역 내 별도의 객체를 가르킨다.

  이를 바이트 코드를 통해 확인해 보면 리터럴을 사용한 선언은 다음과 같이 new 연산을 하지 않는 것을 알 수 있다.

1
2
3
4
L0
    LINENUMBER 9 L0
    LDC "hello"
    ASTORE 1
cs

  이에 반해 new를 통한 할당은 new를 사용하면 새로운 객체를 생성하는 것을 알 수 있다.

1
2
3
4
5
6
7
L0
    LINENUMBER 17 L0
    NEW java/lang/String
    DUP
    LDC "hello"
    INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V
    ASTORE 1
cs

  Constant Pool은 Java Class File의 구성 항목 중 하나이며 리터럴 상수 값을 저장하는 곳이다. 여기에는 String, 모든 종류의 숫자, 문자열, 식별자 이름, Class, method에 대한 참조 같은 값이 포함되 있다.

스트링 덧셈 연산

  스트링을 결합하기 위해 사용되는 + 연산은 자바 1.5 이전에는 concat과 같은 방식으로 동작했기 때문에 메모리 낭비가 심했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public String concat(String str) {
    if (str.isEmpty()) {
        return this;
    }
    if (coder() == str.coder()) {
        byte[] val = this.value;
        byte[] oval = str.value;
        int len = val.length + oval.length;
        byte[] buf = Arrays.copyOf(val, len);
        System.arraycopy(oval, 0, buf, val.length, oval.length);
        return new String(buf, coder);
    }
    int len = length();
    int olen = str.length();
    byte[] buf = StringUTF16.newBytesFor(len + olen);
    getBytes(buf, 0, UTF16);
    str.getBytes(buf, len, UTF16);
    return new String(buf, UTF16);
}
cs

  이때문에 두 개의 스트링을 결합하기 위해선 3 개의 인스턴스를 생성했다. 

1
2
3
4
"hello" + "world"
// 1. "hello" 인스턴스
// 2. "world" 인스턴스
// 3. "hello world" 인스턴스
cs

  이런 방식은 메모리 낭비가 심했기 때문이 이를 최적화 하기 위해 StringBuilder와 StringBuffer가 추가 되었다.

StringBuilder의 append를 사용하면 문자열 결합에 대해 하나의 메모리 주소만 갖는다. 그 이유는 append가 위에서 본 concat과는 다르게 새로운 객체를 생성하지 않기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
 * Appends the specified string to this character sequence.
 * <p>
 * The characters of the {@code String} argument are appended, in
 * order, increasing the length of this sequence by the length of the
 * argument. If {@code str} is {@code null}, then the four
 * characters {@code "null"} are appended.
 * <p>
 * Let <i>n</i> be the length of this character sequence just prior to
 * execution of the {@code append} method. Then the character at
 * index <i>k</i> in the new character sequence is equal to the character
 * at index <i>k</i> in the old character sequence, if <i>k</i> is less
 * than <i>n</i>; otherwise, it is equal to the character at index
 * <i>k-n</i> in the argument {@code str}.
 *
 * @param   str   a string.
 * @return  a reference to this object.
 */
public AbstractStringBuilder append(String str) {
    if (str == null) {
        return appendNull();
    }
    int len = str.length();
    ensureCapacityInternal(count + len);
    putStringAt(count, str);
    count += len;
    return this;
}
 
// 이 StringBuilder 내의 append 메서드는 위 메서드의 override 이다.
@Override
@HotSpotIntrinsicCandidate
public StringBuilder append(String str) {
    super.append(str);
    return this;
}
cs

  이에 따라 + 연산도 내부적으로 StringBuilder의 append를 사용하게 되어 메모리 효율이 증가했다. java 8에서 두 스트링을 더하는 연산을 바이트 코드로 보면 다음과 같이 append를 사용함을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
String str1 = "hello";
String str2 = "world";
String str3 = str1 + str2;
// 위 연산을 바이트 코드로 보면 다음과 같다
L0
    LINENUMBER 17 L0
    LDC "a"
    ASTORE 1
   L1
    LINENUMBER 18 L1
    LDC "b"
    ASTORE 2
   L2
    LINENUMBER 19 L2
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    ALOAD 1
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    ALOAD 2
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    ASTORE 3
cs

  하지만 java 11 부터는 더이상 String의 + 연산에 StringBuilder를 사용하지 않는다. 그 이유를 이해하기 위해 우선 다음과 같은 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
String str4 = "";
for (int i = 0; i < 10; i++) {
    str4 += i;
}
// 위 코드에서 for loop를 바이트 코드로 보면 다음과 같다.
L1
 LINENUMBER 18 L1
 ICONST_0
 ISTORE 2
L2
 FRAME APPEND [java/lang/String I]
 ILOAD 2
 BIPUSH 10
 IF_ICMPGE L3
L4
 LINENUMBER 19 L4
 NEW java/lang/StringBuilder
 DUP
 INVOKESPECIAL java/lang/StringBuilder.<init> ()V
 ALOAD 1
 INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
 ILOAD 2
 INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
 INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
 ASTORE 1
L5
 LINENUMBER 18 L5
 IINC 2 1
 GOTO L2
L3
 LINENUMBER 21 L3
 FRAME CHOP 1
 RETURN
cs

  위 for loop를 바이트 코드로 변환 했을 때 loop 내부에서 지속적으로 StringBuilder를 생성해주는 것을 볼 수 있다. 따라서 성능 문제가 있었다. 따라서 자바 11 부터는 StringConcatFactory.makeConcatWithConstants를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
L1
 LINENUMBER 18 L1
 ICONST_0
 ISTORE 2
L2
 FRAME APPEND [java/lang/String I]
 ILOAD 2
 BIPUSH 10
 IF_ICMPGE L3
L4
 LINENUMBER 19 L4
 ALOAD 1
 ILOAD 2
 INVOKEDYNAMIC makeConcatWithConstants(Ljava/lang/String;I)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 1
L5
 LINENUMBER 18 L5
 IINC 2 1
 GOTO L2
L3
 LINENUMBER 21 L3
 FRAME CHOP 1
 RETURN
cs

StringBuilder와 StringBuffer

  StringBuffer는 StringBuilder와 달리 thread-safe하다. 하지만 이로 인해 느리다. 게다가 String 덧셈 연산을 thread-safe하게 할 일이 거의 없다. 다음은 StringBuffer의 일부 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// synchronized로 떡칠을 했다...
@Override
public synchronized int compareTo(StringBuffer another) {
    return super.compareTo(another);
}
 
@Override
public synchronized int length() {
    return count;
}
 
@Override
public synchronized int capacity() {
    return super.capacity();
}
 
@Override
public synchronized void ensureCapacity(int minimumCapacity) {
    super.ensureCapacity(minimumCapacity);
}
 
/**
 * @since      1.5
 */
@Override
public synchronized void trimToSize() {
    super.trimToSize();
}
cs

  따라서 StringBuffer 대신 StringBuilder를 사용하자.