본문 바로가기
☕️ JAVA/🦋 Effective Java

자바의 동시성 프로그래밍, 가변 데이터를 동기화하는 3가지 방법(+a. 자바 기본 타입의 원자성에 대하여)

by kukim 2022. 1. 24.

이 글은 책 이펙티브 자바 3판 Item78과 하단 Reference 참고했습니다. 잘못된 내용이 있다면 편하게 말씀해주세요 🙏🏻

 

주제

공유 중인 가변 데이터는 동기화해 사용하라

 

결론

가변 데이터는 공유하지 않는 게 좋다.

하지만 공유해야 한다면 가변 데이터의 읽고, 쓰는 동작에 동기화를 신경 써야 한다.

동기화 방법은 3가지(synchronized, Atomic class, volatile)가 있다. 적재적소에 사용하자.

 

목차

동기화란?

synchronized

Atomic class (java.util.concurrent.atomic)

volatile

예제 StopThread

+a 자바 기본 타입의 원자성에 대하여


동시성 프로그래밍에서 스레드 간 데이터를 공유할 때는 불변 데이터만 공유하거나 가변 데이터는 공유하지 않는 것이 좋다. 가변 데이터 공유는 위험하기 때문이다. 가변 데이터는 단일 스레드에서만 쓰거나 공유 자원에 접근할 때 한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에 공유하는 부분만 동기화해도 된다. 그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽을 수 있다.

하지만 가변 데이터를 공유할 때가 있다. 그렇다면 가변 데이터를 읽고 쓸 때 동기화를 어떻게 해야 할까?

동기화란?

동기화란 프로세스나 1개 이상의 스레드들이 데이터를 공유하고 있다는 뜻이다. 따라서 가변 데이터는 어느 누군가 변경한다면 다른 곳에서도 영향을 미치기 때문에 2가지 동기화에 신경 써야 한다.

  1. 배타적 실행(Exclusion), 원자적 연산 : 한 스레드가 특정 영역을 변경, 사용 중일 때 다른 스레드가 사용하지 못하게 해야 한다.
  2. 스레드 사이의 통신 : 한 스레드가 수정한 최종 결과를 다른 스레드에서 수정한 최종 결과를 읽어야 한다.

자바에서 동기화하기 위한 3가지 방법을 소개한다.

1. synchronized

synchronized 키워드는 배타적 실행과 스레드 사이의 통신 두 가지 모두 지원한다.

사용 예) 특정 메서드나 메서드 안에서 블록으로 사용할 수 있다.

public class Counter {
        private long number = 0;
    // 메서드에 synchronized
        public synchronized void increase() {
            number++;
        }

        public void increase2() {
            long temp = 0;
            synchronized (this) { // 메서드 안에서 사용
                number++;
            }
        }
    }

synchronized로 감싸진 영역은 한 번에 한 스레드씩 수행하도록(임계 영역) 보장한다. C언어의 mutex lock과 mutex unlock으로 임계 영역 범위를 지정하는 것과 유사하다. (+a 임계영역 n개의 스레드 진입할 수 있는 semaphore도 자바에 있다. 참고)

임계 영역으로 설정된 블록은 락의 보호하에 한 스레드가 만든 변화를 다른 스레드에서 확인할 수 있다.

단점

과도한 synchroinzed의 사용은 성능을 떨어뜨리고, 교착상태에 빠뜨리기도 한다.

2. Atomic class (java.util.concurrent.atomic)

Atomic class는 말 그대로 원자적인 클래스이다. 이는 java.util.concurrent.atomic에 구현되어있다. 기본 타입인 int나 double의 타입을 wrapper 한 클래스(예를 들어 Integer와 유사한)로 데이터의 원자성을 지킨다. (기본 타입 원자성에 대한 자세한 내용은 글 맨 아래 설명한다.)

Atomic class는 synchronized와 마찬가지로 배타적 실행스레드 간 통신을 지원한다. synchronized와 차이점은 ‘락’이 아닌 CAS(compare-and-swap), 저수준 CPU 연산 기술을 이용한다. 이 기술은 데이터를 메모리에 저장된 값CPU Cache에 저장된 값비교한다. 두 값이 같은 경우에만 데이터를 수정한다. synchronized 보다 좋은 성능으로 동시성을 보장할 수 있다.

public class Counter {
        AtomicLong number = new AtomicLong();
        
        public void increase() {
            number.incrementAndGet();
        }
    }

보다 자세한 메서드 사용법은 이곳을 참고하자.

https://lwn.net/Articles/252125/

3. volatile

volatile 키워드는 스레드 간 통신만 지원한다. 1개 스레드만 쓰기 작업을 하고 나머지 스레드에서 읽기 작업만 한다면 괜찮지만 그 외 2개 이상의 스레드가 동시에 쓰기 작업을 막을 수 없기 때문에 배타적 실행을 지원하지 않는다.

volatile 키워드를 선언한 변수는 CPU Cache에 저장하는 것이 아닌 메인 메모리에 저장한다. 따라서 읽기는 상관없지만 쓰기 작업에 문제가 발생한다. 추가로 Cache를 사용하지 않고 메인 메모리를 사용하기 때문에 성능이 좋지 못할 수 있다.

public class Counter {
        private volatile long number = 0;

	public synchronized void increase() {
            number++;
        }

    }

예제 - StopThread

아래 예는 스레드를 생성하여 stopRequested가 true가 될 때까지 무한루프를 돈다. 이때 메인 스레드에서 1초 뒤 stopRequested에 true를 넣어 스레드의 무한 루프를 멈추고자 한다.

결과는 안타깝게도 무한 루프를 반복하다 언젠간 끝날 수도 있고 안 끝날 수도 있다.

왜 그럴까? 스레드 간 통신 동기화가 안된다. 코드를 살펴보면 스레드가 시작될 때 stopRequested는 false로 시작되었다. 그리고 메인 스레드에서 1초 뒤 true로 변경했다. 하지만 이미 실행된 스레드는 메인 스레드가 변경한 값을 언제 참조할지 알 수 없다. 영영 자신의 CPU Cache에 저장된 값만 바라보고 멈추지 않을 수 있다.

public class StopThread {
    private static boolean stopRequested; // default value : false

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

synchronized

아래 예는 stopRequested 값의 입력과 출력(수정과, 결과 리턴)에 synchronized를 사용해 동기화하였다. 아래 코드는 문제없이 1초 뒤 종료된다.

public class StopThread_synchronized {
    private static boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested()) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}

Atomic class

아래 예도 동일하게 1초 뒤 종료된다.

public class StopThread_Atomic {
    private static AtomicBoolean stopRequested = new AtomicBoolean(false);

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested.get()) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested.set(true);
    }
}

volatile

volatile도 1초 뒤 종료된다. 이 예는 멀티 스레드가 아니고 때문에 중복된 데이터 수정이 없기 때문에 문제가 없다. 하지만 가변 데이터를 동시에 수정하고 레드가 많았다면 배타적 실행의 문제가 발생했을 것이다.

public class StopThread_volatile {
    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested) {
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

아래 내용은 이펙티브 자바 item78 - p414 에서 "언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적이다"에 호기심이 생겨 정리한 내용입니다. 

+a 자바 기본 타입의 원자성에 대하여

원자성, 원자적 데이터란 해당 데이터를 읽거나 쓰는데 '한 번에' 처리되는 것을 말한다. DB의 예로 '트랜젝션'과 유사하다.

자바 언어 문서에는 기본 타입은 모두 원자적이고 double과 long은 Non-atomic이라고 되어 있다. 정말 그럴까? 이에 대해 자세히 살펴보자.

문서에 따르면 double과 long이 아닌 int는 원자적이라고 할 수 있다. 예를 들어 원자적 단위는 CPU가 int 데이터에 접근하여 읽거나 쓸 때 한 번의 명령을 통해 접근할 수 있다는 뜻이다. 이를 바이트 코드로 이야기하자면 int num을 조회 한다면 iload_1의 하나의 명령을 통해 접근할 수 있고 num에 +1을 한다면 하나의 iadd 명령을 통해 쓰기를 할 수 있다. (참고1)

iload_1 // 조회

iadd // +1 연산

long과 double은 어떨까? 사실 원자적인 데이터라 할 수 있고, 아니라고도 할 수 있다. 32bit JVM은 한 번에 데이터를 처리하는 크기는 32bit이다. 하지만 long과 double의 경우 데이터 크기가 64bit라서 32bit로 2번 접근해야 한다. 이런 이유로 long과 double은 읽고 쓰는데 한 번에 처리되지 못하기 때문에 원자적이지 않다고 말하는 것이다. 하지만 64bit JVM는 한 번에 처리할 수 있는 크기가 64bit 이다. 따라서 64bit인 long과 double도 한 번에 처리할 수 있다. 따라서 원자적인 데이터이라고도 말할 수 있다. (참고2)

 

그렇다면 본문에서 살펴본 배타적 실행(원자적 연산)과 자바 기본 타입의 원자성은 어떤 차이가 있을까? 자바 기본 타입의 원자성은 데이터를 한 번에 읽고, 쓰는 것이다. 하지만 스레드의 배타적 실행(원자적 연산)을 지원하지 않는다. 예로 A라는 스레드가 int num를 수정하고 있다. 이때 B라는 스레드가 int num을 하여 수정할 때 아직 A의 수정 결과가 반영이 안 될 수 있다. 이때 문제가 발생한다. 

따라서 자바 기본 타입은 데이터의 읽거나 쓰는 것에 원자적이지만 배타적 실행을 지원하진 않는다. 그 이유로 기본 타입을 동기화하기 위해 synchronized, Atomic class, volatile를 사용해 통해 각 스레드가 동일한 계산 결과를 바라보도록 설정해야 한다.


다시 결론 

가변 데이터는 공유하지 않는 게 좋다.

하지만 공유해야 한다면 가변 데이터의 읽고, 쓰는 동작에 동기화를 신경 써야 한다.

동기화 방법은 3가지(synchronized, Atomic class, volatile)가 있다. 적재적소에 사용하자.


⛓ Reference

https://codechacha.com/ko/java-synchronized-keyword/

https://codechacha.com/ko/java-atomic-integer/

https://stackoverflow.com/questions/9749746/what-is-the-difference-between-atomic-volatile-synchronized

https://medium.com/thxwelchs/동시성-프로그래밍-2편-멀티쓰레드-객체공유와-상태제어-44e9f697c3d9

https://github.com/Meet-Coder-Study/book-effective-java

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

https://stackoverflow.com/questions/24584295/primitive-datatypes-are-atomic-in-java

https://stackoverflow.com/questions/517532/writing-long-and-double-is-not-atomic-in-java/517539

댓글