글을 읽기 전 자바의 모니터에 대한 지식이 없다면 필자의 다른 글 자바 객체의 Lock 과 Monitor 이해하기를 먼저 읽는 것을 추천드립니다.
synchronized 개요
[synchronized 키워드란?]
synchronized 키워드는 자바에서 멀티스레드 환경에서 동기화를 보장하기 위해 사용된다. 특정 블록이나 메서드를 임계영역(critical section)으로 설정하여 하나의 스레드만 접근할 수 있도록 만들어 데이터 불일치 문제(race condition)를 방지하고, 여러 스레드가 공유 리소스를 안전하게 사용할 수 있도록 하는 것이 목적이다.
[synchronized 는 어떻게 적용하는가?]
메서드 전체를 감쌀지, 문장만 감쌀지, static 을 붙일지 말지 선택해서 적용할 수 있다.
메서드에 synchronized 로 선언할지, 메서드 내의 특정 문장만 synchronized 로 감쌀지에 따라 구분하면
- synchronized methods
- synchronized statements
public synchronized void doSomeThing() {
...
}
synchronized(int value) {
...
}
객체단위인지, 클래스단위 인지에따라 구분하면 (static 을 붙일지, 안붙일지)
- synchronized
- static synchronized
synchronized 는 모니터락을 이용해 동기화하기 때문에 성능 오버헤드가 존재한다. 따라서 method, statments 어떤 방식을 사용하던, 필요한 부분만 동기화하는 것이 좋다.
[synchronized 는 언제 사용할까?]
여러 쓰레드가 한 객체에 선언된 메서드에 접근해 데이터를 처리하려고 할 때, 동시 연산에 의해 값이 잘못될 가능성이 있을 경우 사용한다. 즉, 임계영역이 될 특정 코드블록을 지정하고 거기서 작업을 해줘야 할 경우 동기화를 위해서만 쓴다. 이는 매개변수나 메서드에서만 사용하는 지역변수만 다루는 메서드는 synchronized 를 적용할 필요가 없다는 의미다.
아래 예시를 보면 count++ 는 임계영역인데, 동기화가 없으므로, 원자성 문제가 발생할 수 있다.
public class PureCounter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
count++는 단순한 연산처럼 보이지만, 실제로는 세 단계로 분리되어 실행된다.
- count 값을 읽어온다. (load)
- count 값에 1을 더한다. (increment)
- count 값을 다시 저장한다. (store)
이 과정이 한 번의 연산(원자적 연산)이 아니라 여러 개의 연산으로 이루어져 있기 때문에,
멀티스레드 환경에서는 다음과 같은 문제가 발생할 수 있다.
- 스레드 A가 count = 100을 읽어옴
- 스레드 B가 count = 100을 읽어옴
(둘 다 count 값을 동일하게 읽었음) - 스레드 A가 count + 1 연산을 수행하고 101을 저장
- 스레드 B도 count + 1 연산을 수행하고 101을 저장
(올바른 값이어야 할 102가 아니라 101로 덮어씀) - 결과적으로 count 값이 1만 증가하는 문제 발생
즉, 한 번의 연산이 여러 단계로 나뉘어 실행되면서 스레드 간 간섭이 발생한다.
@Test
void synchronized를_사용하지_않으면_동기화문제가_일어날_수_있다() throws InterruptedException, ExecutionException, TimeoutException {
PureCounter pureCounter = new PureCounter();
ExecutorService executorService = Executors.newFixedThreadPool(50);
CountDownLatch latch = new CountDownLatch(5000);
for (int i = 0; i < 5000; i++) {
executorService.submit(() -> {
pureCounter.increment();
latch.countDown();
});
}
latch.await();
assertThat(pureCounter.getCount()).isEqualTo(5000);
}
쓰레드풀 개수를 50개로 잡고 5000 번 increment() 를 실행한 뒤, 값이 5000 인지 확인해보면
테스트가 실패한다. 여기에 synchronized 키워드를 붙이고 실행하면 테스트가 성공하는 것을 확인할 수 있다.
synchronized 동작 원리
[synchronized 는 어떻게 동작할까?]
synchronized 키워드가 메서드 선언에 붙으면, 객체의 경우 그 메서드를 포함하는 인스턴스(this)의 모니터 락(Monitor Lock)을 획득하게 된다.
public class Counter {
private int count = 0;
public synchronized void increment() {
Thread.sleep(3000);
count++;
}
public synchronized int getCount() {
return count;
}
}
- increment()를 호출하는 스레드는 Counter 객체의 모니터 락을 획득해야 실행 가능하다.
- getCount()도 마찬가지로 Counter 객체의 모니터 락을 획득해야 실행 가능하다.
즉, 같은 객체에서 실행되는 모든 synchronized 메서드는 하나의 스레드만 실행할 수 있다. 다른 스레드가 synchronized 메서드를 호출하려고 하면, 기존 스레드가 락을 해제할 때까지 블로킹된다. 예를 들면 increment() 를 호출하는 도중 다른 쓰레드에서 같은 객체의 increament() 나 getCount() 를 호출할 수 없다.
테스트를 통해 확인해보면,
@Test
void increment메서드가_실행중이면_다른쓰레드에서_increment호출은_블로킹된다() throws InterruptedException {
Counter counter = new Counter();
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
AtomicBoolean blocked = new AtomicBoolean(true);
new Thread(() -> {
latch1.countDown();
counter.increment();
}).start();
latch1.await();
new Thread(() -> {
counter.increment();
blocked.set(false);
latch2.countDown();
}).start();
Thread.sleep(2000);
// 2초 후에도 블로킹상태이므로 true
assertThat(blocked.get()).isTrue();
latch2.await();
assertThat(blocked.get()).isFalse();
}
@Test
void increment메서드가_실행중이면_다른쓰레드에서_getCount호출은_블로킹된다() throws InterruptedException, ExecutionException, TimeoutException {
Counter counter = new Counter();
ExecutorService executorService = Executors.newFixedThreadPool(2);
Future<?> incrementFuture = executorService.submit(counter::increment);
Thread.sleep(500);
Future<Integer> getCountFuture = executorService.submit(counter::getCount);
// increment() 에서 모니터락 잡고 3초 대기중이므로, 2초내에 future.get 은 TimeoutException 발생
assertThatThrownBy(() -> getCountFuture.get(2, TimeUnit.SECONDS))
.isInstanceOf(TimeoutException.class);
incrementFuture.get();
// increment()가 끝난 후에는 getCount()가 정상 실행
assertThat(getCountFuture.get()).isEqualTo(1);
executorService.shutdown();
}
첫번째 테스트에서, 다른쓰레드에서 호출한 increment()는 블로킹되어, 2초가 흐른 시점에서 blocked 변수는 변하지 않음을 확인했다.
두 번째 테스트에서, increment() 가 실행중일 때 모니터락을 잡았으므로 다른쓰레드의 future.get 에서 2초 타임아웃으로 받아오려하면 TimeoutExcetion 이 발생한다. 첫번째 쓰레드가 끝나고 future.get() 을 호출하면 정상적으로 받아와짐을 확인했다.
따라서 객체단위로 모니터락이 걸리기 때문에, 다른 쓰레드가 이미 락을 점유하고 있을 경우 블로킹됨을 확인할 수 있다.
[static synchronized 는 어떻게 동작할까?]
static synchronized 의 경우도 마찬가지로 모니터락을 이용한다. 자바의 클래스는 JVM이 로드할 때 java.lang.Class 객체로 표현된다. 즉, 각 클래스는 Class 타입의 객체를 가지고 있으며, JVM에서 클래스 정보를 담고 있는 일종의 메타데이터 역할을 한다. static synchronized 는 클래스 객체(Class 인스턴스)를 통해 모니터 락을 획득 한다.(즉, ClassName.class)
@Test
void 클래스객체를_가져와_동일한_Class인스턴스를_참조하는지_확인한다() throws ClassNotFoundException {
Class<?> class1 = Counter.class;
// 패키지명.클래스명
Class<?> class2 = Class.forName("synchronizedblock.Counter");
Class<?> class3 = new Counter().getClass();
assertThat(class1 == class2).isTrue();
assertThat(class1 == class3).isTrue();
assertThat(class2 == class3).isTrue();
}
[그러면, static synchronized 와 synchronized 는 모니터락이 공유될까?]
일단 생각해보면, Class 인스턴스와 직접 생성한 인스턴스는 서로다른 인스턴스다. 따라서 Lock 은 별도로 동작할 것으로 예상할 수 있다.
아래처럼 생각하고, 테스트로 짜보면
- 각각 3초 대기하는 클래스 단위 락 (static synchronized) 과 인스턴스 단위 락 (synchronized) 을 동시에 실행 후 실행이 끝나면 CountDownLatch 감소
- 서로 다른 모니터 락을 사용하면, 두 작업은 늦어도 5초 이내에는 실행되어야 함
class LockTest {
public static synchronized void staticMethod(CountDownLatch latch) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
latch.countDown();
}
public synchronized void instanceMethod(CountDownLatch latch) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
latch.countDown();
}
}
@Test
void static_synchronized와_synchronized는_서로_다른_락을_사용한다() throws InterruptedException, ExecutionException {
LockTest obj = new LockTest();
CountDownLatch latch = new CountDownLatch(2);
long startTime = System.currentTimeMillis();
new Thread(() -> LockTest.staticMethod(latch)).start();
new Thread(() -> obj.instanceMethod(latch)).start();
latch.await(); // 두 작업이 끝날 때까지 대기
long endTime = System.currentTimeMillis();
long elapsedTime = endTime - startTime;
assertThat(elapsedTime).isLessThan(5000L);
}
만약 같은 락을 사용했다면 3초 실행 대기후 3초 대기하므로, 적어도 6초 이상 실행시간이 소요되었을 것이다. 5초 이내로 끝나므로, 모니터락을 공유하지 않는 것을 확인할 수 있다.
[synchronized 키워드로 가시성 문제도 해결할 수 있을까?]
멀티스레드 환경에서는 한 스레드가 변수 값을 변경했을 때 CPU 캐시와 메인 메모리의 불일치로 다른 스레드가 읽었을 때 변경된 값이 아닌 기존 값을 읽을 수도 있다. 이를 가시성 문제라고 한다. JVM 은 happens-before 관계를 보장해 메모리 가시성 문제가 발생하지 않도록 한다. 즉, 한 스레드가 모니터락을 해제하면, 이후 해당 락을 획득한 스레드는 변경된 데이터를 즉시 볼 수 있다.
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.5
Chapter 17. Threads and Locks
class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder
docs.oracle.com
내용이 이해하기 어렵게 쓰여 있는데, "synchronized 블록을 사용하면, unlock 동작이 이후 lock 동작보다 먼저 발생하므로, unlock 이전의 변경 사항이 lock 이후의 스레드에 보인다" 로 이해했다.
synchronized 문제점
[synchronized 는 어떤 문제점이 있을까?]
- Context Switching 오버헤드
- synchronized를 사용하면 JVM이 내부적으로 락을 획득하고 해제하는 과정이 필요하며, 이 과정에서 OS 레벨의 쓰레드 스케줄링이 개입될 수 있다. 특히, 여러 쓰레드가 락을 기다리는 경우 컨텍스트 스위칭이 빈번하게 발생해 성능 저하로 이어진다.
- Lock Contention
- 여러 쓰레드가 하나의 락을 두고 경쟁하면, 일부 쓰레드는 락을 얻을 때까지 대기해야 하므로 병렬 처리 효율성이 감소한다. 따라서 CPU 사용률이 낮아지고, 전체적인 애플리케이션의 응답성을 떨어뜨릴 수 있다.
- 캐시 일관성 문제
- 멀티코어 환경에서 synchronized는 CPU 캐시와 메모리 간의 동기화가 필요하므로, OS 레벨에서 캐시 미스(Cache Miss)가 증가하고 성능이 저하될 수 있다.
- Deadlock 가능성
- 여러 개의 synchronized 블록을 사용할 때 잘못된 락 순서로 인해 교착 상태가 발생할 수 있다. synchronized 키워드만으로는 데드락 문제를 해결할 수 없다.
synchronized 메커니즘은 Java에서 여러 쓰레드가 공유 객체에 접근할 때 동기화를 보장하기 위한 탄생한 최초의 메커니즘이다. 간단한 동기화 처리에는 유용하지만, 위에서처럼 문제점이 존재하기 때문에 Java 5부터는 보다 세밀한 동시성 제어를 지원하는 다른 동시성 유틸리티 클래스들이 도입되었다.
'Java' 카테고리의 다른 글
[Java] 자바 객체의 Lock 과 Monitor 이해하기 (0) | 2025.03.09 |
---|---|
[Java] Blocking Queue 이해하고 사용해보기 (1) | 2025.03.07 |
[Java] Callable, Feature 이해 및 사용예시 (0) | 2025.02.26 |
[Java] 열거형(Enum) 이해하고 사용하기 (1) | 2025.02.22 |
[Java] Thread, Runnable 이해하고 사용하기 (1) | 2025.02.21 |