해당 포스트는 "Effective Java" 책의 내용을 요약한 것이다.



※ 변경 가능 공유 데이터에 대한 접근은 동기화해라

: 다음 예제를 보자. 한 스레드에서 다른 스레드를 중지시키는 예제이다.

public class test {

	private static boolean stopRequested;
	
	public static void main(String args[]) throws Exception {
		Thread backThread = new Thread(new Runnable(){
			public void run(){
				int i=0;
				while(!stopRequested){
					i++;
				}
			}
		});
		backThread.start();
		
		TimeUnit.SECONDS.sleep(1);
		stopRequested = true;
	}
}

위 예제는 backThread를 1초 동안 동작시킨 후 중지시키는 코드이다. 중지시킬 때 Thread.stop을 사용해도 되지만 deprecated 되었으므로 boolean 변수를 사용했다. 실제로 위 코드를 실행 시켜보면 1초후에 프로그램이 멈추지 않고 계속 실행 된다. 계속 while(!stopRequested) 반복문을 순회한다. 그 이유는 동기화가 적용되지 않았기 때문이다. 동기화가 적용되지 않아 HotSpot 서버 VM이 최적화를 다음과 같이 시킨다.

if(!stopRequested)
   while(true)
        i++;

위와 같이 최적화되 stopRequested를 1초 후에 true로 바꿔도 프로그램은 멈추지 않는다. 이런 문제를 생존 오류(liveness failure)라고 한다. 살아 있기는 하나 더 진행하지는 못하는 프로그램이 되는 것이다. 이를 해결하는 방법은 stopRequested 필드를 동기화하는 것이다.

public class test {

	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 Exception {
		Thread backThread = new Thread(new Runnable(){
			public void run(){
				int i=0;
				while(!stopRequested()){
					i++;
				}
			}
		});
		backThread.start();
		
		TimeUnit.SECONDS.sleep(1);
		requestStop();
	}
}

위는 동기화를 적용한 예이다. 읽기와 쓰기 연산 모두에 동기화를 적용해야 한다. 변경 가능한 데이터를 공유할 때는 해당 데이터를 읽거나 쓰는 모든 스레드는 동기화를 수행해야 한다. 동기화를 적용할 경우 그 비용은 커지는 데 이를 보안할 방법이 있다. 어떤 스레드건 가장 치근에 기록된 값을 읽도록 보장하는 volatile 키워드를 사용하는 것이다.

public class test {

	private static volatile boolean stopRequested;
	
	public static void main(String args[]) throws Exception {
		Thread backThread = new Thread(new Runnable(){
			public void run(){
				int i=0;
				while(!stopRequested){
					i++;
				}
			}
		});
		backThread.start();
		
		TimeUnit.SECONDS.sleep(1);
		stopRequested = true;
	}
}
volatile을 사용할 때 주의할 게있다. 다음 예를 보자. 일련번호를 만드는 메서드이다.

private static volatile long nxtSerialNumber=0; public static int generateSerialNumber(){ return nxtSerialNumber++; }

위 코드는 첫 번째 스레드가 nxtSerialNumber를 읽고 ++를 하기 바로 직전에 두 번째 스레드가 nxtSerialNumber을 읽을 시 같은 일련번호를 가지게 된다. 이것을 안전 오류(safety failure)이라고 한다. 프로그램이 잘못된 결과를 계산하는 것이다. 이에 대한 해결책으로 synchronized 키워드를 붙이고 volatile 키워드를 삭제하는 방법이 있다.

더 좋은 방법은 java.util.concurrent.atomic 에서 제공하는 atomic 변수를 사용하는 것이다.


private static final AtomicLong nxtSerialNumber=new AtomicLong();

public static int generateSerialNumber(){
   return nxtSerialNumber.getAndIncrement();
}

안전 오류나 생존 오류를 해결하는 가장 좋은 방법은 변경 가능 데이터를 공유하지 않는 것이다. 굳이 공유해야 한다면 변경 가능 데이터는 한 스레드만 이용하도록 해야 한다. 특정한 스레드만이 데이터 객체를 변경할 수 있도록 하고 변경이 끝난 뒤에야 다른 스레드와 공유하도록 할 때는 객체 참조를 공유하는 부분에만 동기화를 적용하면 된다.



※ 과도한 동기화는 피하라

: 동기화를 과도하게 적용하면 성능 문제, 교착상태(deadlock), 비결정적 동작 등의 문제가 생길 수 있다. 특히, 생존 오류나 안전 오류를 피하고 싶으면 동기화 메서드나 블록 안에서 클라이언트에게 프로그램 제어 흐름을 넘기면 안 된다. 즉 동기화 영역 안에 재정의 가능 메서드나 클라이언트가 제공하는 함수를 호출하면 안 된다. 재정의 함수나 클라이언트가 제공하는 함수는 내부에서 어떤 동작을 하는 지 전혀 알 수 없고 제어를 할 수 없기 때문이다. 다음 예를 통해 설명한 내용을 구체적으로 알아보자. 다음 예는 observer 패턴을 사용한 예이다. "상속와 구성, 인터페이스" 포스트에 나오는 ForwardingSet 클래스를 이용해 Observable을 구현했다.

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); System.out.println("added :"+added); if(added) notifyElementAdded(element); return added; } @Override public boolean addAll(Collection<? extends E> c){ boolean result = false; for(E element : c) result |= add(element); return result; } }

observer는 addObserver 메서드를 통해 등록하고 removeObserver을 통해 등록을 해지한다. observer는 다음의 인터페이스를 구현한다. observable에 observer가 등록(add) 되면 인터페이스 내 함수 added가 호출된다.

public interface SetObserver<E>{
   void added(ObservableSet<E> set, E element);
}

public static void main(String[] args) { ObservableSet<Integer> set = new ObservableSet<Integer>(new HashSet<Integer>()); //옵저버 익명함수 객체 set.addObserver(new SetObserver<Integer>() { @Override public void added(ObservableSet<Integer> set, Integer element) { System.out.println(element); if(element == 23) { set.removeObserver(this); } } }); for(int i=0; i< 25;i++) set.add(i); }

위 프로그램을 실행하면 0부터 23까지 출력된 후 added 메서드를 호출할 observer 객체가 제거되어 24,25는 출력 안 되고 종료될 것이다. 하지만 23가지 출력된 다음 ConcurrentModificationException이 발생한다. observer의 added 메서드가 호출해 removeObserver을 호출될 때 notifyElementAdded 메서드가 observers 리스트를 순회하고 있었기 때문이다. 즉, 리스트 순회가 이루어지고 있는 도중에 리스트에서 원소를 삭제한 것이다. 또 다른 예를 보자. 

set2.addObserver(new SetObserver<Integer>() {
            @Override
            public void added(ObservableSet<Integer> set, Integer element) {
                System.out.println(element);
                if(element == 23){
                    ExecutorService executor =
                            Executors.newSingleThreadExecutor();
                    final SetObserver<Integer> observer = this;
                    try{
                        executor.submit(new Runnable() {
                            @Override
                            public void run() {
                                set.removeObserver(observer);
                            }
                        }).get();
                    }catch (ExecutionException ex){
                        throw new AssertionError(ex.getCause());
                    }catch (InterruptedException ex){
                        throw new AssertionError(ex);
                    }finally {
                        executor.shutdown();
                    }
                }
            }
        });

위 예제는 예외는 발생하지 않지만 교착상태에 빠진다. 후면 스레드는 removeObserver을 호출할 때 observers에 락을 걸어야 한다. 하지만 주 스레드에서 이미 락을 걸고 있다. 주 스레드는 후면 스레드가 removeObserver을 호출하는 걸 계속 기다린다. 그래서 교착 상태에 빠진다. 이렇게 재정의 메서드나 클라이언트에서 제공하는 메서드를 동기화 영역 안에서 호출할 경우 위험한 부분이 많다. 이에 대한 해결책으로 해당 메서드를 다음과 같이 동기화 영역 밖으로 옮기면 된다.

private void notifyElementAdded(E element){
        List<SetObserver<E>> snapshot = null;
        synchronized (observers){
            snapshot = new ArrayList<SetObserver<E>>(observers);
        }
        for(SetObserver<E> observer : snapshot) {
            System.out.println("this :"+this+" element:"+element);
            observer.added(this, element);
        }
}

위 방법보다 CopyOnWriteArrayList를 사용하면 더 좋다. 이 리스트는 ArrayList의 변종으로 내부 배열을 통째로 복사하는 방식으로 쓰기 연산을 지원한다. 따라서 락을 걸 필요가 없어지고 순회 연산이 많은 곳에서 사용할만 하다. 하지만 성능이 많이 안 좋다. 

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);
    }

중요한 것은 최대한 동기화 영역 안에서 수행되는 작업의 양을 최소화해야 한다.



※ 스레드보다는 실행자와 태스크를 이용하라

: 작은 프로그램이나 부하가 크지 않는 서버에는 Executors.newCachedThreadPool을 사용하는 게 좋다. 설정이 필요 없고 많은 일을 잘 처리하기 때문이다. 하지만 작업이 큐에 들어가는 게 아니라 실행을 담당하는 스레드에 바로 넘기기 때문에 CPU를 많이 사용하는 부하가 심한 서버에서는 적합하지 않다. Executors.newFiexedThreadPool을 사용해 스레드 개수가 고정된 풀을 만드는 게 좋다. 또한 java.util.Timer 대신 ScheduledThreadPoolExecutor을 사용하는 게 좋다.


※ wait나 notify 대신 병행성 유틸리티를 이용하라

: List, Queue, Map 에 대한 병행 컬렉션을 자바는 제공한다. 따라서 이런 컬렉션 외부에서 동기화를 처리 해봐야 아무 효과도 없고 프로그램만 느려진다. 따라서 동기화가 필요하면 병행 컬렉션을 사용하라. 그리고 Collections.synchronizedMap이나 Hashtable 대신 ConcurrentHashMap을 사용해라. 성능이 더 개선된다.

동기자는 스레드들이 서로를 기다릴 수 있도록 하여 상호 협력이 가능하다. wait와 notify 대신 동기자를 사용하는 게 좋다. 동기자로는 CountDownLatch와 Semaphore가 있다. CountDownLatch는 대기 중인 스레드가 진행하려면 생성자로 받는 int 값 인자 횟수만큼 countdown 메서드가 호출되어야 한다. 다음은 CountDownLatch를 사용하는 예이다. 작업 스레드들의 실행 시간을 계산하는 프로그램이다.

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

    for(int i=0; i<concurrency; i++){
        executor.execute(new Runnable() {
            @Override
            public void run() {
                ready.countDown(); // 타이머에게 준비됨을 알림
                try {
                    start.await(); // 다른 작업 스레드가 준비될 때까지 대기
                    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;
}

위에서 주의할 게 있다. 실행자 executor가 concurrency 만큼의 스레드를 생성해야 하는데 그렇지 못하면 테스트는 위 프로그램은 끝나지 않는다. 이를 스레드 고갈 교착 상태라고 한다. 위에서 catch 문을 보면 interrupt를 다시 발생시키고 있는 데 그 덕에 run 메서드를 빠져나갈 수 있다. 또한 특정 구간의 실행시간을 잴 때는 System.currentTimeMillis 대신 System.nanoTime을 사용해야 한다. 그래야 더 정밀하게 잴 수 있다.


- 다른 객체에 대한 뷰 역할을 하는 객체의 경우, 클라이언트는 원래 객체에 대해 동기화 해야 한다. 

Map<K, V> m = Collections.synchronizedMap(new HashMap<K, V>());

Set<K> s = m.keySet();

synchronized(m){ //s가 아니라 m에 대해 동기화
  for(K key : s)
      key.f();
}


※ 스레드 스케줄러에 의존하지 마라

: Thread.yeild나 스레드 우선순위에 의존하지 마라. 안전적이고 즉각 반응하며 이식성이 좋은 프로그램을 만드는 가장 좋은 방법은 실행 가능 스레드의 평균적 수가 프로세서 수보다 너무 많아지지 않도록 하는 것이다. 실행 가능 스레드는 모든 스레드 수와는 다르다. 대기 중인 스레드도 있기 때문이다. 실행 가능 스레드를 일정 수준으로 낮추는 방법은 스레드가 필요한 일을 하고 나서 다음에 할 일을 기다리게 하는 것이다. 필요한 일을 하고 있지 않을 때는 실행 중이어서는 안 된다. 또한 대기를 해도 바쁘게 대기를 해서는 안 된다. 무언가 일어나기를 기다리면서 공유 객체를 계속 검사하는 건 곤란하다. 다음 예를 보자.

public class SlowCountDownLatch { private int count; public SlowCountDownLatch(int count) { if (count < 0) throw new IllegalArgumentException(count + " < 0"); this.count = count; } public void await() { while (true) { // 계속 바쁘게 대기한다. synchronized(this) { if (count == 0) return; } } } public synchronized void countDown() { if (count != 0) count--; } }

참고로 Thread.yield, 스레드 우선순위, 스레드 그룹은 사용하지 마라.

+ Recent posts