주요 콘텐츠로 건너뛰기

CooLWJ 검색

选择搜索范围,输入关键词后按回车。

언어

전체적인 관점에서 보는 기술-Java 멀티스레딩의 발전사

머리말

2022년 09월 22일, JDK19가 출시되었다. 이 버전의 가장 큰 특징은 가상 스레드 지원으로, 이로써 경량 스레드 계열에 또 하나의 든든한 일원이 추가되었다. 가상 스레드는 JVM이 운영체제를 통해 스레드를 스케줄링해야 한다는 제약에서 벗어나 JVM 자체에서 스레드를 스케줄링하게 한다. 사실 초기 sun은 Solaris 운영체제의 가상 머신에서 JVM이 스레드를 스케줄링하는 방식을 구현한 적이 있지만, 복잡성과 유지보수성을 고려해 결국 모두 운영체제가 스레드를 스케줄링하는 방식으로 돌아갔다.

“长安归来锦衣客,昨日城南起新宅。” 장안에서 비단옷 차림으로 돌아온 나그네, 어제 성 남쪽에 새집을 지었네. 지금까지의 여정을 돌아보면 멀티스레딩에 관한 개념은 눈이 어지러울 만큼 많고, 인터넷의 관련 설명도 헤아릴 수 없이 많지만, 늘 전체를 조망하는 시각이 부족하다는 느낌이 들었다. 이에 필자는 Java 멀티스레딩의 발전사를 체계적으로 정리했으며, 여러분이 멀티스레딩 지식을 익히는 데 도움이 되기를 바란다.

이 글에서 다루지 않는 내용:

1 일부 기술 요소의 상세한 구현 원리는 다루지 않으며,소스 코드를 분석하거나 그림을 그리지 않는다,이 글에서 관심 있는 개념이나 기술을 찾았다면 직접 검색해 볼 수 있다 2 Quasar、Akka、Guava 등 동시성을 지원하는 라이브러리와 프레임워크는 다루지 않는다

이 글에서 다루는 내용

1 JDK 멀티스레딩의 발전 역사를 설명합니다 2 발전 과정에 등장한 일부 기술의 기능과 원리, 배경, 그리고 어떤 문제를 해결했는지 설명합니다 3 일부 기술에 대한 필자의 견해를 이야기합니다. 다른 견해가 있는 분들은 댓글에서 토론해 주세요

마일스톤

늘 하던 대로 먼저 통계표부터 보자. 여기에는 역대 JDK에서 스레드와 관련된 핵심 개념을 정리했다. 다소 적절하지 않을 수도 있는 비유를 들자면, 멀티스레딩의 발전을 자동차에 빗댈 수 있다. 멀티스레딩의 발전은 각각 수동 변속기 시대(JDK1.4 이하), 자동 변속기 시대(JDK5-JDK18), 자율 주행 시대(JDK19 이후)를 거쳤다. 이 비유는 JDK5 이후에는 멀티스레딩을 더 편하게 다룰 수 있게 되었다는 점을 독자에게 전하기 위한 것이다. JDK19 이후에는 단순한 편리함을 넘어, IO 집약적 서비스의 성능에 질적인 도약을 가져왔다.

시대버전게시 시간핵심 개념
수동 모드JDK1.01996-01-23Thread와 Runnable
수동 모드JDK1.21998-12-04ThreadLocal、Collections
자동 변속JDK1.5/5.02004-09-30Java 메모리 모델 명확화, 동시성 패키지 도입
자동 변속JDK1.6/6.02006-12-11synchronized 최적화
자동 변속JDK1.7/7.02011-07-28Fork/Join 프레임워크
자동 변속JDK1.8/8.02014-03-18CompletableFuture、Stream
자동 변속JDK1.9/9.02014-09-08락 경합 메커니즘 개선
자동 변속JDK102018-03-21스레드 로컬 관리
자동 변속JDK152020-09-15편향 잠금 비활성화 및 사용 중단
자율 주행JDK192022-09-22가상 스레드

2f98c71c0436418d8d260d75cb921c81~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Lqs5Lic5LqR5byA5Y-R6ICF_q75.webp

수동 변속 시대

필자는 JDK1.4 이하를 멀티스레딩의 “수동 변속” 시대, 또는 네이티브 멀티스레딩 시대라고 부릅니다. 스레드 조작은 여전히 비교적 원시적인 수준이었고, 사용할 수 있는 스레드 풀이 없었습니다. 개발자는 빈번한 스레드 생성에 따른 자원 낭비를 막기 위해 도구를 직접 작성하고, 공유 자원에 수동으로 잠금을 걸어야 했습니다. 바로 이 시대에 뛰어난 멀티스레딩 프레임워크가 많이 태동했으며, 그중 가장 유명한 것이 JDK5.0에 채택되었습니다.

JDK 1.0 Thread와 Runnable

1996년 1월의 JDK1.0 버전은 처음부터 Java의 가장 기본적인 스레드 모델을 확립했으며, 이 스레드 모델은 이후 여러 차례 보완되는 동안에도 실질적인 변화가 없었습니다. 계승할 가치가 있는 좋은 설계라고 할 수 있습니다. 선점형과 협력형은 흔히 쓰이는 두 가지 프로세스/스레드 스케줄링 방식입니다. 운영체제는 프로세스를 스케줄링할 때 선점형 방식을 사용하기에 매우 적합합니다. 서로 다른 프로세스에 타임 슬라이스를 할당하고, 오랫동안 응답하지 않는 프로세스의 자원을 회수하거나 심지어 강제로 중지할 수도 있습니다. 협력형 방식에서는 프로세스가 자발적이고 능동적으로 자원을 해제해야 합니다. 이런 스케줄링 방식에서는, 실행 시간이 매우 긴 스레드 하나 때문에 CPU가 필요한 다른 모든 스레드가 “기아 상태”에 빠질 수 있습니다. Java hotspot 가상 머신의 스케줄링은 선점형이므로, Java 언어는 처음부터 선점형 스레드 스케줄링 방식을 채택했습니다. JDK 1.0에서 스레드를 만드는 주요 방법은 Thread 클래스를 상속하거나 Runnable 인터페이스를 구현하는 것입니다. 객체 인스턴스의 start 메서드로 스레드를 시작하고, 병렬로 처리해야 하는 코드는 run 메서드에 넣으며, 스레드 간 협력과 통신에는 stop/resume/suspend 같은 단순하고 거친 방식을 사용했습니다.

Java 스레드 상태 및 상태 전환 개념도

stop/resume/suspend의 개념을 어떻게 설명하면 좋을까요? 메인 스레드가 자식 스레드의 종료, 일시 정지, 재개 메서드를 직접 호출할 수 있다는 뜻입니다. 어릴 때 휴대용 카세트 플레이어를 써 봤다면, 거기에 종료, 일시 정지, 재개 버튼 세 개가 있었을 겁니다. 지금 휴대용 카세트 플레이어 3대를 동시에 듣고 있다고 상상해 보세요. 플레이어 세 대가 자식 스레드 세 개이고, 여러분이 메인 스레드입니다. 여러분은 이 세 기기의 시작과 정지를 마음대로 제어할 수 있습니다.

이 메커니즘에는 치명적인 문제가 있는데, 교착 상태가 발생하기 쉽다는 점이다. 스레드 A가 어떤 자원에 락을 건 뒤 아직 해제하지 않은 상태에서 주 스레드에 의해 일시 중지되면(suspend 메서드는 락을 해제하지 않는다), 이때 해당 자원을 점유하려는 스레드 B는 스레드 A가 실행 재개(resume) 후 자원을 해제할 때까지 기다릴 수밖에 없다. 그렇지 않으면 영원히 자원을 얻지 못하여 교착 상태가 발생한다.

JDK 1.2

이 버전에서는 거친 stop/resume/suspend 메커니즘의 사용이 금지되고, 대신 wait/notify/sleep처럼 여러 스레드가 협력하는 방식을 채택했습니다. 언급할 만한 점은 이 버전에서 원자 객체 AtomicityXXX가 이미 설계되어 있었다는 것으로, 주로 i++의 비원자성 문제를 해결하기 위한 것이었습니다. ThreadLocal과 Collections가 추가되면서 멀티스레드를 활용하는 방식도 늘어났습니다. 필자는 이 두 기술 때문에 이 시기를 Java의 터보차저 시대라고 부릅니다.

ThreadLocal

ThreadLocal은 잠금 없이 여러 스레드가 스레드 안전하지 않은 객체를 공유하도록 구현하는 방안입니다. “은행 계좌나 재고의 증가、차감” 같은 문제를 해결하지는 못하며,“도구” 성격의 클래스에 복사본을 두어 “도구” 메서드를 안전하게 실행하는 데 강점이 있습니다. 대표적인 예로 SimpleDateFormat、데이터베이스 연결 등이 있습니다. 그 설계가 매우 교묘하다는 점도 언급할 만합니다. 직접 설계한다고 상상해 보세요. 보통 쉽게 떠올릴 수 있는 방식은 ThreadLocal 안에 전역 스레드 안전 Map을 두고,스레드를 key로,공유 객체를 value로 사용하는 것입니다. 이 설계의 단점은 메모리 누수입니다. 스레드가 계속 추가되면 Map이 무한히 커지기 때문입니다. 메모리 누수를 해결하려면 스레드가 종료될 때 해당 Map을 정리해야 하며,그러려면 GC 기능도 강화해야 하므로,분명히 투입 대비 효과가 좋지 않습니다. 그래서 ThreadLocal은 Map을 ThreadLocal이 아닌 Thread 자체가 보유하도록 설계되었습니다. key는 ThreadLocal 변수이고,value는 값입니다. 각 Thread는 사용하는 ThreadLocal을 모두 그 안에 넣습니다(물론 이 설계에도 다른 파생 문제가 있지만 여기서는 다루지 않습니다. 관심 있는 분은 직접 검색해 보세요).

Collections

Collections 유틸리티 클래스는 이 버전에서 설계되었으며,SynchronizedList 같은 스레드 안전 컬렉션을 래핑해 제공했다. Hashtable、Vector、Stack 같은 스레드 안전 컬렉션만 있던 시절에,그 등장은 시대적 의미가 있었다. Collections 유틸리티의 기본 발상은 스레드 안전하지 않은 컬렉션을 스레드 안전하게 감싸 주겠다는 것이다. 그러면 기존 코드를 업그레이드하고 수정하는 데 많은 시간을 들일 필요 없이,컬렉션을 생성할 때 제공된 메서드로 초기화하기만 하면 된다. 엔진 배기량을 바꾸지 않고 출력과 토크를 높이는 자동차의 터보차저 기술과 비슷하다. Java의 터보차저 시대가 도래했다^\_^

자동 변속 시대

JDK 5.0

동시성 패키지 도입

Doug Lea,중국어 이름은 道格·利다。미국의 대학 교사이자 대가급 인물이며,J.U.C가 바로 그의 손에서 탄생했다。JDK1.5 이전에는 프로그램에서 동기화 코드에 대한 동시 접근을 제어할 때 synchronized만 사용할 수 있었다。당시 synchronized는 성능 최적화가 아직 충분히 이루어지지 않아 성능이 좋지 않았으며,스레드 제어에도 Object의 wait와 notify 메서드만 사용할 수 있었다。이때 Doug Lea가 JCP에 JSR-166 제안서를 제출했다。JSR-166를 제출하기 전부터 Doug Lea는 이미 J.U.C 패키지와 유사한 기능의 코드를 3년 넘게 사용하고 있었고,이 코드가 바로 J.U.C의 원형이다。

J.U.C는 원자적 객체, 락 및 유틸리티 모음, 스레드 풀, 스레드 안전 컨테이너 등 몇 가지 큰 범주의 도구를 제공한다. 개발자는 원하는 기능을 유연하게 활용해 자신의 제품을 만들 수 있으며, ReentrantLock으로 기반 프레임워크를 구축할 수도 있고, 준비된 도구나 컨테이너를 바로 사용해 비즈니스 코드를 작성할 수도 있다. 역사적 관점에서 보면, 2004년의 J.U.C는 이견 없이 “최첨단 기술 제품”이라 부를 만했다. Java의 보급에도 큰 공을 세웠다. Java의 자동변속기 시대가 도래한 것이다. 자동변속기 자동차가 운전의 진입 장벽을 낮추듯, J.U.C는 프로그래머가 멀티스레딩을 사용하는 데 따르는 진입 장벽을 크게 낮췄다. 하나의 시대를 연 제품이었다.

물론 J.U.C에도 몇 가지 결점이 있다:

CPU 오버헤드가 큼:스핀 CAS가 오랫동안 성공하지 못하면 CPU에 매우 큰 오버헤드가 발생합니다.

해결 방안:JUC의 일부 구현에서는 CAS 스핀 횟수를 제한합니다. 예를 들면 BlockingQueue의 SynchronousQueue가 있습니다.

ABA 문제:어떤 값이 원래 A였다가 B로 바뀐 뒤 다시 A로 돌아오면, CAS 검사에서는 바뀌지 않은 것으로 판단하지만 실제로는 이미 변경된 상태입니다. 이것이 ABA 문제입니다. 대부분의 경우 ABA 문제는 프로그램의 동시성 정확성에 영향을 주지 않습니다.

해결 방안:각 변수에 버전 번호를 붙이고,변경할 때마다 1씩 증가시킵니다. 즉 A —> B —> A가 1A —> 2B —> 3A로 바뀝니다. Java는 이를 해결하기 위해 AtomicStampedReference를 제공합니다. AtomicStampedReference는 [E,Integer] 튜플을 감싸 객체에 버전 스탬프(stamp)를 표시함으로써 ABA 문제를 방지합니다.

공유 변수 하나에 대한 연산의 원자성만 보장할 수 있다:CAS 메커니즘이 보장하는 것은 단일 변수에 대한 연산의 원자성일 뿐, 코드 블록 전체의 원자성까지 보장하지는 않습니다.

해결 방법:예를 들어 3개 변수의 갱신이 함께 원자적으로 이루어지도록 보장해야 한다면,Synchronized를 사용할 수밖에 없다。여러 변수를 AtomicReference로 감싸는 방법도 고려할 수 있으며,이를 통해 여러 공유 변수가 있는 상황을 처리할 수 있다。

Java 메모리 모델 명확히 이해하기

이 버전의 JDK는 Java 메모리 모델을 새롭게 명확히 정의했다. 그전에는 순차 일관성 메모리 모델과 선행 발생 모델 등이 일반적으로 사용되었다。 순차 일관성 모델에서는 프로그램의 실행 순서가 코드에 나타난 순서와 완전히 일치한다。이는 멀티코어 구조와 명령어 실행 최적화를 사용하는 현대 CPU에서 보장하기가 매우 어렵다。또한 순차 일관성을 보장하면 JVM의 코드 런타임 최적화가 크게 제한된다。

하지만 이 버전의 JSR 133 명세에서 규정한 선행 발생(Happens-before) 관계는 명령어 실행 순서에 유연성을 부여합니다:

동일한 스레드 내에서는 코드 실행 순서(즉 코드의 의미상 순서)에 따라 앞선 연산이 뒤의 연산보다 먼저 발생한다 monitor 객체의 잠금 해제 연산은 이후 동일한 monitor 객체에 대한 잠금 연산보다 먼저 발생한다 volatile 필드에 대한 쓰기 연산은 이후 해당 필드에 대한 읽기 연산보다 먼저 발생한다 스레드의 start 연산(스레드 객체의 start() 메서드 호출)은 해당 스레드의 다른 모든 연산보다 먼저 발생한다 한 스레드의 모든 연산은 다른 어떤 스레드가 해당 스레드에 대해 join() 메서드를 호출하는 것보다 먼저 발생한다 연산 A가 B보다 먼저 발생하고,연산 B가 C보다 먼저 발생하면,연산 A는 C보다 먼저 발생한다

또한 메모리 할당 측면에서,각 스레드의 작업 메모리를 주 메모리와 독립시키면 JVM이 스레드 내부 명령어의 실행을 최적화할 여지가 크게 늘어난다。주 메모리의 변수를 스레드의 작업 메모리에 복사해 독립적으로 연산하고,실행이 끝난 뒤 어느 시점에 결과를 주 메모리에 다시 반영할 수 있다: 하지만 각 스레드 사이의 데이터 일관성은 어떻게 보장할까?JLS(Java Language Specification)가 제시한 방법은,기본적으로 임의의 시점에서 데이터 일관성을 보장하지는 않지만,의미 규정이 강화된 synchronized、volatile、final 키워드를 사용하면 데이터 일관성을 확보할 수 있다는 것이다。

JDK 6.0 synchronized 최적화

‘공화국의 맏아들’인 synchronized 키워드는 5.0 버전에서 ReentrantLock에 밀려 주목을 빼앗겼다. 이번 버전에서는 반드시 한 판을 만회해야 했으므로,JDK 6.0은 락 스핀、락 제거、락 병합、경량 락、편향 락 등 락에 여러 최적화를 적용했다. 이번 최적화는 ‘세밀한 관리’라는 이념을 구현한 사례다. 최적화 전에는 synchronized로 잠근 객체의 상태가 락 없음과 락 있음(중량 락),두 가지뿐이었다. 최적화 후에는 락에 총 4가지 상태가 존재하며,낮은 단계부터 차례로 락 없음、편향 락、경량 락、중량 락이다. 이 상태들은 경합 상황에 따라 점차 상위 단계로 올라가지만,하위 단계로 내려갈 수는 없다. 목적은 락 획득과 해제의 효율을 높이는 데 있다(필자는 사실 너무 복잡해서 JVM 개발자들이 엄두를 내지 못한 것이라고 생각한다).

이번 최적화로 synchronized는 체면을 세웠고, 이후로는 누구도 그 성능이 ReentrantLock보다 떨어진다고 말할 수 없게 되었다. 하지만 진짜 볼거리는 그 뒤에 있었다. 편향 락은 JDK 15에서 폐기 예정으로 지정되었다(─.─||). 필자가 보기에 synchronized가 손해를 보는 이유는 그저 키워드일 뿐이라는 데 있다. 내부 동작은 JVM이 담당하므로, 애플리케이션이 락을 걸 때 어떤 방식이 적합한지는 JVM이 “추측”해야 한다. ReentrantLock은 다르다. 이 일을 프로그래머에게 직접 맡긴다. 공정성을 원하면 공정 락을 쓰고, 불공정성을 원하면 비공정 락을 쓰면 된다. 설계 측면에서는 일종의 편의적인 선택이지만, 동시에 유연성이기도 하다.

JDK 7.0 Fork/Join 프레임워크

Fork/Join 역시 비교적 앞선 기술로 등장했다. 핵심 경쟁력은 작업을 재귀적으로 분할하면서 각 작업의 결과를 병합할 수 있다는 데 있다. 하지만 익숙하면서도 낯선 기술이다. 익숙한 이유는 다음에 JDK8.0에서 다룰 CompletableFuture와 Stream처럼 여러 곳에 활용되기 때문이고;낯선 이유는 실제 업무 개발 과정에서는 거의 사용하지 않는 듯하기 때문이다.

심지어 이를 계륵이라고 여기는 사람도 있다. 필자의 생각은 이렇다. 업무 요구 사항과 관련된 개발을 한다면 거의 쓸 일이 없으므로 계륵이다. 대용량 데이터 처리에는 데이터 웨어하우스 도구가 있고, 다른 상황에서는 스레드 풀로 대체할 수 있기 때문이다. 미들웨어 프레임워크 개발에 종사한다면 계륵이 아니며, 어쩌면 쓸 일이 있을 것이다. 중국어권 인터넷에서는 이 기술에 의문을 제기하는 사람이 드물지만, 해외에서는 이미 논의가 이루어지고 있다. 관심이 있다면 바로 이동해 살펴보자 Java의 Fork-Join 프레임워크는 고장 난 걸까요?

JDK 8.0

이 버전의 출시는 Java에 획기적인 일이었으며, 현재 전 세계에서 실행되는 Java 프로그램 중 이 버전이 절반 이상을 차지할 정도다. 다만 멀티스레드 관련 업데이트는 JDK5.0만큼 혁신적이지는 않았다. 이 버전에서는 몇 가지 원자적 객체를 추가한 것 외에, 다음 두 가지 업데이트가 가장 눈에 띈다.

CompletableFuture

인터넷에는 CompletableFuture에 관한 소개가 많으며,대부분 원리와 사용법을 설명한다。하지만 필자는 줄곧 한 가지가 이해되지 않았다:스레드 풀 도구가 그렇게 많은데도 CompletableFuture는 왜 등장했으며,어떤 고충을 해결했을까?핵심 경쟁력은 대체 무엇일까?여러분도 생각해 본 적이 있다면 이런 의문을 품었을 것이다。괜찮다,필자가 이미 답을 찾아 두었다。

결론:CompletableFuture의 핵심 경쟁력은 작업 오케스트레이션。CompletableFuture는 Future 인터페이스의 특성을 상속하므로 작업의 동시 실행 등이 가능하지만, 이런 기능은 다른 수단으로도 대체할 수 있습니다. 그러나 작업을 조율하는 기능은 대체할 수 없습니다. 핵심 API에 포함된 작업 체인 구성, 작업 결과 병합 등은 모두 작업 조율을 위해 설계되었습니다. 따라서 JDK가 이 버전에서 이 프레임워크를 도입한 주된 이유는 비즈니스 개발에서 점점 더 큰 골칫거리가 되는 작업 조율 요구를 해결하기 위해서입니다.

마지막으로 한마디 덧붙이자면, CompletableFuture는 내부적으로 Fork/Join 프레임워크를 사용해 구현되어 있다.

Stream

《클린 아키텍처》에서는 구조적 프로그래밍(절차 지향 프로그래밍)、객체 지향 프로그래밍、함수형 프로그래밍이라는 세 가지 프로그래밍 패러다임을 언급한다. Stream은 Java 언어에서 함수형 프로그래밍이 구현된 한 형태다. 필자는 초급 개발자가 중급으로 나아가기 위해 반드시 거쳐야 할 길이 Stream을 익히는 것이라고 생각한다. 처음 Stream을 접하면 분명 매우 낯설겠지만, 익숙해지고 나면 프로그래밍 방식에 대한 새로운 시각이 열릴 것이다. 개발자들은 함수형 프로그래밍、Stream、Lambda 표현식이라는 세 개념을 자주 혼동하며, 모두 같은 것을 가리킨다고 생각하곤 한다. 이에 대한 필자의 이해는 다음과 같다:

•함수형 프로그래밍은 프로그래밍 사상의 하나로, 여러 프로그래밍 언어에서 이 사상을 실천하고 있다

•Stream은 JDK8.0의 새로운 기능으로, 새로운 개념을 만들었다고 이해할 수도 있다. 목적은 함수형 프로그래밍이라는 사상에 부응하는 것으로, Stream을 통해 컬렉션 클래스에서 함수형 프로그래밍을 구현할 수 있다

•Lambda 표현식(lambda expression)은 익명 함수이며, 이를 통해 함수형 프로그래밍을 더 간결하고 효율적으로 표현할 수 있다

그렇다면 이렇게 많은 이야기를 했는데,Stream과 멀티스레드는 어떤 관계일까?Stream의 관련 병렬 메서드는 내부적으로 Fork/Join 프레임워크를 사용해 구현되어 있다。《Effective Java》에는 “Stream 병렬 처리를 신중하게 사용하라”라는 관련 조언이 있다。모든 병렬 처리가 하나의 공용 Fork/Join 풀에서 실행되므로,하나의 파이프라인이 비정상적으로 실행되면 관련 없는 다른 부분의 성능에도 악영향을 줄 수 있기 때문이다。

JDK 9.0

락 경합 메커니즘 개선

잠금 경합은 많은 Java 멀티스레드 애플리케이션의 성능을 제한합니다. 새로운 잠금 경합 메커니즘은 Java 객체 모니터의 성능을 개선했으며, JVM의 최대 처리량을 추정할 수 있는 여러 벤치마크(Volano 등)로 검증되었습니다. 실제로 새로운 잠금 경합 메커니즘은 22종의 서로 다른 벤치마크에서 모두 뛰어난 성적을 거두었습니다. 이 새로운 메커니즘이 Java 9에 적용된다면, 애플리케이션의 성능은 크게 향상될 것입니다. 간단히 설명하면 여러 스레드가 잠금을 놓고 경합할 때, 최적화 전에는 늦게 도착한 스레드가 모두 같은 표준 절차로 잠금을 기다립니다. 최적화 후에는 JVM이 최적화할 수 있는 일부 상황을 식별하면, 늦게 도착한 스레드가 곧바로 “VIP 통로” 방식으로 잠금 획득을 시도하도록 합니다.

자세한 설명은 다음을 참고: 경합이 발생한 락 해설 – 성능 관점에서의 접근

리액티브 스트림

리액티브 스트림(Reactive Streams)은 비차단 배압 방식으로 비동기 데이터 스트림을 처리하는 표준으로,필요한 연산과 개체를 설명하는 최소한의 인터페이스,메서드 및 프로토콜을 제공한다.

논블로킹 배압이란 무엇일까?배압은 back pressure를 줄여 부르는 말이다。간단히 말하면,생산자가 소비자에게 데이터를 푸시하다가 소비자가 더 이상 처리하지 못하면 이를 생산자에게 알리고,이때 생산자가 생산 속도를 낮추는 것이다。이 메커니즘은 블로킹 방식으로 구현하는 것이 가장 간단하며,즉 푸시할 때 압력 데이터를 바로 반환한다。논블로킹 방식으로 구현하면 설계가 복잡해지는 동시에 성능도 향상된다。 PS:중국어 번역어 ‘背压’는 잘 번역된 말이 아닌 것 같다,글자만 보고 뜻을 짐작하기 어렵다。‘反压’라고 하는 편이 더 낫지 않을까^\_^

소비자가 막대한 자원 압박(pressure)을 받아 중단될 수 있는 문제를 해결하려면, 빠른 데이터 흐름이 대상을 감당할 수 없는 상태로 몰아넣지 않도록 데이터 흐름의 속도를 제어해야 한다. 이를 흐름 제어(flow control)라고 한다. 따라서 역압, 즉 배압(back pressure)이 필요하며, 생산자와 소비자는 배압 메커니즘을 구현하여 상호 작용해야 한다. 이 배압 메커니즘은 비동기 논블로킹 방식이어야 한다. 동기 블로킹 방식이라면 소비자가 데이터를 처리하는 동안 생산자는 기다려야 하므로 성능 문제가 발생한다.

리액티브 스트림(Reactive Streams)은 일련의 개체, 인터페이스 및 상호 운용 메서드를 정의하여 논블로킹 배압을 구현하는 표준을 제시합니다. 서드파티는 이 표준에 따라 구체적인 솔루션을 구현하며, 대표적으로 Reactor,RxJava,Akka Streams,Ratpack 등이 있습니다.

JDK 10 스레드 로컬 핸드셰이크

Safepoint와 그 한계:

Safepoint는 Hotspot JVM에서 모든 애플리케이션을 멈추게 하는 메커니즘이다. JVM은 일부 저수준 작업을 수행하기 위해 Stop The World를 통해 애플리케이션 스레드를 모두 멈춰야 한다. 하지만 강제로 곧바로 중단하는 것이 아니라, 애플리케이션 스레드에 이제 멈춰야 한다는 명령 신호를 보낸다. 이때 애플리케이션 스레드가 Safepoint에 도달하면 명령에 따라 응답한다. Safepoint라는 이름이 붙은 이유도 여기에 있다. safe를 붙인 것은 JVM이 전역적으로 안전하게 수행해야 할 작업을 한다는 점을 강조하기 위해서다.

전역 안전 지점이 필요한 작업에는 다음이 포함된다: 1、가비지 컬렉션 일시 중지 2、코드 최적화 해제(Code deoptimization)。 3、코드 캐시 비우기。 4、클래스 파일 재정의 시(Class redefinition,예를 들어 핫 업데이트 또는 계측)。 5、편향 잠금 해제(Biased lock revocation)。 6、각종 디버깅 작업(예: 교착 상태 검사 또는 스택 트레이스 덤프 등)。

하지만 모든 스레드가 가장 가까운 safepoint에 도달해 멈추게 하는 것 자체에 상당한 시간이 걸린다. 게다가 모든 스레드를 멈추게 하는 것은 너무 무모하고 독단적이지 않을까. 이를 위해 Java10은 모든 스레드를 멈추지 않아도 되는 방식,즉 스레드 로컬 핸드셰이크(Thread Local Handshake)를 도입했다.

예를 들어 다음은 모든 스레드를 stop하지 않고도 처리할 수 있는 상황이다: 1、편향 잠금 해제. 이는 단일 스레드만 중지하면 편향 잠금을 해제할 수 있으며, 모든 스레드를 중지할 필요가 없다. 2、여러 종류의 서비스 지원용 조회가 VM 전체에 미치는 지연 영향을 줄인다. 예를 들어 Java 스레드가 많은 VM에서 모든 스레드의 스택 트레이스를 가져오는 작업은 느릴 수 있다. 3、신호(signals)에 대한 의존도를 줄여 더 안전한 스택 트레이스 샘플링을 수행한다. 4、이른바 비대칭 Dekker 동기화 기법을 사용해 Java 스레드와 핸드셰이크함으로써 일부 메모리 장벽을 없앤다. 예를 들어 G1과 CMS에서 사용하는 ‘조건부 카드 마킹 코드’(conditional card mark code)는 더 이상 ‘메모리 장벽’이 필요하지 않게 된다. 이렇게 되면 G1이 내보내는 ‘쓰기 장벽(write barrier)’을 최적화할 수 있고, ‘메모리 장벽’을 피하려는 분기들도 삭제할 수 있다.

JDK 15 편향 잠금 비활성화 및 폐기 예정 지정

편향 잠금을 왜 폐기해야 할까?과거 편향 잠금이 가져다주던 성능 향상은 이제 그다지 뚜렷하지 않다. 편향 잠금의 이점을 누리는 애플리케이션은 대개 초기 Java 컬렉션 API(JDK 1.1)를 사용하는 프로그램이며, 이러한 API(Hashtable과 Vector)는 접근할 때마다 동기화를 수행한다. JDK 1.2에서는 단일 스레드 환경을 위한 비동기화 컬렉션(HashMap과 ArrayList)이 도입되었고, JDK 1.5에서는 다중 스레드 환경을 위한 성능이 더 뛰어난 동시성 자료 구조가 나왔다. 이는 불필요한 동기화 때문에 편향 잠금의 이점을 누리던 애플리케이션도 코드를 최신 클래스를 사용하도록 갱신하면 성능이 크게 향상될 수 있다는 뜻이다. 또한 스레드 풀 큐와 작업 스레드를 중심으로 구성된 애플리케이션은 대개 편향 잠금을 비활성화했을 때 성능이 더 좋아진다.

다음은 Hashtable과 Vector의 API를 사용한 구현입니다: java.lang.Classloader Vector 사용 java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL Hashtable 사용 java.net.URConnection Hashtable 사용 java.util.ZipOutputStream Vector 사용 javax.management.timer.TimerMBean 인터페이스에 Vector가 있음

자율주행 시대

가상 스레드는 Java를 자율주행 시대로 이끌었다. Go, C#, Erlang, Lua 등 많은 언어에는 “가상 스레드”와 비슷한 기술이 있으며, 이를 “코루틴”이라고 부른다. 이번에 Java는 어떤 키워드도 추가하지 않았고, 심지어 새로운 개념도 추가하지 않았다. 가상 스레드는 goroutine이나 코루틴보다 훨씬 이해하기 쉬워서 이름만 봐도 대략 무엇을 하는지 알 수 있다.

JDK 19 가상 스레드

기존 Java의 스레드 모델은 운영체제와 1:1 로 대응하며, 스레드 생성과 전환 비용이 매우 크고 운영체제의 제약으로 인해 제한된 개수만 생성할 수 있다。동시 요청이 매우 많으면 요청마다 스레드를 하나씩 생성할 수 없다。스레드 풀을 사용하면 문제를 완화할 수 있다. 스레드 풀은 스레드 생성에 드는 비용을 줄이지만, 스레드 수를 늘려 주지는 못한다。동시 요청 수가 2000이고 스레드 풀에 스레드가 1000개뿐이라면, 같은 시점에 처리할 수 있는 요청은 1000개뿐이며 나머지 1000개는 처리할 수 없다. 이 요청들은 거절하거나, 스레드가 반환될 때까지 기다리게 할 수 있다。

가상 스레드 이전에는 비동기 방식을 사용했다. 이미 많은 프레임워크가 비동기 방식의 동시성 프로그래밍을 구현했으며(예: Spring5의 Reactor), 스레드 공유를 통해 가용성을 높였다. 스레드를 공유하여 스레드 전환을 줄이고 자원 소모를 낮추는 동시에 블로킹도 피하는 원리다. 프로그램이 실행될 때만 스레드를 사용하고, 대기해야 할 때는 스레드를 점유하지 않는다. 비동기 방식은 분명 여러 개선을 가져왔지만 단점도 있다. 대부분의 비동기 프레임워크는 체이닝 문법을 사용하여 프로그램을 여러 단계로 나누며, 각 단계는 서로 다른 스레드에서 실행될 수 있다. 따라서 익숙한 ThreadLocal 같은 동시성 프로그래밍 관련 API를 그대로 사용하면 오류가 발생할 수 있다. 프로그래밍 방식도 크게 바뀌어 기존 방식보다 훨씬 복잡하고 학습 비용도 높다. 프로젝트의 기존 모듈 상당수를 비동기 방식에 맞도록 수정해야 할 수도 있다.

가상 스레드의 구현 원리는 일부 비동기 프레임워크와 비슷하다. 역시 스레드를 공유하므로 당연히 풀링할 필요도 없다. 사용할 때는 가상 스레드가 무한히 풍부하다고 생각해도 된다. 원하는 만큼 생성하면 되며 문제가 생길까 걱정할 필요가 없다. 그뿐만 아니라 가상 스레드는 디버깅을 지원하며 Java 관련 모니터링 도구로도 지원되는데, 이는 매우 중요하다. 가상 스레드는 프로그램의 메모리 사용량을 크게 줄여 주며, 웹 서버와 같은 모든 I/O 집약적 애플리케이션은 동일한 하드웨어 환경에서 I/O 처리량을 크게 높일 수 있다. 기존에는 1G 메모리로 동시에 1000건의 접속을 수용할 수 있었다면, 가상 스레드를 사용한 뒤에는 공식 설명에 따르면 동시 접속 100만 건을 쉽게 처리할 수 있다. 구체적인 업무 환경에서도 감당할 수 있는지는 부하 테스트를 해 봐야 하지만, 수치를 낮춰 잡더라도 10만 건은 쉽게 달성할 수 있을 것이다. 이를 위해 별도의 대가를 치를 필요가 없으며, 코드를 수정할 필요조차 없을 수도 있다. 가상 스레드를 사용하면 요청 하나당 스레드 하나를 사용하는 기존 프로그래밍 방식을 유지할 수 있기 때문이다. 일반 스레드처럼 가상 스레드를 사용하면 되므로 프로그램은 아주 조금만 수정하면 된다. 가상 스레드는 새로운 문법도 도입하지 않았으므로, 학습과 전환 비용이 매우 낮다고 할 수 있다.

가상 스레드도 내부적으로 여전히 Fork/Join 프레임워크를 사용한다는 점은 언급할 만합니다.


댓글

보내는 중

원하는 테마를 선택하세요

선택한 테마가 사이트 전체에 적용됩니다.