ログファイルをメモリに抱え込んではいけない

今回エラーを引き起こしたのは以下のようなクラス(実際にはもう少し色々やっているが割愛)。

public class Reader {
    public List<Log> get(Path file) throws IOException {
        List<String> values = new ArrayList<>();
        try (Scanner scanner = new Scanner(file)) {
            while (scanner.hasNext()) {
                values.add(scanner.nextLine());
            }
        }

        return values.stream().map(Log::new).collect(Collectors.toList());
    }
}
public class Parser {
    public static void main(String[] args) throws IOException {
        new Parser().exec(Path.of("output.log"));
    }

    public void exec(Path file) throws IOException {
        List<Log> logs = new Reader().get(file).stream()
                .filter(log -> Long.parseLong(log.getLine().split(" ")[0]) % 33 == 0)
                .collect(Collectors.toList());

        System.out.println("count = " + logs.size());

        logs.forEach(log -> System.out.println(log.getLine()));
    }
}

output.logのサンプルは以下(これも実際はもっと別の形式だが割愛)。

0 1c12d26c-74e1-44f8-a39a-f3f648ad4050
1 ed9979ee-59fc-4f21-b2dc-9b01001c0943
2 7e776d65-3c6f-4e69-82af-c0e8c21aeef6
3 26f058a6-57ba-45f5-ab16-0596b8d41156
4 b8093ff4-935c-48d1-b79f-2e856d5b0684
5 16aba95f-9c80-499e-80b1-73083336fd37
...

Readerクラスでファイルの内容をすべて読み込んだ後別の形式に変換してリスト化し、Parserクラスでその中の特定のものだけをフィルタリングした後、フィルタリングしたものに対して処理を行っている(今回はコンソールに出力しているだけ)。

dev環境だとテストに使用したログファイルのサイズが小さかったため特に問題なかったが、本番環境のデータを使用して動かすとログファイルのサイズがでかすぎたのか異常終了して処理に失敗した。まぁファイルの内容をすべてメモリに抱え込んでいるため、当たり前と言えば当たり前ではある。

結局以下のような修正を行うことで正しく実行できることを確認した。

public class Reader2 {
    public Stream<Log> get(Path file) throws IOException {
        return new Scanner(file).useDelimiter("\n").tokens().map(Log::new);
    }
}
public class Parser2 {
    public static void main(String[] args) throws IOException {
        new Parser2().exec(Path.of("output.log"));
    }

    public void exec(Path file) throws IOException {
        try (Stream<Log> logStream = new Reader2().get(file)) {
            logStream.filter(log -> Long.parseLong(log.getLine().split(" ")[0]) % 33 == 0)
                    .forEach(log -> System.out.println(log.getLine()));
        }
    }
}

ログファイルの内容をすべてメモリに抱え込むのではなく、1行1行をストリームとして逐次処理する形式にしてやることでメモリへの影響を抑えることが出来た(サンプルはちょっと無精して System.out.println("count = " + logs.size()); の行が無くなっているが)。
実際Reader、Parserのクラスはヒープメモリのサイズを256MBくらい、output.logが600MBくらいにした状態で実行するとOut Of Memoryのエラーが出て処理に失敗するが、Reader2、Parser2のクラスだと同じ条件でも正しく実行することが出来る。

ファイル、特にログファイルのようなものはサイズが大きくなりがちなので、あまり全てを一度に読み込もうとするのではなく、Streamのような形式で1行ずつ処理していったほうが良い。