CodeGym /행동 /JAVA 25 SELF /synchronized, volatile: 문법과 사용법

synchronized, volatile: 문법과 사용법

JAVA 25 SELF
레벨 52 , 레슨 1
사용 가능

1. 키워드 synchronized: 왜 필요하고 어떻게 쓰는가

Java에서 키워드 synchronized는 화장실 문에 붙어 있는 “사용 중!” 표지와 같습니다. 한 스레드가 ‘임계 구역’ 안에 있는 동안 나머지 스레드들은 예의 바르게 자신의 차례를 기다립니다. 첫 번째가 나오면 다음 스레드가 들어가서 자신의 코드를 실행할 수 있습니다.

문법: 블록과 메서드

동기화 블록

synchronized (object) {
    // 임계 구역
}
  • object — 잠금을 “걸고 싶은” 임의의 객체입니다. 한 스레드가 이 블록을 실행하는 동안, 동일한 객체로 보호되는 블록에 들어가려는 다른 스레드들은 기다립니다.

동기화 메서드

public synchronized void increment() {
    // 임계 구역
}
  • 여기서는 “잠금”이 객체 자신(this)에 걸립니다. 즉, 한 번에 하나의 스레드만 이 객체의 어떤 동기화된 메서드든 실행할 수 있습니다.

정적 synchronized 메서드

public static synchronized void foo() {
    // 임계 구역
}
  • 여기서는 개별 객체가 아니라 클래스 수준(ClassName.class)에서 잠금이 걸립니다.

내부 동작 방식

스레드가 동기화된 블록이나 메서드에 들어가면 객체의 모니터(정적 메서드의 경우 클래스의 모니터)를 획득합니다. 모니터가 이미 점유되어 있으면 스레드는 기다립니다. 모니터가 해제되는 즉시 다음 스레드가 들어갈 수 있습니다.

2. 예제: 동기화 유무에 따른 카운터 증가

동기화 없이

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
public class CounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("최종 값: " + counter.getCount());
    }
}

기대값: 2000
실제값: 더 작을 수 있음(예: 1995, 1987...)이며, 실행할 때마다 매번 다른 ‘깜짝 결과’가 나옵니다.

왜 그럴까요? 연산 count++는 원자적이지 않습니다. 이 연산은 세 단계 — 값을 읽고, 증가시키고, 다시 기록 — 로 분해됩니다. 두 스레드가 이것을 동시에 수행하면 서로의 결과를 덮어쓸 수 있습니다.

해결: synchronized

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

이제 한 번에 하나의 스레드만 increment() 메서드를 실행할 수 있습니다. 최종 값은 항상 2000입니다.

대안: 동기화 블록

public class Counter {
    private int count = 0;

    public void increment() {
        synchronized (this) {
            count++;
        }
    }
}

결과는 동일합니다. 메서드 전체가 아니라 필요한 부분만 동기화할 수 있습니다.

3. ‘객체 모니터’ 소개

모니터는 Java의 모든 객체에 내장된 “잠금”입니다. synchronized(object)라고 쓰면 스레드는 해당 객체를 “잠그려” 시도합니다. 잠금이 비어 있으면 스레드가 얻고, 아니라면 자신의 차례를 기다립니다. 스레드가 블록에서 빠져나오는 순간 잠금은 해제됩니다.

중요! 서로 다른 객체에 대해 동기화하면 스레드들은 서로 기다리지 않습니다. 따라서 어떤 객체에 동기화할지 올바르게 선택하는 것이 매우 중요합니다.

정적 synchronized 메서드

공유 자원이 특정 객체가 아니라 클래스의 모든 인스턴스에 공통인 것(예: 정적 변수)일 때는 클래스 수준에서 동기화해야 합니다.

public class StaticCounter {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static int getCount() {
        return count;
    }
}

이는 다음과 동일합니다:

public static void increment() {
    synchronized (StaticCounter.class) {
        count++;
    }
}

잠금은 특정 인스턴스가 아니라 클래스 객체(Class)에 걸립니다.

4. 키워드 volatile: 무엇이며 왜 필요한가

스레드 간 가시성 문제

Java에서 각 스레드는 성능을 위해 변수 값을 캐시할 수 있습니다. 즉 한 스레드가 변수를 변경해도, 다른 스레드는 자신의 로컬 캐시에 저장된 오래된 값을 계속 읽어 “변경을 눈치채지 못할” 수 있습니다. 이는 스레드 간 신호를 주고받는 플래그에서 특히 치명적입니다.

volatile의 동작

변수가 volatile로 선언되면 다음을 의미합니다:

  • 모든 스레드는 캐시를 거치지 않고 항상 주 메모리에서 읽고 주 메모리에 기록합니다.
  • 변수에 대한 모든 변경은 즉시 모든 스레드에서 보입니다.

하지만! volatile 자체는 원자성을 보장하지 않습니다(boolean, int 등 원시 타입의 단순한 읽기/쓰기 정도는 예외). 대입보다 복잡한 작업을 한다면 동기화가 필요합니다.

예시: 종료 플래그

public class Worker extends Thread {
    private volatile boolean running = true;

    public void run() {
        while (running) {
            // 유용한 작업 수행
        }
        System.out.println("스레드가 종료되었습니다");
    }

    public void shutdown() {
        running = false;
    }
}
Worker w = new Worker();
w.start();
// ... 잠시 후
w.shutdown();

volatile이 없으면 스레드가 플래그 변경을 “눈치채지 못해” 영원히 루프를 돌 수 있습니다(특히 다중 코어 시스템에서). volatile을 사용하면 기대한 대로 동작합니다.

5. volatile의 한계: 비원자성

많은 입문자들이 “volatile int로 만들면 count++를 그냥 써도 된다”고 생각합니다. 유감스럽지만 그렇지 않습니다:

private volatile int count = 0;

public void increment() {
    count++;
}

오류! 연산 count++는 여전히 원자적이지 않습니다 — (1) 읽기, (2) 증가, (3) 다시 기록의 세 단계로 이뤄집니다. 두 스레드가 동시에 같은 값을 읽고 둘 다 증가시킨 뒤 같은 결과를 기록하면, 증가 한 번이 “사라집니다”.

결론: volatile은 변경의 가시성만 보장할 뿐, 복잡한 연산에서의 경쟁 상태를 막지는 못합니다.

6. synchronized는 언제, volatile은 언제?

  • volatile — 한 스레드가 쓰고 다른 스레드가 읽는 단순 플래그(예: boolean)가 있을 때. 예: 스레드 종료, 이벤트 신호.
  • synchronized — 증가 같은 연산, 여러 변수 변경, 자료구조 조작 등 복합 연산의 원자성이 필요할 때.

요약 표

시나리오 volatile synchronized
스레드 간 신호 전달
원자적 연산(증가)
임계 구역에서 여러 단계 작업
변경 가시성만 필요

7. synchronizedvolatile 사용 시 흔한 실수

실수 №1: 잘못된 객체를 대상으로 동기화. 로컬 변수나 스레드마다 다른 객체에 동기화하면 아무 보호도 되지 않습니다.

Object lock = new Object();
synchronized (lock) {
    // ...
}

각 스레드가 자신만의 lock을 만들면 아무 소용 없습니다. 모든 스레드가 공유하는 하나의 동기화 지점, 즉 공용 객체가 필요합니다.

실수 №2: volatile에서 원자성을 기대. volatile은 가시성만 보장합니다. count++ 같은 연산은 동기화 없이는 여전히 안전하지 않습니다.

실수 №3: 코드 범위를 지나치게 크게 동기화. 메서드 전체를 동기화하는 대신 한 줄만 필요하다면, 불필요하게 다른 스레드를 막아 성능을 잃게 됩니다. “임계 구역”을 가능한 작게 유지하세요.

실수 №4: 정적 데이터에 ‘정적’ 동기화를 잊음. 정적 변수인데 this에 동기화해 봐야 소용없습니다. 정적 데이터에는 클래스 수준 동기화가 필요합니다: synchronized(ClassName.class).

실수 №5: 문자열 리터럴에 동기화. 문자열에 대한 동기화는 위험합니다. 동일한 리터럴은 JVM에서 인터닝되기 때문에, 프로그램의 서로 다른 부분이 우연히 같은 잠금을 공유할 수 있습니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION