프로그램
- 일반적으로 특정 작업을 수행하는 소프트웨어
프로세스
- 메모리나 CPU와 같은 자원을 할당받아 실행 중인 프로그램
- 독자적인 메모리를 할당받아서 서로 다른 프로세스끼리는 일반적으로 서로의 메모리 영역을 침범하지 못함
스레드:
- 프로세스를 구성하는 하나의 단위, 작업의 실행 단위
- 하나의 프로세스는 여러 스레드가 작동하고 있을 수 있음
- 하나의 프로세스에 존재하는 여러 스레드들은 같은 자원을 공유하여 사용 할 수 있음
- 이로 인해 병렬성의 향상이라는 장점이 있는 반면에 동시성 문제, 데드락과 같은 여러 가지 문제점도 발생 할 수 있음
- 여러 스레드가 작동하는 환경에서도 문제 없이 동작하는 것을 스레드 안전(Thread-safe)하다고 말 할 수 있음
스레드 안정성이 깨지는 상황
- 100명의 스레드로 각각 100번 조회하는 프로그램
- 소스코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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번의 조회수가 나오지 않고 그보다 더 적은 조회수가 나옴
- 조회수를 증가시키는 로직이 두 번의 동작으로 이뤄어지는데 동시에 여러 스레드가 접근하여 첫 번째 동작할 때의 자원과 두 번째 동작할 때의 자원 상태가 변하기 때문(동시성 이슈 발생)
- 여러 스레드에서 동시에 count변수에 접근한다면 동시에 1번 동작을 진행하여 같은 count값을 조회할 것이고 두 개의 스레드가 1을 더하는 조회 로직을 실행한다 해도 2가 더해지는 것이 아닌 1이 더해지게 됨
스레드 안정성을 유지하는 방법 (동시성을 제어하는 방법)
1) 암시적 Lock
- lock을 적용하게 되면 하나의 스레드가 해당 메서드를 실행하고 있을 때 다른 메서드가 해당 메서드를 실행하지 못하고 대기하게 됨(Java에서 synchronized키워드 사용)
- 즉, 한 번에 하나의 스레드만 접근할 수 있게 됨
- 하지만 한 번에 하나의 스레드만 접근하다보니 병렬성을 매우 낮아지게 되고 결과적으로 프로그램의 퍼포먼스가 감소하게 됨
- lock은 메서드, 변수에 각각 걸 수 있음
- 1) 메서드에 lock을 거는 방법
1 2 3 4
class Count { private int count; public synchronized int view() {return count++;} }
- 2) 변수에 lock을 거는 방법
1 2 3 4 5 6 7 8
class Count { private Integer count = 0; public int view() { synchronized (this.count) { return count++; } } }
- 상황에 따라 변수에 lock을 사용하거나 메소드에 lock을 사용하는데 단순하게 메서드에 Lock을 거는 방법 보단 특정 변수에 lock을 사용함으로써 스레드가 해당 로직을 수행하는데 있어 퍼포먼스를 높일 수 있음
2) 명시적 Lock
- synchronized 키워드 없이 명시적으로 ReentrantLock을 사용하는 Lock
- 해당 Lock의 범위를 메서드 내부에서 한정하기 어렵거나, 동시에 여러 Lock을 사용하고 싶을 때 사용
- 직접적으로 Lock 객체를 생성하여 사용할 수 있음
- lock() 메서드를 사용할 경우 다른 스레드가 해당 lock() 메서드 시작점에 접근하지 못하고 대기하게 됨
- unlock() 메서드를 실행해야 다른 메서드가 lock을 획득 할 수 있게 됨
- 예제 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 <b 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한 값을 즉각적으로 확인하지 못함
스레드 안전한 객체 사용
- Concurrent 패키지
- AtomicInteger와 같은 클래스는 i++와 같은 연산을 단일 연산으로 만든 메서드를 제공해줌
- 해당 클래스의 메서드는 내부적으로 Thread-safe 하게 구조화되어 있어서 스레드 안전한 프로그램을 만드는 것에 도움을 줄 수 있음
1 2 3 4 5 6
class Count { private AtomicInteger count = new AtomicInteger(0); public int view() { return count.getAndIncrement(); } }
- ConcurrentHashMap
- concurrent패키지에 존재하는 컬랙션들은 락을 사용할 때 발생하는 성능 저하를 최소한으로 만들어두었음
- 락을 여러 개로 분할하여 사용하는 Lock Striping 기법을 사용하여 동시에 여러 스레드가 하나의 자원에 접근하더라도 동시성 이슈가 발생하지 않도록 도와줌
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
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은 내부적으로 여러개의 락을 가지고 해시값을 이용해 이러한 락을 분할하여 사용
- 불변 객체 (Immutable Instance)
- String 객체처럼 한번 만들면 그 상태가 변하지 않는 객체를 불변객체라고 함
- 불변 객체는 락을 걸 필요가 없습니다. 내부적인 상태가 변하지 않으니 여러 스레드에서 동시에 참조해도 동시성 이슈가 발생하지 않는다는 장점이 있음
- 즉, 불변 객체는 언제라도 Thread-safe함
- 객체의 상태가 변화되지 않도록 setter메소드를 만들지 않고 내부의 모든 변수를 final로 선언하면 됨