デベロッパー
デベロッパー
JavaとC++のパフォーマンスを比較する
はじめに
Javaプログラミング言語の初期のころから、Javaはインタープリタ言語なのでパフォーマンスの点でCやC++に劣る、と主張している人たちがいました。もちろん、C++の信奉者たちは、そもそもJavaを「真の」言語だと思っていないでしょうし、Javaの連中はC++プログラマに向かっていつも「一度書けば、どこでも実行できる」と唱えています。まず重要なことから取り上げましょう。Javaは基本的な整数演算をどれほどうまくやってのけるでしょうか。私が誰かに「2×3は?」と尋ねたら、おそらくすぐに答が返ってくることでしょう。では、相手がプログラムならどうなるでしょうか。これを調べるために、基本的なテストを行ってみましょう。テストの内容は次のとおりです。
- 最初にX個のランダムな整数を生成する
- それらの数に、2からYまでのすべての数を掛ける
- 全体集合の計算に要する時間を計算する
Javaのテスト
Javaでは、ごく簡単に乱数を生成できます。
private void generateRandoms()
{
randoms = new int[N_GENERATED];
for( int i = 0; i < N_GENERATED; i++)
{
randoms[i] = (int)(i * Math.random());
}
}
private void javaCompute()
{
int result = 0;
for(int i = 2; i < N_MULTIPLY; i++)
{
for( int j = 0; j < N_GENERATED; j++ )
{
result = randoms[j] * i;
result++;
}
}
}
javaComputeメソッドの実行に要する時間を単純に測定し、それをテスト結果と見なすこともできます。しかし、これはいくつかの理由で公正ではありません。第一に、JVMはプログラムの実行中に必要に応じてクラスローダーでクラスをロードします。OS自身も、JVMから要求されるさまざまなデータ構造についての準備を行います。そのため、上記の関数を最初に実行するときは、実際には実行の準備にかなりの時間が費やされると考えられます。従って、1回のテストで時間を測定するのは不適当でしょう。代わりに、このプログラムを何回か実行して、結果の平均をとることにしましょう。ただし、関数の初回実行時には、実行時間の点でやはり同じ問題が生じるので、正確な時間測定ができません。その一方で、上記のメソッドの実行を繰り返すほど、JVMやOSによってデータがキャッシュされる可能性が高くなります。そのため、正確な実行時間を反映したデータではなく、OSによるコード実行最適化の結果を反映したデータが一部に含まれてしまう可能性もあり、やはり平均値の算出に影響が出ます。
こうした問題を相殺するため、上記のメソッドを数回実行し、「最も異常な」ケース、すなわち最短時間と最長時間を除外します。そうすれば、真の平均に近い値が得られるはずです。さらに、上記の
javaComputeメソッドを実行した各時間の長さを配列に格納するものとしましょう。これは次のような単純なメソッドで実現されます。
private static long testTime( long diffs[] )
{
long shortest = Long.MAX_VALUE;
long longest = 0;
long total = 0;
for( int i = 0; i < diffs.length; i++ )
{
if( shortest > diffs[i] )
shortest = diffs[i];
if( longest < diffs[i] )
longest = diffs[i];
total += diffs[i];
}
total -= shortest;
total -= longest;
total /= ( diffs.length - 2 );
return total;
}
public static void main(String[] args)
{
IntMaths maths = new IntMaths();
maths.generateRandoms();
//compute in Java
long timeJava[] = new long[N_ITERATIONS];
long start, end;
for( int i = 0; i < N_ITERATIONS; i++ )
{
start = System.currentTimeMillis();
maths.javaCompute();
end = System.currentTimeMillis();
timeJava[i] = (end - start);
}
System.out.println( "Java computing took " + testTime(timeJava) );
}
筆者のラップトップ(それほどハイスペックではないものの、現在の一般的なマシンを代表するものと見てよいでしょう)で上記のコードを実行すると、平均値が約25ミリ秒になります。つまり、筆者のラップトップでは、10,000個のランダムな整数と2から1,000までの各数とを掛け合わせるのに、約25ミリ秒かかるということです。これは基本的に、約10,000,000回の整数演算に25msかかることを意味します。
C++のテスト
今度はC++で同様のことを試してみましょう。
void generate_randoms( int randoms[] )
{
for( int i = 0; i < N_GENERATED; i++ )
randoms[i] = rand();
}
C++での計算処理はJavaと似ています。
void nativeCompute(int randoms[])
{
int result = 0;
for(int i = 2; i < N_MULTIPLY; i++)
{
for( int j = 0; j < N_GENERATED; j++ )
{
result = randoms[j] * i;
result++;
}
}
}
QueryPerformanceCounter関数を使用すると、高精度のタイマーにアクセスできます。この関数を使用するため、このコードでは2つのメソッド(StartとStop)を持つ簡素な「ストップウォッチ」クラスを利用しています。この時間測定に基づいて、このクラスは2つのメソッドがそれぞれいつ呼び出されたかを記録し、ミリ秒単位の時間差を返します。
// Stop watch class.
class CStopWatch
{
public:
// Constructor.
CStopWatch()
{
// Ticks per second.
QueryPerformanceFrequency( &liPerfFreq );
}
// Start counter.
void Start()
{
liStart.QuadPart = 0;
QueryPerformanceCounter( &liStart );
}
// Stop counter.
void Stop()
{
liEnd.QuadPart = 0;
QueryPerformanceCounter( &liEnd );
}
// Get duration.
long double GetDuration()
{
return ( (liEnd.QuadPart - liStart.QuadPart) / long double(liPerfFreq.QuadPart) ) * 1000;
//return result in milliseconds
}
private:
LARGE_INTEGER liStart;
LARGE_INTEGER liEnd;
LARGE_INTEGER liPerfFreq;
};
long double test_times( long double diffs[] )
{
long double shortest = 65535;
long double longest = -1;
long double total = 0;
for( int i = 0; i < N_ITERATIONS; i++ )
{
if( shortest > diffs[i] )
shortest = diffs[i];
else if( longest < diffs[i] )
longest = diffs[i];
total += diffs[i];
}
total -= shortest;
total -= longest;
total /= ( N_ITERATIONS - 2 );
return total;
}
int main(int argc, char* argv[])
{
int randoms[N_GENERATED];
generate_randoms( randoms );
CStopWatch watch;
long double timeNative[N_ITERATIONS];
for( int i = 0; i < N_ITERATIONS; i++ )
{
watch.Start();
nativeCompute(randoms);
watch.Stop();
timeNative[i] = watch.GetDuration();
}
printf( "C computing took %lf\n", test_times(timeNative) );
return 0;
}
C computing took 0.001427
Javaでは25msであるのに対して、C++では0.001msです。大変な違いがあります。ただし、C++コードのコンパイルではフルコンパイラとリンカで速度の最適化が行われていることを忘れてはなりません。最適化を無効にすれば、次の結果が返されます。
C computing took 70.179901
今日の大多数のC++コンパイラは、生成するコードをかなりうまく最適化します。とはいえ、1/1000ミリ秒になるか70ミリ秒になるかはコンパイラに委ねられています。速さゆえにC++に切り替えようという人は、このことを忘れないでください。
浮動小数点ならどうなるか
整数演算についてJavaとC++を比較しましたが、浮動小数点ならどうでしょうか。実際のところ、整数演算だけを行うアプリケーションを見つけるのは難しいでしょう。大抵のプログラムはどこかで(たとえ2つの数の平均値を求めるだけでも)浮動小数点演算を行います。先ほどの例と同じアプローチを使って、ランダムな浮動小数点数を生成し、それらを掛け合わせ、その所要時間を測定し、所要時間の平均値を算出してみましょう。
private void generateRandoms()
{
randoms = new double[N_GENERATED];
for( int i = 0; i < N_GENERATED; i++)
{
randoms[i] = Math.random();
}
multiply = new double[N_MULTIPLY];
for( int i = 0; i < N_MULTIPLY; i++ )
{
multiply[i] = Math.random();
}
}
private void javaCompute()
{
double result = 0;
for(int i = 0; i < N_MULTIPLY; i++)
{
for( int j = 0; j < N_GENERATED; j++ )
result = randoms[j] * multiply[i];
}
}
Java computing took 47
次にC++で試してみましょう(このコードはダウンロードファイル内のDoubleMathsプロジェクトに入っています)。
C computing took 0.001477
C computing took 84.734633
数値比較
これまでのところ、計算の点ではC++の方が有利に見えます。しかし、数値比較ではどうでしょうか。次の2つの例で調べてみましょう。ifステートメントで2つの整数を使用する。このifステートメントでは、ステートメントが真の場合は、単純な割り当てを実行するifステートメントのテスト対象で浮動小数点数を使用する
乱数の生成には、これまでの例と同じ方法を使用します。
/**
* Generate random numbers
*/
private void generateRandoms()
{
randoms = new int[N_GENERATED];
for( int i = 0; i < N_GENERATED; i++)
{
randoms[i] = (int)(i * Math.random());
}
}
Javaのコードは次のようになります。
public static void main( String args[] )
{
IntComparison comp = new IntComparison();
comp.generateRandoms();
long timeJava[] = new long[N_ITERATIONS];
long start, end;
for( int i = 0; i < N_ITERATIONS; i++ )
{
start = System.currentTimeMillis();
for( int j = 0; j < N_REPEAT; j++ )
comp.javaCompare();
end = System.currentTimeMillis();
timeJava[i] = (end - start);
}
System.out.println( "Java compare took " + testTime(timeJava) );
}
Java compare took 50
C++でも同様の実装で結果を調べてみましょう(このコードはダウンロードファイル内のIntComparisonプロジェクトに入っています)。
C computing took 0.001971
インデックスメモリアドレッシング
ここまでは少量のデータへのアクセスを見てきましたが、インデックス化されたデータ(配列)ではメモリ割り当てとアクセスのモデルがどのように働くのでしょうか。その点を調べるために、多数の要素からなる配列を単純に反復処理して、各要素へのアクセスに要する時間を比較します。ここでのアクセスとは、データを読み取り、それを変数に格納し、それから再び配列に書き込むことを意味します。実際に測定に使用する関数は、Javaでは次のようになります。
private void javaTraverse()
{
int temp = 0;
for( int i = 0; i < N_ELEMS; i++ )
{
temp = array[i];
array[i] = temp;
}
}
Java traverse took 53
GlobalAlloc関数を組み込んでいます。これにより、大きなメモリの割り当てが可能になります。
int main(int argc, char* argv[])
{
int * randoms;
HGLOBAL h = GlobalAlloc( GPTR, sizeof(int) * N_GENERATED );
randoms = (int *)h;
generate_randoms( randoms );
CStopWatch watch;
long double timeNative[N_ITERATIONS];
for( int i = 0; i < N_ITERATIONS; i++ )
{
watch.Start();
nativeTraverse(randoms);
watch.Stop();
timeNative[i] = watch.GetDuration();
}
printf( "C traversing took %lf\n", test_times(timeNative) );
GlobalFree( h );
return 0;
}
GlobalAllocを使って1千万個のintを割り当ててから、これをJavaのときと同じやり方で反復処理しています。この演算の結果の平均は次のようになります。C traversing took 10.857639
メモリ割り当て
C++プログラマがよく直面する議論の1つにメモリ管理問題があります。Javaはこの問題を引き受けてくれますが、C++のメモリ管理はコンストラクタとデストラクタ、および取り扱いの難しいnew/deleteペアに依存しています。メモリ割り当てのパフォーマンスを比較するため、まず単純なタスクとして、1,000バイトの配列を繰り返し割り当て、割り当てと割り当て解除に要する時間を測定することにします。これはJavaでは注意を要するタスクです。なぜなら、メモリを解放するための確実な方法がないからです(
System.gcは「必要なら一部のメモリを解放してもいいよ」とJVMに伝える提案にすぎず、実際に解放される保証はありません)。それでも、比較の際にはこの測定値を使用するしかないので、Javaでの測定値にはメモリ解放時間が含まれない可能性があることを踏まえたうえで使用してください(結局のところ、前にも述べたとおり、C++プログラマとJavaプログラマが対立するときの主な論点の1つは、Javaではメモリ管理のことを考えなくて済むのに対し、C++ではメモリ管理に気を配る必要があるということです)。ここでは、整数ではなくバイトの配列を扱うことに注意してください。なぜなら、Javaの整数とC++の整数はサイズが異なるので、種類の違う配列を割り当てるのでは公正でないからです。
そのため、Javaで割り当てを行う関数は次のようになります(IntAlloc.java)。
private void javaAlloc()
{
System.gc();
elements = new byte[N_ELEMS];
elements[0] = 0;
}
このテストでは時間測定をナノ秒単位で行います。ミリ秒単位では十分でない可能性があるからです。Javaでは、これに
System.nanoTime関数を使用します。なお、言うまでもないことでしょうが、1ミリ秒=1,000,000ナノ秒です。上記のコードを実行した平均時間は次のようになります。
Java memory allocation took 11,755,693
javaAlloc関数内のSystem.gc行をコメント化すると)どうなるか試してみましょう。Java memory allocation took 12,994
System.gc()でガーベージコレクションを提案したために、ガーベージコレクションが作動して、使用されていたメモリを再収集したことを示しています。また、ガーベージコレクションのことを気にしなければ、10,000バイトを割り当てるのに1ミリ秒もかからないことを示しています(もっと正確に言えば、約13,000ナノ秒で、1バイトあたりの平均が約1.3ナノ秒)。次にC++で試してみましょう(ダウンロードファイル内のIntAllocプロジェクトに入っています)。
C allocation took 3661.273463
System.gcを普段使っているかどうかをJavaプログラマに尋ねてみれば(リアルタイムシステムのプログラミングをしている人は別として)、実際にSystem.gcを使っているプログラマは非常に少ないことが分かるでしょう。大多数のJavaプログラマはメモリ管理をJVMに任せているのです。というのも、JVMのメモリ管理はうまく実装されていて、自ら作動すべきタイミングを心得ているので、システムパフォーマンスにそれほど大きな影響を与えないからです。なお、本稿のコードをじっくり読み込み、少し変更を加えてみようと考える読者のためにあらかじめ言っておくと、割り当てをintに変更しても、JavaでもC++でもそれほどパフォーマンスが落ちることはありません。
ここまで基本データ型に関して両方の言語の動作を見てきました。一歩進んで、オブジェクトに関してはどんな結果になるか見てみましょう。そのため、複素数データ型をマップするクラスを使って調べることにします。複素数データ型は、実数部と虚数部という2つの部分からなるデータ型で、実数部も虚数部も浮動小数点数です。このクラスを両方の言語で実装し、それを何回かインスタンス化して、何が分かるか調べてみましょう。
Javaでは、データを格納し、それをゲッターとセッターによって公開する2つのプライベートメンバーを使用します。また、デフォルトコンストラクタの上に、2つの値を受け取って、これらのメンバーを初期化するコンストラクタを記述します(Complex.java)。
public class Complex
{
private double real;
private double imaginary;
public Complex()
{
this( 0.0, 0.0 );
}
public Complex( double real, double imaginary )
{
this.real = real;
this.imaginary = imaginary;
}
public double getReal()
{
return real;
}
public void setReal(double real)
{
this.real = real;
}
public double getImaginary()
{
return imaginary;
}
public void setImaginary(double imaginary)
{
this.imaginary = imaginary;
}
}
class Complex
{
public:
Complex(double real = 0, double imaginary = 0)
{
this->real = real;
this->imaginary = imaginary;
}
double getReal()
{
return real;
}
void setReal( double real )
{
this->real = real;
}
double getImaginary()
{
return imaginary;
}
void setImaginary( double imaginary )
{
this->imaginary = imaginary;
}
private:
double real;
double imaginary;
};
- 実際の配列のメモリを割り当てる
- 各オブジェクトを作成する
arr = new Complex[N_GENERATED];
(Java) Java create took 710788 (C++) C instantiation took 29348.262052
条件付きでC++の勝ち
プログラミング言語で扱うのは、もちろんメモリ割り当て、ループ処理、浮動小数点演算だけではありませんが、これらをまったく使用しないプログラムを見つけるのは難しいでしょう。これらの処理を行うなら、どちらが好きかという話は別にして、最適化されたC++コンパイラの方がJavaよりも速いコードを生成できます。もっとも、Javaコンパイル自体に(筆者は特別なフラグなしで標準JDKコンパイラを使用しました)、本稿で述べてきたようなケースで実行速度を向上させる最適化機能がもっと必要なのかという疑問もあります。著者紹介
Liviu Tudor(Liviu Tudor)
英国在住のJavaコンサルタント。特にオンラインメディアセクタの高可用性システムに関して豊富な経験を持つ。Javaに長年取り組んでいるうちに、パフォーマンスが問題になるアプリケーションをうまく機能させるために必要なのは肥大したミドルウェアフレームワークではなく「低レベル」のコアJavaであると悟る。所属する地元ラグビーチーム「Phantoms」でのプレー中に負った怪我の療養をしつつ、Developer.comのためにJavaテクノロジに関する記事を執筆中。
New Topics
Special Ad
| “超高速無線 LAN 時代”の幕開け--新規格 11ac(Draft)に対応したバッファロー最新ルーターの潜在能力を試す | |
![]() |
バッファローは次世代無線 LAN 規格 IEEE802.11ac(Draft)通信速度最大 1,300Mbps 対応無線 LAN ルーター「WZR-1750DHP」を3月下旬に販売開始。今回、同機器を入手できたので、使用感や便利な機能についてレポートしたい。⇒詳細記事へ |
Hot Topics
IT Job
今週のIT求人情報
Interviews / Specials
Follow japan.internet.com
Popular
Access Ranking
Partner Sites









