[Java] 멀티스레딩 - 스레드간 데이터 공유

2024. 10. 31. 00:00·백엔드/Java

 

스레드는 프로세스 내부에서 실행되는 작업의 흐름 단위이다.

 

 

프로세스 내부에서 스레드들끼리 공유되는 데이터를 저장하는 공간을 힙 영역이라고 한다. 그리고 스레드 내부에 독립적으로 존재하는 저장소는 스택이라고 한다.

 

 

 

스택


 

 

스택(Stack)은 프로그램에서 메서드 호출과 관련된 정보가 저장 되는 메모리 영역이다.

 

 

스택에 저장되는 데이터는 주로 아래와 같다.

  • 메서드에 입력되는 파라미터
  • 원시형 변수
  • 힙 영역에 저장된 객체의 참조값

 

위 데이터들은 모두 스레드 내부의 스택 영역에 저장되고, 메서드가 종료되고 스레드의 할당 또한 종료되면 스택 영역의 데이터는 모두 삭제된다.

 

 

스택 프레임

public class StackThread {
    public static void main(String[] args) throws Exception {
        int a = 10; // 1
        int b = 20; // 1
        int result = sum(a, b); // 4
    }

		
    static int sum(int a, int b) { // 2
        int result = a+b; // 3
        return result;
    }
}

 

 

위 코드는 a와 b라는 변수가 main 메서드에서 할당되고, 이 두 변수를 sum() 이라는 메서드에 인자로 보내고, 합한 값을 리턴받는 코드이다.

 

 

 

그리고 이 이미지의 파란색 공간은 main() 메서드가 할당받는 공간, 주황색은 sum() 메서드가 할당받는 공간을 로직 순서대로 나타낸 것이다.

 

 

이미지에 나열된 로직을 순서대로 정리하면 다음과 같다.

  1. main() 메서드의 스택에 a와 b가 할당된다.
  2. sum() 메서드의 스택이 새롭게 할당되고, 파라미터로 받은 a, b가 저장된다.
  3. sum() 스택 영역에 a와 b의 합인 result가 저장된다.
  4. main() 메서드의 스택 영역에 sum() 메서드의 결과인 result가 할당되고, sum() 메서드는 종료되었으므로 스택이 무효화된다.
  5. main() 메서드가 종료되면 main() 메서드의 스택도 무효화된다.

 

 

참고

 

스택을 자료구조로 사용함으로 무효화 과정도 후입선출로 진행된다.

 

 

스택 특징

  • 스택에 입력된 변수들은 모두 스레드의 독립적인 영역에 속하기 때문에 다른 스레드가 접근할 수 없다.
  • 스택은 고정된 길이를 할당받는다.
  • 코드의 호출 계층 구조가 너무 길고, 스택이 저장할 수 있는 크기보다 더 큰 값 저장을 시도한다면, StackOverflow 에러가 발생한다.

 

 

 

힙


 

 

힙은 위에서 말했듯이 하나의 프로세스에서 실행되는 여러 스레드들이 공유하는 저장소 이다.

 

 

저장하는 데이터는 다음과 같다.

  • Object를 상속받거나 new 연산자를 사용해 생성된 객체
  • 특정 클래스 내부에 생성된 변수 (원시형 타입의 객체도 포함됨)
  • 정적 변수 (static 을 사용해 생성된 변수나 객체)

 

스택은 메서드가 종료되면 해당 메서드에 할당된 스택 프레임이 모두 초기화되지만 힙은 다르다.

 

 

힙은 프로세스의 영역이고, 다른 스레드와 공유되고 있는 저장소이기 때문에 매번 초기화할 순 없다. 그래서 힙엔 Garbage Collector 가 사용된다.

 

 

 

Garbage Collector

 

Garbage Collector란, 힙 영역에 저장된 사용되지 않는 객체나 변수를 삭제하는 작업을 뜻한다.

 

주로 아래의 경우 GC 대상이 된다.

  • 객체를 참조하는 값이 NULL이거나,
  • 부모 객체가 NULL 인 경우,
  • 블록 안에서 생성되고, 해당 블록이 종료된 경우

 

 

 

스레드 간의 리소스 공유


 

 

위에서 스택은 스레드별로 독립적인 공간이기 때문에 리소스 공유에 대한 고민거리가 적지만, 힙 영역은 다르다. 일단 힙 영역을 공유 저장소로 사용하는 이유는 리소스의 효율적인 저장과 사용을 위해서다.

 

 

예를 들어, 두 개의 스레드가 서로 다른 작업을 수행하면서도 동일한 Text 데이터를 사용하는 경우, 이 데이터를 각 스레드가 개별적으로 갖는 것보다는 힙 영역에 공유 데이터로 저장하는 편이 메모리 관리 측면에서 더 효율적일 것이다. 덕분에 메모리를 절약하면서도 여러 스레드가 데이터에 접근할 수 있어, 효율적인 동작이 가능해진다.

 

 

하지만 공통된 데이터를 저장 및 조회, 수정하기 때문에 문제가 발생할 가능성 또한 크다. 그 이유는 서로 다른 목적을 가진 스레드가 공유되고 있는 객체에 접근하게 된다면 서로 예상치 못한 값이 나오게 될 수 있기 때문이다. (원자적 작업 X)

 

 

이처럼 여러 스레드가 동일한 데이터에 동시에 접근하여 예상치 못한 데이터를 사용하게 되며 발생하는 문제가 동시성 문제 이다.

 

 

 

원자적 작업

 

하나 또는 여러 개 집합의 작업으로 나머지 시스템이 보기에는 한 번에 동시에 실행된 것처럼 보이는 작업을 뜻한다.

 

 

 

동시성 문제와 임계영역


 

동시성 문제가 발생할 가능성이 있는 구역, 여러 스레드가 동시에 접근하지 못하게 보호해야 하는 코드 영역을 임계영역이라고 한다.

 

public class OnlineShopping {

    ...
		
    static void buyProduct() {
        // 임계 영역 시작
        oper1();
        oper2();
        oper3();
        // 임계 영역 종료
    }
}

 

위의 코드는 oper1(), oper2(), oper3() 가 임계영역에 속한다.

→ 세 메서드가 공유 저장소에 접근하여, 같은 데이터를 대상으로 작업을 진행한다는 전제하에…

 

 

그리고 자바에선 세 메서드가 마치 하나의 작업인 것 처럼, 원자적 작업으로 동작할 수 있도록 여러가지 방법을 지원한다.

 

 

 

Synchronized


 

 

synchronized는 메서드나 블록에 사용할 수 있는 키워드로, 해당 영역에 한 번에 오직 하나의 스레드만 접근하도록 제한한다. 이 키워드를 메서드에 붙이면, 해당 메서드를 사용하는 스레드가 락을 획득할 때까지 다른 스레드가 대기하게 된다.

 

public class CriticalSection {

    ...
	  
    static synchronized void oper1() {
        ...
    }

    static synchronized void oper2() {
        ...
    }
}

 

만약

  • oper2() 메서드를 실행하는 작업을 하는 ThreadA
  • oper1(), oper2() 메서드를 실행하는 작업을 하는 ThreadB

가 있는 상황이고, ThreadA가 작업을 먼저 수행하고 있다면, ThreadB는 작업을 수행하지 못한다.

 

 

그 이유는 ThreadA가 먼저 oper2()의 락을 획득했기 때문이다. → 원자적 작업 수행

 

 

그리고 메서드 선언부에 synchronized 을 붙인다면, 물론 완벽한 동기화 환경을 만들 수 있다. 하지만 굳이 메서드 전체에 동기화 환경이 필요없는 경우엔 따로 Lock 객체를 만들고, 사용할 수 있다.

 

public class CriticalSection {

    static Object lock1 = new Object();
    static Object lock2 = new Object();

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

    static void oper1() {
        // 동기화 환경이 필요 없는 부분
        ...
		    
        // 임계 영역 시작
        synchronized (lock1) {
            ...
        }
        // 임계 영역 종료
    }

    static void oper2() {
        // 동기화 환경이 필요 없는 부분
        ...
		    
        // 임계 영역 시작
        synchronized (lock2) {
            ...
        }
        // 임계 영역 종료
    }
}

 

 

위의 코드처럼 lock1, lock2 객체를 만든 뒤, 메서드 내부에서 사용할 수 있다.

 

 

이렇게 구현한다면 더 많은 스레드가 동시에 메서드에 접근할 수 있기 때문에 지연시간이나 성능 부분에서 더 유리할 수 있다.

 

 

(참고) Lock 객체

synchronized 키워드로 생성되는 락은 자바 객체의 헤더(Object Header)에 저장된다. 모든 객체에는 메모리 헤더가 포함되어 있으며, 이 헤더에는 객체의 메타데이터 정보와 락을 관리하는 정보가 담긴다. 결국 Lock 객체도 힙 영역에 저장되는 것이다.

 

 

 

원자적 연산 vs 비원자적 연산


 

 

원자적 연산이란, 작업이 더이상 나눌수 없는 단일 단위로 수행되는 연산을 의미한다.

 

 

그렇다면 원자적 연산을 수행하기 위한 방식은 어떤 것이 있을까?

 

 

모든 참조형 객체의 할당은 원자적 연산이다.

 

Object a = new Object();
Object b = new Object();
a = b; 

 

특정 클래스의 Getter, Setter 작업은 모두 원자적 연산이다.

 

 

public int getNum() {
    return num;
}

public void setNum(int num) {
    this.num = num;
}

...

 

원시형 타입에 대한 할당도 모두 원자적 연산이다.

 

 

하지만 long과 double은 불가능하다. 그 이유는 길이가 64bits라서 자바가 원자적 연산을 보장해 줄 수 없는 것이다.

→ CPU가 64bits 길이 데이터의 연산을 진행할 땐 32bits, 32bits로 나눠서 할 가능성이 높음!!

 

 

이럴 경우엔 volatile 키워드를 사용하면 된다.

 

volatile double a = 1.0;
volatile double b = 1.2;

 

 

 

volatile

  • 휘발성
    • volatile의 사전적 의미는 휘발성이다. 따라서 volatile로 선언한 변수를 휘발성 공간인 메인 메모리에 저장하겠다는 의미이다.
  • 가시성(Visibility)
    • volatile로 선언된 변수는 한 스레드에서 변경된 값이 다른 모든 스레드에 즉시 반영된다.
    • 결국 스레드가 volatile 변수의 값을 읽을 때, 항상 최신 값을 읽도록 보장한다.
    • 이로 인해 CPU 캐시나 레지스터에 저장된 값이 아닌, 메인 메모리에서 직접 값을 읽는다.
  • 순서 보장
    • volatile 변수를 사용하면 변수의 읽기 및 쓰기 작업이 순차적으로 처리된다.
    • 즉, volatile 변수를 읽기 전에 실행된 모든 작업은 volatile 변수를 읽을 때 보장되며, volatile 변수를 쓰고 나서는 그 이후의 모든 작업이 순서대로 실행된다.

 

 

 

경쟁 상태


 

 

위의 물품 수량 증가 스레드 와 물품 수량 감소 스레드 가 사용중인 메서드는 보다시피 비원자적 연산인 상태이다.

 

 

이럴 경우엔 synchronized 키워드 같은 방법을 사용해 동기화 환경을 제공하여 원자적 연산으로 변경해야 한다.

 

public class OnlineShopping {

    ...
    
    static synchronized void 물품_증가() {
        amount++;
    }

    static synchronized void 물품_감소() {
        amount--;
    }
}

 

 

 

데이터 경쟁


 

 

public class DataRace {

    static int MAX = Integer.MAX_VALUE;

    public static void main(String[] args) {
        SharedClass sharedClass = new SharedClass();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < MAX ; i++) {
                sharedClass.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < MAX ; i++) {
                sharedClass.checkForDataRace();
            }
        });

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

    public static class SharedClass {
        private int x = 0;
        private int y = 0;

        public void increment() {
            x++;
            y++;
        }

        public void checkForDataRace() {
            if (y > x) {
                System.out.println("y > x - Data Race is detected");
            }
        }
    }
}

 

위의 코드를 실행시키면 수 많은 ‘y > x - Data Race is detected’ 가 출력된다. 그 이유는 ThreadA와 ThreadB의 데이터 경쟁 때문이다.

 

 

 

이 이미지처럼 y와 x의 증가 연산이 완전히 종료되기 전에 두 값을 비교해서 생긴 문제이다.

 

 

 

 

데이터 경쟁 해결책

 

이와같은 데이터 경쟁 문제를 해결하기 위해선 기존에 사용했던 synchronized 키워드를 사용해도 되고, 혹은 여러 메서드에 공유되어 사용되는 변수에 volatile를 선언하는 것이다.

 

 

 

synchronized

public static class SharedClass {
    private int x = 0;
    private int y = 0;

    public synchronized void increment() {
        x++;
        y++;
    }
        
    ...
    
}

 

 

 

volatile

public static class SharedClass {
    private volatile int x = 0;
    private volatile int y = 0;
		
    ...
		
}

 

 

 

데드락


 

  • methodA()와 methodB()를 실행하는 ThreadA
  • methodA()와 methodB()를 실행하는 ThreadB

 

이러한 두 스레드가 있는 상황에, methodA()와 methodB()가 모두 synchronized 가 선언되어 있는 상태라면 두 메서드를 실행하기 위해 각각의 락을 획득해야 한다.

 

 

하지만 만약 위의 이미지와 같은 상황이 벌어지면, ThreadA와 ThreadB는 각각 lockB, lockA를 획득하기 위해 계속해서 CPU에게 락 객체를 요구한다.

 

 

위의 상황이 대표적인 데드락 상황이다. 데드락은 락을 획득하지 못해 무한 대기 상태에 걸리는 교착 상태 를 의미한다.

 

 

 

데드락 방지 방법

  • 락 객체 획득 순서 유지
    • 복잡하지 않은 애플리케이션의 경우 가장 간단 명료하고 확실한 방법이다.
  • Watchdog - 데드락 감지 기술
    • 주기적으로 특정 레지스터 상태를 체크한다.
    • 그 레지스터는 매 스레드나 매 명령마다 업데이트 되어야 한다.
    • 만약 업데이트되지 않는다면 데드락 상태로 인지하고 해당 스레드를 재가동한다.
  • 타임아웃 설정
    • 자원 요청에 타임아웃을 설정하여 대기 시간을 줄이고 데드락 가능성을 줄인다.
  • tryLock()
    • 주어진 락을 시도적으로 획득하는 로직을 구현하는 데 사용되는 API이다.
    • 이 메서드는 특정 락이 사용 중인 경우 즉시 실패하고, 다른 작업을 수행할 수 있게 해준다. 
    • public class OnlineShopping {
      
          ...
      		
          static void buyProduct() {
              // 임계 영역 시작
              oper1();
              oper2();
              oper3();
              // 임계 영역 종료
          }
      }
    • 위의 코드는 oper1(), oper2(), oper3() 가 임계영역에 속한다.그리고 자바에선 세 메서드가 마치 하나의 작업인 것 처럼, 원자적 작업으로 동작할 수 있도록 여러가지 방법을 지원한다.

'백엔드 > Java' 카테고리의 다른 글

[Java] 멀티스레딩 - 가상 스레드  (0) 2024.11.04
[Java] 멀티스레딩 - 스레드 간 통신  (2) 2024.11.04
[Java] 멀티스레딩 - 락 심화  (0) 2024.11.04
[Java] 멀티스레딩 - 성능 최적화 (Feat : 스레드 풀)  (0) 2024.10.30
[Java] 멀티스레딩 - 스레드 조정  (0) 2024.10.25
'백엔드/Java' 카테고리의 다른 글
  • [Java] 멀티스레딩 - 스레드 간 통신
  • [Java] 멀티스레딩 - 락 심화
  • [Java] 멀티스레딩 - 성능 최적화 (Feat : 스레드 풀)
  • [Java] 멀티스레딩 - 스레드 조정
오도형석
오도형석
  • 오도형석
    형석이의 성장일기
    오도형석
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • MSA 모니터링 서비스
        • DB
      • 스파르타 코딩클럽
        • SQL
        • Spring
      • 백엔드
        • Internet
        • Java
        • DB
      • 캡스톤
        • Django
        • 자연어처리
      • Spring
        • JPA
        • MSA
      • ETC
        • ERROR
      • 개발 일기 N
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 인기 글

  • 태그

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
오도형석
[Java] 멀티스레딩 - 스레드간 데이터 공유
상단으로

티스토리툴바