[Java] synchronized block 이해하고 사용하기

2025. 3. 9. 23:52·Java

글을 읽기 전 자바의 모니터에 대한 지식이 없다면 필자의 다른 글 자바 객체의 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++는 단순한 연산처럼 보이지만, 실제로는 세 단계로 분리되어 실행된다.

  1. count 값을 읽어온다. (load)
  2. count 값에 1을 더한다. (increment)
  3. count 값을 다시 저장한다. (store)

이 과정이 한 번의 연산(원자적 연산)이 아니라 여러 개의 연산으로 이루어져 있기 때문에,
멀티스레드 환경에서는 다음과 같은 문제가 발생할 수 있다.

  1. 스레드 A가 count = 100을 읽어옴
  2. 스레드 B가 count = 100을 읽어옴
    (둘 다 count 값을 동일하게 읽었음)
  3. 스레드 A가 count + 1 연산을 수행하고 101을 저장
  4. 스레드 B도 count + 1 연산을 수행하고 101을 저장
    (올바른 값이어야 할 102가 아니라 101로 덮어씀)
  5. 결과적으로 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] Java16 에 추가된 Pattern Matching for instanceof  (1) 2025.04.26
[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' 카테고리의 다른 글
  • [Java] Java16 에 추가된 Pattern Matching for instanceof
  • [Java] 자바 객체의 Lock 과 Monitor 이해하기
  • [Java] Blocking Queue 이해하고 사용해보기
  • [Java] Callable, Feature 이해 및 사용예시
기억은 RAM, 기록은 HDD
기억은 RAM, 기록은 HDD
  • 기억은 RAM, 기록은 HDD
    적립식 개발
    기억은 RAM, 기록은 HDD
  • 전체
    오늘
    어제
    • 분류 전체보기 (37)
      • Gradle (1)
      • 알고리즘 (14)
        • 강한 연결 요소 (1)
        • BFS (1)
        • 다이나믹 프로그래밍 (2)
        • 그리디 (1)
        • 투 포인터 (2)
        • 비트마스크 (1)
        • 스택 (1)
        • 백트래킹 (1)
        • 유니온-파인드 (1)
        • 기초 기하학 (1)
        • 분할정복을 이용한 거듭제곱 (1)
        • 볼록 껍질 (1)
      • JPA (3)
      • Java (8)
      • Spring (7)
      • Git&GitHub (1)
      • Infra (1)
  • 최근 글

  • 인기 글

  • 태그

    자바 callable
    비트마스킹
    자바 runnable
    자바synchronized키워드
    데몬쓰레드
    java 라이브러리 추가
    api 문서 작성
    기하학
    자바future
    @embedded
    정렬
    java synchronized
    enum정리
    java
    완전탐색
    투 포인터
    swagger커스텀
    enum활용법
    thread 와 runnable
    통합테스트설정
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.1
기억은 RAM, 기록은 HDD
[Java] synchronized block 이해하고 사용하기
테마상단으로

티스토리툴바