스레드(Thread)란 무엇일까요?
스레드가 무엇인지 설명하기 위해서는 그 상위 단위인 프로세스에 대해 이해할 필요가 있습니다. 일반적으로 특정 작업을 수행하는 소프트웨어를 우린 프로그램이라고 부릅니다. 이러한 프로그램이 실제로 실행되어, 메모리나 CPU와 같은 자원을 할당받으면 이를 프로세스라고 부릅니다. 스레드는 이 프로세스를 구성하는 하나의 단위입니다. 하나의 프로세스에는 여러 스레드가 작동하고 있을 수 있겠네요.
스레드는 작업의 한 단위입니다. 프로세스는 독자적인 메모리를 할당받아서 서로 다른 프로세스끼리는 일반적으로 서로의 메모리 영역을 침범하지 못합니다. 하지만 프로세스 내부에 있는 여러 스레드들은 서로 같은 프로세스 내부에 존재하고 있기 때문에 같은 자원을 공유하여 사용할 수 있습니다. 같은 자원을 공유할 수 있기 때문에 동시에 여러 가지 일을 같은 자원을 두고 수행할 수 있고, 이는 곧 병렬성의 향상으로 이어집니다.
물론 여러 스레드를 사용하는 것이 장점만 있는 것은 아닙니다. 여러 스레드가 동시에 하나의 자원을 공유하고 있기 때문에이 자원을 두고 여러 가지 문제점이 발생할 수 있습니다. 동시성 문제, 데드락과 같은 여러 가지 문제점을 해결해야 멀티스레드 환경에서 문제없는 프로그램을 제작할 수 있습니다.
여러 스레드가 작동하는 환경에서도 문제 없이 동작하는 것을 스레드 안전하다고 말할 수 있습니다. 이번 포스팅의 목표는 스레드 안전성을 지키기 위한 여러 방법을 소개해 드리겠습니다.
스레드 안정성이 깨지는 상황
조회수 계산 프로그램
멀티 스레드 환경에서 스레드 안전(Thread-safe)한 프로그램을 제작하기 위해서는 어떤 경우에 스레드 안전하지 않은 동작이 생기는지 먼저 만들어볼 필요가 있습니다. 정말 간단한 예제로, 조회수 계산 로직을 만들어보겠습니다. 특정 글을 조회하는 순산 원래 조회수에 1을 더한 값을 저장할 것이고, 여러 사용자가 동시에 접근할 것이므로 멀티 스레드 환경에서 동작한다고 가정해보겠습니다.
100명의 스레드로 각각 100번 조회했을 때
public class CountingTest {
public static void main(String[] args) {
Count count = new Count();
for (int i = 0; i < 100; i++) {
new Thread(){
public void run(){
for (int j = 0; j < 100; j++) {
System.out.println(count.view());
}
}
}.start();
}
}
}
class Count {
private int count;
public int view() {return count++;}
public int getCount() {return count;}
}
해당 코드를 실행시켰을 때, 100명의 사용자가 100번 조회했으므로 100 * 100, 즉 10000번의 조회수가 나오기를 기대할 것입니다.
하지만 실제 결과값을 보았을 때는 10000번이 아닌 그보다 더 적은 조회수가 나옵니다. 그 이유는 조회수를 증가시키는 로직이 두 번의 동작으로 이루어지는데 동시에 여러 스레드가 접근하여 첫 번째 동작할 때의 자원과 두 번째 동작할 때의 자원 상태가 변하기 때문입니다.
Count 클래스의 view 메서드는 count++이라는 동작을 진행합니다. count++은 언뜻 보기에는 하나의 동작 같아 보이지만, 실제로는 아래와 같은 동작을 합니다.
1. count 변수의 값을 조회한다.
2. 조회한 count변수 값에 1을 더한 값을 저장한다.
이러한 동작들 사이사이에 여러 스레드가 접근하게 되면 아래와 같은 동시성 이슈가 발생할 수 있습니다.
여러 스레드에서 동시에 count변수에 접근한다면 동시에 1번 동작을 진행하여 같은 count값을 조회할 것이고 두 개의 스레드가 1을 더하는 조회 로직을 실행한다고 해도 2가 더해지는 것이 아닌 1만 더해지는 동작이 발생할 수 있습니다. 개발자는 조회수를 계산할 때 한번 접근에 꼭 1의 조회수가 더해지는 것을 바랄 것입니다. 그렇다면 이러한 동시성 이슈를 해결하기 위해 사용할 수 있는 방법은 어떤 것이 있을까요?
동시성을 제어하는 방법
암시적 Lock
가장 간단하면서 쉬운 방법은 Lock을 걸어 버리는 것입니다. Lock을 적용하게 되면 하나의 스레드가 해당 메서드를 실행하고 있을 때 다른 메서드가 해당 메서드를 실행하지 못하고 대기하게 됩니다. 즉 한 번에 하나의 스레드만 접근할 수 있게 됩니다. 여러 스레드가 동시에 접근할 수 없게 만들어 동시성 이슈를 막을 수 있게 만들지만, 한 번에 하나의 스레드만 메서드를 실행시킬 수 있으므로 병렬성은 매우 낮아지게 됩니다. 문제가 된 view 메서드에 synchronized 키워드를 붙이면 암시적 락이 걸리게 됩니다.
lock은 메서드, 변수에 각각 걸 수 있습니다. 메서드에 lock을 걸 경우 해당 메서드에 진입하는 스레드는 단 하나만 가능합니다. 변수에 lock을 걸 경우 해당 변수를 단 하나의 스레드만 참조할 수 있습니다. 단, 변수에 lock을 걸기 위해선 해당 변수는 객체여야만 합니다. int, long과 같은 기본형 타입에는 lock을 걸 수 없습니다.
메서드 Lock
class Count {
private int count;
public synchronized int view() {return count++;}
}
변수 Lock
class Count {
private Integer count = 0;
public int view() {
synchronized (this.count) {
return count++;
}
}
}
명시적 Lock
synchronized 키워드 없이 명시적으로 ReentrantLock을 사용하는 Lock을 명시적 Lock이라고 부릅니다. 해당 Lock의 범위를 메서드 내부에서 한정하기 어렵거나, 동시에 여러 Lock을 사용하고 싶을 때 사용합니다. 직접적으로 Lock 객체를 생성하여 사용할 수 있습니다. lock() 메서드를 사용할 경우 다른 스레드가 해당 lock() 메서드 시작점에 접근하지 못하고 대기하게 됩니다. unlock() 메서드를 실행해야 다른 메서드가 lock을 획득할 수 있게 됩니다.
명시적 Lock을 사용한 예제
public class CountingTest {
public static void main(String[] args) {
Count count = new Count();
for (int i = 0; i < 100; i++) {
new Thread(){
public void run(){
for (int j = 0; j < 1000; j++) {
count.getLock().lock();
System.out.println(count.view());
count.getLock().unlock();
}
}
}.start();
}
}
}
class Count {
private int count = 0;
private Lock lock = new ReentrantLock();
public int view() {
return count++;
}
public Lock getLock(){
return lock;
};
}
자원의 가시성을 책임지는 volatile
여러 스레드가 하나의 자원에 동시에 read&write를 진행할 때 항상 메모리에 접근하지는 않습니다. 성능의 향상을 위해 CPU 캐시에 해당 값을 저장하는 방법을 사용하기 때문에 해당 데이터가 메모리에 저장된 실제 데이터와 항상 일치하는지 보장할 수 없습니다. 변수에 저장한 데이터를 읽었는데 이 데이터가 실제 데이터와 차이가 있을 수 있다는 것이죠. 메인 메모리에 저장된 실제 자원의 값을 볼 수 있는 개념을 자원의 가시성이라고 부르는데, 이 가시성을 확보하지 못한 것입니다.
volatile은 이러한 CPU 캐시 사용을 막습니다. 해당 변수에 volatile 키워드를 붙여주면 해당 변수는 캐시에 저장되는 대상에서 제외됩니다. 매 번 메모리에 접근해서 실제 값을 읽어오도록 설정해서 캐시 사용으로 인한 데이터 불일치를 막습니다. 실제 메모리에 저장된 값을 조회하고 이를 통해 자원의 가시성을 확보할 수 있습니다.
volatile은 자원의 가시성을 확보해주지만 동시성 이슈를 해결하기에는 그리 충분하지 않습니다. 공유 자원에 read&write를 할 때는 동기화를 통해 해당 연산이 원자성을 이루도록 설정해주어야 동시성 이슈를 해결할 수 있습니다.
volatile이 효과적인 경우는 하나의 스레드가 wtite를 하고 다른 하나의 스레드가 read만 할 경우입니다. 이 경우 read만 하는 스레드는 CPU 캐시를 사용하고 다른 스레드가 write한 값을 즉각적으로 확인하지 못합니다. volatile은 이런 경우 해당 자원에 가시성을 확보하게 해 줌으로써 동시성 이슈 해결에 도움을 줄 수 있습니다.
volatile은 32비트, 64비트 변수에 효과적이라고 합니다. JLS 17.7에 따르면 64비트 변수인 long, double형태의 자료는 한번에 64비트에 대한 쓰기 동작보다 인접한 32 비트 값에 대한 두 개의 쓰기 동작으로 나눕니다. 그렇기 때문에 스레드가 쓰기 동작을 진행할 경우 첫 32비트는 이전 값, 두 번째 32비트는 변한 값을 참조할 수도 있습니다. 64비트 변수에 대한 쓰기 연산 자체가 원자성이 보장되지 않는 것이죠. volatile은 이러한 reat&write 연산에 원자성을 보장해줍니다.
반면 성능을 위한 CPU 캐시를 비활성화하고 매번 메인 메모리에 접근하기 때문에 어느정도의 성능 저하가 필연적으로 발생합니다. 그러므로 자원 가시성이 꼭 필요한지 유의하여 사용할 필요가 있습니다.
스레드 안전한 객체 사용
Concurrent 패키지
AcomitInteger과 같은 클래스는 i++과 같은 연산을 단일연산으로 만든 메서드를 제공해줍니다. 해당 클래스의 메서드는 내부적으로 Thread-safe 하게 구조화되어 있어서 스레드 안전한 프로그램을 만드는 것에 도움을 줄 수 있습니다. 해당 클래스를 사용하면 아래와 같은 코드가 됩니다.
class Count {
private AtomicInteger count = new AtomicInteger(0);
public int view() {
return count.getAndIncrement();
}
}
이외에도 concurrent 패키지는 각종 스레드 안전한 컬랙션을 제공합니다. ConcurrentHashMap과 같은 컬랙션은 스레드 안전하게 사용할 수 있습니다.
ConcurrentHashMap
concurrent패키지에 존재하는 컬랙션들은 락을 사용할 때 발생하는 성능 저하를 최소한으로 만들어두었습니다. 락을 여러 개로 분할하여 사용하는 Lock Striping 기법을 사용하여 동시에 여러 스레드가 하나의 자원에 접근하더라도 동시성 이슈가 발생하지 않도록 도와줍니다.
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
ConcurrentHashMap은 내부적으로 여러개의 락을 가지고 해시값을 이용해 이러한 락을 분할하여 사용합니다. 분할 락을 사용하여 병렬성과 성능이라는 두 마리의 토끼를 모두 잡은 컬랙션이라고 볼 수 있겠네요. 내부적으로 여러 락을 사용하지만 실제 구현이 어떻게 되어 있는지 자세히 알 필요는 없습니다. 일반적인 map을 사용할 때처럼 구현하면 내부적으로 알아서 락을 자동으로 사용해 줄 테니 편리하게 사용할 수 있습니다.
불변 객체 (Immutable Instance)
스레드 안전한 프로그래밍을 하는 방법중 효과적인 방법은 불변 객체를 만드는 것입니다. String 객체처럼 한번 만들면 그 상태가 변하지 않는 객체를 불변객체라고 합니다. 불변 객체는 락을 걸 필요가 없습니다. 내부적인 상태가 변하지 않으니 여러 스레드에서 동시에 참조해도 동시성 이슈가 발생하지 않는다는 장점이 있습니다. 즉, 불변 객체는 언제라도 스레드 안전합니다.
불변 객체는 생성자로 모든 상태 값을 생성할 때 세팅하고, 객체의 상태를 변화시킬 수 있는 부분을 모두 제거해야 합니다. 가장 간단한 방법은 세터(setter)를 만들지 않는 것입니다. 또한 내부 상태가 변하지 않도록 모든 변수를 final로 선언하면 됩니다.
References
docs.oracle.com/javase/specs/jls/se7/html/jls-17.html
http://tutorials.jenkov.com/java-concurrency/volatile.html
'Java > 기초' 카테고리의 다른 글
[Java] 많이 헷갈려하는 String constant pool과 Runtime Constant pool, Class file constant pool (10) | 2023.05.19 |
---|---|
TDD를 소개합니다 (11) | 2020.05.15 |
[Java] Java는 Call by reference가 없다 (25) | 2020.03.24 |
[Java] jConsole로 원격에서 실행중인 JVM 모니터링하기 (0) | 2020.02.14 |
[Java] Java의 GC(Garbage Collection)방법과 종류 (1) | 2019.10.17 |
댓글