[CS] Synchronized

U·2025년 11월 10일
post-thumbnail

📚 Synchronized

"동시에 일어나는"이라는 뜻을 가진 Synchronized는 자바에서 동시성 문제를 해결하기 위해 제공되는 키워드이다. 여러 스레드가 동시에 하나의 공유 자원에 접근하려 할 때 발생할 수 있는 문제를 방지해준다.

synchronized가 붙은 코드 블록이나 메서드를 임계 영역으로 지정하고, 모니터 락 또는 고유 락이라는 내부 잠금 메커니즘을 사용한다.

이때 한 번에 오직 하나의 스레드만이 해당 임계 영역을 실행할 수 있도록 보장하며, synchronized 블록이 끝나면 해당 스레드에서 변경된 모든 공유 변수의 내용이 메인 메모리에 반영된다. 이후 다른 스레드가 락을 획득할 때 메인 메모리에서 최신 값을 읽어온다.

synchronized메서드 전체를 동기화하는 방식과 메서드 내의 특정 코드 블록만 동기화하는 방식 두 가지 형태로 사용할 수 있다.

Method vs Static Method

두 방식의 가장 결정적인 차이는 잠금 대상으로, 어떤 스레드가 락을 거는지, 그 락을 기준으로 다른 스레드들이 대기하는지가 다르다.

📌 synchronized Instance Method

인스턴스 메서드에 synchronized를 붙이면, 현재 객체의 인스턴스가 락의 대상이 된다. 따라서 잠금 대상은 메서드를 호출한 객체 인스턴스가 된다.

  public class Worker {
      // 'this' (Worker 인스턴스)에 락을 걺
      public synchronized void doWork() {
          // ... 임계 영역 ...
      }
  }

  Worker workerA = new Worker();
  Worker workerB = new Worker();

  // Thread A, B는 workerA의 락을 두고 경쟁
  new Thread(() -> workerA.doWork(), "Thread-1").start();
  new Thread(() -> workerA.doWork(), "Thread-2").start();

  // Thread C는 workerB의 락을 사용하므로 위 스레드들과 경쟁하지 않음
  new Thread(() -> workerB.doWork(), "Thread-3").start();
  • workerA 객체의 doWork() 메서드를 A 스레드가 호출 -> A 스레드는 workerA 객체의 락 획득
  • B 스레드가 동일한 workerA 객체의 doWork() 또는 다른 synchronized 인스턴스 메서드를 호출 -> B 스레드는 A 스레드가 락을 놓을 때까지 대기해야 함
  • C 스레드가 새로운 workerB 객체의 doWork()를 호출 -> C 스레드는 workerB 객체의 락을 획득하므로 B 스레드와 경쟁하지 않고 즉시 실행

📌 synchronized Static Method

반면 정적 메서드에 synchronized를 붙이면 해당 클래스의 Class 객체가 락의 대상이 된다.

  public class Worker {
      // 'Worker.class' 객체에 락을 걺
      public static synchronized void doStaticWork() {
          // ... 임계 영역 ...
      }
  }

  Worker workerA = new Worker();
  Worker workerB = new Worker();

  // Thread A, B, C 모두 'Worker.class' 락을 두고 경쟁
  // 어떤 인스턴스(workerA, workerB)로 호출하든 상관없음
  new Thread(() -> Worker.doStaticWork(), "Thread-1").start();
  new Thread(() -> workerA.doStaticWork(), "Thread-2").start();
  new Thread(() -> workerB.doStaticWork(), "Thread-3").start();
  • A 스레드가 Worker.doStaticWork() 정적 메서드 호출 -> A 스레드는 Worker.class 객체의 락 획득
  • 획득된 락은 해당 클래스의 모든 인스턴스에서 공유됨
  • B 스레드가 workerA.doStaticWork()를 호출하든, workerB.doStaticWork()를 호출하든, Worker.doStaticWork()로 직접 호출하든 Worker.class 락을 획득해야 하므로 A스레드가 끝날 때까지 대기

Singletone

하나의 클래스에 오직 하나의 인스턴스만 가지는 패턴으로, 데이터베이스 연결 모듈에 많이 사용된다.

하나의 인스턴스를 다른 모듈들이 공유하며 사용하기 때문에 인스턴스 생성 비용 줄어들지만, 의존성이 높아진다는 단점이 있다.

🔒 Lazy Initialization

  public class Singleton {
      private static Singleton instance;

      private Singleton() {}

      // getInstance() 호출 시에만 인스턴스 생성
      public static Singleton getInstance() {
          if (instance == null) {
              instance = new Singleton();
          }
          return instance;
      }
  }

가장 기본적인 싱글톤 구현으로, 필요할 때만(getInstance()를 호출할 때만) 인스턴스를 생성하여 리소스 낭비를 줄인다.

하지만 멀티스레드 상황에서 두 개의 스레드가 동시에 getInstance()를 호출한다면 싱글톤이 두 번 생성되는 상황이 발생할 수 있어 Thread-safe하지 않다.

🔒 Thread-safe Lazy Initialization

  public class Singleton {
      private static Singleton instance;

      private Singleton() {}

      // synchronized 메서드 선언
      public static synchronized Singleton getInstance() {
          if (instance == null) {
              instance = new Singleton();
          }
          return instance;
      }
  }

Lazy Initialization 방식에서 getInstacne() 메서드에 synchronized를 붙이면 Thread-safe하지 않다는 단점을 보완할 수 있다.

하지만 이 synchronized로 인해 호출이 많을 경우 성능 저하가 생길 수 있다. 매번 getInstacne()를 호출할 때마다 락을 획득하는데, 인스턴스가 이미 생성된 이후에도 계속 락을 거는 오버헤드가 발생하기 때문이다.

🔒 Double-Checked Locking (DCL)

  public class Singleton {
      private static volatile Singleton instance; // volatile 키워드 필수

      private Singleton() {}

      public static Singleton getInstance() {
          if (instance == null) { // (1) 1차 검사 (락 없이)
              synchronized (Singleton.class) { // (2) 락 획득
                  if (instance == null) { // (3) 2차 검사
                      instance = new Singleton(); // (4) 인스턴스 생성
                  }
              }
          }
          return instance;
      }
  }

Thread-safe Lazy Initialization 방식에서 메서드 단위의 synchronized 대신 synchronized 블록을 선언하면 불필요한 락 획득을 피해 성능을 최적화할 수 있다.

(1) 1차 검사 : 대부분의 경우 인스턴스는 미리 생성되어 있기 때문에, 1차 null 확인 로직에서 바로 락 없이 return이 가능하다.

(2) 락 획득 : 만약 인스턴스가 없는 경우에는 락을 걸어 동시성을 제어하는데, 락 범위를 최소화하여 성능을 높인다.

(3) 2차 검사 : 여러 스레드가 1차 검사에서 null을 보고 들어올 수 있기 때문에, 락 안에서도 한 번 더 null인지 확인해야 안전하다.

만약 2차 검사 로직이 없다면 아래과 같이 진행되어 싱글톤의 기본 원칙인 "오직 한 개의 인스턴스"가 깨진다.

Thread A, B가 동시에 들어옴 -> Thread A가 먼저 락을 잡고 인스턴스 생성 -> instance != null 상태 -> Thread A가 락을 풀고 나감 -> Thread B도 이미 if (instance == null)를 통과한 상태기 때문에 락 획득 및 인스턴스 생성

또한 DCL 방식에서는 꼭 Singleton 객체를 volatile로 선언해주어야 한다.

💡 volatile이란?

  • 변수의 값을 CPU 캐시가 아닌 메인 메모리(RAM)에 직접 저장/읽기하도록 강제하는 키워드
  • 가시성(visibility)를 보장해서 어떤 스레드가 변경한 값을 다른 스레드도 즉시 볼 수 있도록 보장하며 재정렬(Instruction Reordering)을 막는 역할도 함

재정렬은 자바 컴파일러, JVM, CPU에서 성능 최적화를 위해 명령어의 순서를 바꾸는 것으로, 결과만 같다면 내부 순서를 변경해도 된다고 판단하는 것이다.

따라서 volatile은 JVM과 CPU가 명령 순서를 재정렬하지 못하도록 막는 역할을 해서 다른 스레드가 접근하지 못하게 막는다.

🔒 Static Inner Class : Holder 패턴

  public class Singleton {
  	  private Singleton() {}
      
      private static class singleInstanceHolder {
          private static final Singleton INSTANCE = new Singleton();
      }

      public static Singleton getInstance() {
          return singleInstanceHolder.INSTANCE;
      }
  }

중첩 클래스를 이용해서 구현한 방식은 DCL의 장점에 깔끔한 코드라는 점도 추가된다. 이 방법이 가장 대중적이며 실무에서도 가장 자주 쓰인다고 한다.

이 방식은 getInstance()가 처음 호출될 때 Holder 클래스가 로딩되며, JVM이 클래스 초기화를 Thread-safe하게 처리한다. 따라서 동시에 여러 스레드가 접근해도 인스턴스는 딱 한 번만 생성된다.

profile
백엔드 개발자 연습생

0개의 댓글