개요
프로젝트를 진행하면서 HashMap을 이용해 Key-Value 형태로 된 InMemory DB를 직접 구현하던 도중, 유저의 정보를 저장할 때 Long Type으로 선언된 유저의 ID값을 증가시켜줘야 하는 경우가 생겼습니다. 이때 대부분 ID 값을 id++ 과 같은 연산을 통해 수행할 텐데, 이렇게 증가 연산을 수행하게 되면 어떤 문제가 발생하게 되는지 그리고 어떻게 해결해야 할지 알아보도록 하겠습니다.
먼저 아래와 같은 연산을 통해 id값을 증가해 준다고 가정하겠습니다
public class UserTable { //예시용 테이블 클래스
Long userId;
public void incrementUserId() {
userId++;
}
}
이렇게 작성된 코드는 싱글 스레드 환경에서는 정확하게 동작할것 입니다 하지만 스레드가 점점 증가하면서 여러 스레드가 사용되게 되면 코드의 결과 값이 작동할 때마다 달라져, 일치하지 않게 될 것입니다.
값이 일치하지 않게 된 이유는 userId++ 로 되어있는 간단한 증감연산 때문입니다. 이 연산은 겉으로 보기에 원자적 연산(한번에 하나씩 수행되는 연산이라고 요약할 수 있습니다)처럼 보일 수 있지만 실제로는 값을 얻고, 얻은 값을 증가시키고, 증가시킨 값(업데이트된 값)을 다시 쓰는 세 가지의 연산이 일어납니다.
이 상황에서 여러 스레드가 동시에 userId++ 연산을 통해 값을 가져오고 업데이트 하려 하면, 기존에 업데이트된 값에 대한 정보가 손실될 수 있습니다.
그럼 여러 스레드가 공유 자원에 동시에 접근하는 상황에서 userId++ 메서드의 값을 일정하게 유지시켜 줄려면 어떻게 해야 할까요? 먼저 synchronized 키워드를 사용하는 방법이 있습니다.
public class UserTable {
Long userId;
public synchronized void incrementUserId(){
userId++;
}
}
위와 같이 synchronized 키워드를 이용해 incrementUserId 메서드의 lock을 걸어주는 방식으로 문제를 해결하면, 동시성 문제는 해결됩니다. 하지만 lock으로 인해 해당 연산이 일어날 때, 여러 스레드가 동시에 해당 메서드에 접근하지 못하고, 한 번에 한 스레드만 접근할 수 있기 때문에 성능적인 문제가 발생할 수 있습니다.
그렇다면 동시성 환경에서 값의 불일치 문제를 해결할 수 있는 다른 방법은 없을까요?
Atomic Operations
동시성 환경을 위한 논블로킹(non-blocking) 알고리즘을 만드는 데 초점을 맞춘 연구 분야가 있습니다. 이러한 non-blocking 알고리즘은 데이터 무결성(data integrity)을 보장하기 위해 CAS(Compare-And-Swap)같은 저 수준의 원자 기계 명령(atomic machine instructions)을 사용합니다.
일반적인 CAS 연산은 다음과 같은 세가지 피연산자에서 작동합니다:
- 작동할 메모리상의 위치(주소) M
- 변수의 기존 기대값 (A)
- 설정해야 하는 새로운 값(B)
CAS 연산은 M의 값을 B로 원자적(atomically)으로 M의 값이 기존 A의 값과 일치하는 경우에만 업데이트합니다. 만약 그렇지 않은 경우에는 아무런 변경도 일어나지 않습니다.
두 경우 모두, M의 기존 값이 return 됩니다. 기존 증감 연산자(++)에서의 값 가져오기, 값 비교 및 값 업데이트의 세 단계를 단일 시스템 수준의 작업으로 결합시킵니다.
여러 스레드가 CAS 알고리즘을 통해 동일한 값을 업데이트하려 할 때, 한 스레드가 경합에서 이기고 해당 값을 업데이트 합니다. synchronized를 이용한 lock을 이용하는 경우와 달리 다른 스레드는 해당 값이 업데이트되는 동안에도 일시 중단되지 않습니다. 대신에 다른 스레드들은 해당 값을 업데이트하지 못했다는 것을 알게 됩니다. 이렇게 되면 해당 값을 업데이트하는 스레드를 제외한 다른 스레드들은 추가로 다른 작업을 진행할 수 있으며 스레드 간의 콘텍스트 스위치도 일어나지 않습니다.
Atomic Variables in Java
자바에서는 AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference 클래스를 제공합니다.
이 클래스들은 각각 원자적으로 업데이트 될 수 있는 int, long, boolean, 그리고 개체 참조를 나타냅니다.
이 클래스들의 주요 method는 다음과 같습니다 :
- get() - 다른 스레드에 의한 변경이 표시되도록 메모리로부터 값을 가져옵니다. volatile 변수를 읽는 것과 동일하게 작동합니다.
- set() - volatile 변수의 쓰기와 동일하게, 해당 값의 변경 사항을 다른스레드에서 볼 수 있도록 메모리에 값을 씁니다.
- lazySet() - 최종적으로 값을 메모리에 기록한 이후에도 관련 메모리 작업으로 순서를 변경할 수 있습니다. 해당 메서드의 한 가지 유스케이스는 가비지 컬렉션에 다시는 접근하지 않을 때 참조를 무효화하는 것입니다.(nullifying references) 이 경우 null volatile 쓰기를 지연해서 쓴다면 성능이 향상됩니다.
- compareAndSet() - 앞서 설명한 CAS 알고리즘과 동일합니다.
- weakCompareAndSet() - 앞서 설명한 CAS 알고리즘과 동일합니다 그러나 이름에서 알 수 있듯이 다른 변수에 대한 업데이트가 반드시 표시되지 않을 수 있습니다. Java 9에서 이 메서드는 weakCompareAndSetPlain() 메서드의 사용을 위해 모든 atmoic 구현체에서 deprecated 되었습니다. weakCompareAndSet() 메서드가 메모리에 미치는 효과는 미미했지만, 그 이름은 volatile 메모리 효과(effect)를 암시합니다. 이런 혼란을 막기 위해 이 메서드를 사용하지 않고 weakCompareAndSetPlain() 또는 weakCompareAndSetVolatile()과 같은 다른 메모리 효과가 있는 네 가지 메서드를 추가했습니다.
thread-safe 하게 user의 Id값을 증가시키는 코드는 아래와 같습니다.
public class UserTable {
AtomicLong userId = new AtomicLong();
public void incrementUserId(){
userId.incrementAndGet(); //현재 값을 1 증가 시키고, 변경된 값을 리턴합니다
}
}
'프로그래밍 > Java' 카테고리의 다른 글
G1 GC에 대해 (0) | 2023.03.31 |
---|---|
Singleton 패턴과 스프링에서는 Singleton 패턴을 어떻게 사용하고 있을까? (0) | 2023.01.27 |
ConcurrentHashMap은 어떻게 동시성 문제를 해결할까? (0) | 2023.01.24 |
Java Thread Pool? (0) | 2022.12.28 |
Checked Exception VS UnChecked Exception (0) | 2022.11.19 |