본문 바로가기

기초 지식/Java

[Java] String 선언 방식에 따른 차이

String을 사용할 때 2가지 방식으로 선언할 수 있는데 어떤 차이가 있을까요?

Java에서 String은 Reference 타입 중 가장 많이 사용되지 않을까 생각됩니다. 그런데 잘 알지도 못하고 사용하고 있다고 위의 질문을 들었을 때 알게 되었습니다. 그리고 Java책을 다시 펴보게 됐네요 ㅎㅎ

 

public class Test {
	public static void main(String [] args) {
    	String init1 = new String("init1");
        String init2 = "init2";
        String init2_2 = "init2_2";
    }
}

 

String을 사용할 때 보통은 init2의 방식대로 사용하겠지만 위의 방식도 가능합니다.

중요한 차이점은 JVM에서 관리하는 메모리 구조상 어디에 저장되는지가 다릅니다.

 

https://www.journaldev.com/797/what-is-java-string-pool

위의 그림과 같이 new를 사용한 선언은 다른 Reference Type의 특징인 Heap 영역에 따로 선언되고 일반적으로 "~" 방식으로 선언된 객체는 Heap 영역 내에 있는 String Pool이라는 객체 내부에 저장되고 참조값은 String Pool을 모두 동일하게 참조하게 됩니다.

 

따라서 위의 main 코드에서 생성된 객체는 총 2개가 됩니다. init2, init2_2는 String Pool 객체를 참조하고 있고 init1만 따로 객체를 할당해서 메모리를 소비하게 됩니다.

 

그럼 String Pool은 어떤 객체이고 어째서 String이라는 타입에만 적용이 되어 있는걸까요?

 

String Pool과 Intern

String Pool은 HashMap 형태로 되어있으며 "~"와 같이 리터럴을 사용하여 String 객체를 생성하면 String Pool 내에서 기존의 같은 값을 가지는 객체가 있는지 검사 후 있다면 그 객체의 참조값을 return 하고, 아니라면 새로 String 객체 생성 후 그 참조값을 return 합니다. 이러한 과정을 intern이라고 합니다. 그리고 리터럴로 생성 시 intern()이라는 메서드를 수행함을 알 수 있습니다.

 

리터럴 방식으로 생성된 String 객체의 참조값은 모두 String Pool 주소값을 지정하고 있고 값을 찾는 방식은 String Pool로 찾아간 후 String Pool 내에서 intern의 과정을 거쳐서 찾는 값과 같은 값을 가진 부분의 주소값을 return 해주는 방식인거죠.

 

String Pool을 사용하는 이유는 JVM의 객체가 생성되는 공간인 Heap에 계속해서 객체를 생성하게 되면 메모리 공간적 측면에서 비효율적이기 때문입니다. 따라서 Pool을 사용한 Caching(캐싱)을 통해서 이를 해결하게 위함이죠.

 

위의 intern 과정 때문에 가끔씩 겪게 되는 String 비교 시 특이점이 생깁니다.

 

String a = "good";
String b = "good";
String c = new String("good");

System.out.println(a == b); //true
System.out.println(a.equals(b)); //true
System.out.println(a == c) //false
System.out.println(a.equals(c)); //true

 

위의 코드에서 a와 b는 String Pool 내부의 같은 참조값을 가지게 되고 c는 String Pool 외부 Heap 영역에 다른 참조값을 가지며 생성되겠죠. 그리고 두 가지 비교 방식의 차이에 따라 주소 값과 실제값을 비교하고 그러므로 3번째 예시같은 경우에는 false가 나오게 됩니다.

  • == : 주소값 비교
  • string.equals() : 실제값 비교

왜 String만 Pool을 주는 거야?

다른 Reference Type들도 이와 같이 Pool을 이용해서 캐싱한다면 참 좋을 거 같은데 유난히 String만 특별 대우해주는 걸까요?

 

이유는 String은 다른 Reference Type들과 다르게 immutable(불변성을 가진)한 타입이기 때문입니다. 다른 Reference Type은 객체를 할당하고 이에 대한 값을 변경하면 같은 주소 값의 객체의 실제 값이 변경되지만 String은 그렇지 않습니다.

 

String a = "First Init";

a = "Changed First Init";

 

이와 같이 변경한다면 String Pool에 처음에는 "First Init"이라는 객체가 저장되고 이를 변경하면 해당 객체의 값을 변경하는 것이 아닌 "Changed First Init"이라는 값을 가진 객체를 새로 만들어 다시 String Pool에 저장하게 됩니다.

 

이렇듯 불변의 객체다 보니 String Pool에서 값을 비교하고 같은 값이면 같은 주소 값을 사용하도록 하는 캐싱을 한다면 이점을 가져갈 수 있기 때문이죠.

 

그렇다면 문자열을 단순히 값을 변경하는 것이 아닌 합치고 쪼개는 경우는 어떨까요?

 

String의 concat, +, StringBuilder, StringBuffer

Concat

우선 concat 메서드로 String을 합치는 경우를 보겠습니다. concat 메서드 내부와 함께 보시죠.

 

String a = "good";
String b = " day";

String c = a.concat(b);
public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    
    return new String(buf, true);
}

 

concat의 코드 내용을 정리하자면 아래와 같습니다.

  • 파라미터로 들어온 String의 길이를 검사 = otherLen
  • concat 메서드를 수행하는 String의 길이를 검사 = len
  • 두 길이를 합한 길이만큼의 char형 배열(buf)을 생성하며 우선 메서드 수행하는 String의 값을 넣습니다.
  • 파라미터로 들어온 문자열을 만들어 두었던 buf에 len만큼 offset 이후에 넣습니다. (value 값 이후에)
  • 해당 char 배열을 new String()을 통해 String으로 만들어 반환합니다. 

위의 코드로 돌아가서 a, b는 모두 String Pool 내부에 선언되어 있을 겁니다. 이의 값들은 당연히 변경이 안되고 배열을 이용해 붙이고 그 값을 새로운 메모리를 할당해서 저장하게 됩니다. 그리고 String Pool외부의 Heap 영역에 생성되겠네요.

 

여기서 수행하는 로직을 크게 보자면 이와 같습니다.

  • 합치는 연산을 할 두 문자열의 길이만큼 배열 할당
  • 배열에 두 값 복사
  • 배열을 String으로 변환

+ 연산자

이번에는 + 연산자를 보겠습니다.

 

String c = "Hello " + "It's a" + " good day";

 

+ 연산자의 경우는 java 1.5 이전과 그 이후를 분리할 수 있습니다. java 1.5 이전에는 내부적으로 concat 메서드와 동일하게 수행되었고 이후에는 StringBuilder로 변환하여 처리하게 되었습니다.

 

그렇다면 StringBuilder는 어떻게 다른지 보면 되겠네요.

 

StringBuilder

StringBuilder sb = new StringBuilder();

sb.append("Hello");
sb.append(" It's a");
sb.append(" good day"); // = "Hello" + " It's a" + " good day"

 

StringBuilder를 사용하는 경우의 가장 큰 차이점은 객체가 Immutable 하지 않다는 점입니다. 따라서 하나의 메모리를 차지하고 그 메모리 내부의 값을 계속 변경해 가면서 진행되기 때문에 시간, 공간적으로 효율적입니다.

 

StringBuilder의 내부적인 로직은 자세히 들여다보지 않겠습니다. concat과 같이 새로운 길이의 배열을 할당하고 복사하는 방식으로는 구성되어 있지 않다는 게 확실하고 다른 Reference Type들과 동일할 테니까요.

 

StringBuffer

그렇다면 StringBuilder와 비슷한 StringBuffer는 왜 사용할까요?

이유는 Thread Safe 하기 때문입니다.

 

public class StringTest {
    StringBuilder sbuilder = new StringBuilder();
    StringBuffer sbuffer = new StringBuffer();
    
    public void testMethod(String value) {
    	sbuilder.append(value);
        sbuffer.append(value);
        ...
    }
    
    public void testMethod2(String value) {
    	sbuilder.append(value);
        sbuffer.append(value);
        ...
    }
}

 

위와 같이 구성되어 있지 않고 StringBuilder가 메서드 내부에서 선언된다면 매번 메서드가 실행될 때 StringBuilder 객체가 생성되므로 이슈는 없을 것입니다.

하지만 위와 같은 코드 환경에서는 testMethod, testMethod2를 여러 곳에서 호출하여 사용한다면 StringBuilder의 경우에는 원하는 결과를 보장할 수 없습니다. 바로 append 메서드에 synchronized 선언이 되어있지 않기 때문이죠.

 

public StringBuilder append(String str) {
	super.append(str);
	return this;
}

public synchronized StringBuffer append(String str) {
	toStringCache = null;
	super.append(str);
	return this;
}

 

각각의 속도 차이

그렇다면 많은 String 합치기 연산들이 수행된다면 어떤 것을 사용하는 게 가장 좋을까요? 이를 비교해놓은 그래프가 있습니다.

http://www.pellegrino.link/2015/08/22/string-concatenation-with-java-8.html

위의 그래프는 for문 내에서 각각의 연산을 수행한 결과입니다.

+ 연산과 StringBuilder는 동일한 연산을 수행하는데 왜 차이가 나지?

+ 연산의 경우는 매 연산 시마다 StringBuilder 객체를 생성하기 때문입니다. 즉 for문을 100번 호출하고 내부에 + 연산이 한번 있다면 StringBuilder 객체는 100번 생겼다 없어지겠네요.

 

 

String Split

Split의 내부 코드 같은 경우에는 정규식을 사용하기 때문에 조금 복잡하게 보일 수 있습니다. 따라서 분리하는 부분과 반환하는 부분을 일부만 살펴보겠습니다.

 

ArrayList<String> list = new ArrayList<>();
while ((next = indexOf(ch, off)) != -1) {
    if (!limited || list.size() < limit - 1) {
        list.add(substring(off, next));  // !! 1번 !!
        off = next + 1;
    } else {    // last one
        //assert (list.size() == limit - 1);
        int last = length();
        list.add(substring(off, last));
        off = last;
        break;
    }
}
// If no match was found, return this
if (off == 0)
    return new String[]{this};  // !! 2번 !!

// Add remaining segment
if (!limited || list.size() < limit)
    list.add(substring(off, length()));

// Construct result
int resultSize = list.size();
if (limit == 0) {
    while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
        resultSize--;
    }
}
String[] result = new String[resultSize];
return list.subList(0, resultSize).toArray(result);  // !! 3번 !!
  • 1번 부분 : list를 이용해서 String을 잘라낸 것을 각각 저장하고 있습니다.
  • 2번 부분 : 자르는 기준에 부합하지 않으면 기존 String을 새로 할당해 반환합니다.
  • 3번 부분 : result라는 String 배열을 생성 후 결괏값을 넣어 반환합니다.

Split에서는 내부적으로 Array가 아닌 List를 사용한다는 점과 로직 흐름 정도만 기억하고 넘어가면 될 것 같습니다.

 

정리

  • String은 Heap 영역 내부에 저장되고 String Pool이라는 객체도 존재한다.
  • 리터럴 방식의 String은 모두 String Pool을 지정하고 내부에서 동일한 값을 찾고 그 부분의 주소값을 반환한다.
  • new String() 방식은 String Pool이 아닌 Heap 영역에 저장된다.
  • concat은 배열을 이용해 두 String을 합치고 배열의 값을 새로운 String에 할당하여 반환한다.
  • +는 java 1.5 이전에는 concat을 1.5 이후에는 StringBuilder를 사용한다.
  • StringBuilder와 StringBuffer의 차이점은 Thread Safe의 차이이다.
  • split은 반환값은 Array지만 내부적으로는 List를 사용한다.
반응형