백엔드 엔지니어 이재혁
[CS] CPU 캐시 메모리의 흥미로운 점들 본문
CPU 캐시 메모리의 캐싱은 내가 알고 있던 캐싱과 미묘하게 다른 흥미로운 점을 가지고 있었다.
일반적으로 캐싱은 "이미 접근한 적 있는" 데이터를 다시 가져올 때, 빠르게 가져오도록 해주는 것으로 익숙해져 있었다. 그런데 CPU의 캐시 메모리는 현재 접근한 데이터를 포함해서 다음 주소에 있는 데이터들도 가져와서 빠르게 접근할 수 있도록 설계되어 있다.
CPU 캐시 메모리

일반적으로 CPU 코어마다 L1/L2 캐시가 존재하며 일부(혹은 모든) 코어간 공유하는 L3 캐시가 존재한다. (CPU마다 다름)
CPU는 메인 메모리의 특정 주소값의 데이터를 불러올 때, 해당 주소값과 그 이후 일정량의 이후 주소값까지의 데이터를 한 뭉텅이로 가져와 L3, L2, L1 캐시에 불러온다. L3 > L2 > L1 캐시로 내려갈 수록 용량이 적다. 속도는 반대로 L1이 제일 빠르다.
CPU는 이런 메모리 계층 구조를 통해 병목 현상을 줄이고, 자주 사용하는 데이터를 더 가까운 캐시에 저장해 빠르게 접근한다.
CPU 캐시에 관한 내용은 아래 글에 너무 잘 정리되어 있었다!
reference: https://beatmejy.tistory.com/15
CPU 캐시에 대해 알아보자!
안녕하세요. 이번시간에는 CPU 캐시에 대해서 알아보도록 하겠습니다. CPU 캐시에 대해 알아보기 전에 우선 CPU에 대해서 간단하게 살펴볼까요?👀 ✔️ CPU 란? (Central Processing Unit) 📖 위키백과에
beatmejy.tistory.com
메모리 가시성: 스레드와 CPU 캐시 메모리
CPU는 모든 정보를 메인 메모리 (RAM)에서 가져오는 것이 아니라, CPU의 캐시 메모리도 활용하여 메인 메모리 대신 참조할 때도 있다. 보통은 코어 단위로도 캐시 메모리가 있다. 같은 인스턴스에 대해 작업을 하더라도, 그 객체를 각각의 코어에 있는 캐시 메모리에 복사해서 사용할 수도 있다는 것이다.
다음과 같이 main 스레드를 돌리고 있는 코어에서 자신의 runFlag를 바꿔봐야 해당 코어의 캐시 메모리에만 반영되어 다른 스레드에서는 아직 해당 변경을 모르는 순간이 발생할 수 있다.

자신의 캐시 메모리에 있는 데이터를 수정하게 되면 메인 메모리에도 반영해야 하고, 또 메인 메모리의 값이 바뀌게 되면 그것을 감지하고 다른 코어의 캐시를 그에 맞게 동기화시켜줘야 한다. 그런 작업에는 시간이 필요하다. 따라서, 같은 시점에 모든 스레드가 무조건 동일한 인스턴스 상태를 가지고 있다고 보장할 수 없다.
조금 더 짧게 설명하자면, 스레드에서 참조하는 인스턴스 메모리 주소가 코어마다 다를 수 있고 (캐시 메모리를 참조하는 경우), 따라서 하나의 인스턴스가 스레드/코어에 따라 상태가 달라질 수 있다.
CPU의 캐시메모리 동기화 작업은 프로그래머가 완벽하게 제어하기 어렵다. 그래서 Java에서는 대안으로 메인 메모리 사용을 강제하는 volatile 키워드를 사용한다.
AMD ZEN3 L3 캐시 구조와 메모리 가시성
이제는 아래와 같이 CPU 구조가 단일 L3 캐시로 변경되었다는 설명을 보고나서 생각해볼 거리가 생겼다.

후자의 L3캐시는 모든 코어를 동시에 커버해주게 바뀌었는데, 그렇다면 Java의 volatile 키워드를 사용해도 운영체제(혹은 JVM... 아직은 여기까진 모르겠다)에서 메인 메모리 대신 L3 캐시를 통해 메모리 가시성을 보장해줄 수도 있지 않을까? 라는 의문도 들었다.
찾아보니 JVM은 하드웨어 캐시 계층의 동작을 신뢰하지 않고, 명확하게 "메인 메모리"를 기준으로 가시성을 정의한다고 한다. 스레드에 대해서 공부했을 때, 많은 부분에서 구체적인 동작 방식은 OS의 CPU 스케쥴러에게 맡긴다고 배웠는데, 아직 OS 레벨에서 CPU 캐시 메모리의 구성에 따라 메모리 가시성을 최적화해주는 방식이 구현되어 있지 않아서 그렇지 않을까 추측한다.
코드 작성시 성능 최적화
추가적으로 CPU 캐싱을 고려하면 반복문 코드레벨에서도 약간의 추가 최적화가 가능하다.
CPU가 메인 메모리에 접근하는 비용은 매우 크기 때문에 메인 메모리에서 데이터를 불러올 때, 꼭 그 데이터만 가져오는 것이 아니라 접근하는 메모리 주소부터 시작해서 일정 구역을 가져오도록 되어 있다고 한다.
따라서, 반복문을 실행할 때는, 실제 메모리 주소가 연속되는 형태로 실행해주는 것이 좋다.
아래와 같이 배열의 메모리 주소 순서대로 사용하는 것이 좋고
for (int i = 0; i < MAX_I; i++) {
for (int j = 0; j < MAX_J; j++) {
sum += arr[i][j];
}
}
아래와 같이 메모리 주소를 건너 뛰는 형태로 사용하는 것은 CPU 캐싱을 제대로 활용하지 못할 가능성이 높다.
for (int i = 0; i < MAX_I; i++) {
for (int j = 0; j < MAX_J; j++) {
sum += arr[j][i]; // i와 j 위치가 뒤집힘
}
}
사실 위와 같은 코드는 처음부터 저렇게 짤리는 없고, 기존 코드에서 순서를 뒤집어야 할 때 가끔 저런 적이 있는데, 잠깐 사용해보는 것이 아니라 계속 돌릴 코드라면 배열 순서를 맞춰주는 방향으로 가야겠다.
이론적으로는 이렇지만, 자료 구조와 CPU의 아키텍처에 따라 달라질 수 있으니 실무에서는 벤치마킹을 하는 것이 중요하다. 코드, 자료구조와 CPU 캐시에 연관된 글은 아래 글을 참고했는데, 정말 좋았다.
reference: https://frogred8.github.io/docs/014_cache_line/
[etc] 2차원 배열의 반복문에서는 왜 j,i보다 i,j가 빠른가
A modern, high customizable, responsive Jekyll theme for documention with built-in search.
frogred8.github.io
마무리
CS는 평생 공부해야 할 것 같다는 생각이 들던 순간이었다.
요즘은 서버를 다 클라우드에서 돌리기 때문에 CPU 스펙을 자세히 볼 일이 있을까 싶지만... 만약, 내가 온프레미스 서버를 구축한다고 하면 스레드에서 사용하는 최대 크기 배열 사이즈 (1차원 기준)를 고려해서 L1 캐시를 맞추면 좋을 것 같다는 생각도 해보고... (물론 비용도 고려사항...)
램의 느린 속도를 보완하기 위해 CPU에 캐시 메모리가 따로 있다고는 알고 있었는데, 실제로 어떻게 동작하는지 알게되니 스레드의 관점에서도 바라보게 되고, 알고리즘 측면에서도 바라보게 되고... 지식들이 이렇게 합쳐지는 순간이 오는게 재밌다.
'흥미로운 것들' 카테고리의 다른 글
| 진짜 REST API와 HATEOAS (0) | 2025.07.13 |
|---|---|
| [Java] MapN의 비트 연산 사용 (0) | 2025.06.18 |
| [Java] List.of() 메서드 (0) | 2025.06.18 |
| [Java] 멤버 변수를 굳이 지역 변수에 복사해서 쓰는 이유가 뭘까? (0) | 2025.06.06 |
| [Java] Java 언어와 JVM (1) | 2025.06.05 |