멀티스레딩과 동시성 타입소거, 내재적 락, Atomic, ReentrantLock

궁금하면 500원·2025년 1월 21일

Java

목록 보기
9/10

1. 타입 소거와 형 확장

타입 소거(Generic Type Erasure)형 확장(Primitive Type Wrapping)
자바에서 제네릭을 사용할 때 중요한 개념입니다.

이들은 자바 컴파일러와 JVM이 어떻게 타입을 처리하는지에 대한 이해를 돕습니다.
아래는 이들에 대한 설명과 예시입니다.

1.1 제네릭 타입 소거의 실제 영향

public class CacheManager<T> {
    private Map<String, T> cache = new HashMap<>();
    
    public void put(String key, T value) {
        cache.put(key, value);
    }
    
    public T get(String key) {
        return cache.get(key);
    }
    
    // 문제가 될 수 있는 코드
    public boolean isInstance(Object obj) {
        // 컴파일 오류! T의 타입 정보가 런타임에 소거됨
        return obj instanceof T;  
    }
}

이를 해결하기 위한 실무적인 접근 방법

public class CacheManager<T> {
    private final Class<T> type;
    
    public CacheManager(Class<T> type) {
        this.type = type;
    }
    
    public boolean isInstance(Object obj) {
        return type.isInstance(obj);
    }
}

// 사용 예시
CacheManager<String> stringCache = new CacheManager<>(String.class);

1.2 형 확장의 성능 영향

형 확장이 성능에 미치는 영향을 실제 코드로 살펴보겠습니다

public class PrimitivePerformance {
    public static void main(String[] args) {
        long start = System.nanoTime();
        
        // 기본형 사용
        int sum1 = 0;
        for (int i = 0; i < 10_000_000; i++) {
            sum1 += i;
        }
        
        long middle = System.nanoTime();
        
        // 래퍼 클래스 사용
        Integer sum2 = 0;
        for (Integer i = 0; i < 10_000_000; i++) {
            sum2 += i;  // 오토박싱/언박싱 발생
        }
        
        long end = System.nanoTime();
        
        System.out.printf("기본형 처리 시간: %d ns%n", middle - start);
        System.out.printf("래퍼클래스 처리 시간: %d ns%n", end - middle);
    }
}

1. 런타임에 모든 제네릭 파라미터는 Object로 처리

  • 제네릭은 컴파일 타임에만 타입 체크가 이루어지며, 런타임에서는 모든 제네릭 타입 파라미터가 Object로 처리됩니다.

  • 예를 들어, List은 컴파일 타임에 String 타입으로 동작하지만, 런타임에서는 Object로 변환됩니다.

주의 사항

배열은 런타임에 타입을 필요로 하므로 제네릭 객체를 배열로 만들 수 없습니다.
예를 들어 List[] lists는 불가능합니다.

2. 로컬 변수는 타입이 제거되고 메모리 슬롯으로 처리

  • 로컬 변수의 제네릭 타입은 컴파일 타임에만 유지되며, 런타임에서는 타입 정보가 소거되어 메모리 슬롯으로 처리됩니다.

3. 람다의 타입은 invokedynamic으로 처리되면서 타입 소거

  • 자바 람다의 타입도 invokedynamic을 통해 런타임에 결정되며, 컴파일 타임에 제네릭 타입은 소거됩니다.

4. 형 확장(Primitive Type Wrapping)

  • 기본형(primitive type)은 특정 규칙에 따라 JVM에서 확장됩니다.
  • byte, short, char는 모두 int로 변환됩니다.
  • 기본형 float은 유지되지만, double이 개입되면 double로 변환됩니다.
  • 함수의 인자로 float을 넘기면 자동으로 double로 변환됩니다.

2. 내재적 락 (Intrinsic Lock == Monitor)

자바에서 내재적 락은 Object 클래스에서 제공하는 모니터(monitor)로, 멀티스레딩 환경에서 스레드 간의 동기화를 처리하는 데 사용됩니다.

2.1 복잡한 동기화 시나리오 처리

다음은 제품 재고 관리 시스템의 코드입니다.

public class InventoryManager {
    private static class Product {
        private final String id;
        private int stock;
        private final int threshold;
        
        public Product(String id, int stock, int threshold) {
            this.id = id;
            this.stock = stock;
            this.threshold = threshold;
        }
    }
    
    private final Map<String, Product> products = new HashMap<>();
    private final Object orderLock = new Object();
    private final Object restockLock = new Object();
    
    public boolean processOrder(String productId, int quantity) {
        synchronized (orderLock) {
            Product product = products.get(productId);
            if (product == null || product.stock < quantity) {
                return false;
            }
            
            synchronized (restockLock) {
                product.stock -= quantity;
                if (product.stock < product.threshold) {
                    requestRestock(productId);
                }
            }
            return true;
        }
    }
    
    private void requestRestock(String productId) {
        // 재고 보충 요청 로직
    }
}

2.2 데드락 방지를 위한 락 순서화

public class BankTransfer {
    private static class Account {
        private final String id;
        private double balance;
        
        public Account(String id, double balance) {
            this.id = id;
            this.balance = balance;
        }
    }
    
    public boolean transfer(Account from, Account to, double amount) {
        // 데드락 방지를 위해 항상 낮은 ID의 계좌부터 락 획득
        Account firstLock = from.id.compareTo(to.id) < 0 ? from : to;
        Account secondLock = from.id.compareTo(to.id) < 0 ? to : from;
        
        synchronized (firstLock) {
            synchronized (secondLock) {
                if (from.balance < amount) {
                    return false;
                }
                from.balance -= amount;
                to.balance += amount;
                return true;
            }
        }
    }
}

1. Object 클래스에 내장된 기능

  • synchronized 키워드를 사용하여 스레드 간에 임계 구역을 보호할 수 있습니다.
    이때, 락은 객체에 내장된 모니터를 사용합니다.

2. 모니터와 스레드

  • 자바에서 객체는 자신을 실행하려는 스레드를 인식할 수 있습니다.
    동일한 객체에 여러 스레드가 접근할 수 있지만, 모니터를 통해 오직 하나의 스레드만 접근할 수 있습니다.

3. 모니터의 동작

  • synchronized 블록 내에서 실행되는 스레드는 해당 객체의 모니터 소유자가 됩니다.
    다른 스레드가 이미 모니터를 소유하고 있다면, 새로운 스레드는 monitor entry set으로 대기하게 됩니다.

4. wait/notify 메커니즘

  • wait()를 호출하면 해당 스레드는 wait set으로 들어가며, notify() 또는 notifyAll()이 호출되어야 대기 중인 스레드가 실행을 재개합니다.

5. 스레드 상태의 추적

  • Thread의 상태를 조회할 수 있지만, 상태는 언제든지 변할 수 있으며, 특히 runnable과 wait 상태는 구분하기 어려운 경우가 많습니다.

3. Atomic 연산의 시리즈

자바에서 Atomic 변수와 Compare-And-Swap(CAS) 메커니즘을 이용해 동기화 없이 안전한 값 변경을 할 수 있습니다.
이들은 java.util.concurrent.atomic 패키지에서 제공됩니다.

3.1 고성능 카운터 구현

public class HighPerformanceCounter {
    private final AtomicLong successCount = new AtomicLong(0);
    private final AtomicLong failCount = new AtomicLong(0);
    private final AtomicLongArray hourlyStats = new AtomicLongArray(24);
    
    public void recordSuccess() {
        successCount.incrementAndGet();
        int hour = LocalDateTime.now().getHour();
        hourlyStats.incrementAndGet(hour);
    }
    
    public void recordFailure() {
        failCount.incrementAndGet();
    }
    
    public double getSuccessRate() {
        long success = successCount.get();
        long total = success + failCount.get();
        return total == 0 ? 0 : (double) success / total;
    }
}

3-2 Lock-Free 알고리즘 구현

public class LockFreeStack<T> {
    private static class Node<T> {
        final T value;
        Node<T> next;
        
        Node(T value) {
            this.value = value;
        }
    }
    
    private final AtomicReference<Node<T>> head = new AtomicReference<>();
    
    public void push(T value) {
        Node<T> newNode = new Node<>(value);
        while (true) {
            Node<T> oldHead = head.get();
            newNode.next = oldHead;
            if (head.compareAndSet(oldHead, newNode)) {
                return;
            }
        }
    }
    
    public T pop() {
        while (true) {
            Node<T> oldHead = head.get();
            if (oldHead == null) {
                return null;
            }
            Node<T> newHead = oldHead.next;
            if (head.compareAndSet(oldHead, newHead)) {
                return oldHead.value;
            }
        }
    }
}

1. volatile 변수와 CAS 사용

  • volatile 변수는 항상 최신 값을 읽어오기 위해 사용됩니다.
    CAS는 변수의 값을 다른 값으로 교체할 때, 기존 값이 예상한 값일 때만 교체하는 방식입니다.

2. CAS와 대기 큐 기반 락 관리

  • CAS는 AtomicInteger와 같은 클래스에서 사용되며, 동기화 없이 안전한 수정을 보장합니다.

4. ReentrantLock과 ReentrantReadWriteLock의 고급 패턴

자바에서 ReentrantLock과 ReentrantReadWriteLock은 내재적 락보다 더 세밀한 락 제어를 제공합니다.
이들은 volatile, CAS, 대기 큐를 기반으로 락을 관리합니다.

4.1 조건부 락킹 패턴

public class BoundedBuffer<T> {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    
    private final T[] items;
    private int putIndex, takeIndex, count;
    
    @SuppressWarnings("unchecked")
    public BoundedBuffer(int capacity) {
        items = (T[]) new Object[capacity];
    }
    
    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();
            }
            items[putIndex] = item;
            putIndex = (putIndex + 1) % items.length;
            count++;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
    
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();
            }
            T item = items[takeIndex];
            items[takeIndex] = null;
            takeIndex = (takeIndex + 1) % items.length;
            count--;
            notFull.signal();
            return item;
        } finally {
            lock.unlock();
        }
    }
}

4.2 읽기/쓰기 락 최적화

public class CacheWithStats<K, V> {
    private final Map<K, V> cache = new HashMap<>();
    private final Map<K, Long> accessStats = new HashMap<>();
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    
    public V get(K key) {
        lock.readLock().lock();
        try {
            V value = cache.get(key);
            if (value != null) {
                // 통계 업데이트를 위해 쓰기 락으로 업그레이드
                lock.readLock().unlock();
                lock.writeLock().lock();
                try {
                    accessStats.merge(key, 1L, Long::sum);
                    lock.readLock().lock();
                } finally {
                    lock.writeLock().unlock();
                }
            }
            return value;
        } finally {
            lock.readLock().unlock();
        }
    }
    
    public void put(K key, V value) {
        lock.writeLock().lock();
        try {
            cache.put(key, value);
            accessStats.put(key, 0L);
        } finally {
            lock.writeLock().unlock();
        }
    }
}

5. 비동기 프로그래밍 패턴

1. 동기(Synchronous) vs 비동기(Asynchronous):

  • 동기는 함수가 호출되면 그 함수의 실행이 완료될 때까지 기다리는 방식입니다.
  • 비동기는 호출한 함수가 실행되는 동안 호출자는 대기하지 않고 다른 작업을 진행할 수 있습니다.

2. 블로킹(Blocking) vs 넌블로킹(Non-blocking):

  • 블로킹은 호출자가 자원을 사용할 수 있을 때까지 대기하는 방식입니다.
  • 넌블로킹은 대기하지 않고 즉시 결과를 반환하거나 다른 작업을 수행하는 방식입니다.

3. 4가지 스타일

  • 동기 + 블로킹: 기본적인 동작 방식
  • 동기 + 넌블로킹: 예를 들어, 자바의 Future
  • 비동기 + 블로킹: 콜백을 사용하되 내부적으로 블로킹
  • 비동기 + 넌블로킹: 최신 비동기 아키텍처 방식, 예: CompletableFuture

5.1 CompletableFuture를 활용한 복잡한 비동기 워크플로우

public class OrderProcessor {
    private final ExecutorService executor;
    
    public OrderProcessor(int threadPoolSize) {
        this.executor = Executors.newFixedThreadPool(threadPoolSize);
    }
    
    public CompletableFuture<OrderResult> processOrder(Order order) {
        return CompletableFuture.supplyAsync(() -> {
            // 재고 확인
            return checkInventory(order);
        }, executor).thenCompose(inventoryOk -> {
            if (!inventoryOk) {
                throw new IllegalStateException("재고 부족");
            }
            // 결제 처리
            return processPayment(order);
        }).thenCompose(payment -> {
            // 배송 처리
            return arrangeShipping(order, payment);
        }).thenApply(shipping -> {
            // 주문 결과 생성
            return new OrderResult(order, shipping);
        }).exceptionally(ex -> {
            // 에러 처리
            logError(order, ex);
            return new OrderResult(order, OrderStatus.FAILED);
        });
    }
    
    // 각각의 비즈니스 로직 메소드들...
    private CompletableFuture<Boolean> checkInventory(Order order) {
        return CompletableFuture.supplyAsync(() -> {
            // 재고 확인 로직
            return true;
        }, executor);
    }
    
    private CompletableFuture<Payment> processPayment(Order order) {
        return CompletableFuture.supplyAsync(() -> {
            // 결제 처리 로직
            return new Payment();
        }, executor);
    }
    
    private CompletableFuture<Shipping> arrangeShipping(Order order, Payment payment) {
        return CompletableFuture.supplyAsync(() -> {
            // 배송 처리 로직
            return new Shipping();
        }, executor);
    }
}

5.2 커스텀 비동기 프레임워크 구현

public class AsyncEventBus<T> {
    private final ExecutorService executor;
    private final Map<Class<?>, List<EventHandler<T>>> handlers = new ConcurrentHashMap<>();
    
    public AsyncEventBus(int threadPoolSize) {
        this.executor = Executors.newFixedThreadPool(threadPoolSize);
    }
    
    public <E extends T> void register(Class<E> eventType, EventHandler<E> handler) {
        handlers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
               .add((EventHandler<T>) handler);
    }
    
    public <E extends T> CompletableFuture<Void> publish(E event) {
        List<EventHandler<T>> eventHandlers = handlers.getOrDefault(event.getClass(), Collections.emptyList());
        
        List<CompletableFuture<Void>> futures = eventHandlers.stream()
            .map(handler -> CompletableFuture.runAsync(() -> handler.handle(event), executor))
            .collect(Collectors.toList());
        
        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
    }
    
    @FunctionalInterface
    public interface EventHandler<T> {
        void handle(T event);
    }
}

결론

단순히 API를 사용하는 것을 넘어 그 내부 동작 원리를 이해하는 것이 얼마나 중요한지였습니다. 특히 다음과 같은 상황에서 이러한 이해가 큰 도움이 되었습니다.

  • 대용량 트래픽 처리 시 발생하는 성능 이슈
  • 멀티스레드 환경에서의 데이터 정합성 문제
  • 복잡한 비동기 처리가 필요한 상황

이 학습 과정을 통해 단순히 코드를 작성하는 것을 넘어, 자바 언어의 깊이 있는 이해가 실제 문제 해결에 얼마나 큰 도움이 되는지 깨달았습니다.

특히 기본기를 다시 다지면서, 이전에는 당연하게 여겼던 개념들의 내부 동작 원리를 이해하게 되었고, 이는 더 나은 설계 결정을 내리는 데 큰 도움이 되었습니다.

앞으로도 이러한 깊이 있는 학습을 통해 더 견고하고 효율적인 시스템을 만드는 개발자가 되고자 합니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글