[면접질문] Java에서 쓰레드간 동기화하는 방법 - bloodfinger8/AlgorithmStudy GitHub Wiki

[java] 쓰레드의 동기화(Synchronized, volatile, atomic)

프로세스와 쓰레드

  • 프로세스란 '실행 중인 프로그램'. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다.
  • 프로세스는 프로그램 수행에 필요한 데이터, 메모리 등의 자원과 쓰레드로 구현되어 있다.
  • 즉 쓰레드는 프로세스의 자원을 이용해서 실제로 작업을 수행
  • 모든 프로세스에는 최소 하나 이상의 쓰레드가 존재, 둘 이상의 스레드를 가진 프로세스는 멀티쓰레드 프로세스라고 한다.

쓰레드의 구현과 실행

  • Thread클래스를 상속받는 방법
public static class ThreadTest2 extends Thread {
        public void run() {
            System.out.println(getName());
        }
    }

// 메인 메서드에서 인스턴스 생성 방법
Thread t1 = new ThreadTest2();

  • Runnabele 인터페이스를 구현하는 방법 (다중 상속이 가능하여 더 일반적으로 사용)
public static class ThreadTest implements Runnable {
        public void run() {
            System.out.println(Thread.currentThread().getName());
        }
    }

// 메인 메서드에서 인스턴스 생성 방법
Thread t1 = new Thread(new ThreadTest()); // new Thread(Runnable target) 생성자 이용

쓰레드의 동기화 과정

멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다. (Heap, Method 영역, Runtime Constant Pool 영역은 스레드간 공유 가능한 영역)

만약 Thread1이 작업하던 도중 다른 Thread2에게 제어권이 넘어갔을 때, Thread1이 작업하던 공유데이터를 Thread2가 임의로 변경했다면, 다시 Thread1이 제어권을 넘겨받아 나머지 작업을 마쳤을 때 원래 의도했던 결과와 다르게 결과가 나올 수 있다.

이러한 일을 방지하기 위해 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요하다.

  • 임계 영역 : 공유 데이터를 사용하는 코드 영역을 지정
  • : 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드 수행 가능.

공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 쓰레드는 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계영역의 코드를 수행할 수 있게 할 수 있다.

이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하게 막는 것을 동기화라고 한다.

스레드를 동기화하는 방법은 여러가지가 있는데 가장 간단한 방법은 synchronized를 이용해서 임계영역을 설정하는 것이다.

synchronized를 이용한 동기화

가장 간단한 동기화 방법이며, 이 키워드는 임계 영역을 설정하는데 사용된다. 메서드 전체를 임계 영역으로 설정하거나, 특정 영역을 임계 영역으로 지정할 수 있다.

public synchronized void calcSum() {
	...
}

synchronized(객체의 참조변수) {
	...
}

  • 두번째 방법은 메서드 내의 코드 일부를 블럭으로 감싸고 블럭 앞에 synchronized(참조변수) 를 붙이는 것이다. 이때 참조변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다.
  • 이 블럭을 synchronized 블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납한다.
  • 두 방법 모두 lock의 획득된 반납이 자동적으로 이루어지므로 임계 영역만 설정해주면 된다.
  • 모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다.

volatile을 이용한 동기화

  • 변수의 가시성 문제는 다음과 같다. 변수의 값은 CPU 메모리와 메인 메모리에 저장된다. 이 값을 CPU 메모리인지 메인 메모리에서 가져오는 지 알 수가 없다는 문제가 변수의 가시성 문제이다. 이렇게 CPU 메모리에서 값을 읽어들인다면 매우 안전하지 못한다.
private volatile int counter; public int getNextUniqueIndex() { return counter++; }
출처: https://mygumi.tistory.com/112 [마이구미의 HelloWorld]
  • volatile 키워드를 변수 앞에 선언하여 사용할 수 있다. 이로써 가시성 문제를 해결할 수 있다. volatile을 사용하면 counter 변수를 읽고 쓰는 과정은 모든 읽기 쓰기 연산을 메인 메모리에서만 처리된다. 따라서 가시성 문제가 해결된다.
  • 경쟁 상태(race condition) - 여러 스레드 같은 시점 변수를 읽는 상태. - 이는 해결이 안됨!
  • 즉, volatile의 경우는 하나의 스레드가 쓰기 연산을 하고, 다른 스레드에서는 읽기 연산을 통해 최신 값을 가져올 경우. 즉 다른 스레드에서는 업데이트를 행하지 않을 경우 이용할 수 있다.

Atomic

AtomicInteger 클래스는 CAS(compare-and-swap) 기반으로 되어있다.

CAS란 특정 메모리 위치의 값이 주어진 값을 비교하여 같으면 새로운 값으로 대체된다.

CAS를 c언어의 코드로 보면 쉽게 이해가 갈 것이다.

Atomic 클래스의 경우는 여러 스레드에서 읽기 쓰기 모두 이용할 수 있다. (CAS)

cpu캐시메모리

멀티쓰레드환경, 멀티코어 환경에서 각 CPU는 메인 메모리에서 변수값을 참조하는게 아닌,

CPU의 캐시 영역에서 메모리를 참조하게 됩니다. (그림 2 참조)

이떄, 메인 메모리에 저장된 값과 CPU 캐시에 저장된 값이 다른 경우가 있는데 (가시성문제)

이럴때 사용되는것이 CAS 알고리즘입니다. 현재 쓰레드에 저장된 값과 메인메모리에 저장된 값을 비교하여

일치하는경우 새로운 값으로 교체되고 , 일치하지 않는다면 실패하고 재시도를 합니다.

이런방법으로 처리하면 ,CPU캐시에 잘못된 값을 참조하는 가시성문제를 해결할 수있습니다.

참고로 synchronized 의 경우 synchronized 진입전 메인메모리와 CPU 캐시메모리 값을 동기화 하여 문제가 없도록 처리합니다.

int compare_and_swap(int* reg, int oldval, int newval)

{
  int old_reg_val = *reg;
  if (old_reg_val == oldval)
     *reg = newval;
  return old_reg_val;
}

출처: https://mygumi.tistory.com/112 [마이구미의 HelloWorld]