Thread Unsafety 사례

ifi9·2024년 10월 18일
0

동시성

목록 보기
2/2

Thread Safety?

Thread Safety란

Thread Safety는 멀티스레드 환경에서 여러 스레드가 동시에 같은 자원에 접근하거나 작업할 때, 올바르게 동작하는지 보장하는 개념이다.

스레드 안정성이 보장되지 않으면 데이터가 손상되거나 예상치 못한 동작을 하게 될 수 있다.

Thread Safety의 필요성

멀티스레드 환경에서는 여러 스레드가 동시에 공유 자원에 접근할 수 있다.
이러한 상황에서는 각각의 스레드가 어떠한 공유 자원에 동시 수정 을 시도 하는 경우 발생하는 Race Condition이나 Deadlock 등과 같은 오류가 문제가 된다.

Race Condition

두 개 이상의 스레드가 같은 데이터를 동시에 수정할 때 발생하는 문제
수정 작업이 서로 덮어쓰거나 동시에 일어나 예상하지 못한 결과를 가져옴

Deadlock

두 개 이상의 스레드가 서로를 기다리는 상황이 발생하여 블로킹되는 상황

Livelock

스레드들이 계속해서 상태를 바꾸면서도, 실질적으로는 아무런 작업을 수행하지 못하는 상태

Thread Unsafety

Java에서의 Thread Unsafety

공유 변수의 동시 접근

public class StaticExample {
    private static int sharedVariable = 0;

    public static void incrementSharedVariable() {
        sharedVariable++;
    }

    public static int getSharedVariable() {
        return sharedVariable;
    }
}

두 스레드가 같은 값을 읽고 증가시키면서 서로 덮어쓰는 현상인 Race Condition이 발생하는 경우
이 경우 여러 스레드가 동시에 sharedVariable에 접근하면 기대한 만큼의 값 증가가 일어나지 않을 수 있다.

Collection에서의 접근

일반적인 HashMap 같은 Java Collection은 스레드에 안전하지 않으므로, 여러 스레드가 동시에 접근할 때 비정상적인 동작이 발생할 수 있다.

public class MapExample {
    private Map<String, Integer> map = new HashMap<>();

    public void addElement(String key, Integer value) {
        map.put(key, value);
    }

    public Integer getElement(String key) {
        return map.get(key);
    }
}

공유 변수때와 비슷하게 쓰기 작업에 대해서 Race Condition이 발생할 수 있다.

Lazy Initialization

클래스의 초기화 시점에서 여러 스레드가 동시에 객체를 생성하는 경우에도 thread-unsafety 문제가 발생할 수 있다.
예를 들면 Singleton 패턴을 사용할 때 아래와 같은 코드는 문제가 될 수 있다.

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

위의 코드에서 getInstance()를 여러 스레드에서 동시에 호출한다면, instance가 아직 생성되지 않았을 때 동시에 여러 인스턴스를 생성할 수 있는 가능성이 있다.

Thread Safety

Thread Safety 보장 방법

Synchronized

스레드가 공유 자원에 접근할 때, synchronized를 사용하여 한 번에 하나의 스레드만 자원에 접근할 수 있도록 제어
Java에서는 synchronized 키워드를 사용하여 메서드 혹은 블록을 동기화할 수 있다.

public synchronized void increment() {
    count++;
}
  • 장점 : 자원에 대한 경쟁 상태 방지, 일관성 보장
  • 단점 : 성능 저하 문제, 병렬성 감소

Lock

Java의 java.util.concurrent.locks 패키지에서 제공하는 Lock 인터페이스를 사용하여 synchronized 보다 유연하게 동기화를 제어할 수 있다.

private final Lock lock = new ReentrantLock();

public void increment() {
    lock.lock();  // 락 획득
    try {
        count++;
    } finally {
        lock.unlock();  // 락 해제
    }
}
  • 장점 : 명시적 락 관리, 유연성
  • 단점 : 락 해제에 대해 주의하지 않으면 교착 상태 발생 가능

Atomic Class

Java의 java.util.concurrent.atomic 패키지는 thread-safe한 연산을 수행할 수 있도록 도와주는 Atomic Class를 제공한다.
대표적으로 AtomicInteger, AtomicLong 등이 있다.

private final AtomicInteger count = new AtomicInteger(0);

public void increment() {
    count.incrementAndGet();
}
  • 장점 : CAS 알고리즘 사용으로 synchronizedlock 없이도 안정성 보장. 높은 성능
  • 단점 : 복잡한 연산 혹은 여러 변수 동시 수정하는 경우 비효율적. 충돌이 잦을 경우 성능 저하

Immutable Object

불변 객체는 한 번 생성되면 그 상태를 변경할 수 없는 객체를 의미
이러한 객체는 여러 스레드가 동시에 접근하더라도 상태가 변하지 않기에 스레드 안정성을 보장
대표적으로 String Class가 있다.

public final class ImmutableValue {
    private final int value;

    public ImmutableValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}
  • 장점 : 스레드 안정성을 기본적으로 보장
  • 단점 : 값이 변경될 경우 새 객체를 생성해야 하므로, 값을 자주 변경하는 상황에서는 성능 저하 발생 가능

Thread-safe Collection

Map<String, String> map = new ConcurrentHashMap<>();
  • 장점 : 락 없이도 여러 스레드에서 동시에 안전하게 처리 가능
  • 단점 : 일부 작업에서는 일반적인 컬렉션보다 성능이 낮을 수 있음

Static Inner Class

Static inner class를 사용하면 JVM이 클래스 로드 시점에 스레드 안전성을 보장해 준다고 한다.

public class Singleton {
    private Singleton() {}

    private static class SingletonHelper {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHelper.INSTANCE;
    }
}

이 방식에서는 SingletonHelper 클래스가 호출될 때까지 로드되지 않으며, 클래스가 처음 로드되는 시점에 JVM에서 인스턴스를 안전하게 생성한다. 그렇기에 synchronized 키워드를 사용할 필요 없이 thread safety를 유지할 수 있다.

static 키워드인데 어떻게 lazy initialization를?

static 키워드를 사용하면서도 lazy initialization을 보장할 수 있는 이유는 JVM의 클래스 로딩 방식으로 인해서다.
JVM의 클래스 로딩 과정은 아래와 같다.

  • 클래스 로딩: 클래스 파일이 처음으로 참조될 때 JVM은 해당 클래스를 로드합니다.
  • 클래스 초기화: 클래스 로딩 후 정적 멤버를 초기화합니다. 이 과정은 스레드 안전하게 이루어지며, 한 번만 실행됩니다.

위와 같은 과정으로 인해 static 키워드를 사용한 static inner class는 클래스가 처음으로 참조될 때 로드되기 때문에, 처음 호출 전까지는 메모리에 적재되지 않고 초기화도 되지 않는다. 이러한 동작 방식이 lazy initialization을 보장하는 이유다.

Initializing != Loading

JVM에서 static 키워드와 관련된 것을 로드할 때 초기화가 된다고 생각을 하였다. 하지만 실제로 확인을 해보았을 때 클래스 메타데이터가 올라간다고 초기화가 되지 않음을 확인하였다.

public class Singleton {

    private static final Logger logger = LoggerFactory.getLogger(Singleton.class);

    // 싱글톤 클래스가 로드될 때 호출됨
    static {
        logger.info("Singleton 클래스가 로드되었습니다.");
    }

    private Singleton() {
        // Singleton 인스턴스가 생성될 때 로그 출력
        logger.info("Singleton 생성자가 호출되었습니다.");
    }

    // static inner class
    private static class SingletonHelper {
        // 정적 내부 클래스가 로드될 때 로그 출력
        static {
            logger.info("SingletonHelper 클래스가 로드되었습니다.");
        }

        // Singleton 인스턴스가 초기화
        private static final Singleton INSTANCE = new Singleton();
    }

    // Singleton 인스턴스를 반환
    public static Singleton getInstance() {
        // getInstance() 호출 시 로그 출력
        logger.info("getInstance() 메서드가 호출되었습니다.");
        return SingletonHelper.INSTANCE;
    }

    public void someMethod() {
        // someMethod() 호출 시 로그 출력
        logger.info("someMethod()가 호출되었습니다.");
    }

}

위와 같은 Singleton 패턴을 구현한 클래스가 있다.

public static void main(String[] args) {
	System.out.println(Singleton.class);
}

다른 클래스에서 class 정보를 출력하면 Singleton 클래스의 static 블록은 호출이 될까?

결과는 단순 메타데이터만 출력이 됨을 알 수 있다.

public static void main(String[] args) {
	Singleton.getInstance();
}

그리고 처음으로 인스턴스를 생성할 때 Lazy Initialization를 보장함을 확인할 수 있다.

0개의 댓글