본문 바로가기
Java

Java String이 불변(immutable)인 이유

by 위대한초밥V 2023. 10. 27.

알고리즘 문제를 풀다가 dfs에서 다음 값을 업데이트해줄 때, 궁금증이 생겼습니다.

이 코드는

for(int i = 0; i < tickets.length; i++) {
   if(!visited[i] && now.equals(tickets[i][0])) {
      visited[i] = true;
	  dfs(tickets[i][1], count+1, trip + "," + tickets[i][1], tickets);
      visited[i] = false;
   }
}

이렇게도 쓸 수 있는거 아닌가요?!

for(int i = 0; i < tickets.length; i++) {
   if(!visited[i] && now.equals(tickets[i][0])) {
      visited[i] = true;
      trip += ",";
      trip += tickets[i][1];
      dfs(tickets[i][1], count+1, trip, tickets);
      visited[i] = false;
   }
}

하지만 이렇게 했을 때 코드는 통과되지 않습니다.

 

GPT의 도움을 받아 찾아보니, Java에서 문자열은 불변(immutable) 객체이므로 문자열을 변경할 때마다 기존 문자열이 변경되는 것이 아니라 변경된 새로운 문자열이 생성되기 때문입니다.

따라서 trip += ","는 새로운 문자열을 생성하고 trip에 더 하는 작업입니다. 따라서 원래 trip이 가리키는 객체와는 다른 객체가 되기 때문에, 예상치 못한 결과가 나옵니다.

 

+) 문자열을 변경할 때에는 StringBuilder나 StringBuffer를 사용하여 문자열을 변경합니다. 이러한 클래스들은 가변(mutable) 객체라고 합니다.

 

아 맞다…😲

 

mutable, immutable

Java에서 문자열을 붙이는 방식 차이를 통해 mutable 객체와 immutable 객체 차이를 알아봅시다.

자바에서 문자열을 붙이는 방법은 + 연산자, StringBuilder, concat이 있습니다.

concat

concat 함수는 기존 value와 새롭게 붙일 str을 합쳐서 new String() 객체로 새로 만들어서 반환합니다. 따라서 문자열을 계속 붙일 때마다, 새로운 주소값을 할당받습니다.

 

concat 함수 살펴보기

더보기
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);
    }
package test;

class Main {
    public static void main(String[] args) {
        String result = "hello";
        System.out.println("result = " + result);
        System.out.println("result = " + System.identityHashCode(result));

        System.out.println("================");

        String new_result = result.concat("world");
        System.out.println("result = " + new_result);
        System.out.println("result = " + System.identityHashCode(new_result));
    }
}

/* 실행 결과
result = hello
result = 1627960023
================
result = helloworld
result = 1720435669
*/

 

StringBuilder

append 함수로 문자열을 붙여줍니다. append 함수는 문자열을 이어붙여도 주소값이 변화하지 않습니다.

 

append 함수 살펴보기

더보기
@Override
@HotSpotIntrinsicCandidate
public StringBuilder append(String str) {
   super.append(str);
   return this;
}

 

package test;

class Main {
    public static void main(String[] args) {
        StringBuilder result = new StringBuilder("Hello");
        System.out.println("result = " + result);
        System.out.println("result = " + System.identityHashCode(result));

        System.out.println("================");

        result.append("world");
        System.out.println("result = " + result);
        System.out.println("result = " + System.identityHashCode(result));
    }
}

/* 실행 결과
result = Hello
result = 357863579
================
result = Helloworld
result = 357863579
*/

 

+ 연산자

자바 1.5이전에는 concat을 이용한 방식이었고, 1.5 이후에는 StringBuilder 를 이용하는 방식을 사용합니다.

+) concat 방식으로 문자열을 합치는 방식은 성능상의 이슈가 많았고, 이를 개선하기 위해 컴파일 단계에서 StringBuilder로 컴파일되도록 변경되었다고 합니다.

 

⇒ 값을 바꿀 수 있는 StringBuilder는 가변객체(mutable)하고, 값을 바꿀 수 없는 String은 불변(immutable)객체라고 합니다.

⇒ String, Boolean, Integer, Float, Long도 불변(immutable) 클래스입니다.

 

본격적인 이유를 알아보기 전에 우선 Java String Pool(String Constant Pool)에 대해 알아봅시다.


Java String Pool(String Constant Pool)이란?

String Pool은 Java에서 문자열 리터럴을 저장하는 독립된 영역을 말합니다.

Java에서 문자열은 생성자 new 연산자를 이용한 문자열 생성 방식과, 문자열 리터럴 생성 방식이 있습니다.

두 방식이 어떻게 다른지 간단한 코드를 작성하여 확인해보겠습니다.

 

new 연산자를 이용한 문자열 생성 방식: 메모리의 Heap 영역에 할당

String str1 = new String("Hello");
String str2 = new String("Hello");

System.out.println(str1 == str2);  // 실행 결과: false

문자열 리터럴 생성 방식: String Constant Pool 영역에 할당

String str1 = "Hello";
String str2 = "Hello";

System.out.println(str1 == str2);  // 실행 결과: true

new 연산자로 생성한 객체는 내용이 같아도 주소가 다르기 때문에 false를 반환합니다.

반면 리터럴 방식으로 생성한 객체는 내용이 같다면, 같은 객체, 즉 같은 메모리 주소를 가르켜서 true를 반환하고 있습니다.

 

그 이유는 JVM의 Heap 영역 내 String Constant Pool이 있기 때문입니다!

그림에서 literal로 생성한 s1, s2 객체는 String Pool에서 동일한 String 객체를 가리키고 있어 메모리 리소스를 절약할 수 있습니다.

new 연산자로 생성된 s3 객체는 String Pool에 같은 값이 존재하더라도, Heap 영역의 별도 객체를 가리키고 있죠.

이처럼 String Pool 내에서도 String 객체를 공유하려면 String은 반드시 불변 상태여야 합니다.(공유하려는 값이 그 사이에 바뀌면 안되겠죠?)

 

그럼 다시 돌아가서 왜 불변(immutable)로 관리하는 것일까요? 크게 보안, 동기화, 해시코드 캐싱, 성능의 이유가 있는데요. 한번 하나씩 알아봅시다!

 

  1. 보안(Security)
    이처럼 String의 immutable한 속성은 값에 연산에 의해 값이 변경되지 않으므로, 중요한 정보를 관리하는데 유익합니다. Java에서 String으로 저장하는 값을 생각해보겠습니다. 사용자 이름, 비밀번호, 포트 번호 등 중요한 정보를 String으로 관리합니다. 만약 String으로 저장하는 값이 변경 가능하다면, 무결성 검사를 한다고 해도 검사 중간에 값이 바뀔 수 있으니 안전할지 확신할 수 없습니다.

  2. 동기화(Synchronization)
    immutable 객체는 동시에 실행중인 여러 스레드 사이에 값이 공유될 수 있습니다. 따라서 ‘Thread-safe하다’라는 특징이 있는데요. 그 이유는 스레드에서 값을 변경하면, 동일한 값을 수정하는 것이 아닌 새로운 String 객체가 스레드 풀에서 생성되기 때문입니다. 따라서 멀티 스레딩 환경에서도 안전한 것이죠.
    +) thread-safe란 멀티 스레드 프로그래밍에서 함수나 변수 혹은 객체가 여러 스레드에서 접근이 이루어져도 프로그램 실행에 문제가 없음을 의미합니다!

  3. 해시코드 캐싱(Hashcode Caching)
    String 클래스를 보면 hashCode() 메서드를 재정의하고 있는 것을 확인할 수 있는데요. hashCode() 메서드는 해당 String의 value에 대한 hash 값이 없을 때에만 실제 로직을 수쟁하고, 이전에 계산한 값은 그냥 리턴합니다. 즉 hashCode 값을 캐싱하고 있는 것이죠.
    public int hashCode() {
    	int h = hash;
    	if (h == 0 && !hashIsZero) {
    		h = isLatin1() ? StringLatin1.hashCode(value)
                           : StringUTF16.hashCode(value);
    		if (h == 0) {
    				hashIsZero = true;
    		} else {
    				hash = h;
    		}
    	}
    	return h;
    }
    +) hashCode 값을 계산하는 상세한 코드는 StringLatin1과 StringUTF16을 참고하시면 됩니다.

  4. 성능(Performance)
    String이 immutable하기 때문에, String pool이 존재하고 이 특징은 힙 메모리를 절약할 수 있습니다. String은 자주 사용되는 데이터 타입이므로, String 성능이 향상되면 전반적으로 애플리케이션 성능에 영향을 미칠 것입니다.

 

⚡ Summary
Java의 String은 값이 바뀌지 않는 immutable 객체입니다.

Java에서 문자열을 생성하는 방식은 리터럴 생성 방식과 new 연산자를 이용한 문자열 객체 생성 방식이 있습니다. new 연산자를 이용하는 경우, 메모리의 Heap에 저장되고 문자열 리터럴 방식은 JVM Heap의 String Constant Pool에 할당됩니다. 따라서 Heap 영역에 있는 것들을 서로 공유가 가능합니다.

Java의 String은 이런 특성을 갖고 있기 때문에 보안, 동기화, 해시코드 캐싱, 성능에서 이점이 있습니다.
반응형