Developer.

[멋사 백엔드 19기] TIL 29일차 Java 멀티 스레드 활용 및 동시성 제어

📂 목차


📚 본문

자바의 멀티스레딩을 보자. 보기 전에 우선 프로세스를 보자.

Process

프로세스는 운영체제에서 실행 중인 프로그램의 인스턴스를 의미한다. 프로그램이 단순히 파일이라면, 프로세스는 그것이 메모리에 적재되고 실행되고 있는 상태이다.

정리

  • 독립성: 각 프로세스는 자신만의 고유한 메모리 공간을 가진다
  • 자원 단위: 운영체제가 CPU, 메모리, 파일 핸들 같은 할당을 하는 기본 단위이다
  • 하나 이상의 스레드 포함: 프로세스 안에는 최소 1개의 스레드가 존재하여 멀티스레딩을 통해 여러 스레드가 동시에 실행될 수 있다

프로세스의 메모리 구조

일반적으로 운영체제에서 공부하는 하나의 프로세스는 다음과 같은 메모리 영역을 가진다.

  • Code: 실행할 프로그램 코드
  • Data: 전역 변수, static 변수
  • Heap: 동적으로 생성된 객체
  • Stack: 함수 호출 스택, 지역 변수, 매개변수

하지만 운영체제가 아닌 자바에서는 예전에 JVM 의 메모리 구조를 살펴보았었다. 다시 정리하자.

  • Method Area: 클래스 메타데이터, static 변수, 상수 풀
  • Heap: new 로 생성된 객체
  • JVM Stack: 각 스레드별 호출 스택(지역 변수, 매개변수, return 값)
    • PC Register: 현재 실행 중인 명령어 주소
    • Native Method Stack: JNI 같은 네이티브 코드 실행 시 사용

운영체제의 입장에서는 자바 프로그램도 하나의 프로세스이고, Code/Data/Heap/Stack 구조를 가지며, 자바 프로그램 안에 JVM 이 관리하는 영역을 따로 구분을 또 하게 된다.

즉 자바 내부에서는 보통 프로세스라는 단어 보다는 스레드 단위로 설명하는게 대부분이며, java.lang.Process 클래스 같은 경우는 운영체제 프로세스를 다루는 래퍼이다.

자바에서는 운영체제에서 다루는 프로세스를 실행하고 다루기 위해서 java.lang.Process 를 정의하고, 만들어진 운영체제 프로세스를 자바 코드에서 제어할 수 있도록 감싼 클래스인 것이다. 실제로 자바 프로그램을 동작할 때 Runtime.getRuntime().exec() 또는 ProcessBuilder 를 사용하여서 JVM 이 운영체제보고 새로운 프로세스를 만들어 달라고 요청을 보내는 것이고, 만들어진걸 받아와 사용하는 것이다.

자바 프로그램이 올라가 프로세스로 되며 프로세스 내부적으로 다수의 스레드 활용 가능

IPC

Inter Process Communication 이라고도 하며 프로세스 간의 상호작용을 하고 싶을때 쓰는 기술이다.

  • PIPE: 한쪽 프로세스의 출력 -> 다른 프로세스로의 입력(부모-자식 프로세스 간에 사용할 수 있는 예시이다)
  • Socket: 네트워크를 이용한 통신 방식(TCP/UDP 기반)이며, 같은 컴퓨터 안 혹은 다른 컴퓨터와도 통신이 가능하다.
  • Message Queue: 운영체제가 제공하는 큐에 메시지를 넣고 빼면서 통신을 한다. 비동기 처리에 유용하다.
  • Shared Memory: 두 프로세스가 물리적으로 같은 메모리 영역을 공유하도록 설정하면 빠르게 통신할 수 있다. 하지만 동기화 문제를 잘 다스려야 한다.
  • Signal: 간단한 알림을 보내는 방식 (kill -9 PID)

Thread

스레드는 프로세스 내부의 메모리 공간 내에서 서로 다른 흐름을 처리하고 싶을 때 사용되는 처리 흐름의 추상화된 클래스다. 따라서 여러 자바 프로그램을 굳이 돌리지 않고 자바 프로그램 하나를 돌려 프로세스를 만든 뒤 프로세스 내의 다수의 스레드를 두어서 다수의 처리를 하도록 하는게 더 가볍다는 것이다.

특징

  • 경량 프로세스: 프로세스와 달리 Heap 과 같은 메모리 공간이나 Method Area 등을 가지지 않는다.
  • 독립된 실행 흐름: 자신만의 Call Stack 이 있음
  • 자원 공유 용이: 같은 프로세스의 여러 스레드는 같은 힙(Heap)메서드(Method Area) 를 공유하기 때문에 서로 간의 데이터 교환이 빠르다.

Java Thread

자바는 Thread 라는 클래스를 기본적으로 내장시켜놨다. JVM 내부의 물리적인 thread 를 추상화 해놓은 클래스라고 보면 되고, 우리는 이를 사용하여 자바 프로그램을 실행할 때 기본적으로 메인 스레드 하나 뿐이 아니라 메인 스레드에서 가지치기 하듯이 여러 스레드를 생성시켜 동작하도록 할 수 있다.

스레드의 물리적 메모리 영역

  • 공유 영역: Heap, Method Area
  • 개별 영역: Stack(메서드 호출, 지역 변수, 매개변수, return 값), PC Register

그러면 우리가 만드는 자바 프로그램에 스레드를 여러개 만들어 놓고 많은 작업들을 많은 스레드들한테 처리하라고 시키면 굉장히 빠를거 같지만, 멀티 스레드에는 다음과 같은 장단점이 있다.

장점

  • 성능 향상: 여러 작업을 동시에 처리
  • 자원 효율성: 프로세스보다 적은 리소스 사용
  • 응답성 향상: UI 애플리케이션에서 빠른 응답

단점

  • Concurrency 문제: 특정 자원에 대한 Race Condition 발생 가능
  • 복잡성 증가: 디버깅이 어려움
  • Deadlock 위험: Circular Wait

단점을 파훼해야 장점들을 유용하게 쓸 수 있다.

Concurrency

스레드를 여러개 가져가서 동시에 실행한다면 생길 수 있는 여러 문제점이 있다. Concurrency 는 동시성이고 여러 실행 흐름(스레드나 프로세스)이 동시에 자원에 접근하거나 작업을 수행하는 능력을 의미한다. 동시적으로 특정 자원에 대해 접근하고자 할 때 직관적으로 그 데이터가 자각하고 있는 데이터 값이어야 하고, 그 값에 대한 연산을 하고 올바른 결과가 도출되어야 할 것이다.

반면 원자성이 부족한 int count 와 같은 것은 count++ 라는 연산을 할 때

  1. count 읽기
  2. count +1
  3. count 쓰기

세 단계로 이루어지므로 할 때 다른 스레드가 끼어들면 값이 꼬이게 된다. 이를 Race Condition 이라고 한다.

Race Condition: 공유 자원에 대해 접근 및 행위가 어떤 순서에 따라 이루어졌는지에 따라 실행 결과가 같지 않고 달라지는 현상. 실행 결과가 실행 순서에 의존하는 현상.

즉, Race Condition 현상이 일어나지 않도록 보장하기 위해 Concurrency 라는 속성을 가져야 할 것이다. 하지만 이 Concurrency 를 얻기 위해서는 자원에 대한 접근 순서를 정렬할 필요가 있었고, 이를 바로잡기 위해 Lock 이라는 자료구조가 나오게 됐다.

하지만 이 또한 문제가 있었다.

Deadlock

두 개 이상의 프로세스가 서로 가지고 있는 자원을 락을 걸었는데, 서로 두 자원을 요구한다면 이는 무한히 멈춰있는 상태가 된다. 두 프로세스는 서로 Deadlock 에 빠지게 된다. 이러한 문제를 다음과 같이 조건부로 정의하게 된다.

Deadlock 발생 조건

  • Mutual Exclusion(상호 배제): 어떤 자원은 한 번에 한 프로세스만 사용 가능
  • Hold and Wait(점유 및 대기): 프로세스가 이미 점유한 자원을 놓지 않고 다른 자원을 기다림
  • No Preemption(비선점): 운영체제가 이미 점유한 자원을 강제로 빼앗을 수 없음
  • Circular Wait(상호 대기): 프로세스들이 원형으로 서로가 가진 자원을 기다리는 상태

이 4가지 조건이 전부 충족될 때 Deadlock 이 발생할 수 있기 때문에 우리는 4개 중에 하나라도 파괴시켜야 Deadlock 을 막을 수 있다.

보통은 Circular Wait 을 파괴하게 되는데, 나머지 3개는 자원의 본질적인 특성이거나 운영체제를 강제로 바꿔야 하는데 그러기 쉽지 않아서이다.

그리고 멀티 스레딩을 사용할 때 데드락 이 외에도 다른 문제가 있을 수 있다.

Starvation

특정 스레드나 프로세스가 필요한 자원을 계속 얻지 못하여 실행되지 못하는 상태가 되어 무한정 대기하는 상태이다. 이는 스케줄링 우선순위나 자원 할당 정책 때문에 발생하게 되며, 실행 순서만 좀 바꿔주면 정상적으로 돌아온다.

이는 운영체제의 스케줄링 문제이기 때문에 그냥 가볍게 보고 넘어간다

Concurrency 얻기

자바에서는 다양한 해결 방법이 존재하는데

  • 함수 및 변수 수준 락: synchronized
  • 락 자료구조: ReentrantLock, Semaphore, Condition, Atomic 변수, Thread-safe 컬렉션
  • 불변 객체(Immutable Object), record

등으로 해결 가능하다.

ReentrantLock

자바에서 제공하는 락 클래스인데, 같은 슬데ㅡ가 이미 획득한 락을 다시 획득이 가능하다 해서 재진입 가능하기에 Reentrant 라는 수식어가 붙었고, Mutex 와 같은 역할을 한다.

뮤텍스도 Critical Section 에 대해 하나의 스레드만이 허용 가능하도록 하기 때문에 ReentrantLock 또한 그 역할을 대신한다고 볼 수 있다.

Semaphore

공유된 자원의 데이터를 여러 스레드 혹은 프로세스가 접근하는 것을 막는 자료구조이다. 내부적으로는 리소스의 상태를 나타내는 간단한 카운터와 같고 카운터의 값에 따라, 하나의 공유 자원에 대해 여러 스레드가 들어갈 수도, 혹은 하나의 스레드만이 들어갈 수도 있다.

Java Thread 활용

우선 생성자를 보자.

// Constructor
Thread()
Thread(Runnable runnable)
Thread(Runnable runnable, String name)

Java 에는 Runnable 인터페이스가 있고 클래스가 이를 구현하기만 한다면 Thread 의 생성자로 넣을 수 있다.

Thread run() vs start()

Runnable 한 것을 받았다면, 이를 실행할 수 있는데, runstart 로 실행할 수 있다. 다만 run 은 그냥 현재 스레드에서 실행하는 것이고, start 를 해야 또 다른 thread 를 생성시켜서 동시에 실행하게 된다.

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " is running");
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();

        System.out.println("Calling run():");
        t1.run(); // 그냥 메서드 호출 → main 스레드에서 실행됨

        System.out.println("\nCalling start():");
        MyThread t2 = new MyThread();
        t2.start(); // 새로운 스레드 생성 → run() 메서드가 별도의 스레드에서 실행
    }
}
synchronized 예시

이는 이전에 다뤘기에 설명은 생략한다.

class Counter {
    private int count = 0;

    // synchronized 메서드: 한 번에 한 스레드만 접근 가능
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount());
    }
}
Block Synchronization

자바에는 synchronized 안에 모니터 락(Monitor Lock) 을 가질 수 있는 객체가 들어가야 한다. 여기서 Object 는 wait(), notify(), notifyAll() 을 가지고, 이는 모니터 락에 대한 API 이다.

따라서 우리가 사용하는 모든 객체들에 대해 모니터락이 이미 참조되고 있는 것이고, synchronized 에 모든 객체를 넣을 수 있게 된다. 안되는 것들은 원시타입, null 등은 안될 것이다.

public class BlockSynchronization {
    private Object lock = new Object();
    private int count = 0;

    public void increment() {
        // 필요한 부분만 동기화
        synchronized(lock) {
            count++;
        }
    }
}