안녕하세요. 개발자 Jindory입니다.
오늘은 Java에서 Thread Unsafe한 상황에 대해서 대해서 알아보고자 합니다.
# 글 작성 이유
Java의 Mutable, Immutable한 변수에 대해서 공부하다보니, Thread-Safe한것과 Unsafe한것이 어떤것인지에 대해서 궁금하게되어 이 글을 작성하게 되었습니다.
동시성과 Thread Safety의 개념 소개
이번 글에서는 동시성(Concurrency)과 Thread Unsafe의 개념에 대해서 소개하고자 합니다. 동시성이란 여러 작업이 동시에 실행되는것을 의미하며, "여러 Thread가 한 Process의 자원을 공유하며 동작하는 것"이라는 의미입니다. 이때 여러개의 Thread가 작업하면서 공유된 Process의 자원에 동시에 접근하여 작업을 하는 상황이 생기는데 이런 경우를 "동시성 문제"가 발생했다고 합니다.
동시성 문제와 관련하여 간단한 예를 들어 설명하도록 하겠습니다.
예를들어, 은행에서 고객이 동시에 특정 계좌에서 돈을 인출한려고 한다고 생각을 해봅시다.
특정 계좌에는 100만원이 예치되어 있다고 가정하겠습니다.
고객은 돈을 인출할때 아래와 같은 단계를 거칩니다.
1. 계좌의 잔액을 확인한다.
2. 인출할 금액이 잔액보다 적은지 확인합니다.
3. 인출할 금액을 잔액에서 뺍니다.
A라는 고객이 2번 절차를 진행하고 있을때, B가 1번 절차를 진행하면 A가 아직 돈을 인출한게 아니므로 인출하기 전 잔액인 100만원을 확인했을것입니다.
A가 3단계를 진행하기전 B가 2단계를 진행한다면, 계좌 잔액은 인출금액보다 적지 않으므로 2단계를 통과하고, A와 B가 특정계좌에서 동시에 100만원을 인출하면, 특정계좌에는 -100이라는 결과가 나옵니다.
이렇게 한 자원을 2개 이상의 작업자가 공유했을때 발생하는 문제를 "동시성 문제"라고 합니다.
위와 같이 Multi-Thread 환경에서 Thread가 동일한 자원에 대해 동시에 작업을 진행할 때 예상치 못한 결과가 나올 수 있는 상황 "Thread Unsafe"하다라고 말합니다.
Thread Unsafe한 상황이 발생하는 주요 조건
- 공유된 데이터에 대한 동시 접근 : 여러 스레드가 동시에 같은 데이터에 접근할 때 문제가 발생할 수 있습니다. 예를 들어, 두 개 이상의 스레드가 동시에 같은 변수를 수정하려고 할 때, 이 변수의 최종 값은 예상치 못한 결과가 도출 될 수 있습니다.
- 순서 종속성 : 여러 스레드가 실행되는 순서에 따라 프로그램의 결과가 달라지는 경우, Thread Unsafe한 상황이 발생할 수 있습니다. 예를 들어, 한 Thread가 다른 Thread가 생성한 데이터를 사용해야 하는데, 데이터를 생성하는 Thread가 아직 데이터를 생성하지 않았다면 문제가 발생할 수 있습니다.
- 비원자적 연산 : 연산이 원자적이지 않다면, 즉 연산이 여러 단계로 이뤄져 있고 이 단계들 사이에 다른 스레드의 연산이 끼어들 수 있다면, Thread Unsafe한 상황이 발생할 수 있습니다.
Thread Unsafe의 원인
그럼 왜 여러개의 Thread가 한 자원에 대해서 접근하여 작업할 때 예상하지 못한 결과를 발생시킬 수 있는걸까요? 이 이유에는 다양한 원인이 있습니다.
1. Memory 가시성(Memory Visibility)
메모리 가시성은 "한 Thread가 공유 자원을 변경했을때 다른 Thread에서도 변경된 최신값을 읽어올 수 있느냐"를 의미합니다. 이는 메모리 모델에 대한 설명을 보면 조금 이해할 수 있습니다.
자바 언어 명세를 보면 스레드가 필드를 읽을 때 '수정이 완벽히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다고 되어 있습니다.
이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정한 자바의 메모리 모델 때문입니다. (JLS, 17.4)
Thread는 동작하는 시점에 하나의 CPU를 점유하고 동작합니다. 선언한 변수의 값이 메모리에만 존재하는것이 아니라, CPU Cache라는 영역에도 존재합니다. 그래서 CPU Cache에 변경하려는 변수가 존재한다면 CPU Cache의 값을 변경합니다. 이는 CPU가 메모리에서 값을 읽어들여오고 다시 쓰는 시간을 아끼기 위함입니다.
하지만 CPU Cache값이 메모리에 언제 옮겨갈지는 알 수 없습니다. 이로 인해 한 스레드가 변수를 수정하고 CPU Cache에 저장을 했지만, 이 변경이 메모리에 바로 반영되지 않아 다른 Thread가 이전 값을 읽어오는 문제가 발생할 수 있습니다.
이러한 문제를 동시성 프로그래밍(Concurrent Programming)에서의 가시성(Visibility)문제라고 합니다.
2. 경쟁 상태(Race Condition)
경쟁 상태(Race Condition)는 "두 개 이상의 Thread가 동시에 같은 자원에 접근하려고 할 때, Thread 간의 경합이 발생하고, 이로 인해 데이터의 일관성이 깨질 수 있다"는 의미입니다.
Thread는 JVM의 Heap 영역와 Method 영역를 공유하므로 Heap 영역에서 관리되는 클래스, 인터페이스, 메서드, 필드, static 변수와 Method 영역에서 관리되는 객체와 인스턴스 변수 및 배열이 Thread가 동시에 접근할 수 있습니다.
이러한 공유 자원에 대해 여러 개의 Thread가 동시에 접근을 시도할때 접근의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있습니다. 결론적으로 작업의 일관성을 해치는 결과를 나타낼 수 있습니다.
아래의 그림처럼 User1와 User2가 Bank계좌에 5만원과 4만원을 입급하려고 경쟁할 때, 원래는 39만원이 결과로 나와야 하지만, 동시에 같은 자원에 접근하여 처리하여 어떤 상황에서는 34만원이, 어떤 상황에서는 35만원이, 어떤 상황에서는 39만원이 결과로 나올 수 있는것입니다.
3. 데드락(Deadlock)
데드락(Deadlock)은 "두 개 이상의 Thread가 서로 다른 Thread가 소유한 자원을 기다리며 무한히 대기하는 상태"를 말합니다. 이런 상황은 시스템의 전체적인 성능을 저하시키며, Thread Unsafe를 발생시킬 수 있습니다.
데드락이 발생하려면 아래의 4가지 조건의 모두 성립해야합니다.
- 상호배제(Mutal exclusion) : 자원은 한 번에 한 프로세스만 사용할 수 있어야 합니다.
- 점유대기(Hold and wait) : 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 첨유하기 위해 대기하는 프로세스가 존재해야 합니다.
- 비선점(No preemption) : 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없습니다.
- 순환 대기(Circular wait) : 프로세스의 집합에서 순환 형태로 자원을 대기하고 있어야 합니다.
4. 재진입 문제(Reentracny Issue)
재진입 문제(Reentrancy Issue)는 "한 Thread가 이미 실행중인 함수를 다시 호출할 때 발생하는 문제"입니다. 이는 함수 내부의 코드가 여러 Thread에 의해 동시에 변경될 수 있으므로, Thread Unsafe를 발생시킬 수 있습니다.
재진입 문제에 대해서는 아래 코드와 함께 설명을 하도록 하겠습니다.
int num = 1
public int func()
{
num = var + 2;
return num;
}
위 함수를 Thread-1이 호출하여 실행하고 있는데, Thread-2가 호출한다면, Thread-1은 num을 3으로 return하고 Thread-2도 num을 3으로 return하여 2번 호출되었음에도 불구하고, 동일한 결과가 나올 수도 있습니다.(순차적으로 호출되면 전역변수인 num에 대해서 5라는 결과가 나올수도 있습니다.)
Thread Unsafe 상황을 해결하는 방법
그렇다면 Thread Unsafe한 상황을 해결하는 방법은 어떤것이 있을까요?
- synchronized를 사용하여 동기화 처리
Java에서 synchronized 키워드를 사용하면 여러 개의 Thread가 하나의 공유 자원에 접근하게 될 때, 현재 데이터를 사용하고 있는 Thread를 제외하고 나머지 Thread들은 데이터데 접근할 수 없게 됩니다. 이는 Java의 모든 인스턴스가 Monitor를 가지고 있으며, Monitor를 통해 스레드 동기화를 수행하기 때문입니다.
Monitor는 공유 자원에 대한 접근을 제어하는 역할을 합니다. synchronized 키워드가 붙은 메서드나 블록에 접근하려면 해당 객체의 모니터 Lock을 획득해야 합니다. 한번에 하나의 Thread만이 Monitor lock을 소유할 수 있으며, 다른 Thread들은 Lock을 소유한 Thread가 Lock을 해제할 때 까지 대기해야합니다.
이렇게 하면 경쟁 상태를 방지하고, 데이터의 일관성을 유지할 수 있습니다. - volatile 키워드
synchronized 블럭의 임계 영역은 Multi-Thread 프로그램의 성능을 좌우하기 때문에 가능하면 임계영역을 최소화해서 효율적인 프로그래밍을 해야합니다.
속도가 더 빠른 대안을 소개하자면 자바에서 volatile이란 한정자로 변수의 읽기와 쓰기를 원자화 할 수 있습니다.
volatile 키워드를 붙이면 해당 변수를 읽어올 때 캐시가 아닌 메모리에서 데이터를 직접 읽어오게 되는데 그렇기 때문에 Thread간 안정적인 통신을 보장할 수 있습니다.
(단! volatile 키워드는 변수의 읽기나 쓰기의 원자화를 보장하지만 배타적 수행과는 상관이 없습니다.) - Atomic 변수
synchronized는 특정 Thread가 해당 블록 전체에 Lock을 걸기 때문에 다른 Thread는 아무런 작업을 하지 못하고 기달리는 상황(Blocking)이 될 수 있어서 낭비가 심합니다. 그래서 Non-Blocking하면서 동기화 문제를 해결하기 위한 방법이 Atomic입니다.
java.util.concurrent.atomic 패키지에는 락 없이도(Lock-free) Thread-safe한 프로그래밍을 지원하는 클래스들이 담겨 있습니다. volatile은 동기화의 두 효과중 스레드 간 통신쪽만 지원하지만 이 패키지는 원자성(배타적 수행)까지 지원합니다. - 불변 객체 사용
데이터를 바꿀 수 있는 setter 메소드를 쓰지 않도록 작성하며, 불변 객체를 사용하여 Thread safe를 유지할 수 있습니다.
이렇게 Thread Unsafe한 상황에 대해서 알아봤습니다.
혹시라도 정정할 내용이나 추가적으로 필요하신 정보가 있다면 댓글 남겨주시면 감사하겠습니다.
오늘도 Jindory 블로그에 방문해주셔서 감사합니다.
[참조]
Lesson: Concurrency (The Java™ Tutorials > Essential Java Classes) (oracle.com)
https://velog.io/@indongcha/Thread-Safety-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0
https://velog.io/@guswlsapdlf/Java-Thread-Safety-Unsafety
https://castlejune.tistory.com/23
'개발 > Java' 카테고리의 다른 글
[Java] ArrayList 길이가 가변적으로 확장되는 방식(JDK 별) (2) | 2024.01.01 |
---|---|
[Java] Java Garbage Collection 동작 과정 (0) | 2023.12.16 |
[Java] Mutable Object와 Immutable Object (2) | 2023.12.15 |
[JAVA] static에 관하여 (0) | 2023.12.13 |
[JAVA] JVM 메모리 구조 (0) | 2023.12.13 |