Dev/Java

[effective-java] 동시성

창문닦이 2020. 1. 15. 23:50

[11. 동시성]

  • 스레드는 여러 활동을 동시에 수행할 있게 해준다. 단일 스레드 프로그래밍보다 동시성 프로그래밍은 어렵다.

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

  • synchronized 키워드는 메소드나 블록을 한번에 스레드씩 수행하도록 보장한다. (=synchronized 붙으면 하나의 스레드만 실행가능)
  • 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 필요하다.
  • Thread.stop 사용하지 말자
  • 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
  • 문제들을 피하는 가장 좋은 방법은 가변 데이터를 공유하지 않는 것이다. (가변 데이터는 단일 스레드에서만 쓰도록 하자)
  • 여러 스레드가 가변 데이터를 공유한다면 데이터를 읽고 쓰는 동작은 반드시 동기화해야 한다.
  • 동기화하지 않으면 스레드가 수행한 변경을 다른스레드가 보지 못할 있다. (쓰레기값 반영)
  • 공유 자원의 동기화를 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 있다.  (디버깅 난이도가 가장 문제)
  • 배타적 실행은 필요없고 스레드끼리의 통신만 필요하다면 volatile 한정자만으로 동기화 가능하다.
 

Java volatile이란?

 

nesoy.github.io

 


과도한 동기화는 피하라

  • 과도한 동기화는 성능을 저하시키고, 교착상태에 빠뜨리고, 예측 불가능한 동작을 낳는다.
  • 응답불가와 안전실패를 피하려면 동기화 메소드나 동기화 블록안에서는 제어를 절대로 클라이언트에 양도하면 안된다.
  • 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