개발을 하다보면 문자열을 다룰 일이 많습니다.
String 클래스야 말로 java에서 가장 많이 사용되는 클래스가 아닐까 생각합니다.
그런것에 비해서 저는 String에 대해서 무관심하게 사용하고 있었습니다.
이번 기회에 몇 가지 String 클래스에 대해서 알아보려고 합니다.
본 문서에서는 String 객체를 만드는 방법과 불변성에 대해서 살펴봅니다.
java에서 String 객체(문자열)을 생성하는 방법은 여러가지가 있겠지만 대표적으로 2가지로 나눠 볼 수 있을 것 같습니다.
이 두가지 생성 방식이 어떤 차이를 가지는지 알아 보겠습니다.
new 키워드로 생성된 경우 String object는 heap 메모리에 위치하게 됩니다.
그래서 매번 new 키워드로 생성한 경우 새로운 String Object가 생성됩니다.
그리고 각각의 Object는 동등성이 보장되더라도(같은 문자열), 동일성을 보장하지는 않습니다.
그 의미는 아래 코드로 살펴볼 수 있다.
// 객체 선언
String love = new String("love");// 1)
String love2 = new String("love");// 2)
// 테스트
System.out.println("love == love2 : " + (love == love2));
System.out.println("love.equals(love2) : " + love.equals(love2));
// 결과
love == love2 : false //동일성 체크
love.equals(love2) : true //동등성 체크
위 코드에서 1)과 2)는 같은 문자열 “love”지만 서로 다른 메모리 주소를 가지게 됩니다.
하지만 Literal String의 경우는 다른 생성 메커니즘을 가집니다.
String literalLove = "love";
String literalLove2 = "love";
위 코드에서 String객체가 생성되는 메커니즘은 아래와 같습니다.
해당 내용을 확인해보기 위해 코드를 작성해보았습니다.
//new 키워드사용한 생성과 쌍따옴표를 사용한 생성
//두 Literal String의 메모리 주소를 비교한다
String literalLove = "love";
String literalLove2 = "love";
System.out.println("literalLove == literalLove2 : " + (literalLove == literalLove2));
System.out.println("literalLove : " + System.identityHashCode(literalLove));
System.out.println("literalLove2 : " + System.identityHashCode(literalLove2));
//결과
literalLove == literalLove2 : true
literalLove : 1531333864
literalLove2 : 1531333864
//두 String Object의 메모리 주소를 비교한다.
String love = new String("love");
String love2 = new String("love");
System.out.println("love == love2 : " + (love == love2));
System.out.println("love : " +System.identityHashCode(love));
System.out.println("love2 : " +System.identityHashCode(love2));
//결과
love == love2 : false
love : 1468177767
love2 : 434091818
코드에서 보았다 싶이, String 객체를 어떻게 생성하냐에 따라서 메모리를 사용하는 방법이 달라지니 염두해두고 사용하면 좋을 것입니다.
new 키워드를 사용하여 계속해서 같은 문자열을 만들 경우에는 불필요하게 메모리를 사용할 수 있습니다.
하지만 이 부분을 알고 있어 Constant Pool을 사용하면 성능에 이점을 얻을 수 있습니다.
Literal로 생성할 경우는 같은 "love"로 여러번 선언하더라도 항상 같은 객체만을 참조할 뿐입니다.
불변객체는 말그대로 변하지 않는 객체입니다. 그리고 String은 불변객체입니다.
즉 한번 생성되면 String 객체의 문자열 내용을 변경할 수 없다는 것입니다.
그런데 우리는코딩을 하면서 너무 당연하다는 듯 문자열을 자주 변경합니다.
그래서 바꿀 수 없다는 것이 무슨 말인지 이해하지 못할 수 있습니다.
이 내용에 대해서 살펴보기 위해 String class의 concat method를 살펴보면 좋을 것 같습니다.
//main method에서
String iLoveJava = "i".concat(" love java");
System.out.print(iLoveJava);
//출력
i love java
//String 클래스의concat method 구현부
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);
}
메소드 구현부의 코드 흐름만 파악해보면
위 내용으로 볼 때 기존 String 객체에는 따로 수정을 가하지 않고, 두 String 객체를 복사하여 더 큰 byte 배열에 넣고 이를 통해서 새로운 String 객체를 생성한다는 것을 확인 할 수 있습니다.
기본적으로 String은 문자열에 수정이 가해지면 해당 객체의 값을 바꾸는 것이 아닌 수정된 값을 가지는 새로운 객체를 만들고 있습니다
String에 대한 Java document를 확인해보면 다음과 같은 문구가 있습니다.
Strings are constant;
their values cannot be changed after they are created.
String buffers support mutable strings.
Because String objects are immutable they can be shared.
대충 해석하자면 String 객체는 상수이며, 객체 생성 후 값을 변할 수 없다는 의미입니다.
만약 가변적인 작업이 필요할 경우는 String Buffer(혹은 String Builder) 클래스를 이용하라고 하네요.
String객체는왜 수정이 안되는 걸까요?
알아볼 수록 질문이 늘어나는 군요...
Stirng 클래스에서 문자열 정보를 담고있는 맴버변수를 확인해보겠습니다.
private final byte[] value; // String class의 맴버변수
맴버 변수 value를 보면 private final 키워드가 붙어있음을 확인 할 수 있습니다.
생성자를 통해서 해당 맴버변수는 초기화되며, 외부에서 value변수로 직접 접근할 수 없도록 캡슐화 하였습니다.
또한 final 키워드를 사용하였기에 객체 생성 시 값을 한번 할당하고 이후에는 변경할 수 없게 하였습니다.
그래서 문자열을 연결하려 concat method를 사용할 때 위와 같이 동작할 수 밖에 없어던 것이다.
String 객체는 수정일어나면 항상 새로운 문자열을 만들 수 밖에 없는데 이런 단점을 가지더라도 왜 문자열 객체를 불변성을 보장하는걸까요?
매번 복사하고, 새로운 객체를 만드는 작업을 할바에 그냥 수정할 수 있게 만드는 게 낫지 않았을까요?
String을 불변객체로 사용함으로 얻는 이점은 다음과 같은 것이 있습니다.
우선 이점에 대해서 살펴봅시다.
public native String intern();
intern 메소드를 사용하면 문자열을 String Constant Pool에 객체를 저장하고 해당 문자열의 메모리주소를 반환합니다.
즉 같은 메모리의 주소를 가지게 됩니다. 이를 통한 이점으로는 equals method가 아닌 ==연산자를 사용하므로 동일성을 파악할 수 있다는 것입니다.
하지만 해당 메소드는 웬만하면 사용하지 않는 것이 좋을 것입니다.
equals보다 ==연산자가 성능이 좋겠지만, 그를 위해 intern 메소드로 매번 문자열을 선언한다면 언젠가 String Constant Pool의 저장공간이 부족하게 될 것이고, GC가 동작하게 될 것입니다.
GC가 동작하면 어플리케이션의 모든 작업은 GC작업이 마무리 될때까지 정지될 것입니다.
이건 좋은 모습은 아닐 것입니다.
보안 : String 객체가 특정 보안체크 로직을 통과 후에 값이 수정될 가능성을 막을 수 있습니다.
어떤 문자열의 경우 어플리케이션 전반에 걸쳐서 보안에 민감할 수 있고, JVM Class Loader 또한 광범위 하게 문자열을 사용합니다. 문자열 객체가 수정이 가능하다면 특정 유효성, 보안 검사를 통과한 후 해당 문자열이 수정될 수 있다는 것을 의미하죠. 이는 아주 큰 보약취약점이 될 것입니다.
동시성 : String 객체는 불변성이 보장되므로 thread-safe합니다. 동시성 이야기를 하자면 내용이 길어지기에 이는 ‘동시성’관련 문서에서 다뤄야 할 것 같습니다.
이렇게 String class의 선언방식에 따른 메모리 사용이나, 불변성과 그로 얻는 이점 등을 살펴보았습니다. 이 문서를 작성하면서 많이 반성하게 되었습니다. 제가 사용하는 것에 대해서 깊게 알지 못했던 것과, 조사하면서 이해하기 힘든 부분이나, 깊이있는 부분들이 참 많다는 걸 알았습니다.
물론 호기심을 채워가는 과정이 재미있었고, 이렇게 문서로 남기니 이론과 개념이 이전보다 훨씬 정리 되었습니다.
💡 참조
좋은 글 감사합니다 ㅎㅎ