본문 바로가기

JAVA

Java String + 연산에 대한 이해

1. 자바에서 +연산으로 문자열 연결

Java String 클래스는 + 연산으로 쉽게 두개의 스트링을 잇는 것이 가능하다.

    public static void main(String[] args) throws Exception {
        String a = "Hello, ";
        String b = "World!";
        a = a + b;
        System.out.println(a); // 출력결과 : Hello, World!
    }

정말 편리하게 사용하고 있는 기능이다. 그런데, String 은 불변 객체라고 알고 있기에, 내부적으로 어떻게 동작하는지 궁금해진다. 객체를 새로 만들어서 두개 스트링을 복사해서 붙여넣는 것일까? 그렇다면 이 과정은 비효율적이지는 않을까? 라고 생각해 볼 수 있다.

eclispe나 vscode에서 브레이크 포인트를 찍어서 실제로 어떤 과정이 이루어지는지 살펴보자.

2. +연산의 내부 동작 과정 살펴보기

a = a + b; 에 브레이크 포인트를 찍고, 동작과정을 살펴봤다.

1) StringBuilder 생성자 호출

    //StringBuilder.class
    @HotSpotIntrinsicCandidate
    public StringBuilder(String str) { 
        super(str); //str에는 "Hello, "가 들어가 있다.
    }

"Hello, " 문자열이 StringBuilder 클래스의 instance로 생성이 된다. StringBuilder는 AbstractStringBuilder를 상속받고 있고, super(str)에서 AbstractStringBuilder의 생성자를 호출한다.

2) AbstractStringBuilder 생성자 호출

    AbstractStringBuilder(String str) {
        int length = str.length();
        int capacity = (length < Integer.MAX_VALUE - 16)
                ? length + 16 : Integer.MAX_VALUE;
        final byte initCoder = str.coder();
        coder = initCoder;
        //value에 byte의 배열이 저장된다. 실제로 character가 저장되는 부분
        value = (initCoder == LATIN1)
                ? new byte[capacity] : StringUTF16.newBytesFor(capacity);
        //append 함수로 str을 value에 복사해준다.
        append(str);
    }

value는 문자열을 저장하고 있는 배열이다. str의 길이를 체크하여 초기 capacity는 str.length() + 16으로 value의 배열을 만들어준다. 여기서는 "Hello, " 가 공백포함 길이가 7이므로 capacity는 7 + 16 = 23으로 계산된다. 이후 value에 str을 넣어주기 위해 append 함수가 호출된다.

3) append 호출

    public AbstractStringBuilder append(String str) {
        if (str == null) {
            return appendNull();
        }
        int len = str.length();
        //str이 추가되었을 때 value의 크기보다 커지는지 확인한다
        //만약 더 커진다면 기존 길이* 2 + 2 만큼으로 value를 재할당하고
        //기존에 있던 value값을 복사해준다.
        ensureCapacityInternal(count + len);
        //count위치에 str를 복사해준다.
        putStringAt(count, str);
        count += len;
        return this;
    }

putStringAt이라는 부분에서 value로 str의 copy가 발생하게 된다. 이게 value에는 "Hello, "가 저장되었다. 

4) 두번째 문자열을 파라미터로 append 호출

a + b에서 a에 관한 작업이 끝나고, a에서 b를 파라미터로한 append가 호출되어 b를 연결시켜준다.

5) toString 호출

    @Override
    @HotSpotIntrinsicCandidate
    public String toString() {
        //새로운 String을 생성한다.
        // Create a copy, don't share the array
        return isLatin1() ? StringLatin1.newString(value, 0, count)
                          : StringUTF16.newString(value, 0, count);
    }

a = a + b;에서 a로 asign하는 연산을 위해서 toString이 호출된다. 이때 String 클래스의 instance가 새롭게 생성되게 되며, value를 새로운 String 객체로 전부 copy해주는 작업이 발생한다.

3. 과정 정리

    public static void main(String[] args) throws Exception {
        String a = "Hello, ";
        String b = "World!";
        
        //a = a + b; 은 아래 과정과 같다.
        StringBuilder builder = new StringBuilder(a); // 1. 생성자 호출
        builder.append(b); //2. b 스트링 추가
        a = builder.toString(); // 3. 스트링 변환
        
        System.out.println(a); // 출력결과 : Hello, World!
    }

결국 a = a + b;는 위에서 볼 수 있듯이 코드 3줄를 축약시켜 놓은 형태라고 할 수 있다. + 연산이 일어날 때마다 새롭게 StringBuilder클래스의 인스턴스를 생성하는 작업이 발생되게 된다.

4. 퍼포먼스 측정

+연산과 StringBuilder를 통한 String append가 얼마나 성능차이가 나는지 확인해보았다. 10,000번 동안 a에다 b를 연결하는 과정을 수행해보았다.

public static void main(String[] args) throws Exception {
        String a = "Hello, ";
        String b = "World!";

        // +속도 측정
        int maxIter = 10000;
        long t0 = System.currentTimeMillis();
        for (int i = 0; i < maxIter; i++) {
            a += b;
        }
        long t1 = System.currentTimeMillis();
        System.out.println(t1 - t0);

        // StringBuilder 속도 측정
        a = "Hello, "; // 초기화
        t0 = System.currentTimeMillis();
        StringBuilder builder = new StringBuilder(a);
        for (int i = 0; i < maxIter; i++) {
            builder.append(b);
        }
        builder.toString();
        t1 = System.currentTimeMillis();
        System.out.println(t1 - t0);

        // + 의 내부 동작이 일치하는지 검증
        a = "Hello, ";
        t0 = System.currentTimeMillis();
        for (int i = 0; i < maxIter; i++) {
            StringBuilder builderCheck = new StringBuilder(a);
            builderCheck.append(b);
            a = builderCheck.toString();
        }
        t1 = System.currentTimeMillis();
        System.out.println(t1 - t0);
    }
    
    //결과
    + : 173ms
    StringBuilder : 2ms
    검증 : 172ms // + 연산과 비슷한 수준으로 측정되었다.

+ 연산의 경우는 173ms가 걸린반면, StringBuilder를 사용한 경우에는 2ms밖에 걸리지 않았다. 반복횟수가 많아질 수록 이차이는 무시할 수 없었다.

5. 마무리

+연산에서 계속 새롭게 StringBuilder를 객체를 생성하고 toString의 호출로 String 객체 또한 생성한다. 따라서, 반복적으로 발생하는 + 의 경우에는 사용을 지양해야할 필요가 있다고 보여진다. 편리하게 사용하는만큼 성능저하가 있으므로 주의해야겠다.

반응형

'JAVA' 카테고리의 다른 글

Adapter Pattern 과 SLF4J  (0) 2021.08.27
Log4j 알아보기  (0) 2021.08.16
Volatile Java  (0) 2021.08.16
Java 익명 클래스와 람다 표현식의 다른 점  (0) 2021.07.13
LinkedHashMap 알아보기  (0) 2020.12.11