Java

Java 멀티스레딩, 병행성 및 성능 최적화

jwKim96 2024. 3. 31. 23:58

이 글은 글또 x 유데미 콜라보를 통해 강의를 지원받아 작성한 글 입니다.

글을 꾸준히 작성하기 위해 글또 커뮤니티에 참여하고있는데, 감사하게도 유데미에서 글또 커뮤니티에 강의를 제공해주셨습니다.
저는 Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기라는 강의를 선택했는데요.

이 강의는 운영체제, 프로세스와 스레드부터 시작해서 멀티 스레딩의 주요 개념들을 지나 가상 스레드까지 모두 담고 있습니다.

멀티스레딩에 대한 큰 그림을 그릴 수 있는 인상적인 강의여서, 이 글에서는 강의의 핵심 내용들을 추려서 알려드리려고 합니다.
(이 글은 강의에서 설명한 내용이 많이 생략되었고, 나름의 표현으로 변경했기 때문에 강의도 꼭 듣는것을 추천드립니다.)

1. 멀티스레딩 개요

멀티스레딩이 필요한 근본적인 이유는 성능 개선 입니다.
예를 들자면 아래와 같은 사례를 들 수 있는데요.

  • 온라인 쇼핑몰에서 많은 사용자가 몰리더라도, 각각의 요청을 빠르게 처리하기 위해
  • 무거운 작업을 여러 스레드로 병렬 실행하여, 작업 속도를 향상시키기 위해

지금 저만 하더라도 이 글을 작성하면서, 음악을 듣고있는데요.
이것도 멀티 스레딩(or 멀티 프로세싱) 덕분에 가능한 일 입니다.

2. 멀티스레딩의 리스크

위 처럼 멀티스레딩은 우리에게 다음과 같은 이점을 가져다 주는데요.
하지만 주의해서 사용하지 않으면 다음과 같은 문제가 발생할 수 있습니다.

  • 경쟁 상태와 같은 동시성 문제 관리 필요
  • 과도한 스레드 생성으로 인한 스래싱(Thrashing) 발생
    • 잦은 컨텍스트 스위칭으로 인한 오버헤드 증가

프로세스의 힙 메모리 영역은 모든 스레드가 공유합니다.
스레드들은 힙에 저장된 작업들을 나눠서 가져가기도 하고, 각자 작업한 결과를 한 공간에 넣는 등의 방식으로 협업합니다.

이렇게 여러 스레드가 동시에 같은 리소스에 접근하고 변경을 시도할 때, 예상치 못한 결과나 오류가 발생할 수 있는데요.
이를 경쟁 상태 라고 합니다.

이런 상황에서 발생할 수 있는 문제는 배타성, 선점/비선점 관점에서 생각해볼 수 있는데요.
특히 문제가 발생할 수 있는 조건 2가지에 대해 살펴보겠습니다.

2.1 비배타성 공유

이 경우에는 스레드들이 하나의 자원에 접근하는데 제한이 없습니다.
그래서 이런 경우에는 비원자적 연산이 발생할 수 있습니다.
(예, 두 스레드에서 동시에 int a = 0; a++; 연산이 일어날 때 2가 아닌, 1이 될 수 있음)

위와 같은 문제가 발생하지 않도록 하기 위해서는 공유하는 자원을 한번에 한 스레드만 접근할 수 있도록 해야하는데요.
이를 임계 영역이라고 합니다.

1) synchronized

대표적으로는 synchronized 키워드를 메서드 혹은 코드 블럭에 사용하여 동기화 하는 방법인데요.

public class SimpleCounter {
    private int count = 0;

    // synchronized 메서드
    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws Exception {
        SimpleCounter counter = new SimpleCounter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter.increment();
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) counter.increment();
        });

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

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

        System.out.println("count: " + counter.getCount()); // 2000
    }
}

하지만, 멀티스레드 환경에서 다른 스레드는 점유를 위해 대기하기 때문에 성능 저하가 발생할 수 있음을 주의해야 합니다.

2) volatile

그리고 또 한가지 volatile 키워드를 사용하는 방법인데요.
volatile 키워드는 변수의 값을 메인 메모리에서 직접 읽고, 쓰게 함으로서 변수의 일관성을 보장합니다.

public class VolatileExample {
    // volatile 키워드를 사용하여 이 변수의 모든 읽기/쓰기를 메인 메모리에서 직접 실행
    private volatile boolean running = true;

    public void start() {
        Thread thread = new Thread(() -> {
            while (running) {
                // 여기서는 복잡한 로직을 실행할 수 있지만, 예시를 위해 비워둠
            }
            System.out.println("Thread 종료");
        });

        thread.start();
    }

    public void stop() {
        running = false; // 다른 스레드에서 이 메소드를 호출하여 루프를 종료시킴
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        example.start();

        Thread.sleep(1000); // 메인 스레드를 1초 동안 멈추고
        example.stop(); // 이후 스레드를 종료시킴
    }
}

2.2 배타적 & 비선점

이 경우에는 특정 자원을 혼자 사용하고, 다른 스레드에 의해 선점 되지 않습니다.
이런 경우 운이 나쁘다면 교착상태가 발생할 수 있습니다.

  • 교착 상태의 4가지 조건
    • 배타적
    • 비선점
    • 점유 & 대기
    • 환형 대기

교착상태의 해결방법은 다음과 같습니다.

  • 방지 : 교착상태의 4가지 중 하나 이상이 발생되지 않도록 함
  • 회피 : 교착상태 발생 가능성이 있는 자원은 할당하지 않음(참고 : 은행원 알고리즘, 자원할당 그래프)
  • 탐지 및 회복 : 교착상태가 발생하면 찾아서 고치는 방법
  • 감시 장치 : 데드락을 감지하고, 해결하기 위한 감시 장치를 사용(tryLock 과 같은 기술)

1) ReentrantLock

여기서 Java 에서 tryLock 의 기능을 제공하는 ReentrantLock 이 있습니다.
(읽기, 쓰기 작업에 따라 별도로 락을 제공하는 ReentrantReadWriteLock 도 참고하면 좋음)

  • ReentrantLock 의 기본 개념
    • synchronized 유사하게 동작하지만, 명시적인 locking, unlocking 을 필요함
    • 명시적인 locking, unlocking 을 잊어버리면 버그나 데드락을 유발할 수 있음
  • ReentrantLock 의 주요 메서드
    • lock() : Lock 을 획득한다. 이후에 unlock 을 통해 Lock 을 반납해야함
    • tryLock() : 락을 얻을 수 있으면 true 를 반환, 그렇지 않으면 false 를 반환함
    • lockInterruptibly()
  • ReentrantLock 의 고급 기능
    • Fair lock : 모든 스레드에 락을 공평하게 분해할 수 있으나, 성능 저하를 유발할 수 있음
    • Queriable : 현재 락을 보유한 스레드, 대기중인 스레드 등의 정보를 제공함

아래 예제는 ReentrantLock 으로 fair lock 을 설정한 예제 입니다.

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock(true); // fair lock 설정

    public void printJob(Object document) {
        lock.lock(); // Lock 획득
        try {
            // 실제 작업 수행
            System.out.println("Printing document: " + document);
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock(); // Lock 반환
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample printer = new ReentrantLockExample();

        Thread t1 = new Thread(() -> printer.printJob("Thread 1's Document"));
        Thread t2 = new Thread(() -> printer.printJob("Thread 2's Document"));

        t1.start();
        t2.start();
    }
}

2.4 Lock-Free 프로그래밍

멀티스레드 환경에서 발생할 수 있는 문제를 해결하기 위해서 Lock 을 활용하는 방법을 알아봤는데요.
하지만 Lock 도 만능이 아니기 때문에 교착 상태, 성능 저하, 오버헤드 & 지연 과 같은 문제점을 마주할 수 있는데요.

애초에 특정 객체 내부에서 원자적 연산을 제공한다면, 굳이 외부에서 락을 이용하지 않아도 되겠죠.
그래서 java.util.concurrent.atomic 패키지가 등장합니다.

  • AtomicInteger
  • AtomicLong
  • AtomicReference

위 처럼 기본 자료형이나 객체 레퍼런스에 대한 원자적 연산을 지원하여, 간단한 작업에는 Lock 을 명시적으로 사용하지 않아도 됩니다.

1) AtomicInteger

먼저, AtomicInteger 의 사용방법을 살펴보겠습니다.
아래 예제는 위의 synchronized 예제와 동일한 예제입니다.

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerExample {
    private final AtomicInteger counter = new AtomicInteger();

    public void increment() {
        counter.incrementAndGet(); // 카운터를 원자적으로 1 증가시킴
    }

    public int getCounter() {
        return counter.get();
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicIntegerExample example = new AtomicIntegerExample();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) example.increment();
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) example.increment();
        });

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

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

        System.out.println("counter: " + example.getCounter()); // 2000
    }
}

내부적으로 원자적인 연산이 이루어지기 때문에 별도로 Lock 이나 synchronized 를 사용하지 않아도 되는 장점이 있습니다.

2) AtomicReference

그 다음으로 AtomicReference 에 대해 살펴보겠습니다.
이 예제에서는 Person 객체의 참조를 안전하게 변경하는 방법을 보여줍니다

import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceExample {
    static class Person {
        String name;

        Person(String name) {
            this.name = name;
        }
    }

    private final AtomicReference<Person> personRef = new AtomicReference<>(new Person("John"));

    public void updateName(String newName) {
        personRef.updateAndGet(currentPerson -> new Person(newName));
        // 기존 Person 객체를 새 이름으로 업데이트
    }

    public String getPersonName() {
        return personRef.get().name;
    }

    public static void main(String[] args) {
        AtomicReferenceExample example = new AtomicReferenceExample();

        System.out.println("Initial name: " + example.getPersonName());

        // 이름 업데이트
        example.updateName("Jane");

        System.out.println("Updated name: " + example.getPersonName());
    }
}

3. 마무리 하며

강의에서는 후반부에 블로킹, 논블로킹에 대한 설명과 더불어 가상 스레드에 대한 소개가 나오는데요.
블로킹, 논블로킹에 대한 내용은 이 글에는 담지 않았고, 가상스레드에 대한 내용은 이전에 제가 작성한 글(Kotlin Coroutine 과 Java Virtual thread)을 참고하셔도 좋을 것 같습니다.