반응형

멀티스레드 문제 예시 - 은행 계좌 잔액 갱신 문제

class BankAccount {
    private int balance = 100;

    public void withdraw(int amount) {
        if (balance >= amount) { // 조건 검사
            balance -= amount;  // 잔액 갱신
        }
    }
}

의도된 동작

  1. 스레드 A가 50을 출금.
  2. 스레드 B가 50을 출금.
  3. 결과적으로 잔액은 0이 되어야 함.

문제 상황

  • 스레드 A와 스레드 B가 동시에 withdraw(50) 메서드를 호출하면, 잔액 조건 검사와 갱신 작업이 중첩될 수 있음.
  • 이로 인해 두 스레드가 잔액 조건을 동시에 검사한 후, 동시에 출금을 진행.
  • 결과적으로, 잔액이 50으로 잘못 계산됨.

 

**정상적인 단일 스레드 실행**

-------
스레드 A 실행:
[검사] 잔액: 100 >= 50  -> True
[갱신] 잔액 -= 50       -> 잔액: 50

**멀티스레드에서의 충돌**
스레드 A 실행:                          스레드 B 실행:
[검사] 잔액: 100 >= 50  -> True        [검사] 잔액: 100 >= 50  -> True
[갱신] 잔액 -= 50       -> 잔액: 50    [갱신] 잔액 -= 50       -> 잔액: 50

결과: 두 스레드가 모두 출금을 완료했지만, 최종 잔액은 50으로 계산됨 (잘못된 결과).

멀티스레드 환경의 위험 요소

  1. Race Condition (경쟁 상태)
    • 여러 스레드가 동시에 동일한 자원에 접근하고 이를 수정할 때, 실행 순서에 따라 결과가 달라질 수 있음.
    • 예: 위의 은행 계좌 예제에서 스레드 간 동기화가 없을 경우 발생.
  2. Deadlock (교착 상태)
    • 두 스레드가 서로 자원을 기다리며 무한히 대기하는 상황.
    • 예: 스레드 A가 자원 1을 점유하고 자원 2를 기다리는 동안, 스레드 B는 자원 2를 점유하고 자원 1을 기다림.
  3. Data Corruption (데이터 손상)
    • 여러 스레드가 동일한 메모리 위치를 수정하면, 결과값이 예기치 않게 손상될 수 있음.
    • 예: 공유 데이터 구조의 상태가 불완전하거나 잘못된 상태로 유지됨.
  4. Thread Interleaving (스레드 중첩 실행)
    • 스레드가 실행 중에 문맥 교환(Context Switching)으로 인해 실행 흐름이 중첩됨.
    • 예: 스레드 A와 B가 교차 실행되며 연산이 중간 상태에서 중단됨.

해결 방법

1. 동기화 사용

  • synchronized 키워드: 공유 자원에 한 번에 하나의 스레드만 접근하도록 보장.
  • ReentrantLock: 더 세밀한 락 제어 가능.
public synchronized void withdraw(int amount) {
    if (balance >= amount) {
        balance -= amount;
    }
}

2. 원자적 연산 사용

  • Java의 AtomicInteger 또는 AtomicLong과 같은 클래스 사용.
  • 내부적으로 CAS(Compare-And-Swap)를 활용하여 동기화 문제를 해결.
import java.util.concurrent.atomic.AtomicInteger;

class BankAccount {
    private AtomicInteger balance = new AtomicInteger(100);

    public void withdraw(int amount) {
        balance.addAndGet(-amount); // 원자적 연산
    }
}

3. 동시성 제어 도구

  • CountDownLatch, Semaphore, CyclicBarrier 등 사용.
  • 특정 조건에서 스레드의 실행을 제어하여 동기화 문제를 방지.
반응형
반응형

AtomicInteger.class

Java에서 사용하는 클래스로 멀티 스레드 환경에서 안전하게 사용되기 위한 원자성을 보장받는 자료형이다

클래스의 주요 기능 및 특징

  • 원자적 연산
    • 다른 스레드가 해당 연산의 중간 상태를 볼 수 없음
  • 스레드 안전
    • 여러 스레드에서 동시에 접근하더라도 데이터 경합 없이 안전하게 값을 읽고 쓸 수 있음
  • 락 없이 동작
    • 기존의 동기화 방식(synchronized 키워드)은 락을 사용하지만, 아토믹 연산은 CPU가 제공하는 하드웨어 수준의 락-프리(Lock-Free) 매커니즘을 사용
  • CAS 알고리즘
    내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용하여 값을 업데이트
    • 특정 메모리 위치 값이 예상값과 같은지 확인한 후, 같다면 새 값으로 교체
    • 이 과정에서 충돌을 감지

주요 메서드

  • get()
  • set(int newValue)
  • getAndIncrement()
    • 현재 값 반환 후 1 증가
  • incrementAndGet()
    • 값을 1 증가시키고 증가된 값 반환
  • compareAndSet(int expect, int update)
    • 현재 값이 expect와 같으면 update 값으로 변경 (CAS 연산)

사용 고려 시점

  • 동시성 프로그래밍에서 데이터를 안전하게 업데이트해야 하는 경우
    • 멀티 스레드 환경에서 카운터 증가
    • 통계 집계
  • 락 기반 동기화보다 가벼운 대안
  • 성능이 중요한 환경에서 데이터 무결성을 유지해야 할 때

❓CAS (Compare-And-Swap)

CAS는 값의 업데이트가 특정 조건에서만 이루어지도록 보장하는 하드웨어 지원 원자적 연산
따라서 자바에만 있는 것이 아니라, C 계열의 언어에서도 std::atmoic 클래스와 같이 사용되고 있음

CAS 작동 방식

  • 특정 메모리 위치에 대해, 세 값을 비교 및 업데이트
    • 현재 값 (current): 현재 메모리에 저장된 값
    • 기대 값 (expected): 우리가 예상하는 값
    • 새 값 (new): 우리가 저장하려는 값
  • CAS 연산의 과정
    • 메모리의 현재 값이 기대 값(expected)과 동일하면, 새 값으로 업데이트
    • 메모리의 현재 값이 기대 값과 다르면 실패를 반환

CAS 장점

  • 스케일링 가능성: 여러 스레드가 동시에 접근해도 성능 저하가 적다.
  • 스레드가 블로킹되지 않으므로 데드락 위험성이 없다

CAS 단점

  • Busy-waiting:
    • 실패할 경우 재시도하는 루프를 사용하므로 CPU 자원을 소비할 수 있다.
    • 특히 충돌이 빈번하면 성능 저하가 발생할 수 있다.
  • ABA 문제:
    • 메모리 값이 A -> B -> A로 변경된 경우, CAS는 값이 바뀌지 않았다고 판단할 수 있다.
    • 이 문제는 태그(tag) 또는 버전 관리를 통해 해결

코드 예시 - 과정만 참고

public final int incrementAndGet() {
    for (;;) {
        int current = get(); // 현재 값 읽기
        int next = current + 1; // 새로운 값 계산
        if (compareAndSet(current, next)) // CAS 연산
            return next; // 성공 시 반환
    }
}

CPU의 원자적 명령어

CPU는 CAS 연산을 지원하기 위해 특정 명령어(기계어)를 제공

  • x86 아키텍처 - CMPXCHG(Compare and Exchange)
  • ARM 아키텍처 - LDREX/STREX

메모리 값을 읽고 수정하는 작업을 한 번에 처리하므로, synchronized 키워드 없이 안전하게 업데이트


반응형

'호기심 천국 > Java' 카테고리의 다른 글

멀티스레드 문제 상황 예시 1  (1) 2025.01.20
StringBuilder vs String 문자열합치기  (0) 2023.09.19
반응형

빌더는 Capacity와 count를 갖고있다. 빌더 안에서 문자열을 합쳐도 StringBuilder의 capacity를 넘지 않는다면 new 연산을 하지 않음

이유는 아래 설명을 통해 풀어가보겠다

StringBuilder : 기존의 capacity일 때는 새로운 메모리에 문자열을 추가하는 것이 아닌 기존의 메모리에 담기 때문에.

String은 불변하고, 불변성의 특징을 갖기 때문에 문자열을 추가할 때는 두개를 서로 붙여서 새로운 메모리를 할당하는 new 연산이 들어감

추가로 StringBuilder와 기능은 동일한 StringBuffer를 사용한다면 성능이 약간 낮아지는 대신에 Thread-Safe하다는 것까지 체크하면 좋을 것 같다.

문자열 합칠 때 비교

  • String : 무조건 new String(a + b);
  • StringBuilder : a.length+b.length < capacity ? StringBuilder(a + b) : new StringBuilder(a + b);

String클래스의 배열 final byte[]

 

StringBuilder는 추상클래스AbstractStringBuilder를 상속받고, 해당 클래스에서 char[]배열은 final선언이 안되어있음

 

때문에 동작은 아래와 같음

 

결론 : 문자열을 더할 때는 String 대신 StringBuilder나 StringBuffer를 사용하자.

참고 사이트 : https://cjh5414.github.io/why-StringBuffer-and-StringBuilder-are-better-than-String/

https://readystory.tistory.com/141

반응형

'호기심 천국 > Java' 카테고리의 다른 글

멀티스레드 문제 상황 예시 1  (1) 2025.01.20
AtomicInteger (멀티스레드 안전) 겉 핥기  (0) 2025.01.19

+ Recent posts