본문 바로가기

Language/Java

자바의 동기화 메커니즘(volatile, synchronized, Lock(ReentrantLock), Atomic/CAS, 동기화 컬렉션)

volatile

멀티스레드 환경에서 한 스레드가 변경한 값을 다른 스레드가 제대로 볼 수 있는지에 대한 문제를 메모리 가시성이라고 한다. 즉, 메모리에 변경한 값이 보이는가, 보이지 않는가의 문제이다.

 

메모리 가시성 문제는 캐시 메모리로 인해 발생한다. CPU 입장에서 메인 메모리는 거리도 멀고 속도도 상대적으로 느리다. CPU 연산은 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라가려면, CPU와 가까이 있으며 매우 빠른 메모리가 필요하다. 이것이 캐시 메모리이다. 캐시 메모리는 메인 메모리보다 CPU와 가까이 있으면서 속도도 더 빠르다. 현대의 대부분의 CPU는 각 코어마다 캐시 메모리를 갖는다. (참고로 여러 코어가 공유하는 캐시 메모리도 있다.)

 

예를 들어, 다음 그림처럼 main 스레드가 CPU 코어1을 사용하고, work 스레드가 CPU 코어2를 사용한다고 가정하자.

그리고 두 스레드가 각각 runFlag라는 값을 읽은 후에, 해당 값을 각자 자신의 캐시 메모리에 보관해둔다. 그리고 이후에 해당 값을 변경할 때는 캐시 메모리에 값을 변경하고, 값을 읽을 때는 캐시 메모리에서 값을 읽는다. 따라서 main 스레드가 runFlag 값을 flase로 변경하더라도, 즉시 메인 메모리로 반영되지 않고, work 스레드는 자신의 캐시 메모리에서 값을 읽어오기 때문에 여전히 runFlag가 true라고 인식하게 된다. 

 

그렇다면, main 스레드가 캐시 메모리에 저장한 false라는 runFlag 값이 언제 메인 메모리에 반영될까? 정답은 알 수 없다. CPU 설계 방식과 종류에 다라 다르다. 그리고 메인 메모리에 runFlag 값이 반영된다고 하더라도, flase로 변경된 값을 다시 work 스레드가 사용하는 캐시 메모리로 불러와야 한다. 그렇다면, 메인 메모리에 변경된 runFlag 값이 언제 CPU 코어 2의 캐시 메모리에 반영될까? 이 또한 정답은 알 수 없다. (주로 컨텍스트 스위칭이 될 때 캐시 메모리도 함께 갱신되는데, 이 또한 환경에 따라 다를 수 있다.)

 

즉, 캐시 메모리를 사용하면 CPU 성능을 개선할 수 있지만 메모리 가시성 문제가 발생할 수 있다. 따라서 자바의 volatile 키워드를 사용하면 이러한 문제를 해결하며, 변수의 가시성을 보장한다.

 

volatile 키워드가 붙은 값에 대해서는 캐시 메모리를 사용하지 않고, 값을 읽거나 쓸 때 항상 메인 메모리에 직접 접근한다. 따라서 여러 스레드에서 같은 값을 읽고 써야 한다면, volatile 키워드를 사용하면 가시성 문제를 해결할 수 있다. 단, 캐시 메모리를 사용할 때보다 성능이 느려진다는 단점이 있다. 

synchronized

모든 객체 인스턴스는 내부에 자신만의 락을 가지고 있다. 이를 모니터 락이라고도 부른다. (객체 내부에 있으며 직접 확인할 수 없다.)

 

스레드가 synchronized 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락을 획득해야 한다. 만약 다른 스레드가 락을 쥐고 있어서 해당 스레드가 락을 획득할 수 없으면, BLOCKED 상태로 대기한다. 즉, 해당 스레드는 RUNNABLE에서 BLOCKED 상태로 변하고, 락을 획득할 때까지 무한정 대기한다. 그리고 스레드가 BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 CPU 실행 스케줄링 큐에 들어가지 않는다. 즉, CPU를 획득하지 않는다.

 

참고로, 여러 개의 스레드가 락을 대기하고 있다면 락이 반납되었을 때 대기 중인 스레드들 중 하나의 스레드만 락을 획득하는데, 이때 어떤 스레드가 락을 획득하는지는 자바 표준에 정의되어 있지 않다. 따라서 대기 순서를 보장하지 않는다.  

 

synchronized 키워드를 사용하면 개발자가 직접 락을 획득, 대기, 반납하고 스레드 상태를 BLOCKED, RUNNABLE 상태로 변경하는 등의 작업을 명시적으로 하지 않아도 된다. 개발자는 synchronized 키워드만 사용하면 위와 같은 작업들이 알아서 관리된다. 따라서 synchronized는 매우 편리하게 동기화를 할 수 있다는 장점이 있다.

 

그러나 synchronized가 제공하는 기능은 너무 단순하다. 시간이 지나면서 멀티스레드가 점점 중요해지고 복잡한 동시성 방법들이 필요해졌는데, synchronized는 다음과 같은 한계를 갖는다. 

  • 무한 대기: BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
  • 공정성: 락이 돌아왔을 때 대기 중인 스레드들 중 어떤 스레드가 락을 획득하는지 알 수 없다. 최악의 경우 특정 스레드가 오랜 기간 락을 획득하지 못할 수 있다.

결국 더 유연하고 세밀한 제어가 가능한 방법들이 필요해졌고, 이를 위해 자바 1.5부터 java.util.concurrent 패키지가 추가되었다.

LockSupport

LockSupport를 사용하면 synchronized의 가장 큰 단점인 무한 대기 문제를 해결할 수 있다.

synchronized는 락을 대기하는 스레드는 BLOCKED 상태가 된다. 그러나 LockSupport는 스레드를 WAITING 상태로 변경한다. 그리고 WAITING 상태는 누가 깨워주기 전까지 계속 대기하며, CPU 실행 스케줄링에 들어가지 않는다.

 

LockSupport의 대표적인 기능은 다음과 같다. 

  • park(): 스레드를 WAITING 상태로 변경한다.
  • parkNanos(nanos): 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경한다. 지정한 시간이 지나면 RUNNABLE 상태로 변경된다.
  • unpark(thread): WAITING 상태의 대상 스레드를 RUNNABLE 상태로 변경한다.

그리고 다음과 같이 활용하면, 무한 대기하지 않는 락 기능을 만들 수 있다.

if (!lock.tryLock(10초)) { // 내부에서 parkNanos() 사용

    log("진입 실패! 너무 오래 대기했습니다.");
    return false;
}

// 락 획득 후에 임계 영역 진입
// 임계 영역
lock.unlock(); // 임계영역 종료 후에 락 반납: 내부에서 unpark()를 사용하여 대기 중인 스레드 깨우기

 

하지만 이런 기능을 직접 구현하기 매우 어렵다. 예를 들어, 스레드 10개를 동시에 실행할 경우, 동시에 접근하는 스레드들 중 딱 하나의 스레드만 락을 획득하도록 해야 하고, 나머지 9개의 스레드가 대기할 공간도 만들어야 한다. 그리고 대기 중인 스레드들 중 어떤 스레드를 깨울 것인지도 결정해야 한다.

 

즉, LockSupport는 너무 저수준이다. synchronized처럼 더 고수준의 기능이 필요하다. 자바는 Lock 인터페이스와 ReentrantLock이라는 구현체로 이런 기능들을 이미 다 구현해놨다. 즉, ReentrantLock은 LockSupport를 활용해서 synchronized의 단점을 극복하면서도 편리하게 사용할 수 있다.

ReentrantLock

Lock 인터페이스는 다음과 같은 메서드를 제공하고, 대표적인 구현체로 ReentrantLock이 있다. 참고로 여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다. 모니터 락과 BLOCKED 상태는 synchronized를 사용할 때만 사용된다. 

package java.util.concurrent.locks;

public interface Lock {

    void lock();

    void lockInterruptibly() throws InterruptedException;

    boolean tryLock();

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    void unlock();
    
    Condition newCondition();
}
  • void lock()
    • 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 WAITING 상태로 대기한다.
    • 이 메서드는 인터럽트에 응답하지 않는다. 정확히는 인터럽트가 발생하면 순간 대기 상태를 빠져나오지만(WAITING → RUNNABLE), lock() 메서드 안에서 강제로 해당 스레드를 다시 WAITING 상태로 변경해버린다.
  • void lockInterruptibly()
    • 락을 획득하며, 다른 스레드가 락을 획득하고 있으면 대기한다. 
    • 대기 중에 인터럽트가 발생하면 InterruptedException이 발생하며 락 대기를 포기한다.
  • boolean tryLock()
    • 락 획득을 시도하고, 즉시 성공 여부를 반환한다.
    • 만약 다른 스레드가 이미 락을 획득했다면 false를, 그렇지 않으면 true를 반환한다.
  • boolean tryLock(long time, TimeUnit unit)
    • 주어진 시간 동안 락 획득을 시도한다.
    • 주어진 시간 안에 락을 획득하면 true를 반환하고, 주어진 시간이 지나도 락을 획득하지 못하면 false를 반환한다.
    • 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException이 발생하며 락 획득을 포기한다.
  • void unlock()
    • 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
    • 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면 IllegalMonitorStateException이 발생할 수 있다.
  • Condition newCondition()
    • Condition 객체를 생성하여 반환한다. 
    • Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있게 한다. 이는 Object 클래스의 wait(), notify(), notifyAll() 메서드와 유사한 역할을 한다.

이러한 메서드들을 사용하면 synchronized보다 더 정교하고 유연하게 고수준의 동기화 기법을 구현할 수 있다. 특히 락을 특정 시간만큼 기다리거나, 인터럽트 가능한 락을 사용할 때 유용하다. Lock 인터페이스가 제공하는 메서드를 통해 synchronized의 단점인 무한 대기 문제를 해결할 수 있다.

 

또한 synchronized의 공정성 문제도 해결할 수 있다. ReentrantLock을 생성할 때 다음과 같이 fair를 설정할 수 있다.

public class ReentrantLock implements Lock, java.io.Serializable {

    public ReentrantLock() {
        sync = new NonfairSync();
    } 
    
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
    
    ...
}
  • 비공정 모드(Nonfair)
    • ReentrantLock의 기본 모드로, 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있다.
    • 성능 우선: 락을 획득하는 속도가 빠르다.
    • 선점 가능: 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
    • 기아 현상 가능성: 특정 스레드가 계속해서 락을 획득하지 못할 가능성이 있다. 
  • 공정 모드(Fair)
    • 공정 모드는 락을 요청한 순서대로 스레드가 락을 획득한다.
    • 공정성 보장: 먼저 대기한 스레드가 락을 먼저 획득한다.
    • 기아 현상 방지: 모든 스레드가 언젠간 락을 획득한다.
    • 성능 저하: 락을 획득하는 속도가 느려질 수 있다.

정리하자면 Lock 인터페이스와 ReentrantLock 구현체를 사용하면, synchronized의 단점인 무한 대기 문제와 공정성 문제를 해결할 수 있다. 또한 더 유연하고 세밀하게 스레드를 제어할 수 있다.

Atomic 클래스, CAS

원자적 연산

원자적 연산은 해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미한다.

예를 들어, 다음은 원자적 연산이다.

i = 1;

 

그러나다음은 원자적 연산이 아니다. i를 읽고, 읽은 값에 1을 더하고, 더한 결과 값을 i에 대입하는 총 3가지 과정으로 나뉘기 때문이다.

i = i + 1;

 

원자적 연산이 아닌 경우에는 멀티스레드 상황에서 의도치 않은 문제가 발생할 수 있다.

원자성 보장을 위한 방법 

이러한 문제를 해결하기 위한 방법으로 뭐가 있을까? 결론부터 말하자면, synchronizedLock 등을 사용해서 한 번에 하나의 스레드만 접근할 수 있는 안전한 임계영역을 만들어주어야 한다. 그리고 그 외에도 자바에서는 Atomic 클래스를 통해 원자성을 보장해준다.

 

참고로 volatile 키워드는 캐시 메모리와 메인 메모리 간 동기화되지 않는 문제를 해결하며 가시성을 보장해주는 것이지, 원자성을 보장해주지는 않는다. 이렇게 연산 자체가 나누어진 경우에는 synchronized나 Lock 등을 사용해서 한 번에 하나의 스레드만 접근할 수 있는 안전한 임계영역을 만들어주어야 한다.

 

각 방법을 예제로 살펴보자. 

IncrementInteger 인터페이스

public interface IncrementInteger {

    void increment();

    int get();
}

1. 기본

public class BasicInteger implements IncrementInteger {

    private int value;

    @Override
    public void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}

2. volatile 사용

public class VolatileInteger implements IncrementInteger {

    private volatile int value;

    @Override
    public void increment() {
        value++;
    }

    @Override
    public int get() {
        return value;
    }
}

3. synchronized 사용

public class SyncInteger implements IncrementInteger {

    private int value;

    @Override
    public synchronized void increment() {
        value++;
    }

    @Override
    public synchronized int get() {
        return value;
    }
}

4. Atomic 클래스 사용

public class MyAtomicInteger implements IncrementInteger {

   AtomicInteger atomicInteger = new AtomicInteger(0);

    @Override
    public void increment() {
        atomicInteger.incrementAndGet();
    }

    @Override
    public int get() {
        return atomicInteger.get();
    }
}

각 방법 비교

public class Example {

    public static final int THREAD_COUNT = 1000;

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        test(new BasicInteger());
        test(new VolatileInteger());
        test(new SyncInteger());
        test(new MyAtomicInteger());
    }

    private static void test(IncrementInteger incrementInteger) throws InterruptedException {

        Runnable runnable = () -> {
            sleep(10);
            incrementInteger.increment();
        };

        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new Thread(runnable);
            threads.add(thread);
            thread.start();
        }

        for (Thread thread : threads) {
            thread.join(); // 모든 스레드의 실행이 종료될 때까지 대기
        }

        int result = incrementInteger.get();
        System.out.println(incrementInteger.getClass().getSimpleName() +
                " result: " + result);
    }
}

 

1000개의 스레드가 하나의 IncrementInteger에 접근하여 그 값을 증가시키는 작업이다. 해당 작업을 각 방법으로 진행했을 때 결과는 다음과 같다.

  • value++ 연산은 value 값을 읽은 후, 1을 더하고, value 변수에 결과를 대입하는 연산이다. 즉, 원자성이 보장되지 않는다. 따라서 동시에 여러 스레드가 접근했을 때 다음과 같은 문제가 발생할 수 있다.
    • 스레드 1이 value 값이 2임을 읽고, 스레드 2도 value 값이 2임을 읽는다.
    • 스레드 1이 2에 1을 더한 값인 3을 value에 대입하고, 스레드 2도 3을 value에 대입한다.
    • 즉, value 값이 2에서 4가 되어야 하는데 3이 되는 문제가 발생했다.
  • 따라서 기본 방법과 volatile을 사용한 방법은 결과 값이 1000보다 작다. 즉, 원자성 문제가 발생했다.
  • synchronized와 Atomic 클래스를 사용한 방법은 원자성이 보장되며 1000개의 스레드가 안전하게 연산을 수행했다.

그리고 각 방법의 성능을 비교해보자. incrementInteger.increment()를 각각 1억번 호출했을 때 걸린 시간은 다음과 같다.

public class Example {

    public static final long COUNT = 100_000_000;

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        test(new BasicInteger());
        test(new VolatileInteger());
        test(new SyncInteger());
        test(new MyAtomicInteger());
    }

    private static void test(IncrementInteger incrementInteger) throws InterruptedException {

        long startMs = System.currentTimeMillis();
        for (long i = 0; i < COUNT; i++) {
            incrementInteger.increment();
        }
        long endMs = System.currentTimeMillis();
        System.out.println(incrementInteger.getClass().getSimpleName() +
                ": ms=" + (endMs - startMs));
    }
}

  • 캐시 메모리를 사용한 기본 방법이 가장 빠르고, 캐시 메모리를 사용하지 않는 volatile 방법은 기본에 비해 더 느리다.
  • 원자성을 보장하는 synchronized와 Atomic 둘 중 synchronized 방식이 더 느리다.

이처럼 자바는 멀티스레드 상황에서 원자성을 보장하며 안전한 연산을 가능하게 해주는 Atomic 클래스를 제공한다. 그리고 Atomic은 락을 사용하지 않고 원자성을 보장하기 때문에 synchronized 보다 더 빠르다.

 

synchronized와 Lock(ReentrantLock) 등의 락 방식은 임계영역에 접근할 때마다 락이 있는지 확인하고 없으면 대기한다. 그리고 그 과정에서 스레드의 상태가 RUNNABLE에서 BLOCKED로 변경되고 context switching도 일어난다. 따라서 Atomic을 통한 원자성 보장보다 더 무거운 방식이다.

 

그렇다면 Atomic은 어떻게 락을 사용하지 않고 원자성을 보장할까? 정답은 CAS라는 알고리즘 덕분이다.

CAS(Compare And Swap/Set)

Atomic은 락을 사용하지 않고 원자성을 보장한다. 그리고 CAS는 락을 사용하지 않기 때문에 락 프리 기법이라고 한다.

 

AtomicInteger은 compareAndSet()이라는 메서드를 제공하는데, 이 메서드가 바로 CAS 연산을 통해 락 없이 원자성을 보장해준다. 예를 들어, atomicInteger.compareAndSet(0, 1)이라고 하면,

  • atomicInteger 값이 0이면 이 값을 1로 변경한 후 true를 반환한다.
  • atomicInteger 값이 0이 아니면 atomicInteger 값은 변경되지 않고 false를 반환한다.

그리고 이러한 연산이 CAS 연산을 통해 원자적으로 실행된다. 그런데 위 명령어는 다음과 같이 2개의 명령어로 나뉘어져 원자적이지 않은 것처럼 보인다.

  • 메인 메모리에 있는 값을 확인한다.
  • 해당 값이 기대하는 값(0)이라면 원하는 값(1)으로 변경한다.

그러나 CAS 연산은 이렇게 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 하나의 원자적 연산으로 묶어서 제공한다. 대부분 현재의 CPU등은 CAS 연산을 위한 명령어를 제공하며, 이렇게 하드웨어가 제공하는 기능을 통해 두 개의 연산을 원자적 연산으로 취급할 수 있는 것이다. 

락 구현 예제

락을 구현하는 예제를 통해 CAS가 어떤 방식으로 구현되어 있는지 알아보자.

1. 기본 방법으로 락 구현

public class SpinLock {

    private volatile boolean lock = false;

    public void lock() {
        log("락 획득 시도");
        while(true) {
            if (!lock) {
                lock = true;
                break;
            } else {
                log("락 획득 실패 - 스핀 대기");
            }
        }
        log("락 획득 성공");
    }

    public void unlock() {
        lock = false;
        log("락 반납 완료");
    }
}
  • lock이 false인지 확인하고, false이면 true로 변경하여 락을 획득한다. 
  • 그러나 lock이 false인지 확인하고, true로 변경하는 연산은 원자적 연산이 아니다.
  • 따라서 실행 결과 다음과 같이 두개의 스레드가 동시에 임계영역에 접근하는 문제가 발생한다. (CPU가 빠르기 때문에 락을 획득하는 과정에 sleep()을 걸어주었다.)
public class Example {

    public static void main(String[] args) throws InterruptedException, ExecutionException {

        SpinLock spinLock = new SpinLock();
        Runnable task = () -> {
            spinLock.lock();
            try {
                // critical section
                log("비즈니스 로직 실행");
            } finally {
                spinLock.unlock();
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");
        t1.start();
        t2.start();
    }
}

  1. 즉, 스레드1이 lock이 false임을 확인하여 if문을 통과한다. 그때 스레드2도 lock이 false임을 확인하여 if문을 통과한다.
  2. 이어서 스레드1이 lock을 true로 변경하고, 스레드2도 lock을 true로 변경한다.
  3. 즉, 스레드1과 스레드2가 동시에 락을 획득하여 임계영역에 접근한다.

2. CAS로 락 구현

이를 Atomic 클래스를 활용하여 변경하면 다음과 같다. Atomic 클래스를 활용하면 CAS를 통해 원자적 연산을 보장해준다.

public class SpinLock {

    private final AtomicBoolean lock = new AtomicBoolean(false);

    public void lock() {
        log("락 획득 시도");
        while (!lock.compareAndSet(false, true)) {
            log("락 획득 실패 - 스핀 대기");
        }
        log("락 획득 성공");
    }

    public void unlock() {
        lock.set(false);
        log("락 반납 완료");
    }
}

즉, 원자적이지 않았던 다음 연산을

while(true) {
    if (!lock) { // 1. lock이 false인지 확인
        lock = true; // 2. lock을 true로 변경
    }
}

 

다음과 같이 Atomic 클래스의 CAS 연산을 통해 원자적 연산으로 바꾸었다.

while (lock.compareAndSet(false, true)) // 1. lock이 false이면 true로 변경

CAS 방식 vs 락 방식

락 방식은 충돌이 잦을 것이라고 예상하여, 임계영역 전에 항상 락을 획득하는 방식이다. 반면, CAS 방식은 락을 사용하지 않고 바로 임계영역에 접근한 후, 충돌이 발생하면 반복적으로 재시도를 한다.

  • 락 방식: 비관적 접근법(충돌이 잦을 것이라고 예상하여, 임계영역 접근 전에 항상 락 획득)
  • CAS 방식: 낙관적 접근법(충돌이 잦지 않을 것이라고 예상하여, 락을 사용하지 않고 바로 임계영역에 접근 후 충돌이 발생하면 재시도)

락 방식과 비교하여 CAS 방식의 장단점은 다음과 같다.

CAS 방식의 장점

락을 사용하면 스레드가 임계영역 접근 전에 매번 락이 있는지 확인하고 대기 및 획득하는 과정을 거친다. 또한 락을 대기할 때 RUNNABLE이었던 스레드의 상태가 BLOCKED 또는 WAITING으로 변경되고 context switching이 발생한다. 즉, 락을 사용하는 방식은 이러한 오버헤드가 발생하여 성능이 떨어질 수 있다.

 

그러나 CAS 방식은 스레드가 계속 RUNNABLE 상태이며 루프를 돌며 재시도하기 때문에 스레드의 상태 변경, context switching 비용 등 오버헤드가 발생하지 않아 성능이 더 좋을 수 있다.

CAS 방식의 단점

그러나 CAS 방식은 임계영역에 접근할 수 있을 때까지 RUNNABLE 상태로 루프를 돌며 CPU를 소모한다. 따라서 충돌이 빈번하게 발생하는 환경이라면, 스레드가 무의미하게 CPU를 잡고 루프를 돌며 자주 재시도하기 때문에 성능이 떨어질 수 있다.

 

예를 들어, 다음과 같이 임계영역의 로직이 오래 걸린다면, 스레드2가 스레드 1이 임계영역을 수행하는 시간내내 루프를 돌며 CPU 자원을 소모하게 된다.

public class Example {

    public static void main(String[] args) throws InterruptedException, ExecutionException {

        SpinLock spinLock = new SpinLock();
        Runnable task = () -> {
            spinLock.lock();
            try {
                // critical section
                log("비즈니스 로직 실행");
                sleep(1); // 오래 걸리는 로직
            } finally {
                spinLock.unlock();
            }
        };

        Thread t1 = new Thread(task, "Thread-1");
        Thread t2 = new Thread(task, "Thread-2");
        t1.start();
        t2.start();
    }
}

 

이처럼 스레드가 락을 획득하기 위해 자원을 소모하면서 반복적으로 확인(스핀)하는 락 메커니즘을 스핀 락이라고 한다. 그리고 이런 스핀 락은 CAS를 통해 구현할 수 있다. 그리고 이런 방식에서 스레드가 락을 획득할 때까지 대기하는 것을 스핀 대기(spin wait) 또는 바쁜 대기(busy wait)라고 한다.

락 방식보다 CAS 방식이 적합한 경우

그렇다면 언제 락이 아닌 CAS를 사용하는 것이 좋을까? 정답은 안전한 임계영역이 필요하면서, 연산이 길지 않고 매우 짧게 끝날 때 사용해야 한다. 예를 들어, 단순 숫자 값의 증가, 자료구조의 데이터 추가와 같이 CPU 사이클이 금방 끝나는 연산에 사용하면 효과적이다. 반면 데이터베이스 결과를 대기하거나, 다른 서버의 요청을 기다리는 등 오래 걸리는 작업에 사용하면, 충돌 시 다른 스레드가 연산이 끝날 때까지 CPU를 사용하며 대기하므로 매우 비효율적이다. 

 

일반적으로는 동기화 락을 사용하고, 아주 특별한 경우에 한정해서 CAS를 사용해서 최적화해야 한다. CAS를 통한 최적화가 더 나은 경우는 스레드가 RUNNABLE BLOCKED, WAITING RUNNABLE 상태로 변경되는 것보다, 스레드를 RUNNABLE로 살려둔 상태에서 계속 락 획득을 반복 체크하는 것이 더 효율적인 경우에 사용해야 한다. 하지만 이 경우 대기하는 스레드가 CPU 자원을 계속 소모하기 때문에 대기 시간이 아주아주아주 짧아야 한다. 따라서 임계 영역이 필요는 하지만, 연산이 길지 않고 매우매우매우! 짧게 끝날 때 사용해야 한다.

동기화 컬렉션

우리가 일반적으로 자주 사용하는 ArrayList, LinkedList, HashSet, HashMap 등의 자료구조는 스레드 세이프하지 않다. 따라서 멀티스레드 환경에서 여러 스레드가 동시에 컬렉션에 접근한다면, java.util 패키자에서 제공하는 일반적인 컬렉션들을 사용하면 안된다. 

Collections.synchronizedXXX()

다음과 같이 Collections.synchronizedXXX() 메서드를 사용하면 동기화된 컬렉션을 반환받을 수 있다.

List<Object> list = Collections.synchronizedList(new ArrayList<>());
Map<Object, Object> map = Collections.synchronizedMap(new HashMap<>());

 

예를 들어, Collection.synchronizedList() 메서드는 다음과 같이 SynchronizedRandomAccessList가 인자로 주어진 컬렉션을 감싼다. 그리고 SynchronizedRandomAccessList는 synchronized를 추가하는 프록시 역할을 한다.

public static <T> List<T> synchronizedList(List<T> list) {
    return (list instanceof RandomAccess ?
        new SynchronizedRandomAccessList<>(list) :
        new SynchronizedList<>(list));
}

따라서 클라이언트가 컬렉션에 접근하려고 하면, 우선 SynchronizedRandomAccessList가 호출되고, SynchronizedRandomAccessList가 원본 컬렉션을 호출한다. 예를 들어, SynchronizedRandomAccessList의 add() 메서드를 살펴보면, 다음과 같이 synchronized를 적용한 후 원본 컬렉션의 add()를 호출하는 것을 볼 수 있다.

public void add(int index, E element) {
    synchronized (mutex) {
        list.add(index, element);
    }
}

synchronized 프록시 방식의 단점

그러나 이 방식은 다음과 같은 단점이 있다.

  • synchronized 키워드가 멀티스레드 환경에서 안전한 접근을 보장하지만, 각 메서드 호출 시마다 동기화 비용이 추가된다. 이로 인해 성능 저하가 발생할 수 있다.
  • 정교한 동기화가 불가능하다. synchronized 프록시를 적용하면 컬렉션 전체에 대한 동기화가 이뤄지기 때문에, 특정 부분이나 메서드에 대해 선택적으로 동기화를 적용하는 것이 어렵다. 따라서 특정 스레드가 컬렉션을 사용하고 있을 때 다른 스레드가 대기해야 하는 상황이 빈번해지며, 이로 인해 성능 저하가 발생할 수 있다.

따라서 자바는 이런 단점을 보완하기 위해 java.util.concurrent 패키지에 동시성 컬렉션을 제공한다.

java.util.concurrent의 동시성 컬렉션

동시성 컬렉션은 synchronized, Lock(ReentrantLock), CAS, 분할 잠금(segment lock) 기술 등을 사용하여 매우 정교하게 동기화를 구현했으며 성능을 최적화했다.

CopyOnWriteArrayList

대표적인 예로, 동기화된 ArrayList인 CopyOnWriteArrayList가 있다. 즉, 동기화된 ArrayList를 생성하는 방법은 2가지가 있다.

  • Collections.synchronizedList()
  • CopyOnWriteArrayList

그러나 Collections.synchronizedList()는 읽기와 쓰기 동작 시 객체 자체에 락이 걸려 성능이 저하된다. 반면, CopyOnWriteArrayList는 쓰기 동작 시 원본 배열의 요소를 복사하여 새로운 임시 배열을 만들고, 이 임시 배열에 쓰기 동작을 수행한 후 원본 배열을 갱신한다. 따라서 쓰기를 하는 동안 원본 배열에 락이 걸리지 않아 읽기 동작이 가능하다.

 

CopyOnWriteArrayList의 get() 메서드는 다음과 같다.

public E get(int index) {
    return elementAt(getArray(), index);
}
  • synchronized가 없으므로 읽기 동작 시 락이 걸리지 않는다.

CopyOnWriteArrayList의 add() 메서드는 다음과 같다.

public void add(int index, E element) {
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        if (index > len || index < 0)
            throw new IndexOutOfBoundsException(outOfBounds(index, len));
        Object[] newElements;
        int numMoved = len - index;
        if (numMoved == 0)
            newElements = Arrays.copyOf(es, len + 1);
        else {
            newElements = new Object[len + 1];
            System.arraycopy(es, 0, newElements, 0, index);
            System.arraycopy(es, index, newElements, index + 1,numMoved);
        }
        newElements[index] = element;
        setArray(newElements);
    }
}
    
final void setArray(Object[] a) {
    array = a;
}
  • add() 메서드에 락이 걸리고, 새로운 임시 배열을 생성하고 매개변수로 넘어온 값을 임시 배열에 쓴다.
  • 그리고 array 변수가 새로운 배열을 가리키도록 한다. 그리고 array 변수는 volatile 변수이다.
    • 따라서 array 변수에 새로운 참조값을 쓸 때 캐시 메모리가 아닌 메인 메모리에서 array 변수 값을 읽어와서 값을 쓰고 메인 메모리에 반영한다. 따라서 동기화 문제를 해결할 수 있다.
    • 그리고 원래 volatile 키워드는 원자성을 보장하지 않아서, 동시에 여러 스레드가 쓰기 작업을 할 경우엔 동기화를 보장하지 않는다. 그런데 쓰기 작업인 add() 메서드에는 락이 걸려있기 때문에 여러 스레드가 동시에 쓰기 작업을 하지 못한다. 따라서 하나의 스레드만 add() 메서드를 실행하고 volatile 변수에 쓰기 작업을 하기 때문에 동기화가 보장된다.

ConcurrentHashMap

동기화된 HashMap은 ConcurrentHashMap이다. 즉, 동기화된 HashMap을 생성하는 방법은 2가지가 있다.

  • Collections.synchronizedMap()
  • ConcurrentHashMap

ConcurrentHashMap은 빈 버킷에 값을 삽입할 때는 CAS 알고리즘을 사용하고, 그 외의 버킷은 버킷 단위로 부분 잠금을 사용한다. 

 

참고로 버킷의 각 노드는 volatile로 선언되어 있다.

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;
        ...
    }
    ...
}

 

노드를 삽입하는 과정은 다음과 같다.

final V putVal(K key, V value, boolean onlyIfAbsent) {
	if (key == null || value == null) throw new NullPointerException();
	int hash = spread(key.hashCode());
	int binCount = 0;
        
	// 반복문
	for (Node<K,V>[] tab = table;;) {
		Node<K,V> f; int n, i, fh; K fk; V fv;
		if (tab == null || (n = tab.length) == 0)
			tab = initTable();
                
		// 버킷이 비어있는지(null인지) 확인
		else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
			// 버킷이 null이면, 한 번 더 버킷이 null인지 확인하고 새로운 노드를 삽입 (CAS 알고리즘)
			if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
				break;                 
		}
		...
	}
	...
}
  • 새로운 노드를 삽입하기 위해 버킷이 비어있는지(null인지) 확인한다.
  • 버킷이 비어있으면(null이면), CAS 알고리즘을 통해 버킷에 노드를 삽입한다. (락 X)
    • 버킷이 비어있으면, 한 번 더 버킷의 노드(volatile 변수)가 null인지 확인하고, null이 맞을 때에만 새로운 노드를 삽입한다. (CAS 알고리즘)
    • 따라서 volatie 변수에 접근하여 가시성을 보장하고, 버킷의 노드가 null이 맞는지 한 번 더 확인하여 일치할 경우에만 노드를 삽입하는 CAS 알고리즘을 통해 원자성을 보장한다.
  • 버킷이 비어있지 않으면, synchronized 블록으로 해당 버킷에만 락을 걸어 노드를 삽입한다.

Reference

  • 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성