자바 String의 특징
String은 객체
자바(Java) 프로그래밍에서 String 은 int 와 char 와 달리 기본형(primitive type)이 아닌 참조형(reference type) 변수로 분류 된다.
즉, 스택(stack) 영역이 아닌 객체와 같이 힙(heap) 에서 문자열 데이터가 생성되고 다뤄진다는 말이다.
혼자만 자료형 키워드 첫글자가 대문자 인 점을 예의 주시해야 한다.
int age = 35;
String name = "홍길동";
String은 불변(Immutable)
기본적으로 자바에서는 String 객체의 값은 변경할 수 없다.
아래 예제 코드를 보면 변수 a 가 참조하는 메모리의 "Hello" 라는 값에 "World" 라는 문자열을 더해서 String 객체의 값을 변경 시킨 것으로 보일수도 있다. 하지만 실제로는 메모리에 "Hello World" 를 따로 만들고 변수 a 를 다시 참조하는 식으로 작동한다.
String a = "Hello";
a = a + " World";
System.out.println(a); // Hello World
hashCode() 메소드를 이용해 실제로 변수가 가지고 있는 주소값을 찍어보면 확인할 수 있다.
※ 참고
hashCode() 메소드는 객체의 메모리 번지를 이용해서 해시코드를 만들어 리턴하는 메소드이다.
String a = "Hello";
System.out.println(a.hashCode()); // 69609650
a = a + " World";
System.out.println(a.hashCode()); // -862545276
똑같은 변수 a 의 해시코드(주소값)을 출력했음에도 들고 있는 값이 바뀜에 따라 아예 주소값이 달라짐을 알 수 있다.
즉, 문자열 값 자체는 불변이라 변경할수 없기 때문에 새로운 문자열 데이터 객체를 대입하는 식으로 값을 대체 하기 때문에 이러한 현상이 생기는 것이다.
불변 객체에 대한 내용은 아래 포스팅을 참고하자.
왜 불변으로 설계 되었는가?
이처럼 String이 불변적인 특성을 가지는 이유는 크게 3가지로 뽑을 수 있다.
첫번째는 JVM(자바 가싱 머신) 에서는 따로 String Constant Pool 이라는 독립적인 영역을 만들고 문자열들을 Constant 화 하여 다른 변수 혹은 객체들과 공유하게 되는데, 이 과정에서 참조하려는 문자열이 String Pool에 존재하는 경우 새로 생성하지 않고 Pool에 있는 객체를 사용하기 때문에 특정 문자열 값을 재사용하는 빈도가 높을 수록 상당한 성능 향상을 기대할 수 있다. 이러한 데이터 캐싱이 일어나기 때문에 그 만큼 성능적 이득을 취할 수 있다.
두번째는 데이터가 불변(immutable) 하다면 Multi-Thread 환경에서 동기화 문제가 발생하지 않기 때문에 더욱 safe 한 결과를 낼 수 있기 때문이다.
세번째는 보안(Security) 적인 측면을 들 수 있다.
예를 들어 데이터베이스 사용자 이름, 암호는 데이터베이스 연결을 수신하기 위해 문자열로 전달되는데, 만일 번지수의 문자열 값이 변경이 가능하다면 해커가 참조 값을 변경하여 애플리케이션에 보안 문제를 일으킬 수 있다.
자바 String 주소할당 방식
자바에서 String 변수를 선언하는 방법은 대표적으로 두가지 방식이 있다.
- 리터럴을 이용한 방식
- new 연산자를 이용한 방식
String str1 = "Hello"; // 문자열 리터럴을 이용한 방식
String str2 = "Hello";
String str3 = new String("Hello"); // new 연산자를 이용한 방식
String str4 = new String("Hello");
이 둘은 "Hello" 라는 문자열 값을 저장한다는 점에서는 차이가 없어 보이지만, JVM 메모리 내부적인 측면에서는 큰 차이가 존재한다.
그리고 이러한 차이 때문에 우리가 자바에서 스트링을 다루면서 고개를 갸우뚱 하게 되는 원인이 되기도 한다.
String Constant Pool
위에서 살펴봤듯이 문자열 데이터를 어떠한 방식으로 저장 함에 따라 메모리에서 적재되는 형태가 다르다.
예를 들어 문자열 리터럴을 변수에 저장하게 되면 이 값은 string constant pool이라는 영역에 존재하게 되고, new를 통해 String을 생성하면 이 값은 Heap 영역에 존재하게 된다.
위의 코드를 그림으로 표현하면 다음과 같이 된다.
여기서 눈 여겨 봐야 할 점은, 문자열 리터럴 값으로 할당한 두 변수 str1, str2 가 같은 메모리 주소를 가리킨다는 점이다.
위에서 String은 불변(immutable) 하다는 특징에서 언급했듯이, String 은 한번 사용이 되면 또다시 재사용될 확률이 높기 때문에, 이에 대한 적절한 대처 방법으로 Heap 영역 내에 문자열 상수의 Pool 을 유지하고 해당 Pool 로 사용자가 정의한 변수가 가지고 있는 value 들을 담고 같은 주소를 참조하도록 연결해주었기 때문이다.
그래서 자바를 프로그래밍 할때 new String() 방식을 안쓰고 바로 문자열 리터럴값을 할당하는 이유가 바로 메모리를 절약할 수 있다는 특징 때문에 지금까지 그렇게 코딩 해왔던 것이다.
※ 참고
String Constant Pool은 Java 6 이전에 Permenent Generation 에 있어 고정된 메모리 사이즈로 문제가 발생했었다.
하지만 Java 7, 8 ... 을 거쳐오며 Heap Size 를 통해 직접 Constant Pool 을 조절할 수 있도록 Heap 영역에서 관리하도록 하였다.
그래서 Constant Pool 의 Heap Size 를 조절하여 더 많은 Constant 를 유지할 수 있게 되었다.
string constant pool의 사이즈는 -xx:StringTableSize 옵션으로 설정이 가능하다. (default : 1009)
또한 string constant pool에 있는 문자열도 GC의 대상이 되기 때문에 효율적인 메모리 관리가 가능해 졌다.
추가적으로 String Constant Pool 은 내부적으로 HashTable 구조를 가지게 된다.
각 String Constant 을 hashing 하고 해당 데이터를 key 로 value를 찾기 때문에 기본적으로도 Constant Pool 의 성능은 어느 정도 보장이 되어있다고 한다.
문자열 비교하기 == , equals() 의 차이점
Java에서 int와 boolean과 같은 일반적인 데이터 타입의 비교는 == 연산자를 사용하여 비교한다.
하지만 String처럼 객체의 값을 비교할때는 == 이 아닌 equals() 라는 메소드를 사용하여 비교한다고 한번쯤은 들어봤을 것이다.
위에서 사용한 코드로 한번 비교 연산을 진행 해보자.
String str1 = "Hello"; // 문자열 리터럴을 이용한 방식
String str2 = "Hello";
String str3 = new String("Hello"); // new 연산자를 이용한 방식
String str4 = new String("Hello");
// 리터럴 문자열 비교
System.out.println(str1 == str2); // true
// 객체 문자열 비교
System.out.println(str3 == str4); // false
System.out.println(str3.equals(str4)); // true
// 리터럴과 객체 문자열 비교
System.out.println(str1 == str3); // false
System.out.println(str3.equals(str1)); // true
위에 예제의 결과에서 "Hello" 라는 문자열 값을 똑같이 가지고 있는데 각기 비교 결과가 다른지 의문을 가질 것이다.
우선 == 연산자와 equals() 메소드의 가장 큰 차이점은, == 연산자는 비교하고자 하는 두개의 대상의 주소값을 비교하는데 반해 equals 메소드는 비교하고자 하는 두개의 대상의 값 자체를 비교한다는 것이다.
그래서 리터럴 문자열 비교 System.out.println(str1 == str2) 같은 경우 둘이 같은 String Constant Pool에 있는 객체값을 바라보고 있기 때문에 참조하고 있는 주소값이 같아 true 가 반환 된 것이다.
하지만 객체를 비교하는 System.out.println(str3 == str4) 에서는 두 변수가 비록 같은 값을 바라보고 있지만, 이 값들은 힙 메모리에서 서로 다른 메모리 영역에 만들어져 있기 때문에 주소값이 달라 false를 반환하게 되는 것이다.
따라서 주소값을 비교하는게 아닌, 가지고 있는 고유의 문자열 값을 비교하면 간단히 해결되는 것이다.
그리고 equals() 메소드가 그 역할을 한다고 이해하면 된다.
동일성(identity)과 동등성(equality) 에 대한 내용은 아래 포스팅을 참고하자.
intern 메소드
문자열을 다루는데 있어 intern() 메소드의 존재 유무는 대학교 강의 조차 그냥 지나가는 경우가 많아 아마 처음 들어본 메소드 일 것이다.
intern() 메소드의 대한 지식은 자바에서 최적화된 문자열을 관리하는데 있어 거쳐가야 할 학습 과정이다.
사실 String을 리터럴로 선언할 경우 내부적으로 String의 intern() 메서드가 호출되게 된다.
intern() 메소드를 사용하면 해당 리터럴이 pool 에 존재하는지 확인하고, 존재하면 해당 pool 에 있는 리터럴을 리턴하고 없다면 리터럴을 pool 에 집어넣고 새로운 pool 주소값을 반환한다.
String a = "Hello";
/* 위 구문은 아래 구문으로 해석한다. */
String b = new String(new char[]{'H', 'e', 'l', 'l', 'o'}).intern();
즉, pool에 값이 있듯 없든 무조건 값이 pool에 생성되는 것이다.
그래서 intern() 을 이용하면 equals() 없이 문자열 비교가 가능해 진다.
String s1 = "Hello"; // 문자열 리터럴을 이용한 방식
String s2 = "Hello";
String s3 = new String("Hi"); // new 연산자를 이용한 방식
String s4 = "Hi";
s3 = s3.intern();
System.out.println(s4 == s3); // true
위의 코드에서 intern() 메소드 반환값을 재할당 받은 변수 s3 의 경우는 String s3 = "Hi" 로 해석할 수 있게 되고, String pool 에 이미 s4 가 만든 "Hi" 값이 존재하므로 결국 s4 와 s3변수는 같은 주소를 참조하게 된다.
그래서 == 연산자에 의해 같은 주소값을 가져 true를 반환했다.
참고