Effective Java 3rd - Ch11
포스트
취소

Effective Java 3rd - Ch11

Java 기초를 다지기 위해 효과적인 자바란 책을 공부 중입니다.

제11장 동시성

  • 스레드는 여러 활동을 동시에 수행할 수 있게 해준다
  • 멀티코어 프로세서의 힘을 제대로 활용하려면 반드시 내 것으로 만들어야 하는 기술이다
  • 동시성 프로그램을 명확하고 정확하게 만들수 있는 조언들을 담았다

78) 공유 중인 가변 데이터는 동기화해 사용하라

synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. (배타적 실행)

  • 메서드는 그 객체에 락(lock)을 걸고 다른 일관된 상태로 변화시킨다
  • 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다
    • 동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

동기화에 실패하면 처참한 결과로 이어질 수 있다.

  • Thread.stop 은 사용하지 말자 (오래전에 deprecated 됨)
  • 다른 스레드를 멈추는 올바른 방법
    • 자신의 boolean 필드를 폴링하면서 그 값이 true 가 되면 멈춘다

78-1 잘못된 코드 - 이 프로그램은 얼마나 오래 실행될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StopThread {
    private static 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;
    }
}

/////////////////////////////
//      // JVM 에 의해 최적화된 코드 (컴파일된)
//      {
//          int i=0;
//          if( !stopRequested )    // 한번 검사하고 끝!
//              while( true )       // 무한루프
//                  i++;
//      }

코드 78-2 적절히 동기화해 스레드가 정상 종료된다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 한정자는 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다. (배타적 수행과는 상관없지만)

코드 78-3 volatile 필드를 사용해 스레드가 정상 종료한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StopThread {
    // volatile 한정자
    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 은 주의해서 사용해야 한다.

  • 대신에 generateSerialNumber 메서드에 synchronized 한정자를 붙이면 이 문제가 해결된다.

코드 78-4 잘못된 코드 - 동기화가 필요하다

1
2
3
4
5
6
7
8
private static volatile int nextSerialNumber = 0;

// 매번 고유한 값을 반환할 의도로 작성됨
public static int generateSerialNumber() {
    // ++ 연산자는 변수에 두번 접근한다. 그 사이에 두번째 스레드가 값을 읽는다면?
    // ==> 이로 인한 값 오류시 이를 안전 실패라 한다
    return nextSerialNumber++;
}

코드 78-5 java.util.concurrent.atomic 을 이용한 락-프리 동기화

1
2
3
4
5
private static final AtomicLong nextSerialNumber = new AutomicLong();

public static long generateSerialNumber() {
    return nextSerialNumber.getAndIncrement();
}

가변 데이터 비동기화로 인한 문제를 피하려면 애초에 가변 데이터를 공유하지 않는 것이다.
:arrow_right: 가변 데이터는 단일 스레드에서만 쓰도록 하자.

한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는
해당 객체에서 공유하는 부분만 동기화해도 된다.
:arrow_right: 사실상 불변 (이를 건네는 행위를 안전 발행이라 함)

79) 과도한 동기화는 피하라

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리며, 심지어 예측할 수 없는 동작을 낳는다.

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안된다.

  • 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안되며 (외계인1)
  • 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다. (외계인2)

구체적인 예 (관찰자 패턴)

  • 어떤 집합(Set)을 감싼 래퍼 클래스이고, 이 클래스의 클라이언트는 집합에 원소가 추가되면 알림을 받을 수 있다
  • 관찰자들은 addObserver 와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다
    • 두 경우 모두 콜백 인터페이스 SetObserver 의 인스턴스를 added 메서드에 건넨다
    • 이 인터페이스는 구조적으로 BiConsumer<ObservableSet<E>,E> 와 똑같다
1
2
3
4
@FunctionalInterface public interface SetObserver<E> {
    // ObeserverSet 에 원소가 더해지면 호출된다.
    void added(ObservableSet<E> set, E element);
}

코드 79-1 잘못된 코드 - 동기화 블록 안에서 외계인 메서드를 호출한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ObservableSet<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) { super(set); }

    private final List<SetObserver<E>> observers
            = new ArrayList<>();                    // 함수 객체들의 리스트

    public void addObserver(SetObserver<E> observer) {
        synchronized(observers) {
            observers.add(observer);
        }
    }

    public boolean removeObserver(SetObserver<E> observer) {
        synchronized(observers) {
            return observers.remove(observer);
        }
    }

    private void notifyElementAdded(E element) {
        synchronized(observers) {
            for (SetObserver<E> observer : observers)
                observer.added(this, element);      // 외계인 메서드 : 클라이언트에서 넘어온
        }
    }

    @Override public boolean add(E element) {
        boolean added = super.add(element);
        if (added)
            notifyElementAdded(element);    // 새로 추가된 함수객체로 notifyElementAdded 호출
        return added;
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        boolean result = false;
        for (E element : c)
            result |= add(element);  // notifyElementAdded 를 호출
        return result;
    }
}

///////////////////////////////////////////

// 0부터 99까지 출력한다 (잘 작동한다)
public static void main(String[] args) {
    ObservableSet<Integer> set =
            new ObservableSet<>(new HashSet<>());

    set.addObserver((s, e) -> System.out.println(e));

    for (int i = 0; i < 100; i++)
        set.add(i);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 출력하다가 그 값이 23 이면 자기 자신을 제거(구독해지)하는 관찰자를 추가
// 0부터 23까지 출력한후 조용히 종료할 것이라 기대 ==> 그렇게 진행되지 않는다!
public static void main(String[] args) {
    ObservableSet<Integer> set =
            new ObservableSet<>(new HashSet<>());

    // 람다를 사용한 이전 코드와 달리 익명 클래스를 사용했다
    // s.removeObserver() 에 함수 객체 자신을 넘겨야 하기 때문이다
    // ==> 람다는 자신을 참조할 수단이 없다
    set.addObserver( new SetObserver<>() {
        public void added(ObservableSet<Integer> s, Integer e) {
            System.out.println(e);
            if (e == 23)                    // e 가 23 이면
                s.removeObserver(this);     // 구독자 제거 : 함수객체 자신
        }                                   // 함수객체 순회중이라 lock 존재
    });                                     // ==> ConcurrentModificationException 발생

    for (int i = 0; i < 100; i++)
        set.add(i);
}

위 프로그램은 23까지 출력한 다음 ConcurrentModificationException 을 던진다.

  • 관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded 가 관찰자들의 리스트를 순회하는 도중이기 때문
    • 허용되지 않은 동작
      • added 메서드는 ObservableSet 의 removeObserver 메서드를 호출하고
      • 이 메서드는 다시 observers.remove 메서드를 호출한다
    • 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다

코드 79-2 쓸데없이 백그라운드 스레드를 사용하는 관찰자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다
public static void main(String[] args) {
    ObservableSet<Integer> set =
            new ObservableSet<>(new HashSet<>());

    // Observer that uses a background thread needlessly
    set.addObserver(new SetObserver<>() {
        public void added(ObservableSet<Integer> s, Integer e) {
            System.out.println(e);
            if (e == 23) {
                ExecutorService exec =
                        Executors.newSingleThreadExecutor();
                try {
                    // 메인 스레드가 이미 s 의 lock 을 쥐고 있다
                    // 그러면서 메인스레드는 submit 문장이 완료되길 기다린다
                    exec.submit(() -> s.removeObserver(this)).get();
                } catch (ExecutionException | InterruptedException ex) {
                    throw new AssertionError(ex);
                } finally {
                    exec.shutdown();
                }
            }
        }
    });

    for (int i = 0; i < 100; i++)
        set.add(i);
}

코드 79-3 외계인 메서드를 동기화 블록 바깥으로 옮겼다

1
2
3
4
5
6
7
8
9
10
11
    // 이 방식을 사용하면 앞서 두 예제에서의
    // 예외 발생과 교착상태 증상이 사라진다
    private void notifyElementAdded(E element) {
        List<SetObserver<E>> snapshot = null;
        synchronized(observers) {
            snapshot = new ArrayList<>(observers);
        }
        // 복사본에 대해서 순회 => 원본을 수정해도 충돌 안난다
        for (SetObserver<E> observer : snapshot)
            observer.added(this, element);
    }

자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList 가 더 나은 방법이다.

  • ArrayList 를 구현한 클래스로 항상 깨끗한 복사본을 만들어 수행하도록 구현되었다
  • 내부의 배열은 절대 수정되지 않으니 순회시 락이 필요 없어 매우 빠르다
  • 수정할 일은 드물고 순회만 번번히 일어나는 관찰자 리스트 용도로는 최적이다

코드 79-4 CopyOnWriteArrayList 를 사용해 구현한 스레드 안전하고 관찰 가능한 집합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    // Thread-safe observable set with CopyOnWriteArrayList
    private final List<SetObserver<E>> observers =
            new CopyOnWriteArrayList<>();

    public void addObserver(SetObserver<E> observer) {
        observers.add(observer);
    }

    public boolean removeObserver(SetObserver<E> observer) {
        return observers.remove(observer);
    }

    private void notifyElementAdded(E element) {
        for (SetObserver<E> observer : observers)
            observer.added(this, element);
    }

    @Override public boolean add(E element) {
        boolean added = super.add(element);
        if (added)
            notifyElementAdded(element);
        return added;
    }

기본 규칙

  • 동기화 영역에서는 가능한 한 일을 적게 하는 것이다
  • 오래 걸리는 작업이라면 동기화 영역 바깥으로 옮기는 방법을 찾아보자

자바의 동기화 비용은, 바로 경쟁하느라 낭비하는 시간이 진짜 비용이다.

  • 병렬로 실행할 기회를 잃고
  • 모든 코어가 메모리를 일관되게 보기 위한 지연시간
  • 가상머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 숨은 비용이다

가변 클래스를 작성하려할 때 선택지

  • 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화되게 하자
    • java.util
  • 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자
    • 단, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만
    • java.util.concurrent

클래스를 내부에서 동기화하기로 했다면
lock splitting, lock striping, nonblocking concurrency control 등 다양한 기법을 동원해 구현할 수 있다.
:arrow_right: 다른 책을 참고하시오!

80) 스레드 보다는 실행자, 태스크, 스트림을 애용하라

실행자 프레임워크(Executor Framework)를 담고 있는 java.util.concurrent 패키지

1
2
3
4
5
6
7
8
// 뛰어난 작업 큐를 단 한줄로 생성할 수 있다
ExecutorService exec = Executors.newSingleThreadExecutor();

// 실행자에 실행할 태스크를 넘기는 방법
exec.execute( runnable );

// 실행자를 우아하게 종료시키는 방법
exec.shutdown();

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

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

둘 이상의 스레드 처리를 위한 실행자 소개

  • ThreadPoolExecutor 클래스로는 스레드풀에 관한 거의 모든 속성을 설정할 수 있다
  • 가벼운 서버라면 Executors.newCachedThreadPool 이 좋은 선택
    • 무거운 서버라면 우선순위에 밀려 굶어 죽을 수 있음
  • 무거운 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool 사용

실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.

  • 작업 단위를 나타내는 핵심 추상 개념이 태스크 이다
  • 태스크 종류: Runnable 과 Callable (값을 반환하고 임의의 예외를 던질 수 있다)
  • 태스크는 실행자 프레임워크가 작업 수행을 담당해준다 (컬렉션이 데이터 모음을 담당하듯)

자바 7이 되면서 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원하도록 확장되었다.

  • ForkJoinTask 의 인스턴스는 작은 하위 태스크로 나뉠 수 있고
  • ForkJoinPool 을 구성하는 스레드들이 하위 태스크들을 처리하며
  • 일을 먼저 끝낸 스레드는 남은 태스크를 가져와 대신 처리할 수도 있다
  • 포크조인 풀을 이용해 만든 병렬스트림을 이용하면 높은 성능을 달성할 수 있다

81) wait 와 notify 보다는 동시성 유틸리티를 애용하라

:bangbang:   핵심 정리

  • 코드를 새로 작성한다면 wait 와 notify 를 쓸 이유가 거의(전혀) 없다
    • 마치 어셈블리 언어로 프로그래밍 하는 것에 비유할 수 있다
  • wait 와 notify 를 사용하는 레거시 코드를 유지보수해야 한다면
    • wait 는 항상 표준 관용구에 따라 while 문 안에서 호출하자
    • 일반적으로 notify 보다는 notifyAll 을 사용해야 한다
      • notify 는 스레드 하나만 깨우며, notifyAll 은 모든 스레드를 깨운다
      • 혹시 notify 를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자

java.util.concurrent 의 고수준 유틸리티의 세 범주

  • 실행자 프레임워크
  • 동시성 컬렉션 (concurrent collection) : List, Queue, Map 등에 동시성 가미
  • 동기화 장치 (synchronizer)

동시성 컬렉션의 여러 메서드들을 원자적으로 묶어 호출할 수 없으므로 ‘상태 의존적 수정’ 메서드들이 추가되었다.

  • 예) ConcurrentHashMap 의 putIfAbsent(key, value)
    • 키에 값이 아직 없을 때만 새 값을 집어 넣는다.
    • 기존값이 있었다면 기존값을 반환. 없었다면 null 반환
    • 이제는 Collections.synchronizedMap 보다 ConcurrentHashMap 이 훨씬 빠르고 좋다
  • 예) BlockingQueue 의 take
    • 큐의 첫 원소를 꺼낸다
    • 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다
    • 작업큐에 쓰이는데 생산자 스레드가 작업을 추가하고, 소비자 스레드가 작업을 꺼내 처리한다
    • ThreadPoolExecutor 를 포함한 대부분의 실행자 서비스 구현체에서 사용하고 있다

코드 81-1 ConcurrentMap 으로 구현한 동시성 정규화 맵 - 최적은 아니다

1
2
3
4
5
6
private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>();

public static String intern( String s ){
    String previousValue = map.putIfAbsent(s, s);
    return previousValue == null ? s : previousValue;
}

코드 81-2 ConcurrentMap 으로 구현한 동시성 정규화 맵 - 더 빠르다!

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Intern {
    private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>();

    public static String intern(String s) {
        String result = map.get(s);         // ConcurrentMap 은 get 기능에 최적화
        if (result == null) {               // 필요한 때만 putIfAbsent 호출 ==> 더 빠르다
            result = map.putIfAbsent(s, s);
            if (result == null)
                result = s;
        }
        return result;
    }
}

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해준다.

  • CountDownLatch
    • 일회성 장벽으로 하나 이상의 스레드가 또다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다
    • 유일한 생성자는 int 값을 받으며, 이 값이 Latch 의 countDown 메서드를 몇번 호출해야 대기중인 스레드들을 깨우는지 결정한다
    • 동작들을 실행할 실행자와 동작을 몇개나 동시에 수행할 수 있는지를 뜻하는 동시성 수준(concurrency)을 매개변수로 받는다
  • Semaphore
  • 그 외에 CyclicBarrier, Exchanger, 그리고 가장 강력한 Phaser 가 있다

코드 81-3 동시 실행 시간을 재는 간단한 프레임워크

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Simple framework for timing concurrent execution 327
public class ConcurrentTimer {
    private ConcurrentTimer() { } // Noninstantiable

    public static long time(Executor executor, int concurrency,
                            Runnable action) throws InterruptedException {
        CountDownLatch ready = new CountDownLatch(concurrency);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done  = new CountDownLatch(concurrency);

        for (int i = 0; i < concurrency; i++) {
            executor.execute(() -> {
                ready.countDown();      // 스레드 하나의 준비 완료 시그널
                try {
                    start.await();      // 대기 상태 돌입 => countDown 되면 풀림
                    action.run();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();   // 스레드 하나의 실행 완료 시그널
                }
            });
        }

        ready.await();     // 모든 작업자가 준비완료될 때까지 대기
        long startNanos = System.nanoTime();        // 타이머 시작 기록

        start.countDown(); // 동시에 실행 시작

        done.await();      // 모든 작업자가 완료될 때까지 대기
        return System.nanoTime() - startNanos;      // 타이머 종료 출력
    }
    // 시간 간격을 잴 때는 항상 System.currentTimeMills 가 아닌 System.nanoTime 을 사용하자
}

새로운 코드라면 언제나 wait 와 notify 가 아닌 동시성 유틸리티를 써야 하지만, 어쩔수 없이 다뤄야 할 때도 있다.

코드 81-4 wait 메서드를 사용하는 표준 방식

1
2
3
4
5
6
synchronized(obj) {
    while( "<조건이 충족되지 않았다>" )
        obj.wait();     // 락을 놓고, 깨어나면 다시 잡는다

    ...     // 조건이 충족됐을 때의 동작을 수행한다
}

82) 스레드 안전성 수준을 문서화 하라

:bangbang:   핵심 정리

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

:hand: 스레드 안정성 수준

  • 불변 (@Immutable)
    • 마치 상수와도 같다. 절대 안전
  • 무조건적 스레드 안전 (@ThreadSafe)
    • 외부 동기화 없이 사용해도 안전하다
  • 조건부 스레드 안전 (@ThreadSafe)
    • 일부 메서드는 외부 동기화가 필요하다.
    • Collections.synchronized 래퍼 메서드가 반환한 컬렉션들
  • 스레드 안전하지 않음 (@NotThreadSafe)
    • 이 클래스의 인스턴스는 수정될 수 있어서 메서드 호출을 외부 동기화 메커니즘으로 감싸야 한다
    • ArrayList, HashMap 같은 기본 컬렉션들
  • 스레드 적대적
    • 모든 메서드 호출을 외부 동기화로 감싸더라도 멜티스레드 환경에서 안전하지 않다
    • 이런 클래스나 메서드는 사용 자제(deprecated) API 로 지정한다

synchronizedMap 이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락을 사용해 수동으로 동기화하라
:arrow_right: 아래 코드 패턴처럼 따르지 않으면 동작을 예측할 수 없다.

1
2
3
4
5
6
7
8
Map<K, V> m = Collections.synchronizedMap( new HashMap<>() );
Set<K> s = m.keySet();  // 동기화 블록 밖에 있어도 된다
...

synchronized(m) {       // s가 아닌 m 을 사용해 동기화해야 한다
    for( K key : s )
        key.f();
}

서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개(private) 락 객체를 사용해야 한다.
:hand: 서비스 거부 공격 : 클라이언트가 공개된 락을 오래 쥐고 놓지 않는 행위

코드 82-1 비공개 락 객체 관용구 - 서비스 거부 공격을 막아준다

1
2
3
4
5
6
7
8
9
// final 선언으로 우연히라도 락 객체가 교체되는 것을 예방했다
// ==> lock 필드는 항상 final 로 선언하라
private final Object lock = new Object();

public void foo() {
    synchronized(lock) {
        ...
    }
}

83) 지연 초기화는 신중히 사용하라

지연 초기화 : 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법

  • 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.
  • 지연 초기화는 양날의 검 :arrow_right: 성능을 느려지게 할 수도 있다

:bangbang:   핵심 정리

  • 대부분의 상황에서 필드는 지연시키지 말고 곧바로 초기화해야 한다
  • 올바른 지연 초기화 기법을 사용하자
    • 성능 때문에 혹은 위험한 초기화 순환을 막기 위해 꼭 지연 초기화를 써야 한다면
  • 인스턴스 필드에는 이중검사 관용구를, 정적 필드에는 지연 초기화 홀더 클래스 관용구를 사용하자
  • 반복해 초기화해도 괜찮은 인스턴스 필드에는 단일검사 관용구도 고려 대상이다

코드 83-1 인스턴스 필드를 초기화하는 일반적인 방법

1
private final FieldType field = computeFieldValue();

지연 초기화가 초기화 순환성을 깨뜨릴 것 같으면 synchronized 접근자를 사용하자.

코드 83-2 인스턴스 필드의 지연 초기화 - synchronized 접근자 방식

1
2
3
4
5
6
7
private FieldType field;

private synchronized FieldType getField() {
    if( field == null )
        field = computeFieldValue();
    return field;
}

성능 때문에 정적 필드를 지연 초기화 해야 한다면 지연 초기화 홀더 클래스 관용구를 사용하자.

코드 83-3 정적 필드용 지연 초기화 홀더 클래스 관용구

1
2
3
4
5
6
7
8
private static class FieldHolder {
    static final FieldType field = computeFieldValue();
}

// getField 가 처음 호출되는 순간 FieldHolder.field 가 처음 읽히면서
// 비로소 FieldHolder 클래스 초기화를 촉발한다.
// ==> 동기화를 하지 않는 기법이라 성능이 느려질 꺼리가 전혀 없다
private static FieldType getField() { return FieldHolder.field; }

성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사 관용구를 사용하라.

코드 83-4 인스턴스 필드 지연 초기화용 이중검사 관용구

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private volatile FieldType field4;

private FieldType getField4() {
    // result 지역변수가 필요한 이유는?
    // ==> 필드가 이미 초기화된 상태라면 그 필드를 딱 한번만 읽도록 보장하는 역활을 한다
    FieldType result = field4;

    if (result != null)         // 첫번째 검사 (no locking)
        return result;

    synchronized(this) {
        if (field4 == null)     // 두번째 검사 (with locking)
            field4 = computeFieldValue();
        return field4;
    }
}

이따금 반복해서 초기화해도 상관없는 인스턴스 필드를 지연 초기화해야 할 때도 있는데, 이런 경우 이중검사에서 두번째 검사를 생략할 수 있다.

코드 83-5 단일검사 관용구 - 초기화가 중복해서 일어날 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
private volatile FieldType field5;

private FieldType getField5() {
    FieldType result = field5;
    if (result == null)
        field5 = result = computeFieldValue();
    return result;
}

private static FieldType computeFieldValue() {
    return new FieldType();
}

이번 아이템에서 이야기한 모든 초기화 기법은 기본 타입 필드와 객체 참조 필드 모두에 적용할 수 있다.

84) 프로그램의 동작을 스레드 스케줄러에 기대지 말라

구체적인 스레드 스케줄링 정책은 운영체제마다 다를 수 있다. 따라서 잘 작성된 프로그램이라면 이 정책에 좌우돼서는 안된다.

견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법은

  • 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.
  • 적게 유지하려면, 각 스레드가 다음 일거리가 생길 때까지 대기하도록 하는 것이다
    • :arrow_right: 스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안된다 (바쁜 대기 X)

:bangbang:   핵심 정리

  • 프로그램의 동작을 스레드 스케줄러에 기대지 말자
    • 견고성과 이식성을 모두 해치는 행위
  • 같은 이유로 Thread.yield 와 스레드 우선순위에 의존해서도 안된다
    • 이 기능들은 스레드 스케줄러에 제공하는 힌트일 뿐 (테스트할 수단도 없다)
  • 스레드 우선순위는 이미 잘 작동하는 프로그램의 서비스 품질을 높이기 위해 드물게 쓰일 수 있지만
    • 간신히 동작하는 프로그램을 고치는 용도로 사용해서는 절대 안된다

코드 84-1 끔찍한 CountDownLatch 구현 - 바쁜 대기 버전!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SlowCountDownLatch {
    private int count;

    public SlowCountDownLatch(int count) {
        if (count < 0)
            throw new IllegalArgumentException(count + " < 0");
        this.count = count;
    }

    // 스레드 1000 개에서 약 10배가 느렸다
    public void await() {
        while (true) {                  // 바쁜 대기
            synchronized(this) {
                if (count == 0)
                    return;
            }
        }
    }
    public synchronized void countDown() {
        if (count != 0)
            count--;
    }
}

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.