자바의 동시성
업데이트:
동시성?
- 자바는 멀티 쓰레드를 지원하는 언어이다.
- 멀티 쓰레딩 프로그래밍에서는 기본적으로 고려해야 할 것이 바로 동시성 문제!
- 즉, 동기화를 꼭 해주어야 한다.
Thread safe?
- 멀티 쓰레드 프로그래밍에서 특정 자원에 대해 여러 쓰레드로부터 동시 접근이 이루어져도 프로그램의 실행에 문제가 없음을 의미.
- 자바에서는 JVM내 Method 영역 내의 데이터나 인스턴스 변수와 같이 Heap 영역 내에 저장되는 데이터들은 여러 스레드들 간에 공유되는 자원이기 때문에 Thread safe한 자원이 아님.
- 즉, Thread safe하지 않은 자원에 대해 동시성 문제를 발생시키지 않기 위한 동기화가 필요하다.
- 그렇지 않으면, 해당 데이터의 안정성과 신뢰성을 보장할 수 없다.
- 반면, 메서드 내에 선언되는 로컬변수는 호출 시, thread의 stack frame 내에 생성되는 데이터이며, 여러 쓰레드들간 공유할 수 없는 자원이기 때문에 Thread safe한 자원이다.
자바에서의 동기화 방법
synchronized
volatile
Atomic
Synchronized
- 여러개의 쓰레드가 특정 자원을 사용하고자 할 때, 현재 데이터를 사용하고 있는 해당 쓰레드를 제외하고 나머지 쓰레드들은 데이터에 접근 할 수 없도록 하는 자바의 예약어.
- 사용방법
- synchronized methods
- 메서드 자체를 synchronized로 선언하는 방법
public synchronized void plus(int value) { amount += value; }
- 동일한 객체에 대한 위의 메서드에 2개의 쓰레드가 접근하든 10개의 쓰레드가 접근하든 한 순간에는 하나의 쓰레드만 해당 메서드를 수행할 수 있다.
- synchronized statements
- 메서드 내의 특정 문장만 synchronized로 감싸는 방법
public void plus(int value) { synchronized(this) { amount += value; } }
- 이전 방식처럼 메서드에 synchronized를 추가하면 성능상 문제가 발생할 가능성이 크다.
- 만약 메서드의 길이가 100줄인데 1줄에 대해서만 thread safe를 보장해 주면 되는 상황이라고 가정해보자.
- thread safe를 보장해야할 1줄을 위해 나머지 99을 처리할 때, 필요없는 대기시간이 발생할 수 있다.
- 그렇기 때문에 thread safe를 보장해야할 코드만 synchronized 블럭으로 감싸주면 보다 더 효율적인 프로그래밍이 가능하다.
- synchronized methods
Synchronized의 한계
- 다만, synchronized는 synchronized에 해당하는 자원에 대해서는 병렬 프로그래밍을 제공하지 않기 때문에, 남발하면 그만큼 성능이슈를 발생시킬 수 있다.
- synchronized는 대상 자원에 대해 Lock을 잡는 것이기 때문에 오버헤드가 있고, dead-lock의 문제를 일으킬 가능성 이 존재할수 있다는 치명적인 단점이 있다.
- 그렇기 때문에 실무에서는 거의 사용되지 않는 방식이다.
volatile
- Java에서 변수값을 메인메모리에 저장하겠다고 명시하는 키워드.
- 매번 변수의 값을 read 할때마다 CPU의 cache에 저장된 값이 아닌 메인 메모리에서 읽는 것.
- 또한, 변수 값을 write 할때마다 메인 메모리에까지 작성하는 것.
-
이를 이해하기 위해서는 아래와 같이 CPU가 메인메모리에 접근하기 전에 성능향상을 위해서
cache
를 사용한다는 것을 알아야 한다. - 즉, volatile 변수를 사용하고 있지 않는 multi thread 어플리케이션에서는 task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU cache에 저장한다.
- 한 thread 작업 시 한번 main memory에서 read 해온 값은 CPU cache에 저장해두고, 이후 read 시 cache에 저장된 값을 read함.
- 만약, 한 쓰레드가 변경된 값을 cache memory에서 메인메모리로 데이터를 저장하기 전에 다른 쓰레드에서 메인메모리의 해당 값을 읽어 변경되기 이전의 값을 처리한다면 data 불일치 문제가 발생한다.
- 이러한 상황을
가시성 문제
라고 한다.
- 이러한 상황을
- 이러한 가시성 문제를 해결할 수 있는 방법이 바로
volatile!
- 즉, 가시성이 보장되어야하는 변수를 cache memory에서 읽는 것이 아니라, 메인메모리 에서만 읽도록 보장하는 것이다.
그렇다면 volatile로 가시성을 보장하면 모든 동시성 문제를 해결할 수 있을까?
- 정답은
아니요!
다. - 다음과 같은 예시를 가정해보자.
- 내가 만든 쇼핑몰 서비스(멀티 쓰레드를 이용함)가 있는데, 상품을 구매할때마다 해당 상품에 대한 puchaseCount를 늘려주어야 한다고 가정해보자.
- 두명의 고객이 동시에 해당 상품을 구매했다고 가정해보자.
-
이를 CPU와 메인메모리의 측면에서 보면 다음과 같을 것이다.
- 메인메모리에서 puchaseCount가 0일때 두개의 쓰레드가 동시에 purchaseCount를 read하여 각각의 CPU cache에 저장한다.
- 이후, 각각의 쓰레드에서 CPU cache에 저장된 purchaseCount를 +1을 해주는 로직을 수행한다.
- 이후, main memory에 purchaseCount를 저장한 후 쓰레드가 종료된다.
- 이 과정이 끝난이후 예상했던 purchaseCount는 2지만 1로 counting될 것이다.
- 동시성 문제로 인해
데이터 일관성 깨지는 문제
가 발생한다.
- 동시성 문제로 인해
- 이러한 동시성 문제는 애플리케이션의 심각한 장애로 이어질 수 있다.
그렇다면 volatile은 언제 사용할 수 있을까?
한 스레드만 '쓰기'
하고,나머지 스레드는 '읽기'만
하는 상황에서는 volatile을 이용한 동시성 보장이 가능하다.- 다만, 여러 스레드가 동시에 데이터를 ‘쓰기’하는 경우는 다른 방법을 이용해서 아예 서로 다른 스레드가 동시에 실행되는 상황을 막아야한다.
Atomic과 CAS
- Atomic은 CAS 방식을 기반으로 하며, multi thread 환경에서의 동시성 문제를 해결한다.
- CAS는
Compare And Swap
그렇다면 CAS는 무엇일까?
- CAS는
변수의 값을 변경하기 전에 기존에 가지고 있던 값이 내가 예상하던 값과 같을 경우에만 새로운 값을 할당하는 방법
이다. -
코드로 예를 들면 다음과 같다.
... public class AtomicEx { int val; public boolean compareAndSwap(int oldVal, int newVal) { if(val == oldVal) { val = newVal; return true; } else { return false; } } } ...
- 즉, CAS는 값을 변경하기 전에 한 번 더 확인하는 것이라고 볼 수 있다.
- Java에서 제공하는
Atomic Type
들은 이러한 CAS를 하드웨어(CPU)의 도움을 받아 한순간에 단 하나의 스레드만 변수의 값을 변경할 수 있도록 제공하고 있다.
CAS는 왜 쓰나?
- CAS를 이용하면 synchronized와 달리 병렬성을 해치지 않으면서 동시성을 보장하기 때문에
더 좋은 성능
을 가져올 수 있다. - 또한, volatile에서 발생할 수 있었던
가시성 문제도 해결
할 수 있다.
CAS 사용 예
- 자바의 concurrent 패키지의 타입들은 이렇게
현재 스레드에서 사용되는 값이 메인메모리의 값과 같은지 비교
하고 불일치한다면 업데이트된 값(main memory의 값)을 가져와 계산하는 CAS 알고리즘을 이용해데이터의 원자성을 보장하고, 좋은 성능을 보장
한다.- 대표적으로
ConcurrentHashMap, AtomicInteger, AtomicBoolean
등이 CAS를 이용하는 type들이다.
- 대표적으로
JAVA에서의 CAS 동작 예시
- 위의 그림과 같이 JVM내의 thread scheduler에 의해서 각각의 core에 thread-1과 thread-2가 선점된 상태라고 가정해보자.
- 각 쓰레드는 동시에 수행되면서, heap 내에 있던 동일한 count 값(0)을 read하여 각 cache에 로딩시킨다.
- 그리고 각 쓰레드는 코드를 한줄씩 실행하면서 count 값을 1씩 증가시킨다.
- count를 1씩 증가시키는 로직이 있다고 가정하자.
- 이후, threaed-1에서 업데이트한 count 값(1)을 heap에 write 하려고 시도한다.
- 이 과정에서 thread-1에서 처음 읽었을 때 heap의 count 값이었던 0과, 해당 시점에 heap에 저장돼있는 count 값을 비교한다.
- 만약 같으면, heap에 1값을 write하고, 다르면 해당 시점에서 heap내의 count를 다시 새로 가져와서 또 다시 같은지 비교한다.
- 무한 loop와 volatile 사용.
- 값이 다르면 위 과정을 반복하고, 같으면 heap에 실제로 업데이트된 데이터를 write 한다.
- thread-2에서도 마찬가지로 위의 과정들을 수행한다.
댓글남기기