메모리 하드웨어 아키텍처
최신 메모리 하드웨어 아키텍처는 Java의 내부 메모리 모델과 다릅니다. 따라서 Java 모델이 어떻게 작동하는지 알기 위해서는 하드웨어 아키텍처를 이해해야 합니다. 이 섹션에서는 일반적인 메모리 하드웨어 아키텍처에 대해 설명하고 다음 섹션에서는 Java가 작동하는 방식에 대해 설명합니다.
다음은 최신 컴퓨터의 하드웨어 아키텍처를 간략하게 나타낸 다이어그램입니다.
현대 세계에서 컴퓨터에는 2개 이상의 프로세서가 있으며 이는 이미 표준입니다. 이러한 프로세서 중 일부에는 여러 개의 코어가 있을 수도 있습니다. 이러한 컴퓨터에서는 동시에 여러 스레드를 실행할 수 있습니다. 각 프로세서 코어는 주어진 시간에 하나의 스레드를 실행할 수 있습니다. 즉, 모든 Java 응용 프로그램은 선험적으로 다중 스레드이며 프로그램 내에서 프로세서 코어당 하나의 스레드가 한 번에 실행될 수 있습니다.
프로세서 코어에는 메모리(코어 내부)에 상주하는 일련의 레지스터가 포함되어 있습니다. 컴퓨터의 주 메모리(RAM)에 있는 데이터보다 훨씬 빠르게 레지스터 데이터에 대한 작업을 수행합니다. 이는 프로세서가 이러한 레지스터에 훨씬 빠르게 액세스할 수 있기 때문입니다.
각 CPU는 자체 캐시 계층을 가질 수도 있습니다. 대부분의 최신 프로세서에는 이 기능이 있습니다. 프로세서는 메인 메모리보다 훨씬 빠르게 캐시에 액세스할 수 있지만 내부 레지스터만큼 빠르지는 않습니다. 캐시 액세스 속도의 값은 대략 주 메모리와 내부 레지스터의 액세스 속도 사이입니다.
또한 프로세서에는 다단계 캐시가 있을 수 있습니다. 그러나 이것은 Java 메모리 모델이 하드웨어 메모리와 상호 작용하는 방식을 이해하기 위해 아는 것이 그다지 중요하지 않습니다. 프로세서에 일정 수준의 캐시가 있을 수 있음을 아는 것이 중요합니다.
모든 컴퓨터에는 동일한 방식으로 RAM(메인 메모리 영역)이 포함되어 있습니다. 모든 코어는 메인 메모리에 액세스할 수 있습니다. 주 메모리 영역은 일반적으로 프로세서 코어의 캐시 메모리보다 훨씬 큽니다.
프로세서가 메인 메모리에 접근해야 하는 순간, 프로세서는 그 일부를 캐시 메모리로 읽어들입니다. 또한 캐시에서 내부 레지스터로 일부 데이터를 읽은 다음 작업을 수행할 수 있습니다. CPU가 결과를 메인 메모리에 다시 써야 할 때 내부 레지스터에서 캐시로 데이터를 플러시하고 어느 시점에서 메인 메모리로 플러시합니다.
캐시에 저장된 데이터는 일반적으로 프로세서가 캐시에 다른 것을 저장해야 할 때 메인 메모리로 다시 플러시됩니다. 캐시에는 메모리를 지우는 동시에 데이터를 쓸 수 있는 기능이 있습니다. 프로세서는 업데이트 중에 매번 전체 캐시를 읽거나 쓸 필요가 없습니다. 일반적으로 캐시는 작은 메모리 블록에서 업데이트되며 "캐시 라인"이라고 합니다. 하나 이상의 "캐시 라인"을 캐시 메모리로 읽어들일 수 있으며 하나 이상의 캐시 라인을 메인 메모리로 다시 플러시할 수 있습니다.
Java 메모리 모델과 메모리 하드웨어 아키텍처 결합
이미 언급했듯이 Java 메모리 모델과 메모리 하드웨어 아키텍처는 다릅니다. 하드웨어 아키텍처는 스레드 스택과 힙을 구분하지 않습니다. 하드웨어에서 스레드 스택과 HEAP(힙)는 메인 메모리에 상주합니다.
스택 및 스레드 힙의 일부는 때때로 CPU의 캐시 및 내부 레지스터에 존재할 수 있습니다. 이것은 다이어그램에 표시됩니다.
개체와 변수를 컴퓨터 메모리의 다른 영역에 저장할 수 있는 경우 특정 문제가 발생할 수 있습니다. 다음은 두 가지 주요 항목입니다.
- 스레드가 공유 변수에 적용한 변경 사항의 가시성.
- 공유 변수를 읽고 확인하고 쓸 때 경합 상태입니다.
이 두 가지 문제는 아래에서 설명합니다.
공유 객체의 가시성
둘 이상의 스레드가 휘발성 선언 또는 동기화를 적절히 사용하지 않고 개체를 공유하는 경우 한 스레드에서 공유 개체에 대한 변경 사항이 다른 스레드에 표시되지 않을 수 있습니다.
공유 객체가 초기에 메인 메모리에 저장되어 있다고 상상해 보십시오. CPU에서 실행 중인 스레드는 공유 객체를 동일한 CPU의 캐시로 읽어들입니다. 그곳에서 그는 객체를 변경합니다. CPU의 캐시가 메인 메모리로 플러시될 때까지 공유 객체의 수정된 버전은 다른 CPU에서 실행 중인 스레드에 표시되지 않습니다. 따라서 각 스레드는 공유 개체의 자체 복사본을 얻을 수 있으며 각 복사본은 별도의 CPU 캐시에 있습니다.
다음 다이어그램은 이 상황에 대한 개요를 보여줍니다. 왼쪽 CPU에서 실행 중인 한 스레드는 공유 개체를 캐시에 복사하고 count 값을 2로 변경합니다. 이 변경 사항은 count에 대한 업데이트가 아직 주 메모리로 다시 플러시되지 않았기 때문에 오른쪽 CPU에서 실행 중인 다른 스레드에서는 보이지 않습니다.
이 문제를 해결하기 위해 변수를 선언할 때 volatile 키워드를 사용할 수 있습니다. 주어진 변수가 주 메모리에서 직접 읽히고 업데이트될 때 항상 주 메모리에 다시 기록되도록 할 수 있습니다.
경쟁 조건
둘 이상의 스레드가 동일한 객체를 공유하고 둘 이상의 스레드가 해당 공유 객체의 변수를 업데이트하면 경합 상태가 발생할 수 있습니다.
스레드 A가 공유 개체의 카운트 변수를 프로세서의 캐시로 읽는다고 상상해 보십시오. 또한 스레드 B가 동일한 작업을 수행하지만 다른 프로세서의 캐시에 있다고 상상해 보십시오. 이제 스레드 A는 count 값에 1을 더하고 스레드 B는 동일한 작업을 수행합니다. 이제 변수가 각 프로세서의 캐시에서 +1씩 두 번 증가했습니다.
이러한 증가가 순차적으로 수행되면 카운트 변수는 두 배가 되어 주 메모리에 다시 기록됩니다(원래 값 + 2).
그러나 적절한 동기화 없이 동시에 두 증분이 수행되었습니다. 어떤 스레드(A 또는 B)가 업데이트된 버전의 카운트를 주 메모리에 쓰는지에 관계없이 새 값은 두 번 증가하더라도 원래 값보다 1만 더 커집니다.
이 다이어그램은 위에서 설명한 경쟁 조건 문제의 발생을 보여줍니다.
이 문제를 해결하기 위해 Java 동기화 블록을 사용할 수 있습니다. 동기화된 블록은 주어진 시간에 하나의 스레드만 주어진 코드의 중요한 섹션에 들어갈 수 있도록 합니다.
동기화된 블록은 또한 동기화된 블록 내에서 액세스되는 모든 변수가 주 메모리에서 읽혀지고 스레드가 동기화된 블록을 종료할 때 변수가 휘발성으로 선언되었는지 여부에 관계없이 업데이트된 모든 변수가 주 메모리로 다시 플러시되도록 보장합니다.
GO TO FULL VERSION