ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Java 자료형과 String pool
    CS/DS & OOP 2020. 7. 1. 18:52

    오늘 작년에 같이 대외활동했던 동생이 자바 공부한다고 연락이 왔습니다.

    원래 파이썬을 쓰던 친구라 그런지 일반적인 언어에서 쓰는 개념과는 조금 헷갈려 하더라구요.

    (물론 파이썬이라서 그렇다기보단 새로운 언어를 공부하게 되면 겪는.. 혼동은 항상 있겠죠.)

     

    또한, 일반적이라고 말하기엔 조금 편견이 있지만, python이 친근해진 것은 다른 언어에 비해 최신이니 이와 같이 표현했습니다.

    어쨌든, 그 중 문자열과 문자 관련한 이야기가 나왔는데, 마침 정리해 두는 것이 좋을 것 같아 포스팅합니다.

     

    대부분의 언어에선 문자와 문자열은 다르게 취급됩니다.

    C언어 쪽은 문자는 char, 문자열은 char[]로 표시를 한다 알고 있습니다.

    (문자 여러 개 == 문자열이니까 꽤나 직관적이네요.)

     

     

    자바에서는 문자는 char, 문자열은 String으로 표시합니다.

    일반적으로 char, int 이런 것들은 앞 글자를 소문자로 표기하는 자료형입니다.

    자료형에도 종류가 두 가지 입니다. primitive type, reference type

     

    Primitive type (기본 자료형)

    정수형 - byte, short, int, long

    실수형 - float, double

    문자형 - char

    진리형 - boolean

     

    Reference type (참조 자료형)

    class, array, interface, enum

     

    여기서 Wrapper class, String class는 일반 자료형과 유사하게 사용되지만 조금 다르게 취급됩니다.

    Wrapper class -> Integer, Long, Double 등..

     

    null의 값을 가질 수 있고, 자바에서 사용되는 자료구조(Collection: List, Map) 등에 타입 선언으로 사용가능 합니다.

    이번 포스팅은 String을 집중적으로 파헤치기 위함이므로, Wrapper class는 이정도만 하고 넘어가겠습니다.

     

     

    String

    String 또한 Wrapper class와 동일합니다. null을 가지고, Collection에 타입으로 선언될 수 있습니다.

    그런데 String에는 큰 특징이 하나 있습니다. 바로 선언 방식입니다.

     

    물론 Wrapper class 또한 선언 방식이 두가지가 존재합니다.

    public class Main {
        public static void main(String[] args) {
            Integer a1 = 1;
            Integer A = Integer.valueOf(1);
        }
    }

    숫자를 바로 넣는 것은 사실 primitive 타입에서 가능한 것인데, 상관없습니다.

    왜냐하면 jdk ver1.5부터 오토박싱(autoboxing), 언박싱(unboxing)이 지원되었기 때문입니다.

     

    즉, 현재는 위의 코드처럼 일반 숫자만 넣어도 자동으로 처리가 가능해졌습니다.

     

     

    그런데 String은 조금 다릅니다. 물론 선언방식은 Wrapper class와 유사합니다.

    일반적인 글자를 넣는 Literal방식과 new 생성자 호출을 통한 값 주입 방식인데요.

    아래와 같이 선언 가능합니다.

    public class Main {
        public static void main(String[] args) {
            String str1 = "apple";                          // by literal
            
            char[] apple = {'a', 'p', 'p', 'l', 'e'};
            String str2 = new String(apple);                // by new constructor
        }
    }

     

    우선 String 자체가 이미 class 형태만 존재하기 때문에 boxing과는 관련이 없습니다.

    또한 String str2 = new String("apple");로도 선언이 가능하지만, 이런 경우는 잘 사용하지 않습니다.

    그 이유를 지금부터 설명드리겠습니다.

     

    우선 new String은 char 배열을 문자열 형태로 반환해줍니다.

    Java 라이브러리에 쓰여있는 코드를 가져왔습니다.

     

    String.java

        /**
         * Allocates a new {@code String} so that it represents the sequence of
         * characters currently contained in the character array argument. The
         * contents of the character array are copied; subsequent modification of
         * the character array does not affect the newly created string.
         *
         * @param  value
         *         The initial value of the string
         */
        public String(char value[]) {
            this(value, 0, value.length, null);
        }

    파라미터를 보니 0번째 부터 char 배열의 길이까지 문자열을 생성해주는 작업을 해준다는 것을 알 수 있습니다.

    즉, 반복문이 내부에서 동작할 것이라는 유추가 가능합니다. (문자열 생성에 비용이 많이 드는 이유 중 하나로 볼 수 있겠네요.)

    또한 설명을 보니 문자열을 생성한 후에는 문자배열의 값이 변경되어도 영향을 주지 않는다고 쓰여있네요.

     

     

    다음으로 this가 정의된 부분을 따라 가보겠습니다.

    (여기서 this는 해당 클래스에 선언된 String이라는 이름을 가진 메소드입니다.)

     

    String.java -> this method

        /*
         * Package private constructor. Trailing Void argument is there for
         * disambiguating it against other (public) constructors.
         *
         * Stores the char[] value into a byte[] that each byte represents
         * the8 low-order bits of the corresponding character, if the char[]
         * contains only latin1 character. Or a byte[] that stores all
         * characters in their byte sequences defined by the {@code StringUTF16}.
         */
        String(char[] value, int off, int len, Void sig) {
            if (len == 0) {
                this.value = "".value;
                this.coder = "".coder;
                return;
            }
            if (COMPACT_STRINGS) {
                byte[] val = StringUTF16.compress(value, off, len);
                if (val != null) {
                    this.value = val;
                    this.coder = LATIN1;
                    return;
                }
            }
            this.coder = UTF16;
            this.value = StringUTF16.toBytes(value, off, len);
        }

     

    값을 바이트로 압축해서 문자열로 만들어주는 기능으로 보입니다. 어떤 최적화를 진행하고 있나 봅니다.

    (현재 다루기엔 너무 깊은 이야기인 것 같아 생략합니다.)

     

    해당 메소드에서보면 반드시 도는 메소드가 존재합니다.

    바로 StringUTF16의 compress또는 toBytes입니다.

    이것들의 동작을 마지막으로 살펴보면 아래와 같습니다.

     

     

    String.java -> this method -> StringUTF16.compress()

        @HotSpotIntrinsicCandidate
        public static int compress(char[] src, int srcOff, byte[] dst, int dstOff, int len) {
            for (int i = 0; i < len; i++) {
                char c = src[srcOff];
                if (c > 0xFF) {
                    len = 0;
                    break;
                }
                dst[dstOff] = (byte)c;
                srcOff++;
                dstOff++;
            }
            return len;
        }

    String.java -> this method -> StringUTF16.compress() / StringUTF16.toBytes()

        @HotSpotIntrinsicCandidate
        public static byte[] toBytes(char[] value, int off, int len) {
            byte[] val = newBytesFor(len);
            for (int i = 0; i < len; i++) {
                putChar(val, i, value[off]);
                off++;
            }
            return val;
        }

    compress, toBytes를 통해 압축작업을 하고 이를 문자열 데이터처럼 보이도록 변형해주는 것 같습니다.

    예상대로 내부에 반복문이 진행되고 있죠. 직접적으로 문자열을 반환해주는지는 이 코드를 봐선 알 수 없네요.

    아마도 캐릭터 배열을 쭉 나열해서 뽑아주는 작업을 진행하는 것 같습니다.

    (기회가 되면 이 부분에 대해 좀 더 깊게 공부해보고 추가 포스팅 하겠습니다.)

     

     

    어찌되었던, 결과적으로 new String()을 사용하면 아래와 같은 차이를 보입니다.

    new String 사용

     

    char 배열은 배열의 주솟값을 출력하는 반면, new String()을 씌우니 문자열 값이 나타났습니다.

    new String은 이렇게 사용이 가능한데, literal은 간단하게 큰 따옴표만 사용하면 됩니다.

    literal

    직접 문자열을 선언해야하는 경우 편리하겠네요.

    이미 문자열이기 때문에 new String("apple") 이런식으론 사용되지 않습니다.

    (중복 선언이겠죠.)

     

    그런데 literal은 장점아닌 장점을 지니고 있습니다.

     

     

    String Pool

    그 이유는 literal을 통한 선언은 heap area의 String constant pool에 등록된다는 점입니다.

    이번 내용의 핵심이 되겠습니다.

     

    물론 new String으로 선언한 값도 intern 메소드를 통해 String pool에 등록이 가능합니다.

    String Pool에 등록이 되면, 같은 값을 갖는 문자열은 지속적으로 String Pool에서 참조해 쓸 수 있습니다.

    같은 문자열을  계속 참조해 쓰니 동일한 값일 것이구요.

    이 의미는 서로 다른 변수에서 선언한 값도 변수의 값이 같고 String pool에 등록된 경우 같은 값을 참조한다는 의미입니다.

    (이는 상당히 중요한 의미를 갖습니다.)

     

    즉, 변경이 적고 참조만 많은 문자열을 사용하는 경우 상당히 유용하겠죠.

    그러나 지속적으로 다른 문자열이 생성되는 경우 String Pool에 지속적인 등록이 필요하고, 그만큼 상당한 비용이 발생합니다.

     

    그런데, 등록이 안된 친구들은 서로 다른 문자열로 인식됩니다.

    즉, primitive type에선 '=='로 비교하는 경우 비교가 가능한데, String에선 등록되지 않았다면 불가능합니다.

     

    그 이유를 알기위해선 우선 아래의 내용을 아셔야 합니다.

    일반적으로 java에서 '=='는 얕은 비교 즉, 변수가 가진 값만 가지고 비교를 수행하려합니다.

     

    primitive 타입은 변수에 그에 대한 값만 존재하죠.

    그러나 reference 타입은 실제 값을 저장하는 heap 영역에 대한 참조도 포함하고 있습니다.

    즉, 실제 객체의 메모리 주소를 보유하게 된다. 라고 이해하시면 되겠습니다.

     

    그러니 primitive 타입은 얕은 비교가 가능했으나, reference는 진짜 (주소까지)같은 녀석인가?를 비교하게 되는 것입니다.

    그렇다면, 아까 봤던 Integer와 int의 같은 값을 넣고 비교하면 무엇이 출력 될까요?

    방금 말씀드린 대로라면 false가 떠야겠죠.

    compare Integer, int

    근데 아닙니다. true가 반환됩니다.

    그 이유는 아까 말씀드린 unboxing때문입니다.

     

    얕은 비교를 하려 했고, 때문에 Integer를 unboxing을 통해 비교를 실시해 동일한 값으로 인식된 것이죠.

     

     

    String은 어떻게 나올까요? true일까요?

    compare String

    실제로 값만 가지고 비교하는 equals는 전부 true로 나타났습니다.

    또한 String pool에 등록된 str3, str4는 같은 문자열로 취급되었지만, 나머지는 서로 다른 것으로 취급되었습니다.

     

    Wrapper class와는 다릅니다.

    문자열은 boxing과 관련이 없기 때문이죠. (관련도 없고 불가능하죠.)

     

    intern을 사용(String pool에 등록)하면 전부 true로 변경됩니다.

    intern 호출

     

    toString 메소드도 존재하는데요. 얘도 new String과 유사하다 생각하시면 됩니다.

    또한 값 뒤에 literal을 더해도 String으로 변환이 가능하지만 상동합니다.

    즉, intern 메소드를 호출해야 같은 값으로 인식이 됩니다.

    to String 비교
    "" + value

    이렇게 일반적으로 문자열 만드는 방법들이 대부분 상수풀에 등록이 되는 것은 아니니 equals 사용을 권고하는 것입니다.

     

     

    이제껏 봤던 String pool 동작들을 그림으로 다시 한번 보여드리겠습니다.

    String pool 도식화

    String pool에 등록되는 동작을 정확하게 표현하진 못했네요.

    문자열 A, B 선언을 통해 조금 더 설명해보겠습니다. literal 형식이라면 intern으로 등록하는 과정은 생략됩니다.

    (A와 B에 할당 된 값(문자열)은 같다고 가정합니다.)

    1. 선언된 문자열 A가 String pool에 등록됩니다.
    2. 해당 문자열은 pool내에서 유일한 값과 주소를 가집니다.
    3. B인 문자열을 하나 더 선언합니다.
    4. 이후 문자열 B를 intern을 통해 등록합니다.
    5. 이전에 pool 내부에 이미 등록되어 있었고, B와 같은 값을 가지는 문자열이 있는지 확인합니다.
    6. A에 의해 등록된 문자열이 5번 조건에 부합하겠죠. 즉, 이제부터 B와 A는 같은 문자열로 인식됩니다.
    7. 결국 코드에서 A, B를 호출한다면 String pool에서 같은 문자열 객체를 가져다 쓰게 됩니다.

    즉, 같은 값을 가진 변수들은 풀에 등록되는 순간 완전히 (주소까지)동일한 변수로 여겨집니다.

     

     

    추가로 문자열을 하나씩 생성해 붙이는 경우는 특히나 많은 비용이 듭니다.

    이렇게 'a += a'를 반복적으로 하는 경우 매번 새로운 문자열을 만들게 되거든요.

    "a", "aa", "aaa", "aaaa" ...... "aaaaaa.....a" 이렇게요.

    우리가 필요한건 마지막에 만들어진 문자열인데 낭비가 심하겠죠.

     

    그래서 자바에선 StringBuilder, StringBuffer 클래스를 제공합니다.

    두 클래스는 문자열 생성없이 append를 통해 붙여 한번에 필요한 문자열을 만들어 낼 수 있도록 도와줍니다.

    StringBuilder usecase

     

    차이점은 StringBuffer는 동기화를 제공합니다. 따라서, 단일 스레드 환경에서는 StringBuilder를 사용하시면 됩니다.

    이렇게 문자열은 편리한 만큼 많은 주의를 기울여 사용해야합니다. (특히 알고리즘 풀 때는 더욱 그렇습니다.)

     

     

    잘못된 내용이나, 이해가 잘 안되는 부분은 댓글로 알려주시면 늦지않게 답변 드리겠습니다. 감사합니다.

    반응형

    'CS > DS & OOP' 카테고리의 다른 글

    JVM (Java Virtual Machine), GC (Garbage Collection)  (0) 2021.06.22
    Exception (try-with-resources)  (0) 2021.01.12
    Exception (checked, unchecked)  (0) 2021.01.10
    객체지향 5원칙 (SOLID)  (0) 2020.06.24
    트리와 그래프  (0) 2020.03.05

    댓글

Designed by minchoba.