본문 바로가기

프로그래밍/Java

String

반응형

해당 포스트의 목적

F-lab멘토링 의 과제로, String의 +연산의 유의점에 대해 알아보려다 보니, String에 대해 전반적으로 이해를 해야 할 것 같아 String에 대한 이해를 목적으로 작성한다. String에 대한 전반적인 이해와 +연산의 유의점과 그에 따른 StrungBuffer와 StringBuilder에 대해서 알아보는것을 목표로 한다.


String 객체 생성

String은 객체를 생성하는 방법이 new 연산자 외에, 기본 자료형처럼 "" 로 리터럴 생성이 가능하다.

이때

생성되는 방식에 따라, 저장되는 메모리 공간도 달라진다

.

new 연산자 사용시, 기타 다른 참조 자료형들과 마찬가지로, Heap메모리에 객체가 생성되고, 해당 객체의 주소값을 변수에 할당한다. 그러나, 리터럴 생성시, Heap메모리에 위치한 String Pool 이라는 공간에 객체가 생성되고, 주소값을 할당한다. 두 방식 모두 Heap메모리에 위치하지만, 리터럴 생성시에는 Heap 메모리 중에서도 String Pool이라는 특정 공간에 생성이 되는 것이다.

출처:
https://readystory.tistory.com/140
💡
그렇다면 이 둘의 차이는 무엇일까??

Constant Pool VS String Pool

String Pool은 JVM의 메소드영역에 있는 런타임 상수풀과는 다른 영역이다. 햇갈리지 말도록 하자. 런타임 상수풀은 클래스 로드시 '상수풀'이 올라가는 메모리 공간이다.

'상수풀'이란, 클래스파일의 리소스 저장소 로써, 무엇을 저장하고 있냐하면, 해당 클래스가 클래스인지 인터페이스인지 여부, 해당 타입의 속성, 전체이름, 부모클래스의 전체이름 과 변수와 메소드의 이름, 데이터타입, 접근제어자, 리턴타입을 저장하고 있다. 즉 상수풀은 컴파일 된 클래스 파일의 일부분 이라고 할 수 있다.

PS E:\gitPrjxt\F-lab> javap -v -l -p .\HeapExam.class
Classfile /E:/gitPrjxt/F-lab/HeapExam.class
  Last modified 2021. 5. 6.; size 468 bytes
  MD5 checksum 0e0d99160adb8dddd62c0b564c5e3b2a
  Compiled from "HeapExam.java"
public class HeapExam
  minor version: 0
  major version: 56
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #5                          // HeapExam
  super_class: #6                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool:
   #1 = Methodref          #6.#17         // java/lang/Object."<init>":()V
   #2 = Methodref          #5.#18         // HeapExam.changeInt:(I)V
   #3 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #21.#22        // java/io/PrintStream.println:(I)V
   #5 = Class              #23            // HeapExam
   #6 = Class              #24            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               changeInt
  #14 = Utf8               (I)V
  #15 = Utf8               SourceFile
  #16 = Utf8               HeapExam.java
  #17 = NameAndType        #7:#8          // "<init>":()V
  #18 = NameAndType        #13:#14        // changeInt:(I)V
  #19 = Class              #25            // java/lang/System
  #20 = NameAndType        #26:#27        // out:Ljava/io/PrintStream;
  #21 = Class              #28            // java/io/PrintStream
  #22 = NameAndType        #29:#14        // println:(I)V
  #23 = Utf8               HeapExam
  #24 = Utf8               java/lang/Object
  #25 = Utf8               java/lang/System
  #26 = Utf8               out
  #27 = Utf8               Ljava/io/PrintStream;
  #28 = Utf8               java/io/PrintStream
  #29 = Utf8               println
{
  public HeapExam();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: bipush        10
         2: istore_1
         3: iload_1
         4: invokestatic  #2                  // Method changeInt:(I)V
         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: iload_1
        11: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        14: return
      LineNumberTable:
        line 3: 0
        line 4: 3
        line 5: 7
        line 6: 14

  public static void changeInt(int);
    descriptor: (I)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: iconst_0
         1: istore_0
         2: return
      LineNumberTable:
        line 9: 0
        line 10: 2
}
SourceFile: "HeapExam.java"

런타임 상수풀은 클래스 로드시 앞서말한 상수풀이 올라가는 메모리 공간이며, 컴파일되면서 모든게 결정되었던 상수풀과 달리, 실행중 동적으로 새로운 상수가 추가될수도 있다. ex) int of the StringClass.()method 등... 이 예시는 잘 모르겠다... 뭔 소리인지...

 

String Pool은 앞서말한 상수풀과 런타임 상수풀과 달리, 클래스 로드시 정보를 저장하지 않고, 위치또한 메소드 영역이 아니라 힙메모리에 생성된다. String Pool은 String 객체가 리터럴 생성 되거나,

(리터럴 생성은 내부적으로 .intern()메소드를 사용한다.) 혹은 그렇지 않은 String 객채가 .intern()연산을 할경우 String Pool에 해당 String 객채를 저장한다. 만약 리터럴 문자열 생성시, 이 String Pool에 저장된 String의 값과 동일하다면, 새로운 객체를 생성하지 않고, String Pool에 있는 객체의 주소값을 바로 반환해준다. (이를 활용해서 String객체의 무분별한 생성을 막을 수 있다.)


String Pool

StringPool에 들어간 객체는 값이 같은 중복 생성이 되지 않는다.

String a = "a";
String b = "a";

반면 new연산자로 Heap메모리에 생긴 객체들은 얼마든지 중복생성이 가능하다.

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

이런 차이로 인해 다음과 같은 현상이 발생한다.

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

if(a==b){
    System.out.println("a==b");
}
if(a==c){
    System.out.println("a==c");
}
if(b==c){
    System.out.println("a==c");
}
if(a.equals(b)){
    System.out.println("a equals b");
}
if(a.equals(c)){
    System.out.println("a equals c");
}
if(b.equals(c)){
    System.out.println("b equals c");
}
a==c 
a equals b 
a equals c
b equals c

3문자열의 값은 모두 동일하지만, a와c는 StringPool내에 생성된 하나의 객체를 가르키고, b는 StringPool과는 별도의 Heap메모리에 객체를 생성한다. 그래서 == 연산시 객체의 주소값이 같은 a와c만 출력이 되고(==은 참조자료형 일시 객체의 주소값을 비교한다.), equals 메소드 사용시 값이 모두 같기에 같다고 나오는 것이다.

💡
그럼 왜 굳이 이렇게 StringPool을 따로 만들걸까??

디자인 패턴의 교과서인 GoF에서는 플라이웨이트 패턴에 대해 다음과 같이 정의하고 있습니다.

'공유(Sharing)'를 통하여 대량의 객체들을 효과적으로 지원하는 방법

이처럼 플라이웨이트 패턴은 많은 수의 객체를 생성해야 할 때 주로 쓰입니다.

플라이웨이트 패턴은 공유 객체에 의해 메모리에 로드 되는 객체의 개수를 줄일 수 있습니다.

String Pool은 플라이웨이트 디자인 패턴(Flyweight Design Pattern)을 잘 적용한 대표적인 사례입니다. String Pool은 Runtime에서 많은 양의 String 객체를 생성하게 되더라도 많은 양의 메모리 공간을 절약할 수 있습니다.

즉, 메모리 관리측면에서 매우 효율적이기 때문에 String Pool을 만들어 관리하는것이다.


String은 Immutable 하다.

String의 특징으로 Immutable 하다 라는 특징이 있습니다. 불변하다 라는 뜻인데, 왜 String은 불변하는 특징을 갖고 있는지, 이 특징이 앞서 배운 내용과 어떻게 연관이 있는지 알아보자.

1. String Pool

String이 불변이기 때문에 String Pool도 존재할 수 있다.

어떤 프로그래밍 언어라도 String 타입은 매우 빈번하게 사용된다. 그래서 Java에서는 String Pool이라는 공간에 String을 포함시켜서, 매번 String 객체를 새로 생성하기보다는 값이 같은 String이라면 String Pool에 있는 객체를 재사용할 수 있도록 구현했다.

즉 값이 같은 String은 String Pool 내에서 String 객체를 공유하도록 한 것이다. 그런데 공유를 하려면 String은 반드시 immutable, 즉 불변이어야 한다. mutable하다면 두 객체의 공유는 불가능하다.

2. 보안

Java에서 메서드의 파라미터로 String을 받는 경우는 매우 흔하다.

예를 들어 사용자의 이름이나 패스워드, 혹은 네트워크 연결을 위한 포트 번호나 connection URL, 파일 이름 등 중요한 정보를 String으로 받을 때가 많다. JVM의 class loader가 class loading을 수행할 때도 마찬가지다.

3. 동기화 (Synchronization)

객체가 불변이면 멀티 스레드 환경에서도 값이 바뀔 위험이 없기 때문에, 자연스럽게 thread-safe한 특성을 갖게 되고, 동기화와 관련된 위험 요소에서 벗어날 수 있다. 여러 스레드에서 동시에 접근해도 별다른 문제가 없다.

또한 String의 경우 한 스레드에서 값을 바꾸면, 해당 객체의 값을 수정하는 것이 아니라 새로운 객체를 String Pool에 생성한다. 따라서 thread-safe하다고 볼 수 있다.

4. Hashcode Caching

String의 hashCode() 메서드 구현을 보면, 아직 hash 값을 계산한 적이 없을 때 최초 1번만 실제 계산 로직을 수행한다. 이후부터는 이전에 계산했던 값을 그냥 리턴만 하도록 되어 있다. 즉 hashCode 값을 캐싱(caching)하고 있다.

이렇게 caching이 가능한 것도 결국 String이 불변이기 때문에 얻을 수 있는 이점이다.

hashCode() 메서드는 Hash 자료구조의 구현체, 예를 들면 HashMap, HashTable, HashSet와 같은 클래스에서 꽤 자주 호출된다. String 객체와 함께 Hash 구현체를 사용하는 경우라면 이러한 caching 덕분에 성능상 큰 이점을 볼 수 있을 것이다.

5. 성능

위에서 나온 내용들을 몇가지 종합해보면, String이 불변성을 가짐으로써 "성능"이라는 측면에서 유리하다는 것을 알 수 있다. String은 상대적으로 자주 쓰이는 타입이기 때문에, String의 성능을 개선하는 것은 전체 애플리케이션의 성능에도 긍정적인 영향을 주게 된다.


String Builder와 String+연산

String의 불변성에 대해서도 알았고, 불변성을 이용해 객체를 공유함으로써 메모리관리 측면에서 매우 유용하다는 것도 알았다. 그렇다면 String의 덧셈연산을 할때는 무엇을 유의해야 하는지 알아보자.

String의 +연산

String str = "Kim"; //str : "Kim" 
str += "Ready" // str : "KimReady"

해당 연산에 따른 자바 메모리의 변화는 다음과 같다.

https://readystory.tistory.com/141?category=784159

"Kim"객체의 메모리에 "Ready"를 추가하지 않고, "KimReady"라는 새로운 객체를 만들고, 메모리를 할당 받는다. 이렇듯 문자열 수정 시 마다 새로운 메모리를 할당 받기 때문에, 많은양의 수정이 일어난다면, 성능저하가 심각하게 일어날 수 있다. 아이러니 하게도 메모리 관리 측면에서 이점을 보였던 불변성 때문에, 수정 시에는 성능저하를 보이는 것이다.

💡
그렇다면, 어떻게 해야 불변성의 이점을 취하면서, 문자열 수정 시 발생하는 성능저하를 줄일 수 있을까?

StringBuilder와 StringBuffer

String의 불변성을 통해 얻는 이점도 있지만, 덧셈연산과 같은 단점도 있다. 이런 단점을 보완하기 위해 나온것이 String Builder와 String Buffer이다.

String Builder는 Oracle Javadoc에서 다음과 같이 정의하고 있다.

💡
A mutable sequence of characters

즉 가변적인 문자의 시퀀스이다.

StringBuilder는 문자열이 변경 될 때마다 새로운 메모리를 할당 받는것이 아닌, 버퍼를통해 문자열을 관리하다가,(값이 추가 될때 마다, 메모리가 늘어난다!) toString()메소드를 통해 String객체를 생성한다.

그럼 둘의 유의미한 성능차이를 확인해 보자.

public class Main {
    private static final int MAX_COMPARE = 100000;
 
    public static void main(String[] args) {
        // String Test
        String test1 = "";
        long start = System.currentTimeMillis();
        for(int i = 0; i < MAX_COMPARE; i++) {
            test1 += "a";
        }
        long end = System.currentTimeMillis();
        System.out.println("String : " + (end - start) + " ms");
 
        // StringBuilder Test
        StringBuilder sb = new StringBuilder();
        start = System.currentTimeMillis();
        for(int i = 0; i < MAX_COMPARE; i++) {
            sb.append("a");
        }
        end = System.currentTimeMillis();
        System.out.println("StringBuilder : " + (end - start) + " ms");
    }
}
/*
String : 1107 ms
StringBuilder : 3 ms
*/

많은 양의 문자열 수정 시 String 보다 String Builder를 사용하는게 유리하다. 단, 모든 경우에서 String Builder를 쓰는것이 옳지는 않다. String의 불변성으로 인한 이점중 하나인 쓰레드 세이프한 강점을 String Builder는 포기해야한다. 그런 상황에서는 String Buffer를 사용하자. String Builder와 동일한 API를 제공하면서, 동시에 쓰레드 세이프하다. 성능은 String Builder보다는 느리지만, String보다는 빠르다. 또 Java8 이후로 String +연산시 컴파일러가 자동으로 String Builder 연산으로 변환해준다. 단,

반복문과 여러줄에 걸친 연산에서는 변환이 불가능

하니, 해당 경우에서는 String Builder/Buffer를 써주도록 하자.


String의 덧세연삼 내부 작동원리

public class Application {
 public static void main(String[] args) { 
		String s1 = "afas"; 
		String s2 = "asfasfaasf";
		String s3 = s1 + s2;
		System.out.println(s3); 
	} 
}
public class com.jinseong.soft.string.Application {
	public com.jinseong.soft.string.Application();
	 Code: 
		0: aload_0 
		1: invokespecial #1 // Method java/lang/Object."<init>":()V 
		4: return 
	public static void main(java.lang.String[]);
	 Code: 
		0: ldc #2 // String afas 
		2: astore_1 
		3: ldc #3 // String asfasfaasf 
		5: astore_2 
		6: new #4 // class java/lang/StringBuilder 
		9: dup 
		10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V 
		13: aload_1 
		14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 
		17: aload_2 
		18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 
		21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 
		24: astore_3 
		25: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream; 
		28: aload_3 
		29: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 
		32: return

문자열 덧셈연산시 내부적으로 StringBuilder를 호출 후 append한후에 다시 toString()으로 반환한다.

매 연산시 마다 StringBuilder를 생성, toString으로 반환하므로 비효율적이다.

💡
아직 해당 부분은 완벽히 이해하지는 못하고, 그저 생성과 호출의 반복으로 비효율적임만을 이해하고 있다. 추후에 이해가 될때 추가적을 작성해보겠다.

Reference

https://doohong.github.io/2018/03/04/java-string-pool/

https://starkying.tistory.com/entry/why-java-string-is-immutable

https://gbsb.tistory.com/255

https://readystory.tistory.com/141?category=784159

https://siyoon210.tistory.com/160

https://codingdog.tistory.com/entry/java-string-연산-어떻게-동작하는지-알아봅시다

 

반응형

'프로그래밍 > Java' 카테고리의 다른 글

String의 == 연산시 동작  (0) 2021.05.13
equals() 와 ==  (0) 2021.05.12
Buffer VS Cache  (0) 2021.05.12
캐시는 어느영역에서 사용되나?  (0) 2021.05.11
캐시(Cache)란?  (0) 2021.05.11