paint-brush
Java 스레드 프리미티브 재학습을 위한 필수 가이드~에 의해@shai.almog
761 판독값
761 판독값

Java 스레드 프리미티브 재학습을 위한 필수 가이드

~에 의해 Shai Almog8m2023/04/11
Read on Terminal Reader
Read this story w/o Javascript

너무 오래; 읽다

동기화는 혁명적이었고 여전히 유용하게 사용됩니다. 그러나 이제 더 새로운 스레드 기본 형식으로 이동하고 잠재적으로 핵심 논리를 다시 생각해 볼 때입니다.
featured image - Java 스레드 프리미티브 재학습을 위한 필수 가이드
Shai Almog HackerNoon profile picture

나는 첫 번째 베타 이후로 Java로 코딩했습니다. 그 당시에도 스레드는 제가 가장 좋아하는 기능 목록의 최상위에 있었습니다. Java는 언어 자체에 스레드 지원을 도입한 최초의 언어입니다. 당시에는 논란의 여지가 있는 결정이었습니다.


지난 10년 동안 모든 언어는 async/await를 포함하기 위해 경쟁했고 심지어 Java에도 이를 위한 일부 타사 지원이 있었습니다 . 하지만 Java는 zagged 대신 zig를 사용하여 훨씬 뛰어난 가상 스레드를 도입했습니다(project Loom) . 이 게시물은 그것에 관한 것이 아닙니다.


나는 그것이 훌륭하고 Java의 핵심 능력을 증명한다고 생각합니다. 언어뿐만 아니라 문화로도 말이죠. 유행에 휩쓸리지 않고 신중하게 변화하는 문화.


이번 포스팅에서는 Java에서 스레딩을 수행하는 기존 방식을 다시 살펴보고 싶습니다. 나는 synchronized , wait , notify 등에 익숙합니다. 그러나 이것이 Java의 스레딩에 대한 우수한 접근 방식인 지 오래되었습니다.


나는 문제의 일부입니다. 나는 여전히 이러한 접근 방식에 익숙하며 Java 5 이후에 사용된 일부 API에 익숙해지기가 어렵습니다. 이는 습관의 힘입니다. 티


여기 비디오에서 논의할 스레드 작업을 위한 훌륭한 API가 많이 있지만, 기본적이면서도 중요한 잠금에 대해 이야기하고 싶습니다.

동기화 대 ReentrantLock

내가 동기화를 떠나는 것을 꺼려하는 점은 대안이 그다지 좋지 않다는 것입니다. 오늘 이를 떠나려는 주된 동기는 현재 동기화가 Loom에서 스레드 고정을 트리거할 수 있다는 것인데 이는 이상적이지 않습니다.


JDK 21은 이 문제를 해결할 수도 있지만(Loom이 GA로 전환되면) 여전히 그대로 두는 것이 합리적입니다.


동기화를 직접 대체하는 것은 ReentrantLock입니다. 불행히도 ReentrantLock은 동기화에 비해 이점이 거의 없으므로 마이그레이션의 이점은 기껏해야 모호합니다.


실제로 한 가지 큰 단점이 있습니다. 이를 이해하기 위해 예를 살펴보겠습니다. 이것이 우리가 동기화를 사용하는 방법입니다:

 synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }


ReentrantLock 의 첫 번째 단점은 장황함입니다. 블록 내에서 예외가 발생하면 잠금이 유지되므로 try 블록이 필요합니다. 우리를 위해 원활하게 동기화된 핸들.


일부 사람들이 AutoClosable 로 자물쇠를 래핑하는 데 사용하는 트릭이 있는데 대략 다음과 같습니다.

 public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }


나는 이상적인 Lock 인터페이스를 구현하지 않았다는 점에 주목하세요. 이는 lock 메소드가 void 대신 자동 종료 가능 구현을 반환하기 때문입니다.


그렇게 하면 다음과 같이 보다 간결한 코드를 작성할 수 있습니다.

 try(LOCK.lock()) { // safe code }


나는 장황함이 줄어든 것을 좋아하지만 try-with-resource는 정리 목적으로 설계되었으며 잠금을 재사용하므로 이는 문제가 있는 개념입니다. close를 호출하고 있지만 동일한 객체에서 해당 메서드를 다시 호출합니다.


잠금 인터페이스를 지원하기 위해 리소스 구문을 사용하여 try를 확장하는 것이 좋을 것이라고 생각합니다. 그러나 그렇게 되기 전까지는 이것은 가치 있는 트릭이 아닐 수도 있습니다.

ReentrantLock의 장점

ReentrantLock 사용하는 가장 큰 이유는 Loom 지원입니다. 다른 장점도 좋지만 그 어느 것도 "킬러 기능"은 아닙니다.


연속 블록 대신 메서드 간에 사용할 수 있습니다. 잠금 영역을 최소화하고 실패가 문제가 될 수 있으므로 이는 아마도 나쁜 생각일 수 있습니다. 저는 그 기능을 장점으로 생각하지 않습니다.


공정성의 옵션이 있습니다. 이는 잠금에서 먼저 중지된 첫 번째 스레드를 제공한다는 의미입니다. 나는 이것이 중요할 현실적이고 복잡하지 않은 사용 사례를 생각하려고 노력했지만 공백을 그렸습니다.


많은 스레드가 리소스에 지속적으로 대기하는 복잡한 스케줄러를 작성하는 경우 다른 스레드가 계속해서 들어오기 때문에 스레드가 "고갈"되는 상황이 발생할 수 있습니다. 그러나 이러한 상황은 아마도 동시성 패키지의 다른 옵션을 사용하는 것이 더 나을 것입니다. .


아마도 여기에 뭔가 빠졌을 수도 있습니다…


lockInterruptibly() 사용하면 스레드가 잠금을 기다리는 동안 스레드를 중단할 수 있습니다. 이는 흥미로운 기능이지만 현실적으로 변화를 가져올 상황을 찾기는 어렵습니다.


인터럽트에 매우 반응해야 하는 코드를 작성하는 경우 해당 기능을 얻으려면 lockInterruptibly() API를 사용해야 합니다. 그런데 평균적으로 lock() 메소드 내에서 얼마나 오랜 시간을 보내시나요?


고급 멀티 스레드 코드를 수행하는 경우에도 이것이 중요할 수 있지만 대부분의 사람들이 겪게 될 문제는 아닌 경우가 있습니다.

읽기쓰기재진입 잠금

훨씬 더 나은 접근 방식은 ReadWriteReentrantLock 입니다. 대부분의 리소스는 빈번한 읽기 원칙을 따르고 쓰기 작업은 거의 없습니다. 변수를 읽는 것은 스레드로부터 안전하므로 변수에 쓰는 과정이 아닌 이상 잠금이 필요하지 않습니다.


이는 쓰기 작업을 약간 느리게 하면서 읽기를 극도로 최적화할 수 있음을 의미합니다.


이것이 귀하의 사용 사례라고 가정하면 훨씬 더 빠른 코드를 만들 수 있습니다. 읽기-쓰기 잠금으로 작업할 때 두 개의 잠금이 있습니다. 다음 이미지에서 볼 수 있듯이 읽기 잠금입니다. 여러 스레드를 통과할 수 있으며 사실상 "모두에게 무료"입니다.


변수에 써야 하면 다음 이미지에서 볼 수 있듯이 쓰기 잠금을 얻어야 합니다. 쓰기 잠금을 요청하려고 하지만 아직 변수에서 읽는 스레드가 있으므로 기다려야 합니다.


스레드 읽기가 완료되면 모든 읽기가 차단되고 쓰기 작업은 다음 이미지에 표시된 것처럼 단일 스레드에서만 발생할 수 있습니다. 쓰기 잠금을 해제하면 첫 번째 이미지의 "모두에게 무료" 상황으로 돌아갑니다.


이는 컬렉션을 훨씬 더 빠르게 만들기 위해 활용할 수 있는 강력한 패턴입니다. 일반적인 동기화 목록은 상당히 느립니다. 읽기 또는 쓰기 등 모든 작업을 동기화합니다. 읽기에는 빠른 CopyOnWriteArrayList가 있지만 쓰기는 매우 느립니다.


메서드에서 반복자의 반환을 피할 수 있다고 가정하면 목록 작업을 캡슐화하고 이 API를 사용할 수 있습니다.


예를 들어 다음 코드에서는 이름 목록을 읽기 전용으로 노출하지만 이름을 추가해야 할 때는 쓰기 잠금을 사용합니다. 이는 synchronized 목록보다 쉽게 성능을 발휘할 수 있습니다.

 private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }

스탬프 잠금

StampedLock 에 대해 가장 먼저 이해해야 할 것은 재진입이 불가능하다는 것입니다. 다음 블록이 있다고 가정해 보겠습니다.

 synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }

이것은 작동합니다. 동기화는 재진입이 가능하기 때문입니다. 우리는 이미 잠금을 보유하고 있으므로 methodA methodB() 에서 methodA() )로 들어가는 것은 차단되지 않습니다. 이는 동일한 잠금 또는 동일한 동기화 객체를 사용한다고 가정하면 ReentrantLock에서도 작동합니다.


StampedLock 잠금을 해제하는 데 사용하는 스탬프를 반환합니다. 그렇기 때문에 약간의 한계가 있습니다. 하지만 여전히 매우 빠르고 강력합니다. 여기에는 공유 리소스를 보호하는 데 사용할 수 있는 읽기 및 쓰기 스탬프도 포함되어 있습니다.


그러나 ReadWriteReentrantLock, 과 달리 잠금을 업그레이드할 수 있습니다. 왜 그렇게 해야 할까요?

이전의 addName() 메소드를 보세요... "Shai"로 두 번 호출하면 어떻게 될까요?


예, Set을 사용할 수 있습니다… 하지만 이 연습에서는 목록이 필요하다고 가정하겠습니다… ReadWriteReentrantLock 사용하여 해당 논리를 작성할 수 있습니다.

 public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }


이건 짜증나. 나는 어떤 경우에는 contains() 확인하기 위해서만 쓰기 잠금에 대해 "비용을 지불"했습니다(중복 항목이 많다고 가정). 쓰기 잠금을 얻기 전에 isInList(name) 호출할 수 있습니다. 그러면 우리는:


  • 읽기 잠금을 잡아라


  • 읽기 잠금을 해제하세요


  • 쓰기 잠금을 잡아라


  • 쓰기 잠금을 해제하세요


두 가지 경우 모두 대기할 수 있으며 추가 번거로움을 겪을 가치가 없을 수도 있습니다.


StampedLock 을 사용하면 읽기 잠금을 쓰기 잠금으로 업데이트하고 필요한 경우 다음과 같이 그 자리에서 변경할 수 있습니다.

 public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }

이러한 경우에 대한 강력한 최적화입니다.

마지막으로

위의 비디오 시리즈에서 유사한 주제를 많이 다루었습니다. 확인해 보고 어떻게 생각하는지 알려주세요.


나는 종종 다시 생각하지 않고 동기화된 컬렉션에 손을 뻗습니다. 이는 때때로 합리적일 수 있지만 대부분의 경우 아마도 차선책일 것입니다. 스레드 관련 기본 요소에 약간의 시간을 투자함으로써 성능을 크게 향상시킬 수 있습니다.


이는 기본 경합이 훨씬 더 민감한 Loom을 다룰 때 특히 그렇습니다. 100만 개의 동시 스레드에서 읽기 작업을 확장한다고 상상해 보십시오. 이 경우 잠금 경합을 줄이는 것이 훨씬 더 중요합니다.


synchronized 컬렉션이 ReadWriteReentrantLock 이나 심지어 StampedLock 사용할 수 없는지 생각할 수도 있습니다.


API의 노출 영역이 너무 커서 일반적인 사용 사례에 맞게 최적화하기가 어렵기 때문에 문제가 됩니다. 낮은 수준의 기본 요소에 대한 제어가 높은 처리량과 차단 코드 사이의 차이를 만들 수 있는 곳입니다.