멀티스레드 환경에서의 자바 동시성 제어 기법
멀티스레드 프로세스 환경에서의 자바 동시성 기어 제법 들어가기 전에
프로세스와 쓰레드, 멀티쓰레드부터 뭔지 알아보자.
프로세스
- 실행중인 프로그램(program)이 메모리에 적재되어 실행되는것
- 프로세스 내에는 코드 영역, 데이터 영역, 스택 영역, 힙 영역이 존재한다.
- Code 영역
- 실행한 프로그램의 코드가 저장되는 메모리 영역 (프로그램 명령어, 소스 코드 자체)
- Data 영역
- 프로그램의 전역 변수와 static 변수가 저장되는 메모리 영역( 전역변수, static 변수. 정적 )
- Heap 영역
- 프로그래머가 직접 공간을 할당(malloc)/해제(free) 하는 메모리 영역(new() 등 동적)
- Stack 영역
- 함수 호출 시 생성되는 지역 변수와 매개 변수가 저장되는 임시 메모리 영역 (지역변수, 매개변수, 함수, 리턴값 동적)
- Heap 영역에는 주로 긴 생명주기를 가지는 데이터들이 저장된다. (대부분의 오브젝트는 크기가 크고, 서로 다른 코드블럭에서 공유되는 경우가 많다)
- 애플리케이션의 모든 메모리 중 stack 에 있는 데이터를 제외한 부분이라고 보면 된다.
- 모든 Object 타입(Integer, String, ArrayList, ...)은 heap 영역에 생성된다.
- 몇개의 스레드가 존재하든 상관없이 단 하나의 heap 영역만 존재한다.
- Heap 영역에 있는 오브젝트들을 가리키는 레퍼런스 변수가 stack 에 올라가게 된다.
스레드 (쓰레드)
- 프로세스 내에서 실행되는 여러 흐름의 단위.
- 한 프로세스 내에 여러 스레드가 존재할 수 있다.
- 스레드는 Stack영역을 제외한 다른 영역을 공유한다
- 같은 프로세스 안에 있는 여러 스레드들은 같은 힙 공간을 유지한다.
멀티 쓰레드 (멀티 스레드)
- 멀티 스레드란 한 프로세스(실행중인 하나의 프로그램) 를 여러개의 쓰레드로 구성하고 각 쓰레드로 하여금 한 작업(task)를 처리하도록 하는 것
- 한 프로세스에서 여러 작업을 병렬로 처리하는것
- 멀티 쓰레드에서는 한 프로세스 내에 여러개의 쓰레드가 있고, 각 쓰레드들은 Stack 영역을 제외한 Data, Code, Heap 영역을 공유한다.
동시성 제어?
JVM에서는 한 프로세스 내에서 여러 스레드를 가질 수 있다.
스레드들은 Stack 영역을 제외한 Data, Code, Heap 영역을 공유하는데,
Heap 영역에 참조 변수들이 load되어 서로 공유된다.
이 때 Heap 영역에 있는 변수들을 여러 스레드가 동시에 접근하게 되어 계산했을 시, 예상하는 결과와 다른 문제가 생길 수 있다.
이를 동시에 접근하여 문제가 생기는 것들을 동시성 문제(Concurrency ) 라고 하며,
동시성 문제를 제어하는것을 동시성 제어 (Concurrency Control) 이라고 한다.
이 동시성 문제가 생길 수 있는 예제로 여러 사용자가 게시물을 조회했을 때, 조회수가 올바르지 않게 조회되는 것을 확인해보자
동시성 문제 예제, 조회수
100명의 사용자가 1번씩 게시물을 조회했다고 하자.
1번 조회했을때 조회수는 1번씩 올라간다 하고, 단순 계산 했을 시에 조회수는 100이 되어야 한다.
public class ViewCounter {
private int count = 0;
public void view() {
this.count += 1;
System.out.println("currentThreadName : " + Thread.currentThread().getName()
+ ", view Count : " + this.getViewCount());
}
public int getViewCount() {
return this.count;
}
public static void main(String[] args) {
int threadCount = 100;
ViewCounter viewCounter = new ViewCounter();
for (int i = 0; i < threadCount; i++) {
new Thread(viewCounter::view).start();
}
System.out.println(viewCounter.getViewCount());
}
}
- 결과
- 결과로 100번이 아닌, 99번의 조회수가 나왔습니다.
왜 그러냐 하면, 조회수를 증가시킬 때, 조회수 변수인 count를 동시에 여러 스레드가 접근하여
값이 바뀌기 전의 값을 증가시키기 때문입니다.
// 쓰레드가 4개 있다고 가정.
// 모든 쓰레드는 동시에(완벽히 동시가 아닌, 아주 빠르게실행하면 동시에 접근한것처럼 보이므로 란 뜻)
1번 쓰레드 : count 0인 값을 가져와서 0 -> 1로 증가시킴
2번 쓰레드 : count 1인 값을 가져와서 1 -> 2로 증가시킴
3번 쓰레드 : count 1인 값을 가져와서 1 -> 2로 증가시킴 // 2번쓰레드와 겹침 이슈 발생
4번 쓰레드 : count 2인 값을 가져와서 2 -> 3로 증가시킴
예제로 적은 값과 쓰레드번호는, 상황에 따라 매번 다르게 변할 수 있다.
즉, 어떤 쓰레드가 접근했을 때 값을 이미 바꿨는데, 다른 쓰레드가 그 값을 바꾸기 직전을 참조해서 생기는 이슈 인것이다.
그렇다면 Java에서는 이 이슈를 어떻게 해결할까?
- 크게 3가지가 있다.
- Synchronized 키워드
- volataile 키워드
- java.util.Concurrent 패키지 의 Atomic 클래스들과 Concurrent 컬렉션들(List, Map 등)
1. Synchonized 키워드
자바에서는 synchronized 키워드를 이용해 메서드, 내부(변수)에 lock을 걸어 동기화 할 수 있다.
lock을 자바에서는 2가지를 이용할 수 있습니다.
- ReentrantLock 클래스 사용 - 명시적 동기화, 명시적 lock
- 동기화된 메소드와 문장을 사용하여 액세스 할 수 있는 암시적인 모니터 잠금 기능과 같은 기본적인 동작과 의미를 가진 reentran
- 상호 간의 상호 배제된 상호 배제 잠금 기능
- 잠금의 시작점과 끝 점을 수동적으로 설정이 가능하다.
- synchronized 키워드 사용 - 암시적 동기화, 암시적 lock
- 메서드나, 메서드 내의 블럭 안에 syncronized 키워드를 사용하여 잠금 기능을 함
- 변수에 lock을 걸기 위해선 해당 변수는 객체여야만 한다. int, long과 같은 기본형 타입에는 lock을 걸 수 없다.
- 구간 전체를 lock을 걸기 때문에, 시작점과 끝점을 명시할 수 없다.
1. ReentrantLock 클래스 사용 예제
public class ViewCounter {
private int count = 0;
private Lock lock = new ReentrantLock();
public int view() {
return count++;
}
public Lock getLock() {
return lock;
}
public static void main(String[] args) {
ViewCounter viewCounter = new ViewCounter();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
viewCounter.getLock().lock();
System.out.println(viewCounter.view());
viewCounter.getLock().unlock();
}).start();
}
}
}
- Lock의 범위를 메서드 내부에서 한정하기 어렵거나, 동시에 여러 Lock을 사용하고 싶을 때 사용.
- 직접적으로 Lock 객체를 생성하여 사용.
- lock() 메서드를 사용할 경우 다른 스레드가 해당 lock() 메서드 시작점에 접근하지 못하고 대기.
- Lock 객체의 unlock() 메서드를 실행해야 다른 메서드가 lock을 획득할 수 있다
2. synchronized 키워드 사용 예제
- 메서드 전체 동기화 - 이 때 범위는 메서드 전체이고, 변수는 primitive 같은 기본형 타입이여도 된다.
public class ViewCounter {
private int count = 0;
public synchronized void view() { // 메서드 Lock
this.count += 1;
System.out.println("currentThreadName : " + Thread.currentThread().getName()
+ ", view Count : " + this.count);
}
}
- 메서드 내 변수 동기화 - 이 때, 변수는 객체(오브젝트) 타입이여야 한다.
public class ViewCounter {
private Integer count = 0; // 변수는 객체여야 한다.
public void view2() { // 변수 Lock
synchronized (this.count) { // 변수는 객체여야 한다.
this.count += 1;
System.out.println("currentThreadName : " + Thread.currentThread().getName()
+ ", view Count : " + this.count;
}
}
}
- synchronized을 적용하게 되면 하나의 스레드가 해당 메서드를 실행하고 있을 때 다른 메서드가 해당 메서드를 실행하지 못하고 대기하게 된다.
- 즉 한 번에 하나의 스레드만 접근할 수 있게 된다.
- 멀티 스레드가 동시에 접근해서 생긴 문제인데, 여러 스레드가 동시에 접근할 수 없게 만들어 동시성 이슈를 막을 수 있음
- 즉 한 번에 하나의 스레드만 접근할 수 있게 된다.
- 단점 : 한 번에 하나의 스레드만 메서드를 실행시킬 수 있으므로 병렬성은 매우 낮아짐
- 성능 이슈가 생길 수 있다.
- 문제가 된 view 메서드에 synchronized 키워드를 붙이면 암시적 락이 걸린다.
lock은 메서드, 변수에 각각 걸 수 있다.
- 메서드에 lock을 걸 경우 해당 메서드에 진입하는 스레드는 단 하나만 가능
- 변수에 lock을 걸 경우 해당 변수를 단 하나의 스레드만 참조할 수 있다.
언제 synchoronized 식별자 사용하는것이 좋을까?
- 하나의 객체에 여러개의 스레드가 접근해서 처리하고자 할때
- static으로 선언한 객체에 여러 스레드가 동시에 사용할때
- 변수를 공유할땐 static 변수사용, 하지만 static변수는 객체를 생성할 수 없다.
- Atomic은 기존변수에 동시성 제어를 하는것이 아니라 AtomicType객체를 생성해서 해당 AtomicType객체에 대한 동시성을 제어하는 것이다.
- 새롭게 생성할 수 없는 static변수나 기존 변수에 동시성을 제어하기 위해선 synchoronized 키워드를 붙여서 사용한다.
2. volatile 키워드
- Multi Thread환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생한다.
- volatile 변수를 사용하고 있지 않는 MultiThread 어플리케이션에서는 Task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache에 저장한다.
- volatile keyword는 Java 변수를 CPU cache가 아닌 Main Memory에 저장하겠다라는 것을 명시하는 것.
- 즉, volatile 키워드는 CPU cache의 사용을 막는 것
- 매번 변수의 값을 Read할 때마다 CPU cache에 저장된 값이 아닌 Main Memory에서 읽는 것.
- 또한 변수의 값을 Write할 때마다 Main Memory에 까지 작성하는 것.
- 매 번 메모리에 접근해서 실제 값을 읽어오도록 설정해서 캐시 사용으로 인한 데이터 불일치를 막는다
- 다만, 성능을 위한 CPU 캐시를 비활성화하고 매번 메인 메모리에 접근하기 때문에 어느정도의 성능 저하가 필연적으로 발생한다.
언제 volatile을 사용하는 것이 적합할까?
- Multi Thread 환경에서 하나의 Thread만 read & write하고 나머지 Thread가 read하는 상황에서 가장 최신의 값을 보장
- 이 경우 read만 하는 스레드는 CPU 캐시를 사용하고 다른 스레드가 write한 값을 즉각적으로 확인하지 못한다.
public class ViewCounter {
private volatile int counter = 0;
}
3. Atomic 클래스
- Java 1.5 버전 이상 부터는 java.util.concurrent 라는 동시성 제어 유틸리티 패키지를 제공
- java.util.concurrent : 동시성 제어 유틸 & 동시성 제어가 적용된 Collections 클래스들
- java.util.concurrent.atomic : Boolean, Integer, Long 및 참조타입 의 동시성 제어 클래스들
- java.util.concurrent.locks : 읽기, 쓰기관련 동시성 제어 유틸
- Atomic 클래스는 *CAS(compare-and-swap)를 이용하여 동시성을 보장
- CAS(compare-and-swap)
- 특정 메모리위치의 값이 주어진 값과 동일하다면 해당 메모리 주소를 새로운 값으로 대체.
- 이 연산은 atomic이기 때문에 새로운 값이 최신의 정보임을 보장
- 만약 값 비교 와중에 다른 스레드에서 그 값이 업데이트 되어 버리면 쓰기는 실패
- CAS(compare-and-swap)
- AtomicInteger는 synchronized 보다 적은 비용으로 동시성을 보장
public class ViewCounter {
private AtomicInteger count = new AtomicInteger();
public int view() {
return count.getAndIncrement(); // 값을 먼저 리턴하고 후에 증가. count++
// return count.incrementAndGet(); // 값을 먼저 증가하고 후에 리턴. ++count
}
}
- getAndIncrement(); // 값을 먼저 리턴하고 후에 증가. count++
- incrementAndGet(); // 값을 먼저 증가하고 후에 리턴. ++count
이외의 불변 객체
불변 객체 (Immutable Instance)
- 스레드 안전한 프로그래밍을 하는 방법중 효과적인 방법은 불변 객체(Immutable)를 만드는 것.
- String 객체처럼 한번 만들면 그 상태가 변하지 않는 객체를 불변객체.
- 불변 객체는 락을 걸 필요가 없다.
- 불변 객체는 생성자로 모든 상태 값을 생성할 때 세팅하고, 객체의 상태를 변화시킬 수 있는 부분을 모두 제거해야 합니다.
- 가장 간단한 방법은 세터(setter)를 만들지 않는 것
- 또한 내부 상태가 변하지 않도록 모든 변수를 final로 선언
참조
https://devwithpug.github.io/java/java-thread-safe/
https://devkingdom.tistory.com/276
https://nesoy.github.io/articles/2018-06/Java-volatile
'Java' 카테고리의 다른 글
에러와 예외 (Error, Exception) (1) | 2022.11.26 |
---|---|
Java - double brace (생성과 동시에 초기화, 함수호출) (0) | 2022.11.20 |
Java List to String array. 리스트를 스트링으로 (0) | 2022.11.14 |
Java interface 사용 이유, interface의 장단점. (2) | 2022.09.29 |