[Effective Java] 11장. 동시성

kkatal_chae·2022년 11월 20일
0

Effective Java

목록 보기
10/11
post-thumbnail

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

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

동기화를 제대로 사용하면 어떤 메서드도 이 객체의 상태가 일관되지 않은 순간을 볼 수 없을 것이다.

동기화에는 중요한 기능이 하나 더 있다. 동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.

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

// 적절히 동기화해 스레드가 정상 종료되는 코드 
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();
	}
}

위의 코드에서 stopRequested 필드를 volatile 으로 선언하면 동기화를 생략해도 된다. volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

// java.util.concurrent.atomic 을 이용한 락-프리 동기화
private static final AtomicLong nextSerialNum = new AtomicLong();

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

가변 데이터는 단일 스레드에서만 쓰도록 하자

💡 핵심 정리
여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화 해야 한다.


아이템 79. 과도한 동기화는 피하라

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

// 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 notifiyElementAdded( E element ) {
	for ( SetObserver<E> observer : observers )
		observer.added( this, element );
}

기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다.

락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, 락을 놓는다. 오래 걸리는 작업이라면 동기화 영역 바깥으로 옮기는 방법을 찾아보자

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

가변 클래스를 작성하려거든 다음 두 선택지 중 하나를 따르자

첫 번째, 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.

두 번째, 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.

단, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법을 선택해야 한다.

💡 핵심 정리
교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자. 일반화해 이야기하면, 동기화 영역 안에서의 작업은 최소한으로 줄이자.


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

java.util.concurrent 패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.

// 모든 면에서 뛰어난 작업 큐 생성 
ExecutorService exec = Executors.newSingleThreadExecutor();

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

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

큐를 둘 이상의 스레드가 처리하게 하고 싶다면 간단히 다른 정적 팩터리를 이용하여 다른 종류의 실행자 서비스 ( 스레드 풀 ) 를 생성하면 된다.

작은 프로그램이나 가벼운 서버라면 Executors.newCachedThreadPool 이 일반적으로 좋은 선택일 것이다. 특별히 설정할 게 없고 일반적인 용도에 적합하게 동작한다.

CachedThreadPool 에서는 요청받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위임돼 실행된다.

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

작업 큐를 손수 만드는 일은 삼가야 하고, 스레드를 직접 다루는 것도 일반적으로 삼가야 한다. 스레드를 직접 다루면 Thread 가 작업 단위와 수행 메커니즘 역할을 모두 수행하게 된다.

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

아이템 81. waitnotify 보다는 동시성 유틸리티를 애용하라

waitnotify 는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자

java.util.concurrent 의 고수준 유틸리티는 세 범주로 나눌 수 있다.

  • 실행자 프레임워크
  • 동시성 컬렉션
  • 동기화 장치

높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.

따라서 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

// ConcurrentMap 으로 구현한 동시성 정규화 맵 
public static String intern( String s ) {
	String result = map.get( s );
	if ( result == null ) {
		result = map.putIfAbsent( s, s );
		if ( result == null ) 
			result = s;
	}
	return result;
}
// 동시 실행 시간을 재는 간단한 프레임워크 
public static long time( Executor executor, int concurrency, 
	Runnable action ) throws InterruptedException {
	CountDownLatch ready = new CountDownLatch( concurrency );
	CountDownLatch start = new CountDownLatch( 1 );

	CounDownLatch done = new CountDownLatch( concurreny );

	for ( int i = 0; i < concurrency; i++ ) {
		executor.execute( () -> {
			// 타이머에게 준비를 마쳤음을 알린다. 
			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;
}
	

시간 간격을 잴 때는 항상 System.currentTimeMillis 가 아닌 System.nanoTime 을 사용하자

wait 메서드를 사용할 때는 반드시 대기 반복문 관용구를 사용하라. 반복문 밖에서는 절대로 호출하지 말자

💡 핵심 정리
코드를 새로 작성한다면 waitnotify 를 쓸 이유가 거의 없다. 이들을 사용하는 레거시 코드를 유지보수해야 한다면 wait 는 항상 표준 관용구에 따라 while 안에서 호출하도록 하자
일반적으로 notify 보다는 notifyAll 을 사용해야 한다. 혹시하도 notify 를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자.


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

메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API 에 속하지 않는다

따라서 이것만으로는 그 메서드가 스레드 안전하다고 믿기 어렵다.

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

스레드 안전성이 높은 순

  • 불변 : 이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요 없다. ex ) String, Long, BigInteger
  • 무조건적 스레드 안전 : 이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다. ex ) AtomicLong, ConcurrentHashMap
  • 조건부 스레드 안전 : 무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다. ex ) Collections.synchronized
  • 스레드 안전하지 않음 : 이 클래스의 인스턴스는 수정될 수 있다. 동시에 사용하려면 각각의 메서드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야 한다. ex ) ArrayList, HashMap
  • 스레드 적대적 : 이 클래스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다.

이 분류는 < 자바 병렬 프로그래밍 >부록에 나오는 스레드 안전성 애너테이션 @Immutable, @ThreadSafe, @NotThreadSafe 과 대략 일치한다.

조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다. 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그리고 그 순서로 호출하려면 어떤 락 혹은 락들을 얻어야 하는지 알려줘야 한다.

// 비공개 락 객체 관용구 - 서비스 거부 공격을 막아준다. 
private final Object lock = new Object();
	
public void foo() {
	synchronized( lock ) {
		...
	}
}

💡 핵심 정리
모든 클래스가 자신의 스레드 안전성 정보를 명확히 문서화해야 한다.
무조건적 스레드 안전 클래스를 작성할 때는 synchronized 메서드가 아닌 비공개 락 객체를 사용하자. 이렇게 해야 클라이언트나 하위 클래스에서 동기화 메커니즘을 깨뜨리는 걸 예방할 수 있고, 필요하다면 다음에 더 정교한 동시성을 제어 메커니즘으로 재구현할 여지가 생긴다.


아이템 83. 지연 초기화는 신중히 사용하라

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

이 기법은 정적 필드와 인스턴스 필드 모두에 사용할 수 있다. 지연 초기화는 주로 최적화 용도로 쓰이지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.

지연 초기화는 양날의 검이다. 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 그 대신 지연 초기화하는 필드에 접근하는 비용은 커진다.

멀티스레드 환경에서는 지연 초기화를 하기가 까다롭다. 지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 어떤 형태로든 반드시 동기화해야 한다.

대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.

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

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

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

// 인스턴스 필드를 초기화하는 일반적인 방법 
private final FieldType field = computeFieldValue();

// 인스턴스 필드의 지연 초기화 - synchronized 접근자 방식 
private FieldType field;

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

// 정적 필드용 지연 초기화 홀더 클래스 관용구
private static class FieldHolder {
	static final FieldType field = computeFieldValue();
}

private static FieldType getField() {
	return FieldHolder.field;
}

// 인스턴스 필드 지연 초기화용 이중검사 관용구
private volatile FieldType field;

private FieldType getField() {
	FieldType result = field;
	if ( result != null ) {
		return result;

		synchronized( this ) {
			if ( field == null ) 
				field = computeFieldValue();
			return field;
		}
}

💡 핵심 정리
대부분의 필드는 지연시키지 말고 곧바로 초기화해야 한다. 성능 때문에 혹은 위험한 초기화 순환을 막기 위해 꼭 지연 초기화를 써야 한다면 올바른 지연 초기화 기법을 사용하자.


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

정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.

견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법은 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.

스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.

💡 핵심 정리
프로그램의 동작을 스레드 스케줄러에 기대지 말자. 견고성과 이식성을 모두 해치는 행위다. 같은 이유로, Thread.yield 와 스레드 우선순위에 의존해서도 안 된다.

0개의 댓글