JVM·JMM / Java 컬렉션

허정석·4일 전

TIL

목록 보기
19/19
post-thumbnail

JVM·JMM / Java 컬렉션 내부 동작 정리

개요

JVM과 JMM은 Java 애플리케이션이 동작하는 핵심 기반 기술입니다. JVM은 Java 바이트코드를 실행하는 가상 머신으로, 플랫폼 독립성과 메모리 관리 기능을 제공합니다. JMM은 멀티스레드 환경에서 메모리 접근 규칙을 정의하여 동시성 문제를 해결합니다. HashMap을 비롯한 Java 컬렉션 프레임워크는 효율적인 데이터 저장과 조회를 위한 자료구조를 제공합니다. 이 글에서는 JVM의 메모리 구조와 클래스 로딩 과정, JMM의 가시성과 원자성 보장 메커니즘, 그리고 HashMap의 내부 동작 원리를 실무 관점에서 정리합니다.

목차

  1. JVM (Java Virtual Machine)
  2. JMM (Java Memory Model)
  3. volatile vs synchronized 비교
  4. HashMap 내부 동작
  5. Java 컬렉션 프레임워크 개요
  6. FAQ

1. JVM (Java Virtual Machine)

1.1 JVM의 정의와 개요

JVM(Java Virtual Machine)은 Java 바이트코드를 실행할 수 있는 가상의 컴퓨터입니다. JVM은 추상화된 실행 환경을 제공하여 Java 프로그램이 운영체제나 하드웨어에 종속되지 않고 동작할 수 있도록 합니다.

JVM의 핵심 역할은 바이트코드 실행, 메모리 관리(가비지 컬렉션), 플랫폼 독립성 제공("Write Once, Run Anywhere"), 그리고 바이트코드 검증을 통한 보안입니다.

실행 흐름을 살펴보면, Java 소스코드(.java)는 javac 컴파일러를 통해 플랫폼 독립적인 바이트코드(.class)로 변환됩니다. 이 바이트코드는 플랫폼 종속적인 JVM에서 실행되며, JVM은 운영체제와 하드웨어와 상호작용하여 프로그램을 동작시킵니다.


1.2 JVM의 3가지 핵심 영역

(1) 클래스 로더 서브시스템

클래스 로더 서브시스템은 .class 파일을 메모리로 로드하고 실행 가능한 상태로 만드는 시스템입니다. 이 과정은 로딩, 링킹, 초기화의 3단계로 나뉩니다.

로딩(Loading) 단계에서는 클래스 파일을 찾아서 메모리로 읽어들입니다. Bootstrap Class Loader는 최상위 로더로 JDK 내부 클래스(java.lang., java.util. 등)를 로드합니다. Extension Class Loader는 JDK 확장 디렉토리의 클래스를 처리하며, Application Class Loader는 개발자가 작성한 클래스와 외부 라이브러리(classpath)를 로드합니다.

위임 모델(Delegation Model)은 보안을 위한 핵심 메커니즘입니다. 자식 로더가 클래스 로드 요청을 받으면 먼저 부모에게 위임하고, 부모가 찾지 못하면 자식이 로드를 시도합니다. 이를 통해 사용자가 java.lang.String을 만들어도 JDK의 String이 우선적으로 사용됩니다.

링킹(Linking) 단계에서는 로드된 클래스를 실행 가능하게 준비합니다. 검증(Verify) 단계에서 바이트코드가 JVM 명세를 따르는지 검사하고, 준비(Prepare) 단계에서 static 변수에 기본값을 할당하여 메모리 공간을 확보합니다. 해석(Resolve) 단계에서는 심볼릭 참조를 실제 메모리 주소로 변환합니다.

초기화(Initialization) 단계에서는 static 변수에 실제 값을 할당하고 static 블록을 실행합니다.

public class Example {
    static int count = 10; // 준비: 0 → 초기화: 10
  
    static {
        count = 20; // 초기화 단계에서 실행
    }
}

(2) 런타임 데이터 영역

런타임 데이터 영역은 JVM이 프로그램을 실행하면서 사용하는 메모리 공간입니다. 이 영역은 스레드 간 공유 여부에 따라 두 가지로 구분됩니다.

스레드 공유 영역

Method Area(메서드 영역 / Metaspace)는 클래스 메타데이터(클래스 이름, 부모 클래스, 메서드 정보), static 변수, 상수 풀(Constant Pool)을 저장합니다. Java 8부터는 Metaspace로 변경되어 Native Memory를 사용합니다.

Heap(힙)은 모든 객체 인스턴스와 배열을 저장하는 공간입니다. 모든 스레드가 공유하며 가비지 컬렉션의 대상이 됩니다.

힙 메모리는 Generational GC 방식으로 구조화됩니다. Young Generation은 Eden 영역과 Survivor 0, 1 영역으로 나뉘며, Eden은 객체가 최초로 생성되는 공간이고 Survivor는 Eden에서 살아남은 객체를 저장합니다. Old Generation의 Tenured 영역은 오래 살아남은 객체를 저장합니다.

스레드 독립 영역

Stack(스택)은 메서드 호출과 지역 변수를 관리합니다. 각 스레드마다 독립적으로 생성되며, 지역 변수, 매개변수, 메서드 호출 정보를 저장합니다.

public void calculate() {
    int x = 10;           // Stack: primitive 값
    User user = new User(); // Stack: 참조, Heap: 실제 객체
}

스택 프레임(Stack Frame)은 메서드 호출 시마다 생성되며 메서드 종료 시 제거(pop)됩니다.

PC Register는 현재 실행 중인 JVM 명령어 주소를 저장하며 각 스레드마다 하나씩 존재합니다. Native Method Stack은 Java가 아닌 C/C++ 같은 Native 코드 실행에 사용됩니다.


(3) 실행 엔진

실행 엔진은 로드된 바이트코드를 실행하는 엔진입니다.

인터프리터는 바이트코드를 한 줄씩 해석하고 실행합니다. 빠르게 시작할 수 있지만 반복 실행 시 비효율적입니다.

JIT 컴파일러(Just-In-Time Compiler)는 자주 실행되는 코드(Hot Code)를 네이티브 기계어로 컴파일합니다. 컴파일된 코드는 캐시에 저장하여 재사용하므로 이후 실행 시 인터프리터 없이 직접 실행되어 매우 빠릅니다. 핫스팟 탐지는 메서드 호출 횟수를 추적하여 일정 횟수 이상이면 JIT 컴파일을 수행합니다.

가비지 컬렉터(GC)는 사용하지 않는 객체를 자동으로 메모리에서 제거합니다. Mark-Sweep-Compact 알고리즘을 사용하며, Mark 단계에서 사용 중인 객체를 표시하고, Sweep 단계에서 표시되지 않은 객체를 제거하며, Compact 단계에서 메모리 단편화를 방지합니다(선택적).


1.3 코드 실행 흐름 예시

public class HelloWorld {
    public static void main(String[] args) {
        String message = "Hello";
        System.out.println(message);
    }
}

실행 과정:

  1. javac HelloWorld.javaHelloWorld.class 생성
  2. java HelloWorld 실행 → JVM 시작
  3. 클래스 로더가 HelloWorld.class를 Method Area에 로드
  4. main 메서드 발견 → 새 스레드 생성 → Stack에 main 프레임 생성
  5. String message = "Hello" → Stack에 지역변수, Heap에 "Hello" 객체
  6. 실행 엔진이 바이트코드를 해석하여 실행

2. JMM (Java Memory Model)

2.1 JMM의 정의와 개요

JMM(Java Memory Model)은 멀티스레드 환경에서 스레드들이 메모리를 통해 어떻게 상호작용하는지를 정의한 명세(Specification)입니다.

JMM이 다루는 핵심 질문은 두 가지입니다. 스레드 A가 쓴 값을 스레드 B가 언제 볼 수 있는가, 그리고 여러 연산의 실행 순서가 보장되는가입니다.


2.2 왜 JMM이 필요한가

근본적인 이유

하드웨어마다 메모리 일관성 모델이 다릅니다. 각 CPU 제조사(Intel, ARM, SPARC 등)는 서로 다른 메모리 일관성 모델을 가지고 있습니다.

Intel x86은 강한 메모리 모델로 대부분의 메모리 순서를 보장하며 쓰기-쓰기 재배치가 거의 없습니다. 반면 ARM은 약한 메모리 모델로 많은 재배치를 허용하며 성능 최적화를 적극적으로 수행합니다. 이로 인해 같은 Java 코드가 플랫폼마다 다르게 동작할 수 있습니다.

JMM이 없다면 발생하는 문제

// 공유 변수
int data = 0;
boolean ready = false;

// Thread 1
public void producer() {
    data = 100;      // 1번
    ready = true;    // 2번
}

// Thread 2
public void consumer() {
    if (ready) {     // 3번
        print(data); // 4번
    }
}

Intel x86에서는 메모리 순서가 비교적 강하게 보장되어 대부분 예상대로 동작하며 data = 100이 출력됩니다. 그러나 ARM에서는 명령어 재배치가 자유로워 2번이 1번보다 먼저 실행될 수 있습니다. 이 경우 ready는 true인데 data는 0이 출력되는 버그가 발생합니다. 같은 Java 코드가 플랫폼마다 다르게 동작하는 문제가 발생합니다.


JMM이 제공하는 해결책

JMM은 플랫폼 독립적인 표준 계약입니다. 개발자와 JVM 구현체 사이의 표준 명세 역할을 합니다.

개발자에게는 "volatile, synchronized 같은 규칙을 따르면, 어떤 플랫폼에서든 이렇게 동작할 것을 보장합니다"라고 약속합니다. JVM 구현체에게는 "이 규칙에 맞춰 각 플랫폼에서 적절한 메모리 배리어를 삽입하세요"라고 요구합니다.

volatile 규칙이 적용된 예를 살펴보겠습니다.

private volatile boolean ready = false;

// Thread 1
ready = true;

// Thread 2
if (ready) {
    // ...
}

이 volatile 규칙을 구현하기 위해 JVM은 각 플랫폼에 맞는 메모리 배리어를 삽입합니다. Intel x86에서는 기본적으로 순서 보장이 강하므로 최소한의 배리어를 사용하고, ARM에서는 강력한 메모리 배리어(dmb)를 삽입하여 순서를 보장하며, SPARC에서는 해당 플랫폼의 배리어(stbar)를 사용합니다. 개발자는 플랫폼 차이를 몰라도 됩니다.

JMM은 "멀티스레드 환경의 Write Once, Run Anywhere"를 가능하게 하는 표준 명세입니다.


2.3 JMM의 핵심 개념

(1) 원자성 (Atomicity)

원자성은 연산이 중단 없이 완전히 실행되거나, 전혀 실행되지 않음을 보장합니다.

문제 상황을 살펴보겠습니다.

private int count = 0;

public void increment() {
    count++; // 원자적이지 않음!
}

count++는 실제로 3단계로 구성됩니다.

  1. count 값 읽기(READ)
  2. 값에 1 더하기(ADD)
  3. count에 쓰기(WRITE)

멀티스레드 실행 시 초기값이 count = 0일 때, Thread 1이 READ (0) → ADD (1) 후 중단되고, Thread 2가 READ (0) → ADD (1) → WRITE (1)을 수행한 뒤, Thread 1이 재개되어 WRITE (1)을 수행하면 결과는 count = 1이 됩니다. 예상 결과는 2이지만 실제로는 1입니다.


(2) 가시성 (Visibility)

가시성은 한 스레드가 쓴 값을 다른 스레드가 즉시 볼 수 있음을 보장합니다.

문제 상황을 살펴보겠습니다.

private boolean stopRequested = false;

// Thread 1
public void backgroundWork() {
    while (!stopRequested) {
        // 작업
    }
}

// Thread 2
public void stopWork() {
    stopRequested = true; // Thread 1이 못 볼 수 있음!
}

Thread 1이 CPU 캐시에서 읽고, Thread 2가 자신의 캐시에만 쓰면, 메인 메모리에 쓰기 전까지 Thread 1은 변경을 못 봅니다.


(3) 순서성 (Ordering)

순서성은 프로그램의 명령어 실행 순서가 예상대로 보장됨을 의미합니다.

문제 상황을 살펴보겠습니다.

// 초기화
private Config config = null;
private boolean initialized = false;

// Thread 1
public void initialize() {
    config = new Config();  // 1단계
    initialized = true;      // 2단계
}

// Thread 2
public Config getConfig() {
    if (initialized) {
        return config; // config가 null일 수 있음!
    }
    return null;
}

CPU가 1단계와 2단계 순서를 바꿀 수 있어서 문제가 발생합니다.

JMM은 이 3가지를 보장함으로써 플랫폼 독립성(이식성), 추상화, 안정성을 제공합니다.

추상화 (Abstraction)

개발자는 volatile, synchronized만 이해하면 되고, JVM은 각 플랫폼에 맞는 메모리 배리어를 삽입합니다.

이식성 (Portability)

한 번 작성하면 Intel, ARM, SPARC 등 모든 플랫폼에서 동일하게 동작합니다.

안정성 (Safety)

JMM 규칙을 따르면 플랫폼에 관계없이 동시성 버그를 예방할 수 있습니다.


2.4 Happens-Before 관계

JMM의 핵심 규칙은 "A happens-before B" → B는 A의 결과를 볼 수 있음입니다.

주요 규칙은 다음과 같습니다. 프로그램 순서 규칙에 따라 같은 스레드에서 먼저 작성된 코드가 먼저 실행됩니다. volatile 규칙에서는 volatile 쓰기가 이후 volatile 읽기보다 먼저 발생합니다. synchronized 규칙에서는 unlock이 이후 lock보다 먼저 발생합니다. 스레드 시작 규칙에서는 thread.start()가 그 스레드의 모든 동작보다 먼저 실행됩니다.


3. volatile vs synchronized 비교

3.1 핵심 개념

volatile

volatile은 변수가 항상 메인 메모리와 직접 상호작용하도록 강제합니다.

동작 방식을 살펴보면, 쓰기 시에는 CPU 캐시에서 즉시 메인 메모리로 반영하고 다른 캐시를 무효화합니다. 읽기 시에는 CPU 캐시를 무시하고 메인 메모리에서 직접 읽습니다. 메모리 배리어(Memory Barrier) 삽입으로 명령어 재배치를 방지합니다.

volatile은 가시성과 순서성을 보장하지만, 원자성은 보장하지 않습니다(복합 연산 보호 안 됨).

적합한 사용 사례는 다음과 같습니다.

private volatile boolean running = true;

public void stop() {
    running = false; // 단일 쓰기 - volatile로 충분
}

public void run() {
    while (running) {
        // 작업
    }
}

synchronized

synchronized는 모니터 락(Monitor Lock)을 사용해 임계 영역(Critical Section)을 보호합니다.

동작 방식을 살펴보면, 객체의 모니터 락을 사용하며, 진입 시에는 Load Barrier로 메인 메모리에서 최신 값을 읽고, 종료 시에는 Store Barrier로 모든 변경을 메인 메모리에 씁니다.

synchronized는 원자성(블록 전체가 원자적), 가시성, 순서성, 상호 배제(한 번에 하나의 스레드만)를 모두 보장합니다.

락 최적화

Java는 성능을 위해 3단계 락을 사용합니다. 편향 락(Biased Lock)은 같은 스레드가 반복 획득 시 락을 생략하며, 경량 락(Lightweight Lock)은 CAS 연산으로 빠르게 처리하고, 중량 락(Heavyweight Lock)은 충돌이 많을 때 OS 뮤텍스를 사용합니다.

적합한 사용 사례는 다음과 같습니다.

private int counter = 0;

public synchronized void increment() {
    counter++; // 원자성 + 가시성 보장
}

3.2 비교표

특징volatilesynchronized
원자성❌ (복합 연산 불가)✅ (블록 전체 원자적)
가시성
순서성
상호 배제
성능빠름 (락 없음)상대적으로 느림 (락 사용)
사용 사례단순 플래그, 상태 변수복합 연산, 여러 필드 업데이트

3.3 실무 선택 가이드

volatile 사용

단순 플래그나 상태 변수에 적합합니다.

private volatile boolean running = true;
private volatile long timestamp = System.currentTimeMillis();

synchronized 사용

복합 연산이나 여러 필드를 함께 업데이트할 때 적합합니다.

public synchronized void transfer(Account from, Account to, int amount) {
    from.balance -= amount;
    to.balance += amount;
}

AtomicInteger 사용

단일 숫자 카운터에 적합합니다.

private AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // 원자적 증가

4. HashMap 내부 동작

4.1 HashMap의 기본 구조

HashMap은 배열 + 연결리스트/트리 구조를 사용하는 해시 테이블 기반 자료구조입니다.

기본 데이터 구조는 다음과 같습니다. Node 배열(table)은 실제 데이터를 저장하는 배열이며, 각 배열 요소(버킷)에는 Node(key-value 쌍)가 들어갑니다. 해시 충돌 시 연결리스트 또는 트리로 연결됩니다.

transient Node<K,V>[] table;

static class Node<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}

4.2 핵심 상수

static final int DEFAULT_INITIAL_CAPACITY = 16;  // 초기 용량
static final float DEFAULT_LOAD_FACTOR = 0.75f;  // 로드 팩터
static final int TREEIFY_THRESHOLD = 8;          // 트리화 임계값
static final int UNTREEIFY_THRESHOLD = 6;        // 트리 해제 임계값
static final int MIN_TREEIFY_CAPACITY = 64;      // 트리화 최소 크기

4.3 해시 함수와 인덱스 계산

해시 함수와 인덱스 계산은 3단계 과정으로 진행됩니다.

1단계는 hashCode() 계산입니다.

String key = "hello";
int hashCode = key.hashCode();

2단계는 해시 확산 (Hash Spreading)입니다.

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

상위 16비트를 하위 16비트와 XOR하여 해시 분산을 개선합니다.

3단계는 배열 인덱스 계산입니다.

int index = (n - 1) & hash;  // n = table.length

& 연산을 사용하는 이유는 % 연산보다 훨씬 빠른 비트 연산이기 때문입니다. 단, n이 2의 제곱수일 때만 동작하며, hash & (n - 1)hash % n과 동일합니다(n이 2의 제곱수일 때). 이것이 HashMap의 용량이 항상 2의 제곱수인 이유입니다.


4.4 put() 메서드 흐름

public V put(K key, V value) {
    // 1. 해시 계산
    int hash = hash(key);
  
    // 2. 인덱스 계산
    int index = (table.length - 1) & hash;
  
    // 3. 해당 위치가 비어있으면 새 노드 생성
    if (table[index] == null) {
        table[index] = new Node(hash, key, value, null);
    }
    // 4. 충돌 발생
    else {
        // 4-1. 키가 같으면 값 업데이트
        // 4-2. 트리면 트리에 삽입
        // 4-3. 리스트면 리스트 끝에 추가
        //      → 길이가 8 이상이면 트리로 변환
    }
  
    // 5. 크기가 임계값 초과하면 리사이징
    if (++size > threshold) {
        resize();
    }
}

4.5 해시 충돌 처리

체이닝 (Separate Chaining)

체이닝은 같은 인덱스에 여러 요소를 연결리스트로 저장하는 방식입니다.

Java 7 이하에서는 연결리스트만 사용하며 검색 시간 복잡도는 O(n)입니다.

Java 8 이상에서는 연결리스트에서 트리로 전환됩니다. 조건은 두 가지입니다. 리스트 길이가 8 이상이고, 테이블 크기가 64 이상일 때입니다. 이 경우 검색 시간 복잡도는 O(log n)입니다.

Red-Black Tree를 사용합니다. Red-Black Tree는 균형 이진 탐색 트리로 최악의 경우에도 O(log n)을 보장합니다.

트리 해제는 노드가 6개 이하로 줄면 다시 리스트로 전환됩니다. 히스테리시스로 반복 변환을 방지합니다.


4.6 리사이징 (Rehashing)

리사이징은 다음 조건에서 트리거됩니다.

if (++size > threshold)
    resize();

// threshold = capacity * loadFactor
// 기본: 16 * 0.75 = 12

13번째 요소 추가 시 리사이징이 발생합니다.

리사이징 과정은 다음과 같습니다. 배열 크기를 2배로 확장하고(16 → 32), 모든 요소를 새 배열로 재배치합니다.

최적화 트릭

용량이 2배가 되면, 각 요소의 새 인덱스는 원래 인덱스 그대로 또는 원래 인덱스 + oldCapacity 중 하나입니다.

if ((e.hash & oldCap) == 0) {
    // 인덱스 그대로
} else {
    // 인덱스 + oldCap
}

해시 재계산이 불필요합니다.


4.7 equals()와 hashCode() 규약

규칙

equals()가 true면 hashCode()도 같아야 합니다.

if (a.equals(b)) {
    assert a.hashCode() == b.hashCode();
}

hashCode()가 다르면 equals()는 false입니다.

잘못된 구현 예시를 살펴보겠습니다.

class User {
    private String name;
  
    @Override
    public boolean equals(Object o) {
        // equals만 오버라이드
    }
    // hashCode() 미구현 → 버그!
}

올바른 구현 예시는 다음과 같습니다.

class User {
    private String name;
    private int age;
  
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return age == user.age && Objects.equals(name, user.name);
    }
  
    @Override
    public int hashCode() {
        return Objects.hash(name, age); // 같은 필드 사용!
    }
}

4.8 시간 복잡도

이상적인 경우(충돌 없음) put, get, remove는 O(1)입니다.

충돌이 있는 경우 연결리스트는 O(n)이고, 트리(Java 8+)는 O(log n)입니다.

평균적으로 로드 팩터 0.75이면 평균 버킷당 0.75개가 되므로 O(1 + 0.75) ≈ O(1)입니다.


5. Java 컬렉션 프레임워크 개요

5.1 핵심 계층 구조

Collection 인터페이스는 세 가지 주요 서브 인터페이스로 나뉩니다.

List는 순서가 있고 중복을 허용합니다. ArrayList는 동적 배열이고, LinkedList는 이중 연결 리스트이며, Vector는 동기화된 ArrayList입니다.

Set은 순서가 없고 중복을 불허합니다. HashSet은 해시 테이블을 사용하고, LinkedHashSet은 순서를 유지하는 HashSet이며, TreeSet은 정렬된 Set으로 Red-Black Tree를 사용합니다.

Queue는 FIFO 구조입니다. PriorityQueue는 우선순위 큐이고, ArrayDeque는 양방향 큐입니다.

Map 인터페이스는 Collection과 별도의 계층입니다. HashMap은 해시 테이블을 사용하고, LinkedHashMap은 순서를 유지하는 HashMap이며, TreeMap은 정렬된 Map으로 Red-Black Tree를 사용합니다. Hashtable은 동기화된 HashMap으로 레거시입니다.


5.2 컬렉션 선택 기준

요구사항추천 컬렉션시간 복잡도
빠른 인덱스 접근ArrayListO(1)
빈번한 삽입/삭제 (중간)LinkedListO(1)
중복 제거HashSetO(1)
정렬된 순서 유지TreeSetO(log n)
Key-Value 저장HashMapO(1)
정렬된 Key-ValueTreeMapO(log n)

6. FAQ

JVM과 JMM의 차이는 무엇인가요?

JVM은 Java 실행 환경으로 Heap, Stack 등의 물리적 메모리 공간을 관리합니다. JMM은 멀티스레드 환경의 메모리 가시성 규칙을 정의하는 규약/스펙입니다.

JVM은 "어디에" 데이터가 저장되는지를 정의하고, JMM은 "언제" 데이터가 보이는지를 정의합니다.

volatile은 왜 원자성을 보장하지 못하나요?

volatile은 개별 읽기/쓰기의 가시성만 보장하지, 복합 연산의 원자성은 보장하지 않습니다.

counter++는 실제로 3단계로 구성됩니다. 메모리에서 counter 읽기, 레지스터에서 +1, 메모리에 counter 쓰기입니다.

이 3단계가 별도로 실행되므로, 중간에 다른 스레드가 끼어들 수 있습니다.

Spring Bean의 인스턴스 변수는 왜 위험한가요?

Spring의 @Service, @Controller는 기본적으로 싱글톤입니다.

@Service
public class UserService {
    private int requestCount = 0; // 모든 HTTP 요청 스레드가 공유!
  
    public void process() {
        requestCount++; // Race Condition 발생!
    }
}

여러 HTTP 요청이 동시에 들어오면 여러 Tomcat 스레드가 같은 UserService 인스턴스의 필드에 동시 접근합니다.

해결 방법

  • 인스턴스 변수 제거 (Stateless 설계)
  • AtomicInteger 사용
  • synchronized 사용

HashMap의 초기 용량을 2의 제곱수로 하는 이유는 무엇인가요?

인덱스 계산을 빠르게 하기 위해서입니다.

일반적인 방법은 index = hash % capacity로 느린 % 연산을 사용합니다. HashMap은 index = hash & (capacity - 1)로 빠른 비트 연산을 사용합니다.

capacity가 2의 제곱수일 때만 hash & (capacity - 1) = hash % capacity가 성립합니다.

예를 들어 capacity = 16 (2^4)일 때, capacity - 1 = 15 = 0000 1111이고, hash & 15는 hash의 하위 4비트만 추출하므로 hash % 16과 동일합니다.

로드 팩터가 0.75인 이유는 무엇인가요?

시간과 공간의 트레이드오프입니다.

너무 낮으면 (0.5): 충돌이 적고 빠르지만 메모리를 낭비하며, 리사이징이 자주 발생합니다.

너무 높으면 (1.0): 메모리는 효율적이지만 충돌이 많아 느리고, 리사이징이 드물게 발생합니다.

0.75: 평균 버킷당 0.75개 요소로 O(1) 성능을 유지하면서 메모리 효율적입니다.

Java 7과 Java 8의 HashMap 차이는 무엇인가요?

가장 큰 차이는 충돌 처리 방식입니다.

Java 7은 연결리스트만 사용하며, 충돌이 많으면 O(n) 성능 저하가 발생하고, 리사이징 중 순환 참조 버그가 가능합니다.

Java 8은 연결리스트와 Red-Black Tree를 함께 사용합니다. 리스트 길이가 8 이상이면 트리로 변환하며, 최악의 경우에도 O(log n)을 보장합니다. 해시 충돌 공격(Hash DoS)에 대한 방어력도 향상되었습니다.

HashMap에서 Key로 사용할 객체의 요구사항은 무엇인가요?

equals()와 hashCode()를 올바르게 구현해야 합니다. a.equals(b)가 true면 a.hashCode() == b.hashCode()여야 하고, a.hashCode() != b.hashCode()a.equals(b)는 false여야 합니다.

불변(Immutable) 객체를 권장합니다. Key 객체가 변경되면 hashCode()가 달라질 수 있고, 원래 저장된 인덱스에서 찾을 수 없게 됩니다.

좋은 예: String, Integer, Long

나쁜 예: 변경 가능한 커스텀 객체

volatile과 synchronized를 언제 사용하나요?

volatile 사용: 단순 플래그, 상태 변수

private volatile boolean running = true;

synchronized 사용: 복합 연산, 여러 필드 업데이트

public synchronized void withdraw(int amount) {
    if (balance >= amount) {
        balance -= amount;
    }
}

AtomicInteger 사용: 단일 숫자 카운터

private AtomicInteger counter = new AtomicInteger(0);

실무에서 동시성 문제를 어떻게 해결하나요?

우선순위는 다음과 같습니다.

인스턴스 변수 제거 (Stateless 설계) - 가장 권장합니다. Thread-safe 컬렉션 (ConcurrentHashMap)을 사용하거나, Atomic 클래스 (AtomicInteger)를 활용합니다. 복합 연산에는 synchronized를 사용하고, 필요시 DB 락 (비관적 락, 낙관적 락) 또는 분산 락 (Redis)을 사용합니다.

핵심 원칙

  • 가능하면 상태를 갖지 않도록 설계 (Stateless)
  • 불가피하게 상태가 필요하면 Thread-safe 방법 사용
  • 지역 변수와 메서드 파라미터를 최대한 활용

면접에서 "HashMap의 시간복잡도가 O(1)인 이유"를 물으면 어떻게 답하나요?

HashMap은 배열을 기반으로 하며, 해시 함수를 통해 키를 배열 인덱스로 직접 변환합니다.

index = (배열 크기 - 1) & hash(key)

배열 접근은 O(1)이므로, 충돌이 없다면 O(1)입니다.

충돌이 있어도 좋은 해시 함수와 로드 팩터 0.75를 사용하면 평균적으로 각 버킷에 1개 미만의 요소가 들어가므로 실질적으로 O(1)에 가깝습니다.

Java 8부터는 충돌이 많을 때 트리를 사용해 최악의 경우에도 O(log n)을 보장합니다.


학습 요약

핵심 포인트

JVM은 클래스 로더(로딩-링킹-초기화), 런타임 데이터 영역(Method Area, Heap, Stack), 실행 엔진(인터프리터, JIT, GC)으로 구성되며 플랫폼 독립성을 제공합니다.

JMM은 멀티스레드 환경의 메모리 가시성 규칙을 정의하며 원자성, 가시성, 순서성을 보장합니다.

volatile vs synchronized를 비교하면, volatile은 가시성만 보장하며 단순 플래그용으로 적합하고, synchronized는 원자성 + 가시성 + 상호 배제를 보장하며 복합 연산용으로 적합합니다.

HashMap은 배열 + 연결리스트/트리 구조를 사용하며, 용량은 항상 2의 제곱수로 빠른 인덱스 계산이 가능합니다. 로드 팩터 0.75로 평균 O(1) 성능을 보장하며, Java 8 이상에서는 충돌이 많으면 트리로 변환하여 O(log n)을 보장합니다.

면접 대비 체크리스트

  • JVM의 메모리 구조 설명 가능 (Heap, Stack, Method Area)
  • 클래스 로더의 3단계 과정 설명 가능
  • JMM의 원자성, 가시성, 순서성 설명 가능
  • volatile과 synchronized의 차이점 명확히 구분
  • Spring Bean의 동시성 문제와 해결 방안 설명 가능
  • HashMap의 시간복잡도가 O(1)인 이유 설명 가능
  • HashMap의 해시 충돌 처리 방식 설명 가능
  • equals()와 hashCode() 규약 설명 가능

0개의 댓글