자바와 C++의 비교
성능

컴파일된 자바 프로그램을 실행할 때에는, 컴파일된 자바 프로그램 뿐만 아니라 자바 가상 머신도 실행해야 한다. 반면 컴파일된 C++ 프로그램은 외부 프로그램 없이 스스로 동작할 수 있다. 자바의 초기 버전의 성능은 C++와 같은 정적 컴파일 언어에 한참 미달했다. 왜냐하면 겉으로는 비슷하게 생긴 두 언어의 한 스테이트먼트는, C++에서는 적은 수의 머신 인스트럭션으로 컴파일되는 데 반해 자바에서는 몇 개의 바이트코드로 변환되며, 이 바이트코드들은 JVM에서 각각 몇 개의 머신 인스트럭션을 만들어내기 때문이다. 예를 들어
| Java/C++ 스테이트먼트 | C++로 생성된 코드 (x86) | 자바로 생성된 바이트 코드 |
| --- | --- | --- |
| vector[i]++; | mov edx,[ebp+4h] mov eax,[ebp+1Ch] inc dword ptr [edx+eax*4] | aload_1 iload_2 dup2 iaload iconst_1 iadd iastore |
성능 최적화는 매우 복잡한 문제이기 때문에 C++와 자바의 성능을 일반적인 기준으로 정량화해서 비교하는 것은 어렵고, 대다수의 벤치마크들은 신뢰성이 낮고 편파적이다. 두 언어의 본질은 매우 다르기 때문에 두 언어의 질적인 차이를 명확히 그리는 것은 어렵다.
한 마디로 말하자면, 자바는 유연성 있는 고수준 추상화에 크게 의존하기 때문에, 성능 최적화를 하는 데에는 본질적인 비효율성과 한계가 있다. 강력한 JIT 컴파일러를 사용하면 몇몇 문제들을 해결할 수 있다. 그리고 언제든 자바 코드의 성능이 부족하다 판단되면 자바 네이티브 인터페이스를 통해 C/C++ 코드를 자바에서 사용할 수 있다.
자바 언어의 본질적인 비효율성들은 다음과 같다:
모든 객체들은 힙 영역에 할당된다. 작은 객체를 이용하는 함수의 경우, 이 때문에 성능 저하와 힙 단편화가 일어날 수 있다. 반면 스택 할당은 비용의 거의 없다. 그러나 현대의 JIT 컴파일러는 escape analysis와 escape detection을 통해 일부 객체를 스택에 생성한다.
효율적인 데이터베이스 시스템이나 메시징 라이브러리 같은 프로젝트들은 성능이 매우 중요하기 때문에 sun.misc.Unsafe 같은 내부적 비공식 API를 통해 수동 자원 관리 권한을 얻고 스택 할당을 해야 했다. 사실상 포인터를 사용하는 것과 다름 없었다.
메소드들은 기본적으로 가상 함수이다. (final 키워드를 사용할 수 있긴 하다.) virtual 메소드는 올바른 메소드를 찾기 위해 가상 메소드 테이블을 이용하는데, 이는 함수 호출을 느리게 만들고 메모리 공간을 차지한다. 또한 JIT 컴파일러가 작은 함수들은 virtual에서 일반 함수로 변경하는 작업을 추가적으로 해야 하기 때문이다.
여러 런타임 캐스팅은 표준 컨테이터 클래스를 사용해야 해서 성능에 악영향을 준다. 다만 이는 JIT 컴파일러에 의해 최적화될 수 있다.
안전성 검사가 성능 하락을 유발한다. 예를 들어 배열과 같은 경우 컴파일러는 매번 범위 검사를 수행하여 배열 외부의 값에 접근하지 못하게 지킨다. 그러나 이는 효율적이지 않다. 그래서 대부분의 JIT 컴파일러들은 이를 제거하거나 루프 밖으로 이동시키려 시도한다. (C++에서도 선택적으로 범위 검사를 수행할 수 있는데, 그 경우 컴파일러가 동일한 최적화를 수행한다.)
로우레벨에 접근할 수 없기 때문에, 컴파일러가 할 수 없는 최적화를 개발자가 할 수 없다.
유저 정의 타입엔 반드시 reference-semantics를 써야 한다는 점 때문에 메모리 내에서 데이터들이 멀리 떨어져 있어(JIT 컴파일러가 개입하지 않는 한) 빈번한 캐시 미싱을 야기한다. cache-aware이나 cache-oblivious 데이터 구조를 통해 구현할 수 있는 캐시 최적화는 크기 비교(orders of magnitude)의 성능을 개선할 수 있고, 시간 복잡성 증대를 피할 수 있다. 즉 이는 최적화에 있어 매우 중요한 기술인데, 자바의 reference-semantics은 이러한 최적화 실현하는 것을 불가능하게 만든다.
가비지 콜렉션은 메모리 오버헤드를 유발한다.
하지만 자바의 디자인으로 인해 실현된, 혹은 이론적으로 제기된 이익들도 많이 있다.
자바의 카비지 콜렉션은 malloc/new 보다 메모리 할당에 있어 cache coherence에 유리할 수 있다. 그렇지만 두 방법 다 힙 영역을 똑같이 단편화시켜 cache locality에 유리하지 않다는 주장도 있다. 그러나 C++에서는 하나의 작은 객체는 힙이 아니라 스택에 할당되는 경우가 대부분이고, 여러 개의 작은 객체는 STL 컨테이너를 이용해 블록으로 할당된다.
런타임 컴파일은 코드가 실행되는 플랫폼의 정보를 이용해 더 최적화된 코드를 만들어낼 수도 있다. 그러나 최신 네이티브(C, C++ 등) 컴파일러들은 여러 개의 코드 경로를 만들어서 해당 시스템에서 최적의 성능을 낼 수 있도록 한다. 또한 반대의 의견도 있을 수 있는데, 네이티브 컴파일러가 멀티 플랫폼 JVM보다 특정 아키텍처에 맞는 최적화를 더 잘 달성할 수 있다.
런타임 컴파일은 정적 컴파일보다 가상 함수를 더 공격적으로 인라이닝 할 수 있다. 왜냐하면 JIT 컴파일러는 가상 함수 호출의 대상에 대한 정보를 더 많이 가질 수 있기 때문이다. JVM은 대다수의 단일형과 이형의 호출을 인라이닝하는 데 문제가 없고, 변성의 호출을 인라이닝 하는 연구도 진행 중이다. 이는 자바 7에 추가된 invoke dynamic enhancements 덕분이다. 인라이닝은 루프 벡터화나 loop unrolling과 같은 최적화를 가능하게 한다.
자바에는 스레드 동기화가 언어에 내장되어 있다. 그래서 JIT 컴파일러가 escape analysis, elide locks를 이용해 네이티브 멀티쓰레드 코드의 성능을 향상시킬 수 있다. 이 방식은 Sun JDK 6 update 10에서 biased locking이라는 이름으로 소개되었다.
C++에서도 몇몇 성능 문제가 일어난다.
포인터가 모든 주소를 가리킬 수 있게 허용하는 것은, 서로를 참조하는 포인터 사이의 간섭 때문에 최적화를 힘들게 할 수 있다. 그러나 strict-aliasing 규칙이 이 문제를 해결했다.
템플릿 클래스를 통해 생성된 각기 다른 타입의 코드들은 서로 공유되지 않기 때문에, 과도한 템플릿 클래스의 사용은 실행 파일의 크기를 키울 수 있다. 그러나 함수 템플릿은 공격적으로 인라이닝되기 때문에 코드 크기를 줄이는 경우도 있다. 그리고 컴파일러가 정적 분석을 하여 최적화하는 데 용이하기 때문에, 템플릿이 아닌 코드보다 훨씬 효율적이다. 반면 자바의 제네릭스는 제네릭스가 아닌 코드보다 덜 효율적이다.
전통적인 C++ 컴파일러에서는 코드 생성과 최적화가 이루어진 뒤 동적 링킹이 일어나기 때문에, 동적 모듈에서 불러온 함수에 대해선 인라이닝이 불가능했다. 하지만 MSVC, Clang+LLVM과 같은 현대의 컴파일러들은 링크 타임 코드 생성 옵션이 있어 링크 단계에서 인라이닝이 가능하다.
쓰레드는 대체로 라이브러리를 통해 제공됐었기 때문에 C++ 컴파일러는 쓰레드 관련 최적화는 하지 못했다. 하지만 멀티쓰레딩 메모리 모델이 C++11에서 추가되었기 때문에 현대 컴파일러들은 최적화가 가능하다.