자바에 대하여 - 5

SUM·2025년 1월 24일

Java

목록 보기
5/5

동시성 프로그래밍 기초

1. 동시성과 병렬성의 차이점

여러 작업을 한 번에 다루는 개념이지만, 실제 의미와 구현 방식에서 차이가 있다.

1. 동시성(Concurrency)

작업이 동시에 발생하는 것처럼 보이기 위해 번갈아 가며서 작업을 수행하는 것

2. 병렬성(Parallelism)

실제로 독립적으로 동시에 작업을 수행하는 것

3. Java에서의 동시성과 병렬성

멀티 스레드 환경인 자바에서는 이 동시성과 병렬성에서 어떤 문제가 있고 어떻게 해결할까?

멀티 스레드에서는 공유 자원에 동시에 여러 스레드가 접근할 때, 공유 자원이 변경되어 다른 스레드 작업에 영향을 미쳐 올바른 결과를 반환하지 못하게 된다. 이를 동시성 문제라고 한다.

자바에서는 이런 동시성 문제를 해결해주기 위해 여러가지 방법을 제공해주고 있습니다.

자바에서 제공해주는 concurrent 패키지의 일부분입니다. atomic 패키지도있고 Thread Safe하면 많이 알고 있는 Concurrentmap도 여기에 구현이 되어 있습니다.

2. Thread-Safe?

스레드 안전(thread 安全, 영어: thread safety)은 멀티 스레드 프로그래밍에서 일반적으로 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없음을 뜻한다. 보다 엄밀하게는 하나의 함수가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하여 동시에 함께 실행되더라도 각 스레드에서의 함수의 수행 결과가 올바로 나오는 것으로 정의한다.

  • Thread Safe를 지키는 방법

(1) Re-entrancy
어떤 함수가 한 스레드에 의해 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하더라도 그 결과가 각각에게 올바로 주어져야 한다.

(2) Thread-local storage
공유 자원의 사용을 최대한 줄여 각각의 스레드에서만 접근 가능한 저장소들을 사용함으로써 동시 접근을 막는다.

이 방식은 동기화 방법과 관련되어 있고, 또한 공유상태를 피할 수 없을 때 사용하는 방식이다.

(3) Mutual exclusion
공유 자원을 꼭 사용해야 할 경우 해당 자원의 접근을 세마포어 등의 락으로 통제한다.

(4) Atomic operations

공유 자원에 접근할 때 원자 연산을 이용하거나 '원자적'으로 정의된 접근 방법을 사용함으로써 상호 배제를 구현할 수 있다.

가시성 문제와 원자성 문제

동시성(멀티스레드/멀티프로세스) 프로그래밍 환경에서 자주 등장하는 문제 중 대표적인 것이 가시성(Visibility) 문제와 원자성(Atomicity) 문제입니다. 이 두 가지는 제대로 된 동기화 메커니즘을 사용하지 않을 경우 프로그램이 예측할 수 없는 동작을 보이게 하는 원인이 됩니다.


1. 가시성(Visibility) 문제

1) 정의

  • 여러 스레드가 공유 변수를 사용할 때, 한 스레드의 변경 사항이 다른 스레드에서 즉시 보이지 않는(즉, 관측되지 않는) 현상을 가리킵니다.
  • CPU 코어마다 캐시(cache)가 따로 존재하거나, 컴파일러 최적화 및 메모리 계층 구조 등의 이유로 발생합니다.

2) 왜 발생할까?

  • 현대 CPU는 연산 효율을 극대화하기 위해 캐시(cache)레지스터 등을 활용합니다.
    • 스레드 A가 어떤 변수를 1 → 2로 업데이트했다고 해서, 그 값이 곧바로 메인 메모리에 쓰이지 않을 수 있습니다(캐시에만 존재).
    • 스레드 B는 메인 메모리 또는 자신의 캐시에 이전 값(1)만 반영되어 있을 수 있습니다.
  • 자바(Java) 등 언어에서는 volatile 키워드나 synchronized 블록 등을 통해 ‘메모리 가시성’을 보장하도록 합니다.
    • volatile은 해당 변수에 대한 읽기/쓰기가 항상 메인 메모리에서 이루어지도록 하여, 다른 스레드가 즉시 바뀐 값을 볼 수 있도록 만듭니다.
    • synchronized 블록에서는 블록 진입 시점과 종료 시점에 관련된 메모리 배리어(Memory Barrier) 를 설정하여 값을 제대로 밀어 넣고 꺼내게 만듭니다.

3) 예시

class VisibilityExample {
    private static boolean ready = false;  // 가시성 문제가 발생할 수 있는 공유 변수

    public static void main(String[] args) {
        new Thread(() -> {
            while (!ready) {
                // ready가 false인 것으로 계속 인식해 멈추지 않을 수 있음
            }
            System.out.println("Thread1 sees ready == true");
        }).start();

        new Thread(() -> {
            // 잠깐 대기 후 ready를 true로 설정
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            ready = true;
            System.out.println("Thread2 sets ready == true");
        }).start();
    }
}
  • 만약 ready 변수에 대한 가시성이 제대로 보장되지 않으면, 첫 번째 스레드는 ready의 변경을 인식하지 못해 무한 루프에 빠질 수도 있습니다.
  • ready 변수에 volatile을 선언하거나 synchronized로 감싸면 이 문제를 해결할 수 있습니다.

2. 원자성(Atomicity) 문제

1) 정의

  • 여러 스레드가 동시에 공유 자원에 접근해 읽고-쓰는 작업을 ‘하나의 불가분 연산(Atomic Operation)’으로 처리하지 못해 발생하는 문제입니다.
  • 원자성이란, 그 연산 전체가 중간 상태 없이 “전부 수행되거나, 전혀 수행되지 않은 것”으로 보이도록 보장하는 것을 의미합니다.

2) 왜 발생할까?

  • 단순히 정수 하나를 갱신하거나 하나의 값을 읽는 연산이라 하더라도, 실제로는 CPU 입장에서 load -> update -> store 같은 여러 단계로 나누어집니다.

  • 예를 들어 i++를 수행할 때,

    1. 변수 i를 읽는다(load)
    2. 1 증가시킨다(update)
    3. 다시 저장한다(store)

    이 과정 중 다른 스레드가 중간 단계에 끼어들어 값을 변경하거나 읽을 수 있습니다.

  • 이를 방치하면 원하는 결과보다 적게(또는 더 많이) 증가되는 등 예상치 못한 결과가 나올 수 있습니다.

3) 예시

class AtomicityExample {
    private int count = 0;

    public void increment() {
        // count++ 는 실제로 여러 단계로 나뉘어 수행됨
        count++;
    }
}
  • 여러 스레드가 increment()를 동시에 호출할 경우, 원자성이 깨져서 최종 count 값이 기대했던 만큼 증가하지 않을 수 있습니다.
  • 이 문제를 해결하기 위해 synchronized 키워드나 AtomicInteger 등의 동시성 유틸을 사용해 원자적 연산을 보장해야 합니다.
  • 자바의 동시성 이슈를 해결하는 방법을 아는만큼 설명해 주세요.
    자바(Java)에서 멀티스레드를 활용할 때는 여러 가지 동시성 이슈가 발생할 수 있습니다. 이러한 이슈를 잘 이해하고 적절히 대응하지 않으면, 프로그램이 의도대로 동작하지 않거나 성능이 저하되는 문제가 생길 수 있습니다. 대표적인 자바 동시성 이슈와 그 원인, 해결 방법을 정리해보겠습니다.

1. 자바 메모리 모델(Java Memory Model)과 가시성 문제

1) 자바 메모리 모델 개념

  • 자바는 Java Memory Model(JMM)을 통해 스레드 간에 공유되는 데이터가 어떻게 읽고/쓰여야 하는지 규정합니다.
  • 각 스레드는 기본적으로 레지스터, CPU 캐시, 메인 메모리 사이에서 데이터를 읽고 쓸 수 있으며,
    JMM은 “어떤 시점에, 어떤 스레드가 값의 변경을 볼 수 있어야 하는가?”에 대한 규칙을 제공합니다.

2) 가시성(Visibility) 문제

  • 한 스레드가 공유 변수를 변경해도, 다른 스레드에서 그 변경 사항을 즉시 볼 수 없을 수 있습니다.
  • 캐시 메모리나 컴파일러의 최적화로 인해 변경된 값이 아직 “공유 메모리(메인 메모리)”에 반영되지 않거나, 반영되었어도 다른 스레드의 캐시가 업데이트되지 않았을 수 있기 때문입니다.
  • 해결 방법
    • volatile 키워드를 사용해 변수의 읽기·쓰기 연산을 항상 메인 메모리와 동기화
    • synchronized 블록(또는 ReentrantLock)으로 락을 사용해 진입 시점과 종료 시점에 메모리 배리어(Memory Barrier) 발생
    • 고수준 동시성 유틸리티(예: Atomic* 클래스, Concurrent 패키지) 사용

2. 원자성(Atomicity) 문제와 Race Condition

1) 원자성(Atomicity) 문제

  • 어떤 연산이 원자적으로 처리되지 않아, 중간 결과가 다른 스레드에 의해 관찰되거나 덮어쓰여서 발생하는 문제입니다.
  • 예: i++ 같은 간단해 보이는 연산도 실제로는 “읽기 → 증가 → 쓰기” 3단계로 분할됨.

2) Race Condition(경쟁 상태)

  • 두 개 이상의 스레드가 공유 자원에 동시에 접근할 때, 그 순서가 결과를 좌우해 예측 불가능한 동작이 발생하는 상황을 말합니다.
  • 동기화가 이루어지지 않은 변수를 읽거나, 락 없이 공유 데이터 구조를 수정하는 시나리오가 대표적입니다.

3) 해결 방법

  • synchronized 키워드나 Lock(예: ReentrantLock)으로 임계영역(Critical Section)을 보호
  • AtomicInteger, AtomicReference원자적 연산을 제공하는 클래스를 이용해 락 없이 안전한 연산 수행
  • 불변(Immutable) 객체 또는 Copy-On-Write 전략 활용

3. 데드락(Deadlock), 라이블락(Livelock), 기아(Starvation)

1) 데드락(Deadlock)

  • 서로 다른 스레드가 각각 상대 스레드가 보유한 락을 기다리면서 영원히 대기 상태에 빠지는 문제
    • 예: 스레드 A는 락1을, 스레드 B는 락2를 잡고, A는 락2가 해제되길 기다리고 B는 락1이 해제되길 기다리는 상태
  • 해결 방법
    • 락 획득 순서를 통일하고 엄격히 지키기
    • 타임아웃 락 사용(tryLock(long time, TimeUnit unit))으로 일정 시간 기다린 후 해제 시도
    • 교착 상태 탐지(Deadlock Detection) 알고리즘 도입

2) 라이블락(Livelock)

  • 데드락과 달리 스레드가 동작을 하긴 하지만, 계속 서로 양보하거나 재시도를 반복하느라 실질적 진전이 없는 상태
  • 해결 방법
    • 락 획득 시도에 대한 백오프(Backoff) 전략
    • 무작위 지연(Randomized Delay) 등을 도입해 동시에 재시도를 반복하지 않도록 설계

3) 기아(Starvation)

  • 어떤 스레드가 자원을 획득하거나 스케줄링되어 실행될 기회를 박탈당해, 오랫동안 혹은 영원히 실행 기회를 얻지 못하는 현상
  • 우선순위가 낮은 스레드가 높은 우선순위 스레드에 계속 밀려 실행 기회를 갖지 못하는 경우 등에 발생
  • 해결 방법
    • 우선순위 스케줄링 조정
    • 적절한 페어 스케줄링(Fair Scheduling) 기법 사용

4. 동시성 컬렉션(Concurrent Collections) 활용

1) 문제 배경

  • 기존의 ArrayList, HashMap 등을 여러 스레드에서 동시에 수정하면 Race Condition이 쉽게 발생
  • Collections.synchronizedList(...)등을 통해 동기화 래퍼를 씌울 수 있지만, 성능 저하가 심하고 세밀한 동시 접근 제어는 어렵습니다.

2) 자바 동시성 컬렉션

  • java.util.concurrent 패키지에서 지원하는 고성능 동시성 컬렉션
    • ConcurrentHashMap, ConcurrentLinkedQueue, CopyOnWriteArrayList
  • 내부적으로 락 분할(Lock Striping) 또는 비차단(non-blocking) 알고리즘 등이 사용되어,
    동시 접근에 비교적 안전하며 성능을 높이는 전략을 취함.

5. 스레드 풀(Thread Pool) 관리와 태스크 실행

1) 스레드 풀의 필요성

  • 스레드를 무작정 생성/종료하면 성능 저하 및 시스템 자원 고갈이 발생할 수 있음.
  • 자바에서는 Executor, ExecutorService, ThreadPoolExecutor 등을 통해 스레드 풀을 효율적으로 관리.

2) 이슈 예시

  • 스레드 풀의 크기(코어 스레드 수, 큐 크기, 최대 스레드 수 등)가 부적절하면 오히려 과부하가 발생하거나 태스크가 너무 오래 대기
  • 스레드 풀을 잘못 종료하면(shutdown vs shutdownNow) 예상치 못한 결과를 유발

3) 해결 방안

  • CPU 바운드 작업: 코어 수에 맞추어(또는 코어 수 + α) 풀 크기를 조정
  • I/O 바운드 작업: 필요 시 더 많은 스레드 생성 가능
  • 모니터링(Metrics) 및 동적 조정을 통해 가장 적합한 풀 크기 결정

6. 고수준 동시성 API

1) Future, CompletableFuture

  • 비동기 계산 결과를 나타내는 Future 인터페이스
  • CompletableFuture는 더 풍부한 콜백 연계(thenApply, thenCombine 등)를 지원

2) Parallel Streams

  • 자바 8 이상에서 제공되는 스트림 API를 병렬화(.parallel())하여 멀티코어 활용
  • 내부적으로 ForkJoinPool 사용
  • 무분별한 병렬 스트림 사용은 오히려 성능을 떨어뜨릴 수 있으므로 신중해야 함

3) Reactive Streams / Project Reactor, RxJava

  • 비동기 스트림을 효율적으로 처리하기 위한 리액티브 프로그래밍 모델
  • 자바 표준 Flow API (Java 9+) 기반, 확장 라이브러리 사용 시 주의점(백프레셔, 구독 해지 등)

7. 정리

  1. 가시성, 원자성 문제: 자바 메모리 모델(JMM)을 이해하고, volatile/synchronized/Atomic 클래스 등으로 적절히 동기화해야 함.
  2. 데드락, 라이블락, 기아: 락 설계, 스레드 우선순위 관리, 교착상태 회피 전략 등을 통해 방지.
  3. 동시성 컬렉션 사용: ConcurrentHashMap, CopyOnWriteArrayList 등 안전하면서도 고성능을 낼 수 있는 컬렉션 활용.
  4. 스레드 풀 관리: ExecutorService, ThreadPoolExecutor를 적절히 구성, 모니터링 및 튜닝이 필요.
  5. 고수준 비동기 API: CompletableFuture, 병렬 스트림, 리액티브 라이브러리 등을 통해 복잡한 동시성 제어 로직을 단순화 가능.

자바의 동시성은 단순히 synchronized 키워드나 스레드 생성으로 해결되지 않으며,
자바 메모리 모델에 대한 이해와 동시성(멀티스레드) 설계 원칙이 매우 중요합니다.
올바른 동기화, 적절한 락 사용, 혹은 락 프리(lock-free) 알고리즘 적용 등을 통해
안전하고 효율적인 멀티스레드 프로그램을 작성하는 것이 핵심입니다.

volatile 키워드

자바 volatile 키워드는 멀티스레드 환경에서 공유 변수(필드) 접근 시 가시성(Visibility) 문제를 해결하기 위해 사용하는 키워드입니다.

1. volatile의 주요 특징

  1. 가시성(Visibility) 보장

    • volatile로 선언된 변수에 대한 읽기·쓰기 연산은 항상 메인 메모리와 동기화됩니다.
    • 하나의 스레드가 volatile 변수를 갱신하면, 다른 스레드에서 즉시 바뀐 값을 볼 수 있습니다(캐시에만 남아있지 않음).
    • 자바 메모리 모델(JMM)에서 volatile은 읽기/쓰기에 메모리 배리어(Memory Fence) 를 적용하여,
      • 쓰는 쪽(Write): 해당 변수 변경을 메인 메모리에 즉시 반영
      • 읽는 쪽(Read): 최신 값을 메인 메모리에서 읽어온다는 의미
  2. 원자성(Atomicity)은 보장하지 않음

    • volatile 변수라 하더라도, 이를 활용한 복합 연산(i++, count += x 등)은 여전히 원자적이지 않습니다.
    • 즉, volatile int count; count++; 같은 코드는 내부적으로 여러 단계(읽기→증가→쓰기)로 나뉘기 때문에,
      동시에 여러 스레드가 접근하면 값이 제대로 반영되지 않는 Race Condition이 발생할 수 있습니다.
    • 원자적 증가/감소가 필요하다면 AtomicInteger 같은 원자성 보장 클래스synchronized 블록을 사용해야 합니다.
  3. 스레드 간 재정렬(Reordering) 방지

    • 컴파일러 최적화나 CPU의 명령어 파이프라인/캐시로 인해, 코드가 재정렬되어 실행될 수도 있습니다.
    • volatile 변수에 대한 접근 시점에는 재정렬이 제한되어, 다른 스레드가 예상하지 못한 순서로 실행 결과를 보게 될 위험을 줄여줍니다.

2. 일반 변수 vs. volatile 변수

구분일반 변수volatile 변수
가시성
  • 스레드 A가 변경한 값이 스레드 B에게 늦게 보일 수 있음
  • 캐시 동기화가 즉시 이루어지지 않을 수 있음
  • 항상 메모리에 동기화됨
  • 다른 스레드가 즉시 변경 내용을 인식 가능
  • 재정렬 방지
  • 컴파일러나 CPU 최적화로 재정렬 가능
  • volatile 접근부는 재정렬 억제
  • 원자성
  • 단일 읽기·쓰기 자체는 CPU 레벨에서는 대체로 원자적이지만, 복합 연산(i++) 등은 비원자적
  • 단일 읽기·쓰기 연산만 보장. 복합 연산은 여전히 비원자적
    • 따라서, 간단한 플래그(예: boolean isRunning)처럼 읽기·쓰기가 모두 단일 단계인 변수라면 volatile만으로도 충분히 동기화를 보장할 수 있습니다.
    • 하지만, 카운터나 복합 연산이 필요한 데이터라면 volatile만으로는 부족하고, 다른 동기화 메커니즘(락, Atomic 클래스 등)을 사용해야 합니다.

    3. 사용 예시

    1. 상태 플래그 제어

      public class VolatileExample {
          private volatile boolean running = true; // 플래그
      
          public void start() {
              new Thread(() -> {
                  while (running) {
                      // running이 false가 되는 즉시 인식 가능
                  }
                  System.out.println("Thread stopped.");
              }).start();
          }
      
          public void stop() {
              running = false; // 다른 스레드에서 즉시 인식
          }
      }
      • volatile로 선언된 running은 가시성이 보장되어, stop()에서 running = false로 바꾸면 start() 메서드 내의 스레드가 즉시 변경 사항을 인식하게 됩니다.
    2. 단순 카운터

      public class VolatileCounter {
          private volatile int count = 0;
      
          public void increment() {
              count++; // 이 연산은 원자적이지 않음
          }
      }
      • 이 코드는 volatile을 사용했어도, 여러 스레드가 동시에 increment()를 호출하면 Race Condition이 발생할 수 있습니다.

      • 만약 “정확한 카운팅”이 필요하다면 아래와 같이 원자적 클래스를 쓰는 편이 낫습니다.

        private AtomicInteger count = new AtomicInteger(0);
        
        public void increment() {
            count.incrementAndGet(); // 원자적 연산 보장
        }

    4. 결론

    • volatile 키워드는 동시성(Concurrency) 프로그래밍에서 가시성 문제와 부분적으로 재정렬 문제를 해결하기 위한 기초적인 도구입니다.
    • 그러나 복합적인 읽기-쓰기 연산의 원자성volatile만으로 보장되지 않으므로, 필요한 경우에는 락(예: synchronized) 또는 Atomic 클래스 같은 다른 동기화 기법을 적용해야 합니다.
    • “간단한 플래그”나 “읽기 후 한 번 쓰기만 하는 상태 값”처럼 가시성 보장만 필요한 경우에 volatile은 효율적이고 적절한 선택입니다.

    synchronized 키워드

    자바에서 synchronized 키워드는 임계영역(Critical Section)을 설정하고, 해당 블록(또는 메서드)을 단일 스레드만 접근 가능하도록 락(Lock)을 거는 동기화 수단입니다. 이를 통해 여러 스레드가 동시에 공유 자원(객체 변수, 정적 변수 등)에 접근할 때 발생할 수 있는 원자성(Atomicity), 가시성(Visibility), Race Condition 문제 등을 예방합니다. 아래에서 좀 더 자세히 살펴보겠습니다.


    1. synchronized 키워드란?

    1) 사용 방법

    1. 메서드 선언부에 사용

      public synchronized void increment() {
          // 임계영역 (이 메서드는 한 번에 오직 1개의 스레드만 진입 가능)
      }
      • 메서드 전체가 임계영역이 됨.
      • 이 때, this 객체(인스턴스 메서드라면)가 락(모니터 락)으로 사용됩니다.
      • 정적 메서드(static)에 synchronized를 붙일 경우, 해당 클래스의 Class 객체가 락으로 사용됩니다.
    2. 블록에 사용

      public void increment() {
          synchronized (lockObject) {
              // 임계영역
          }
      }
      • 특정 객체(lockObject)를 락으로 사용하여 그 블록 안에만 동기화를 적용.
      • 여러 블록에서 동일한 lockObject를 사용하면, 해당 락을 기준으로 진입이 상호 배타적으로 제한됩니다.

    2) 작동 방식(모니터 락)

    • 자바 가상 머신(JVM) 내부적으로 synchronized 구문은 모니터(Monitor) 라 불리는 락(잠금) 메커니즘을 사용합니다.
    • 컴파일 시점에 monitorentermonitorexit 바이트코드 명령이 추가되어, 해당 객체 모니터를 획득(acquire)하고 해제(release)합니다.
    • 단일 스레드만이 모니터를 획득할 수 있으므로, 임계영역에 동시에 여러 스레드가 진입하지 않도록 보장합니다.
    • 자바 메모리 모델(JMM)에 의해, 임계영역에 들어가기 전·후로 메모리 배리어(Memory Barrier) 가 동작하므로,
      동기화 블록 안에서 수정된 변수의 값은 다른 스레드가 같은 락을 획득할 때 최신 상태로 보이게 됩니다(가시성 보장).

    3) 재진입(Reentrancy) 가능

    • 자바의 synchronized재진입 가능(Reentrant) 락입니다.
    • 즉, 하나의 스레드가 이미 lockObject의 모니터를 획득한 상태에서, 동일 스레드가 같은 객체 락을 다시 획득하려 해도 계속 진행됩니다(락 횟수 카운트가 1씩 증가).
    • 블록을 빠져나올 때마다 카운트가 감소하여 최종적으로 0이 될 때 락이 완전히 해제됩니다.

    2. synchronized의 문제점

    1) 성능 저하(비교적 무거운 락)

    • synchronized 블록/메서드로 감싸면, 해당 락을 획득하기 위해 스레드는 대기해야 하므로 블로킹(Blocking)이 발생합니다.
    • 특히 자주 호출되는 메서드나, 임계영역이 큰 블록에서 성능 저하가 심할 수 있습니다.
    • 자바 1.6부터 경량화 기법(Biased Locking, Lightweight Locking 등)이 도입되어 과거보다 성능이 많이 개선되었지만,
      여전히 빈번한 락 충돌이 일어나면 병목(Bottleneck)이 발생할 수 있습니다.

    2) 세밀한 락 관리의 어려움

    • synchronized는 지정된 객체 모니터(또는 클래스 모니터)에 대해 배타적으로 동작하므로,
      동시 접근이 빈번해지는 상황에서 락 경쟁이 심해지거나 교착상태(Deadlock)가 발생할 위험이 있습니다.
    • (예) 여러 객체에 걸쳐 락을 잡아야 하는 복잡한 시나리오에서, synchronized만으로는 락 순서, 타임아웃, 공정성(Fairness) 등을 세밀하게 제어하기 어렵습니다.
    • 이런 상황에서는 ReentrantLock, ReadWriteLock 같은 java.util.concurrent.locks 패키지의 락을 사용하는 것이 유연합니다.

    3) 무조건 상호 배타, 조건 대기/알림을 위한 별도 메커니즘 필요

    • synchronized는 기본적으로 임계영역 보호가 목적입니다.
    • 스레드 간에 특정 조건을 기다리거나 알림을 주고받으려면 wait() / notify() 같은 Object 모니터 메서드를 사용해야 하며,
      사용 방식이 까다롭고 오용하면 데드락, 스퍼리어스 웨이크업(Spurious Wakeup) 같은 문제가 발생할 수 있습니다.

    3. synchronized의 내부 구현 방식

    자바 소스 코드를 JVM 바이트코드 레벨에서 보면, synchronized 블록에는 monitorenter, monitorexit 명령이 추가됩니다.

    1. monitorenter

      • 스레드가 해당 객체(또는 클래스)의 모니터(락)을 획득하는 과정
      • 이미 다른 스레드가 모니터를 보유하고 있다면, 락이 해제될 때까지 대기(블로킹)
    2. monitorexit

      • 블록을 빠져나올 때 모니터를 해제하는 과정
      • 내부적으로는 “락 카운트”를 1 감소시키고, 0이 되면 모니터를 완전히 해제

    1) 객체 헤더(Object Header)에 있는 Mark Word

    • HotSpot JVM에서 각 객체 헤더에는 Mark Word라고 불리는 필드가 존재합니다.
    • 이 Mark Word에는 락 상태, 스레드 ID, HashCode 등의 정보를 저장할 수 있습니다.
    • 경량화 락(경량 락, Biased 락) 등의 최적화 기법도 이 Mark Word를 활용해 구현됩니다.

    2) 락 최적화 기법 (HotSpot JVM 기준)

    • Biased Locking
      • 락에 편향(Bias) 정보를 두어, 처음 락을 획득한 스레드가 다시 락을 사용하면 추가 비용 없이 빠르게 임계영역에 들어갈 수 있도록 함.
    • Lightweight Locking
      • 경쟁이 발생하기 전까지는 CAS(Compare-And-Swap) 같은 원자적 연산을 통해 락 획득을 빠르게 처리.
    • Heavyweight Locking
      • 스레드 간에 실제 경쟁이 감지되면 OS 수준의 모니터 락(뮤텍스)로 전환. 이 단계에서는 스레드가 블로킹됨.

    자바 11 이후로는 Biased Locking이 기본적으로 비활성화되거나 제거될 움직임도 있으나,
    전체적인 개념은 “처음엔 가벼운 방식으로 락을 잡고, 경쟁이 심해지면 무겁게 전환”하는 다단계 구조를 취한다는 점입니다.


    4. 요약

    1. 정의

      • synchronized는 자바에서 임계영역을 설정하고 동시에 접근하는 스레드를 순차화하기 위한 키워드.
      • 내부적으로 모니터(락) 를 획득하고 해제하는 방식을 사용한다.
    2. 장점

      • 구현이 간단(키워드만 붙이면 됨).
      • 코어 언어 차원에서 지원하므로 문법적 오류가 적고 JVM 최적화 혜택이 있음.
      • JMM에 의해 가시성원자성이 보장됨(블록 입장/퇴장 시 메모리 배리어).
    3. 문제점

      • 성능 오버헤드: 경쟁이 잦으면 블로킹이 빈번해져 성능 저하.
      • 유연성 부족: 타임아웃, 공정성, 조건 대기/알림 등의 세밀한 제어가 어려움.
      • 교착상태 발생 가능성 높음(잘못된 락 순서 등).
    4. 구현 원리

      • 컴파일 시점에 monitorenter, monitorexit 명령 추가.
      • 각 객체가 가진 모니터(Mark Word, Object Header)를 기반으로 락 상태 관리.
      • 경량 락 → 무거운 락으로 전환하는 최적화 기법이 JVM 내부에서 적용.

    결론적으로, synchronized는 자바에서 가장 기본적이고 간편한 동기화 수단입니다.
    다만 빈번한 락 충돌이나 복잡한 동기화 시나리오(락 타임아웃, 다중 조건 대기 등)에서는
    Lock 인터페이스(ReentrantLock, ReadWriteLock 등)나 동시성 라이브러리를 사용하는 것이 더 유연하고 나을 수 있습니다.

    • atomic 키워드
      동시성 프로그래밍(멀티스레드, 멀티프로세스 등)에서 원자성(Atomicity)은 어떤 연산(또는 작업)이 한 번에 전부 실행되어, 중간 단계가 다른 스레드나 프로세스에 노출되지 않는 것을 의미합니다.

    예를 들어, “i를 1 증가시키는 연산(i++)” 같은 단순해 보이는 작업도 실제로는
    1) i값 읽기
    2) i값을 1 증가
    3) 증가된 i값 쓰기
    라는 세 단계로 나뉩니다.
    이 세 단계가 원자적으로(Atomic) 보장되지 않으면, 여러 스레드가 동시에 해당 연산을 호출했을 때 예상치 못한 결과가 나올 수 있습니다.

    • 예: A 스레드가 i를 읽고(예: 100), 아직 증가된 값을 쓰기 전인데 B 스레드가 i를 읽으면, 둘 다 100으로 인식하고 각각 101을 다시 저장해 최종 값이 101만 되는 문제 등.

    1. “atomic하다는 것”의 의미

    1. 불가분(Indivisible)

      • 한 번에 전부 수행되거나, 전혀 수행되지 않은 것처럼 보이는 특성
      • 중간 상태가 노출되지 않고, 다른 스레드가 끼어들지 못함
    2. 중단되거나 간섭받지 않음

      • 도중에 스케줄링 교체(문맥 전환)가 일어나더라도, 그 연산 자체는 끊기지 않는 단위로 처리
      • 다른 스레드에서 볼 때 “순간적으로 한 번에 끝난 것”과 동일
    3. Race Condition 방지

      • 여러 스레드가 동시에 공유 자원을 읽고 쓸 때, 원자성이 깨지면 Race Condition(경쟁 상태)이 발생하기 쉬움
      • 원자성을 보장하는 연산을 사용하면 스레드 간 간섭 문제가 줄어듦

    2. “atomic 타입(Atomic Type)”이란?

    자바에서 java.util.concurrent.atomic 패키지에 있는 Atomic 클래스들을 가리키는 경우가 많습니다. 예컨대:

    • AtomicInteger
    • AtomicLong
    • AtomicBoolean
    • AtomicReference<T>

    이들은 내부적으로 Compare-And-Set(CAS) 같은 CPU의 원자적(Atomic) 명령을 사용하여, 락(lock) 없이도 스레드 안전하게(중간단계 없이) 값을 읽거나 수정할 수 있도록 지원합니다.

    1) AtomicInteger 예시

    private AtomicInteger counter = new AtomicInteger(0);
    
    public void increment() {
        // 원자적으로 counter 값을 1 증가
        counter.incrementAndGet(); 
    }
    • incrementAndGet()는 내부적으로 “기존 값 읽기 → 1 증가 → 저장” 과정을 하나의 불가분 연산으로 처리합니다.
    • 다른 스레드가 동시에 접근해도 값의 손실 없이 안전하게 증가 가능

    2) 왜 “Atomic” 타입을 쓸까?

    • 단순 int count; count++; 같은 연산은 스레드가 여러 개일 때 순서 문제로 값이 제대로 증가되지 않을 수 있음. (원자성 X)
    • AtomicIntegerAtomic 타입은 “복합 연산도 안전하게” 보장
    • 락(fine-grained lock)을 직접 걸지 않아도 되므로, 상황에 따라 synchronized 또는 Lock보다 빠르게 동작

    3. 원자적 연산(Atomic Operations)이 필요한 이유

    1. 원자성(Atomicity)

      • 읽기-수정-쓰기의 전체 과정을 한 번에 처리해야 Race Condition을 방지할 수 있음
    2. 락 오버헤드 줄이기

      • 전통적으로는 synchronized 블록이나 ReentrantLock 등을 통해 원자성을 확보함(임계영역화)
      • 그러나 락은 스레드를 블로킹시키고, 복잡한 락 경쟁 상황에서는 성능이 떨어질 수 있음
      • Atomic 연산은 내부적으로 CPU의 저수준 CAS 명령 등을 사용해 락 없이도 원자성을 달성하므로, 락 경합이 심하지 않은 시나리오에서 더 효율적
    3. 가시성(Visibility)도 함께 보장

      • Atomic 클래스 메서드는 내부적으로 자바 메모리 모델에서 정의하는 메모리 배리어(Memory Fence) 효과를 발생시켜, 쓰기 결과가 즉시 다른 스레드에게 보이도록 처리
      • 즉, 읽고 쓰는 연산 자체가 모두 최신 값을 반영하게 함

    4. 요약

    • “원자적(atomic)이다” = 연산이 중간 단계 없이 한 번에 이루어지며, 다른 스레드가 그 사이에 간섭할 수 없음.
    • “atomic 타입(Atomic Type)” = 자바에서 AtomicInteger, AtomicLong 등, 내부적으로 CAS를 사용해 원자적 연산을 제공하는 클래스.
    • 원자적 연산을 통해 동시성 문제(특히 Race Condition)를 간편하게 해결하거나, 전통적인 락보다 성능을 높일 수 있음.

    결국 동시성 프로그래밍에서 “원자성”은 매우 중요한 개념이며, Atomic 타입을 적절히 활용하면 락을 최소화하면서도 안전하게 공유 데이터를 처리할 수 있습니다.

    profile
    백엔드 개발자 SUM입니다.

    0개의 댓글