공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 02 / 10

멀티스레드와 동기화

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

3. 코드 예제

예제 1: 경쟁 조건 재현과 세 가지 해결책

같은 count++ 를 (1) 무방비 (2) synchronized (3) AtomicInteger 로 각각 실행해 결과를 비교합니다.

java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class RaceConditionDemo {
    static int unsafeCount = 0;
    static int syncCount = 0;
    static final AtomicInteger atomicCount = new AtomicInteger();
    static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        int threads = 8, perThread = 100_000;
        Runnable work = () -> {
            for (int i = 0; i < perThread; i++) {
                unsafeCount++;                                  // 경쟁 조건
                synchronized (lock) { syncCount++; }            // 상호 배제
                atomicCount.incrementAndGet();                  // CAS
            }
        };
        Thread[] ts = new Thread[threads];
        for (int i = 0; i < threads; i++) { ts[i] = new Thread(work); ts[i].start(); }
        for (Thread t : ts) t.join();                           // join 후 읽기는 happens-before 로 안전

        int expected = threads * perThread;
        System.out.println("expected = " + expected);
        System.out.println("unsafe   = " + unsafeCount + (unsafeCount == expected ? "" : "  <-- 갱신 손실"));
        System.out.println("sync     = " + syncCount);
        System.out.println("atomic   = " + atomicCount.get());
    }
}
// 출력 (unsafe 값은 실행마다 다름):
// expected = 800000
// unsafe   = 613482  <-- 갱신 손실
// sync     = 800000
// atomic   = 800000

예제 2: 은행 계좌 이체 — 락 순서 고정으로 데드락 예방

두 계좌 사이를 양방향으로 동시에 이체하면 락 순서가 엇갈려 데드락이 납니다. 계좌 ID 순으로 락을 잡아 순환 대기를 끊습니다.

java
import java.util.concurrent.*;

public class BankAccount {
    private final int id;
    private long balance;

    BankAccount(int id, long balance) { this.id = id; this.balance = balance; }
    long balance() { synchronized (this) { return balance; } }

    static void transfer(BankAccount from, BankAccount to, long amount) {
        // 순환 대기 차단: 항상 id 가 작은 계좌부터 잠근다
        BankAccount first = from.id < to.id ? from : to;
        BankAccount second = from.id < to.id ? to : from;
        synchronized (first) {
            synchronized (second) {
                if (from.balance < amount) throw new IllegalStateException("잔액 부족");
                from.balance -= amount;
                to.balance += amount;
            }
        }
    }

    public static void main(String[] args) throws Exception {
        BankAccount a = new BankAccount(1, 1_000_000);
        BankAccount b = new BankAccount(2, 1_000_000);
        try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
            for (int i = 0; i < 10_000; i++) {
                pool.submit(() -> transfer(a, b, 10));   // a → b
                pool.submit(() -> transfer(b, a, 10));   // b → a (역방향, 데드락 후보)
            }
        }   // close(): shutdown + awaitTermination
        System.out.println("a = " + a.balance() + ", b = " + b.balance());
        System.out.println("total = " + (a.balance() + b.balance()));   // 불변식: 합계 보존
    }
}
// 출력:
// a = 1000000, b = 1000000
// total = 2000000

예제 3: 데드락 재현과 tryLock 탈출

일부러 락 순서를 엇갈리게 잡아 데드락을 만들고, ThreadMXBean 으로 감지한 뒤, tryLock 버전으로 같은 작업이 끝나는 것을 보여줍니다.

java
import java.lang.management.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockDemo {
    public static void main(String[] args) throws Exception {
        ReentrantLock l1 = new ReentrantLock(), l2 = new ReentrantLock();

        // 1) 데드락 재현: 서로 반대 순서
        Thread t1 = new Thread(() -> grabBoth(l1, l2), "T1");
        Thread t2 = new Thread(() -> grabBoth(l2, l1), "T2");
        t1.start(); t2.start();
        t1.join(500); t2.join(500);                       // 타임아웃으로 안전 종료

        long[] dead = ManagementFactory.getThreadMXBean().findDeadlockedThreads();
        System.out.println("deadlocked threads = " + (dead == null ? 0 : dead.length));
        t1.interrupt(); t2.interrupt();                  // lockInterruptibly 대기 중이므로 깨어남
        t1.join(); t2.join();

        // 2) tryLock: 못 잡으면 포기하고 재시도 → 데드락 없음
        ReentrantLock a = new ReentrantLock(), b = new ReentrantLock();
        Thread t3 = new Thread(() -> tryBoth(a, b), "T3");
        Thread t4 = new Thread(() -> tryBoth(b, a), "T4");
        t3.start(); t4.start(); t3.join(); t4.join();
        System.out.println("tryLock version finished");
    }

    static void grabBoth(ReentrantLock first, ReentrantLock second) {
        try {
            first.lockInterruptibly();
            try {
                Thread.sleep(100);                        // 상대가 다른 락을 잡을 시간
                second.lockInterruptibly();               // 여기서 영원히 대기 → 데드락
                second.unlock();
            } finally { first.unlock(); }
        } catch (InterruptedException e) {
            System.out.println(Thread.currentThread().getName() + " interrupted");
        }
    }

    static void tryBoth(ReentrantLock first, ReentrantLock second) {
        while (true) {
            if (first.tryLock()) {
                try {
                    if (second.tryLock()) {
                        try { return; } finally { second.unlock(); }
                    }
                } finally { first.unlock(); }             // 두 번째 실패 → 첫 번째도 놓고 재시도 (점유와 대기 깨기)
            }
            Thread.onSpinWait();
        }
    }
}
// 출력:
// deadlocked threads = 2
// T1 interrupted
// T2 interrupted
// tryLock version finished

예제 4: 생산자-소비자 — BlockingQueue

주문 이벤트를 생산자가 큐에 넣고 소비자 2개가 꺼내 처리합니다. BlockingQueue 가 락과 대기를 내부에서 처리하므로 wait/notify 를 직접 쓸 필요가 없습니다. 종료는 "독약(poison pill)" 방식입니다.

java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class ProducerConsumer {
    record OrderEvent(int id, long amount) {}
    static final OrderEvent POISON = new OrderEvent(-1, 0);

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<OrderEvent> queue = new ArrayBlockingQueue<>(10);   // 용량 10: 가득 차면 put 이 대기
        AtomicInteger processed = new AtomicInteger();
        int consumers = 2, total = 50;

        Runnable consumer = () -> {
            try {
                while (true) {
                    OrderEvent e = queue.take();          // 비어 있으면 대기
                    if (e == POISON) break;
                    processed.incrementAndGet();
                }
            } catch (InterruptedException ex) { Thread.currentThread().interrupt(); }
        };
        Thread[] cs = new Thread[consumers];
        for (int i = 0; i < consumers; i++) { cs[i] = new Thread(consumer, "consumer-" + i); cs[i].start(); }

        for (int i = 1; i <= total; i++) queue.put(new OrderEvent(i, i * 100L));   // 생산
        for (int i = 0; i < consumers; i++) queue.put(POISON);                    // 소비자 수만큼 독약
        for (Thread c : cs) c.join();

        System.out.println("processed = " + processed.get());
        System.out.println("queue empty = " + queue.isEmpty());
    }
}
// 출력:
// processed = 50
// queue empty = true

예제 5: 병렬 파일 처리 — 플랫폼 풀 vs 가상 스레드

파일 100개를 읽어 줄 수를 세는 작업을 submit + Future 로 처리하고, 같은 작업을 가상 스레드로 돌립니다. 실패 작업의 예외가 Future.get() 에서 나오는 것도 확인합니다.

java
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;

public class ParallelFiles {
    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("logs");
        for (int i = 0; i < 100; i++)
            Files.writeString(dir.resolve("app-" + i + ".log"), "line\n".repeat(i + 1));

        long t0 = System.nanoTime();
        long total = countLines(dir, Executors.newFixedThreadPool(8));
        System.out.println("fixed pool total lines = " + total);

        long total2 = countLines(dir, Executors.newVirtualThreadPerTaskExecutor());
        System.out.println("virtual thread total lines = " + total2);
        System.out.println("both done in < 1s: " + ((System.nanoTime() - t0) / 1_000_000_000 < 1));
    }

    static long countLines(Path dir, ExecutorService pool) throws Exception {
        List<Future<Long>> futures = new ArrayList<>();
        try (pool; var files = Files.list(dir)) {
            for (Path p : files.toList())
                futures.add(pool.submit(() -> { try (var s = Files.lines(p)) { return s.count(); } }));
        }
        long sum = 0;
        for (Future<Long> f : futures) {
            try { sum += f.get(); }
            catch (ExecutionException e) { System.out.println("failed: " + e.getCause()); }   // 작업 안 예외는 여기서
        }
        return sum;
    }
}
// 출력:
// fixed pool total lines = 5050
// virtual thread total lines = 5050
// both done in < 1s: true

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다.

cd java-src\advanced\02_multithread
javac -encoding UTF-8 *.java && java Main
코드 예제
  • 예제 1: 경쟁 조건 재현과 세 가지 해결책
  • 예제 2: 은행 계좌 이체 — 락 순서 고정으로 데드락 예방
  • 예제 3: 데드락 재현과 tryLock 탈출
  • 예제 4: 생산자-소비자 — BlockingQueue
  • 예제 5: 병렬 파일 처리 — 플랫폼 풀 vs 가상 스레드
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제