Java에서의 Multi-Thread 환경 개발

HeoSeungYeon·2021년 8월 20일
1

Java Study

목록 보기
6/9
post-thumbnail

개요


Multi-Thread 환경에서 가져오는 이점이 무엇일까요?
먼저 Thread(스레드)의 개념에 대해서 살펴봅시다. 스레드는 프로세스(Process)의 작업 단위 중 하나 입니다. 만약 스레드가 없었으면 프로세스는 독자적인 메모리를 할당 받고 다른 프로세스의 메모리 영역에 접근하지 못합니다. 하지만 스레드는 프로레스 내의 자원을 공유하며 병렬성 향상에 기여합니다. 그렇다고 해서 스레드의 사용이 무조건적으로 좋은 것은 아닙니다. 여러 스레드가 하나의 자원을 공유하려고 할때 동시성, 데드락과 같은 문제 상황이 발생하는데요. 이를 고려하고 코드를 작성하여야 멀티 스레드 환경에서 원활한 성능 향상을 맛볼 수 있을 것입니다‼️

0. 멀티 스레드에서의 동시성 이슈


0-1. 동시성 이슈 예제


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 는 두가지 행위를 합니다.

  1. cnt 의 값을 조회
  2. cnt 값에 1을 더한 후 저장.

상황을 한번 생각해볼까요?

  1. A 스레드가 ++cnt 실행
  2. A 스레드에서 ++cnt 행위 중 1번 행위 수행(cnt의 값을 조회)
  3. B 스레드가 ++cnt 실행
  4. B 스레드에서 ++cnt 행위 중 1번 행위 수행(cnt의 값을 조회)
  5. A 스레드에서 ++cnt 행위 중 2번 행위 수행(cnt 값에 1을 더한 후 저장.)
  6. B 스레드에서 ++cnt 행위 중 2번 행위 수행(cnt 값에 1을 더한 후 저장.)

분명 cnt 의 값에 1을 더하는 행위를 2번을 했지만 "cnt 값 조회" 라는 행위를 동시에 했기에 동시성 이슈가 발생하여 cnt 엔 1을 더하는 행위를 1번만 한 꼴이 생기게 된 것입니다.

0-2. 동시성 이슈 해결 방법


그렇다면 이런 동시성 이슈는 어떻게 회피할 수 있을까요?

간단합니다. 조회와 저장을 하나의 행위로 묶어 반드시 조회와 저장의 트랜잭션이 끝난 뒤에 다른 스레드가 조회와 저장을 하게 하면 동시성 이슈를 제어 할 수 있습니다.

  • Read(조회) , Write(저장,쓰기) 행위를 하나의 작업으로 묶는다.

하나로 묶기 위해선 Lock 이라는 행위를 해주어야 합니다.

Lock은 다음 2가지 방식으로 수행할 수 있습니다.

1) 암시적 Lock


먼저 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

2) 명시적 Lock


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;
}

3) Thread Safe 객체


Concurrent 패키지


Concurrent 패키지는 Thread Safe 한 여러 클래스를 제공해줍니다.

또한 Concurrent 패키지안의 Thread Safe한 컬렉션에서는 Lock Striping 기법을 사용하는데 Lock Striping 기법Lock을 여러개로 분할하여 동시성 이슈를 제어 합니다.

대표적으로 AtomicInteger , ConcurrentHashMap 에 대해 실습을 해보도록 하겠습니다.

  1. 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
  2. ConcurrentHashMap

    • 여러 개의 락을 통해 동시성 이슈를 해결한 Map 컬렉션.
    • 예시 코드
    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 객체와 HashMap 객체를 생성하고 100개의 스레드가 10000번 데이터를 삽입하는 과정을 수행해 보았다.
    // 결과 
    concurrentHashMap Size: 999997
    hashMap Size: 999981
    concurrentHashMap Size: 999998
    hashMap Size: 999982
    concurrentHashMap Size: 999999
    hashMap Size: 999983
    concurrentHashMap Size: 1000000
  • 왜 HashMap 에 대해 동시성 이슈가 발생하였을까요⁉️
    • 크게 2가지 상황(해시가 단순히 동일한 두 데이터 삽입, 해시 충돌나는 두 데이터 삽입)에서 발생할 수 있습니다.

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️⃣ (해시가 단순히 동일한) 상황 에서도 위와 비슷한 로직으로 동시성 이슈가 발생할 수 있습니다.

  • 해시가 동일한 상황은 다음 2가지 일 수 있습니다.
    - key 값이 동일한 데이터를 넣을려고 할 때
    - key 값의 해시 값이 동일한 데이터를 넣을려고 할때

4) 불변 객체(Immutable Instance)


사실 가장 효과적인 스레드 세이프(Thread Safe) 방식은 불변 객체를 만드는 것입니다. Write 행위로 상태를 변경할 수 없으니 어느 스레드에서 해당 객체를 참조해도 항상 같은 값은 반환하게 될 것입니다.

불변 객체를 만드는 방법은 여러가지가 있습니다.

  • 세터(Setter)를 만들지 않는다.
  • final 변수를 만든다.

하지만 모든 객체의 값들이 항상 불변할 수 없습니다. 그렇기에 불변해야 하는 값들은 항상 상태를 변경하지 못하는 장치(Setter X, final keyword)를 사용하고, 그렇지 않은 경우엔 위의 방식들(Lock, ThreadSafe 객체 사용)등을 활용해 멀티 스레드 환경에서의 데이터 정합성을 보장해야할 것입니다.

1. Multi Thread 공부 회고


  • ConcurrentHashMap 의 동작 원리에 대해서 좀더 깊게 알아보고 싶습니다. 어떻게 동작하여 동시성 이슈를 막는지 궁금했습니다. 왜 실습 코드에선 HashMap 에 대해 동시성 이슈가 발생하였을까요? 이 점은 추후에 추가하도록 해볼 예정입니다 ☺️ ( 추가 완료 )
  • Spring Application에서 어떤 상황에서 멀티 스레드 상황이 발생하는지 궁금해졌습니다. 이점도 추후에 추가할 예정입니다 ☺️

참고 문서


[Java] Multi Thread환경에서 동시성 제어를 하는 방법

Spring, 멀티 스레드

[java] ConcurrentHashMap 동기화 방식

0개의 댓글