본문 바로가기
dev/java

Java - ReentrantLock

by igooo 2024. 7. 19.
728x90

개요

There are two scenarios in which a virtual thread cannot be unmounted during blocking operations because it is pinned to its carrier:
When it executes code inside a synchronized block or method, orWhen it executes a native method or a foreign function.
Pinning does not make an application incorrect, but it might hinder its scalability. If a virtual thread performs a blocking operation such as I/O or BlockingQueue.take() while it is pinned, then its carrier and the underlying OS thread are blocked for the duration of the operation. Frequent pinning for long durations can harm the scalability of an application by capturing carriers.
The scheduler does not compensate for pinning by expanding its parallelism. Instead, avoid frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guard potentially long I/O operations to use java.util.concurrent.locks.ReentrantLock instead. There is no need to replace synchronized blocks and methods that are used infrequently (e.g., only performed at startup) or that guard in-memory operations. As always, strive to keep locking policies simple and clear.

출처 : https://openjdk.org/jeps/425

 

Java 21 Virtual Threads가 도입된 이후로 Lock이(동시성) 필요한 부분에는 sychronized 대신에 ReentrantLock을 사용하도록 가이드하고 있다.

 

Example : java.net.InetAddress.java

InetAddress의 소스를 보면 Java 8에서는 동기화 로직이 synchronized로 구현이 되어있고 Java 21에서는 synchronized 구문이 사라지고 ReentrantLock으로 구현되어 있는 것을 볼 수 있다.

Java 8  Java 21
    private static void cacheAddresses(String hostname,
                                       InetAddress[] addresses,
                                       boolean success) {
        hostname = hostname.toLowerCase();
        synchronized (addressCache) {
            cacheInitIfNeeded();
            if (success) {
                addressCache.put(hostname, addresses);
            } else {
                negativeCache.put(hostname, addresses);
            }
        }
    }
        private final Lock lookupLock = new ReentrantLock();
        
        @Override
        public InetAddress[] get() {
            long now = System.nanoTime();
            if ((refreshTime - now) < 0L && lookupLock.tryLock()) {
                try {
                    // cachePolicy is in [s] - we need [ns]
                    refreshTime = now + InetAddressCachePolicy.get() * 1000_000_000L;
                    // getAddressesFromNameService returns non-empty/non-null value
                    inetAddresses = getAddressesFromNameService(host);
                    // don't update the "expirySet", will do that later
                    staleTime = refreshTime + InetAddressCachePolicy.getStale() * 1000_000_000L;
                } catch (UnknownHostException ignore) {
                } finally {
                    lookupLock.unlock();
                }
            }
            return inetAddresses;
        }

 

사용방법은 try 구문과 함께 사용하여 try전에 lock을 하고 finally 구문에서 unlock을 하는 구조를 추천하고 있다.

class X {
   private final ReentrantLock lock = new ReentrantLock();
   // ...

   public void m() {
     lock.lock();  // block until condition holds
     try {
       // ... method body
     } finally {
       lock.unlock()
     }
   }
 }

 

ReentrantLock은 synchronized와 동일한 Lock 기능을 제공하지만 추가적인 확장된 기능을 제공한다. synchronized는 암묵적으로 동기화 구문을 지정하지만 ReentrantLock의 경우 lock(), unlock() 메서드를 사용하여 명시적으로 표현한다.

 

Example

멤버 변수를 count를 조회하는 로직과 멤버 변수 count에 값을 1씩 증가시키는 로직을 각각의 스레드에서 10000번씩 다중 스레드로 실행하는 테스트 코드를 작성하고 실행해 본다. (조회 thread 2, 값 증가 thread 2)

class ReentrantLockTests {
	private static final Logger logger = LoggerFactory.getLogger(ReentrantLockTests.class);
    
    private ReentrantLock lock = new ReenterantLock();
    private int count = 0;
    
    @Test
    void runThreads() throws Exception{
    	var executor = Executors.newFixedThreadPool(10);
        
        executor.execute(readValue());
        executor.execute(readValue());
        executor.execute(increaseValue());
        executor.execute(increaseValue());
        executor.shutdown();
        
        executor.awaitTermination(Long.MAX_VALUE, Timeunit.NANOSECONDS);
    }
    
    Runnable readValue() {
    	return () -> {
        	for (int i = 0; i < 10000; i++) {
            	this.lock.lock();
                try {
                	logger.info("[{}] Read value = {}, index = {}", Thread.currentThread().getName(), count, i);
                }
                finally {
                	this.lock.unlock();
                }
            }
        };
    }
    
    Runnable increaseValue() {
    	return () -> {
        	for (int i = 0; i < 10000; i++) {
            	this.lock.lock();
                try {
                	count = count + 1;
                }
                finally {
                	this.lock.unlock();
                }
            }
            logger.info("[{}] Write value = {}", Thread.currentThread().getName(), count);
        };
    }
}

 

실행결과

count 멤버 변수의 값은 정상적으로 20000까지 증가하고 마지막으로 실행된 조회 스레드는 정상적으로 20000의 값을 출력한다.

[pool-1-thread-2] Read value = 15836, index = 2912
[pool-1-thread-2] Read value = 15836, index = 2913
[pool-1-thread-4] Write value = 20000
[pool-1-thread-1] Read value = 20000, index = 2091
[pool-1-thread-1] Read value = 20000, index = 2092

 

lock 관련 코드를 제거하고 실행하면 value가 20000까지 증가하지 못하는 것을 볼 수 있다. (실행할 때마다 다름)

[pool-1-thread-1] Read value = 0, index = 0
[pool-1-thread-3] Write value = 18453
[pool-1-thread-1] Read value = 18453, index = 1
[pool-1-thread-1] Read value = 18453, index = 2
[pool-1-thread-1] Read value = 18453, index = 3
[pool-1-thread-1] Read value = 18453, index = 4
[pool-1-thread-4] Write value = 18453
[pool-1-thread-1] Read value = 18453, index = 4
[pool-1-thread-1] Read value = 18453, index = 5

 

Constructor fair parameter

ReentrantLock 객체 생성 시 fair 파라미터를 사용하여 ReentrantLock을 사용한 스레드 간에 가능한 공정한 스레드 실행 순서를 보장할 수 있다. 내부적으로 스레드의 대기 시간에 측정하여 비슷한 대기 시간을 가지도록 설정할 수 있다.

......
[pool-1-thread-1] Read value = 19918, index = 9973
[pool-1-thread-2] Read value = 19919, index = 9997
[pool-1-thread-1] Read value = 19919, index = 9974
[pool-1-thread-2] Read value = 19921, index = 9998
[pool-1-thread-1] Read value = 19967, index = 9998
[pool-1-thread-1] Read value = 19969, index = 9999
[pool-1-thread-4] Write value = 20000
[pool-1-thread-3] Write value = 19997

 

실행결과를 보면 fair를 true로 설정하기 전보다 각 스레드 간에 비슷한 순서로 실행됨을 볼 수 있다. (fair 옵션을 사용하면 처리량은 떨어질 수 있다.)

Programs using fair locks accessed by many threads may display lower overall throughput (i.e., are slower; often much slower) than those using the default setting, but have smaller variances in times to obtain locks and guarantee lack of starvation.

 

 

ReentrantReadWriteLock

추가적으로 ReentrantLock은 위에 예제처럼 공유 자원에 대하여 읽기/쓰기에 따른 Lock을 구분하고 있지 않다. 하지만 일반적인 서비스의 경우 Read가 Write보다 많은 실행 횟수를 가진다. 그런 경우를 위해서 ReentrantReadWriteLock 클래스도 제공한다. 

class ReentrantReadWriteLockTests {
	private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    
    private ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
   
	private ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
    
    ......
    
    Runable readValue() {
    	return () -> {
        	this.readLock.lock();
            try {
            	......
            }
            finally {
            	this.readLock.unlock();
            }
        };
    }
    
    Runable increaseValue() {
    	return () -> {
        	this.writeLock.lock();
            try {
            	......
            }
            finally {
            	this.writeLock.unlock();
            }
        };
    }
}

 

참고

 

'dev > java' 카테고리의 다른 글

Building a SpringBoot Monorepo with Gradle  (2) 2024.11.06
Java 23 : Structured Concurrency  (0) 2024.09.28
Generational ZGC in Java 21  (0) 2024.07.02
Java Virtual Threads 사용 시 synchronized 주의  (0) 2024.06.04
Virtual Threads  (0) 2024.06.02