Efek Performa Pengecualian di Java

1. Ikhtisar

Di Java, pengecualian umumnya dianggap mahal dan tidak boleh digunakan untuk kontrol aliran. Tutorial ini akan membuktikan bahwa persepsi ini benar dan menunjukkan dengan tepat apa yang menyebabkan masalah kinerja.

2. Menyiapkan Lingkungan

Sebelum menulis kode untuk mengevaluasi biaya kinerja, kita perlu menyiapkan lingkungan pembandingan.

2.1. Java Microbenchmark Harness

Mengukur overhead pengecualian tidak semudah menjalankan metode dalam putaran sederhana dan mencatat total waktu.

Alasannya adalah kompiler just-in-time dapat menghalangi dan mengoptimalkan kode. Pengoptimalan semacam itu dapat membuat kode berkinerja lebih baik daripada yang sebenarnya dilakukan di lingkungan produksi. Dengan kata lain, hal itu mungkin memberikan hasil yang salah positif.

Untuk membuat lingkungan terkontrol yang dapat mengurangi pengoptimalan JVM, kita akan menggunakan Java Microbenchmark Harness, atau disingkat JMH.

Subbagian berikut akan memandu pengaturan lingkungan pembandingan tanpa membahas detail JMH. Untuk informasi lebih lanjut tentang alat ini, silakan lihat tutorial Microbenchmarking dengan Java kami.

2.2. Mendapatkan Artefak JMH

Untuk mendapatkan artefak JMH, tambahkan dua dependensi berikut ke POM:

 org.openjdk.jmh jmh-core 1.21   org.openjdk.jmh jmh-generator-annprocess 1.21 

Silakan merujuk ke Maven Central untuk versi terbaru dari JMH Core dan JMH Annotation Processor.

2.3. Kelas Tolok Ukur

Kami membutuhkan kelas untuk memegang tolok ukur:

@Fork(1) @Warmup(iterations = 2) @Measurement(iterations = 10) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) public class ExceptionBenchmark { private static final int LIMIT = 10_000; // benchmarks go here }

Mari kita lihat anotasi JMH yang ditunjukkan di atas:

  • @Fork : Menentukan berapa kali JMH harus menelurkan proses baru untuk menjalankan benchmark. Kami menetapkan nilainya ke 1 untuk menghasilkan hanya satu proses, menghindari menunggu terlalu lama untuk melihat hasilnya
  • @ Pemanasan : Membawa parameter pemanasan. The iterasi elemen menjadi 2 cara pertama dua berjalan diabaikan ketika menghitung hasilnya
  • @Pengukuran : Membawa parameter pengukuran. Sebuah iterasi nilai 10 menunjukkan JMH akan mengeksekusi setiap metode 10 kali
  • @BenchmarkMode : Ini adalah cara JHM mengumpulkan hasil eksekusi. Nilai AverageTime memerlukan JMH untuk menghitung waktu rata-rata yang diperlukan metode untuk menyelesaikan operasinya
  • @OutputTimeUnit : Menunjukkan unit waktu keluaran, yang dalam kasus ini adalah milidetik

Selain itu, ada bidang statis di dalam badan kelas, yaitu LIMIT . Ini adalah jumlah iterasi di setiap badan metode.

2.4. Menjalankan Tolok Ukur

Untuk menjalankan benchmark, kita membutuhkan metode utama :

public class MappingFrameworksPerformance { public static void main(String[] args) throws Exception { org.openjdk.jmh.Main.main(args); } }

Kami dapat mengemas proyek ke dalam file JAR dan menjalankannya di baris perintah. Melakukannya sekarang, tentu saja, akan menghasilkan keluaran kosong karena kita belum menambahkan metode pembandingan apa pun.

Untuk kenyamanan, kita bisa menambahkan maven-jar-plugin ke POM. Plugin ini memungkinkan kita mengeksekusi metode utama di dalam IDE:

org.apache.maven.plugins maven-jar-plugin 3.2.0    com.baeldung.performancetests.MappingFrameworksPerformance    

Versi terbaru maven-jar-plugin dapat ditemukan di sini.

3. Pengukuran Kinerja

Saatnya memiliki beberapa metode pembandingan untuk mengukur kinerja. Masing-masing metode ini harus membawa anotasi @Benchmark .

3.1. Metode Pengembalian Biasanya

Mari kita mulai dengan metode yang kembali secara normal; yaitu, metode yang tidak memunculkan pengecualian:

@Benchmark public void doNotThrowException(Blackhole blackhole) { for (int i = 0; i < LIMIT; i++) { blackhole.consume(new Object()); } }

The blackhole parameter referensi contoh Blackhole . Ini adalah kelas JMH yang membantu mencegah penghapusan kode mati, pengoptimalan yang dapat dilakukan oleh kompiler just-in-time.

Tolok ukur, dalam hal ini, tidak mengeluarkan pengecualian apa pun. Faktanya, kami akan menggunakannya sebagai referensi untuk mengevaluasi kinerja mereka yang melakukan pengecualian.

Menjalankan metode utama akan memberi kami laporan:

Benchmark Mode Cnt Score Error Units ExceptionBenchmark.doNotThrowException avgt 10 0.049 ± 0.006 ms/op

There's nothing special in this result. The average execution time of the benchmark is 0.049 milliseconds, which is per se pretty meaningless.

3.2. Creating and Throwing an Exception

Here's another benchmark that throws and catches exceptions:

@Benchmark public void throwAndCatchException(Blackhole blackhole) { for (int i = 0; i < LIMIT; i++) { try { throw new Exception(); } catch (Exception e) { blackhole.consume(e); } } }

Let's have a look at the output:

Benchmark Mode Cnt Score Error Units ExceptionBenchmark.doNotThrowException avgt 10 0.048 ± 0.003 ms/op ExceptionBenchmark.throwAndCatchException avgt 10 17.942 ± 0.846 ms/op

The small change in the execution time of method doNotThrowException isn't important. It's just the fluctuation in the state of the underlying OS and the JVM. The key takeaway is that throwing an exception makes a method run hundreds of times slower.

The next few subsections will find out what exactly leads to such a dramatic difference.

3.3. Creating an Exception Without Throwing It

Instead of creating, throwing, and catching an exception, we'll just create it:

@Benchmark public void createExceptionWithoutThrowingIt(Blackhole blackhole) { for (int i = 0; i < LIMIT; i++) { blackhole.consume(new Exception()); } }

Now, let's execute the three benchmarks we've declared:

Benchmark Mode Cnt Score Error Units ExceptionBenchmark.createExceptionWithoutThrowingIt avgt 10 17.601 ± 3.152 ms/op ExceptionBenchmark.doNotThrowException avgt 10 0.054 ± 0.014 ms/op ExceptionBenchmark.throwAndCatchException avgt 10 17.174 ± 0.474 ms/op

The result may come as a surprise: the execution time of the first and the third methods are nearly the same, while that of the second is substantially smaller.

At this point, it's clear that the throw and catch statements themselves are fairly cheap. The creation of exceptions, on the other hand, produces high overheads.

3.4. Throwing an Exception Without Adding the Stack Trace

Let's figure out why constructing an exception is much more expensive than doing an ordinary object:

@Benchmark @Fork(value = 1, jvmArgs = "-XX:-StackTraceInThrowable") public void throwExceptionWithoutAddingStackTrace(Blackhole blackhole) { for (int i = 0; i < LIMIT; i++) { try { throw new Exception(); } catch (Exception e) { blackhole.consume(e); } } }

The only difference between this method and the one in subsection 3.2 is the jvmArgs element. Its value -XX:-StackTraceInThrowable is a JVM option, keeping the stack trace from being added to the exception.

Let's run the benchmarks again:

Benchmark Mode Cnt Score Error Units ExceptionBenchmark.createExceptionWithoutThrowingIt avgt 10 17.874 ± 3.199 ms/op ExceptionBenchmark.doNotThrowException avgt 10 0.046 ± 0.003 ms/op ExceptionBenchmark.throwAndCatchException avgt 10 16.268 ± 0.239 ms/op ExceptionBenchmark.throwExceptionWithoutAddingStackTrace avgt 10 1.174 ± 0.014 ms/op

By not populating the exception with the stack trace, we reduced execution duration by more than 100 times. Apparently, walking through the stack and adding its frames to the exception bring about the sluggishness we've seen.

3.5. Throwing an Exception and Unwinding Its Stack Trace

Finally, let's see what happens if we throw an exception and unwind the stack trace when catching it:

@Benchmark public void throwExceptionAndUnwindStackTrace(Blackhole blackhole) { for (int i = 0; i < LIMIT; i++) { try { throw new Exception(); } catch (Exception e) { blackhole.consume(e.getStackTrace()); } } }

Here's the outcome:

Benchmark Mode Cnt Score Error Units ExceptionBenchmark.createExceptionWithoutThrowingIt avgt 10 16.605 ± 0.988 ms/op ExceptionBenchmark.doNotThrowException avgt 10 0.047 ± 0.006 ms/op ExceptionBenchmark.throwAndCatchException avgt 10 16.449 ± 0.304 ms/op ExceptionBenchmark.throwExceptionAndUnwindStackTrace avgt 10 326.560 ± 4.991 ms/op ExceptionBenchmark.throwExceptionWithoutAddingStackTrace avgt 10 1.185 ± 0.015 ms/op

Hanya dengan melepas pelacakan tumpukan, kami melihat peningkatan besar sekitar 20 kali lipat dalam durasi eksekusi. Dengan kata lain, kinerjanya jauh lebih buruk jika kita mengekstrak pelacakan tumpukan dari pengecualian sebagai tambahan untuk membuangnya.

4. Kesimpulan

Dalam tutorial ini, kami menganalisis efek kinerja dari pengecualian. Secara khusus, diketahui bahwa biaya kinerja sebagian besar adalah tambahan dari pelacakan tumpukan ke pengecualian. Jika jejak tumpukan ini dibatalkan setelahnya, overhead menjadi jauh lebih besar.

Karena membuang dan menangani pengecualian itu mahal, kita tidak boleh menggunakannya untuk aliran program normal. Sebaliknya, seperti yang tersirat dari namanya, pengecualian hanya boleh digunakan untuk kasus-kasus luar biasa.

Kode sumber lengkap dapat ditemukan di GitHub.