デベロッパー

デベロッパー

JavaとC++のパフォーマンスを比較する

Liviu Tudor
2010年4月2日 / 10:00
 
 

はじめに

 Javaプログラミング言語の初期のころから、Javaはインタープリタ言語なのでパフォーマンスの点でCやC++に劣る、と主張している人たちがいました。もちろん、C++の信奉者たちは、そもそもJavaを「真の」言語だと思っていないでしょうし、Javaの連中はC++プログラマに向かっていつも「一度書けば、どこでも実行できる」と唱えています。

 まず重要なことから取り上げましょう。Javaは基本的な整数演算をどれほどうまくやってのけるでしょうか。私が誰かに「2×3は?」と尋ねたら、おそらくすぐに答が返ってくることでしょう。では、相手がプログラムならどうなるでしょうか。これを調べるために、基本的なテストを行ってみましょう。テストの内容は次のとおりです。

  1. 最初にX個のランダムな整数を生成する
  2. それらの数に、2からYまでのすべての数を掛ける
  3. 全体集合の計算に要する時間を計算する
 乱数の生成に要する時間が知りたいわけではないので、測定を始める前に乱数を生成しておくことが大事です。

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;
}
 これを全部まとめると、次のコードができあがります(このコードはIntMaths.javaにも入っています)。

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();
}
 厳密なことを言えば、Javaで生成される数値はC++で生成される数値と同じではありません。従って、両方の実装で同じ数値セットが使われるという保証はありません。もっとも、両者の差異はそれほどないはずです。

 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++;
      }
   }
}
 C++には演算の時間をミリ秒単位の精度で測定する標準的な方法がなさそうなので、このコードはWindowsプラットフォームに的を絞っています。QueryPerformanceCounter関数を使用すると、高精度のタイマーにアクセスできます。この関数を使用するため、このコードでは2つのメソッド(StartStop)を持つ簡素な「ストップウォッチ」クラスを利用しています。この時間測定に基づいて、このクラスは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;
}
 このパターンでは次の実装が必要です(IntMaths.cに入っています)。

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;
}
 上記のコードはサンプルコードに含まれているIntMathsプロジェクトに入っています。筆者のラップトップで実行すると、次の結果が返されます(測定値の単位はミリ秒)。

C computing took 0.001427
 つまり、約10,000,000回の算術演算を実行するのに約1/1000ミリ秒を要したことになります。

 Javaでは25msであるのに対して、C++では0.001msです。大変な違いがあります。ただし、C++コードのコンパイルではフルコンパイラとリンカで速度の最適化が行われていることを忘れてはなりません。最適化を無効にすれば、次の結果が返されます。

C computing took 70.179901
 驚いたことに、Javaバージョンより3倍も遅いではありませんか。つまり、こういうことです。一見したところではC++の方が速いのですが、それはコンパイラがコードをうまく最適化する場合に限られるのです(この点は非常に重要です)。

 今日の大多数の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];
   }
}
 上記のコード(DoubleMaths.java内)を筆者のラップトップで実行すると、次の結果が返されます。

Java computing took 47
 つまり、Javaで約10,000,000回の浮動小数点演算(乗算)を実行するのに平均で47ミリ秒(整数演算のおよそ2倍)を要したことになります。

 次にC++で試してみましょう(このコードはダウンロードファイル内のDoubleMathsプロジェクトに入っています)。

C computing took 0.001477
 前の例と同様、これもコンパイラで速度の最適化を行った結果です。最適化を無効にすると、次の結果が返されます。

C computing took 84.734633
 つまり、C++バージョンで最適化されたコンパイルを行った場合は、浮動小数点演算でもほとんど変わらないようです。それなりのコンパイラを使用すれば、整数演算と浮動小数点演算との差はほとんどなく、しかも生成されるコードは約25,000倍も速くなるでしょう。ただし、コンパイラを慎重に選ぶことが大事です。さもないと、Javaバージョンより2倍も遅くなってしまう可能性があります。

数値比較

 これまでのところ、計算の点ではC++の方が有利に見えます。しかし、数値比較ではどうでしょうか。次の2つの例で調べてみましょう。

  1. ifステートメントで2つの整数を使用する。このifステートメントでは、ステートメントが真の場合は、単純な割り当てを実行する
  2. 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());
   }
}
 実行時間の平均値算出にも同じ方法を使用します。ただし、これを実行する方法は違います。もちろん、整数(数千万個)からなる大きな配列を割り当てて、それを上述のやり方で反復処理していくこともできますが、そうするとインデックスメモリアクセス時間という別の問題が生じます(これについては後ほど説明します)。そのため、代わりにいくつものint(この例では100個)からなる小さな配列を使用し、同じ演算を100,000回繰り返します。このようにして演算の時間を測定します。

 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
 つまり、1千万(100,000×100)回の整数比較を実行するのに平均で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;
   }
}
 上記のコード(ArraysAccess.java内)を実行すると、次の結果が返されます。

Java traverse took 53
 つまり、1千万個の要素からなる配列を反復処理するのに平均で53ミリ秒を要したことになります。これと同等のC++コードの実装(ダウンロードファイル内のArraysAccessプロジェクトに入っています)は、前の例とは少し異なります。なぜなら、C++ではデフォルトで1つの配列に要素を65,535個までしか入れられないからです。この制限に対処するため、この例でもWindows APIを使用し、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
 つまり、Javaより約5倍速いということです。ただし、このコードをコンパイルするときに最適化を無効にすると、Javaより2倍から3倍ほど遅くなります。

メモリ割り当て

 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
 つまり、10,000バイトを割り当てるのに約11ミリ秒を要したことになります。上記のコードでガーベージコレクションの提案を行わないと(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
 この結果に驚く人もいるかもしれませんが、実はC++でのメモリ割り当てはJavaのものに匹敵するのです。これらの測定値から、どちらが速いのか判断するのは難しいでしょう。いずれも1ミリ秒未満だからです。ただし、それほど大きな違いはありません。なお、繰り返しになりますが、ここに示した結果も最適化されたC++コードのものです。

 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;
   }
}
 同様に、C++では次のように実装します。

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;
};
 ここで使用しているコードは非常に単純です(このコードはダウンロードファイル内のComplexCreateプロジェクトおよびComplexCreate.javaに入っています)。ただし、大きな違いがあります。Javaのインスタンス化プロセスは、次のような2つのステップになっています。

  1. 実際の配列のメモリを割り当てる
  2. 各オブジェクトを作成する
 それに対し、C++ではこれを1つのステップで行えます。

arr = new Complex[N_GENERATED];
 new演算子を使用すると、実は1つのステップでアイテムが作成されて初期化されます。C++コードとJavaコードを実行すると、次の結果が得られます。

(Java) Java create took 710788
 
(C++) C instantiation took 29348.262052
 この結果もナノ秒単位です。およそ710,000(Java)対29,000(C++)という比較です。この意味に関してはあえて触れません。

条件付きでC++の勝ち

 プログラミング言語で扱うのは、もちろんメモリ割り当て、ループ処理、浮動小数点演算だけではありませんが、これらをまったく使用しないプログラムを見つけるのは難しいでしょう。これらの処理を行うなら、どちらが好きかという話は別にして、最適化されたC++コンパイラの方がJavaよりも速いコードを生成できます。もっとも、Javaコンパイル自体に(筆者は特別なフラグなしで標準JDKコンパイラを使用しました)、本稿で述べてきたようなケースで実行速度を向上させる最適化機能がもっと必要なのかという疑問もあります。

著者紹介

Liviu Tudor(Liviu Tudor)
英国在住のJavaコンサルタント。特にオンラインメディアセクタの高可用性システムに関して豊富な経験を持つ。Javaに長年取り組んでいるうちに、パフォーマンスが問題になるアプリケーションをうまく機能させるために必要なのは肥大したミドルウェアフレームワークではなく「低レベル」のコアJavaであると悟る。所属する地元ラグビーチーム「Phantoms」でのプレー中に負った怪我の療養をしつつ、Developer.comのためにJavaテクノロジに関する記事を執筆中。
【関連記事】
Windows Mobile WebアプリケーションにAJAXサポートを追加する
日本情報通信とネットワールドが仮想化情報基盤で協業
Javaの単体テストでランダムなデータを活用する
Opera Mini 5 beta、Windows Mobile 向け配布を開始
CTC、ネティーザ、SAP ジャパン共同の BI アプライアンス、CTC が販売

New Topics

Special Ad

ゆりかごからロケットまで、すべての乗り物をエンジョイ
ゆりかごからロケットまで、すべての乗り物をエンジョイ えん乗り」は、ゆりかごからロケットまで、すべての乗り物をエンジョイする、ニュース、コラム、動画などをお届けします! てんこ盛りをエンジョイするのは こちらから

Hot Topics

IT Job

Interviews / Specials

Popular

Access Ranking

Partner Sites