[교재 EffectiveJava] 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라

반응형
728x90
반응형

synchronized 키워드

해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. 많은 프로그래머가 동기화를 배타적 실행, 즉 한 스레드가 변경하는 중이라서 상태가 일관되지 않은 순간의 객체를 다른 스레드가 보지 못하게 막는 용도로만 생각한다. 한 객체가 일관된 상태를 가지고 생성되고, 이 객체에 접근하는 메서드는 그 객체에 락(lock)을 건다. 락을 건 메서드는 객체의 상태를 확인하고 필요하면 수정한다. 

 

즉, 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시킨다. 동기화를 제대로 사용하면 어떤 메서드도 이 객체의 상태가 일관되지 않은 순간을 볼 수 없을 것이다. 

 

여기에 중요한 기능이 한가지 더 있다.

동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수도 있다. 동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다. 

 

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다. 

 

 

예제로 파악하기

다른 스레드를 멈추는 올바른 방법은 다음과 같다.

첫번째 스레드는 자신의 boolean 필드를 폴링하면서 그 값이 true가 되면 멈춘다. 이 필드를 false로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할때 true로 변경하는 식이다. boolean 필드를 읽고 쓰는 작업은 원자적이라 어떤 프로그래머는 이런 필드에 접근할때 동기화를 제거하기도 한다. 

 

잘못된 코드 - 이 프로그램은 얼마나 오래 실행될까?
package com.java.effective.item78;

import java.util.concurrent.TimeUnit;

public class Main {
    private static 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;
    }
}

 

위 프로그램이 1초 후 종료될까? 메인 스레드가 1초 후 stopRequested를 true로 설정하면 backgoroundThread는 반복문을 빠져나올 것 처럼 보일 것이다. 하지만 끝나지 않고 영원히 수행되는 위험한 코드다. 

 

원인은 동기화에 있는데, 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다. 동기화가 빠지면 가상머신이 다음과 같은 최적화를 수행할 수도 있다. 

 

OpenJDK 서버 VM이 실제로 적용하는 끌어올리기(hoisting)라는 최적화 기법으로 최적화한 코드 
/* 기존 */
while (!stopRequested) {
    i++;
}
            
/* 최적화한 코드 */
if (!stopRequested) {
	while (true) {
        i++;
    }
}

 

stopRequested 필드를 동기화하여 문제를 해결해보자.

package com.java.effective.item78;

import java.util.concurrent.TimeUnit;

public class Main2 {
    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();
    }
}

쓰기/읽기 메서드를 모두 동기화해야한다. 둘중 하나만 동기화해서는 동작을 보장하지 않는다. 

 

 

안전 실패(safety failuer)

동기화하는 또다른 방안으로, stopRequested 필드를 volatile으로 선언하면 동기화 생략이 가능하다. volatile 한정자는 배타적 수행과는 상관 없지만 항상 가장 최근에 기록된 값을 읽게됨을 보장한다.

 

■ volatile 포스팅 바로가기

https://devfunny.tistory.com/841?category=957918 

 

[JAVA] Volatile 변수

volatile 변수 volatile로 선언된 변수의 값을 바꿨을때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. ▶ 변수의 값을 읽을때 CPU cache에 저장된 값이 아닌 Main 메모리에서 읽는다. 기존에

devfunny.tistory.com

 

volatile 필드를 사용하여 스레드가 정상 종료된다.
package com.java.effective.item78;

import java.util.concurrent.TimeUnit;

public class MainVolatile {
    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;
    }
}

하지만, volatile 필드 사용에도 주의해야할 사항이 있다.

 

잘못된 코드 - 동기화가 필요하다
package com.java.effective.item78;

import java.util.concurrent.TimeUnit;

public class MainVolatile2 {
    private static volatile int nextSerialNumber = 0;

    public static int generateSerialNumber() {
        return nextSerialNumber++;
    }
}

이 메서드는 매번 고유한 값을 반환할 의도로 만들어졌다. 이 메서드의 상태는 nextSerialNumber라는 단 하나의 필드로 결정되는데, 원자적으로 접근할 수 있고 어떤 값이든 허용한다. 따라서 굳이 동기화되지 않더라도 불변식을 보호할 수 있어보인다.  하지만 동기화 없이는 올바로 동작하지 않는 코드다.

nextSerialNumber++;

이 코드를 보자. 증가 연산자(++)을 사용하여 코드상으로는 하나지만 실제로는 nextSerialNumber 필드에 두번 접근한다.

1) 먼저 값을 읽고,
2) 그런 다음 1이 증가한 새로운 값을 저장한다.

만약 두번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫번째 스레드와 똑같은 값을 돌려받게 된다. 프로그램이 잘못된 결과를 계산해내는 것을 '안전 실패(safety failuer)' 라고 한다.

 

해결방안1
package com.java.effective.item78;

import java.util.concurrent.TimeUnit;

public class MainVolatile2 {
    private static int nextSerialNumber = 0;

    public synchronized static int generateSerialNumber() {
        return nextSerialNumber++;
    }
}

synchronized를 메서드에 붙였다면 nextSerialNumber 필드에 volatile을 제거해야한다. 이 메서드를 더 견고하게 하려면 int 대신 long을 사용하거나 nextSeraialNumber 가 최댓값에 도달하면 예외를 던지게해야 한다.

 

다음단계. java.util.concurrent.atomic을 이용한 락-프리 동기화
private static final AtomicLong nextSerialNumber = 0;

public static long generateSerialNumber() {
	return nextSerialNum.getAndIncrement();
}

AutomicLong 타입으로 필드를 선언했다. volatile이 동기화의 두 효과 중 통신쪽에만 지원하지만 이 패키지는 원자성(배타적 실행)까지 지원한다. 우리가 generateSerialNumber에 원하는 바로 그 기능이다. 성능도 동기화 버전보다 더 우수하다.

 

■ AutomicLong 포스팅 바로가기

https://devfunny.tistory.com/812?category=957918 

 

[JAVA8 병렬프로그래밍] 원자적 변수 atomic

예제 Counter.java package org.example.atomic; public class Counter { private int c = 0; public void increment() { c++; } public void decrement() { c--; } public int value() { return c; } } 위 예제는..

devfunny.tistory.com

 

 

정리

이번 아이템에서 언급한 문제들을 피하기 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는것이다. 불변 데이터만 공유하거나 아무것도 공유하지 말자. 다시 말해, 가변 데이터는 단일 스레드에서만 쓰도록 하자. 

 

한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다. 그러면 그 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어나갈 수 있다. 이런 객체를 사실상 불변이라고 하고, 다른 스레드에 이런 객체를 건네는 행위를 안전 발행(safe publication)이라고 한다.  

 

객체를 안전하게 발행하는 방법은 많다.

1) 정적 필드

2) volatile 필드

3) final 필드

4) 락을 통해 접근을 하는 필드

5) 동시성 컬렉션에 저장하는 방법 

 

 

반응형

Designed by JB FACTORY