volatile은 언제 사용해야 하는가
일단 내가 기본적으로 알고 있던 volatile의 특징은 다음과 같았다.
변수가 바뀔 때 캐시가 아니라 항상 메인 메모리를 거치게 만드는 게 volatile이고, 읽기와 쓰기 모두 여기에 해당한다.
하지만 그렇다면 최대한 많은 객체들에 volatile을 많이 붙이는 게 안전하지 않나는 생각이 들게 되었고, 이에 대해 정리한 내용을 공유하고자 한다.
일단 이 생각은 틀렸다.
왜 이 생각이 틀렸는가
첫째, volatile을 붙인다고 해서 객체 내부 필드의 가시성까지 보장되는 건 아니다. 참조 자체의 가시성만 보장할 뿐이고, 그 객체 내부는 따로 불변 객체로 만들거나 락 메커니즘으로 개발자가 직접 보호해야 한다.
둘째, “변수가 바뀔 때 캐시가 아니라 메인 메모리를 거친다”는 것도 냉정하게 보면 사실과 조금 다르다. volatile을 써도 캐시 자체는 여전히 쓰인다. 캐시를 우회해서 메인 메모리로 직행하는 게 아니라, fence가 store buffer를 비우고 다른 코어의 캐시를 무효화시키는 과정(후에 설명할 MESI)을 강제로 트리거하는 것에 가깝다. “메인 메모리를 거친다”는 이 두 메커니즘이 만들어내는 결과를 뭉뚱그려 표현한 것이었다.
그럼 언제 필요한가
여러 스레드가 공유 가능한 객체라면 volatile을 써야 하는가? 대략적으로는 맞다.
다만 그렇다면 전역 객체나 static 변수는 항상 volatile을 써야 하는지 헷갈릴 수 있다. 우선 관련은 있지만 100% 필요하진 않다. 예를 들어 static 변수는 존재하는 이상 모든 스레드가 그 static을 바라볼 수밖에 없긴 하지만, 실제로 write 하는 곳이 없다면 굳이 volatile을 붙일 필요는 없다(전역 객체도 마찬가지).
또한 역으로, 예를 들어 공유 큐에 담겨 여러 스레드가 접근할 수 있는 객체는 전역도 static도 아니지만 volatile이 필요할 수 있다.
그렇다면 “여러 스레드가 공유 가능한 객체고, 객체에 write도 한다면 volatile을 써야 하는가?”는 어떨까. 위에서 정리한 대로 이런 경우는 쓰는 게 좋겠지만, volatile에는 당연히 비용이 따른다.
volatile의 비용
비용은 크게 세 가지로 정리할 수 있다.
1. 하드웨어 레벨
CPU는 store 명령을 실행해도 바로 캐시나 메모리에 반영하지 않고, store buffer라는 내부 큐에 넣은 뒤 다음 명령으로 넘어간다(아마 성능 때문). 그리고 fence라는 명령은 이 store buffer에 남아 있는 내용을 전부 캐시로 흘려보내기 전까지 다음 명령을 실행하지 못하게 강제한다.
volatile 필드에 쓰기를 하면 JVM은 컴파일된 기계어에 이 fence 명령을 자동으로 끼워 넣는다. 따라서 오래 걸린다.
2. JIT 컴파일러 최적화 불가능
JIT는 성능을 위해 캐시까지 갈 것도 없이 레지스터를 사용한다. 근데 volatile을 사용하면 이 레지스터 캐싱 최적화를 막을 수밖에 없다. 매번 실제 메모리(캐시)에서 다시 읽어야 하기 때문이다.
3. 캐시 사이의 동기화를 트리거해야 함
먼저 캐시 라인을 알아야 한다. 캐시는 메모리를 1바이트씩 갖고 오지 않는다. 당연히 성능을 위해서다. 대신 정해진 크기의 덩어리 단위로 한 번에 가져오는데, 이 덩어리를 캐시 라인이라 하고 보통 크기는 64바이트다(x64 기준).
이런 캐시 라인이 가질 수 있는 속성들의 앞글자를 따서 MESI라는 개념이 도입됐다. 각 코어가 값을 다시 갖고 와야 할지, 혹은 무효화됐는지 판단할 수 있는 기준이고, 코어마다 각자 갖고 있다.
- Modified: 나만 갖고 있고, 내가 고쳤음(메모리보다 최신)
- Exclusive: 나만 갖고 있고, 메모리랑 똑같음(아직 안 고침)
- Shared: 여러 코어가 같이 갖고 있고, 다 똑같음
- Invalid: 내 복사본은 못 씀, 다시 받아와야 함
이런 상태들이 존재하는 이유는 당연히 정확한 값을 갖기 위해서고, 그 정확성을 지키는 방식 자체가 프로토콜(코어 간 통신)로 구현돼 있다. 그래서 프로토콜이 트리거되기에 오래 걸린다.
마치며
지금까지 volatile이 필요한 경우와 이때 고려해야 할 비용에 대해 정리해보았다. 사실 volatile은 가시성에 대한 측면만 보장해주기에 완전한 sync를 위해서는 락 개념이 반드시 필요할 수밖에 없다. 이는 후속 글에서 정리해보겠다.