[Java] 딥 다이브 (String, Wrapper Class, Asynchronize)

String

String의 불변성과 final

String은 클래스 자체가 final이고, 내부에 문자 배열도 final이라 한 번 생성하면 내용이 절대 바뀌지 않는 불변객체이다. 이런 연산들(+, substring, replace 등)은 기존 객체를 수정하는 게 아니라 새로운 String 객체를 생성해 반환한다. 불변성이 있기 때문에 멀티스레드 환경에서도 동기화 없이 안전하게 공유할 수 있고, 해시값이 변하지 않아 HashMap 키로 쓰기에도 적합합니다. 그렇기 때문에 String을 + 와 사용하는 경우 아래와 같은 원리로 문제가 발생한다.

  • 매 반복마다 새로운 String이 생성되고, 기존 문자열 전체를 새 배열에 복사
  • 시간 복잡도 관점에서 사실상 누적 O(n²) 수준이 되어 큰 입력에서 속도가 급격히 느려짐
  • +로 생성된 수많은 임시 String들이 GC 대상이 되어 메모리 낭비

 

String Pool과 리터럴, new, intern()

"abc" 같은 리터럴로 만든 문자열은 JVM이 String Pool이라는 공간에 저장해두고, 같은 리터럴이 다시 등장하면 기존 객체를 재사용한다. String s1 = "abc"; String s2 = "abc"; 에서 s1 == s2 는 true 가 되는데, 둘 다 Pool의 같은 객체를 참조하기 때문이다. new String("abc")는 동일한 내용이 Pool에 있어도 Heap에 무조건 새 객체를 만든다는 점이 다르다. intern()은 new String("abc").intern() 처럼 호출하면, Pool에 같은 내용이 있으면 그 객체의 참조를, 없으면 Pool에 등록 후 그 참조를 반환한다.

 

equals vs ==, 그리고 비교 시 주의점

== 연산자는 참조 주소(같은 객체 인스턴스인지) 를 비교하고, **equals()**는 문자열 내용이 같은지를 비교한다. String은 **equals()**가 내용 기반으로 오버라이드 되어 있으므로, 문자열 비교 시에는 항상 **==**가 아니라 equals() 또는 **equalsIgnoreCase()**를 사용해야 한다. String Pool 때문에 어떤 경우에는 우연히 **==**가 **true**가 될 수 있어 더 헷갈리므로, “문자열 비교엔 무조건 equals” 라고 생각하는 게 안전하다.

 

 

Wrapper Class

Java generics 에는 primitive type 을 쓸 수 없는 이유

Java Generic은 Type Erasure로 작동하기 때문이다. Java Generics는 컴파일 타임에만 존재하고, 런타임에는 타입 정보가 지워진다. 즉, List<String>은 컴파일 후 List<Object>가 된다. 또한, Primitive type(int, double 등)은 Object를 상속하지 않고, Generics는 Object 기반이므로 primitive type을 직접 사용할 수 없다.

 

 

Asynchronize

Synchronized의 작동 원리는?

Synchronized는 임계 구역을 보호하며 한 번에 하나의 스레드만 접근할 수 있도록 동시성을 처리하는 기술이다.

자바의 모든 객체에는 고유한 락(= 모니터, 모니터 락, intrinsic lock) 이 하나씩 붙어 있다. synchronized를 사용하면, 해당 객체(또는 클래스)의 모니터 락을 획득한 스레드만 그 블록/메서드에 진입할 수 있습니다.다른 스레드는 그 락이 풀릴 때까지 BLOCKED 상태로 EntrySet에서 대기하다가, 락이 반환되면 순서대로 진입한다.

  1. synchronized의 대상에 대한 객체의 모니터 락을 얻는다.
  2. public synchronized void foo() { ... } // this 객체의 모니터 락 public static synchronized void bar() { ... } // MyClass.class의 모니터 락 public void baz() { synchronized (lockObj) { ... } // lockObj의 모니터 락 }
  3. synchronized 블록/메서드 실행
    • 해당 객체의 모니터 락을 획득 시도
    • 다른 스레드가 이미 그 락을 가지고 있으면, 현재 스레드는 EntrySet에서 BLOCKED 상태로 대기
  4. 락을 가진 블록/메서드가 종료
    • 모니터 락을 자동으로 반환
    • EntrySet에 있던 스레드 중 하나가 락을 획득하고 임계 영역에 진입

 

Volatile은 왜 쓰는가?

Volatile로 선언된 변수는 멀티 스레드 환경에서 가장 최신에 반영된 값이 모든 스레드에서 보이도록 하는 키워드이다. 즉, 항상 메인 메모리에서 읽기/쓰기하기 때문에 CPU 캐시에 오래된 값을 들고 있지 않고, 읽을 때마다 최신 값을 본다. Volatile을 사용하지 않고 다음 코드를 돌릴 경우, 스레드마다 CPU 캐시에 있는 값을 사용할 수도 있기 항상 최신값을 보도록 보장하지 않는다.

 

boolean running = true;

Thread t1: while (running) { ... } // 루프
Thread t2: running = false;        // 종료 신호

 

static도 메모리에서 공유되므로 Volatile과 같은거 아닌가? 라고 생각할 수도 있다. 그러나 공유한다고 해서 이것이 항상 최신 값을 본다는 보장이 없다. 각 CPU 코어는 자기 캐시에 이 static 변수를 복사해 둘 수 있고, 한 코어에서 값을 바꿔도, 그 값이 메인 메모리로 flush되기 전까지 다른 코어는 이전 값을 캐시에서 계속 읽을 수 있다.

 

항상 최신 값을 보도록하지 경쟁 상태를 해결해주지 못한다. 즉, Volatile은 가시성만 보장할 뿐 상호배제까진 보장해주지 못하므로 주의하자. 만약 상호 배제를 보장하고자 한다면 Atomic 객체를 사용하자.

'Java & Kotlin' 카테고리의 다른 글

[Java] Java에서의 Lock-Free (Atomic, Volatile)  (0) 2025.11.30