[JAVA] 자바 synchronized 를 바이트코드로 까보며 이해하기

2026. 4. 2. 20:38·Language/Java

자바에서 synchronized는 모니터 락을 잡는다 정도로 보통 설명하지만, 실제로 JVM 바이트코드 레벨에서는 어떻게 구현되는지 궁금해졌다. 간단한 Account 예제를 작성해서 javap -c -v로 디스어셈블하고, synchronized 블록과 메서드가 어떤 바이트코드로 바뀌는지 직접 확인해 보았다.

1. 예제 코드와 디스어셈블 결과

이번에 사용한 예제 바이트코드는 대략 다음과 같다.

public class Account
  minor version: 0
  major version: 65
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #8                          // Account
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 8, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // Account.balance:D
   #8 = Class              #10            // Account
   #9 = NameAndType        #11:#12        // balance:D
  #10 = Utf8               Account
  #11 = Utf8               balance
  #12 = Utf8               D
  #13 = Utf8               (I)V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               rawWithdraw
  #17 = Utf8               (I)Z
  #18 = Utf8               StackMapTable
  #19 = Utf8               rawDeposit
  #20 = Utf8               getRawBalance
  #21 = Utf8               ()D
  #22 = Utf8               safeWithdraw
  #23 = Class              #24            // java/lang/Throwable
  #24 = Utf8               java/lang/Throwable
  #25 = Utf8               safeWithdrawToMethod
  #26 = Utf8               safeDeposit
  #27 = Utf8               getSafeBalance
  #28 = Utf8               SourceFile
  #29 = Utf8               Account.java
{
  public Account(int);
    descriptor: (I)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iload_1
         6: i2d
         7: putfield      #7                  // Field balance:D
        10: return
      LineNumberTable:
        line 4: 0
        line 5: 4
        line 6: 10

  public boolean rawWithdraw(int);
    descriptor: (I)Z
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=5, locals=2, args_size=2
         0: aload_0
         1: getfield      #7                  // Field balance:D
         4: iload_1
         5: i2d
         6: dcmpl
         7: iflt          23
        10: aload_0
        11: aload_0
        12: getfield      #7                  // Field balance:D
        15: iload_1
        16: i2d
        17: dsub
        18: putfield      #7                  // Field balance:D
        21: iconst_1
        22: ireturn
        23: iconst_0
        24: ireturn
      LineNumberTable:
        line 10: 0
        line 11: 10
        line 12: 21
        line 15: 23
      StackMapTable: number_of_entries = 1
        frame_type = 23 /* same */

  public void rawDeposit(int);
    descriptor: (I)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=5, locals=2, args_size=2
         0: aload_0
         1: aload_0
         2: getfield      #7                  // Field balance:D
         5: iload_1
         6: i2d
         7: dadd
         8: putfield      #7                  // Field balance:D
        11: return
      LineNumberTable:
        line 19: 0
        line 20: 11

  public double getRawBalance();
    descriptor: ()D
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: getfield      #7                  // Field balance:D
         4: dreturn
      LineNumberTable:
        line 23: 0

  public boolean safeWithdraw(int);
    descriptor: (I)Z
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=5, locals=4, args_size=2
         0: aload_0
         1: dup
         2: astore_2
         3: monitorenter
         4: aload_0
         5: getfield      #7                  // Field balance:D
         8: iload_1
         9: i2d
        10: dcmpl
        11: iflt          29
        14: aload_0
        15: aload_0
        16: getfield      #7                  // Field balance:D
        19: iload_1
        20: i2d
        21: dsub
        22: putfield      #7                  // Field balance:D
        25: iconst_1
        26: aload_2
        27: monitorexit
        28: ireturn
        29: aload_2
        30: monitorexit
        31: goto          39
        34: astore_3
        35: aload_2
        36: monitorexit
        37: aload_3
        38: athrow
        39: iconst_0
        40: ireturn
      Exception table:
         from    to  target type
             4    28    34   any
            29    31    34   any
            34    37    34   any
      LineNumberTable:
        line 27: 0
        line 28: 4
        line 29: 14
        line 30: 25
        line 32: 29
        line 34: 39
      StackMapTable: number_of_entries = 3
        frame_type = 252 /* append */
          offset_delta = 29
          locals = [ class java/lang/Object ]
        frame_type = 68 /* same_locals_1_stack_item */
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public synchronized boolean safeWithdrawToMethod(int);
    descriptor: (I)Z
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=5, locals=2, args_size=2
         0: aload_0
         1: getfield      #7                  // Field balance:D
         4: iload_1
         5: i2d
         6: dcmpl
         7: iflt          23
        10: aload_0
        11: aload_0
        12: getfield      #7                  // Field balance:D
        15: iload_1
        16: i2d
        17: dsub
        18: putfield      #7                  // Field balance:D
        21: iconst_1
        22: ireturn
        23: iconst_0
        24: ireturn
      LineNumberTable:
        line 38: 0
        line 39: 10
        line 40: 21
        line 43: 23
      StackMapTable: number_of_entries = 1
        frame_type = 23 /* same */

  public void safeDeposit(int);
    descriptor: (I)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=5, locals=4, args_size=2
         0: aload_0
         1: dup
         2: astore_2
         3: monitorenter
         4: aload_0
         5: aload_0
         6: getfield      #7                  // Field balance:D
         9: iload_1
        10: i2d
        11: dadd
        12: putfield      #7                  // Field balance:D
        15: aload_2
        16: monitorexit
        17: goto          25
        20: astore_3
        21: aload_2
        22: monitorexit
        23: aload_3
        24: athrow
        25: return
      Exception table:
         from    to  target type
             4    17    20   any
            20    23    20   any
      LineNumberTable:
        line 47: 0
        line 48: 4
        line 49: 15
        line 50: 25
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 20
          locals = [ class Account, int, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public double getSafeBalance();
    descriptor: ()D
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: getfield      #7                  // Field balance:D
         8: aload_1
         9: monitorexit
        10: dreturn
        11: astore_2
        12: aload_1
        13: monitorexit
        14: aload_2
        15: athrow
      Exception table:
         from    to  target type
             4    10    11   any
            11    14    11   any
      LineNumberTable:
        line 53: 0
        line 54: 4
        line 55: 11
      StackMapTable: number_of_entries = 1
        frame_type = 255 /* full_frame */
          offset_delta = 11
          locals = [ class Account, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
}

 

raw* 계열 메서드는 일반적인 산술/필드 접근 바이트코드만 보이고, 동기화 관련 명령은 등장하지 않는다. 반대로 safe* 계열에서는 monitorenter / monitorexit 혹은 ACC_SYNCHRONIZED 플래그가 붙는 것을 확인할 수 있다.

2. synchronized 블록: monitorenter / monitorexit

먼저 블록에 synchronized 를 건 safeWithdraw(int)의 바이트코드이다.

 

public boolean safeWithdraw(int);
  Code:
    stack=5, locals=4, args_size=2
       0: aload_0
       1: dup
       2: astore_2
       3: monitorenter
       4: aload_0
       5: getfield      #7                  // balance
       8: iload_1
       9: i2d
      10: dcmpl
      11: iflt          29
      14: aload_0
      15: aload_0
      16: getfield      #7
      19: iload_1
      20: i2d
      21: dsub
      22: putfield      #7
      25: iconst_1
      26: aload_2
      27: monitorexit
      28: ireturn
      29: aload_2
      30: monitorexit
      31: goto          39
      34: astore_3
      35: aload_2
      36: monitorexit
      37: aload_3
      38: athrow
      39: iconst_0
      40: ireturn
    Exception table:
       from    to  target type
           4    28    34   any
          29    31    34   any
          34    37    34   any

 

여기서 볼 수 있는 특징은 다음과 같다.

  1. synchronized 블록 → monitorenter / monitorexit 로 치환
    • synchronized (this) 부분이 aload_0, dup, astore_2, monitorenter로 컴파일된다.
    • 나갈 때는 항상 aload_2로 같은 객체를 로드한 뒤 monitorexit를 호출한다.
  2. 컴파일러가 자동으로 try-finally 패턴을 만든다
    • 예외 테이블을 보면, 본문 구간에서 예외가 발생하면 34로 점프해서 monitorexit를 실행한 뒤 예외를 다시 던지는 구조이다.
    • 즉 synchronized 블록은 바이트코드 레벨에서 “try { ... } finally { monitorexit }” 형태로 변환되어, 예외가 발생하더라도 락이 반드시 풀리도록 보장한다.
  3. 락 대상 객체를 로컬 변수에 별도로 저장
    • dup 후 astore_2로 this를 따로 저장해 두고, 모든 monitorexit에서 동일한 로컬 변수(여기서는 2번)를 사용한다.
    • 컴파일러 입장에서는 “어떤 경로로 빠져나가도 동일한 객체에 대해 exit 한다”는 것을 명확히 하기 위한 패턴으로 볼 수 있다.

단순한 safeDeposit(int)도 같은 패턴을 따른다.

 

public void safeDeposit(int);
  Code:
     0: aload_0
     1: dup
     2: astore_2
     3: monitorenter
     4: ... // 작업
    15: aload_2
    16: monitorexit
    17: goto          25
    20: astore_3
    21: aload_2
    22: monitorexit
    23: aload_3
    24: athrow
    Exception table:
       from    to  target type
           4    17    20   any
          20    23    20   any

 

정리하면, synchronized 블록은 바이트코드에서 monitorenter / monitorexit + 예외 테이블을 이용한 try-finally 패턴으로 구현된다 정도로 이해할 수 있다.

3. synchronized 메서드: ACC_SYNCHRONIZED 플래그

이번에는 메서드 전체에 synchronized 를 붙인 safeWithdrawToMethod(int)이다.

 

public synchronized boolean safeWithdrawToMethod(int);
  descriptor: (I)Z
  flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=5, locals=2, args_size=2
       0: aload_0
       1: getfield      #7                  // balance
       4: iload_1
       5: i2d
       6: dcmpl
       7: iflt          23
      10: aload_0
      11: aload_0
      12: getfield      #7
      15: iload_1
      16: i2d
      17: dsub
      18: putfield      #7
      21: iconst_1
      22: ireturn
      23: iconst_0
      24: ireturn

 

여기서는 눈에 띄는 점이 두 가지이다.

  • 바이트코드에 monitorenter / monitorexit가 전혀 등장하지 않는다.
  • 대신 메서드 플래그에 ACC_SYNCHRONIZED가 붙어 있다.

JVM 스펙에 따르면, 이 플래그가 붙은 메서드는 호출 시점에 JVM이 암묵적으로 해당 모니터에 진입하고, 메서드가 리턴하거나 예외로 종료될 때 자동으로 모니터에서 빠져나온다.

 

인스턴스 메서드인 경우 this의 모니터를 사용하고, static synchronized 메서드는 해당 클래스의 Class 객체 모니터를 사용한다. 즉, synchronized 메서드는 “메서드 전체를 하나의 synchronized 블록으로 본다”라고 이해하면 되고, 컴파일된 바이트코드는 일반 메서드와 거의 동일하지만, 동기화 여부는 메서드 플래그에 의해 JVM이 처리한다는 차이가 있다.

 

4. monitorenter / monitorexit 가 하는 일

바이트코드에서 본 monitorenter / monitorexit 명령은 JVM 내부에서 모니터(락)와 관련된 핵심 동작을 수행한다.

스펙 수준에서 보면 대략 다음과 같이 정의된다.

  • monitorenter
    • 스택 꼭대기에 있는 객체 참조를 꺼내 그 객체와 연관된 모니터를 획득한다.
    • 모니터 소유자가 없으면 현재 스레드가 소유자가 되고, 이미 현재 스레드가 소유 중이면 재진입 카운터만 증가한다.
    • 다른 스레드가 소유 중이면 모니터가 해제될 때까지 블록된다.
  • monitorexit
    • 스택에서 객체 참조를 꺼내 모니터의 재진입 카운터를 감소시키고, 카운터가 0이 되면 모니터를 해제한다.
    • 현재 스레드가 소유하지 않은 모니터에 대해 호출하면 IllegalMonitorStateException이 발생한다.

구현 관점에서 보면, HotSpot JVM은 이 모니터를 바로 OS 락에 매핑하지 않고, 여러 단계의 최적화를 거친다.

  • 편향 락(biased locking)
    • 경쟁이 거의 없는 경우, 객체 헤더의 Mark Word에 “이 객체는 특정 스레드에게 편향되어 있다”는 정보만 남겨 두고, 그 스레드가 다시 synchronized 에 들어올 때는 별다른 동기화 연산을 하지 않는 식으로 동작한다.
  • 경량 락(lightweight lock)
    • 경쟁이 있지만 크리티컬 섹션이 짧은 경우, CAS 연산과 스레드 스택에 잠금 레코드를 두는 방식으로 락을 구현한다.
  • 중량 락(heavyweight lock)
    • 실제로 여러 스레드가 동시에 경쟁하는 경우, OS 수준의 뮤텍스/커널 객체를 사용하는 무거운 락으로 승급한다.

또한 락의 진입/해제 시점에는 적절한 메모리 배리어를 삽입해서, Java Memory Model 이 요구하는 가시성과 순서 보장(happens-before 관계)을 만족하도록 구현한다.

5. 정리

이번에 바이트코드를 직접 까보면서 정리한 포인트는 다음과 같다.

  • synchronized 블록은 바이트코드에서 monitorenter / moniterexit 명령으로 변환되고, 예외 테이블을 이용한 try-finally 패턴으로 컴파일되어 어떤 경로로든 블록을 빠져나갈 때 반드시 monitorexit가 한 번 호출되도록 보장한다.
  • synchronized 메서드는 바이트코드에 동기화 관련 명령이 보이지 않고, 메서드 플래그에 ACC_SYNCHRONIZED만 붙는다. 실제 모니터 진입/퇴출은 메서드 호출/리턴 시점에 JVM이 처리한다.
  • monitorenter / monitorexit 는 객체 모니터의 소유권과 재진입 카운터를 관리하는 명령이고, HotSpot 같은 구현체에서는 편향 락, 경량 락, 중량 락 등 여러 수준으로 구현하여 대부분의 synchronized를 빠르게 처리하면서도 Java Memory Model의 요구사항을 만족시킨다.

이 정도까지 보면, 소스 코드에서 synchronized 한 줄을 쓰는 게 단순히 “락 잡기”가 아니라, JVM 입장에서는 바이트코드 수준 모니터 명령과 다양한 락 구현, 메모리 배리어까지 엮이는 꽤 많은 일이라는 걸 확인할 수 있다.

'Language > Java' 카테고리의 다른 글

[JAVA] 스레드 안전한 공유 객체를 만드는 세 가지 축: 불변, 공개, confinement  (0) 2026.04.06
[JAVA] 자바 동시성 프로그래밍: 기초부터 메모리 모델, 설계 전략까지 한 번에 정리하기  (0) 2026.04.01
[JAVA] JVM Class Loading, Metaspace, 그리고 메서드 바이트코드까지 한 번에 이해하기  (0) 2026.03.31
[EFFECTIVE JAVA 3/E] #2 생성자에 매개변수가 많다면 빌더를 고려하라  (0) 2025.09.16
[EFFECTIVE JAVA 3/E] #1 생성자 대신 정적 팩터리 메서드를 고려하라  (0) 2025.09.15
'Language/Java' 카테고리의 다른 글
  • [JAVA] 스레드 안전한 공유 객체를 만드는 세 가지 축: 불변, 공개, confinement
  • [JAVA] 자바 동시성 프로그래밍: 기초부터 메모리 모델, 설계 전략까지 한 번에 정리하기
  • [JAVA] JVM Class Loading, Metaspace, 그리고 메서드 바이트코드까지 한 번에 이해하기
  • [EFFECTIVE JAVA 3/E] #2 생성자에 매개변수가 많다면 빌더를 고려하라
Log Cat
Log Cat
잊어버리지 않기 위한 메모장
  • Log Cat
    개발 메모장
    Log Cat
  • 전체
    오늘
    어제
    • 전체보기 (45) N
      • Book Review (6)
      • Language (15) N
        • Java (10) N
        • Go (1)
        • JavaScript (1)
        • TypeScript (3)
      • Computer Science (3)
        • Network (1)
        • Database (1)
        • Design Pattern (0)
      • Spring Framework (10)
        • Spring & Spring Boot (4)
        • Spring Batch (4)
        • Servlet & JSP (2)
      • Python Framework (3)
        • FastAPI (3)
      • Infra (3)
        • Dcoker (1)
        • Kafka (2)
      • ORM (1)
        • JPA (1)
      • Fun (1) N
      • Error (2)
      • Retrospective (0)
      • Certificate (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 인기 글

  • 태그

    개발서적리뷰
    spring boot
    자바
    리트코드
    leetcode
    Typescript
    이팩티브 자바
    BOJ
    개발서적
    프로그래머스
    Python
    우아한테크코스
    프로그래머스 문제
    프로그래머스문제
    코테
    자바 풀이
    백준
    effective java 3/e
    성공과 실패를 결정하는 1%의 네트워크 원리
    네트워크 원리
    jmm
    programmers
    Java
    jvm
    코딩테스트
    escape-analysis
    fastapi
    네트워크
    spring
    spring kafka
  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
Log Cat
[JAVA] 자바 synchronized 를 바이트코드로 까보며 이해하기
상단으로

티스토리툴바