Java

[Java] String을 초기화하는 방법 - 성능 비교

Lea Hwang 2023. 11. 13. 12:50

자바에서 String을 초기화하는 방법에는 두 가지가 있습니다.

1. String a = "Hello";                                        // 리터럴 사용

2. String a = new String("Hello");                   // new키워드 사용

두 방식의 차이점은 무엇일까요?

둘 다 Hello라는 문자열이 생성되지만 방식에 따라 저장되는 위치가 다릅니다. 

첫 번째 방법인 리터럴로 선언하면, Hello값을 StringConstantPool에 넣고 참조변수 a는 이를 가리킵니다. 

(a는 값이 아닌 참조형입니다.) 

 

두 번째 방법으로 new키워드를 사용하면 Hello는 더 이상 StringConstantPool이 아닌 Heap영역에 생성되고 참조변수 a가 이를 가리키게됩니다.

🧐 [정리]
리터럴로 객체를 생성:  StringConstantPool을 사용
new키워드로 객체를 생성: 객체(Hello)가 Heap메모리에 새롭게 할당됨

 

리터럴을 사용해서 객체를 생성하기 전에는 먼저 StringConstantPool을 확인합니다. 원하는 값이 이미 존재한다면 별도로 생성하지 않고 같은 주소값을 반환하다는 특징이 있습니다. (재활용 가능)

반면, new 키워드를 사용하면 매번 새로운 객체를 생성하기 때문에 메모리 낭비가 발생합니다.

 

 

앞에서 두 가지 방법에 따라 성능에 영향이 있다고 학습한 부분을 코드를 사용해서 확인해 보는 시간을 가져보도록 하겠습니다. 

public class StringCreationTimeTest {
    public static void main(String[] args) {
        // 리터럴로 String 객체 생성
        long startTimeLiteral = System.currentTimeMillis();
        for (int i = 0; i < 200000; i++) {
            String strLiteral = "Hello, World!";
        }
        long endTimeLiteral = System.currentTimeMillis();
        System.out.println("Literal Creation Tqime: " + (endTimeLiteral - startTimeLiteral) + "ms");

        // new 연산자를 사용하여 String 객체 생성
        long startTimeNew = System.currentTimeMillis();
        for (int i = 0; i < 200000; i++) {
            String strNew = new String("Hello, World!");
        }
        long endTimeNew = System.currentTimeMillis();
        System.out.println("New Operator Creation Time: " + (endTimeNew - startTimeNew) + "ms");
    }
}

// 100000
Literal Creation Tqime: 2ms
New Operator Creation Time: 5ms

// 200000
Literal Creation Time: 4ms
New Operator Creation Time: 7ms

// 1000000
Literal Creation Tqime: 3ms
New Operator Creation Time: 6ms

 

10번, 20번 정도로 반복문 돌면서 객체를 생성하는 시간은 비슷할 수 있지만 100,000번, 200,000번 동안 객체를 생성하면 시간이 두 배이상 차이가 난다는 것을 확인할 수 있습니다.

🧐 [정리]
String 객체를 선언할 때 리터럴을 사용하는 것을 권장합니다. (성능, 비용, 메모리 효율 관점에서 우수)
단, 해당 객체가 재활용이 안 될 예정이거나 반대로 재활용이 되면 안 되는 상황이라면 new 연산자를 사용합니다. 

 


 

그럼 무조건 객체를 생성할 때 StringConstantPool을 사용하는 것이 좋을까요?

문자열 연결을 해야 할 경우에는 아닐 수도 있습니다. (Case by Case)

 

기본적으로 String은 불변(immutable)합니다. 변하지 않는다는 것은 수정할 수 없다는 뜻이기도 합니다. 

예를 들어 다음과 같이 문자열을 연결한다면 당연하게도 "Hello world"가 출력될 것입니다. 

String a = "Hello";
a += " world";

 

 

여기서 a에서는 어떤 일이 벌어질까요?

이미 StringConstantPool에는 Hello가 들어있고 참조변수 a는 이를 가리키고 있습니다. 

그 후 StringConstantPool 다른 곳에 Hello world라는 문자열이 새롭게 생성되고 참조변수 a는 이젠 Hello world 문자열을 바라보게 됩니다. 

따라서 기존  a의 주소값과 문자열을 더한 뒤의 주소값이 다르게 되는데, 이미 존재하는 Hello 문자열 옆에 world가 붙지 않는 이유는 String이 불변하기 때문입니다. 

 

 

😅 그러면, 리터럴로 객체를 선언하는 게 만능은 아닌 것 같은데요. 

이를 해결하기 위해  StringBuffer(가변)와 StringBuilder(가변)가 등장하게 되었습니다. 

 

코드를 통해  문자열을 연결할 때 String, StringBuffer, StringBuilder별로 성능을 비교해보겠습니다.

(예) 문자열 연결
0 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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74.. 99999

 

public class StringOperationTest {
    public static void main(String[] args) {
        final int iterations = 100000;

        // String 연산
        long startTimeString = System.currentTimeMillis();
        stringConcatenation(iterations); 
        long endTimeString = System.currentTimeMillis();
        System.out.println("String 연결 소요 시간: " + (endTimeString - startTimeString) + "ms");

        // StringBuffer 연산
        long startTimeStringBuffer = System.currentTimeMillis();
        stringBufferConcatenation(iterations);
        long endTimeStringBuffer = System.currentTimeMillis();
        System.out.println("StringBuffer 연결 소요 시간: " + (endTimeStringBuffer - startTimeStringBuffer) + "ms");

        // StringBuilder 연산
        long startTimeStringBuilder = System.currentTimeMillis();
        stringBuilderConcatenation(iterations);
        long endTimeStringBuilder = System.currentTimeMillis();
        System.out.println("StringBuilder 연결 소요 시간: " + (endTimeStringBuilder - startTimeStringBuilder) + "ms");
    }

    public static String stringConcatenation(int iterations) {
        String result = "";
        for (int i = 0; i < iterations; i++) {
            result += " " + i;
        }                             
        return result;
    }

    public static String stringBufferConcatenation(int iterations) { 
        StringBuffer result = new StringBuffer();
        for (int i = 0; i < iterations; i++) {
            result.append(" ").append(i);
        }
        return result.toString();
    }

    public static String stringBuilderConcatenation(int iterations) {
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < iterations; i++) {
            result.append(" ").append(i);
        }
        return result.toString();
    }
}

// 100,000번
String 연결 소요 시간: 5283ms
StringBuffer 연결 소요 시간: 10ms
StringBuilder 연결 소요 시간: 7ms

// 200,000번
String 연결 소요 시간: 32339ms
StringBuffer 연결 소요 시간: 27ms
StringBuilder 연결 소요 시간: 24ms

// 300,000번
String 연결 소요 시간: 68824ms
StringBuffer 연결 소요 시간: 44ms
StringBuilder 연결 소요 시간: 46ms

 

코드를 통해 알 수 있는 점은 다음과 같습니다. 

첫 번째, StringBuffer, StringBuilder가 String보다 성능이 월등하게 좋다.

두 번째, StringBuffer, StringBuilder는 성능에서 별 차이가 없는데 아무거나 쓰면 되는 건가?

 

 

코드를 통해 확인해 보자면, 그럼 무조건 StringBuffer / StringBuilder를 사용하는 것이 좋다고 생각할 수 있겠지만 상황에 따라 다릅니다. 

 

두 클래스 내부에는 Buffer가 존재하고 그 안의 동일 객체 내에서 변경 작업을 할 수 있지만 buffer크기를 초기에 설정해 줘야 하고 늘리고 줄이는 내부적인 연산이 추가적으로 필요하다는 trade off가 존재하기 때문입니다. 

🧐 [정리]
문자열 변경 작업이 적거나 단순 조회일 경우에는 String사용
많은 문자열 변경 작업이 있을 경우 StringBuffer 또는 StringBuilder을 사용합니다. 

 


꼬리질문

위에서 다루기에는 흐름을 끊을 것 같아서 생략했지만 짚고 넘어가고 싶은 부분을 따로 정리해 보았습니다. 

 

 

Q. stringConcatenation, stringBufferConcatenation, stringBuilderConcatenation 메서드들을 전부 static으로 선언한 이유가 무엇입니까?

해당 메서드는 main 메서드에서 호출되기 때문입니다. main메서드는 프로그램이 실행하고 가장 먼저 JVM에 의해 호출되는 static메서드로 객체 생성없이 다른 static 메서드를 호출할 수 있습니다.

stringConcatenation, stringBufferConcatenation, stringBuilderConcatenation 메서드들을 인스턴스 생성없이 호출하고 싶었기에 메서드를 static으로 선언하였습니다.

만약 해당 메서드들을 static으로 선언하고 싶지 않다면, 추가적으로 StringOperationTest 클래스의 인스턴스를 생성하는 코드를 추가하면 됩니다.

StringOperationTest stringOperationTest = new StringOperationTest();
stringOperationTest.stringConcatenation(iterations);
...

 

 

Q.StringBuffer와 StringBuilder의 차이점을 알고 있나요?

멀티 스레드에서 안전한지 안전하지 않은지의 차이점이 존재합니다. 

1. StringBuffer 클래스

- 스레드에서 안전(Thread safe)하다.

- 동기화를 지원하여 멀티 스레드 환경에서도 안전하게 동작한다.

 

2. StringBuilder 클래스

- 스레드에서 안전하지 않다.

- 동기화를 지원하지 않는다.

 

StringBuffer클래스는 동기화를 지원하므로  한 스레드가 append() 작업을 하면 다른 스레드는 대기 중입니다. 이러한 이유로 StringBuilder 보다 시간이 오래 걸릴 수 있습니다.

비동기로 동작하는 경우가 많고 멀티 스레드 환경이라면 StringBuffer를 사용하는 것이 안전합니다

 

StringBuilder 클래스는 동기화를 지원하지 않으므로 스레드들이 동시에 append()를 수행하면 몇 번은 제대로 수행이 되지 않기에  StringBuffer보다 시간이 적게 걸리는 결과를 볼 수 있습니다. 싱글스레드나 비동기를 사용할 일이 없다면 StringBuilder를 쓰는 것이 좋습니다. 

 

  String StringBuffer StringBuilder
불변, 가변 여부 불변 가변 가변
Thread safe O O X
연산 속도 아주 느림 빠름 StringBuffer보다 빠름
권장하는 사용 환경 문자열 추가 연산이 적음, 
Thread safe 환경
문자열 변경 연산이 많음,
Thread safe 환경
문자열 변경 연산이 많음,
싱글 Thread 환경