이론 정리/java

Virtual Thread 와 Synchronized, 그리고 Concurrent 클래스의 ReentrantLock에 관하여

철매존 2026. 7. 31. 04:28
728x90
반응형

요즘 참 공부를 안하고 살았다.

그냥 안한게 아니라 어떤 공부를 하는게 맞는지, 뭘 해야 하는지도 모르겠어서 더 문제였다.

사실 개인적으로 스트레스도 워낙 많았기 때문에... 더 손에 잡히지 않기도 했다.


암튼 기회가 있어서 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단계로 나눠서 관리했고

  1. 편향 락
  • 특정 스레드 하나만 락을 쓴다고 편향되게 추측
    • 객체 헤더에 스레드의 ID만 적어두고, 그냥 ID 만 적혀있으면 통과
      • 오버헤드가 거의 없다.
  1. 경량 락
  • 다른 스레드가 해당 락을 쓰려고 할 때 승급
    • OS 커널로 가지 않고 각 스레드의 스택 프레임에 락 레코드를 만들고 CAS 연산으로 객체 헤더의 주소 변경
      • 그래서 CAS 를 계속 진행하면서 OS 대기 상태가 아니라 락이 풀렸는지 계속 확인
  1. 중량 락
  • 스핀락이 길어지거나 여러 스레드가 락을 쓰려할 떄 승급
    • 이 때는 결국 OS 의 Mutex 를 사용하고 스레드는 실제로 OS 블로킹 큐에 들어가서 대기

이렇게 최적화된 덕분에 synchronized 방식도 매우 가벼워졌다.

3. JDK 15에서의 2단계 최적화

JDK 15부터는 다중 코어이기 때문에 오히려 편향 락 과정에서 오버헤드가 매우 커졌다.
이 문제점은 다음과 같은데

  1. 하나의 스레드만 객체를 쓴다고 생각하고 객체 헤더에 ID 기입
  2. 다중 코어라서 다른 스레드가 해당 객체에 접근
  3. 락이 걸리기 때문에 경량락 승격
  4. 그러면 최초에 객체 헤더에 기입된 스레드의 ID 를 제거해야 한다
  5. 문제는 최초 사용 스레드가 진짜 락을 다 쓴건지 아니면 아직 쓰고있는지를 모름
  6. JVM 이 최초 스레드의 모든 스택 프레임 직접 조회
  7. 스레드 스택을 보려면 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

대충 이런 것이다.

보면 알겠지만 스레드를 대기큐에 등록하고 대기 상태로 전환하면 VT 를 unmount 하기 때문에 계속 블로킹되어 마운트되지 않는다.

근데 이거 딱봐도 뭔가 이슈가 있어 보이는데,

  • 메모리 이슈
    • 이거 만들 때마다 ReentrantLock 이라는 객체가 만들어지는 것이다. 많이 만들어지는 경우 메모리에 이슈가 생긴다.

ConcurrentHashMap 의 동작은?

JDK1.5 ~ 7 에 최초 도입

  • 처음에는 ReentrantLock 을 사용했다고 한다.
  • 전체 맵을 default 16개의 세그먼트로 나눠서 사용
  • 전체적으로 락을 ReentrantLock 기반 사용

JDK8 에서 synchronized 조합으로 활용

위의 내용에서 살펴보면 synchronized 가 굉장히 좀 성능이 좋지 않아 보인다.
그래서 처음 ConcurrentHashMap 이 ReentrantLock 을 만들 때에는 그나마 성능에 괜찮은 ReentrantLock 으로 만들어졌다고 한다.
근데 이게 좀 바뀐 다음에는 synchronized 로 바꿨는데 그 이유는 ReentrantLock 이 가진 단점에서 비롯된다.

  • 버킷(노드) 단위로 락을 걸어버린다.
    • 즉 락을 거는 것이 세그먼트 단위에서 엄청 세분화되었다.
  • Map 같은 애들은 사실 락이 걸리는 시점과 한계가 명확한 편이다.
    • 해시 버킷에 데이터를 넣거나 빼는건 굉장히 금방 끝난다.
    • concurrentHashMap 은 버킷마다 락을 건다.
      • 그러면 그 버킷마다 ReentrantLock 이 생기고, 그거는 메모리를 왕창 잡아먹는다.
        • synchronized 라면? 이렇게 객체가 왕창 생길 일이 없다.

그래서 계륵같은 ReentrantLock 녀석을 빼고 synchronized 기반으로 최적화를 하는데,

  • 빈 버킷에 데이터를 넣는 경우는 CAS
  • 데이터가 존재하는 버킷에 데이터를 넣을 떄
    • 해당 버킷의 첫번째 노드에만 synchronized 를 건다.
      • 어차피 해시 충돌이 나는 경우 어차피 linkedList 나 tree 구조로 찾아가는데 첫번째를 반드시 통해야 하므로 여기서 걸면 하나만 오게됨.

요렇게 해서 최적화를 해낸 것이다.

JDK21 에서는 synchronized 잘 안쓴다며 그럼 어캄?

  • 나 말고도 비슷한 이야기를 많이들 했다고 한다.
    • synchronized 부분에서 가상스레드 언마운트 안되면 어떡하지 이런
  • 근데 그냥 쓰기로 함
    • concurrentHashMap 에서 그 락을 갖고있는 시간은 포인터 연결 혹은 레드블랙트리 노드 추가정도의 매우 짧은 시간이여서 이슈 적음 (보통 실무에서 이런식으로 말하는 경우가 많은데 concurrentHashMap 같은 것도 이렇게 할줄은 몰랐다..)
      • 근데 그러면 compute 나 merge 연산을 하는 경우는 블로킹이 많을텐데 그거는 어떡할까?
        • 해결책을 모르겠음!
          • 최대한 여기서는 short and simple 로 작성하도록 한다.
    • JDK24+ 에서 synchronized 내부의 피닝 문제가 해결돼서 괜찮다고 한다.

JDK24+ 에서는 또 synchronized 문제가 해결됨

synchronized 라는게 그렇게 쓰지마 하고 안쓸수 있는것도 아니고, 이거를 없애는것도 안돼서 고수 개발자들이 해결법을 고민했다고 한다.

  • 기존 문제 : synchronized 는 블로킹이 되었음
  • 해결 방법 : 그러면 락 소유자를 가상스레드로 바꾸고 unmount 하자
    1. VT가 synchronized 블록으로 진입
    2. I/O 혹은 대기 발생
    3. 그러면 이 가상스레드만 대기 큐로 들어가고 carrier OS 스레드는 그냥 반납
    4. 이 반납된 carrier OS 스레드가 다른 가상 스레드를 마운트해서 할일함
    5. I/O가 완료되면 최초의 가상 스레드는 다시 또 어딘가의 유휴 carrier OS thread 에 마운트
    6. 락 해제
    7. 성공!!

간단하게 말하자면 VT 가 carrier thread 와 독립적으로 락을 소유.

이런 것이다.
뭔가 쓰고나니 그렇군 되게 싶군 싶은데, 이거를 어떻게 생각했나 하는 생각이 든다.

그럼 결론이 뭐냐.

  • JDK21로 가면서 synchronized 가 가진 OS 락 문제가 생겼고, 그래서 여기서는 어지간하면 피닝이 많은 곳에서는 synchronized 코드를 쓰지 않는게 제안되었다.
  • Atomic 은 애초에 CAS 이니 피닝 대상이 아니므로 논외.
  • Concurrent 는 synchornized 이지만 그리 대기시간이 길지도 않으니 그냥 그대로 쓰임
  • 근데 JDK24로 가면서 synchronized 에서 OS 락을 빼고 바로 언마운트, 락은 VT가 써서 굉장히 좋아짐
  • 그리고 그 덕분에 JDK24부터는 그냥 기존 코드 쓰면 됨
반응형