
멀티 스레드 환경은 여러 작업을 동시에 처리할 수 있어 성능 향상에 유리하지만, 공유 자원에 대한 접근을 제대로 제어하지 않으면 데이터 정합성 문제가 발생할 수 있다. 아래 예제를 통해 살펴보자.
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count); // 13095
위 코드는 2개의 스레드에서 count 변수를 각각 10000번씩 증가시키는 코드다. 20000이란 결과를 생각했지만, 실제 출력은 엉뚱한 값이 출력된다.
이러한 현상은 멀티 스레드 환경에서 공유 자원을 동시에 수정할 때 발생하는 Race Condition 때문이다. count++(count = count + 1)은 원자적인 연산처럼 보이지만, 내부적으로 다음과 같은 단계를 갖는다.
여러 스레드가 위 과정을 동시에 수행하면, 일부 스레드의 결과가 덮어쓰여 일부 연산이 사라지게 된다.
이런 동시성 문제를 해결하기 위한 다양한 방법에 대해 알아보자.
synchronized 키워드를 활용하면 동시성 문제를 해결할 수 있다. synchronized 키워드는 특정 코드 영역(임계영역)을 하나의 스레드만 실행하도록 보장하는 키워드로 공유 자원에 대한 동시 접근을 제어한다.
...
Thread t1 = new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
});
...
synchronized는 내부적으로 monitor lock이라는 메커니즘을 사용한다. 모든 객체는 하나의 monitor를 가지며, synchronized 블록이나 메서드에 진입할 때 해당 객체의 monitor lock을 획득해야 한다. 이미 다른 스레드가 lock을 보유하고 있다면, lock이 해제될 때까지 대기하게 된다.
synchronized는 아래와 같이 method 단위로 설정할 수 있다.
synchronized void test() {
// do anything
}
method 단위로 synchronized를 설정하면 간단하게 동기화를 적용할 수 있다는 장점이 있다. 하지만 메서드 전체가 임계 영역으로 설정되기 때문에, 실제로 동기화가 필요하지 않은 코드까지 lock의 영향을 받게 된다. 이로 인해 다른 스레드가 불필요하게 대기하게 되고, 결과적으로 성능 저하가 발생할 수 있다.
아래와 같이 사용하면 필요한 부분에만 synchronized를 적용할 수 있다
void test() {
// do something
synchronized (this) {
count++;
}
// do something
}
block 단위로 synchronized를 설정하면 실제로 동기화가 필요한 코드 영역만 임계 영역으로 지정할 수 있다. 이를 통해 불필요한 lock 범위를 줄일 수 있으며, 다른 스레드가 불필요하게 대기하는 상황을 최소화할 수 있다.
static synchronized 역시 동일하게 monitor lock을 기반으로 동작한다.
JVM에서 클래스는 로딩될 때 java.lang.Class 객체 형태로 생성되며, static synchronized 메서드는 이 Class 객체의 monitor lock을 획득하여 실행된다.아래 예제를 통해 인스턴스에 대한 lock과 클래스에 대한 lock이 별도로 동작하는 것을 확인할 수 있다.
public class LockTest {
public static void main(String[] args) throws Exception {
Person p1 = new Person();
Person p2 = new Person();
Thread t1 = new Thread(p1::instanceAdd, "T1");
Thread t2 = new Thread(p2::instanceAdd, "T2");
Thread t3 = new Thread(Person::staticAdd, "T3");
Thread t4 = new Thread(Person::staticAdd, "T4");
// start, join thread
...
}
class Person {
public synchronized static void staticAdd() {
System.out.println("staticAdd() start by " + Thread.currentThread().getName());
...
System.out.println("staticAdd() end by " + Thread.currentThread().getName());
}
public synchronized void instanceAdd() {
System.out.println("instanceAdd() start by " + Thread.currentThread().getName());
...
System.out.println("instanceAdd() end by " + Thread.currentThread().getName());
}
}
}
실행 결과를 보면 T1, T2, T3는 동시에 실행되지만 T4는 T3의 종료를 기다린 후 실행되는 것을 확인할 수 있다. 이는 instance synchronized와 static synchronized가 서로 다른 monitor lock을 사용하기 때문이다.
Java에서 synchronized는 특정 객체의 monitor lock을 획득하여 임계 영역을 보호한다. 즉, 어떤 객체를 기준으로 synchronized를 사용하느냐에 따라 lock의 범위가 결정된다.
예를 들어 다음 코드를 보자.
Thread 1 : A() 수행
Thread 2 : B() 수행
class Test {
void A() {
synchronized(this) {
...
}
}
void B() {
synchronized(this) {
...
}
}
}
위 코드에서 synchronized(this)는 현재 객체 인스턴스를 lock으로 사용한다. 따라서, A() 실행 중이면 B()는 같은 객체의 lock을 획득해야 하므로 동시에 실행될 수 없다.
만약, A()와 B()에서 서로 다른 자원에 접근하거나 독립적인 작업이라면 하나의 lock을 공유하는 것은 불필요한 대기를 발생시킨다.
class Test {
private final Object lockA = new Object();
private final Object lockB = new Object();
void A() {
synchronized(lockA) {
...
}
}
void B() {
synchronized(lockB) {
...
}
}
}
동시성을 높이기 위해 lock 객체를 분리할 수 있다. 이를 통해 A()와 B()를 동시에 수행할 수 있게 된다.
synchronized는 lock을 획득한 스레드만 임계 영역에 접근하도록 하여 강력한 동시성 제어를 제공한다. 하지만, 락에 대한 경합이 증가할수록 스레드가 대기하는 시간이 늘어나 성능 저하로 이어질 수 있다.
이러한 경우 volatile을 고려해 볼 수 있다. volatile은 변수의 가시성을 보장하기 위한 키워드이다. 멀티 스레드 환경에서는 각 스레드가 CPU 캐시를 통해 데이터를 읽고 쓰기 때문에 한 스레드에서 변경한 값이 다른 스레드에 즉시 보이지 않는 문제가 발생할 수 있다.
아래 예제를 통해 확인할 수 있다.
static volatile boolean run = true;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
while (run) {
// do something
}
}, "T1");
t1.start();
Thread.sleep(5000);
run = false; // 원자적 연산
t1.join();
}
volatile을 사용하지 않으면 T1 스레드가 run 변수의 변경을 인지하지 못해 루프가 종료되지 않을 수 있다. 하지만 volatile을 사용하면 다른 스레드에서 변경한 값을 즉시 확인할 수 있어 정상적으로 종료된다.
다만 volatile은 가시성만 보장할 뿐, 동시에 여러 스레드가 접근하는 것을 막아주지는 않는다. 즉, 원자적인 연산이 아닌 복합 연산의 경우(ex. count++)에는 여전히 동시성 문제가 발생할 수 있다.
CAS(Compare And Swap) 알고리즘은 락을 사용하지 않고 동시성을 제어할 수 있는 방법이다. 특정 메모리 위치의 값이 예상한 값과 동일한 경우에만 새로운 값으로 교체하는 방식으로 동작하며, 이때 비교와 교체 과정은 하나의 원자적 연산으로 수행된다.
CAS는 충돌이 발생했을 때 재시도를 통해 값을 갱신하는 방식으로 자주 활용된다. 따라서 여러 스레드가 동시에 값을 수정하려는 경합이 심해지면 반복적인 재시도로 인해 성능이 저하될 수 있다. 그러나 CAS는 락을 획득하기 위해 스레드가 대기하거나 블로킹되지 않기 때문에, 특정 스레드의 지연이 전체 처리 흐름을 멈추게 하지 않는 Non-blocking 구조에 유리하다.
아래는 Java의 AtomicInteger를 사용하여 CAS를 구현한 대표적인 예제이다.
AtomicInteger counter = new AtomicInteger(0);
void increment() {
int oldValue, newValue;
do {
oldValue = counter.get();
newValue = oldValue + 1;
} while (!counter.compareAndSet(oldValue, newValue));
}
CAS는 조건이 만족될 때만 값을 원자적으로 변경하는 연산이므로, 위 예제와 다르게 실패했을 경우 재시도 대신 다른 작업을 수행하거나 즉시 반환하는 등의 non-blocking 설계에도 활용될 수 있다.
Java의 Atomic 클래스는 내부적으로 JVM intrinsic을 통해 CPU가 제공하는 CAS 명령을 사용하여, 여러 단계로 이루어진 복합 연산을 하나의 원자적 작업처럼 수행할 수 있도록 지원한다.
정리하자면 lock을 사용하는 기법은 스레드가 Blocking 되어 다른 작업을 수행하지 못하지만, CAS는 충돌이 발생하더라도 스레드를 대기 상태로 만들지 않고 재시도를 하는 non-Blocking 방식이다.