본문 바로가기

Java

[Java] 멀티스레드와 동시성

CPU 코어 

실제로 스레드를 동작시키는 주체입니다. 이 CPU 코어는 정말 빨라서 여러 스레드를 번갈아가며 실행시켜도 사람이 느끼기에는 동시에 스레드가 동작하고 있다고 착각할 정도로 빠릅니다.

이때 실제로 동시에 실행시키진 않지만 동시에 실행시키는 것 처럼 느끼게 하는것이 동시성입니다.

이와 반대로 실제 다수의 CPU 코어로 동시에 스레드를 동작시키는것은 병렬성입니다.

 

CPU 스케줄링

운영체제는 내부에 스케줄링 큐라는것을 가지고있습니다. 이 스케줄링 큐안에 스레드가 들어가 대기하고 있으면 CPU 코어가 스케줄링 큐안에 들어있는 스레드를 가져다가 동작시키는 원리입니다.

 

Context Switching

병렬성을 설명할 때, 여러 스레드를 하나의 코어에서 번갈아가며 실행시킨다고 했습니다. 이때 CPU 코어가 스레드를 번갈아가며 실행하기 위해 CPU 코어에 할당된 정보( 메모리, 스레드 처리에 필요한 정보등 ) 들을 비우고 새로운 스레드의 정보로 채우는 과정을 "Context Switching"이라고 합니다.

이는 CPU 코어가 스레드 숫자에 비해 적다면 자주 발생하게 되는데, 교체하는 작업은 CPU의 입장에선 아무래도 성능저하의 주요원인이 될 수 있습니다.

 

자바의 메모리 구조

스레드를 제대로 이해하기 위해선 메모리 구조를 알아야합니다.

해당 글은 자바를 기준으로 함으로 자바의 메모리 구조를 설명하겠습니다.

 

Method

메서드 영역은 프로그램을 실행하는데 필요한 공통 데이터를 관리합니다. 이 영역은 프로그램의 모든 영역에서 공유를 합니다.

클래스 정보 : 클래스의 실행 코드, 필드, 메서드와 생성자 코드등
Static 영역 : static 변수들
런타임 상수 풀 : 프로그램을 실행하는데 필요한 공통 리터럴 상수들

 

Stack

각 Stack Frame은 지역변수, 중간 연산결과, 메서드 호출정보를 포함합니다. 각 스레드별로 하나의 실행 Stack 영역이 생성됩니다. 따라서 스레드의 개수만큼 Stack영역이 생성됩니다.

메서드를 호출할 때 마다 하나의 스택프레임이 쌓이고, 
메서드가 종료되면 해당 스택 프레임이 제거됩니다.

 

Heap

객체와 배열이 생성되는 영역이다. GC가 이뤄지는 주요영역이며, 더이상 참조되지 않는 객체는 GC에 의해 제거됩니다.

동적으로 생성되는 변수들이 포함 ex) new

 

Thread

스레드란 프로세스안에서 동작하는 하나의 실행단위입니다. 이 스레드는 각각 Stack 영역을 할당받고, 프로세스 안에서 Stack 영역을 제외한 나머지 영역을 공유합니다.

프로세스는 하나 이상의 스레드가 존재하는데, 이런 메인 스레드말고도 데몬 스레드라는 스레드도 존재합니다.

스레드는 일반적으로 사용자 스레드와 데몬 스레드 2가지로 구분할 수 있습니다.

 

사용자 스레드

  • 프로그램의 주요 작업을 수행한다.
  • 작업이 완료될 때까지 실행된다.
  • 모든 사용자 스레드가 종료되면 JVM도 종료된다.

데몬 스레드

  • 백그라운드에서 보조적인 작업을 수행한다.
  • 모든 사용자 스레드가 종료되면 데몬스레드는 자동으로 종료된다.

JVM 은 데몬 스레드의 실행 완료를 기다리지않고 종료됩니다. 데몬 스레드가 아닌 모든 스레드가 종료되면 자바 프로그램도 종료됩니다.

 

Thread의 생명주기

스레드는 다음과 같은 생명주기가 존재합니다.

Runnable : 스레드가 실행중이거나 실행될 준비가 된 상태

Blocked : 스레드가 동기화 락을 기다리는 상태

Waiting : 스레드가 무기한으로 다른 스레드의 작업을 기다리는 상태

Timed Waiting : 스레드가 일정시간 동안 다른 스레드의 작업을 기다리는 상태

 

메모리 가시성

하나의 스레드에서 변경한 값이 언제 다른 스레드들에게 적용이 되는지에 대한 문제를 메모리 가시성이라고 합니다.

하나의 프로세스 내의 여러 스레드들은 공유자원(Heap, Method)에 접근하여 값을 변경하면 공유자원의 값(메인 메모리)을 변경하는 것이 아닌 본인이 가지고있는 값(각 스레드에 할당 된 캐시 메모리)만 바꿉니다.

 

또 이렇게 바꾼 캐시메모리의 값이 언제 메인 메모리에 적용이 되는지는 CPU의 정책마다 다릅니다.

일반적으로는 Context Switching이 일어날 때 캐시를 비우고 다시 가져와야하기때문에 바뀝니다.

자바에서는 메인 메모리에 바로 접근할 수 있게 volatile 키워드를 제공합니다.

 

동시성 문제

모든 스레드가 공유하는 자원이자 함부로 변경되면 타 스레드에 영향이 가는 영역을 임계영역이라 부릅니다.

이런 임계영역에 동시에 여러 스레드가 접근하여 값을 변경 및 참조하게되면 원하는 결과가 아닌 다른결과가 나올 가능성이 높습니다.

그렇기 때문에 이런 임계영역에 하나의 혹은 N개의 스레드만 들어갈 수 있게 Lock 이라는 개념이 탄생하였습니다.

 

모든 객체 인스턴스는 락 한개와 Waiting 상태의 스레드가 대기할 수 있는 큐를 지니고 있습니다.

 

자바에서 제공하는 synchronized 키워드를 붙이게되면 해당 객체 인스턴스의 Lock을 가지고가서 본인의 작업이 끝날 때 까지 Lock을 반환하지 않습니다.

따라서 synchronized 로 동시성 문제를 해결 할 수 있습니다. 또한 synchronized 는 메서드에 달 수 있을 뿐아니라 메서드 내의 코드 블럭에도 적용할 수 있습니다. 아무래도 Lock을 반납하기 전까지는 해당 객체 인스턴스에 synchronized 가 붙은 영역은 접근할 수 없으니 빠르게 반납하기위해 동시성문제가 발생할만한 곳에만 synchronized 키워드를 사용하는것이 좋습니다.

 

비관적 락과 낙관적 락

데이터의 일관성을 유지하기 위해 나온 개념들입니다. 보통 데이터베이스에서도 볼 수 있는 개념이고, 멀티스레딩 기술에서 데이터의 일관성을 유지하기 위해 나오는 개념이기도 합니다.

 

비관적 락은 충돌날 것을 비관적으로 가정하고 데이터를 처음부터 잠그는 방식입니다.

그래서 스레드 관점에서 본다면 데이터에 접근하는 스레드가 락을 가지고 데이터에 접근을 하며 이것은 Lock 을 거는 방법입니다.

 

낙관적 락은 충돌날 것을 낙관적으로 가정하고 실제 충돌 발생 시에 대응하는 방법입니다. 이것은 예시로 CAS 연산이라는 방법이 있고, 말 그대로 접근하려는 데이터가 다른 스레드가 건들여 기대하는 데이터값이 아니라면 실패하는 방식을 사용합니다.

CAS는 Lock을 얻을 때까지 계속 loop을 돌며 확인하기 때문에 cpu를 계속 쓰게됩니다. 
따라서 오래 걸리는 로직에는 사용하면 CPU를 계속 사용하여 성능에 치명적인 타격을 입힙니다.

일반적으로 데이터의 충돌이 많다면 비관적 락을 사용하는 게 옳지만 
데이터의 충돌이 적으면 적을수록 낙관적 락을 통해 성능을 이득 볼 수 있습니다.

 

 

Producer- Consumer

작업을 요구하는 Producer 와 작업을 받아 수행하는 Consumer의 관계는 코드상에서 많이 찾아볼 수 있는 관계입니다.

하지만 해당 관계에서 여러가지 문제점이 발생하는데, Producer의 작업을 요구가 너무 많아 Consumer가 감당하기 힘든경우 혹은 Producer의 작업 요구가 너무 없어 Consumer가 대기만 하는 경우의 문제점이 발생합니다.

 

언뜻보면 별 문제가 없어보이지만 스레드의 영역으로 내려오고부터는 성능문제와 관련하여 여러 비효율적인 상황이 생기게됩니다.

 

Producer의 작업을 요구가 너무 많아 Consumer가 감당하기 힘든경우

  • 작업을 요구하기 위해 Producer Thread가 왔는데 처리할 수 있는 Consumer Thread가 없어 처리하지 못합니다.
  • 처리할 수 없으니 Producer Thread는 작업 실패 처리와 동시에 Thread는 Terminate 상태에 빠지게됩니다.
  • 하지만 Consumer는 곧 작업이 끝날거였기 때문에 아주 조금만 기다리면 Producer 가 요구하는 작업을 해낼 수 있었습니다.

 

Producer의 작업 요구가 너무 없어 Consumer가 대기 만 하는 경우

  • Producer Thread의 작업을 기다리는 Consumer Thread들은 작업이 있는지 확인하기위해 Buffer를 확인합니다.
  • Buffer를 확인했는데 요청된 작업이 없기에 Consumer Thread들은 모두 Terminate 상태에 빠지게됩니다.
  • 하지만 Producer 는 곧 작업을 요구하였을 것이기 때문에 아주 조금만 기다리면 Consumer 작업을 해낼 수 있었습니다.

 

두 경우 모두 아주 조금만 기다리면 각각의 스레드는 제 역할을 할수 있었습니다.

이런 문제를 해결하기 위해 대기할 수 있는 큐가 생깁니다.

 

모든 객체 인스턴스는 락 한개와 Waiting 상태의 스레드가 대기할 수 있는 큐를 지니고 있습니다.

 

위에서 보았던 문장입니다. 모든 객체 인스턴스는 Waiting 상태의 스레드가 대기할 수 있는 큐 를 지니고 있습니다.

따라서 스레드가 Terminate 상태가 되지않고, Waiting 상태로 해당 큐에서 대기할 수 있습니다.

그리고 스레드가 필요할 때 notify() 를 통해 해당 큐에서 스레드를 사용할 수 있습니다.

 

하지만 기본적으로 자바에서 제공하는 는 각 객체인스턴스마다 하나밖에 없기때문에 효율적으로 producer 와 consumer 스레드를 호출하기 어렵습니다.

 

따라서 ReentrantLock 라이브러리가 나오게 되었고, 각각의 큐를 producer와 consumer의 역할에 맞게 만들 수 있습니다.

또한 이 ReentrantLock 라이브러리를 통해 동시성 문제를 방지한 Queue 가 BlockingQueue 입니다.

 

CAS

Compare And Swap의 약자로 "하드웨어가 제공하는 락"을 통해 원자성 연산을 처리하는 방식입니다.

이제까지 설명한 Lock을 통해 비관적으로 동시성 문제를 해결한 방법과는 달리 낙관적으로 해결하게됩니다.

 

내가 예상한 값이 맞는지 판단

  • 맞다면 다른 스레드는 Target 메모리를 못건들이게하고 값을 변경한다.
  • 틀리다면 false를 반환한다.

여기서 보통 CAS 연산은 false를 뱉었을 시, loop를 돌면서 Compare And Swap 과정이 성공할 때까지 거칩니다.

따라서 그 동안 CPU의 리소스를 태우고있기 때문에 오래걸리는 작업은 지양해야합니다.

 

Concurrent

자바는 멀티스레딩을 고려해서 나온 언어인 만큼 선배 자바 개발자들이 많은 라이브러리를 남겨두었습니다.

그 중 다양한 자료구조들을 멀티스레딩 관점에서 문제가 발생하지 않도록 만들어둔 라이브러리들이 존재합니다.

 

List

  • CopyOnWriteArrayList : ArrayList의 대안

Set

  • CopyOnWriteArraySet : HashSet의 대안
  • ConcurrentSkipListSet : TreeSet의 대안

Map

  • ConcurrentHashMap : HashMap의 대안
  • ConcurrentSkipListMap : TreeMap의 대안

 

Thread Pool

스레드의 생성은 일반적인 객체를 만드는 작업과는 비교할 수 없을정도의 무거운 작업입니다.

 

메모리할당

  • 각 스레드는 자신만의 호출 스택을 가지고있어야합니다. 이 호출 스택은 스레드가 실행되는 동안 사용하는 메모리공간입니다. 즉, 호출 스택을 위한 메모리를 할당해야합니다

운영체제

  • 스레드를 생성하는 작업은 운영체제 커널 수준에서 이루어지며, 이는 CPU 자원을 사용합니다.

운영체제 스케줄러

  • 새로운 스레드가 생성되면 운영체제의 스케줄러는 이 스레드를 관리하고 실행 순서를 조정해야합니다.

따라서 미리 스레드를 생성해놓은 뒤 재사용하는 방법을 고려합니다.

Executor 프레임워크

Executor 프레임워크는 간단하게 Thread Pool을 설정하고 만들 수 있게 해줍니다.

기본 구현체는 ThreadPoolExecutor이고 설정할 수 있는 대표적인 값들은 다음과 같습니다.

  • corePoolSize : Thread Pool에서 관리되는 기본 Thread의 개수
  • maximumPoolSize : Thread Pool에서 관리되는 최대 Thread의 개수
  • keppAliveTime, timeUnit : corePoolSize를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시간, 이 시간동안 처리할 작업이 없다면 초과 스레드는 제거됩니다.
  • bockingQueue : 스레드가 대기하는 Queue (Producer - Consumer 문제 시 보았던 BlockingQueue)

maximumPoolSize

corePoolSize에서 설정한 스레드의 개수만큼 스레드가 동작중이고, bockingQueue에 Waiting 스레드가 가득차 있을경우 maximumPoolSize까지 관리되는 스레드의 개수가 증가합니다.

또한 maximumPoolSize를 초과하는 요청이 들어온다면 RejectedExecutionException이 발생합니다.

그리고 keppAliveTime 동안 초과된 스레드가 하는일이 없다면 제거가됩니다.

 

Future

Callable 객체를 Executor 에 던지게(submit, invoke..)되면 Future 라는 객체를 반환해줍니다.

이 Future안에는 원하는 "스레드처리 시 반환되었으면 하는 리턴값" 이 담겨있고 스레드가 작업을 완료했다면 원하는 값이, 그렇지 않다면 값을 요청한 스레드에서 값이 나올때까지 Blocked 상태가되어 기다리게됩니다.

 

따라서 Future 객체의 값을 기대할 때는 충분히 스레드가 작업을 완료했을 시점에 반환받는것이 좋습니다.

 

Thread Pool의 종료

서버를 재시작해야될 시, 클라이언트의 요청을 처리하고있는 도중에 서버가 재시작이 된다면 안전하게 요청을 수행하지 못합니다. 따라서 안전하게 서비스를 종료하는것은 매우 중요한데, 이를 Graceful Shutdown 이라고 합니다.

 

Thread Pool을 종료하는 방법

  • void shutdown() : 새로운 작업을 받지 않고, 이미 제출된 작업을 모두 완료한 후에 종료합니다. (Non-Blocking Method)
  • List<Runnable> shutdownNow() : 실행중인 작업을 중단하고, 대기중인 작업을 반환하며 즉시 종료합니다. (Non-Blocking Method)
  • boolean awaitTermination(long timeout, TimeUnit unit) : 모든 작업이 완료될 때까지 대기합니다. (Blocking Method)

위와 같은 다양한 메서드를 통해 더이상 요청을 받지않고, 이미 들어온 요청은 처리하려고 노력은 하되 이상현상이 발생해 이미 들어온 요청이 비정상적으로 끝나지 않는다면 N초를 기다려 준 뒤, Interrupt를 발생 시켜 종료할 수 밖에 없습니다.

여기서 Interrupt 를 통해 강제로 종료를 할 때에도 자원정리 및 예외발생처리 등을 기다려주는것 또한 필요한 작업입니다.

 


● 참고자료 : 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 (인프런 | 김영한)

 

 

'Java' 카테고리의 다른 글

[Java] 자바의 Compiler  (1) 2024.09.08
[Java] Wrapper  (0) 2023.03.15
[Java] Optional  (0) 2023.03.15
[Java] 메모리 영역과 Garbage Collection  (0) 2023.03.14
[Java] JRE, JDK, JVM  (0) 2023.03.14