[Java] Thread-safe하게 변수 관리하는 방법들
- [ Languages ]/Java
- 2023. 11. 3.
개요
자바에서 일반적으로 사용되는 변수는 여러가지가 있는데, 흔히 유명한 것들이 메소드나 블록 내에서만 유효한 로컬 변수(local variable)나 매개변수(paramter), 객체의 생성과 함께 관리되는 인스턴스 변수(instance variable), static 키워드와 함께 선언되어 클래스 로딩 시점에 초기화되는 스태틱 변수(static variable)와 같은 변수들이 있다.
하지만 위와 같은 변수들은 멀티쓰레드 환경에서의 safety를 보장하지 않는다. 멀티쓰레드 환경에서는 여러 쓰레드가 동시에 동일 데이터에 접근을 시도하는 경우 race-condition으로 인한 가시성 문제(한 쓰레드에서 수정한 사항이 다른 쓰레드에서 보이지 않음)나 동시 접근 문제(여러 쓰레드가 동시에 접근하여 값이 유효하게 동작하지 않음) 등이 발생할 수 있다. stack영역의 경우 쓰레드간 공유가 이루어지지 않으나, heap과 static영역은 쓰레드간 공유가 이루어지므로 이러한 공유 자원에 대한 관리를 해주어야 하는데, 이를 위해서 제목에서 소개한 다양한 방법들이 사용된다.
No Synchronization
일반적으로 동기화를 고려하지 않은 변수의 경우, 메모리 혹은 캐시에서 읽어오고 처리한 이후 다시 메모리나 캐시에 쓰는 작업으로 동작한다. 이러한 방법은 싱글 쓰레드 환경에서는 문제가 발생하지 않지만, multi-core, multi-CPU, multi-level caches 환경에서는 제대로 동작하지 않을 수 있다.
Synchronized
Synchronized 키워드를 사용하면 같은 객체에 한하여 쓰레드간 동기화를 보장해준다(단, static 메서드에 synchronized를 적용할 경우에는 모든 인스턴스에 대해서 동기화를 보장한다). 또한 객체 내부의 모든 synchronized가 사용된 메서드에 락을 걸기 때문에, synchronized가 걸려있는 메서드간에는 상호 충돌이 발생하지 않는다.
(테스트 github: https://github.com/eckrin/various-tests)
synchronized는 Lock과 blocking을 사용하여 멀티 스레드 환경에서 공유 객체를 동기화하는 키워드이다. 그러나 blocking에는 여러 가지 단점이 존재하는데, 그 중에서 성능이 가장 큰 단점으로 꼽힌다.
특정 스레드가 해당 동기화 블락 전체에 lock을 걸면, 해당 lock에 접근하는 스레드들은 blocking 상태에 들어가기 때문에 아무 작업도 하지 못한 채 자원을 낭비한다. 또한 blocking 상태의 스레드를 준비 혹은 실행 상태로 변경하기 위해 시스템의 자원을 사용해야 하기 때문에, 이로 인해서 성능의 저하가 일어나게 된다.
또한 synchronized 키워드는 하나의 프로세스에서만 동시성을 보장하기 때문에, 서버 여러대가 존재하여 각각의 서버에서 같은 db의 데이터를 공유하게 될 경우 동시성을 보장하지 못하게 된다.
이러한 문제들 때문에 non-blocking 방법을 사용하여 원자성을 보장하기 위한 방법으로 사용되는 것이 Atomic 변수이다.
AtomicInteger
AtomicInteger는 int 자료형을 가지고 있는 wrapper 클래스이다. AtomicInteger 외에도 AtomicBoolean, AtomicLong 등의 래퍼 클래스들이 존재한다.
Atomic 변수는 CPU 레벨에서의 CAS(Compare-And-Swap) 방식을 사용하여 동작한다. 이러한 CAS작업은 원자성을 가진다.
멀티 쓰레드 환경에서는 메모리를 참조할 때, 각 쓰레드별로 할당된 별도의 캐시를 참조하게 될 수 있다. 따라서 CAS는 현재 쓰레드의 캐시에 저장된 값과, 메모리에 할당한 값을 비교하여 일치하는 경우 값을 업데이트하고, 일치하지 않는 경우에는 값을 업데이트하지 못하고 재시도하게 된다.
특이한 점은 AtomicInteger 내에서 사용하는 value값이 volatile int형으로 선언되어 있다는 점이다. 아래에서 설명하겠지만 volatile은 무조건 캐시가 아닌 메인 메모리를 참조하여 값을 가지고 오는데, 따라서 멀티쓰레드 환경에서 다중 쓰레드 접근이 이루어질 때에도 변수의 가시성과 원자성을 유지할 수 있다.
Volatile
Volatile이라는 키워드를 사용할 경우, 모든 volatile 변수는 컴퓨터의 메인 메모리로부터 직접 읽어지고, 쓰기 작업시에도 메인 메모리로 직접 쓰여진다(CPU 캐시를 사용하지 않는다). 이 방법은 하나의 쓰레드에서 쓰기 작업이 이루어지는 경우, 나머지 모든 쓰레드들에서 해당 변수를 읽는 경우의 가시성을 보장하지만, 동시에 여러 쓰레드에서 쓰기 작업이 이루어지는 경우에는 동시성을 보장하지 않는다.
ThreadLocal
ThreadLocal은 쓰레드마다 독립적으로 할당된 로컬 변수를 제어할 수 있도록 자바에서 제공하는 기능이다. 우리는 ThreadLocal의 get()또는 set() 메서드를 통해서 쓰레드에 독립적으로 할당된 로컬 변수에 접근할 수 있다. 이 변수는 쓰레드마다 독립적으로 할당되므로, 다른 쓰레드에서 접근할 수 없다.
ThreadLocal은 set(), get(), remove()라는 3개의 메소드를 사용하여 쉽게 관리할 수 있다.
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}
활용 예시)
ThreadLocal<CustomClass> tl = new ThreadLocal<>();
// ThreadLoca.set()을 사용하여 값 저장
tl.set(val);
// ThreadLocal.get()을 사용하여 값 가져오기
CustomClass ex = tl.get();
// ThreadLocal.remove()를 사용하여 ThreadLocal 메모리에 할당된 값 모두 제거 (메모리 free)
tl.remove();
앞서 이야기했듯이 ThreadLocal을 사용하여 생성된 변수는 쓰레드마다 독립적으로 할당되므로, 다른 쓰레드에서 접근할 수 없다.
다만 ThreadLocal을 사용할 때 반드시 유의해주어야 하는 점이 있는데, WAS와 같이 쓰레드 풀을 활용하는 경우 쓰레드가 종료되고 나면 쓰레드 풀에 할당된 메모리는 초기화되지 않고 그대로 반환되기에, 쓰레드 종료 전에 반드시 remove() 메소드를 통해 threadLocal에 할당됐던 메모리 공간을 초기화해주어야 한다.
만약 쓰레드 풀에 개인정보와 같은 민감한 정보들이 저장될 경우 사용이 끝난 ThreadLocal을 제거해주지 않으면, 새로운 요청에 대해 쓰레드 풀을 할당해주었을 때 제거되지 않은 타 사용자의 정보가 노출되는 일도 발생할 수 있다.
ThreadLocal은 쓰레드 단위 변수 할당을 위한 여러 기능들에서 활용되고 있는데, 대표적으로 Spring security에서 SecurityContextHolder가 SecurityContext를 제공할때 ThreadLocal을 사용하여 다중 쓰레드 환경에서 각각의 쓰레드에 대해서 독립적인 보안 환경을 유지할 수 있고, 트랜잭션이 Datasource가 database connection을 획득하는 과정에서도 커넥션이 저장된 TransactionContext 객체를 ThreadLocal을 사용하여 관리함으로서 멀티쓰레드 환경에서 쓰레드간의 트랜잭션간의 독립성을 보장한다.
(https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html)
정리
- ThreadLocal을 제외하고, synchronized, atomic-, volatile 중에서는 synchronized가 가장 낮은 성능을 보인다. 이는 synchronized 키워드가 blocking방식을 채택하고 있으며, lock을 사용함으로써 다른 방법보다 strict하고 inflexible한 방법을 채택하고 있기 때문으로 보인다.
- volatile 키워드는 cpu cache를 사용하는 대신, memory에 직접 i/o를 진행한다. 따라서 멀티쓰레드 환경에서 thread-safe할 수는 있으나, cache를 사용하는 방법보다 성능 하락이 있을 수 있음을 짐작할 수 있다.
- 멀티쓰레드 환경에서, 모든 쓰레드는 cpu cache를 공유할 수도, 공유하지 않을 수도 있다. 일반적으로 L1~L3까지로 구성된 cpu cache의 경우, L1과 같은 상위 캐시는 seperated한 상태로 사용할 수 있으나, 메모리와 가까운 L3와 같은 하위 캐시는 shared한 상태로 사용할 수 있다. 다만 이것은 고정된 방법은 아니고, 아키텍처와 configuration에 의존적이다.
'[ Languages ] > Java' 카테고리의 다른 글
[Java] Collection (0) | 2024.08.10 |
---|---|
[Java] 자바의 예외(Exception) 처리 (0) | 2023.02.12 |
[Java] 람다식 이해하기 (0) | 2023.02.07 |
[Java] stream (0) | 2022.08.07 |
[Java] 자바의 동작 원리와 특징 (0) | 2022.07.08 |