메모리 가시성, 동기화

나무나무·2025년 9월 19일

자바

목록 보기
5/6

Volatile, 메모리가시성


메모리 가시성

  • 멀티 스레드 환경에서 한 스레드가 변경한 값 → 언제 보이는지에 대한 것
  • 메모리에 변경한 값이 보이는지 안보이는지?

Java Memory Model

  • 자바 프로그램이 메모리에 접근하고 수정하는 방식 규정
  • 멀티 스레드 프로그래밍에서 스레드 간 상호작용을 의미함
  • 핵심은 작업 순서를 보장하는 happens-before 관계

happens-before

  • “앞에 동작한 결과가 뒤따르는 동작에 반드시 반영됨”
  • 스레드 간의 작업 순서를 정의하는 개념 - 메모리 가시성을 보장하는 규칙
  • 한 동작이 다른 동작보다 먼저 발생
  • 한 스레드에서 수행한 작업 → 다른 스레드가 참조할 때 최신 상태가 보장된다는 의미

발생하는 경우

  • 프로그램 순서 규칙
  • volitile 변수에 대한 쓰기 작업은 해당 변수를 읽는 모든 스레드에 보이도록 함
  • 스레드 시작 규칙 → start() 호출 시 스레드 내 모든 작업은 start() 호출 이후에 실행된 작업 보다 happens-before 관계 성립
  • 스레드 종료 규칙 → join()을 호출하면 join대상 스레드의 모든 작업은 join이 반환된 후의 작업보다 happens-before 관계
  • 인터럽트 규칙 → interrupt()를 호출하는 작업이 인터럽트 된 스레드가 인터럽트 감지하는 시점의 작업보다 happens-before 관

Volatile 키워드

  • 캐시 메모리에 적용하지 않고 바로 메인 메모리에 작성함
  • CPU에 붙어 있는 캐시 메모리는 가격이 비싸서 큰 용량을 구성하긴 어려움 → 일반적으로 코어 단위로 캐시 메모리를 각각 보유하고 있음
  • 일반적으로 컨텍스트 스위칭이 되면서 메인 메모리 값이 갱신됨 → 갱신을 보장하는 것은 아님
  • 이를 위해 성능을 포기하는 대신 volatile이라는 키워드를 사용해 메인 메모리에 직접 접근하면 된다.

공유 자원

  • 같은 리소스에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제
  • 여러 스레드가 접근하는 자원을 공유자원이라고 함
  • 접근을 적절히 동기화해서 동시성 문제가 발생하지 않게 방지하는 것이 중요함

임계 영역

  • 여러 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하거나 수정하는 부분을 임계 영역이라고 함
  • 여러 스레드가 공유 자원을 여러 단계로 나눠서 사용하면 문제가 발생함
  • ex) 출금 → (검증 → 출금)
    • 이 경우 검증에서 확인한 잔액은 출금 단계까지 유지가 되어야 함
    • 중간에 다른 스레드가 잔액의 값을 변경하면 혼란 발생
    • 어 그러면 한 번에 한 스레드만 실행할 수 있도록 제한하면? 중간에 다른 스레드가 값 변경 못하잖아!
  • 임계 영역에 한 번에 하나의 스레드만 접근할 수 있도록 안전하게 보호해야 함 → 이를 위해 synchronized 키워드를 이용함

Synchronized


Synchronized

  • 메서드 동기화 / 블록 동기화 가능
  • 모든 인스턴스는 내부에 자신의 lock을 가지고 있음 ⇒ monitor lock이라고도 부름
  • synchronized 키워드가 있는 메서드에 진입하고자 하면 반드시 해당 인스턴스의 lock이 필요
  • lock이 없으면 BLOCKED 상태로 대기

참고

  • volatile사용 안해도 synchronized안에 접근하는 변수 메모리 가시성 문제는 해결됨
  • 동기화 사용 시 경합 조건(두 스레드가 동일한 자원 수정)과 데이터 일관성 문제 해결 가능
  • 자바의 모든 객체 인스턴스는 멀티 스레드 임계 영역을 다루기 위해 모니터 락, 락 대기 집합, 스레드 대기 집합 총 3가지 기본 요소를 지닌다.

단점

  • 한 번에 하나의 스레드만 실행 가능함 → 성능 저하
  • lock 획득 순서가 보장되지 않음
  • lock을 얻기 위해 BLOCKED 상태가 되면 락을 얻기까지 무한 대기해야함

Concurrent.Lock


LockSupport

  • 스레드를 WAITING 상태로 변경 → 누가 깨워주기 전까지 계속 대기(CPU 실행 스케줄링에 들어가지 않음)
  • WAITING 상태의 스레드는 인터럽트를 걸어 중간에 깨울 수 있음
    • BLOCKED상태는 인터럽트로 대기 상태를 빠져나올 수 없음(Synchronized에서만 사용하는 특별한 대기 상태)
    • WAITING, TIME_WAITING상태는 인터럽트가 걸리면 대기 상태를 빠져나옴

기능

  • park() : 스레드를 WAITING상태로 변경
  • parkNanos(nanos) : 스레드를 나노초 동안만 TIMED_WAITING으로 변경 → 이후 RUNNABLE로 변경
  • unpark(thread) : WAITING상태의 대상 스레드를 RUNNABLE로 변경

ReentrantLock

  • 안전한 임계 영역을 위한 lock구현에 사용되는 lock 인터페이스 → 대표적인 구현체로 ReentrantLock

기능

  • void lock() : lock 획득 / 인터럽트에 응답하지 않음
  • void lockInterruptily() : lock 획득 시도, 다른 스레드가 인터럽트할 수 있음 → 대기 중에 인터럽트가 발생하면 InterruptedException발생, lock획득 포기
  • boolean tryLock() : lock획득 시도, 즉시 성공 여부 반환 → 다른 스레드가 이미 lock을 획득했다면 바로 포기
  • boolean tryLock(long time, TimeUnit unit) : 주어진 시간 동안 락 획득 시도 → 주어진 시간 안에 획득하면 true, 못하면 그냥 false 반환, 포기
  • void unlock() : lock 해제, 대기중인 스레드가 lock 획득
  • Condition newCondition() : Condition 객체 생성, 반환 →

공정성

  • private final Lock nonFairLock = new ReentrantLock(); : 비공정 모드 락
    • lock획득 속도가 빠름
    • 새로운 스레드가 다른 기존 스레드보다 빠르게 선점할 수 있음
    • 기아 현상 가능
  • private final Lock nonFairLock = new ReentrantLock(true); : 공정 모드 락
    • 공정성 보장
    • 기아 현상 방지
    • 성능 저하

Synchronized vs ReentrantLock 대기

  • Lock(ReentrantLock)도 2가지 단계의 대기 상태가 존재

➡️ Synchronized 대기

  • 모니터 lock 획득 대기
    • BLOCKED 상태로 “lock 대기 집합”에서 관리
    • 다른 스레드가 synchronized 를 빠져나갈 때 lock 획득 시도
  • wait() 대기
    • wait() 호출 시 “스레드 대기 집합”에서 대기
    • 다른 스레드가 notify() 호출 시 빠져나감
    • WAITING 상태로 대기

➡️ ReentrantLock 대기

  • ReentrantLock 락 획득 대기
    • lock.lock()을 호출했을 때 lock이 없으면 대기
    • WAITING 상태로 lock 획득 대기
    • 다른 스레드가 lock.unlock()을 호출했을 떄 대기가 풀리며 lock 획득 시도
  • await() 대기
    • condition에서 WAITING 상태로 대기
    • wait()를 호출했을 때 객체 내부의 스레드 대기 집합에서 관리
    • 다른 스레드가 notify() 호출할 때 스레드 대기 집합 빠져나감

생산자 & 소비자

  • 생산자(Producer) : 데이터 생성 역할
  • 소비자(Consumer) : 생성된 데이터 사용 역할
  • 버퍼(Buffer) : 생산자가 생성한 데이터 일시 저장

➡️ 문제 상황(producer-consumer problem / bounded-buffer problem)

  • 생산자가 너무 빠를 때 → Buffer가 가득 참 → Buffer에 빈 공간이 생길 때까지 생산자는 기다려야 함
  • 소비자가 너무 빠를 때 → Buffer가 비어있음 → Buffer에 새로운 데이터가 들어올 때까지 기다려야 함

⇒ 결국 버퍼 크기가 한정되어 있고, 생산자와 소비자가 함께 생산하고 소비하기 때문에 발생하는 문제


BlockingQueue


BlockingQueue

특정 조건이 만족될 때까지 스레드의 작업을 차단(Blocking)

  • 데이터 추가 차단
    • add(), offer(), put(), offer(타임 아웃)
  • 데이터 획득 차단
    • take(), poll(타임아웃), remove()
  • 인터페이스 대표 구현체
    • ArrayBlockingQueue
    • LinkedBlockingQueue

➡️ 멀티 스레드 사용 시 응답성이 중요!

  • 대기 상태 → 고객이 중지 요청을 하거나, 너무 오래 대기한 경우 포기하고 빠져나갈 수 있는 방법 필요
    • ex) 큐 한계가 1000개일 때, 순간적으로 1000개 넘는 주문이 들어오면 소비가 생산을 따라가지 못하고 가득차게 됨
  • 위의 경우 선택지는 4가지가 있음
    • 예외 던지기
    • 대기 안하고 false 반환
    • 대기
    • 특정 시간만큼 대기

1. Throw Exception - 대기시 예외

  • add(e) : 지정된 요소 큐에 추가 → 큐가 가득 차면 IllegalStateException 예외 던짐
  • remove() : 큐에서 요소 제거, 반환 → 큐가 비어 있으면 NoSuchElementException 예외 던짐
  • element() : 큐 머리 요소 반환, 요소 제거(x) → 큐가 비어 있으면 NoSuchElementException
    예외 던짐

2. Special Value - 대기시 즉시 반환

  • offer(e) : 지정된 요소 큐에 추가 시도 → 큐가 가득 차면 false 반환
  • poll() : 큐에서 요소 제거, 반환 → 큐가 비면 null 반환
  • peek() : 큐의 머리 요소 반환, 요소 제거(x) → 큐가 비어 있으면 null 반환

3. Blocks - 대기

  • put(e) : 지정된 요소 큐에 추가할 때까지 대기 → 큐가 가득 차면 공간 생길 때까지 대기
  • take() : 큐에서 요소 제거 후 반환 → 큐가 비어 있으면 요소가 준비될 때까지 대기
  • Examine (관찰) : 해당 사항 없음.

4. Times Out - 시간 대기

  • offer(e, time, unit): 지정된 요소 큐에 추가 시도, 지정된 시간 동안 큐가 비워지기를 대기 → 시간 초과 시 false 반환
  • poll(time, unit) : 큐에서 요소 제거 후 반환 → 큐에 요소가 없으면 지정된 시간 동안 요소 준비를
    기다림 → 시간 초과 시 null 반환

원자적 연산


  • 더 나눌 수 없는 단위로 수행되는 연산
  • 멀티 스레드 상황에서 다른 스레드 간섭 없이 안전히 처리되는 연산
    • ex_1 ) i = 1 : 원자적 연산(o)
    • ex_2 ) i = i + 1 : 원자적 연산(x)
  • 원자적이지 않은 연산을 멀티 스레드 환경에서 실행할 경우 문제가 발생할 수 있음

AtomicInteger

  • AtomicInteger : 멀티 스레드 환경에서 안전한 증가 연산 수행을 돕는 클래스
  • synchronized 연산과 달리 락을 사용하지 않고 원자적 연산을 만들어 냄

CAS(Compare-And-Set)

  • 락 프리(lock-free) 기법
  • 락을 걸지 않고 원자적 연산을 수행하는 방법(완전히 안거는건 아니고 작은 단위에 검)
  • 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 특별히 하나의 원자적인 연산으로 묶어서 제공하는 기능 → SW가 아닌 HW가 제공하는 기능

CAS 연산 vs Lock 방식

Lock 방식

  • 비관적 방식 → 다른 스레드가 방해할 것이라고 가정
  • Data 접근 전 lock 획득 필수
  • 다른 스레드 접근을 막음
  • 락을 대기하는 스레드는 CPU를 거의 사용하지 않음
  • 락 사용 시 획득 시점, 대기 시점의 상태 변경 시 컨텍스트 스위칭이 발생, 오버헤드가 증가할 수 있음

CAS 방식

  • 낙관적 방식 → “어지간해선 충돌 없겠지”
  • lock을 사용하지 않고 데이터에 바로 접근
  • 충돌이 발생하면 재시도
  • 스핀락과 유사한 오버헤드 → 충돌 빈도가 높으면 성능 저하가 발생함

💡 충돌이 많이 없는 경우는 CAS가 빠름
→ ex) 간단한 CPU 연산의 경우
→ 오래 기다리는 작업을 요청할 경우 CPU를 계속 사용하며 기다리게 됨

⇒ 일반적으로 동기화 락을 사용, 특별한 경우에 한해서 CAS 사용해야 함

⇒ 자바의 동시성 라이브러리들은 일반적으로 CAS 연산을 활용함. 우리가 직접 CAS 연산을 활용하는 경우는 드뭄

동시성 컬렉션

  • 컬렉션 프레임 워크가 제공하는 대부분의 연산은 원자적 연산이 아님 → 스레드 세이프 하지 않음
  • 여러 스레드가 접근해야할 경우 synchronized, Lock 등과 같은 안전한 임계영역을 만들어 해결 → 코드를 모두 복사해서 synchronized 기능 추가해야 할까? → 성능과 트레이드 오프 발생

Proxy

  • 프록시가 대신 동기화 기능을 처리
  • 어떤 객체에 대한 접근을 제어하기 위해 해당 객체의 대리인 또는 인터페이스 역할을 하는 객체 제공 패턴
  • 주요 목적
    • 접근 제어 - 실제 객체에 대한 접근 제한
    • 성능 향상 - 실제 객체 생성 지연 or 캐싱 → 성능 최적화
    • 부가 기능 제공 - 실제 객체에 추가적인 기능 제공 가능

자바 동시성 컬렉션 - synchronized

  • List<String> list = Collections.synchronizedList(new ArrayList<>());
    • synchronized를 추가하는 프록시 역할 → 동기화 프록시를 만들어냄
  • Collections 가 제공하는 동기화 프록시 기능 덕분에 스레드가 안전한 컬렉션으로 변경해서 사용할 수 있음

단점

  • 동기화 오버헤드가 발생함 → 호출 시마다 동기화 비용이 추가됨
  • 전체 컬렉션에 대해 동기화 발생 → 병렬처리 효율 저하
  • 정교환 동기화 불가 → 특정 부분이나 메서드에 대한 선택적인 동기화 적용은 어려움

자바 동시성 컬렉션 - 동시성 컬렉션

  • ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue (생산자, 소비자)
  • 일부 메서드에만 동기화 적용
  • 정교한 동기화 구현 및 성능 최적화

동시성 컬렉션 종류

  • List : CopyOnWriteArrayListArrayList 대안
  • Set : CopyOnWriteArraySet, ConcurrentSkipListSet (정렬 순서 유지, comparator 사용)
  • Map : ConcurrentHashMap , ConcurrentSkipListMap (정렬 순서 유지, comparator 사용)
  • Queue : ConcurrentLinkedQueue
  • Deque : ConcurrentLinkedDeque
  • BlockingQueue
    • ArrayBlockingQueue : 크기 고정 블로킹 큐, 공정 보드 사용(성능 저하)
    • LinkedBlockingQueue : 크기 고정, 무한 블로킹 큐
    • PriorityBlockingQueue : 우선순위 블로킹 큐
    • SynchronousQueue : 중간 큐 없이 생산자, 소비자 직접 거래
    • DelayQueue : 지정된 지연 시간이 지난 후에 소비됨
profile
백엔드 개발자 나무입니다

0개의 댓글