Java 멀티스레딩, 병행성 및 성능 최적화
이 글은 글또 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)을 참고하셔도 좋을 것 같습니다.