[Java] ReentrantLock
몇 주 전에 가상 스레드를 공부하기로 했는데, 생각보다 공부할게 너무 많아서 잘못 건드렸다는 생각이 많이 든다. 포스팅을 적어도 2주에 한 번씩은 하자고 생각했는데, 2주는 무슨 거의 한 달이 된 상황... 그래서 야금야금 분할정복하듯이 키워드를 적어보려고 한다.
* 운영체제에 있는 내용은 안다고 가정하고 글을 작성함
* CAS 알고리즘에 대해 알고 있으면 좋다 (CAS 알고리즘 포스팅 글)
1. Synchornized
자바의 락은 모니터 개념을 사용하고 있다. 모니터란 특정 순간에 하나의 스레드만 객체 인스턴스에 접근할 수 있는 뮤텍스라고 생각하면 된다. 예를 들어서 메서드에 synchronized를 적용하면 인스턴스 메서드에 락이 걸리고, synchronized(객체 인스턴스)를 적용하면 객체 인스턴스에 락이 걸린다.
synchronized는 사용 방법은 HashTable, ConcurrentHashMap을 대표 스샷으로 가지고 왔다. 위 사진처럼 객체나 메서드에 synchronized를 적용할 수 있다.
여담으로 ConcurrentHashMap은 이런 락의 범위를 최소화 시켜서 성능상의 이점을 노렸다.
그러면 모니터는 구체적으로 어떻게 동작할까?? JVM의 objectMonitor 코드를 보면 주석에 이런 저런 내용이 담겨있는데, 주요 내용을 정리하면 아래와 같다.
- 임계구역에 진입하기 위한 스레드 경쟁은 CAS (Compare and Swap) 알고리즘을 사용하고 있다.
- CAS 알고리즘을 사용하면 뒤따라오는 문제점인 ABA 문제는 해결했다.
- 모니터는 뮤텍스 속성을 사용한다.
정리하면 락을 얻기 위한 스레드간 경쟁은 CAS 알고리즘을 사용했고, 실제 락은 뮤텍스 락으로 구현 했다는 점이다.
참고로 락도 정보를 가지고 있는데, 객체에 접근하는 스레드의 개수가 몇개냐에 따라 Biased Lock → LightWeightLock → HeavyWeightLock 순서로 업그레이드를 하며 변하게 된다. 여기에 대해 궁금한 분들은 Alibaba Cloud Blog - Let's Talk about Serveral of the JVM-level Locks in Java 글을 참고하면 좋다.
2. Synchronized의 한계
자바 1.4까지는 syncrhonized 키워드로만 Thread-Safe하게 만들 수 있었는데, 1.5부터 java.util.concurrent 패키지가 추가되어 개발자가 동시성 제어를 쉽게 할 수 있도록 만들었다. 이외엔 제네릭, enum도 추가된 버전이다.
이중에서 java.util.concurrent.locks이 나오게 된 계기는 synchronized 만으로는 동시성을 충분히 제어할 수 없기 때문이다. 운영체제 시간에 Lock에 대해 다룬다면 wait()과 notify()에 대해 들어봤을 것이다.
[락 획득 순서가 보장되지 않음]
톰캣에 스레드 풀이 있는 것처럼 자바 객체 인스턴스마다 고유의 대기 풀(Waiting Pool)이 존재한다. 객체마다 가지고 있는건데, wait() 메서드를 호출하면 해당 스레드는 대기 풀에 들어가게 된다. 이후 다시 락을 획득시키기 위해서 notify()를 호출할텐데, 여기서의 문제점은 어느 객체를 깨우는지 명시를 안해두고 있다. 대기 풀에 있는 랜덤한 스레드 1개가 락을 획득한다는 것이다. 이건 기아상태가 발생할 수도 있다는 것을 시사한다. 이렇게 오래 기다린 순으로 스레드가 락을 획득하지 못하는 것을 Unfair Lock이라고도 부른다.
[경쟁 조건에 대한 성능 저하]
notify() 대신 스레드를 깨우는 다른 방법은 notifyAll()이다. 대기 풀에 있는 모든 스레드를 깨운다는 것인데, 내가 원하는 대기 풀의 스레드를 깨우는 것이 아니라서 락을 획득하기 위한 경쟁을 하게 된다. 즉 내가 경쟁 조건을 세밀하게 제어할 수 없다는 뜻이다.
Coffee coffee = new Coffee();
Machine machine = new Machine(coffee);
Barista barista = new Barista(coffee);
Customer customer1 = new Customer(coffee);
Customer customer2 = new Customer(coffee);
Customer customer3 = new Customer(coffee);
예를 들어서 위와 같은 코드가 있다고 가정하자. 이때 기계, 바리스타와 소비자 모두 Coffee에 synchronized가 적용된 메서드를 호출한 상황이다. 만약 기계가 먼저 락을 획득 했다면 Coffee의 대기 풀엔 {barista, customer1, customer2, customer3}가 들어간 상태이다. 이후 커피가 완성되어서 바리스타만 깨우고 싶은 상황이 존재하는데, customer 1 ~ 3까지 모두 깨운다는 단점을 가지고 있다.
정리하면 synchornized 단점은 크게 2가지로 나눌 수 있다.
- 대기 풀에 들어간 스레드를 랜덤으로 1개 깨우거나, 모두 깨워서 경쟁 상태를 만든다 ( = Unfair Lock)
- 위의 문제로 인해 대기 풀에 대한 세밀한 제어가 불가능하다.
그래서 이런 한계점을 개선한 것이 ReentrantLock이다.
3. ReentrantLock
Reentrant 단어는 재진입성이라는 뜻을 가지고 있다. 언제든지 락을 걸고 해제할 수 있다는 뜻으로 실제 lock(), unlock() 메서드를 사용하여 명시적으로 락을 관리한다. 그런 의미에서 synchronized는 암묵적 락이라고도 부른다.
java.util.concurrent.locks가 추가되면서 Lock 인터페이스가 추가 됐는데, Lock의 기본 구현체가 ReentrantLock이다.
[락 획득 순서를 보장할 수 있음]
ReentrantLock은 Unfair Lock, Fair Lock 모두 지원을 해주는데, Fair Lock은 가장 오래 기다린 스레드가 먼저 락을 획득하는 것이다.
[Condition을 활용한 원하는 경쟁조건에 따른 성능 개선]
synchronized와 다르게 ReentrantLock은 대기 풀을 구분할 수 있다. Condition을 사용하는 것이다.
Coffee coffee = new Coffee();
Machine machine = new Machine();
Barista barista = new Barista(coffee);
Customer customer1 = new Customer(coffee);
Customer customer2 = new Customer(coffee);
Customer customer3 = new Customer(coffee);
위 코드가 있으면
ReentrantLock lock = new ReentrantLock();
Condition waitMachine = lock.newCondition();
Condition waitBarista = lock.newCondition();
Condition waitCustomer = lock.newCondition();
으로 대기 풀을 각자 다르게 설정할 수 있다.
이후 wait() 대신 await(), notify() 대신 signal()을 사용해서 대기 풀을 넣을 수 있다.
[대기 큐]
ReentrantLock의 대기 큐는 AbstractQueueSynchronizer (AQS)를 기반으로 구현되어 있다.
여기서 AQS는 volatile과 CAS 알고리즘을 기반으로 만들어졌는데, 스핀락에서 사용하는 CLH 큐의 변형이라고 한다. (CLH = Craig, Landin, and Hagersten)
하지만 코드를 확인해보면 실제 노드는 큐가 아닌 더블링크드리스트로 이루어져 있다.
내부 코드를 뜯어보면 멤버 변수는 volatile을 키워드가 선언되어 있고
큐 삽입의 경우 CAS 알고리즘이 적용되어 있고
대기 큐에서 락을 획득하는건 스핀락을 부분적으로 사용하고 있다. 스핀락은 이론적으로만 배웠는데, 실제 사용하는 곳은 처음본다.
정리하면 위 그림이 된다.
4. ReentrantLock이 다시 언급되고 있는 이유
자바의 경량 스레드인 가상 스레드 때문이다. 경량 스레드가 나오게 된 이유는 CPU 자원을 효율적으로 활용하는 것처럼 이제는 스레드 자원도 효율적으로 사용하는 방식으로 생각하면 편하다. 기존 자바의 스레드 방식은 [커널 스레드 - 유저 스레드]가 한 쌍으로 1대1 매핑이 되어있는데, 경량 스레드로 인해 N:M 관계가 되었다. 그래서 경량 스레드로 좋은 성능이 나오려면 경량 스레드가 블로킹이 일어날 때, 블로킹된 경량 스레드와 연결된 커널 스레드는 연결을 끊고, 새로운 경량 스레드랑 연결하여 작업해야한다.
하지만 Pinning 현상이라는 것이 있는데, 경량 스레드가 블로킹 상태가 되면 커널 스레드와 연결이 끊겨야 하는데, 계속 연결이 유지되고 있는 현상이다. 이것은 곧 커널 스레드도 블로킹 상태가 되기 때문에 기존 자바 Thread 방식과 똑같아진다. 이게 synchornized를 사용하게 된다면 Pinning 현상이 일어나기 때문에 synchornized에서 ReentrantLock으로 점차 변경할 예정이라고 한다.
자바에서는 이런 내용도 있고, 스프링에서는 synchornized와 관련해서 가상 스레드에 대한 내용도 있으니 참고해보면 좋을 것 같다. (관련 링크) 스프링에서는 이미 synchronized 블록을 옛날부터 수정해와서 좋은 상태를 유지하고 있지만, 가상 스레드를 최대한 활용할 수 있도록 검토한다고 나와있다.
5. 참고한 링크
1) Alibaba Cloud Blog - Let's Talk about Several of the JVM-level Locks in Java
2) wjdrbs96님 깃허브 - TIL - ReentrantLock이란?
3) 우아한형제들 기술 블로그 - Java의 미래, Virtual Thread
5) Spring Blog - Embracing Virtual Threads