instanceof와 캐스팅은 런타임 타입에 따라 동작을 달리해야 하는 코드에서 자주 쓰입니다. 특히 인터페이스나 상위 타입으로 다루던 객체를 특정 기능에 맞게 좁혀 처리해야 할 때 필요합니다.
대부분의 경우 instanceof나 캐스팅은 성능 문제가 없습니다. 다만 멀티스레드 환경에서 인터페이스 타입 체크가 특정 패턴으로 반복되면 코어 수를 늘려도 처리량이 잘 오르지 않는 스케일링 병목이 생길 수 있습니다.
이 문제는 애플리케이션 로직보다 JVM 내부 최적화와 캐시 일관성 비용이 원인이라서 겉으로는 추적이 까다로울 수 있습니다.
이 글에서는 HotSpot 기준으로 instanceof 와 캐스팅(이 글에서는 합쳐서 타입 체크로 명칭)에서 어떤 병목이 생길 수 있었는지 설명드리고 어떤 버전에서 개선이 진행되었는지 정리하겠습니다.
성능 문제가 발생하는 상황
instanceof나 캐스팅을 썼다고 해서 항상 성능 이슈가 생기지는 않습니다. 문제가 되는 쪽은 다중 인터페이스를 구현한 객체를 상위 인터페이스 기준으로 타입 체크가 반복되는 상황입니다.
반복이라고 해서 "루프문에서만 타입 체크 안하면 되는거 아니야?" 라고 생각할 수 있습니다.
하지만 멀티쓰레드 환경에서는 여러 스레드가 같은 종류의 객체를 동시에 처리하면서 결과적으로 타입 체크가 매우 자주 일어날 수 있습니다. 특히 같은 종류의 객체에 대해 서로 다른 인터페이스 체크나 캐스팅이 번갈아 섞이면 코어 수를 늘려도 처리량이 기대만큼 오르지 않는 형태로 나타날 수 있습니다.
클래스 상속과 인터페이스 타입 체크의 차이
왜 이런 성능 저하가 인터페이스 타입 체크에서만 두드러질까요?
이유는 JVM이 타입 체크를 할 때 비교 대상이 클래스인지 인터페이스인지에 따라 내부 처리 방식이 달라지기 때문입니다.
클래스 상속은 부모가 하나뿐이라 JVM이 위로 따라가며 확인하면 됩니다. 확인 경로가 한 줄이라 비교적 단순합니다.
반면 인터페이스는 한 객체가 여러 개의 인터페이스를 구현할 수 있습니다. 그래서 JVM은 특정 인터페이스를 구현했는지 확인하려면 구현한 인터페이스들 중에 그 인터페이스가 있는지 찾아야 합니다. 구현한 인터페이스가 많아질수록 확인해야 할 대상도 늘어날 수 있습니다.
이 차이 때문에 HotSpot은 인터페이스 타입 체크를 빠르게 처리하려고 최근 타입 체크 결과를 캐시(secondary_super_cache)에 저장하는 최적화를 사용해 왔습니다.
캐시(secondary_super_cache)란 무엇인가
여기서 말하는 캐시는 일반적인 애플리케이션 캐시가 아니라, HotSpot JVM 내부 구현에서 사용하는 캐시를 뜻합니다.
HotSpot은 인터페이스 타입 체크를 빠르게 처리하기 위해 secondary_super_cache라는 1칸짜리 저장 공간을 두었고, 보통 캐시(SSC)라고 부릅니다.
이 캐시가 존재하는 이유는 인터페이스는 한 객체가 여러 개를 구현할 수 있기 때문에, 특정 인터페이스를 구현했는지 확인하려면 구현 인터페이스 목록에서 모두 다 뒤져서 찾아야 합니다. 구현한 인터페이스가 많아지면 이 확인 비용이 커질 수 있습니다. HotSpot은 이런 비용을 줄이기 위해 클래스 메타데이터인 Klass 안에 최근에 타입 체크했던 인터페이스 1개를 저장해두고, 다음 타입 체크에서 빠르게 재사용할 수 있게 만들었습니다.
왜 캐시가 성능 문제가 될까
문제는 타입 체크가 자주 반복되거나, 멀티쓰레드 환경에서 같은 클래스에 대해 서로 다른 인터페이스 체크가 섞일 때 잘 드러납니다. 해당 문제는 JDK-8180450 으로 공식으로 지정되었습니다.
JDK-8180450 공식 문서에서는 Klass::secondary_super_cache의 갱신이 특정 워크로드에서 과도한 cache line invalidation 트래픽을 만들 수 있고, 그 결과 스케일링이 잘 되지 않는 문제가 발생할 수 있다고 설명합니다.
이 문제의 원인은 SSC는 1칸짜리 캐시이기 때문에 타입 체크 대상 인터페이스가 바뀔 때마다 값이 변경되기 때문입니다.
단일 스레드에서 같은 인터페이스 체크가 반복되는 경우에는 도움이 되지만, 여러 스레드가 동시에 같은 클래스에 대해 서로 다른 인터페이스 체크를 번갈아 수행하면 상황이 달라집니다. 각 스레드가 자기 검사 결과를 최근 값으로 만들기 위해 SSC를 계속 갱신하게 되고, 이 과정에서 같은 메모리 위치를 두고 쓰기 경쟁이 발생합니다. 그 결과 캐시 일관성을 맞추기 위한 비용이 커지면서 캐시라인 핑퐁이 발생하고, 코어 수를 늘려도 처리량이 기대만큼 오르지 않는 병목으로 이어질 수 있습니다.
문제 재현
이 문제는 단순히 instanceof를 많이 호출한다고 재현되지 않습니다. 같은 클래스에 대해 인터페이스 타입 체크 대상이 번갈아 바뀌는 상황을 만들어야 잘 드러납니다. 그래서 벤치마크 코드는 인터페이스 케이스 2개와 클래스 상속 케이스 1개로 나눠 비교하도록 구성했습니다.
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
@Fork(2)
@State(Scope.Benchmark)
@Threads(4)
public class InstanceofBench {
// Interface test types
public interface IfaceA {}
public interface IfaceB {}
public interface DualIface extends IfaceA, IfaceB {}
public static class DualImpl1 implements DualIface {}
public static class DualImpl2 implements DualIface {}
// Class inheritance test types
public static class Base {}
public static class Sub1 extends Base {}
public static class Sub2 extends Sub1 {}
private final DualIface dual1 = new DualImpl1();
private final DualIface dual2 = new DualImpl2();
private final Base baseAsSub1 = new Sub1();
private final Base baseAsSub2 = new Sub2();
public boolean isIfaceA(DualIface value) {
return value instanceof IfaceA;
}
public boolean isIfaceB(DualIface value) {
return value instanceof IfaceB;
}
public boolean isBase(Object value) {
return value instanceof Base;
}
public boolean isSub1(Object value) {
return value instanceof Sub1;
}
// 같은 인터페이스 타입(IfaceA)만 반복 체크
@Benchmark
public void ifaceSingleTarget(Blackhole bh) {
bh.consume(isIfaceA(dual1));
bh.consume(isIfaceA(dual2));
bh.consume(isIfaceA(dual1));
bh.consume(isIfaceA(dual2));
}
// 인터페이스 타입을 번갈아가며 체크(IfaceA <-> IfaceB)
@Benchmark
public void ifaceAlternatingTargets(Blackhole bh) {
// 같은 객체를 여러번 비교하면 JVM이 최적화하기 때문에 캐시라인 핑퐁 재현 안됨 -> 객체도 번갈아가며 체크하도록 수정
bh.consume(isIfaceA(dual1));
bh.consume(isIfaceA(dual2));
bh.consume(isIfaceB(dual1));
bh.consume(isIfaceB(dual2));
}
// 상속받은 상위클래스를 번갈아가며 체크
@Benchmark
public void classAlternatingTargets(Blackhole bh) {
bh.consume(isBase(baseAsSub1));
bh.consume(isBase(baseAsSub2));
bh.consume(isSub1(baseAsSub1));
bh.consume(isSub1(baseAsSub2));
}
}
IfaceA,IfaceB는 타입 체크 대상 인터페이스입니다.DualIface는 두 인터페이스를 동시에 상속하고,DualImpl1,DualImpl2는DualIface를 구현합니다. 즉dual1,dual2는 둘 다IfaceA,IfaceB에 해당하는 객체입니다.Base,Sub1,Sub2는 클래스 상속 비교를 위한 타입입니다.baseAsSub1,baseAsSub2는 상위 타입Base로 참조하지만 실제 객체는 각각Sub1,Sub2입니다.
벤치마크 결과
Benchmark Mode Cnt Score Error Units
Sample.InstanceofBench.classAlternatingTargets sample 2464393 31.965 ± 0.306 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.00 sample ? 0 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.50 sample ? 0 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.90 sample 100.000 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.95 sample 100.000 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.99 sample 100.000 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.999 sample 100.000 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p0.9999 sample 300.000 ns/op
Sample.InstanceofBench.classAlternatingTargets:classAlternatingTargets·p1.00 sample 75136.000 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets sample 1779991 214.187 ± 0.684 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.00 sample ? 0 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.50 sample 200.000 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.90 sample 400.000 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.95 sample 400.000 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.99 sample 700.000 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.999 sample 1000.000 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p0.9999 sample 6696.083 ns/op
Sample.InstanceofBench.ifaceAlternatingTargets:ifaceAlternatingTargets·p1.00 sample 135168.000 ns/op
Sample.InstanceofBench.ifaceSingleTarget sample 2131303 32.588 ± 0.405 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.00 sample ? 0 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.50 sample ? 0 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.90 sample 100.000 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.95 sample 100.000 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.99 sample 100.000 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.999 sample 100.000 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p0.9999 sample 300.000 ns/op
Sample.InstanceofBench.ifaceSingleTarget:ifaceSingleTarget·p1.00 sample 153088.000
결과를 평균 시간 기준으로 요약
ifaceSingleTarget약 32.6 ns/opclassAlternatingTargets약 32.0 ns/opifaceAlternatingTargets약 214.2 ns/op
같은 인터페이스만 반복 체크하는 경우와 클래스 상속 체크는 비슷한 수준인데, 인터페이스 체크 대상을 번갈아 바꾸면 평균이 약 6배 이상 증가했습니다. 이는 인터페이스 타입 체크에서 최근 결과 캐시가 안정적으로 재사용되지 않으면 성능을 크게 저하할 수 있음을 보여줍니다.
해결 방법
이 문제는 JVM 내부 구현에서 비롯되었기 때문에, 가장 확실한 방법은 JDK를 23~24 버전으로 올리는 것입니다. 이 문제는 HotSpot의 인터페이스 타입 체크 경로에서 발생한 문제여서 해당 문제가 개선된 버전으로 업그레이드하면 근본적으로 영향을 줄일 수 있습니다. JDK 23부터 C2에 해당 문제를 개선한 로직이 적용되었고, JDK 24부터 C1/인터프리터까지 개선 되었습니다. 자세한 개선 사항은 JDK 24 공식문서를 확인하시기 바랍니다.
다만 JDK 23 버전 이하에서는 코드를 수정해서 우회하거나 영향도를 낮추는 방식으로 해결해야 합니다.
코드에서는 인터페이스 타입 체크가 반복되는 구조를 줄이는 것으로 해당 문제를 피할 수 있습니다. 특히 같은 객체에 대해 인터페이스 A와 인터페이스 B를 번갈아 검사하는 형태가 문제를 키우므로, 문제 재현 코드같이 한 요청 흐름에서 여러 인터페이스를 교차로 체크하는 구조를 피하는 것이 좋습니다.
만약 타입별로 다른 로직을 실행하려는 목적이라면 instanceof 체인 대신 다형성을 활용하서 타입 체크를 하도록 수정하는 것을 권장합니다. 상위 타입의 메서드 호출로 해결할 수 있는 구조라면 타입 체크 자체를 줄일 수 있고 코드도 단순해집니다.
참고자료
- JDK-8180450 secondary_super_cache does not scale well
- Inside Java Performance Improvements in JDK 24 JDK-8180450
- 멀티 스레드 환경에서 JDK-818045 문제가 발생한 케이스가 넷플릭스 공식 기술블로그에도 사례로 나와 있습니다. 해당 내용을 참고하시면 좋을 것 같습니다.