1. 스레드 안전성
- 스레드 안전성이란?
- 여러 스레드가 클래스에 접근할 때, 실행 환경이 해당 스레드들의 실행을 어떻게 스케줄하든 어디에 끼워 넣든, 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 말한다.
- 스레드 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화한다.
- 상태 없는 객체는 항상 스레드 안전하다.
- 단일 연산
- 증감 연산자는 단일 연산이 아니다
- 변수 값을 가져온다.
- 값에 1을 더한다.
- 변수에 값을 저장한다.
- 늦은 초기화 시 경쟁 조건
- 한 스레드에서 생성자를 통해 객체를 생성할 때, 다른 스레드에서 인스턴스에 접근하면 null일지 객체가 생성되었을지 알 수 없다.
- 다른 스레드가 읽기 전에 복합 동작을 모두 수행할 경우, 단일 연산으로 본다.
- atomic 클래스처럼 복합 동작을 단일 연산으로 수행하는 메서드를 제공하므로, 스레드 안전하게 사용할 수 있다.
- 증감 연산자는 단일 연산이 아니다
- 락
- 상태를 일관성 있게 유지하려면 관련 있는 변수들을 하나의 단일 연산으로 갱신하여 한다.
- atomic 클래스 변수 2개를 선언하고, 각각 set를 사용하는 사이에 다른 스레드의 요청이 있다면 안전성이 깨지게 된다.
- synchronized라는 구문으로 암묵적인 락(모니터 락)을 제공한다.
- 암묵적인 락은 mutex로 동작한다.
- synchronized 블록들은 서로 단일 연산으로 실행된다.
- synchronized를 사용하면 여러 클라이언트가 동시에 사용하지 못하므로, 응답성이 엄청나게 떨어질수 있다.
- 암묵적인 락은 재진입 가능하기 때문에 특정 스레드 본인이 이미 획득한 락을 다시 확보할 수 있다.
- 상태를 일관성 있게 유지하려면 관련 있는 변수들을 하나의 단일 연산으로 갱신하여 한다.
- 락으로 상태 보호하기
- 여러 스레드에서 접근할 수 있고 변경 가능한 모든 변수를 대상으로 해당 변수에 접근할 때는 항상 동일한 락을 먼저 확보한 상태여야 한다. 이 경우 해당 변수는 확보된 락에 의해 보호된다고 말한다.
- 모든 변경할 수 있는 공유 변수는 정확하게 단 하나의 락으로 보호해야 한다. 유지 보수하는 사람이 알 수 있게 어느 락으로 보호하고 있는지를 명확하게 표시하라.
- 여러 변수에 대한 불변 조건이 있으면 해당 변수들은 모두 같은 락으로 보호해야 한다. 이럼으로, 단일 연산을 보장할 수 있다.
- 활동성과 성능
- 복잡하고 오래 걸리는 계산 작업, 네트워크 작업, 사용자 입출력 작업과 가ㅑㅌ이 빨리 끝나지 않을 수 있는 작업을 하는 부분에서는 가능한 한 락을 잡지 말아라
- 락을 획득하고, 해지하는 것도 비용이다. synchronized 블록을 너무 잘게 쪼개지 마라.
2. 객체 공유
- 가시성
- 동기화 기능을 지정하지 않으면 컴파일러나 프로세서, JVM(자바 가상 머신) 등이 프로그램 코드가 실행되는 순서를 임의로 바꿔 실행하는 이상한 경우가 발생하기도 한다. 다시 말하자면, 동기화 되지 않은 상황에서 메모리상의 변수를 대상으로 작성해둔 코드가 '반드시 이런 순서로 동작할 것이다'라고 단정지을 수 없다. -> 재배치 현상
- 동기화가 제대로 이루어지지 않으면 스테일 현상이 발생할 수 있다.
- 64비트를 사용하는 long이나 double 형은 32비트씩 2번 비트를 읽어오는 것이다. volatile을 쓰지 않는다면 이상한 값을 읽어올 수도 있다.
- 락은 상호배제뿐만 아니라 정상적인 메모리 가시성을 확보하기 위해서도 사용한다. 변경 가능하면서 여러 스레드가 공유해 사용하는 변수를 각 스레드에서 각자 최신의 정상적인 값으로 활용하려면 동일한 락을 사용해 모두 동기화시켜야 한다.
- volatile로 지정된 변수는 프로세서의 레지스터에 캐시되지도 않고, 프로세서 외부의 캐시에도 들어가지 않기 때문에 항상 다른 스레드가 보관해둔 최신의 값을 읽어갈 수 있다.
- volatile 변수를 사용하는 적절한 경우는, 일반적으로 변수에 보관된 클래스의 상태에 대한 가시성을 확보하거나 중요한 이벤트가 발생했다는 등의 정보를 정확하게 전달하고자 하는 경우 등이 해당된다.
- 락을 사용하면 가시성과 연산의 단일성을 모두 보장받을 수 있다. 하지만 volatile 변수는 연산의 단일성은 보장하지 못하고 가시성만 보장한다.
- 공개와 유출
- 내부적으로 사용할 변수를 외부에 공개시키는 것은 좋지 않다. 관련 객체까지 덩달아 공개되는 경우가 있다.
- 생성 메소드를 실행하는 도중에는 this 변수가 외부에 유출되지 않게 해야 한다.
- 생성 메소드에서 쓰레드 새로 만들어 실행시키기 금지
- 스레드 한정
- 주먹구구식 - volatile로 선언하고, 특정 단일 쓰레드에서만 쓰기 작업을 하도록 구현
- 스택 한정 - 로컬 변수를 사용하여 현재 실행 중인 스레드에 한정시킨다. 단, 참조 객체를 외부에 유출시키지 않도록 유의한다.
- ThreadLocal - ThreadLocal 객체를 사용하여 관리, JDBC의 커넥션 풀처럼 객체를 미리 만들어두고, 재활용할 때 주로 사용
- 불변성
- 불변 객체는 언제라도 스레드에 안전하다.
- 불변 객체의 조건
- 생성되고 난 이후에는 객체의 상태를 변경할 수 없다.
- 내부의 모든 변수는 final로 설정돼야 한다.
- 적절한 방법으로 생성돼야 한다(예를 들어 this 변수에 대한 참조가 외부로 유출되지 않아야 한다.)
- final 키워드를 적절하게 사용하면 초기화 안전성을 보장한다. 나중에 변경할 일이 없다면 final로 선언하는 것도 좋다.
- 불변 객체에 volatile을 사용하면, 가시성과 불변성을 확보하기 때문에 따로 락을 사용하지 않아도 스레드에 안전하다.
- 안전 공개
- 불변 객체는 별다른 동기화 방법을 적용하지 않아도, 안전하게 사용할 수 있다. (JMM에서 final이 붙은 건 객체 생성 전 기본값을 보여주지 않음, 생성자에서 초기화한 값을 항상 볼 수 있도록 저장)
- 안전한 공개 방법의 특성
- 객체에 대한 참조를 static 메소드에서 초기화시킨다.
- 객체에 대한 참조를 volatile 변수 또는 AtomicReference 클래스에 보관한다.
- 객체에 대한 참조를 올바르게 생성된 클래스 내부의 final 변수에 보관한다.
- 락을 사용해 올바르게 막혀 있는 변수에 객체에 대한 참조를 보관한다.
- 객체를 안전하게 공유하기
- 스레드 한정
- 읽기 전용 객체를 공유
- 스레드에 안전한 객체를 공유
- 동기화 방법 적용
3. 객체 구성
- 스레드 안전한 클래스 설계
- 객체가 가질수 있는 값의 범위와 변동 폭을정확하게 인식하지 못한다면, 스레드 안전성을 완벽하게 확보할 수 없다. 클래스의 상태가 정상적이라는 여러가지 제약 조건이 있을 때, 클래스의 상태를 정상적으로 유지하려면 여러가지 추가적인 동기화 기법을 적용하거나 상태 변수를 클래스 내부에 적절히 숨겨야 한다.
- 상태 의존 연산 : 현재 조건에 따라 동작 여부가 결정되는 연산
- 캡슐화 정책은 내부에 객체와 함께 상태 정보를 숨기기 때문에 객체의 상태에 대한 소유권이 있다.
- 소유권 분리 : 컬렉션 클래스를 놓고 볼 때 컬렉션 내부의 구조에 대한 소유권은 컬렉션 클래스가 갖고, 컬렉션에 추가되어 있는 객체에 대한 소유권은 컬렉션을 호출해 사용하는 클라이언트 프로그램이 갖는 구조이다.
- 인스턴스 한정
- 데이터를 객체 내부에 캡슐화해 숨겨두면 숨겨진 내용은 해당 객체의 메소드에서만 사용할 수 있기 때문에 숨겨진 데이터를 사용하고자 할 때에는 항상 지정된 형태의 락이 적용되는지 쉽고, 정확하게 파악할 수 있다.
- 인스턴스 한정 기법을 사용하면 전체 프로그램을 다 뒤져보지 않고도 스레드 안전성을 확보하고 있는지 쉽게 분석해볼 수 있기 때문에 쓰레드에 안전한 객체를 좀 더 쉽게 구현할 수 있다.
- 자바 모니터 패턴 : 변경 가능한 데이터를 모두 객체 내부에 숨긴 다음 객체의 암묵적인 락으로 데이터에 대한 동시 접근을 막는다.
- 스레드 안전성 위임
- Atomic 클래스 같은 스레드 안전한 클래스들을 조합하여 만든 클래스들은 스레드 안전성 문제를 위임할 수 있다.
- 독립 상태 변수 : 두 개 이상의 변수가 서로 독립적이라면 스레드 안전성을 위임할 수 있다.
- Atomic 클래스 같은 스레드 안전한 클래스라 하더라도 단일 연산이 아닐 경우 스레드 안전하지 않다.
- 스레드 안전하게 구현된 클래스에 기능 추가
- 클라이언트 측 동기화 : 공유 객체의 동기화를 그 객체 외부에서, 즉 사용하는 쪽(클라이언트)이 책임지는 방법
- 클라이언트 측 락 방법으로 단일 연산을 구현하는 방법은 특정 클래스 내부에서 사용하는 락을 전혀 관계없는 제 3의 클래스에서 갖다 쓰기 때문에 위험한 방법이다.
- 클라이언트 측 락을 구현할 때도 캡슐화되어 있는 동기화 정책을 무너뜨릴 가능성이 있다.
- 동기화 정책 문서화하기
- 구현한 클래스가 어느 수준가지 스레드 안전성을 보장하는지에 대해 충분히 문서를 작성해둬야 한다. 동기화 기법이나 정책을 잘 정리해두면 유지보수 팀이 원활하게 관리할 수 있다.
4. 구성 단위
- 동기화된 컬렉션 클래스
- 여러개의 연산을 묶어 하나의 단일 연산처럼 활용할 때는 동기화된 컬렉션 클래스라도 스레드 안전하지 않다.
- 동기화된 컬렉션 클래스는 대부분 클라리언트 측 락을 사용할 수 있도록 만들어져 있어 동기화가능하다.
- Iterator와 ConcurrentModificationException
- Iterator를 사용하여 컬렉션 클래스 내부의 값을 읽을 때, 다른 스레드에서 추가, 삭제를 시도하면 문제가 발생한다.
- clone 메소드를 사용하여, 복사본을 반복문에서 사용한다.
- 컬렉션 클래스의 toString에는 iterator 메소드가 숨겨져 있다. 컬렉션 클래스를 출력할 때는 락을 걸어야 한다.
- 여러개의 연산을 묶어 하나의 단일 연산처럼 활용할 때는 동기화된 컬렉션 클래스라도 스레드 안전하지 않다.
- 병렬 컬렉션
- 기존에 사용하던 동기화 컬렉션 클래스를 병렬 컬렉션으로 교체하는 것만으로도 별다른 위험 요소 없이 전체적인 성능을 상당히 끌어올릴 수 있다.
- CurrentHashMap은 락 스트라이핑 동기화 방법을 사용해 동기화한다.
- CurrentHashMap은 단일 연산으로 putIfAbsent, removeIfEqual, replaceIfEqual을 지원한다.
- CopyOnWriteArrayList는 불변 객체를 외부에 공개하면 여러 스레드가 동시에 사용하려는 환경에서도 별다른 동기화 작업이 필요 없다는 개념을 바탕으로 스레드 안전성을 확보하고 있다.
- 블로킹 큐와 프로듀서 - 컨슈머 패턴
- 프로듀서 - 컨슈머 패턴 : 해야 할 일 목록을 가운데에 두고 작업을 만들어 내는 주제와 작업을 처리하는 주체를 분리시키는 설계 방법이다.
- 블로킹 큐에서 take와 put 메서드를 사용하면 자원이 생기거나, 공간이 생길 때까지 대기한다. offer 메서드는 큐에 값을 넣을 수 없을 때, 대기하지 않고, 공간이 모자라 추가할 수 없다는 오류를 알려준다.
- 블로킹 큐를 사용하면 처리할 수 있는 양보다 훨신 많은 작업이 생겨 부하가 걸리는 상황에서 작업량을 조절해 애플리케이션이 안정적으로 동작하도록 유도할 수 있다.
- 직렬 스레드 한정 : 스레드에 한정된 객체는 특정 스레드 하나만이 소유권을 가질 수 있고, 개체를 안전한 방법으로 공개하면 객체에 대한 소유권을 이전할 수 있다.
- 덱, 작업 가로채기 : 컨슈머 각자 deque를 가지며, 본인의 작업을 모두 끝내면, 다른 컨슈머의 deque의 끝에서 작업을 가로채와 작업한다.
- 블로킹 메소드, 인터럽터블 메소드
- InterruptedException을 전달
- 인터럽트를 무시하고 복구
- 동기화 클래스
- 상태 정보를 사용해 스레드 간의 작업 흐름을 조절할 수 있도록 만들어진 모든 클래스. 세마포어, 배리어, 래치 등이 있다.
- Latch: 스스로가 터미널 상태에 이를 때까지의 스레드가 동작하는 과정을 늦출 수 있도록 해주는 동기화 클래스
- 특정 자원을 확보하기 전에는 작업을 시작하지 말아야 하는 경우에 사용
- 의존성을 갖고 있는 다른 서비스가 시작하기 전에는 특정 서비스가 실행되지 않도록 막아야 하는 경우
- 특정 작업에 필요한 모든 객체가 실행할 준비를 갖출 때까지 기다리는 경우
- CountDownLatch의 countDown 메소드는 대기하던 이벤트가 발생했을 때 내부에 갖고있는 이벤트 카운터를하나 낮춰주고, await 메서드는 래치 내부 카운터가 0일 될 때까지(대기하던 이벤트가 모두 발생할 때까지) 대기시킨다.
- FutureTask : Callable 인터페이스를 구현하도록 되어 있고, 시작 전 대기, 시작됨, 종료됨과 같은 세 가지 상태를 가질 수 있다. 종료된 상태는 정상적인 종료, 취소, 예외 상황 발생과 같이 연산이 끝나는 모든 종류의 상태를 의미한다.
- 한번 종료됨 상태에 이르면 더 이상 상태가 바뀌는 일은 없다.
- get 메서드를 사용하면 종료 상태에 이를 따까지 대기하고, 이후에 연산 결과나 예외 상황을 알려준다.
- Executor 프레임웍에서 비동기적인 작업을 실행하고자 할 때 사용하며, 기타 시간이 많이 필요한 모든 작업이 있을 때 실제 결과가 필요한 시점 이전에 미리 작업을 실행시켜두는 용도로 사용
- Future.get 메소드에서 ExecutionException으로 한번 감싼 다음 throw 하기에, 에러 클래스를 알려면 ExecutionException.getCause 메소드를 사용해 에러 클래스를 알아야 한다.
- Semaphore
- 카운팅 세마포어 : 특정 자원이나 특정 연산을 동시에 사용하거나 호출할 수 있는 스레드의 수를 제한하고자 할 때 사용한다.
- 세마포어 클래스는 가상의 퍼밋을 만들어 내부 상태를 관리하며, 세마포어를 생성할 때 생성 메소드에 최초로 생성할 퍼밋의 수를 넘겨준다.
- 이진 세모파어는 초기 퍼밋값이 1로 지정된 카운팅 세마포어로, 비재진입 락의 역할을 하는 뮤텍스로 활용할 수 있다.
- Barrier
- 특정 이벤트가 발생할 때까지 여러 개의 스레드를 대기 상태로 잡아둘 수 있다는 측면에서 래치와 비슷하다고 볼 수 있다. 차이점은 모든 스레드가 배리어 위치에 동시에 이르러야 관문이 열리고 계속해서 실행할 수 있다는 점이다.
- 래치 : 이벤트를 기다리기 위한 동기화 클래스, 배리어 : 다른 스레드를 기다리기 위한 동기화 클래스
- 배리어는 대부분의 실제 작업은 모두 여러 스레드에서 병렬로 처리하고, 다음 단계로 넘어가기 전에 이번 단계에서 계산해야 할 내용을 모두 취합해야 하는 등의 작업이 많이 일어나느 시물레이션 알고리즘에서 유용하게 사용된다.
- 효율적이고 확장성 있는 결과 캐시 구현
- ConcurrentHashMap의 putIfAbsent() 메서드를 활용한 원자적 연산
- FutureTask를 통한 계산 중인 작업의 상태 관리
- 예외 상황 처리를 통한 캐시 오염 방지