Javaなのに自分でメモリ管理は変態すぎてよろしい

GCの停止時間だが、そもそもGCが発生しなければどうということはない。

ということで超手抜きで実装してみた。そしてうまくいった。

GCの停止時間が致命的なアクション系ゲームを想定してる。

まずゲームの基本的なコードを超大雑把に書いてみる。

        List<GameObject> task = new ArrayList<GameObject>();

        //ゲームループ
        for(int i=0;i<10*1000;i++){
            //毎フレーム追加
            for(int j=0;j<10;j++){
                task.add(new GameObject());
            }

            //処理をさせる
            for(GameObject obj : task){
                obj.run();
            }

            //60フレーム立ったやつは削除(死んだオブジェクトのつもり)
            for(int j=task.size()-1;j>=0;j--){
                if(task.get(j).getCount() > 60){
                    task.remove(j);
                }
            }

            //ページフリッピング
            render();

        }

ゲームのループは無限だし、オブジェクトの削除等は各オブジェクトのrunメソッドの中で間接的に削除していく。また、フレーム単位で生成するというよりはオブジェクトがオブジェクトを生成していく。つまり、ツリーのようなものではあるが、親が消えても子が動き続けることもあるためフラットでよい。親が消える際には子に通知といったオブザーバパターンも必須だ。最初のスタート地点ではオブジェクトはひとつだけ生成しておいて。ということで、あくまでもわかりやすく書いただけと認識してほしい。

これを普通に実行するとしばらく新世代のGCが発生し続け、そのうちFullGCが発生し、がくっと処理落ちが1分に1回くらいおこる。10フレームくらいなのでアクション系以外や可変フレームレートのFPSのような3Dアクション系はあんまりわからないと思う。

問題は安定して60fpsを維持する2Dゲー。


そこでGCを一切発生させないコードを書いてみた。実装がやっつけなので処理速度が1/100くらい(Listもどきのremoveで全部バイト移動してるのが原因)になるけどたしかにGCは1度も発生しなかった。

特徴を並べてみる。

  • メソッドはstaticのみもしくはシングルトン
  • オブジェクトの使用するメモリ量をしっかり把握すること。固定だと実装は楽
  • データは1つのbyte配列にすべてマッピングする
  • 扱う型はプリミティブ型のみ
  • メモリをしっかり自分で管理(確保、開放)する
  • Listは使わない。自分でメモリ管理をもったListもどきを作る

この時点でほとんどの人は気がついたかもしれない。どこをどう見てもJavaらしくない。C言語のようだ。

ポイントはNewIOを使う。そう、ByteBufferだ。
http://java.sun.com/javase/ja/6/docs/ja/api/java/nio/ByteBuffer.html


ではint型のcountという4バイトの整数型ひとつだけを処理してみよう。

public class GameObject {

    private int count = 0;

    //runの実行前
    public void preRun(ByteBuffer memmap,int index) {
        count = memmap.getInt(index*256);
    }

    //オブジェクトの処理
    public void run(){
        count++;
    }

    //runの実行後
    public void postRun(ByteBuffer memmap,int index){
        memmap.putInt(index*256,count);
    }
}

ここではひとつのGameObjectが256バイトだということをあらわす。オブジェクト番号でかけてアドレスを計算している。そしてintの使用メモリである4バイトを読み込んで、書き込むときにも同じアドレスに4バイトを書き込む。バイトオーダーのデフォルトはビッグエンディアンなのでリトルエンディアンに慣れている人は注意。

本来きれいに書くなら自分の管理する256バイトの相対アドレスでの処理をさせるようなラッパを用意するべき。直接ByteBufferを使うのではなくて。また、AOPによってローカル変数とのバインディングを隠すのもよいかもしれない。メモリの配置もこのままではあまりにも手動すぎて、アドレスの計算を間違ったりするとバグるため、以下のようにアノテーションかなんかでメモリマッピングも全自動にしたほうがよいかもしれない。

    @MemoryMapping(order=1)
    private int count = 0;
    @MemoryMapping(order=2)
    private short x = 0;
    @MemoryMapping(order=3)
    private short y = 0;

アノテーションが着いたフィールドが自動でマッピングされる。そしてその順序がorder属性に記述した順序となる。使用するメモリのサイズはリフレクションで動的に。場合によっては名前でソートしてorderも省略したほうがいいのかもしれない。継承した場合が頭が痛いけど…。


こうすることでたとえば最大1024オブジェクトだとすると256*1024=256KBのメモリにあらゆるデータがマッピングされる。この256KBを起動時に確保、ゲーム終了まで保持し続けることになるのでGCは起きない。


ByteBufferはC言語に慣れてる人だともっとも身近に感じるクラスかもしれない。


ガチガチに最適化したい人はこういったメモリの面倒な制御をカプセル化したフレームワークがあるとJavaでのゲーム開発はもっと楽になるかもしれない。どう考えてもJavaとか新しい言語しか触ってない(といってももうJavaが発表されてから14年たつのか…)人はアレルギーがあるかもしれない。一方でおいらのようにハンドアセンブルやってメモリのマッピングを考えていた人にとってこれはいたって普通のこと。アノテーションによる全自動割付もとんでもない!自分にやらせろ!といいたくなったり。


まぁ本当は一番の鬼門は音楽周りだとは思うが。実際PureWindではBGMの処理のところが一番メモリに負担をかけている。ShinGL3はBGMをOgg Vorbis等のファイルからストリーミングとして読み込んで逐次処理をしている。ストリーミングということはメモリをどんどん捨てるわけで、ここが負担となりやすい。1つのBGMはOggのように圧縮を使えば2MBもあればまずおさまるため、最初にメモリに一気に読み込んでおくのが一番いいのかもしれない。ShinGLを作り始めた当時はメモリの負担が大きく、コンカレントGCがなかったのでいかにヒープを小さくするかがポイントだったというのもある。いまどき2MBのメモリ消費にこだわる人ももういない気がする。これならば最初にメモリに読み込んでしまえばよい。