From dd60f31be1d332f57d552ce2ca5b0a693768e0c2 Mon Sep 17 00:00:00 2001 From: Yongbum Kim Date: Mon, 29 Jun 2026 22:57:32 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20JVM=20=EB=B0=91=EB=B0=94=EB=8B=A5?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=ED=8C=8C=ED=97=A4=EC=B9=98=EA=B8=B0=2013?= =?UTF-8?q?=EC=9E=A5=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\275_\354\265\234\354\240\201\355\231\224" | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 "JVM \353\260\221\353\260\224\353\213\245\352\271\214\354\247\200 \355\214\214\355\227\244\354\271\230\352\270\260/\352\271\200\354\232\251\353\262\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224" diff --git "a/JVM \353\260\221\353\260\224\353\213\245\352\271\214\354\247\200 \355\214\214\355\227\244\354\271\230\352\270\260/\352\271\200\354\232\251\353\262\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224" "b/JVM \353\260\221\353\260\224\353\213\245\352\271\214\354\247\200 \355\214\214\355\227\244\354\271\230\352\270\260/\352\271\200\354\232\251\353\262\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224" new file mode 100644 index 0000000..ec56c1c --- /dev/null +++ "b/JVM \353\260\221\353\260\224\353\213\245\352\271\214\354\247\200 \355\214\214\355\227\244\354\271\230\352\270\260/\352\271\200\354\232\251\353\262\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224/13\354\236\245_\354\212\244\353\240\210\353\223\234_\354\225\210\354\240\225\354\204\261\352\263\274_\353\235\275_\354\265\234\354\240\201\355\231\224" @@ -0,0 +1,289 @@ +12장에서 메모리 모델과 스레드를 다뤘다면, 13장은 "그래서 여러 스레드가 같은 데이터를 만질 때 어떻게 안전하게, 그리고 빠르게 처리할 것인가"를 다룬다. 백엔드 서버는 결국 수많은 요청을 동시에 처리하는 일이고, 동기화는 정확성을 지켜주는 대신 성능을 깎아먹는 양날의 검이다. 이번 장은 그 "정확성을 지키는 방법"과 "JVM이 동기화 비용을 줄이려고 어떤 최적화를 하는지"를 순서대로 보여준다. 성능 최적화에 관심이 많은 입장에서 가장 흥미로웠던 챕터라 정리해본다. + +13.1 들어가며 + +소프트웨어 초창기에는 알고리즘이 중심이었고, 개발자는 데이터(문제 공간의 개체)와 절차(그 데이터를 처리하는 코드)를 구분해서 생각했다. 이게 절차 지향이다. 반대로 객체 지향은 데이터와 행위를 합쳐 객체로 표현하고, 현실 세계를 그대로 모델링한다. + +문제는 현실 세계와 컴퓨터 세계가 완전히 같을 수 없다는 점이다. 객체가 작업 도중에 다른 객체로 전환되거나, 작업이 잠시 멈춘 사이에 객체의 상태(데이터)가 바뀌어 버리는 일은 현실에서는 상상하기 어렵지만 컴퓨터에서는 흔하다. 멀티스레드 환경이 바로 그런 세계다. + +그래서 좋은 설계 원칙이라도 현실과 타협해야 한다. 순서는 항상 두 단계다. + +올바르게 실행되는지 먼저 확인한다 (정확성을 완벽하게 보장) + +그 다음에 최적화한다 (효율적인 흐름) + +이번 장도 이 순서를 따른다. 동시성을 '완벽하게 보장하는 법'을 먼저 이야기하고, 그 위에서 효율을 높이는 흐름으로 진행한다. + +13.2 스레드 안전성 + +스레드 안전성의 정의는 의외로 까다롭다. 인터넷에 흔히 떠도는 "여러 스레드에서 동시에 안전하게 쓸 수 있으면 스레드 안전"이라는 말은 틀린 건 아니지만 실무에 쓰기엔 정보가 부족하다. 이 책은 《Java Concurrency In Practice》의 저자 브라이언 게츠(Brian Goetz)의 정의를 채택한다. + +여러 스레드가 한 객체에 동시에 접근할 때, 어떤 런타임 환경에서든 다음 두 조건을 모두 충족하면서 객체를 호출하는 행위가 올바른 결과를 얻을 수 있다면, 그 객체는 스레드 안전하다. + +특별한 스레드 스케줄링이나 대체 실행 수단을 고려할 필요가 없다. + +추가적인 동기화 수단이나 호출자 측에서의 조율이 필요 없다. + +여기서 핵심은 "호출자가 아무것도 하지 않아도 안전해야 한다"는 것이다. 동기화 장치를 코드 자체에 완벽히 내장해서, 멀티스레드인지 아닌지 호출자가 신경 쓸 필요가 없어야 한다는 뜻이다. 이게 생각보다 엄격한 조건이라 뒤에서 다시 누그러뜨린다. + +자바에서 말하는 스레드 안전성은 결국 여러 스레드가 공유하는 데이터에 대한 이야기다. 다른 스레드와 아무 데이터도 공유하지 않는다면 동시 실행이든 순차 실행이든 차이가 없다. + +13.2.1 자바 언어의 스레드 안전성 + +자바는 공유 데이터의 "안전한 정도"를 다섯 단계로 나눈다. 안전한 순서대로 보면 이렇다. + +① 불변(Immutable) + +불변 객체는 아무런 안전장치 없이도 그 자체로 스레드 안전하다. 불변성이 선사하는 안전성은 가장 직접적이고 완전무결하다. JDK 5 이후 자바 메모리 모델이 개정되면서, 불변 객체가 올바르게 만들어지기만 하면(생성자에서 this 참조가 탈출하지 않으면) 외부에서 보이는 상태는 절대 달라지지 않는다. + +구현 방법은 간단하다. 상태에 해당하는 모든 변수를 final로 선언하면 생성자 종료 후엔 값을 바꿀 수 없다. + +java.lang.Integer는 내부 value를 private final int로 정의했다. + +String도 대표적인 불변 객체다. substring(), replace(), concat()은 원본을 두고 새 String을 반환한다. + +열거(enum) 타입, Long/Double/BigInteger/BigDecimal 같은 Number 하위 클래스 대부분도 불변이다. + +단, AtomicInteger와 AtomicLong은 불변이 아니다. (값을 직접 읽고 변경할 수 있도록 설계됐기 때문) + +② 절대적 스레드 안전(Absolute thread-safe) + +브라이언 게츠의 정의를 완벽하게 충족하는 단계다. "호출자가 추가 동기화를 할 필요가 절대 없다"는 조건을 만족시키려면 비용이 너무 크거나 비현실적이다. 그래서 자바 API에서 "스레드 안전하다"고 표시된 클래스 대부분이 사실은 절대적 스레드 안전이 아니다. + +대표 예가 java.util.Vector다. add(), get(), size()가 전부 synchronized라 스레드 안전해 보이지만, 멀티스레드에서 호출자가 추가 동기화 없이 쓰면 여전히 깨진다. + +// 한 스레드는 계속 remove, 다른 스레드는 계속 get/print +for (int i = 0; i < vector.size(); i++) { // ② size로 크기를 얻고 + System.out.println(vector.get(i)); // ③ 그 사이 다른 스레드가 remove하면 +} // → ArrayIndexOutOfBoundsException + +size()로 크기를 얻은 다음, get()이 실행되기 전에 다른 스레드가 원소를 제거하면 인덱스가 범위를 벗어나 예외가 터진다. 메서드 하나하나는 동기화돼 있지만, 메서드를 연달아 호출하는 구간은 보호되지 않는다. 진짜 절대적 스레드 안전이 되려면 일관된 스냅숏을 유지해야 하는데, 원소가 바뀔 때마다 새 스냅숏을 만들면 성능·메모리 비용이 너무 크다. + +③ 조건부 스레드 안전(Conditional thread-safe) + +우리가 보통 "스레드 안전하다"고 말할 때의 수준이다. 단일 작업(메서드)은 별도 보호 없이 안전하지만, 특정 순서로 연달아 호출하는 상황에서는 호출자가 추가 동기화를 해줘야 한다. 위 Vector 예제가 딱 이 경우이고, 해결책은 호출 구간을 synchronized (vector) { ... }로 감싸는 것이다. + +Vector, HashTable, Collections.synchronizedXxx()로 래핑한 컬렉션이 대체로 여기에 속한다. + +④ 스레드 호환(Thread-compatible) + +객체 자체는 스레드 안전하지 않지만, 호출자가 동기화 조치를 잘 취하면 멀티스레드에서도 안전하게 쓸 수 있다. 자바 클래스 대다수가 여기 속한다. ArrayList, HashMap이 대표적이다. + +조건부와 호환의 차이가 헷갈렸는데, 정리하면: 조건부는 단일 호출은 그냥 안전(연속 호출만 신경), 호환은 단일 호출조차 호출자가 책임진다. + +Vector → ArrayList (스레드 호환 버전) + +HashTable → HashMap (스레드 호환 버전) + +⑤ 스레드 적대적(Thread-hostile) + +호출자가 동기화 조치를 취하더라도 멀티스레드에서 안전하게 쓸 수 없는 코드다. 해롭기 때문에 피해야 한다. 자바는 처음부터 스레드를 지원한 덕에 적대적 코드는 드물다. + +Thread.suspend() / resume(): 한 스레드는 suspend(), 다른 스레드는 resume()을 시도하면 교착 상태에 빠진다. 동기화해도 소용없어서 폐기 대상이 됐다. + +System.setIn(), System.setOut(), System.runFinalizersOnExit()도 스레드 적대적이다. + +13.2.2 스레드 안전성 구현 + +스레드 안전성은 대부분 코드 작성법의 문제지만, 가상 머신이 제공하는 동기화와 락 메커니즘도 중요한 역할을 한다. + +상호 배제 동기화(Mutual exclusion) + +가장 일반적이고 중요한 동시성 보장 수단이다. 핵심 의미는 "공유 데이터에 여러 스레드가 접근하려는 상황에서, 그 어떤 시점에든 단 하나의 스레드만 데이터를 사용할 수 있다"는 것이다. (세마포어를 쓰면 n개까지 허용) + +뮤텍스가 대표적인 수단이고, 임계 영역(크리티컬 섹션)과 세마포어도 쓰인다. 정리하면 상호 배제가 원인/수단이고, 동기화가 결과/목적이다. + +자바에서 가장 기본적인 수단은 synchronized 키워드다. javac가 컴파일하면 monitorenter와 monitorexit 바이트코드가 생성되어 동기화 블록 전후에 실행된다. + +monitorenter 실행 시 객체의 락을 얻으려 시도한다. 락을 얻으면 카운터를 1 증가시킨다. + +monitorexit 실행 시 카운터를 1 감소시킨다. 카운터가 0이 되면 락이 해제된다. + +여기서 두 가지 중요한 결론이 나온다. + +같은 스레드는 동기화 블록에 다시 진입할 수 있다(재진입). 즉, 락을 이미 소유한 스레드는 여러 번 진입해도 블록되지 않는다. + +락을 소유한 스레드가 작업을 마치고 락을 해제할 때까지 다른 스레드의 진입을 무조건 차단한다. 강제로 락을 해제시킬 방법도, 기다리는 스레드를 인터럽트해 깨울 방법도 없다. + +그래서 synchronized는 매우 주의해서 써야 한다. 결정적으로 락을 소유하는 일은 실행 비용 측면에서 상당히 무거운 작업이다. 핫스팟은 플랫폼 스레드를 OS 커널 스레드와 매핑하기 때문에, 스레드를 정지·재개하려면 운영 체제의 도움(사용자 모드 ↔ 커널 모드 전환)이 필요하다. 게터/세터처럼 짧은 코드를 synchronized로 묶으면 코드 실행보다 모드 전환에 더 긴 시간이 든다. 그래서 경험 많은 개발자는 꼭 필요할 때만 제한적으로 쓴다. + +가상 머신도 나름의 최적화를 한다. 스레드를 블록하기 전에 바쁜 대기(busy waiting / spinning)를 잠깐 넣어 모드 전환이 자주 일어나지 않게 한다. (이게 뒤에 나올 스핀 락의 기반) + +JDK 5부터는 java.util.concurrent 패키지가 추가되고, 그 안의 Lock 인터페이스가 새로운 상호 배제 동기화를 제공한다. 언어 특성(족쇄)을 벗어나 클래스 라이브러리 수준에서 동기화를 구현했다는 게 핵심이다. 대표 구현이 ReentrantLock이고, synchronized와 사용법은 비슷하지만 몇 가지 진보된 기능이 있다. + +대기 중 인터럽트: 락을 오래 잡고 있는 스레드가 있으면, 대기 중인 다른 스레드가 락을 포기하고 다른 일을 할 수 있다. 동기화 블록이 길 때 유용하다. + +페어 락(Fair lock): 대기 스레드가 많을 때 락 획득을 시도한 시간 순서대로 락을 얻는다. synchronized는 언페어(unfair)다. ReentrantLock도 기본은 언페어지만 생성자에서 페어로 설정할 수 있다. 단, 페어 락은 성능(처리량)이 급격히 떨어지니 주의. + +둘 이상의 조건 지정: newCondition()을 여러 번 호출해 여러 Condition 객체를 만들 수 있다. + +성능은? JDK 5까지는 synchronized가 크게 뒤처졌지만, JDK 6에서 락 최적화가 도입되면서 거의 같아졌다. 그래서 둘 중 무엇을 고를지 성능은 더 이상 고려 대상이 아니다. 저자는 둘 다 쓸 수 있는 상황이면 synchronized를 권한다. + +synchronized는 자바 구문 수준이라 명확하고 간결하다. + +Lock은 반드시 finally 블록에서 해제해야 한다. 안 그러면 예외 발생 시 락이 영영 안 풀릴 수 있다. synchronized는 예외가 나도 가상 머신이 해제를 보장한다. (이건 실무에서 정말 중요한 차이라고 느꼈다) + +길게 보면 동기화 최적화는 가상 머신에 맡기는 게 유리하다. synchronized를 쓰면 VM이 어느 스레드가 어느 락을 소유했는지 알 수 있지만, Lock은 그렇지 못하다. + +논블로킹 동기화(Non-blocking synchronization) + +상호 배제의 큰 문제는 스레드 일시 정지·재개가 일으키는 성능 저하다. 이건 "일단 락부터 걸고 본다"는 비관적 동시성 전략에 속한다. + +반대로 하드웨어 명령어가 발전하면서 낙관적 동시성 전략이 생겼다. "충돌이 안 날 거다"라고 가정하고 일단 작업을 진행하다가, 공유 데이터를 두고 경합한 스레드가 없으면 성공, 혹시라도 충돌하면 보완 조치(경합이 없을 때까지 재시도)를 취한다. 스레드를 멈추지 않으니 논블로킹이고, 이런 코딩을 락프리 프로그래밍이라고 한다. + +이게 가능하려면 "작업 진행"과 "충돌 감지"가 한 명령어처럼 원자적으로 수행돼야 한다. 그래서 하드웨어 명령어의 도움이 필요하다. + +TAS(Test-and-Set), FAA(Fetch-and-Add), Swap → 20세기 프로세서들도 대부분 지원 + +CAS(Compare-and-Swap), LL/SC(Load-Linked/Store-Conditional) → 비교적 최근에 추가 + +자바에서 직접 쓸 수 있는 건 CAS다. CAS는 피연산자 세 개를 받는다. + +V: 메모리 위치 + +A: 예상하는 이전 값 + +B: 새로 설정할 값 + +V의 값이 A와 같으면 V를 B로 갱신하고, 다르면 갱신하지 않는다. 그리고 갱신 여부와 상관없이 A를 반환한다. 이 전체가 원자적이라 중간에 다른 스레드가 끼어들 수 없다. + +JDK 5부터 sun.misc.Unsafe의 compareAndSwapInt() 등으로 CAS를 쓰기 시작했고, 핫스팟은 이걸 내장 함수 최적화로 처리해 메서드 호출을 없애고 프로세서 CAS 명령어로 바꾼다(무조건 인라인). 다만 Unsafe는 사용자 프로그램용이 아니라서, JDK 9의 VarHandle이 나오기 전까지는 자바 클래스 라이브러리(예: AtomicInteger)를 거쳐 간접적으로만 쓸 수 있었다. + +public static AtomicInteger race = new AtomicInteger(0); +race.incrementAndGet(); // 내부적으로 CAS 루프를 돌며 원자적 증가 + +CAS의 ABA 문제: 변수 V를 읽었을 때 A였고, 갱신 직전에도 여전히 A라면 정말 안 바뀐 걸까? 그 사이 A→B→A로 돌아왔다면 CAS는 "한 번도 안 바뀌었다"고 오해한다. java.util.concurrent.atomic.AtomicStampedReference가 버전(스탬프)으로 이를 해결한다. 다만 대부분의 경우 ABA는 정확성에 영향을 주지 않아서, 정말 해결해야 한다면 차라리 기존 상호 배제 동기화가 더 효율적일 때가 많다. + +동기화가 필요 없는 메커니즘 + +아무 장치 없이도 스레드 안전한 경우가 있다. 동기화는 어디까지나 공유 데이터에 경합이 생겼을 때 정확성을 보장하는 수단일 뿐, 공유 데이터를 아예 안 쓰면 동기화도 필요 없다. + +재진입 코드(순수 코드): 실행 중간에 끼어들어 다른 코드를 수행하고 와도(재귀 포함) 결과에 영향이 없는 코드. 전역 변수·힙 데이터·공유 자원을 안 쓰고 필요한 모든 정보를 매개변수로 받는다. 같은 입력에 항상 같은 결과를 반환하면 재진입 가능하고 당연히 스레드 안전하다. + +스레드 로컬 저장소(ThreadLocal): 공유 데이터의 가시 범위를 한 스레드로 제한한다. 요청 하나당 스레드 하나를 배정하는 고전적 웹 서버가 대표 예다. 자바에서는 ThreadLocal 클래스로 스레드별 저장소를 만든다. 각 Thread 객체가 ThreadLocalMap을 하나씩 갖는 구조다. + +13.3 락 최적화 + +JDK 6에서 동시성 효율이 크게 개선됐다. 적응형 스핀, 락 제거, 락 범위 확장, 경량 락, 편향 락 등 다양한 락 최적화 기술이 들어갔다. 목표는 하나 — 데이터를 더 효율적으로 공유하고 경합 문제를 줄여 실행 효율을 높이는 것. + +13.3.1 스핀 락과 적응형 스핀 + +상호 배제가 성능에 악영향을 주는 주범은 블로킹(커널 모드 전환)이다. 그런데 분석해 보니 수많은 애플리케이션이 공유 데이터를 아주 잠깐만 잠갔다 곧바로 푼다. 이 찰나에 스레드를 정지·재개하는 건 낭비다. + +요즘은 멀티코어라 다른 코어에서 락이 풀리는지 지켜볼 수 있으니, 스레드를 멈추지 않고 루프를 돌며(spin) 기다린다. 이게 스핀 락이다. + +JDK 1.4.2에 도입(-XX:+UseSpinning), JDK 6부터 기본 활성화. + +블로킹을 완전히 대체하진 못한다. 스레드 전환 부하는 없지만 프로세서 시간을 소비하므로, 락이 잠깐 잠길 때만 유리하고 오래 잠기면 자원 낭비가 된다. + +기본 최대 스핀 횟수는 10회(-XX:PreBlockSpin). + +적응형 스핀(JDK 6): 스핀 시간을 고정하지 않고, 같은 락의 이전 스핀 시간과 락 소유자 상태에 따라 결정한다. 직전 스핀이 성공했다면 이번에도 성공 가능성이 높다고 보고 더 길게 시도하고, 거의 실패했다면 아예 스핀을 생략한다. 프로그램이 쌓아온 성능 모니터링 정보를 활용해 점점 정확하게 예측한다. + +13.3.2 락 제거 + +JIT 컴파일러가 탈출 분석으로 "이 코드엔 데이터 경합이 없다"고 판단하면 락을 제거하는 최적화다. + +대표 예가 문자열 합치기다. + +public String concatString(String s1, String s2, String s3) { + return s1 + s2 + s3; +} + +String이 불변이라 javac는 이걸 StringBuilder(또는 옛날엔 StringBuffer)의 append() 연속 호출로 바꾼다. StringBuffer.append() 안에는 synchronized가 있고 sb 객체가 락으로 쓰이지만, sb는 메서드 바깥으로 탈출하지 않아 다른 스레드가 접근할 일이 없다. 그래서 JIT 컴파일 후엔 동기화 장치를 제거하고 더 빠르게 실행한다. + +13.3.3 락 범위 확장(락 거칠게 하기) + +원칙적으로 동기화 블록은 가능한 좁게 잡는 게 좋다. 경합이 생겨도 락을 짧게 쓰고 넘겨주면 전체 대기 시간이 줄기 때문이다. + +하지만 똑같은 락 객체를 반복해서 잠그고 푸는 상황(예: 루프 안의 연속 append())이라면, 경합이 없어도 상호 배제가 빈번하게 일어나 의미 없이 성능만 깎인다. 이럴 때 가상 머신은 락의 유효 범위를 첫 append 직전부터 마지막 append 직후까지 한 번으로 확장해 락을 단 한 번만 얻도록 한다. 13.3.2의 좁히기와 정반대 방향이지만, 둘 다 "불필요한 동기화 비용을 줄인다"는 목적은 같다. + +13.3.4 경량 락 + +JDK 6에 추가됐다. OS 뮤텍스를 쓰는 기존 락(중량 락)보다 가볍다는 뜻이다. 중량 락을 대체하려는 게 아니라, 경합이 없을 때 중량 락의 성능 저하를 줄이는 게 목적이다. + +이해하려면 객체 헤더의 마크 워드를 알아야 한다. 객체 헤더는 두 부분으로 나뉜다. + +해시 코드, GC 세대 나이 등 객체 자신의 런타임 데이터 (= 마크 워드, 경량/편향 락의 핵심) + +메서드 영역의 타입 데이터를 가리키는 포인터 (배열이면 길이도 추가) + +마크 워드는 공간을 아끼려고 객체 상태에 따라 저장 내용을 재사용한다. 락 상태는 마지막 2비트(락 플래그)로 구분한다. + +락 상태 + +락 플래그 + +저장 내용 + +잠겨 있지 않음 + +01 + +해시 코드, 세대 나이 + +경량 락 + +00 + +호출 스택의 락 레코드를 가리키는 포인터 + +중량 락(확장) + +10 + +중량 락(뮤텍스)을 가리키는 포인터 + +GC 플래그 + +11 + +공백 + +편향 가능 + +01 + +스레드 ID, 에폭, 세대 나이 + +동작 흐름 + +잠겨 있지 않은(01) 객체에 진입하려 할 때, 스택 프레임에 락 레코드(마크 워드 복사본)를 만든다. + +CAS로 객체의 마크 워드를 락 레코드 포인터로 바꾼다. 성공하면 락 획득, 플래그를 00(경량 락)으로 바꾼다. + +실패하면 경합하는 스레드가 있다는 뜻이다. 마크 워드가 현재 스레드의 스택을 가리키면 이미 자신이 가진 것(재진입), 아니면 중량 락(10)으로 확장하고 대기 스레드는 블록된다. + +해제도 CAS로 한다. 마크 워드와 옮겨둔 복사본을 교체한다. 실패하면 경합이 있었다는 뜻이라 락 해제와 동시에 대기 스레드를 깨운다. + +경량 락은 "대부분의 락은 경합을 겪지 않는다"는 경험 법칙에 기댄다. 경합이 없으면 뮤텍스 없이 CAS만으로 끝나지만, 경합이 있으면 뮤텍스 부하에 CAS 부하까지 더해져 오히려 중량 락보다 느려진다. + +13.3.5 편향 락 + +JDK 6에 도입됐다. 경량 락이 경합 없을 때 CAS로 뮤텍스를 피했다면, 편향 락은 한 걸음 더 나아가 CAS조차 없앤다. + +'편향'은 락을 마지막에 썼던 스레드 쪽으로 락을 '찜'해 둔다는 의미다. 다음 실행까지 다른 스레드가 락을 안 건드리면, 직전에 쓴 스레드는 다시 동기화할 필요가 없다. + +어떤 스레드가 락 객체를 처음 획득하면 VM이 락 플래그를 01, 편향 모드를 1로 설정하고, CAS로 스레드 ID를 마크 워드에 기록한다. + +이후 그 스레드는 아무 동기화 작업 없이 동기화 블록에 몇 번이고 진입할 수 있다. + +다른 스레드가 락을 시도하는 즉시 편향 모드가 종료되고, 상태에 따라 잠겨 있지 않음(01)이나 경량 락(00)으로 돌아간다. + +해시 코드 충돌 문제: 객체가 편향 상태일 때 마크 워드의 23비트가 스레드 ID로 쓰이는데, 원래 이 공간은 해시 코드 자리다. 그래서 Object::hashCode()나 System::identityHashCode()로 신원 해시 코드 계산을 요청받으면 편향 모드가 즉시 풀리고 중량 락으로 전환된다. (단, hashCode()를 오버라이딩했다면 이 문제는 안 생긴다) + +편향 락이 무조건 이득은 아니다. 락 대부분을 여러 스레드가 경쟁적으로 얻으려 하면 오히려 불필요한 작업만 늘어난다. 혜택을 보는 건 주로 Vector/Hashtable 같은 초기 컬렉션을 쓰는 레거시 프로그램이다. JDK 1.2 이후 컬렉션 API나 동시성 API를 쓰면 불필요한 경합 자체가 줄어든다. 게다가 로직이 너무 복잡해 가상 머신의 동기화 서브시스템 설계를 개선하는 데 방해가 됐다. 그래서 편향 락은 JDK 15에서 폐기 예정으로 지정되고 JDK 18에서 제거됐다. + +13.4 마치며 + +이번 장은 스레드 안전성의 개념과 다섯 단계 분류, 동기화 구현 방법(상호 배제 / 논블로킹 / 무동기화), 그리고 JDK 6의 락 최적화 기술들을 다뤘다. + +성능과 확장성이 뛰어난 동시성 프로그램을 짜는 일은 시스템 내부 구현을 이해해야만 능숙해지는 고급 기술이다. 백엔드에서 동시성은 결국 처리량(TPS)과 직결되므로, 고급 개발자로 성장하려면 반드시 체득해야 할 영역이라고 느꼈다. + +synchronized vs ReentrantLock: 기능은 Lock이 우세(인터럽트·페어·다중 Condition)하지만, 예외 시 자동 해제·VM 최적화 가시성 때문에 기본은 synchronized. Lock은 finally 해제 필수. + +5단계 분류에서 ArrayList/HashMap이 "스레드 호환", Vector/HashTable이 "조건부 스레드 안전"인 이유를 구분해서 설명할 수 있어야 함. + +CAS와 ABA 문제 → AtomicStampedReference로 연결. + +JDK 6 락 최적화 4종(스핀/제거/확장/경량·편향)이 "경합이 적다"는 가정 위에 서 있다는 점, 편향 락은 JDK 18에서 제거됐다는 최신 변화 내용 \ No newline at end of file