Language/Java

[effective java] 아이템 82. 스레드 안전성 수준을 문서화하라.

JOYERIM 2023. 8. 13. 22:48

 

 

 

 

 

 

핵심 내용

 

  • 모든 클래스를 자신의 스레드 안전성 정보를 명확히 문서화해야 한다.
    • 정확한 언어로 명확히 설명하거나 스레드 안전성 애너테이션을 사용할 수 있다.
  • synchronized 한정자는 문서화와 관련이 없다.
  • 조건부 스레드 안전 클래스는 메소드를 어떤 순서로 호출할 때 외부 동기화가 요구되고, 그때 어떤 락을 얻어야 하는지도 알려줘야 한다.
  • 무조건적 스레드 안전 클래스를 작성할 때는 synchronized 메소드가 아닌 비공개 락 객체를 사용하자.
    • 클라이언트나 하위 클래스에서 동기화 메커니즘을 깨뜨리는 걸 예방할 수 있다.

 

 

 

 

API 문서에서 synchronized 한정자
: synchronized 한정자만으로 스레드 안전성을 판단하는 것은 위험하다.

 

  • 메소드의 내부 구현이나 호출하는 다른 메소드들과의 상호작용 등에 따라 안전성이 보장되지 않을 수 있다.
  • 참고로, JavaDoc은 synchronized 한정자를 포함하지 않는다.
    • API 문서는 어떻게 동작하는지에 대한 설명을 제공하는 것에 초점을 맞추며, 어떻게 구현되었는지에 대한 세부 사항을 숨긴다. (예전 어느 아이템 ..)

 

 

 

 

스레드 안전성 수준 문서화의 중요성

 

  • 개발자에게 스레드 환경에서의 안정성과 동기화 방법을 알려주는 역할을 한다.
  • 다중 스레드 문제를 사전에 방지하고 코드의 안정성을 보장하는 데 중요하다.

 

✍ 멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.

 

 

 

 

 

 

스레드 안전성 수준

 

1. 불변(immutable) - @Immutable
  • 인스턴스가 불변이므로, 외부에서 추가적인 동기화 없이 여러 스레드에서 동시에 안전하게 사용할 수 있다.
  • ex. String, Long, BigInteger(item 7)

 

2. 무조건적 스레드 안전(unconditionally thread-safe) - @ThreadSafe
  • 객체의 상태가 변경될 수는 있지만 내부에서 동기화를 충실히 처리하여, 외부에서 추가적인 동기화없이 여러 스레드에서 안전하게 사용될 수 있다.
  • ex. AtomicLong, ConcurrentHashMap

 

3. 조건부 스레드 안전(conditionally thread-safe) - @ThreadSafe
  • 무조건적 스레드 안전과 같으나, 일부 메소드 호출 시에는 외부 동기화가 필요하다.
예시 - Collections.synchronized 메소드
더보기
Java의 Collections.synchronized 메소드는 기본 컬렉션 객체를 스레드 안전한 버전으로 래핑하는 데 사용된다. 이러한 래핑된 컬렉션은 내부 동기화를 통해 멀티 스레드 환경에서 안전성을 보장하지만, 일부 동작에 대해서는 아니다. (ex. Iterator, Spliterator, Stream)

Collections.synchronizedList 주석을 보면 Iterator, Spliterator, Stream은 동기화가 필요하다고 작성돼있다.

내부 구현을 보면 Iterator은 따로 동기화 처리하지 않았다는 점을 알 수 있다.

 

✍ 조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다.
  • 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그 순서로 호출하려면 어떤 락 혹은 락들을 얻어야 하는지 알려줘야 한다.
  • 일반적으로 인스턴스 자체를 락으로 얻지만 예외도 있다. (ex. Collections.synchronizedMap)
더보기
// 일반적인 경우
public class Foo {
    private Object data;

    public void someMethod() {
        synchronized(this) {
            // some operations on data
        }
    }
}



Collections.synchronizedMap



Collections.synchronizedMap

 

  • 문서화 tip
    • 보통 클래스의 문서화 주석에 작성하며, 독특한 메소드의 경우 메소드의 주석에 작성하자.
    • 열거 타입은 굳이 불변이라고 쓰지 않아도 된다.
    • 정적 팩터리 메소드가 반환하는 객체의 스레드 안전성과 같은 중요한 특성을 반환 타입과 메소드만으로는 명확히 알 수 없다면 이를 문서화하자 (ex. Collections.synchronizedMap)
예시 - Collections.synchronizedMap
더보기
Collections.synchronizedMap 주석

 

반환된 맵의 특정 뷰(ex. ketSet)를 순회할 때는 반드시 해당 맵에 동기화를 해야 한다고 명시하고 있다.

 

keySet을 호출할 때는 동기화 블록이 필요없지만, keySet에 의해 반환된 Set 객체를 순회할 때는 원본 맵에 대한 동기화가 필요하다. 동기화를 해야 원본 맵에 동시 접근하는 모든 스레드를 제어하고, 순회 중에 맵의 변경을 방지하여 안전성을 유지할 수 있다.

 

Collections.synchronizedMap의 keySet 메소드

처음으로 keySet이 호출될 때만 SynchronizedSet 객체를 생성하고, 그 후로는 동일한 객체를 반환한다.

또한, SynchronizedSet 객체는 원래의 SynchronizedMap과 동일한 mutex(락)을 공유한다.

 

 

SynchronizedSet은 SynchronizedCollection을 확장하는데, 이는 주어진 Collection의 모든 연산을 동기화된 형태로 제공하는 래퍼 클래스이다.

 

그런데 keySet 반환 값의 add 메소드를 호출하려고 하면 UnsupportedOperationException이 발생한다(키만 따로 추가하는 것은 의미가 없기 때문). 반면 SynchronizedMap은 값을 추가하는 연산을 지원한다.

 

즉, SynchronizedSet만 동기화하게 되면 SynchronizedSet을 순회하는 동안 SynchronizedMap에는 값이 추가될 수 있다. 이렇게 되면 예기치 않은 결과나 데이터 불일치 문제가 발생할 수 있다.

 

결국, 올바르게 동기화를 보장하기 위해 SynchronizedMap을 동기화해야 한다.

 

 

4. 스레드 안전하지 않음(not thread-safe) - @NotThreadSafe
  • 여러 스레드에서 동시에 해당 객체에 접근할 때 동기화가 필요하다.
  • 각각의 메소드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야 한다.
  • ex. ArrayLisy, HashMap같은 기본 컬렉션

 

 

5. 스레드 적대적(thread-hostile)
  • 외부 동기화를 하더라도 멀티스레드 환경에서 안전하게 사용될 수 없다.
  • ex. item 78의 generateSerialNumber

 

 

❔ 스레드 안전성 애너테이션
더보기
코드의 스레드 안전성 수준을 명시하기 위해 사용되는 표시이다.

1. @Immutable - 불변(immutable)
2. @ThreadSafe - 무조건적 스레드 안전(unconditionally thread-safe), 조건부 스레드 안전(conditionally thread-safe)
3. @NotThreadSafe - 스레드 안전하지 않음(not thread-safe)

 

 

 

 

비공개 Lock 객체
: 무조건적 스레드 안전 클래스에서 사용

 

락 객체의 공개와 문제점

아래 코드는 공개된 락을 사용하는 예시이다.

public class SharedResource {
    public final Object lock = new Object(); // 공개된 락

    public void doSomething() {
        synchronized(lock) {
            // 어떤 작업 수행
        }
    }
}
public class ExternalClient {
    public void manipulateResource(SharedResource resource) {
        synchronized(resource.lock) {
            // resource에 대한 원자적 연산
        }
    }
}

 

  • 장점
    • 클라이언트는 여러 메소드 호출을 원자적으로 수행할 수 있다.
  • 단점
    1. 성능
      • 내부에서 처리하는 고성능의 동시성 제어 메커니즘(ex. CocurrentHashMap)을 외부에서 제공되는 락과 혼용할 수 없다.
      • ex. ShareResource에서 ConcurrentHashMap같은 고성능 동시성 제어 메커니즘을 사용하려고 할 때, 외부에서 이미 lock을 사용하고 있기 때문에 해당 메커니즘을 적용하기 어렵다.
    2. 서비스 거부 공격
      • 클라이언트가 의도적으로 락을 오래 쥐고 놓지 않으면, 다른 클라이언트나 스레드가 해당 리소스에 접근할 수 없데 되어 서비스가 중단될 수 있다.
      • ex. 아래 코드를 보면, MaliciousClient 코드는 SharedResource의 락을 계속 쥐고 있어서, 다른 클라이언트나 스레드가 SharedResource에 접근하는 것을 방해한다.
public class MaliciousClient {
    public void denialOfServiceAttack(SharedResource resource) {
        synchronized(resource.lock) {
            while(true) { 
                // 무한 루프로 락을 영원히 쥐고 있음
            }
        }
    }
}

 

비공개 락 객체

이러한 문제를 막으려면 클래스 내부에서만 사용되는 비공개 락 객체를 사용하자.

private final Object lock = new Object();

public void foo() {
    synchronized(lock) {
        // Some operations
    }
}

 

  • 외부에서 락 객체에 접근할 수 없으므로, 클라이언트가 동기화와 관련된 문제를 야기시키거나 락을 오래 쥐고 놓지 않는 공격을 수행할 수 없다.
  • 동일한 이유로 성능 문제도 해결된다.

 

🌈 락 필드는 항상 final로 선언하라.
  • final로 선언하여 락 객체가 교체되는 일을 방지한다. (item 15, 78)
  • private로 선언해 클래스 내부에서만 접근할 수 있다. (item 17)

 

어느 상황에서 사용할까?
  1. 무조건적 스레드 안전 클래스
    • 조건부 스레드 안전의 경우, 비공개 락 객체를 사용하면 외부에서 동기화 블록을 설정하는 것이 불가능하므로 사용할 수 없다.
  2. 상속용 클래스(item 19)
    • 하위 클래스와 상위 클래스가 동일한 락(자신의 인스턴스)을 사용하면 동기화 문제가 발생할 수 있다.
    • 이는 인스턴스 대신 비공개 락 객체를 사용하면 문제를 해결할 수 있다.
    • ex. Thread 클래스
      • Thread 클래스 메소드 중 일부는 synchronized로 동기화되어 있는데, 하위 클래스에서 이러한 메소드를 확장하거나 오버라이드할 떄 동기화 문제가 발생할 수 있다.
      • 따라서 Thread 클래스를 확장하는 것은 권장되지 않으며, Runnable 인터페이스를 구현하는 것이 더 안전하고 유연한 방법이다.

 

예시 - 부모 클래스와 자식 클래스가 동일한 락을 사용할 경우 문제 상황
더보기
class Parent {
    synchronized void syncMethod() {
        System.out.println("Parent's syncMethod");
        // ... 기타 작업
        try {
            Thread.sleep(1000); // 이 락은 1초 동안 유지된다.
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class Child extends Parent {
    void childMethod() {
        super.syncMethod(); // 여기에서 부모 클래스의 synchronized 메소드를 호출하면서 락을 획득한다.
    }

    synchronized void anotherSyncMethod() {
        System.out.println("Child's anotherSyncMethod");
        // ... 기타 작업
    }
}

public class Main {
    public static void main(String[] args) {
        Child child = new Child();
        
        // 첫 번째 스레드: 부모 클래스의 synchronized 메소드 호출
        new Thread(child::childMethod).start();
        
        // 잠깐의 딜레이를 주어 첫 번째 스레드가 락을 획득하도록 한다.
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 두 번째 스레드: 자식 클래스의 synchronized 메소드 호출
        new Thread(child::anotherSyncMethod).start();
    }
}​

첫 번째 스레드는 chileMethod를 통해 Parent 클래스의 syncMethod를 호출하며 락을 획득한다.
두 번째 스레드는 Chile 클래스의 anotherSyncMethod를 호출하려고 시도한다.
그러나 첫 번째 스레드가 아직 syncMethod의 락을 획득한 상태이므로, 두 번째 스레드는 락을 대기하게 한다.