[11장. 동시성]
- 스레드는 여러 활동을 동시에 수행할 수 있게 해준다. 단일 스레드 프로그래밍보다 동시성 프로그래밍은 어렵다.
공유중인 가변 데이터는 동기화해 사용하라
- synchronized 키워드는 메소드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. (=synchronized가 붙으면 단 하나의 스레드만 실행가능)
- 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.
- Thread.stop은 사용하지 말자
- 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
- 이 문제들을 피하는 가장 좋은 방법은 가변 데이터를 공유하지 않는 것이다. (가변 데이터는 단일 스레드에서만 쓰도록 하자)
- 여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다.
- 동기화하지 않으면 한 스레드가 수행한 변경을 다른스레드가 보지 못할 수 있다. (쓰레기값 반영)
- 공유 자원의 동기화를 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다. (디버깅 난이도가 가장 큰 문제)
- 배타적 실행은 필요없고 스레드끼리의 통신만 필요하다면 volatile 한정자만으로 동기화 가능하다.
- volatile 관련 포스팅 : https://nesoy.github.io/articles/2018-06/Java-volatile
과도한 동기화는 피하라
- 과도한 동기화는 성능을 저하시키고, 교착상태에 빠뜨리고, 예측 불가능한 동작을 낳는다.
- 응답불가와 안전실패를 피하려면 동기화 메소드나 동기화 블록안에서는 제어를 절대로 클라이언트에 양도하면 안된다.
- CopyOnWriteArrayList 를 이용해서 스레드 안전하고 관찰 가능한 코드를 구현
- 변경이 거의 없고 관찰이 필요할 경우에 사용
- 동기화 영역에서는 가능한 한 일을 적게 한다.
- 자바의 동기화 비용은 빠르게 낮아져 왔지만, 과도한 동기화를 피하는 것은 어느 때보다 중요하다
- 가변 클래스를 작성하려거든 다음 두 선택지 중 하나를 따르자
- 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두번째 방법을 선택해야 한다.
- 클래스 내부에서 동기화하기로 했다면, 락 분할, 락 스트라이핑, 비차단 동시성 제어 등 다양한 기법으로 동시성을 높 수 있다.
- 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 한다.
- 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.
- 교착상태와 데이터 훼손을 피하려면 동기화 영역안에서 외계인 메소드를 절대 호출하지 말자.
- 동기화 영역에서의 작업은 최소한으로 줄이자.
- 가변 클래스를 설계할 때는 스스로 동기화해야 할지 말지 고민하자.
- 멀티코어 세상인 지금도 과도한 동기화를 피하는 것이 굉장히 중요
- 합당한 이유가 있을 경우 내부에서 동기화하고 문서화하자.
스레드보다는 실행자, 태스크. 스트림을 애용하라
- 실행자 서비스의 다양한 기능
- exec.execute(runnable); 실행자에 태스크를 넘기는 방법이다.
- exec.shutdown(); 실행자를 종료시키는 방법이다.
- get 특정 태스크가 완료되기를 기다린다.
- invokeAny, invokeAll태스크 모음 중 아무것하나 혹은 모든태스크가 완료되기를 기다린다.
- awaitTermination 실행자 서비스가 종료하기를 기다린다.
- ExceutorCompletionService 완료된 태스크들의 결과를 차례로 받는다.
- ScheduledThreadPoolExecutor 태스크를 특정시간에 혹은 주기적으로 실행하게 한다.
- 평범하지 않은 실행자를 원한다면 ThreadPoolExecutors 클래스를 직접 사용해도 된다.
- 스레드풀을 동작을 결정하는 거의 모든 속성을 설정 가능하다.
- Executors.newCachedThreadPool() : 초기 스레드 수와 코어 스레드 수가 0개이다. '스레드 수 < 작업 수'의 경우에 새 스레드를 생성시켜 작업을 처리한다. 1개 이상의 스레드가 추가되었을 때 60초 동안 추가된 스레드가 작업을 하지 않으면 추가된 스레드를 종료하고 풀에서 제거한다.
- Executors.newFixedThreadPool() : 초기 스레드수는 0개 이고, 코어 스레드 수는 프로그래머가 설정한 n개이다. '스레드 수 < 작업 수'의 경우 새 스레드를 생성하고 작업을 처리한다. 스레드가 놀고 있더라도 스레드 수가 줄지 않는다. 최대 스레드 수는 파라미터로 설정한 n값이다.
- Executors.newScheduledThreadPool(corePoolSize) : 특정시간 이후에 실행되거나 주기적으로 작업을 실행할 수 있도록 하는 멀티스레드 풀이다.
- 작업 큐를 직접 만드는 일은 삼가야하고, 스레드를 직접 다루는 것도 일반적으로 삼가야한다. 실행자 프레임워크에서는 작업 단위와 실행 매커니즘이 분리된다.
- 작업 단위를 나타내는 핵심 추상 개념이 태스크(task)다.
- 태스크의 종류는 Runnable, Callable 두 가지이다.
- Thread 클래스는 상속을 받아 사용해야 하므로 다른 클래스들을 상속받을 수 없다. 인터페이스를 활용하면 다른 클래스를 상속 받아 쓸 수 있다. 코드의 재사용성과 일관성을 높일 수 있다.
- Runnable : Runnable 인터페이스는 구현할 메소드가 run() 하나다. 함수형 인터페이스이다. Thread 클래스를 상속받으면 다중 상속이 불가능하다. 그래서 Runnable 인터페이스를 구현해서 추상메소드를 오버라이딩해 사용하는 것이다. 개발자의 의도에 따라 선택하면 된다.
- Callable : Callable은 Runnable과 유사하지만 값을 반환하고 임의의 예외를 던질 수 있다.
- 태스크를 수행하는 일반적인 매커니즘이 실행자 서비스이다.
- 태스크 수행을 실행자 서비스에 맡기면 원하는 태스크 수행 정책을 선택/변경할 수 있다.
- 컬렉션 프레임워크가 데이터 모음을 담당하듯 실행자 프레임워크가 작업 수행을 담당한다.
- ForkJoinPool
- 자바7에서 실행자 프레임워크는 포크 조인 태스크를 지원한다.
- 포크-조인 태스크는 포크-조인 풀이라는 특별한 실행자 서비스가 실행한다.
- ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있다.
- ForkJoinPool를 구성하는 스레드 들이 이 태스크들을 처리한다.
- 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 처리 가능하다.
- 모든 스레드가 바쁘게 움직여 CPU를 최대한 활용하면서 높은 처리량 낮은 지연시간을 달성다.
- ForkJoinPool를 이용해 만든 병렬 스트림을 이용해보자 !
wait, notify 보다는 동시성 유틸리티를 애용하라
- wait와 notify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자
- 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
- Collections.synchronizedMap 보다는 ConcurrentHashMap을 사용하는 것이 훨씬 좋다.
- 시간 간격을 잴 때는 항상 System.currentTimeMillis() 보다는 System.nanoTime()을 사용하자.
- wait 메소드를 사용할 때는 반드시 대기 반복문(wait loop)관용구를 사용하자. 반복문 밖에서는 절대로 호출하지 말자.
- 요약
- wait와 notify를 직접 사용하는 것을 동시성 어셈블리 언어로 프로그래밍하는 것에 비유 할 수 있다. 반면 java.util.concurrent는 고수준 언어에 비유할 수 있다.
- 코드를 새로 작성한다면 wait와 notify를 쓸 이유가 거의 없다.
- 이미 사용한 레거시 코드를 유지보수해야 한다면 wait은 항상 while문 안에서 호출하도록 하자.
- 일반적으로 notify 보다는 notifyAll을 사용해야 한다.
- notify를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자.
스레드 안전성 수준을 문서화 하라
- API문서에 synchronized 한정자가 보이는 메소드는 스레드 안전하다.
- 자바독이 기본 옵션에서 생성한 API 문서에는 synchronized 한정자가 포함되지 않는다.
- 메소드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.
- 따라서, 스레드 안전성에 관하여 문서화를 명확하게 하지 않은 것이다.
- 멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.
- 스레드 안전성(안정성 높은순)
스레드 안전성
스레드 안전성 |
설명 |
어노테이션 |
예시 클래스 |
불변 |
이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요없다. |
@Immutable |
String, Boolean, Integer, Float, Long. Immutable Class는 heap영역에서 변경이 불가능하지 재할당을 못하는 것은 아니다. |
무조건적 스레드 안전 |
이 클래스의 인스턴스는 수정될 수 있으나 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다. |
@ThreadSafe |
AtomicLong, ConcurrentHashMap |
조건부 스레드 안전 |
무조건적인 스레드 안전과 같으나, 일부 메소드는 동시에 사용하려면 외부 동기화가 필요하다 |
@ThreadSafe |
Collections.synchronized 래퍼 메소드가 반환한 컬렉션 |
스레드 안전하지 않음 |
이 클래스의 인스턴스는 수정될 수 있다. 동시에 사용하려면 각각의 메소드 호출을 클라이언트가 선택한 외부 동기화 매커니즘으로 감싸야 한다. |
@NotThreadSafe |
ArrayList, HashMap |
스레드 적대적 |
이 클래스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 이런 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다. |
@NotThreadSafe |
동시성을 고려하지 않고 작성하다보면 우연히 만들어 질 수 있다. |
- 스레드 적대적인 클래스나 메소드는 일반적으로 문제를 고쳐 재배폭하거나 사용자제 API로 지정한다.
- 요약
- 클라이언트나 하위 클래스에서 동기화 매커니즘을 깨뜨리는 걸 예방할 수 있다
- 정교한 동시성을 매커니즘으로 재구현할 여지가 생긴다.
- 모든 클래스가 자신의 스레드 안전성 정보를 명확히 문서화 해야 한다.
- 정확한 언어로 명확히 설명하거나 스레드 안전성 annotation을 사용할 수 있다.
- synchronized 한정자는 문서화와 관련이 없다.
- 조건부 스레드 안전 클래스는 메소드를 어떤 순서로 호출할 때 외부 동기화가 요구되고 어떤 락을 얻어야 하는지도 알려줘야 한다.
- 무조건적 스레드 안전 클래스를 생성할 때는 synchronized 메소드가 아닌 비공개 락 객체를 사용하자
지연 초기화(Lazy initialization)는 신중히 사용하라.
- 지연초기화 : 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다.
- 정적 필드와 인스턴스 필드 모두에 사용 가능 하다.
- 주로 최적화 용도로 사용하지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.
- 필요할 때 까지 하지 말라
- 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 지연 초기화하는 필드에 접근하는 비용은 커진다.
- 대부분의 상황에서 일반적인 초기화가 지연초기화보다 낫다.
- 성능 떄문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스 관용구를 사용하자
- lazy initialization holder class
- 성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사 관용구를 사용하라
- 이 아이템에서 얘기한 모든 초기화 기법은 기본 타입 필드와 객체 참조 필드 모두에 적용할 수 있다.
- 단일 검사 관용구 racy single-check
- 정리
- 대부분의 필드는 지연시키지 말고 곧바로 초기화해야 한다.
- 성능때문에 위험한 초기화 순환을 막기 위해 꼭 지연초기화를 써야 한다면 올바른 지연 초기화 기법을 사용하자.
- 인스턴스 필드에는 이중 검사 관용구를, 정적 필드에는 지연 초기화 홀더 클래스 관용구를 사용하자
- 반복해서 초기화해도 괜찮은 인스턴스 필드에는 단일검사 관용구도 고려 대상이다.
프로그램의 동작을 스레드 스케줄러에 기대지 말라.
- 정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.
- 스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.
- Thread.yield를 이용해서 문제를 해결하지 말자. 테스트할 수단도 없다.
- 스레드 우선순위는 자바에서 이식성이 가장 나쁜 특성에 해당한다.
- 심각한 응답 불가 문제를 스레드 우선순위로 해결하려는 시도는 절대 합리적이지 않다. 진짜 원인을 찾아 수정하자
- 핵심정리
- 프로그램의 동작을 스레드 스케줄러에 기대지 말자. 견고성과 이식성을 모두 해친다.
- Thread.yield와 스레드 우선순위에 의존하지말자.
Reference
https://heowc.dev/2018/02/10/spring-boot-async/
https://cofs.tistory.com/320
https://springboot.tistory.com/38
https://jeong-pro.tistory.com/187
https://stackoverflow.com/questions/48266649/springs-async-ignores-synchronized
https://www.baeldung.com/spring-async
'Dev > Java' 카테고리의 다른 글
[java] FilenameFilter, FileFilter (0) | 2020.06.10 |
---|---|
[java] jmeter 성능 테스트 (0) | 2020.05.20 |
[effective-java] 일반적인 프로그래밍 원칙 (0) | 2020.01.14 |
[java] CSV 파싱하기 (0) | 2019.12.13 |
[java] Logback 과 Maven (0) | 2019.10.04 |