Multi-Thread 환경에서 가져오는 이점이 무엇일까요?
먼저 Thread(스레드)의 개념에 대해서 살펴봅시다. 스레드는 프로세스(Process)의 작업 단위 중 하나 입니다. 만약 스레드가 없었으면 프로세스는 독자적인 메모리를 할당 받고 다른 프로세스의 메모리 영역에 접근하지 못합니다. 하지만 스레드는 프로레스 내의 자원을 공유하며 병렬성 향상에 기여합니다. 그렇다고 해서 스레드의 사용이 무조건적으로 좋은 것은 아닙니다. 여러 스레드가 하나의 자원을 공유하려고 할때 동시성, 데드락과 같은 문제 상황이 발생하는데요. 이를 고려하고 코드를 작성하여야 멀티 스레드 환경에서 원활한 성능 향상을 맛볼 수 있을 것입니다‼️
static int cnt = 0;
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
System.out.println(++cnt);
}
}
}.start();
}
}
cnt 변수를 스레드 100개를 통해 10000번 반복하여 cnt를 1씩 증가하여 출력하는 행위를 진행하였습니다.
...
999933
999934
999935
999936
999937
이 때 결과 값이 100*10000 = 1000000 이 아니라, 그보다 작은 999937 이라는 값이 나오는걸 확인할 수 있었습니다.
왜 이런 결과가 나오는지 생각해봅시다. 먼저 ++cnt
는 두가지 행위를 합니다.
상황을 한번 생각해볼까요?
++cnt
실행++cnt
행위 중 1번 행위 수행(cnt의 값을 조회)++cnt
실행++cnt
행위 중 1번 행위 수행(cnt의 값을 조회)++cnt
행위 중 2번 행위 수행(cnt 값에 1을 더한 후 저장.)++cnt
행위 중 2번 행위 수행(cnt 값에 1을 더한 후 저장.)분명 cnt 의 값에 1을 더하는 행위를 2번을 했지만 "cnt 값 조회" 라는 행위를 동시에 했기에 동시성 이슈가 발생하여 cnt 엔 1을 더하는 행위를 1번만 한 꼴이 생기게 된 것입니다.
그렇다면 이런 동시성 이슈는 어떻게 회피할 수 있을까요?
간단합니다. 조회와 저장을 하나의 행위로 묶어 반드시 조회와 저장의 트랜잭션이 끝난 뒤에 다른 스레드가 조회와 저장을 하게 하면 동시성 이슈를 제어 할 수 있습니다.
하나로 묶기 위해선 Lock 이라는 행위를 해주어야 합니다.
Lock은 다음 2가지 방식으로 수행할 수 있습니다.
먼저 synchronized
키워드를 사용하는 암시적 Lock 이 있습니다.
synchronized
키워드는 메서드, 변수에 붙일 수 있는데
기본 타입의 변수(int,long)일 경우엔 lock을 걸 수 없으니 주의해야 합니다.
메서드 Lock 예시 코드
static int cnt = 0;
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
System.out.println(add());
}
}
}.start();
}
}
synchronized public static int add(){
return ++cnt;
}
// 결과
999996
999997
999998
999999
1000000
ReentrantLock
을 사용하는 Lock을 명시적 Lock이라고 합니다. lock 객체를 생성하여 lock()
메서드를 호출 시점과 unlock()
메서드 호출 시점 사이의 행위에 대해서 Lock 행위를 적용할 수 있습니다.
명시적 Lock 예시 코드
static int cnt = 0;
static Lock lock = new ReentrantLock();
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
lock.lock();
System.out.println(add());
lock.unlock();
}
}
}.start();
}
}
public static int add(){
return ++cnt;
}
Concurrent 패키지
Concurrent 패키지는 Thread Safe 한 여러 클래스를 제공해줍니다.
또한 Concurrent 패키지안의 Thread Safe한 컬렉션에서는 Lock Striping 기법을 사용하는데 Lock Striping 기법은 Lock을 여러개로 분할하여 동시성 이슈를 제어 합니다.
대표적으로 AtomicInteger , ConcurrentHashMap 에 대해 실습을 해보도록 하겠습니다.
AtomicInteger
++cnt
과 같은 연산에 대해 단일 연산으로 수행하는 메서드를 제공합니다.static AtomicInteger cnt = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
System.out.println(add());
}
}
}.start();
}
}
public static int add(){
return cnt.incrementAndGet();
}
// 결과
999996
999997
999998
999999
1000000
ConcurrentHashMap
static int cnt = 1;
static ConcurrentHashMap<Integer,String> concurrentHashMap = new ConcurrentHashMap<>();
static HashMap<Integer,String> hashMap = new HashMap<>();
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
hashMap.put(add(),"sample");
concurrentHashMap.put(add(),"sample");
System.out.println("hashMap Size: "+hashMap.size());
System.out.println("concurrentHashMap Size: "+ concurrentHashMap.size());
}
}
}.start();
}
}
synchronized public static int add(){
return ++cnt;
}
// 결과
concurrentHashMap Size: 999997
hashMap Size: 999981
concurrentHashMap Size: 999998
hashMap Size: 999982
concurrentHashMap Size: 999999
hashMap Size: 999983
concurrentHashMap Size: 1000000
1️⃣ (해시 충돌) 상황을 가정해봅시다. 만약에 동일한 해시값을 가지는 key가 35, 91 이라고 해봅시다.
1. `hashMap.put(data(key 35))` 수행
2. put 메서드에서 data(key 35) 를 넣을 수 있는 위치 조회 (해시 값이 같아 위치가 같음)
3. `hashMap.put(data(key 91))` 수행
4. put 메서드에서 data(key 91) 를 넣을 수 있는 위치 조회 (해시 값이 같아 위치가 같음)
5. data(key 35) 저장
6. data(key 91) 저장
- 즉 , 저장 전에 저장할 위치를 조회하였기에, 해시 체이닝이 적용되지 않아 같은 장소에 두번 삽입되어 하나의 데이터가 누락될 수 있는 것을 알 수 있습니다.
2️⃣ (해시가 단순히 동일한) 상황 에서도 위와 비슷한 로직으로 동시성 이슈가 발생할 수 있습니다.
사실 가장 효과적인 스레드 세이프(Thread Safe) 방식은 불변 객체를 만드는 것입니다. Write 행위로 상태를 변경할 수 없으니 어느 스레드에서 해당 객체를 참조해도 항상 같은 값은 반환하게 될 것입니다.
불변 객체를 만드는 방법은 여러가지가 있습니다.
하지만 모든 객체의 값들이 항상 불변할 수 없습니다. 그렇기에 불변해야 하는 값들은 항상 상태를 변경하지 못하는 장치(Setter X, final keyword)를 사용하고, 그렇지 않은 경우엔 위의 방식들(Lock, ThreadSafe 객체 사용)등을 활용해 멀티 스레드 환경에서의 데이터 정합성을 보장해야할 것입니다.