article thumbnail image
Published 2022. 7. 14. 20:23

 

String는 불변 객체(immutable object)이다.

불변 객체란, 객체가 생성된 후 내부의 상태가 변하지 않고 계속 유지되는 객체를 말한다. 즉 변수가 한 번 할당되면, 해당 객체의 참조를 변경할 수도, 내부의 상태를 수정할 수도 없다.

 

1. String Pool

Java는 String Pool을 이용해서 같은 객체를 공유한다. 이는 String이 불변이기 때문에 가능하다.

String s1 = "Java";
Stromg s2 = "Java";

s1 = "C++";

위 예시에서 s1, s2는 “Java”라는 값을 갖는 String Pool 내부의 하나의 String 객체를 바라보고 있다. 그리고 s1이 값을 “C++”이라고 바꾼다면 s1은 String Pool 내부의 다른 객체를 바라보게 된다.

만약 String이 mutable 하다면? s1의 값만 “C++”로 바뀌고 s2는 “Java” 그대로 남아있게 되는 샘인데, 값이 다른데 같은 참조를 가진다는 것은 말이 되지 않는다.

2. 보안

String은 사용자 이름, 암호, 연결 URL, 네트워크 연결 등과 같은 민감한 정보를 저장하기 위해 Java 애플리케이션에 널리 사용된다. 또한 클래스를 로드하는 동안 JVM 클래스 로더에서도 광범위하게 사용된다.

따라서 String 클래스의 보안은 일반적으로 전체 응용 프로그램의 보안과 관련하여 중요하다.

void criticalMethod(String userName) {
    // perform security checks
    if (!isAlphaNumeric(userName)) {
        throw new SecurityException(); 
    }
	
    // do some secondary tasks
    initializeDatabase();
	
    // critical task
    connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
      " WHERE UserName = '" + userName + "'");
}

신뢰할 수 없는 소스로부터 String 객체를 수신했다고 가정해보자. 처음에 String이 영숫자일뿐인지 확인하기 위해 필요한 모든 보안 검사를 수행한 후 몇가지 추가 작업을 수행한다. 신뢰할 수 없는 소스 호출자 메서드에서는 여전이 이 userName에 대한 참조가 있다.

문자열이 변경 가능 했다면 업데이트를 실행할 때 보안 검사를 수행한 후에도 수신한 문자열이 안전한지 확신할 수 없다. 신뢰할 수 없는 호출자 메서드에서 여전히 참조가 있으며 무결성 검사 사이에 문자열을 변경할 수 있다. 따라서 이 경우 쿼리를 SQL 주입에 취약하게 만든다. 따라서 가변 문자열은 시간이 지남에 따라 보안을 저하시킬 수 있다.

 

3. 동기화 (Synchronization)

객체가 불변이면 멀티 스레드 환경에서도 바뀔 위험이 없기 때문에 자연스럽게 thread-safe한 특성을 갖게 되고 동기화와 관련된 위험 요소에서 벗어날 수 있따. 여러 스레드에서 동시 접근해도 별다른 문제가 없다. String의 경우 한 스레드에서 값을 바꾸면 해당 객체의 값을 수정하는 것이 아니라 새로운 객체를 String Pool에 생성한다. 따라서 thread-safe하다고 볼 수 있다.

 

4. Hashcode Caching

String의 hashCode() 메서드 구현을 보면 hash 값을 계산한 적이 없을 때 최초 1번만 실제 계산 로직을 실행하고 이후부터는 이전에 계산했던 값을 그대로 리턴한다. 즉 hashCode 값을 캐싱하고 있다. 이렇게 캐싱이 가능한 것도 결국 String이 불변이기 때문에 얻을 수 있는 이점이다.

 

5. 성능

이전에 보았듯이 문자열 풀은 문자열이 변경 불가능하기 때문에 존재한다. 결과적으로 문자열로 작동할 때 힙 메모리를 절약하고 해시 구현에 더 빠르게 액세스하여 성능을 향상시킨다. String은 상대적으로 자주 쓰이는 타입이기 때문에 String의 성능을 개선하는 것은 전체 어플리케이션 성능에도 긍정적인 영향을 준다.

 

 

 

Java의 문자열 생성 방법

평소 일반적으로 객체를 생성할 때는 new 키워드를 사용하여 객체를 생성한다. 그러나 특이하게도 문자열은 new 연산자가 아니라 바로 값을 할당할 수 있는데 이를 문자열 리터럴이라 부른다.

 

리터럴 문자와 new String의 차이

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

System.out.println(str1.equals(str2)); // true 
System.out.println(str1 == str2); // false 
System.out.println(str2 == str3); // true

String Constant Pool은 힙 내부에 있는 작은 캐시이다. 여기에 저장된 String 값은 불변성을 가지게 되는데 여기서 말하는 불변성은 값이 변함이 없으며 동일한 String 값을 가지고 있으면 같은 것을 가르킨다는 것을 의미한다. 이렇게 하면 같은 값에 대해서는 새로운 메모리 할당 없이 재사용이 가능하다는 장점을 가진다.

만약 String Object로 객체의 형태로 String을 생성한다면 각 객체는 다른 메모리를 가르키기 때문에 동일한 값이 나오지 않는다. 따라서 String 리터럴로 생성하면 해당 String 값은 힙 영역 내 String Constant Pool에 저장되어 재사용되지만 new 연산자로 생성하면 같은 내용이라도 여러 개의 객체가 각각 힙영역을 차지하기 때문에 new 연산자로 String 객체를 생성하지 않는 것이 좋다.

String interning

String 클래스에는 intern()이라는 메서드가 있다.

intern()은 해당 String과 동등한(equal) String 객체가 이미 String Constant Pool에 존재하면 그 객체를 그대로 리턴한다. 그렇지 않으면 호출된 String 객체를 String Pool에 추가하고 객체의 reference를 리턴한다.

@Test
    public void testStringWithIntern() {
        String name = "hello";
        String name1 = new String("hello");
        String internName = name1.intern();

        Assert.assertTrue(name.equals(internName));
        Assert.assertTrue(name == internName);
    }

기존에 new 연산자로 생성한 문자열 name1에 대해 intern() 메서드를 실행하면 name1의 값과 동일한 문자열이 String Constant Pool에 있는 지 확인할 것이다. 여기에는 이미 name으로 생성된 객체가 있을 것이기 때문에 name이 가리키는 주소와 동일한 주소값이 internName에 할당될 것이다. 그래서 해당 테스트는 동일한 주솟값을 가지는 문자열이기 때문에 테스트를 통과할 수 있다.

추가적으로 String Constant Pool은 HashTable 구조를 가진다. 각 String Constanct를 hashing하고 해당 데이터를 key로 value를 찾기 때문에 성능은 어느 정도 보장되어 있다고 한다.

 

String의 equals 메서드

String 클래스에서는 equals 메서드를 아래와 같이 오버라이딩 해서 사용하고 있다.

즉 객체 자체가 아닌, 문자열 값을 비교해서 같으면 true 다르면 false를 리턴한다.

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

 

Java에서 문자열을 붙이는 방법

 

Concat

concat은 String 클래스에서 제공하는 기본 메서드이며 동작 방법은 합친 문자열을 String으로 생성한다. concat() 메서드를 이용해서 문자열을 추가할 때마다, 새로운 인스턴스를 생성하기 때문에 성능이나 속도 면에서 좋지 않다.

 

StringBuilder

StringBuilder를 선언하고 append함수를 통해 문자열을 덧붙일 수 있다. StringBuilder가 String과 가장 다른 점은 ‘수정 가능'하다는 점이다. String은 immutable한 객체이기 때문에 값을 수정하려면 다른 값을 가진 String을 다시 대입하는 식으로 처리해야 한다. StringBuilder는 ‘새로운 String 객체를 생성하여 메모리에 할당하는 과정’ 없이도 수정 가능하다는 장점이 있다. StringBuilder의 경우 복잡하거나 반복적인 문자열 수정 시 사용하는 것이 좋다.

 

‘+’ 연산자

jdk 1.5 버전 이전에는 concat() 메서드처럼 문자열을 추가할 때마다 새로운 인스턴스를 생성했지만 이후에는 StringBuilder롤 변환해서 처리하는 것으로 변경되었다. 문자열을 먼저 StringBuilder로 변환시킨 뒤 append로 문자열을 더하고 다시 toString함수로 문자열로 반환해주는 방식이다.

for (int i = 1; i <= 1000; i++) {
    temp += i;
}

// 컴파일 시
for (int i = 1; i <= 1000; i++) {
    temp = (new StringBuilder(temp)).append(i).toString();
}

하지만 반복적인 작업 전에 미리 StringBuilder 객체를 생성하지 않았다면, 컴파일시 자동으로 StringBuilder 객체로 변환되어도 + 연산을 반복한만큼 자동으로 변환되었던 StringBuilder 객체가 버려진다. 결론적으로 버려전 StringBuilder 객체들이 GC의 대상이 되고 메모리와 성능에 영향을 미치는 사실은 변하지 않는다.

따라서 +연산은 문자열을 반복적으로 더하지 않을 경우에만 사용하는 것이 좋다.

 

StringBuffer

StringBuffer는 StringBuilder와 호환 가능하기 때문에 사용법은 동일하다. 차이점은 StringBuffersms thread-safe 하다는 점이다. String Builder는 동기화를 보장하지 않는다. StringBuffer 클래스는 동시에 이 객체에 접근했을 때, 동시성을 제어해주는 기능이 존재하고, StringBuilder 클래스는 동시성 제어 기능을 제외하여 상대적으로 동작속도가 빠르다. synchronized를 사용한 동기화는 lock을 걸고 푸는 오버헤드가 있기 때문에 속도가 느리기 때문이다.

쓰임새는 동일하나 멀티스레드를 이용하여 하나의 문자열을 수정할 필요가 있다면 StringBuffer 클래스를 사용하는 것이 바람직하다.

  1. 멀티 스레드 환경에서 안전한 프로그램이 필요할 때
  2. static으로 선언된 문자열을 변경할 때
  3. singleton으로 선언된 클래스의 문자열을 변경할 때

 

 

String, StringBuilder, StringBuffer 각각의 객체가 문자열 더하는 라인을 100만 번씩 실행한 결과는 다음과 같다.
속도는 String을 기준으로 StringBuffer가 약 367배 빠르고, StringBuilder는 약 512배 빠르다.
메모리는 StringBuffer와 StringBuilder가 똑같이 사용하고 String은 약 3,390배 더 사용한다

이상민, 자바 성능 튜닝 이야기, 인사이트(2013), p52

 

 

Java 7에서의 변화

Java 7 이전에는 JVM 이 고정된 크기의 PermGen 공간 에 Java String Pool을 배치했습니다. 이 공간은 런타임에 확장할 수 없고 가비지 수집에 적합하지 않다

( 힙 대신) PermGen 에서 문자열 을 인턴하는 위험은 너무 많은 문자열 을 인턴할 경우 JVM에서 OutOfMemory 오류가 발생할 수 있다는 것이다

Java 7부터 Java String Pool은 JVM에 의해 가비지 수집 되는 힙 공간에 저장한다*.* 이 접근 방식의 장점은 참조되지 않은 문자열 이 풀에서 제거되어 메모리가 해제 되기 때문에 OutOfMemory 오류 의 위험이 감소한다는 것이다.

 

 

 

 

 

참고

https://www.baeldung.com/java-string-pool

https://junghn.tistory.com/entry/JAVA-문자열-붙이는-방법concat-StringBuilder-StringBuffer

https://tecoble.techcourse.co.kr/post/2020-09-07-dive-into-java-string/

https://www.baeldung.com/java-string-immutable

 

'자바' 카테고리의 다른 글

JVM, JDK, JRE, JIT  (0) 2022.07.18
Call by Value와 Call by Reference  (0) 2022.07.14
equals()와 hashCode()  (0) 2022.07.14
복사했습니다!