Java 동기화 메커니즘: 스레드 안전한 코드 작성을 위한 완벽 가이드
목차
동기화의 필요성
멀티스레드 환경에서는 여러 스레드가 동시에 같은 자원에 접근할 때 데이터 불일치 문제가 발생할 수 있습니다. 다음 예제는 동기화되지 않은 카운터 클래스입니다:
public class ThreadUnsafeCounter {
private int count = 0;
public void increment() {
count++; // 이 연산은 원자적이지 않습니다!
}
public int getCount() {
return count;
}
}
- 경쟁 상태(Race Condition): 여러 스레드가 동시에 count 변수를 읽고 변경할 때 발생
- 가시성 문제(Visibility Problem): 한 스레드가 변경한 값이 다른 스레드에 즉시 보이지 않음
- 명령어 재배치(Instruction Reordering): 컴파일러나 JVM의 최적화로 코드 실행 순서가 변경됨
synchronized 키워드
synchronized
는 Java에서 가장 기본적인 동기화 메커니즘으로, 메소드나 코드 블록에 적용할 수 있습니다.
메소드 동기화
public class SynchronizedCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
블록 동기화
public class BlockSynchronizedCounter {
private int count = 0;
private final Object lock = new Object(); // 명시적 락 객체
public void increment() {
synchronized(lock) {
count++;
}
}
public int getCount() {
synchronized(lock) {
return count;
}
}
}
- 내부 구현: 모니터(monitor)를 사용하여 상호 배제(mutual exclusion) 구현
- 재진입성(Reentrancy): 이미 락을 획득한 스레드는 동일한 락을 다시 획득할 수 있음
- 자동 락 해제: 예외가 발생해도 락이 자동으로 해제됨
- 성능: Java 6 이후 적응형 락(adaptive lock) 메커니즘으로 성능 개선
volatile 키워드
volatile
키워드는 변수의 가시성(visibility) 문제를 해결하지만, 원자성(atomicity)은 보장하지 않습니다.
public class VolatileExample {
private volatile boolean flag = false;
private int counter = 0;
public void setFlag() {
flag = true; // 다른 스레드에 즉시 가시적
}
public void incrementIfFlag() {
if (flag) {
counter++; // volatile이 이 연산의 원자성을 보장하지는 않음
}
}
public boolean isFlag() {
return flag;
}
public int getCounter() {
return counter;
}
}
- 메모리 가시성: 한 스레드에서 변경한 값이 다른 스레드에 즉시 보임
- 순서 보장: volatile 변수의 읽기/쓰기 주변의 명령어 재배치 방지
- 하드웨어 메모리 배리어 삽입: CPU 캐시와 메인 메모리 간의 동기화 보장
- 적합한 사용 사례: 단일 스레드가 쓰고 다른 스레드들이 읽는 상황, 상태 플래그
Atomic 클래스
java.util.concurrent.atomic
패키지는 원자적 연산을 위한 특수 클래스들을 제공합니다.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 원자적 연산
}
public int getCount() {
return count.get();
}
public void addValue(int value) {
count.addAndGet(value); // 원자적 연산
}
public boolean compareAndSet(int expect, int update) {
return count.compareAndSet(expect, update); // CAS 연산
}
}
- AtomicInteger, AtomicLong, AtomicBoolean: 기본 타입을 위한 클래스
- AtomicReference<V>: 객체 참조를 위한 클래스
- AtomicIntegerArray: 배열을 위한 클래스
- AtomicFieldUpdater: 기존 객체의 필드를 원자적으로 업데이트
특징 | synchronized | Atomic 클래스 |
---|---|---|
구현 방식 | 모니터 락 | Compare-And-Swap (CAS) |
블로킹 | 블로킹 | Non-blocking |
성능 | 경쟁 시 성능 저하 | 경쟁이 적을 때 높은 성능 |
복합 연산 | 지원 | 제한적 |
Lock 인터페이스
java.util.concurrent.locks
패키지의 Lock 인터페이스는 synchronized
보다 더 유연한 락 메커니즘을 제공합니다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 락 획득
try {
count++;
} finally {
lock.unlock(); // 반드시 unlock 호출
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
public boolean tryIncrement() {
if (lock.tryLock()) { // 락 획득 시도
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // 락 획득 실패
}
}
ReentrantLock의 추가 기능
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class AdvancedLockExample {
private final ReentrantLock lock = new ReentrantLock(true); // 공정성 설정
public void fairLockMethod() {
lock.lock();
try {
// 공정한 락 획득
} finally {
lock.unlock();
}
}
public boolean timedLockMethod() throws InterruptedException {
// 2초 동안 락 획득 시도
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
// 락 획득 성공
return true;
} finally {
lock.unlock();
}
}
// 락 획득 실패
return false;
}
public void interruptibleLockMethod() throws InterruptedException {
// 인터럽트에 반응하는 락 획득
lock.lockInterruptibly();
try {
// 락 획득 성공
} finally {
lock.unlock();
}
}
}
ReadWriteLock
ReadWriteLock
은 읽기 작업이 많고 쓰기 작업이 적은 경우에 유용한 동기화 메커니즘입니다.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteCounter {
private int count = 0;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public void increment() {
lock.writeLock().lock(); // 쓰기 락 (배타적)
try {
count++;
} finally {
lock.writeLock().unlock();
}
}
public int getCount() {
lock.readLock().lock(); // 읽기 락 (공유 가능)
try {
return count;
} finally {
lock.readLock().unlock();
}
}
}
- 여러 스레드가 동시에 읽기 락 획득 가능
- 쓰기 락은 배타적(exclusive)으로 획득
- 기본적으로 쓰기 스레드에 우선권 부여
- 읽기 작업이 쓰기 작업보다 훨씬 많은 경우에 적합
StampedLock
Java 8에서 도입된 StampedLock
은 더 유연한 락 메커니즘을 제공합니다.
import java.util.concurrent.locks.StampedLock;
public class StampedLockCounter {
private int count = 0;
private final StampedLock lock = new StampedLock();
public void increment() {
long stamp = lock.writeLock(); // 쓰기 락 획득 및 스탬프 반환
try {
count++;
} finally {
lock.unlockWrite(stamp); // 스탬프로 락 해제
}
}
public int getCount() {
long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 시도
int currentCount = count; // 값 읽기
if (!lock.validate(stamp)) { // 읽기 중 변경 확인
stamp = lock.readLock(); // 읽기 락으로 전환
try {
currentCount = count; // 값 다시 읽기
} finally {
lock.unlockRead(stamp);
}
}
return currentCount;
}
}
- 낙관적 읽기(Optimistic Reading): 락 없이 읽기 시도, 이후 유효성 검사
- 스탬프 기반: 모든 락 획득/해제 작업이 스탬프(long 값)를 사용
- 락 변환: 한 모드에서 다른 모드로 락 변환 지원
- 높은 성능: 경쟁이 적은 경우 ReadWriteLock보다 높은 성능
- 제한 사항: 재진입(reentrant) 불가, 조건 변수(condition) 미지원
ThreadLocal
ThreadLocal
은 각 스레드가 자신만의 변수 사본을 가질 수 있게 해주는 특별한 변수 타입입니다.
import java.text.SimpleDateFormat;
import java.util.Date;
public class ThreadLocalExample {
// 각 스레드별로 독립적인 SimpleDateFormat 인스턴스 제공
private static final ThreadLocal<SimpleDateFormat> dateFormatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
return dateFormatter.get().format(date);
}
// ThreadLocal 변수를 초기값과 함께 생성
private static final ThreadLocal<Integer> threadId =
ThreadLocal.withInitial(() -> (int) (Thread.currentThread().getId()));
public Integer getThreadId() {
return threadId.get();
}
// 사용 후 명시적 제거
public void cleanup() {
dateFormatter.remove();
threadId.remove();
}
}
- 메모리 누수: 스레드 풀에서 사용 시 remove() 호출 필요
- 상속: 자식 스레드는 부모의 ThreadLocal 값을 상속하지 않음
- 성능: 과도한 사용 시 성능 저하 가능
동기화 컬렉션
Java는 스레드 안전한 컬렉션 클래스들을 제공합니다.
동기화 래퍼(Synchronized Wrappers)
import java.util.*;
public class SynchronizedCollections {
public static void main(String[] args) {
// 동기화 맵
Map<String, Integer> synchronizedMap =
Collections.synchronizedMap(new HashMap<>());
// 동기화 리스트
List<String> synchronizedList =
Collections.synchronizedList(new ArrayList<>());
// 동기화 세트
Set<Integer> synchronizedSet =
Collections.synchronizedSet(new HashSet<>());
// 다중 스레드 환경에서 사용
synchronizedMap.put("key", 1);
synchronizedList.add("value");
// 반복 시 명시적 동기화 필요
synchronized(synchronizedList) {
for(String value : synchronizedList) {
System.out.println(value);
}
}
}
}
동시성 컬렉션(Concurrent Collections)
import java.util.concurrent.*;
public class ConcurrentCollections {
public static void main(String[] args) {
// ConcurrentHashMap
ConcurrentMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
// CopyOnWriteArrayList
CopyOnWriteArrayList<String> copyOnWriteList = new CopyOnWriteArrayList<>();
// ConcurrentLinkedQueue
Queue<String> concurrentQueue = new ConcurrentLinkedQueue<>();
// BlockingQueue
BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
// ConcurrentSkipListMap (정렬된 동시성 맵)
ConcurrentNavigableMap<String, Integer> skipListMap =
new ConcurrentSkipListMap<>();
// 안전한 반복 (명시적 동기화 필요 없음)
for(String key : concurrentMap.keySet()) {
System.out.println(key);
}
// 원자적 조건부 연산
concurrentMap.putIfAbsent("key", 1);
concurrentMap.replace("key", 1, 2);
}
}
특징 | 동기화 컬렉션 | 동시성 컬렉션 |
---|---|---|
구현 | Collections 유틸리티 메소드 | java.util.concurrent 패키지 |
동기화 방식 | 메소드 전체에 락 적용 | 세밀한 락 또는 lock-free 알고리즘 |
반복자(Iterator) | fail-fast (ConcurrentModificationException) | 일관성 보장 또는 실패 없음 |
성능 | 경쟁 상황에서 성능 저하 | 고병렬성 지원 |
원자적 연산 | 제한적 | 다양한 복합 연산 지원 |
병렬 스트림 처리
Java 8에서 도입된 Stream API는 병렬 처리를 통해 멀티코어 프로세서의 성능을 활용할 수 있는 강력한 기능을 제공합니다.
import java.util.Arrays;
import java.util.List;
public class ParallelStreamExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 순차 스트림
long startSeq = System.nanoTime();
int sumSeq = numbers.stream()
.filter(n -> n % 2 == 0)
.mapToInt(n -> n * 2)
.sum();
long endSeq = System.nanoTime();
// 병렬 스트림
long startPar = System.nanoTime();
int sumPar = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.mapToInt(n -> n * 2)
.sum();
long endPar = System.nanoTime();
System.out.println("Sequential: " + sumSeq + ", time: " + (endSeq - startSeq) + " ns");
System.out.println("Parallel: " + sumPar + ", time: " + (endPar - startPar) + " ns");
}
}
병렬 스트림 사용 시 주의사항
// 문제가 발생할 수 있는 코드
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> result = new ArrayList<>();
// 병렬 처리 중 ArrayList에 동시 접근으로 인한 예상치 못한 결과
numbers.parallelStream()
.map(n -> n * 2)
.forEach(n -> result.add(n)); // 안전하지 않음!
System.out.println(result.size()); // 10보다 작을 수 있음
// 올바른 방법
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList()); // 스레드 안전
병렬 스트림의 커스텀 스레드 풀
import java.util.concurrent.ForkJoinPool;
public class CustomThreadPoolExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 커스텀 스레드 풀 생성 (4개 스레드)
ForkJoinPool customPool = new ForkJoinPool(4);
try {
// 커스텀 풀에서 병렬 작업 실행
int sum = customPool.submit(() ->
numbers.parallelStream()
.filter(n -> n % 2 == 0)
.mapToInt(n -> {
System.out.println("Processing " + n +
" in thread " + Thread.currentThread().getName());
return n * 2;
})
.sum()
).get();
System.out.println("Sum: " + sum);
} catch (Exception e) {
e.printStackTrace();
} finally {
customPool.shutdown();
}
}
}
- 독립적인 작업(데이터 간 종속성 없음)
- 계산 비용이 높은 작업
- 충분히 큰 데이터셋
- 분할이 용이한 자료구조 사용(ArrayList, 배열)
- 상태를 변경하는 연산 피하기
이름 없는 패턴(Unnamed Patterns) – Java 21
Java 21에서 도입된 이름 없는 패턴(Unnamed Patterns)은 사용하지 않는 변수를 명시적으로 처리할 수 있게 해주는 기능입니다.
// Java 21 이전 - 사용하지 않는 변수에 대한 경고
public class PatternExample {
public void processPoint(Object obj) {
if (obj instanceof Point(int x, int y)) {
// y 좌표는 사용하지 않지만 선언해야 함
System.out.println("X coordinate: " + x);
}
}
}
// Java 21 - 이름 없는 패턴 사용
public class UnnamedPatternExample {
public void processPoint(Object obj) {
if (obj instanceof Point(int x, _)) {
// y 좌표는 사용하지 않음을 명시적으로 표현
System.out.println("X coordinate: " + x);
}
}
}
switch 표현식에서의 이름 없는 패턴
public class SwitchUnnamedPatterns {
// Record 타입 정의
public record Point(int x, int y) {}
public record Color(int r, int g, int b) {}
public void demonstrateSwitchPatterns(Object obj) {
// 이름 없는 패턴을 사용한 switch 표현식
String result = switch (obj) {
case Point(int x, _) when x > 0 ->
"Positive X point: " + x;
case Point(_, int y) when y > 0 ->
"Positive Y point: " + y;
case Point(_, _) ->
"Point at origin or negative coordinates";
case Color(int r, _, _) when r > 128 ->
"High red component: " + r;
case Color(_, _, _) ->
"Color with low red component";
case null ->
"Null object";
default ->
"Unknown object type";
};
System.out.println(result);
}
}
try-catch에서의 이름 없는 패턴
import java.io.IOException;
public class UnnamedExceptionHandling {
public void demonstrateUnnamedCatch() {
try {
performRiskyOperation();
} catch (IOException _) {
// IOException은 사용하지 않음
System.err.println("IO operation failed");
} catch (SecurityException _) {
// SecurityException은 사용하지 않음
System.err.println("Security check failed");
} catch (Exception e) {
// 다른 예외는 사용할 수 있음
System.err.println("Unexpected error: " + e.getMessage());
}
}
}
- 코드 명확성: 사용하지 않는 변수를 명시적으로 표현
- 컴파일러 최적화: 미사용 변수에 대한 경고 제거
- 의도 표현: 코드 작성자의 의도를 더 명확히 표현
- 유지보수성: 어떤 부분이 중요한지 쉽게 파악
실제 사례와 성능 비교
계좌 이체 시스템 예제
public class BankTransferExample {
public static void main(String[] args) {
final Account account1 = new Account(1, 1000);
final Account account2 = new Account(2, 1000);
// 동시에 여러 이체 작업 실행
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 100; j++) {
transfer(account1, account2, 10);
transfer(account2, account1, 10);
}
}).start();
}
}
// 데드락을 방지하는 계좌 이체 메소드
private static void transfer(Account from, Account to, int amount) {
// 항상 낮은 ID의 계좌부터 락 획득으로 데드락 방지
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized(first) {
synchronized(second) {
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
}
}
}
}
static class Account {
final int id;
int balance;
Account(int id, int balance) {
this.id = id;
this.balance = balance;
}
}
}
성능 비교 벤치마크
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SynchronizationBenchmark {
private static final int THREADS = 10;
private static final int ITERATIONS = 1_000_000;
public static void main(String[] args) throws InterruptedException {
// 다양한 카운터 구현 테스트
testCounter("Unsynchronized", new UnsynchronizedCounter());
testCounter("Synchronized", new SynchronizedCounter());
testCounter("LockBased", new LockBasedCounter());
testCounter("Atomic", new AtomicCounter());
}
private static void testCounter(String type, Counter counter) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
long start = System.nanoTime();
// 스레드 생성 및 시작
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
counter.increment();
}
});
threads[i].start();
}
// 모든 스레드 완료 대기
for (Thread thread : threads) {
thread.join();
}
long end = System.nanoTime();
long duration = (end - start) / 1_000_000; // 밀리초
System.out.printf("%s Counter: count=%d, time=%d ms%n",
type, counter.getCount(), duration);
}
}
동기화 관련 일반적인 문제점과 해결책
1. 데드락(Deadlock)
두 개 이상의 스레드가 서로 상대방이 점유한 자원을 기다리며 무한정 대기하는 상태입니다.
public class DeadlockExample {
private final Object resource1 = new Object();
private final Object resource2 = new Object();
public void method1() {
synchronized(resource1) {
System.out.println("Thread 1: Holding resource 1...");
try { Thread.sleep(100); }
catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for resource 2...");
synchronized(resource2) {
System.out.println("Thread 1: Holding resource 1 & 2...");
}
}
}
public void method2() {
synchronized(resource2) { // 여기서 순서가 다름 (데드락 원인)
System.out.println("Thread 2: Holding resource 2...");
try { Thread.sleep(100); }
catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for resource 1...");
synchronized(resource1) {
System.out.println("Thread 2: Holding resource 1 & 2...");
}
}
}
}
- 락 순서 정하기: 항상 동일한 순서로 락 획득
- 락 타임아웃 사용: tryLock()에 타임아웃 설정
- 데드락 감지 및 복구: 순환 대기 감지 후 락 해제
2. 라이브락(Livelock)
스레드가 계속 활성화되어 있으나, 작업을 진행하지 못하는 상태입니다.
- 무작위 지연(Random Delay) 추가
- 우선순위 기반 접근
- 백오프(backoff) 전략 사용
3. 기아 상태(Starvation)
특정 스레드가 필요한 자원을 계속 얻지 못하는 상태입니다.
- 공정한 락(Fair Lock) 사용:
new ReentrantLock(true)
- 자원 사용 시간 제한
- 우선순위 기반 스케줄링
4. 성능 최적화 기법
락 분할(Lock Splitting)
// 분할 전 - 하나의 락 사용
class SingleLockCache {
private final Object lock = new Object();
private Map<String, Object> map1 = new HashMap<>();
private Map<String, Object> map2 = new HashMap<>();
public Object getFromMap1(String key) {
synchronized(lock) {
return map1.get(key);
}
}
public Object getFromMap2(String key) {
synchronized(lock) {
return map2.get(key);
}
}
}
// 분할 후 - 각 맵마다 별도의 락 사용
class SplitLockCache {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
private Map<String, Object> map1 = new HashMap<>();
private Map<String, Object> map2 = new HashMap<>();
public Object getFromMap1(String key) {
synchronized(lock1) {
return map1.get(key);
}
}
public Object getFromMap2(String key) {
synchronized(lock2) {
return map2.get(key);
}
}
}
락 스트라이핑(Lock Striping)
public class StripedMap<K, V> {
private static final int NUM_LOCKS = 16;
private final Object[] locks;
private final List<Map<K, V>> maps;
public StripedMap() {
locks = new Object[NUM_LOCKS];
maps = new ArrayList<>(NUM_LOCKS);
for (int i = 0; i < NUM_LOCKS; i++) {
locks[i] = new Object();
maps.add(new HashMap<>());
}
}
private int hash(Object key) {
return Math.abs(key.hashCode() % NUM_LOCKS);
}
public V get(K key) {
int hash = hash(key);
synchronized (locks[hash]) {
return maps.get(hash).get(key);
}
}
public V put(K key, V value) {
int hash = hash(key);
synchronized (locks[hash]) {
return maps.get(hash).put(key, value);
}
}
}
요약 및 결론
Java의 동기화 메커니즘은 다양한 상황에 맞게 선택할 수 있는 풍부한 옵션을 제공합니다. 적절한 동기화 메커니즘을 선택하는 것은 애플리케이션의 성능과 안정성에 큰 영향을 미칩니다.
동기화 메커니즘 선택 가이드
상황 | 권장 메커니즘 | 이유 |
---|---|---|
단순한 동기화 | synchronized | 간단하고 안전함 |
가시성만 보장 | volatile | 성능 우수, 단순 변수에 적합 |
단일 변수의 원자적 연산 | Atomic 클래스 | Non-blocking, 높은 성능 |
세밀한 제어 필요 | Lock 인터페이스 | 타임아웃, 인터럽트 지원 |
읽기 작업이 많음 | ReadWriteLock | 동시 읽기 허용 |
최대 성능 필요 | StampedLock | 낙관적 읽기로 성능 최적화 |
스레드별 데이터 | ThreadLocal | 동기화 불필요 |
고성능 컬렉션 | java.util.concurrent | 세밀한 동기화 |
모범 사례
- 공유 상태 최소화: 가능한 한 공유 상태를 최소화하세요
- 불변 객체 사용: 상태가 변경되지 않는 불변 객체를 활용하세요
- 락 범위 최소화: 필요한 코드 부분만 동기화하세요
- 데드락 방지: 일관된 락 획득 순서를 유지하세요
- 세밀한 락 사용: 단일 락 대신 여러 개의 세밀한 락을 고려하세요
- 적절한 도구 선택: 상황에 맞는 동기화 메커니즘을 선택하세요
- 성능 테스트: 중요한 코드는 반드시 성능 테스트를 수행하세요
- 요구사항 분석: 정확히 어떤 종류의 스레드 안전성이 필요한지 파악하세요
이 가이드를 통해 Java의 동기화 메커니즘을 마스터하고 멀티스레드 프로그래밍에서 발생할 수 있는 다양한 문제들을 해결하는 데 도움이 되길 바랍니다.
멀티스레드 프로그래밍은 복잡하지만, 적절한 동기화 메커니즘을 사용하면 안전하고 효율적인 애플리케이션을 개발할 수 있습니다. 각 메커니즘의 특성을 이해하고 상황에 맞게 적용하는 것이 성공적인 동시성 프로그래밍의 열쇠입니다.