Java Thread - 놓치기 쉬운 개념들

이강현·2025년 5월 1일

놓치기 쉬운 개념들

목록 보기
15/19

Lock & Condition

Java 의 Thread 는 발전을 거듭하면서 synchronized 블럭만으로는 부족했던 기능들을 ReentrantLock, Condition 을 통해 제공하게 되었습니다.

Lock 종류설명
ReentrantLock재진입이 가능한 lock, 같은 객체에서 lock 을 얻었으면 그걸 계속 사용할 수 있음, 기본 lock
ReentrantReadWriteLock읽기끼리는 허용해줌, 쓰기면 lock 을 획득해야 함
StampedLock낙관적 읽기, 일단 읽기를 시도하고 확인했더니 쓰기 lock 이었다면 lock 을 획득하고 다시 읽음

Lock 메서드설명
void lock()lock 잠금
void unlock()lock 해제
boolean isLocked()lock 잠겼는지 확인
boolean tryLock()lock 잠겼으면 기다리지 않음, lock 을 얻으면 true 반환
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedExceptionlock 잠겼으면 지정한 시간동안만 기다림
Condition newCondition()새로운 쓰레드 대기 영역을 생성

Condition 메서드설명
void await()현재 스레드를 락을 해제하고 다른 스레드의 신호를 기다리는 대기 상태로
void awaitUninterruptibly()현재 스레드를 인터럽트 없이 락을 해제하고 다른 스레드의 신호를 기다리는 대기 상태로
boolean await(long time, TimeUnit unit)현재 스레드를 지정된 시간 동안 락을 해제하고 다른 스레드의 신호를 기다리는 대기 상태로
long awaitNanos(long nanosTimeout)현재 스레드를 최대 나노초 동안 락을 해제하고 다른 스레드의 신호를 기다리는 대기 상태로
boolean awaitUntil(Date deadline)현재 스레드를 특정 데드라인까지 락을 해제하고 다른 스레드의 신호를 기다리는 대기 상태로
void signal()이 Condition 에서 대기하고 있는 단일 스레드를 깨움
void signalAll()이 Condition 에서 대기하고 있는 모든 스레드를 깨움



volatile

현대의 멀티 코어 프로세서를 사용하는 컴퓨터는 CPU 마다 cache 를 가지게 되면서 메모리와 동기화 되지 않아 각 cache 마다 상이한 값을 가지게 되는 cache coherence 문제가 생기게 되었습니다.

이를 해결하기 위해 Java 에서는 volatile 키워드를 제공합니다.

✅ volatile 키워드가 붙은 변수는 반드시 메모리에서 값을 가져옵니다.

  • cache coherence 문제를 해소합니다.

✅ long, double 과 같은 CPU 가 2개 이상의 명령어를 필요로 하는 타입의 변수를 volatile 키워드로 원자화 할 수 있습니다.

  • 원자화 되었다고 해서 동기화 된것은 아닙니다. CPU가 사용하는 2개 이상의 명령어 중간에 context-switching 을 방지하는 것이지, 멀티 쓰레드 상황에서 동기화가 필요한 작업들과는 별개입니다.


fork & join

멀티 쓰레드를 사용할 수 있게 되었다고 해서 모든 것이 효율적으로 바뀐것은 아닙니다.
여전히 쓰레드 생성, 관리, 작업 분할 및 결과 병합은 골치아픈 과제입니다.
Java 는 이러한 과제들을 알아서 처리해주는 기능을 fork & join 프레임워크를 통해 제공합니다.

✅ 사용자는 작업 분할 방식만 정의하면, fork & join 프레임워크가 쓰레드 관리 및 작업 스케줄링을 알아서 처리해줍니다.

  • 작업을 구현할 때 다음과 같은 추상 클래스를 상속받아 compute() 메서드를 구현합니다.
추상 클래스설명
RecursiveAction반환값이 없는 작업을 구현할 때 사용
RecursiveTask반환값이 잇는 작업을 구현할 때 사용

Recursive 라는 단어가 붙은 이유는 분할 정복을 통해 재귀적으로 compute() 를 호출하게끔 compute() 를 구현하기 때문입니다.

✅ 작업을 분할해서 .fork() 를 통해 해당 작업을 쓰레드 풀의 작업 큐에 넣습니다.
✅ 재귀 호출과 함께 .join() 을 사용해서 결과를 통합합니다.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;

public class ForkJoinEx {
    static final ForkJoinPool pool = new ForkJoinPool();

    public static void main(String[] args) {
        long from = 1L, to = 100_000_000L;

        SumTask task = new SumTask(from, to);

        long start = System.currentTimeMillis();
        Long result = pool.invoke(task);
        System.out.println("Elapsed time(4 core):" + (System.currentTimeMillis() - start));

        System.out.printf("sum of %d~%d=%d%n", from, to, result);
        System.out.println();

        result = 0L;
        start = System.currentTimeMillis();
        for (long i = from; i <= to; i++) {
            result += i;
        }

        System.out.println("Elapsed time(1 core):" + (System.currentTimeMillis() - start));
        System.out.printf("sum of %d~%d=%d%n", from, to, result);
    }
}

class SumTask extends RecursiveTask<Long> {
    long from, to;

    public SumTask(long from, long to) {
        this.from = from;
        this.to = to;
    }

    @Override
    protected Long compute() {
        long size = to - from + 1;

        if (size <= 5) {
            return sum();
        }

        long half = (from + to) / 2;

        SumTask leftSum = new SumTask(from, half);
        SumTask rightSum = new SumTask(half+1, to);

        leftSum.fork();

        return rightSum.compute() + leftSum.join();
    }

    long sum() {
        long tmp = 0L;

        for (long i = from; i <= to; i++) {
            tmp += i;
        }
        return tmp;
    }
}
profile
백엔드 개발자 지망생입니다.

0개의 댓글