본문 바로가기

프로그래밍언어/JAVA

[JAVA] 자바 Thread-safe

반응형

자바에서는 성능의 향상을 위하여 스레드 기능을 제공한다. 하지만 스레드 환경에서 개발을 하다보면 의도했던 결과가 나오지 않는 경우가 있다. 주로 여러 스레드가 하나의 자원에 동시에 접근하면서 발생하는 문제이다. 이러한 문제들을 해결하기 위하여 자바에서는 여러 기능들을 제공하는데, 이러한 기능들을 사용하여 멀티 스레드 환경에서의 실행에 문제가 없도록 프로그래밍한 프로그램을 thread-safe 하다고 한다.

1. synchronized (암시적 lock 사용)

자바에서 thread-safe 하게 구현하는 가장 간단한 방법은 synchronized 키워드를 사용하는 것이다. thread-safe 하게 동작하게 하고 싶은 메서드 또는 블럭을 지정하여서 synchronized 키워드와 함께 선언하여 구현하면 된다.

 

public class SyncSingleton {

    private int count = 0;

   // synchronized method
   public synchronized int increase() {
        return ++count;
    }

    // synchronized statement 로 구현하여 해당 블럭에 한번에 한 쓰레드만 접근할 수 있도록 한다.
    public int increase() {
        synchronized(this) {
            return ++count;
        }
    }
}

 

synchronized 로 선언된 블럭은 한 스레드만 접근할 수 있다. 한 스레드에 의해서 실행 중인 메서드에 다른 스레드가 접근하는 경우 블로킹된다. 메서드를 수행 중인 스레드가 수행을 마치거나 예외가 발생하여 메서드에서 탈출할 때까지 블로킹된다.

 

synchronized 는 암시적 lock 사용이라고도 불리는데, 그 이유는 Lock 클래스를 사용하여 직접 lock, unlock 을 구현하는 것이 아니라 내부적으로 객체의 고유 락을 사용하여 접근을 제어하기 때문이다.

 

synchronized 키워드를 사용하면 간편하지만 최적화가 되어있지 않아서 다른 방법들에 비해 성능이 떨어진다.

 

intrinsic lock (고유 락)

 

자바의 모든 객체는 lock 을 가지고 있다. 이를 고유 락 (intrinsic lock) 또는 모니터 락 (monitor lock) 이라고 부른다.

synchronized 는 고유 락을 사용하는데, synchronized 메서드가 호출되면 해당 스레드는 고유 락을 획득하고 메서드가 종료되면 락을 해제한다.

2. 명시적 lock 사용

synchronized 키워드를 사용하여 암시적으로 lock 을 제어하는 것이 아닌, 직접 Lock 객체를 명시적으로 생성하여 제어할 수 있다.

 

자바는 'java.util.concurrent.lock' 패키지를 통해서 고유 락 외에 직접 동기화를 구현할 수 있는 lock 구현체를 제공한다. 이들은 보다 다양한 기능을 제공하고 있으며, 이를 통해서 코드에서 lock 획득과 해제를 구현할 수 있다.

 

해당 패키지에는 ReentrantLock, ReentrantReadWriteLock, StampedLock 등의 클래스가 포함된다.

- ReentrantLock

ReentrantLock 은 가장 일반적인 lock 이다. Reentrant 라는 이름이 붙은 이유는 특정 조건에서 lock 을 반납하고 이후에 다시 재진입을 시도할 수 있기 때문이다.

 

ReentrantLock 을 사용하기 위해서는 ReentrantLock 객체를 생성해야 한다. lock 을 걸고 싶은 위치에서 ReentrantLock 객체의 lock() 메서드를 호출하고, 해제하고 싶은 위치에서 unlock() 을 호출하여 lock 을 제어할 수 있다.

 

public class ReentrantLockSingleton {

    private int count = 0;
    private Lock lock = new ReentrantLock();

    public int increase() {
        lock.lock(); // lock 을 획득한다.
        try {
            return ++count;
        } finally {
            lock.unlock(); // lock 을 반납한다.
        }
    }
}

 

※ 스레드의 재진입

 

스레드가 락을 획득하여 작업을 진행하던 중, 더이상 로직을 수행하지 못하고 대기해야 하는 경우가 있다. 이때 deadlock 상황을 피하기 위하여 wait() 을 호출하여 락을 반납하고 대기하도록 한다. 이후에 로직을 다시 수행할 수 있는 상황에 notify() 를 호출하여 다시 해당 영역에 재진입을 시도할 수 있다.

 

ReentrantLock 에서는 wait, nofity 대신에 await(), signal(), signalAll() 등의 메서드를 사용하여 스레드의 재진입을 구현할 수 있다.

- ReentrantReadWriteLock

ReentrantReadWriteLock 은 읽기 락과 쓰기 락을 함께 제공한다.

 

읽기의 경우에는 값을 변경하지 않기 때문에 lock 이 걸려있어도 중복으로 lock 을 걸고 자원에 접근하여 읽기를 수행할 수 있다. 하지만 쓰기의 경우 lock 이 걸려있으면 중복으로 접근할 수 없다.

 

읽기 + 읽기의 경우에는 중복 접근이 가능하지만, 읽기 + 쓰기 또는 쓰기 + 쓰기의 경우에는 중복으로 접근이 불가능하다.

 

public class ReentrantReadWriteLockSingleton {

    private int count = 0;
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock(); // 읽기 락
    private Lock writeLock = lock.writeLock(); // 쓰기 락

    // 값을 읽는 연산이기 때문에 readLock 을 사용한다.
    public int getCount() {
        readLock.lock();
        try {
            return count;
        } finally {
            readLock.unlock();
        }
    }

    // 값을 쓰는 연산이기 때문에 writeLock 을 사용한다.
    public void setCount(int count) {
        readLock.lock();
        try {
            this.count = count;
        } finally {
            readLock.unlock();
        }
    }

    public int increase() {
        writeLock.lock();
        try {
            return ++count;
        } finally {
            writeLock.unlock(); // lock 을 반납한다.
        }
    }
}

3. concurrent 패키지 사용

"java.util.concurrent.atomic" 패키지에서는 thread-safe 한 클래스 타입 들을 제공한다. 해당 클래스 타입으로 변수를 선언하여 thread-safe 한 동기화를 구현할 수 있다.

 

atomic 타입은 해당 타입으로 선언한 단일 변수에 대하여 atomic operations 를 지원하는 타입이다. atomic 타입은 사용 시에 내부적으로 CAS (Compare And Swap) 알고리즘을 사용하여 값을 비교하는 방식으로 동기화를 적용한다. 이 방식을 사용하여 따로 lock 을 사용하지 않고 동기화를 처리할 수 있다.

 

import java.util.concurrent.atomic.AtomicInteger;

public class ReentrantLockSingleton {

    private AtomicInteger count = new AtomicInteger(0);

    public int increase() {
        return count.incrementAndGet();
    }
}

- Compare And Swap

CAS 알고리즘은 변수의 값을 확인할 때, 현재 스레드의 데이터를 실제 메모리에 저장된 데이터와 비교하여 두 값이 일치하는 경우에만 해당 값을 사용할 수 있도록 한다. 현재 연산 중에서 스레드의 값과 메모리의 값이 다른 경우 중간에 다른 스레드를 통한 작업이 있었던 것으로 판단하여 write 를 중단하고 작업을 재시도하도록 한다.

4. 기타

이외에도 구현의 방식에 따라서 Thread-Local, volatile, immutable object 등을 사용하여 thread-safe 하게 구현할 수 있다. 각 방식마다 동작에 따른 성능의 문제나 구현하는 방식 등이 다르기 떄문에 이를 잘 고려하여서 알맞은 방식으로 구현하면된다.

 

 

[reference]

반응형

'프로그래밍언어 > JAVA' 카테고리의 다른 글

[JAVA] Virtual Thread  (0) 2024.05.03
[JAVA] 람다식 (lambda expression)  (1) 2024.01.24
[JAVA] 메서드 참조 (::)  (0) 2022.04.14
[JAVA] 쓰레드의 동기화  (0) 2022.03.15
[JAVA] 쓰레드 실행제어  (0) 2022.03.11