요즘 참 공부를 안하고 살았다.
그냥 안한게 아니라 어떤 공부를 하는게 맞는지, 뭘 해야 하는지도 모르겠어서 더 문제였다.
사실 개인적으로 스트레스도 워낙 많았기 때문에... 더 손에 잡히지 않기도 했다.
암튼 기회가 있어서 virtual thread 쪽을 좀 파보는데, 이게 좀 이상한 부분이 있었다.
Thread Pool 이 고갈되는 것을 막아주는 데에 큰 역할을 하는데 synchronized 블록을 사용한 상태에서 Block 되면 VT가 Carrier Thread에 고정되어 버린다는것.
그래서 잠깐 고민을 해봤는데 synchronized 는 그럼 못쓰는건가? Atomic Class 는 CAS니까 어차피 unmount 대상도 아니고, Concurrent 는 어떨까 싶었다.
이전에 JDK에 대해 공부할 때에 ConcurrentHashMap 이런 애들이 Synchronized 로 중간에 세그먼트를 막는걸 봤던 것 같아서 그럼 Concurrent 도 못쓴다고? 해서 찾아봤다.
결론부터 말하자면 되게 애매하다. 이슈는 있지만 쓰고 있고, 그 이슈를 고치고 있다는 느낌?
짧고 드문 경우는 사용해도 되고 너무 장시간이 되지 않으면 된다는 것 같다.
처음부터 말하자면 다음과 같다.
Synchronized 의 변천사
1. JDK1.0 ~ 1.5 때에는 OS 의 락
JDK 초창기 시절에는 OS 의 Mutex 나 세마포어를 직접 호출했다고 한다.
그래서 락을 걸 때마다 무조건 OS 커널을 거쳐야 해서 이 때에는 거의 안썼다. (고 한다...)
2. JDK 6 ~ 8 락 활용
위에서 "락을 걸 때마다" 라고 했는데, 아마 락을 써본 사람들은 알겠지만 이게 생각보다 락 경합이 많지가 않다.
그 말은 즉 걸 때마다가 아니라 락 경합이 있을 때 관리하는게 낫다는 뜻이다.
그래서 락을 3단계로 나눠서 관리했고
- 편향 락
- 특정 스레드 하나만 락을 쓴다고 편향되게 추측
- 객체 헤더에 스레드의 ID만 적어두고, 그냥 ID 만 적혀있으면 통과
- 오버헤드가 거의 없다.
- 객체 헤더에 스레드의 ID만 적어두고, 그냥 ID 만 적혀있으면 통과
- 경량 락
- 다른 스레드가 해당 락을 쓰려고 할 때 승급
- OS 커널로 가지 않고 각 스레드의 스택 프레임에 락 레코드를 만들고 CAS 연산으로 객체 헤더의 주소 변경
- 그래서 CAS 를 계속 진행하면서 OS 대기 상태가 아니라 락이 풀렸는지 계속 확인
- OS 커널로 가지 않고 각 스레드의 스택 프레임에 락 레코드를 만들고 CAS 연산으로 객체 헤더의 주소 변경
- 중량 락
- 스핀락이 길어지거나 여러 스레드가 락을 쓰려할 떄 승급
- 이 때는 결국 OS 의 Mutex 를 사용하고 스레드는 실제로 OS 블로킹 큐에 들어가서 대기
이렇게 최적화된 덕분에 synchronized 방식도 매우 가벼워졌다.
3. JDK 15에서의 2단계 최적화
JDK 15부터는 다중 코어이기 때문에 오히려 편향 락 과정에서 오버헤드가 매우 커졌다.
이 문제점은 다음과 같은데
- 하나의 스레드만 객체를 쓴다고 생각하고 객체 헤더에 ID 기입
- 다중 코어라서 다른 스레드가 해당 객체에 접근
- 락이 걸리기 때문에 경량락 승격
- 그러면 최초에 객체 헤더에 기입된 스레드의 ID 를 제거해야 한다
- 문제는 최초 사용 스레드가 진짜 락을 다 쓴건지 아니면 아직 쓰고있는지를 모름
- JVM 이 최초 스레드의 모든 스택 프레임 직접 조회
- 스레드 스택을 보려면 STW 해야함
그래서 해결법은 되게 간단했다.
그냥 편향락을 Deprecated 해버린것
처음부터 경량락으로 만들어버리면 경함 시 CAS 로 스핀락 빙글빙글 하거나 아니면 중량락으로 넘어가면 됐다.
4. VT 에서의 피닝 문제
아까 위에서 잠깐 이야기했던게, VT 로 가면서 synchronized 의 사용을 지양하게 되었다는 것이다.
이게 synchronized 내에서 블로킹이 발생하고, 그러면 VT 가 unmount 되지 못하기 때문에 계속 스레드를 가지게 되는 것.
그래서 VT가 돌아가는 JDK21 부터는 피닝이 많이 발생하는 경우 synchronized 를 잘 안쓰게 되었다고 한다.
그럼 ReentrantLock 은 뭔데 괜찮은가?
- 이름 그대로(재진입성) 락을 획득한 스레드가 본인이면 락을 바로 통과하는 성질을 의미한다.
- 당연하면 당연한게 스레드가 락을 획득해놓고 자기가 락 놓기를 기다리면 데드락이 생긴다.
- 이 락은 AQS(AbstractQueuedSynchronizer) 기반으로 동작한다.
- 락 호출 시 AQS 의
state를 0에서 1로 변경하는 CAS 시도- CAS 성공하는 경우
exclusiveOwnerThread(락 소유 스레드)에 현재 스레드 등록 - CAS 실패하는 경우
exclusiveOwnerThread가 자기가 맞는지(스레드 확인) 보고- 맞으면
state를 1로 증가하고 진입 - 아니면 현재 스레드를 대기큐에 등록하고 대기 상태 전환
- VT라면 이 시점에 carrier thread 로 부터 unmount
- 맞으면
- CAS 성공하는 경우
- 락 호출 시 AQS 의
대충 이런 것이다.
보면 알겠지만 스레드를 대기큐에 등록하고 대기 상태로 전환하면 VT 를 unmount 하기 때문에 계속 블로킹되어 마운트되지 않는다.
근데 이거 딱봐도 뭔가 이슈가 있어 보이는데,
- 메모리 이슈
- 이거 만들 때마다 ReentrantLock 이라는 객체가 만들어지는 것이다. 많이 만들어지는 경우 메모리에 이슈가 생긴다.
ConcurrentHashMap 의 동작은?
JDK1.5 ~ 7 에 최초 도입
- 처음에는 ReentrantLock 을 사용했다고 한다.
- 전체 맵을 default 16개의 세그먼트로 나눠서 사용
- 전체적으로 락을 ReentrantLock 기반 사용
JDK8 에서 synchronized 조합으로 활용
위의 내용에서 살펴보면 synchronized 가 굉장히 좀 성능이 좋지 않아 보인다.
그래서 처음 ConcurrentHashMap 이 ReentrantLock 을 만들 때에는 그나마 성능에 괜찮은 ReentrantLock 으로 만들어졌다고 한다.
근데 이게 좀 바뀐 다음에는 synchronized 로 바꿨는데 그 이유는 ReentrantLock 이 가진 단점에서 비롯된다.
- 버킷(노드) 단위로 락을 걸어버린다.
- 즉 락을 거는 것이 세그먼트 단위에서 엄청 세분화되었다.
- Map 같은 애들은 사실 락이 걸리는 시점과 한계가 명확한 편이다.
- 해시 버킷에 데이터를 넣거나 빼는건 굉장히 금방 끝난다.
- concurrentHashMap 은 버킷마다 락을 건다.
- 그러면 그 버킷마다 ReentrantLock 이 생기고, 그거는 메모리를 왕창 잡아먹는다.
- synchronized 라면? 이렇게 객체가 왕창 생길 일이 없다.
- 그러면 그 버킷마다 ReentrantLock 이 생기고, 그거는 메모리를 왕창 잡아먹는다.
그래서 계륵같은 ReentrantLock 녀석을 빼고 synchronized 기반으로 최적화를 하는데,
- 빈 버킷에 데이터를 넣는 경우는 CAS
- 데이터가 존재하는 버킷에 데이터를 넣을 떄
- 해당 버킷의 첫번째 노드에만 synchronized 를 건다.
- 어차피 해시 충돌이 나는 경우 어차피 linkedList 나 tree 구조로 찾아가는데 첫번째를 반드시 통해야 하므로 여기서 걸면 하나만 오게됨.
- 해당 버킷의 첫번째 노드에만 synchronized 를 건다.
요렇게 해서 최적화를 해낸 것이다.
JDK21 에서는 synchronized 잘 안쓴다며 그럼 어캄?
- 나 말고도 비슷한 이야기를 많이들 했다고 한다.
- synchronized 부분에서 가상스레드 언마운트 안되면 어떡하지 이런
- 근데 그냥 쓰기로 함
- concurrentHashMap 에서 그 락을 갖고있는 시간은 포인터 연결 혹은 레드블랙트리 노드 추가정도의 매우 짧은 시간이여서 이슈 적음 (보통 실무에서 이런식으로 말하는 경우가 많은데 concurrentHashMap 같은 것도 이렇게 할줄은 몰랐다..)
- 근데 그러면 compute 나 merge 연산을 하는 경우는 블로킹이 많을텐데 그거는 어떡할까?
- 해결책을 모르겠음!
- 최대한 여기서는 short and simple 로 작성하도록 한다.
- 해결책을 모르겠음!
- 근데 그러면 compute 나 merge 연산을 하는 경우는 블로킹이 많을텐데 그거는 어떡할까?
- JDK24+ 에서 synchronized 내부의 피닝 문제가 해결돼서 괜찮다고 한다.
- concurrentHashMap 에서 그 락을 갖고있는 시간은 포인터 연결 혹은 레드블랙트리 노드 추가정도의 매우 짧은 시간이여서 이슈 적음 (보통 실무에서 이런식으로 말하는 경우가 많은데 concurrentHashMap 같은 것도 이렇게 할줄은 몰랐다..)
JDK24+ 에서는 또 synchronized 문제가 해결됨
synchronized 라는게 그렇게 쓰지마 하고 안쓸수 있는것도 아니고, 이거를 없애는것도 안돼서 고수 개발자들이 해결법을 고민했다고 한다.
- 기존 문제 : synchronized 는 블로킹이 되었음
- 해결 방법 : 그러면 락 소유자를 가상스레드로 바꾸고 unmount 하자
- VT가 synchronized 블록으로 진입
- I/O 혹은 대기 발생
- 그러면 이 가상스레드만 대기 큐로 들어가고 carrier OS 스레드는 그냥 반납
- 이 반납된 carrier OS 스레드가 다른 가상 스레드를 마운트해서 할일함
- I/O가 완료되면 최초의 가상 스레드는 다시 또 어딘가의 유휴 carrier OS thread 에 마운트
- 락 해제
- 성공!!
간단하게 말하자면 VT 가 carrier thread 와 독립적으로 락을 소유.
이런 것이다.
뭔가 쓰고나니 그렇군 되게 싶군 싶은데, 이거를 어떻게 생각했나 하는 생각이 든다.
그럼 결론이 뭐냐.
- JDK21로 가면서 synchronized 가 가진 OS 락 문제가 생겼고, 그래서 여기서는 어지간하면 피닝이 많은 곳에서는 synchronized 코드를 쓰지 않는게 제안되었다.
- Atomic 은 애초에 CAS 이니 피닝 대상이 아니므로 논외.
- Concurrent 는 synchornized 이지만 그리 대기시간이 길지도 않으니 그냥 그대로 쓰임
- 근데 JDK24로 가면서 synchronized 에서 OS 락을 빼고 바로 언마운트, 락은 VT가 써서 굉장히 좋아짐
- 그리고 그 덕분에 JDK24부터는 그냥 기존 코드 쓰면 됨
'이론 정리 > java' 카테고리의 다른 글
| JDK LTS 와 각각의 내용에 대해 간략히 알아보자(JDK8, JDK11, JDK17, JDK21, JDK25) (0) | 2025.10.10 |
|---|---|
| 간략한 Shenandoah GC 방식 설명 (0) | 2025.10.10 |
| 간략한 ZGC 방식 설명 (0) | 2025.10.10 |
| 간략한 G1GC 방식 설명 (0) | 2025.10.10 |
| 간략한 Serial, Parellal GC 방식 설명 (0) | 2025.10.09 |