astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

Java9でDeprecatedになったfinalize()をjava.lang.ref.Cleanerで代替する

f:id:astamuse:20180722232243p:plain
はじめまして、お初にお目にかかります。
4月に入社したomiと申します。現在開発部で唯一の女子部員です。
優しく愉快な先輩方に囲まれて、日々感謝の気持ちで胸がいっぱいになりながら楽しく過ごしています。

さて、Javaの話をします。
弊社のJavaシステムの中で、Java9でDeprecated対象となったjava.lang.Object.finalize()を使用している箇所があり、同じくJava9で追加されたjava.lang.ref.Cleanerを用いて代替する方法を検討しましたので、その内容をご紹介します。

そもそもfinalizeのような機構のものを使うべきでない、という話は置いときます。
いろいろな事情でしょうがなく・もしくは意図的にfinalize()を使ってきた人たちのための代替方法です。

finalize()について

java.lang.Object.finalize()は、このオブジェクトへの参照はもうないとガベージ・コレクションによって判断されたときに、ガベージ・コレクタによって呼び出されるメソッドです。(公式API参照)
finalize()内にリソースのクローズ処理を記述しておくことで、GCのタイミングでリソースの破棄前にクローズ処理を実行させることができるものです。(とは言ってもご存知の通りfinalize()は呼ばれるタイミングが不定、確実に呼ばれる保証はない、など多くの問題があります)

 実際に動きを確認してみます。

public class TestFinalize {
    public static void main (String[] args) throws Exception {
        Finalize s = new Finalize();
        // using resource
        s = null; // 参照の削除
        System.out.println("System.gc()-start");
        System.gc(); // 強制GC
        System.out.println("System.gc()-end");
    }
}

class Finalize {
    public Finalize() {
        System.out.println("constructor");
    }

    public void close() {
        System.out.println("close");
    }
    
    @Override
    protected void finalize() throws Throwable{
        try{
            System.out.println("finalize()");
            this.close();
        } finally {
            super.finalize();
        }
    }
}

結果はこのようになりました。

$ java TestFinalize
constructor
System.gc()-start
System.gc()-end
finalize()
close

GCが走ったタイミングでfinalize()が呼ばれ、記述しておいたclose処理が実行されていることがわかります。

Cleanerについて

java.lang.ref.Cleanerは、クローズ対象のオブジェクトと対応するクリーニングアクション(Runnable実装クラス)を登録しておくことで、オブジェクトがルートセットから参照されなくなった時(PhantomReachableになった時)に該当クリーニングアクションを呼び出してくれるものです。(公式API参照)

CleanerによるRunnable実行(クリーニングアクション実行)タイミングには2通りあります。

  • Cleaner.Cleanable.clean()メソッドが明示的に呼び出された時
  • 上記clean()の呼び出しがされておらず、オブジェクトがPhantomReachableになった時

前者はAutoCloseableを継承しオーバーライドしたclose()メソッドにCleanable.clean()呼び出しを記述しておけば、実質try-with-resourcesにおいてリソースがスコープから外れた際に自動でRunnable実行しクローズを行うよう記述することができます。(実装は公式API参照)これは「最も効率的な方法」と書かれています。
(しかし従来通り、オーバーライドしたclose()メソッドに直接クローズ処理を記載しておけば、わざわざCleanerを実装する必要がないように思います。。??私には需要がわかりませぬ。。だれか教えてください。。)

今回はfinalize()の代替として同じ動きを再現したいので、後者のタイミングでのクローズ処理について動きを確認してみます。
クローズ処理を記述するRunnable実装クラスを作っておき、コンストラクタでCleaner.registerメソッドを呼び出し、オブジェクトと対応Runnableを登録しておきます。

import java.lang.ref.Cleaner;

public class TestClean {
    public static void main (String[] args) throws Exception {
        Clean s = new Clean();
        // using resource
        s = null; // 参照の削除
        System.out.println("System.gc()-start");
        System.gc(); // 強制GC
        System.out.println("System.gc()-end");
    }
}

class Clean {
    private CloseObject obj = new CloseObject();
    private static final Cleaner cleaner = Cleaner.create();
    private final CloseRunnable closeRunnable;
    private final Cleaner.Cleanable cleanable;

    public Clean() {
        System.out.println("constructor");
        this.closeRunnable = new CloseRunnable(obj);
        this.cleanable = cleaner.register(this, closeRunnable);
    }
}

class CloseObject {
    public void close() {
        System.out.println("close");
    }
}

class CloseRunnable implements Runnable{
    CloseObject obj;
    CloseRunnable (CloseObject obj) {
        this.obj = obj;
    }
    public void run() {
        System.out.println("Runnable.run()");
        obj.close();
    }
}

結果はこのようになりました。

$ java TestClean
constructor
System.gc()-start
System.gc()-end
Runnable.run()
close

finalize()同様、GCが走ったタイミングで登録しておいたRunnableが呼ばれ、記述しておいたclose処理が行われていることがわかります。

finalize()とCleanerとメモリ解放について

finalize()やCleanerを使用する上で留意しておかなければならないことがあります。
finalize()、Cleanerによるメモリ解放は、Full GCが少なくとも2回以上発生しないと実現しない、ということです。
finalize()、Cleanerのメモリ解放の流れは以下の通りです。

  1. ルートセットからオブジェクト参照がなくなる。
  2. Full GCが実行される。(オブジェクトがPhantomReachableになる)
  3. (PhantomReachableになったのを検知し、)FinalizerやCleanerの処理が実行される。
  4. (オブジェクトがReferenceQueに格納され、)オブジェクトが削除対象となる。
  5. 次に起こるFull GCでオブジェクト削除され、メモリ解放される。

※()内は、少し難しいので読み飛ばして頂いて結構です。
GCによるクリーンアップはjava.lang.refパッケージの各種Reference達やReferenceQueを用い制御することができます。
PhantomReachable:オブジェクトの参照レベルが、一番弱い参照レベルのPhantomReferenceのみになった状態。
参照レベルによってオブジェクトがReferenceQueに格納されるタイミングが異なります。Phantomの場合はオブジェクトがfinalize()処理された後にReferenceQueに入ります。Queに格納対象となったオブジェクトは次GCで削除されます。

実際に確認してみます。まずfinalize()実行時のメモリ解放。
メモリの増減が分かりやすいようにインスタンスを大量生産しています。

import java.util.concurrent.TimeUnit;
import java.lang.management.MemoryMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.MemoryUsage;

public class TestFinalizeMemoryCheck {
    public static void main (String[] args) throws Exception {
        check("初期値");
        // using resource
        for(int i = 0 ; i < 100000 ; i++) {
            Finalize s = new Finalize();
            s = null; // 参照の削除
        }
        check("インスタンスnew後");
        System.out.println("System.gc()-1回目");
        System.gc(); // 強制GC
        check("GC-1回目後");
        TimeUnit.SECONDS.sleep(3);
        System.out.println("System.gc()-2回目");
        System.gc(); // 強制GC
        check("GC-2回目後");
    }

    // 使用メモリを計測
    public static void check(String str){
        StringBuilder buff = new StringBuilder();
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        MemoryUsage usage = memoryBean.getHeapMemoryUsage();
        buff.append("usedmemory=");
        buff.append(usage.getUsed());
        System.out.println(buff.toString()+ " : " + str);
    }
}

class Finalize {
    public Finalize() {
        // System.out.println("constructor");
    }

    public void close() {
        // System.out.println("close");
    }

    @Override
    protected void finalize() throws Throwable{
        try{
            this.close();
        } finally {
            super.finalize();
        }
    }
}

結果はこうなりました。

$ java TestFinalizeMemoryCheck
usedmemory=2684392 : 初期値
usedmemory=8053248 : インスタンスnew後
System.gc()-1回目
usedmemory=7273832 : GC-1回目後
System.gc()-2回目
usedmemory=1674040 : GC-2回目後

2回目のGCでメモリ解放されています。
同様に、Cleanerの場合は

import java.util.concurrent.TimeUnit;
import java.lang.ref.Cleaner;
import java.lang.management.MemoryMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.MemoryUsage;

public class TestCleanerMemoryCheck {
    public static void main (String[] args) throws Exception {
        check("初期値");
        // using resource
        for(int i = 0 ; i < 100000 ; i++) {
            Clean s = new Clean();
            s = null; // 参照の削除
        }
        check("インスタンスnew後");
        System.out.println("System.gc()-1回目");
        System.gc(); // 強制GC
        check("GC-1回目後");
        TimeUnit.SECONDS.sleep(3);
        System.out.println("System.gc()-2回目");
        System.gc(); // 強制GC
        check("GC-2回目後");
    }

    // 使用メモリを計測
    public static void check(String str){
        StringBuilder buff = new StringBuilder();
        MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
        MemoryUsage usage = memoryBean.getHeapMemoryUsage();
        buff.append("usedmemory=");
        buff.append(usage.getUsed());
        System.out.println(buff.toString()+ " : " + str);
    }
}

class Clean {
    private CloseObject obj = new CloseObject();
    private static final Cleaner cleaner = Cleaner.create();
    private final CloseRunnable closeRunnable;
    private final Cleaner.Cleanable cleanable;

    public Clean() {
        // System.out.println("constructor");
        this.closeRunnable = new CloseRunnable(obj);
        this.cleanable = cleaner.register(this, closeRunnable);
    }
}

class CloseObject {
    public void close() {
        // System.out.println("close");
    }
}

class CloseRunnable implements Runnable{
    CloseObject obj;
    CloseRunnable (CloseObject obj) {
        this.obj = obj;
    }
    public void run() {
        // System.out.println("Runnable.run()");
        obj.close();
    }
}

結果は

$ java TestCleanerMemoryCheck
usedmemory=2821744 : 初期値
usedmemory=13617704 : インスタンスnew後
System.gc()-1回目
usedmemory=9668736 : GC-1回目後
System.gc()-2回目
usedmemory=2118416 : GC-2回目後

同じく2回目のGCでメモリ解放されています。
Full GCが起こる頻度にも気をつけなければいけないということですね。

おわりに

とうとう死亡フラグがたったfinalize()の代替方法について検討してみました。
何らかの理由でfinalize()を使っていた方のとりあえずの回避策として参考になれば嬉しいです。

最後に、アスタミューゼではエンジニア・デザイナーを大募集しています!
少しでも気になった方は下のバナーから是非採用サイトをご覧下さい!優しくて愉快な先輩方と私があなたをお待ちしてます!わくわく!
では。サリュ!

Copyright © astamuse company, ltd. all rights reserved.