PDFBoxでjava.lang.OutOfMemoryErrorと性能問題に悩まされたお話

PFDファイルを扱う場合にはPDFBoxを使うとめっちゃ高機能で便利です
でもコイツ、よく理解せずにえいやって使うと簡単にメモリ周りでハマります
...ハマりませんか?
私は見事にハマりましたorz

前提環境

PDFBox v2.0.8
Java 1.8

やろうとしたこと

簡単に説明すると、

  1. あるテンプレート用のPDFファイルがあり、このテンプレートPDFの1ページ目をメモリに展開する
  2. テンプレートの内容に追記モードでメモリ上で書き換える
  3. 出力先のPDFストリームへ書き換えたページを追加する

つまり、よくある帳票システムをPDFでやろうとしたんです

100ページくらいの描画なら普通に動く

"やろうとしたこと"を素直に実装した下記のコードは実際、環境やテンプレートPDFのファイルサイズに依存するものの、恐らく100~200枚くらいなら期待通りに動作します

下記はx:10mm, y:10mmを中心座標として文字列を描画したページを追加していくコード

public static void main(String[] args) {
        final File tOutputPdfFile = new File("/home/zienchan/project/test/output.pdf");
        final PDDocument tOutputDocument = new PDDocument();
        final File tTemplatePdf = new File("/home/zienchan/project/test/template.pdf");

        try (PDDocument tDesignDocument = PDDocument.load(tTemplatePdf)) {
            final PDPage tTemplatePage = tDesignDocument.getPage(0);

            //仮に1000ページ追加するとする
            for (int i = 0; i < 1000; i++) {
                tOutputDocument.addPage(tTemplatePage);
                final PDPage tCurrentPage = tOutputDocument.getPage(tOutputDocument.getPages().getCount() - 1);
                final PDPageContentStream tContentStream = new PDPageContentStream(tOutputDocument, tCurrentPage, PDPageContentStream.AppendMode.APPEND, true);
                final String tValue = "TEST RENDER STRING VALUE!日本語もあるんだぴょーん" + i;
                PDFont tFont = PDType1Font.COURIER;
                double fontSize = 9;
                tFont = PDType0Font.load(tOutputDocument, new File("/home/zienchan/project/test/fontname.ttf"));
                tContentStream.beginText();
                tContentStream.setFont(tFont, (int) fontSize);
                float tStringWidth = tFont.getStringWidth(tValue) * (float) (int) fontSize / 1000f;
                float tStringHeight = tFont.getHeight('H') * (float) (int) fontSize;
                tContentStream.newLineAtOffset(MeasureUtil.m2p((float) 10.0) - tStringWidth / 2
                        , tCurrentPage.getMediaBox().getHeight() - MeasureUtil.m2p((float) 10.0) - tStringHeight / 2);
                tContentStream.showText(tValue);
                tContentStream.endText();
                tContentStream.close();
                System.out.println(i + ":" + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "MB");
            }

            tOutputDocument.save(tOutputPdfFile);
            tOutputDocument.close();
        } catch (IOException aE) {
            aE.printStackTrace();
        }
    }

動きますが...いずれヒープが不足してこんなエラーを吐きます
(吐かないまま落ちるケースもあります)

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at org.apache.fontbox.ttf.VerticalMetricsTable.read(VerticalMetricsTable.java:64)
	at org.apache.fontbox.ttf.TrueTypeFont.readTable(TrueTypeFont.java:335)
	at org.apache.fontbox.ttf.TTFParser.parseTables(TTFParser.java:173)
	at org.apache.fontbox.ttf.TTFParser.parse(TTFParser.java:150)
	at org.apache.fontbox.ttf.TTFParser.parse(TTFParser.java:87)
	at org.apache.pdfbox.pdmodel.font.PDType0Font.load(PDType0Font.java:66)
	at jp.qwx.aoi.variable.ticket.pritner.test.bug.main(bug.java:30)

原因

いくつかあります

フォントの解放忘れ

PDType0Font.load(tOutputDocument, new File("/home/zienchan/project/test/fontname.ttf"));

私は完全に見落としていましたが、まずこいつがメモリリークしてます
このサンプルでは無意味なので一回読めばいいじゃんかって感じですが、実際のコードはフォントの設定が細かに可能になっていて、キャッシュ制御はローダー側がやってくれてると思い込んでました...

実際は使う側で不要になったらサブセットを閉じるか、性能問題に直結するのでキャッシュして最後に閉じる等の制御が必要です

分割保存が必要な場合がある

PDDocumentのsaveメソッドはファイルに書き出すため、最後の一回に一度で行いたいところですが、PDFの規模によってはどうやらsave中にメモリ不足になって落ちます...
この場合、分割(一定のまとまりでsave)するなりの処理が必要です

分割保存するくらいなら、結合するアプローチを取ったほうが良い場合がある

上記のsaveメソッドですが、公式の課題にもあるようにめちゃくちゃ遅いんです
当然ですがページ数やファイルサイズがでかくなればなるほど急激に遅くなります
なので出来れば可能な限り少ない単位で一度だけsaveしたいところです

またPDFBoxのPDFDocumentはスレッドセーフでないので、並列して同じドキュメントを編集できません
これでは同一ドキュメントに並列で編集したりページを追加して高速化するといった手法は取れません

従って、大量のページを作成する場合はPDFMergerUtilityを使ったほうが高速になるケースがあります
具体的には並列にそれぞれ固有のPDFDocumentインスタンスを作成し、1ページ(または任意のページ)をレンダリングし追加、保存します
これによって並列で分割保存された全PDFが作成されたタイミングで全体をソートしてPDFMergerUtilityで一括マージすることで安全に並列化できます
更に並列数にもよりますが、これなら一度に消費するメモリも少なく、またコントロールできます

根本的な原因について

時間があったら根本的な原因を探したい(PDF仕様とコードを読みたい)ですが、結局私は並列にマージするアプローチで少ないヒープで安定して満足のいく速度が出るようになったのでとりあえず解決としてます