Java

[ Java ] Synchronized와 volatile 그리고 Atomic

haenni 2025. 9. 18. 13:51

 

Notion에서 작성 된 글입니다. 템플릿이 깨진다면 Notion을 확인해주세요. 
 

Lock | Notion

들어가기앞서…

hail-buttercup-c86.notion.site

 

들어가기앞서…

동시성 이슈는 개발자라면 한 번쯤은 마주하게 되는 난관 중 하나이다.

특히 다중 스레드를 다루는 웹 서버나 백엔드 시스템에서 값이 제대로 반영되지 않는 문제가 발생하면, 그 근본 원인은 대부분 동시성 문제로 나타난다.

오늘은 동시성이 무엇이고, 왜 문제가 생기는지, 그리고 어떻게 해결할 수 있는지 정리해보자.


자바에서의 동기화

동시성(Concurrency) 문제란 도대체 무엇일까?

동시성 이슈라고 하면, 많은 사람이 겪고 고민하는 문제중 하나라고 생각한다. 당장 구글링을 해보면 동시성 문제에 대한 트러블 슈팅과 정의가 넘쳐난다. 도대체 동시성 문제란 무엇일까?

 

 

동시성이란, 여러 스레드가 같은 데이터(공유 자원)를 동시에 읽고 쓰면서 서로의 작업을 덮어쓰거나, 중간 상태를 보거나 순서가 뒤틀려서 “의도한 값”이 나오지 않는 현상이다.

이런 문제가 발생하는 이유는 대표적으로 원자적(atomic)이지 않은 연산을 동시에 수행하기 때문이다.

예를들어 x가 0일 때, x = x + 1과 같은 연산을 수행한다고 해보자.

해당 연산은 간단해보이지만, 실제로 읽고 더하고 값을 더하는 3단계의 과정을 거치게된다.

여기서 스레드1과 스레드2가 공유 자원을 통해 동시에 쓰기 작업을 진행한다고 하였을 때, 스레드1이 더하는 과정에서 스레드2가 읽어버리면 어떻게 될까?

결과는 총 2가 될 것 같지만, 1이 나오게 된다.

스레드1은 읽는 과정에서 0을 읽고 더하기를 준비하고 있고, 스레드2는 스레드1이 더하기를 하기 전의 값인 0을 읽었기 때문에, 둘다 똑같이 0의 값에 1을 더하게 되는 것이다. (이러한 현상을 레이스 컨디션이라고도한다.)

 

동시성 예제

동시성을 예제를 통해서 알아보자.

아래는 100개의 스레드가 1000원씩 더해 총 값 100000원을 기대하는 예제코드이다.

import java.util.concurrent.*;

public class RaceCounterBad {
    static int counter = 0; // 공유 변수 (보호 안 함)

    public static void main(String[] args) throws Exception {
        int threads = 100;
        int perThread = 1000; // 총 기대값 = 100 * 1000 = 100,000

        ExecutorService pool = Executors.newFixedThreadPool(threads);
        CountDownLatch latch = new CountDownLatch(threads);

        for (int t = 0; t < threads; t++) {
            pool.submit(() -> {
                for (int i = 0; i < perThread; i++) {
                    // 읽기 -> +1 -> 쓰기 (원자적 X, 끼어들기 가능)
                    counter++;
                }
                latch.countDown();
            });
        }

        latch.await();
        pool.shutdown();

        System.out.println("기대값 = " + (threads * perThread));
        System.out.println("실제값 = " + counter);
    }
}
기대값 = 100000
실제값 = 72345   //실제 값은 매번 다르게 나오게 된다.

실제 값은 다르게 도출되는 것을 확인할 수 있다.

 

이런 문제를 어떻게 해결해야할까?

자바에서는 이러한 동시성 이슈를 해결하기 위해 여러가지 도구와 키워드들을 제공한다.

대표적으로는 synchronized, volatile, Atomic등이 존재한다.

어떻게 동시성을 해결할 수 있을 지 알아보자!

 

동시성 문제 해결 방법

1) Synchronized

Synchronized는 자바 내장 모니터 락이다. synchronized는 임계영역을 한 번에 한 스레드만 들어오도록 보장하여, 데이터의 무결성과 일관성을 보장한다.

Synchronized의 동작 방식

synchronized는 임계영역에 한 번에 한 스레드만 들어오도록 보장한다고 했는데, 어떻게 동작하는걸까?

여러 개의 스레드가 동시에 snychronized method를 호출하려고 한다.

하지만 synchronized는 동시에 단 1개의 스레드만 허용하기때문에, thrend-1이 락을 먼저 흭득하여 thread-2,3,4는 블로킹 상태로 들어간다.(대기상태)

thread-1이 synchronized 메서드 실행이 끝내고 나가면 락을 해제한다. 이후 대기중이던 스레드가 들어가게 된다.(순서는 JVM 스케쥴러에 따라 달라질 수 있다)

synchronized는 이렇게 한 번에 하나의 스레드만 안전하게 실행할 수 있도록 해, 동시성을 보장한다.

 

Synchronized의 단점

synchronized의 가장 큰 단점은 성능 저하이다.

특정 스레드가 블럭 전체에 락(lock)을 걸면, 해당 락에 접근하는 스레드들은 모두 블로킹(blocking) 상태에 들어가기 때문에 병렬성이 떨어지며, 아무 작업도 하지 못한 채 자원을 낭비하게 된다.

또한 여러 락(lock)을 동시에 걸어놓기때문에 교착 상태의 위험이 있으며, 스레드가 들어온 순서대로 실행될 것 같지만, 실제 순서는 JVM 스케쥴러가 결정하기 때문에 락 공정성이 보장되지않아 특정 스레드가 계속 락을 못 얻고 기아(starvation)에 따질 수 있다.

또한, 블로킹 상태의 스레드를 준비 또는 실행 상태로 변경하기 위해서는 시스템의 자원을 사용해야한다.

 

2) volatile

volatile은 JVM에서 스레드는 실행되고 있는 CPU 메모리 영역에 데이터를 캐싱한다.

멀티 코어 프로세서에서 다수의 스레드가 변수 a를 공유하더라도 캐싱된 시점에 따라 데이터가 다를 수 있으며, 서로 다른 코어의 스레드는 데이터 값이 불일치하는 문제가 생긴다.

임의로 데이터를 갱신해 주지 않는 이상, 캐싱된 데이터가 언제 갱신되는지 또한 정확히 알 수 없다.

이런 경우 volatile 키워드를 사용하여 CPU 메모리 영역에 캐싱된 값이 아니라 항상 최신의 값을 가지도록 메인 메모리 영역에서 값을 참조하도록 할 수 있다.

즉, 동일 시점에 모든 스레드가 동일한 값을 가지도록 동기화한다.

volatile을 사용할 때 변수 옆에 키워드를 붙여서 선언이 가능하다.

public volatile long count = 0;

하지만 volatile 을 통해 모든 동기화 문제가 해결되는 건 아니다. ++ 연산과 같이 원자성이 보장되지 않는 경우 동시성 문제는 동일하게 발생한다.

단지 멀티 코어에서의 모든 스레드가 캐시 없이 최신의 값을 보게할 뿐이다.

따라서 volatile은 상호배제(mutual exclusion)를 제공하지 않고도 데이터 변경의 가시성을 보장하며, 원자적 연산에서만 동기화를 보장한다.

 

Atomic

atomic 변수는 멀티 스레드 환경에서 원자성을 보장하기 위해 나온 개념이다.

synchronized와는 다르게, blocking이 아닌 non-blocking으로 원자성을 보장하며, 동기화 문제를 해결한다.

 

CAS(Compare And Swap)알고리즘

atomic의 핵심 동작 원리는 CAS 알고리즘이다.

더보기

용어 정리

destination: 실제 값이 저장된 메모리 위치 (공유 변수)

compared value: 내가 기대하는 값 (예상 값)

exchanged value: 바꾸려는 새로운 값

CAS 알고리즘 흐름은 다음과 같다.

destination 값과 compared value를 비교한 뒤 같으면 destination을 exchanged value로 교체한다.

destination 값과 compared value를 비교한 뒤 다르면 아무것도 하지않고 실패 처리한다.

즉 값이 예상한 그대로일 때만 바꾸게되는데, 다른 스레드가 끼어들어 값을 변경했다면 실패하기 때문에 안전하게 재시도를 할 수 있다.

 

CPU 캐시 메모리 문제 해결

CAS 같은 기법이 왜 필요할까? 아래와 같이 그림을 살펴보자.

 

각 CPU는 RAM(메인 메모리)에서 값을 가져와 자신의 캐시 메모리에 저장하고 연산한다.

만약에 CPU1과 CPU2가 같은 객체(obj.count)를 동시에 다루게 된다면 어떻게 될까?

  • CPU1은 캐시에 obj.count를 2라고 저장했는데, CPU2는 캐시에 여전히 obj.count = 1을 가지고 있을 수 있다.

이 차이때문에 데이터 불일치가 발생하는 것이다.

이런 문제를 해결하고자 현재 쓰레드에 저장된 값과 메인 메모리에 저장된 값을 비교하여 일치하는 경우 새로운 값으로 교체하고, 일치 하지 않는 다면 실패하고 재시도를 한다. 이렇게 처리되면 CPU캐시에서 잘못된 값을 참조하는 가시성 문제가 해결되게 된다.

  • 가시성이란?
    • 스레드1이 값을 바꾼다 → 하지만 CPU 캐시에만 있고, 메인 메모리에 반영을 안 한다.
    • 스레드2는 메인 메모리에서 값을 읽는다 → 근데 CPU1 캐시에 있던 최신 값은 못 본다.
    • 그래서 스레드2는 값이 변경 되었는 지 여부를 알 수 없어 다른 값을 보게 된다.→ 이게 가시성 문제다.
  • :한 스레드가 변경한 값이 다른 스레드에게 바로 보이지 않는 문제이다.

 

Atomic을 적용해보자.

atomic type인 AtomicInteger 클래스가 동기화 문제를 어떻게 해결하는 지 살펴보자.

public class AtomicIntegerTest {

    private static int count;

    public static void main(String[] args) throws InterruptedException {
        AtomicInteger atomicCount = new AtomicInteger(0);
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
                atomicCount.incrementAndGet();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
                atomicCount.incrementAndGet();
            }
        });

        thread1.start();
        thread2.start();

        Thread.sleep(5000);
        System.out.println("atomic 결과 : " + atomicCount.get());
        System.out.println("int 결과 : " + count);
    }
}
atomic 결과 : 200000
int 결과 : 142837

AtomicInteger와 int 타입인 count 변수를 생성한 다음, 두 개의 스레드에서 count++ 연산을 한다.

AtomicInteger 타입인 atomicCount는 의도대로 200000이 출력되었지만 int 타입인 count는 동기화가 되징낳아 잘못된 값을 출력하는 것을 볼 수 있다.

동기화가 어떻게 지켜지는지 AtomicInteger 클래스의 incrementAndGet()메서드를 살펴보자.

public class AtomicInteger extends Number implements java.io.Serializable {

    private static final Unsafe U = Unsafe.getUnsafe();
    private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
    private volatile int value;

    public final int incrementAndGet() {
                return U.getAndAddInt(this, VALUE, 1) + 1;
    }
}

public final class Unsafe {
        @HotSpotIntrinsicCandidate
        public final int getAndAddInt(Object o, long offset, int delta) {
                int v;
                do {
                        v = getIntVolatile(o, offset);
                } while (!weakCompareAndSetInt(o, offset, v, v + delta));
                        return v;
        }
}

incrementAndGet() 내부에서 CAS 알고리즘을 구현하고 있다.

getAndAddInt() 내부에서는 weakCompareAndSetInt() 메서드를 호출하여 메모리에 저장된 값과 현재 값을 비교하여 동일하다면, 메모리에 변경한 값ㅇㄹ 저장하고 true를 반환하여 while문을 빠져나간다.

추가로 눈 여겨 볼 점은 value 변수에 volatile 키워드가 붙어있다.

따라서 Atomic 변수는 CAS(원자성) + volatile(가시성)을 보장하게 된다.

즉, 동시접근 문제와 가시성 문제 모두 해결할 수 있다.


레퍼런스

Java의 동기화를 공부하며 참고한 레퍼런스입니다.

쓰레드 동기화 (sync, volatile, AtomicClass)

 

쓰레드 동기화 (sync, volatile, AtomicClass)

쓰레드의 동기화 : 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간서하지 못하게 하는 것. 쓰레드는 class의 멤버 변수(자원)을 사용한다. 멀티쓰레드 환경에서는 쓰레드 동기화를 하지않으면, 심

jiwondev.tistory.com

 

[Java] 동시성 문제 해결을 위한 atomic과 CAS 알고리즘

 

[Java] 동시성 문제 해결을 위한 atomic과 CAS 알고리즘

synchronized의 문제점synchronized는 blocking을사용하여 멀티 스레드 환경에서 공유 객체를 동기화하는 키워드입니다.그러나 blocking에는 여러 가지 단점이 존재하는데, 그 중에서 손 꼽는 문제는 성능

jtm0609.tistory.com

 

Java Tutorials - Thread Synchronisation | synchronized keyword

 

Java Tutorials - Thread Synchronisation | synchronized keyword

The java programming language supports multithreading. The problem of shared resources occurs when two or more threads get execute at the same time. In such a situation, we need some way to ensure that the shared resource will be accessed by only one threa

www.btechsmartclass.com

 

[Java] 동시성 문제 해결을 위한 atomic과 CAS 알고리즘

 

[Java] 동시성 문제 해결을 위한 atomic과 CAS 알고리즘

synchronized의 문제점synchronized는 blocking을사용하여 멀티 스레드 환경에서 공유 객체를 동기화하는 키워드입니다.그러나 blocking에는 여러 가지 단점이 존재하는데, 그 중에서 손 꼽는 문제는 성능

jtm0609.tistory.com