요약

모든 클래스가 자신의 스레드 안전성 정보를 명확히 문서화해야 한다.

  • 클라이언트에게 스레드 안전성 정보 전달하는 방법
    1. 주석으로 명확히 설명
    2. 스레드 안전성 애너테이션(ex. @ThreadSafe)
  • 무조건적 스레드 안전 클래스 작성시 synchronized 메서드가 아닌 비공개 락 객체를 사용하자
    • 클라이언트/하위 클래스에서 동기화 메커니즘 방해를 예방할 수 있기 때문이다.
  • 조건부 스레드 안전 클래스 사용시 호출 메서드 순서에 따른 외부 동기화 및 락 종류를 알려줘야 한다

 

개요

한 메서드를 여러 스레드가 동시에 호출할 때, 그 메서드가 어떻게 동작하는지를 알리는 것은 클래스와 클라이언트 사이의 중요한 계약과 같다. 이를 하지 않으면 클라이언트는 나름의 가정을 해야하고, 그 가정이 틀린다면 프로그램을 충분히 동기화 하지 못하거나 지나치게 동기화를 한 상태가 될 것이다. 이는 심각한 오류로 이어질 수 있다.

synchronized 한정자는 문서화와 관련이 없다

이유는 다음과 같다.

  1. 자바독이 기본 옵션에서 생성한 API 문서에는 synchronized 한정자가 포함되지 않는다
    1. 메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐, API에 속하지 않기 때문이다. 따라서 이것만으로는 그 메서드가 스레드 안전하다고 믿기 어렵다
  2. 스레드 안전성에도 수준이 나뉜다
    1. 스레드 안전성은 모 아니면 도가 아니라 다양한 수준을 가지고 있기 때문이다

 

단계별 스레드 안전성

멀티스레드 환경에서도 API를 안전하게 사용하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야한다. 단계별 스레드 안전성은 다음과 같고, 스레드 안전성의 내림차순 순서로 정렬했다.

1. 불변(immutable)

불변 클래스 인스턴스는 마치 상수와 같아서 외부 동기화가 필요없다.

  • 예시
    • String, Long, BigInteger

2. 무조건적 스레드 안전(unconditionally thread-safe)

이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다.

  • 예시
    • AtomicLong, ConcurrentHashMap

3. 조건부 스레드 안전(conditionally thread-safe)

무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다.

  • 예시
    • Collections.synchronized 래퍼 메서드가 반환한 컬렉션들
      • 이 컬렉션들이 반환한 반복자는 외부에서 동기화해야 한다
  • 해당 수준에서 클라이언트에게 명시적으로 알려줘야하는 정보
    1. 메서드 호출 순서에 따라 요구되는 외부 동기화 종류
    2. 그때 얻어야 하는 락 종류
  • Collections.synchronizedMap
  • // synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 // 반드시 그 맵을 락으로 사용해 수동으로 동기화 하라 * <pre> * Map m = Collections.synchronizedMap(new HashMap()); * ... * Set s = m.keySet(); // 동기화 블록 밖에 있어도 된다. * ... * synchronized (m) { // s가 아닌 m을 사용해 동기화해야 한다! * Iterator i = s.iterator(); // 동기화 블럭에 있어야 한다. * while (i.hasNext()) * foo(i.next()); * } * </pre> // 이대로 따르지 않으면 동작을 예측할 수 없다

4. 스레드 안전하지 않음(not thread-safe)

스레드 안전하지 않은 클래스의 인스턴스는 수정될 수 있고, 동시에 사용하려면 각각의 메서드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야 한다.

  • 예시
    • ArrayList, HashMap 같은 기본 컬렉션

5. 스레드 적대적(thread-hostile)

이 클래스의 인스턴스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다.

  • 참고
    • 스레드 적대적으로 밝혀진 클래스나 메서드는 일반적으로 문제를 고쳐 재배포하거나 사용 자제(deprecated) API로 지정한다

 

문서화 규칙

  • 클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하자
  • 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하자
  • 열거 타입은 굳이 불변이라고 쓰지 않아도 된다
  • 반환 타입만으로는 명확히 알 수 없는 정적 팩터리라면 자신이 반환하는 객체의 스레드 안전성을 반드시 문서화하자(예시: Collections.synchronizedMap)

 

공개 락

클래스가 외부에서 사용할 수 있는 락을 제공할 때의 장점과 단점은 다음과 같다.

공개 락 중에 하나가 synchronized 붙인것

장점

  • 클라이언트에서 일련의 메서드 호출을 원자적으로 수행 가능

단점

  • 내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없음
    • ConcurrentHashMap 같은 동시성 컬렉션과는 함께 사용하지 못함
  • 서비스 공격(denial-of-service attack)이 발생 가능
    • 서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.

 

비공개 락

  • 비공개 락 객체 관용구
    • 비공개 락 객체는 클래스 바깥에서는 볼 수 없으니 클라이언트가 그 객체의 동기화에 관여할 수 없어 서비스 거부 공격을 막아줄 수 있다
  • private final Object lock = new Object(); public void foo() { synchronized(lock) { ... } }

설명

  1. 무조건적 스레드 안전 클래스에서만 사용 가능
    1. synchronized 메서드 보다는 비공개 락 객체를 사용하자
  2. 조건부 스레드 안전 클래스에서는 사용 불가능
    1. 해당 클래스에서는 특정 호출 순서에 필요한 락이 무엇인지를 클라이언트에게 알려줘야 하기 때문이다
  3. 락 필드는 final로
    1. 락 필드의 변경 가능성을 최소화해 락이 교체되었을 때 발생할 문제를 미리 예방할 수 있다
  4. 상속용 클래스
    1. 상속용 클래스에서 자신의 인스턴스를 락으로 사용한다면, 하위 클래스는 쉽게 또 의도치 않게 기반 클래스의 동작을 방해할 수 있다. (반대도 가능)
    2. 따라서 이 경우에도 비공개 락을 사용해 하위 클래스에서 동기화 메커니즘을 깨뜨리는 걸 예방할 수 있다

요약

java.util.concurrent 패키지를 활용해 실행자 프레임워크를 사용하자

 

1. java.util.concurrent 패키지

이 패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다. 이 프레임워크를 사용하면 간단한 코드로 작업 큐 관련 작업을 수행할 수 있다.

실행자 코드

// 작업 큐 생성
ExecutorService exec = Executors.newSingleThreadExecutor();

// 실행자에게 태스크 넘기기
exec.execute(runnable);

// 실행자 종료
exec.shutdown();

실행자 서비스의 주요 기능들

  • get() : 특정 태스크가 완료되기를 기다린다
  • invokeAny() : 태스크 모음 중 아무것 하나가 완료가 되기를 기다린다
  • invokeAll() : 태스크 모음 중 모든 태스크가 완료되기를 기다린다
  • awaitTermination() : 실행자 서비스가 종료하기를 기다린다
  • ExecutorCompletionService : 완료된 태스크들의 결과를 차례로 받는다
  • ScheduledThreadPoolExecutor : 태스크를 특정 시간 혹은 주기적으로 실행하게 한다

java.util.concurrent.Executors

위 패키지의 정적 팩터리들을 이용하면 필요한 대부분의 실행자를 생성할 수 있다. 이 정적 팩터리로 실행자 서비스(스레드 풀)를 생성하면 스레드 개수도 유동적으로 설정할 수 있고, 큐를 둘 이상의 스레드 처리할 수 있게 설정할 수 있다.

서버 크기에 맞는 스레드 풀

CachedThreadPool(Executors.newCachedThreadPool)에서는 요청 받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위임돼 실행한다. 만약 가용한 스레드가 없다면 새로 하나 생성한다. 그렇기에 이 스레드 풀은 작은 프로그램이나 가벼운 서버에서만 사용하는 것이 좋다. 무거운 프로덕션 서버에서는 사용하면 안된다. 만약 무거운 프로덕션 서버에서 이 스레드 풀을 사용한다면 CPU 사용률이 100%으로 치닫고, 새로운 태스크가 도착하는 족족 또 다른 스레드를 생성해 상황을 더욱 악화시킬 수 있기 때문이다.

따라서 무거운 프로덕션 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool을 선택하거나 완전히 통제할 수 있는 ThreadPoolExecutor를 직접 사용하는 편이 훨씬 낫다.

 

2. 태스크

실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다. 이때 작업 단위를 나타내는 핵심 추상 개념이 태스크이다.

Runnable, Callable

태스크에는 Runnable과 Callable이 있다.

실행자 서비스가 이런 태스크를 수행하는 일반적인 메커니즘이다. 실행자 프레임워크가 작업 수행을 담당해준다.

 

3. 포크-조인 태스크

자바 7부터 실행자 프레임워크는 포크-조인 태스크를 지원하도록 확장되었다.

  • 실행 과정
    • 포크-조인 태스크는 작은 하위 태스크로 나뉠 수 있다
    • 포크-조인 풀이라는 특별한 실행자 서비스가 포크-조인 태스크를 실행
    • 포크조인 풀을 구성하는 스레드들이 이 태스크를 처리
      • 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수 있다.

→ 모든 스레드가 바쁘게 움직여 CPU를 최대한 활용해 높은 처리량과 낮은 지연시간을 달성한다.

병렬 스트림

이런 포크-조인 태스크를 직접 작성하고 튜닝하는 것은 어렵기에 자바의 병렬 스트림을 사용하면 손쉽게 이점을 얻을 수 있다. (포크-조인에 적합한 형태의 작업일 때)

요약

동기화는 배타적 실행 뿐만 아니라 스레드 간 안정적인 통신을 보장하기 때문에 공유 중인 가변 데이터는 동기화해 사용해야 한다.

그렇지 않으면 응답 불가 상태에 빠지거나 안전 실패(프로그램이 잘못된 결과를 계산해내는 오류)로 이어질 수 있다.

 

동기화의 두 가지 기능

  1. 배타적 실행

배타적 실행은 한 스레드가 객체의 상태를 변경하고 있을 때, 상태가 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 막는 것을 의미한다. 따라서, 동기화를 사용하면 항상 일관적인 객체 상태를 보장할 수 있다.

  1. 스레드 사이의 안정적 통신 보장

동기화를 사용하면 하나의 스레드에서 수행한 작업의 결과를 다른 스레드가 접근해 상태를 확인할 수 있다.

 

원자적 데이터

long, double 외의 type 데이터를 읽고 쓰는 동작은 원자적

→ 여러 스레드가 동기화 없이 수정하는 중에도 다른 스레드가 저장한 값을 읽어올 수 있다.

→ BUT, 원자적 데이터로 만들고 동기화를 하지 말아야겠다는 생각 NO

→ 자바에서는 스레드가 필드를 읽을 때 항상 수정이 완전하게 반영된 값을 얻지만, 이는 다른 이 값을 다른 스레드가 볼 수 있는지 보장하지는 못한다(자바 메모리 모델이 원인)

실패 예시

  • 로직
    • stopRequested = false로 초기화
    • 첫번째 스레드가 stopRequested를 폴링하다가 stopRequested == true 일 때 loop 멈춤
    • 다른 스레드에서 스레드를 멈추려고 할 때 stopRequest = true
  • 예상 : 1초만에 실행 종료 예상
  • 실제 : 무한 루프
public class StopThread {
    private static boolean stopRequested;

    // 동기화를 사용하지 않아 무한루프를 도는 코드
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) { // background 스레드
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true; // 메인 스레드 
    }
}

원인

  • 동기화를 하지 않았기 때문 → 안정적 통신을 보장한다
  • 동기화를 하지 않아 main 스레드가 수정한 값을 background 스레드가 언제쯤에 보게 될지 보증할 수 없다
  • 동기화가 빠지면 VM이 다음과 같이 최적화를 수행할 수도 있다
    • 이는 OpenJDK 서버 VM이 실제로 적용하는 끌어올리기(hosting)라는 최적화 기법으로, 그 결과 프로그램은 응답 불가(liveness failure) 상태가 되어 더이상 진전이 없다
  • // 원래 코드 while (!stopRequested){ i++; } // 최적화한 코드 if (!stopRequested) { while(true) { i++; } }

해결

public class StopThread {
    private static boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested()) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}
  • 쓰기/읽기 메서드 모두 동기화함
  • 여기서 동기화는 스레드간 통신 목적으로만 사용됨

 

volatile

위 코드의 stopRequested 필드를 volatile으로 선언하면 동기화를 생략해도 된다.

public class StopThread {
    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

사용시 주의할 점

volatile은 동기화가 아니라 가장 최근에 기록된 값 읽기를 보장하기 때문에 값 읽기에 문제가 발생할 가능성이 있다.

private static volatile int nextSerialNumber = 0;
  public static int generateSerialNumber() {
      return nextSerialNumber++;
}
  • 의도
    • 2^32을 넘지 않을 경우, 매번 고유한 값 반환하는 것
  • 문제
    • nextSerialNumber가 int 타입으로 원자적으로 접근할 수 있어서 동기화하지 않아도 불변식을 보호할 수 있어 보이지만, 실제로는 올바르게 동작하지 않는다
  • 원인 - 증가 연산자(++)
    • 이 연산자는 nextSerialNumber에 두 번 접근하기 때문에 문제가 발생한다
    • 만약 두번째 스레드가 첫 번째와 두 번째 사이에 접근한다면, +1된 값이 아닌 첫번째 스레드와 똑같은 값을 돌려받게 되는 문제가 발생한다
  • 해결 방법
    1. generateSerialNumber()에 synchronized 설정하기
      1. 여러 스레드가 동시에 호출해도 서로 간섭하지 않으며, 이전 호출이 변경한 값을 읽을 수 있게됨
      2. 이때 nextSerialNumber 앞에 volatile은 제거해야 한다
      3. ➕ int 타입 → long 타입 || nextSerialNumber가 최댓값 도달 시 예외 던지기
      4. ➕ java.util.concurrent.atomic 패키지 클래스(AtomicLong) 사용하기
      • AtomicLong을 활용한 최적화 코드
      • public class StopThreadAtomic { private static final AtomicLong nextSerialNum = new AtomicLong(); public static long generateSerialNumber() { return nextSerialNum.getAndIncrement(); } }

 

애초부터 동기화 문제를 피하는 방법

  1. 가변 데이터는 단일 스레드에서만 쓰도록 하자
  2. 사용하려는 프레임워크와 라이브러리를 깊게 이해하자
  3. 필요한 부분만 부분 동기화 하자
    1. 한 스레드가 데이터를 다 수정한 후, 다른 스레드에 공유할 때 해당 객체에서 공유하는 부분만 동기화하는 것
    2. 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드를 동기화 없이 자유롭게 값을 읽어갈 수 있다
    3. 이를 사실상 불변(effectively immutable)이라고 한다
    4. 이런 사실상 불변 객체를 다른 스레드에게 전달하는 것을 안전 발행(safe publication)이라 한다

 

참고

자바 메모리 모델 이해하기

요약

예외 발생 원인과 관련된 정보를 필요한 것만 자세히 담아서 알려주도록 하자.

  • 스택 추적에 꼭 넣어야 하는 정보
    • 예외가 발생한 파일 이름/줄번호
    • 스택에서 호출한 다른 메서드들의 파일 이름/줄번호

 

스택 추적(Stack Trace)

  • 스택 추적은 예외 객체의 toString 메서드를 호출해 얻는 문자열
  • 보통은 예외의 클래스 이름 뒤에 상세 메시지가 붙는 형태
  • 실패 원인을 분석하기 위해 얻을 수 있는 유일한 정보

→ 예외의 toString 메서드에 실패 원인에 관한 정보를 꼭 필요한 것만 자세하게 예외의 상세 메시지에 담는 것이 중요하다.

스택 추적에 넣어야하는 정보

  • 예외가 발생한 파일 이름/줄번호
  • 스택에서 호출한 다른 메서드들의 파일 이름/줄번호

 

예시 - IndexOutOfBoundsException

IndexOutOfBoundsException의 상세 메시지는 범위의 최솟값/최댓값, 그 범위를 벗어났다는 인덱스 값을 담아야 한다.

예외 발생하는 상황

  • 인덱스 < 최솟값
  • 인덱스 ≥ 최댓값
  • 최솟값 > 최댓값 (내부 불변식이 심각히 깨진 경우)

위 현상들의 원인은 모두 다르므로, 최솟값/최댓값/인덱스 값 정보를 제공하면 원인을 분석하는데 도움이 될 것이다.

 

예외 생성자에서 상세 메시지 미리 생성하기

실패를 적절하게 포착하는 방법으로 예외 생성자에서 상세 메시지를 미리 생성하는 방법도 있다.

  • 예시 코드
    • 이 코드는 상세 메시지를 만들어내는 코드를 예외 클래스 안으로 모아주는 효과도 있어 클래스 사용자가 메시지를 만드는 작업을 중복하지 않아도 되는 효과도 있다
    public class IndexOutOfBoundsException extends RuntimeException {
        private int lowerBound;
        private int upperBound;
        private int index;
    
        /**
         * IndexOutOfBoundsException을 생성한다. 
         * 
         * @param lowerBound 인덱스의 최솟값
         * @param upperBound 인덱스의 최댓값 + 1
         * @param index 인덱스의 실제값
         */
        public IndexOutOfBoundsException(int lowerBound,
                                         int upperBound,
                                         int index
        ) {
            // 실패를 포착하는 상세 메시지를 생성한다.
            super(String.format(
                    "최솟값 : %d, 최댓값 : %d, 인덱스 : %d",
                    lowerBound, upperBound, index));
    
            // 프로그램에서 이용할 수 있도록 실패 정보를 저장해둔다.
            this.lowerBound = lowerBound;
            this.upperBound = upperBound;
            this.index = index;
        }
    }
    
    • 길이가 5인 배열, arr[5]; → 예외 발생
    • 예외 메시지 : “최솟값 : 0, 최댓값 : 4, 인덱스 : 5”
  • 참고
    • 자바 9에서 IndexOutOfBoundsException의 생성자는 정수 인덱스를 받는다
    public IndexOutOfBoundsException(String  s){ }
    

+ Recent posts