ConcurrentHashMap은 조회시 Lock을 할까?

Picbel·2022년 7월 17일
1

Concurrency Programming

목록 보기
1/4
post-thumbnail
post-custom-banner

이번에 다양한 Thread에서 오는 정보나 작업의 요청을 관리 해야할 업무를 맞게 되었습니다.
그래서 여러쓰레드에서 접근하다보니 ConcurrentHashMap을 사용하였는데 해당부분을 사용하다 생각외로 동작하는 부분을 발견하였습니다.


data class ServiceKey(
    private val key: String
)

class ServiceManager(
    private val serviceMap: ConcurrentHashMap<ServiceKey, String>
) {
    fun get(key: String): String {
        return serviceMap[ServiceKey(key)]!!
    }
    
     fun isWorking(key: String): Boolean {
        return serviceMap.containsKey(ServiceKey(key))
    }

    fun execute(key: String, value: Int) = serviceMap.set(ServiceKey(key), "Something Do! $value")
}

class ServiceManagerTest {

    private val sut: ServiceManager = ServiceManager(ConcurrentHashMap())

    @Test
    fun `여러 쓰레드에서 isWorkingServiceManager을 호출합니다`() {
        val numberOfThreads = 10
        val service: ExecutorService = Executors.newFixedThreadPool(100)
        val latch = CountDownLatch(numberOfThreads)

        // 1번 key 1번으로 1이이라는 값을 세팅합니다
        service.execute {
            sut.execute("1",1)
        }
        
		// 2번 쓰레드10개에서 같은 key로 map에서 조회합니다 위에서 같은 키로 조회하였기 때문에 전부 true가 나와야합니다.
        for (i in 0 until numberOfThreads) {
            service.execute() {
                sut.isWorking("1")
                    .also { println("${Thread.currentThread().name} = $it") }
                latch.countDown()
            }
        }

        latch.await()
    }
 }

다음과 같은 코드가 있을때 만약 Test 실행시 isWorking의 값이 전부 true로 나올까요?
막상 돌려보면 전부 false입니다.

Map에 저장되기도 전에 조회를 해서 그럴지도 모릅니다
그럼 만약 1번에서 실행한 요청이 메모리에 쓰여질시간이 주어지고 요청이 들어온다 가정하겠습니다
그리고 동시에 10개의 쓰레드에서 해당 Key로 접근하여보겠습니다
단 중간에 값을 변경하여 보도록하죠

@Test
fun `여러 쓰레드에서 isWorkingServiceManager을 호출합니다 - 2`() {
	val numberOfThreads = 10
    val service: ExecutorService = Executors.newFixedThreadPool(100)
    val latch = CountDownLatch(numberOfThreads)

    // 실행
    service.execute {
       sut.execute("1",1)
    }
    Thread.sleep(100) // 위 요청이 map에 저장할동안 기다립니다 합니다

    for (i in 0 until numberOfThreads) {
        if (i == 5){ //5번째 요청일때 1번키의 값을 5번으로 바꿉니다
            service.execute{
                 sut.execute("1",i)
            }
        }
        service.execute() { // 0 ~ 4번까지는 1, 5 ~ 9번까지는 5의 값으로 예상됩니다
           sut.get("1").also { println("${Thread.currentThread().name} = $it") }
           latch.countDown()
        }
    }

    latch.await()
}

결과는 ?!

네 어딘가 좀 이상해보이죠? 1111155555순으로 나오는 경우도있으나 반복적으로 돌리다보면 동시성보장이 안되고 있단걸 보실수 있습니다.

이유!

이유는 다음과 같았습니다
ConcurrentHashMap이 조회의 경우엔 항상 동시성을 보장하지않습니다.
내부 구현 코드를 보면 다음과 같습니다

lock을 활용하는 부분이 직접적으로 보이진 않습니다.
조회가 아닌 저장하는 함수인 putVal을 보면 synchronized를 활용하시는걸 볼 수있는거에 비해 조회쪽은 내부 구현중 tabAt함수를 보시면 다음과 같은 함수를 이용합니다



익숙한 키워드인 volatile이 메서드명에 보이네요 :)
네 여기서 느끼신분이 계실껍니다 volatile은 하나의 쓰레드만 수정을하고 나머지가 읽을때 동시성이 보장되죠
여러 쓰레드에서 동시에 write을 하게된다면 보장하지 않습니다.

구글링중 확인하니 java-volatile-semantics-in-concurrenthashmap란 질문도 있네요.

해결법

여러 해결법이 있겠지만 저는 해당 ServiceManager에서 isWorking에 접근할때 lock을 거는 방법으로 해결하였습니다.
어떤 락을 사용할것인지는 다음 포스팅을 확인하여주세요!

예제는 blog-example-jvm에 있습니다.

profile
Software Developer
post-custom-banner

0개의 댓글