Bagaimana tidak membuang sampah sembarangan di Jawa

Ada kesalahpahaman yang populer bahwa jika Anda tidak suka pengumpulan sampah, maka Anda perlu menulis bukan di Jawa, tetapi di C / C ++. Selama tiga tahun terakhir saya telah menulis kode Java latensi rendah untuk perdagangan mata uang, dan saya harus menghindari membuat objek yang tidak perlu dalam segala hal. Sebagai hasilnya, saya merumuskan beberapa aturan sederhana untuk diri saya sendiri, bagaimana mengurangi alokasi di Jawa, jika tidak ke nol, kemudian ke beberapa minimum yang masuk akal, tanpa menggunakan manajemen memori manual. Mungkin itu juga akan berguna bagi seseorang dari komunitas.


Mengapa menghindari sampah sama sekali


Tentang apa itu GC dan bagaimana mengkonfigurasinya, katanya dan banyak ditulis. Tetapi pada akhirnya, tidak peduli bagaimana Anda mengatur GC, kode yang membuang sampah akan bekerja secara optimal. Selalu ada trade-off antara throughput dan latensi. Menjadi tidak mungkin untuk memperbaiki yang satu tanpa memperburuk yang lain. Sebagai aturan, overhead GC diukur dengan mempelajari log - Anda dapat memahami dari mereka pada saat-saat apa ada jeda dan berapa banyak waktu yang dibutuhkan. Namun, log GC tidak mengandung semua informasi tentang overhead ini. Objek yang dibuat oleh utas secara otomatis ditempatkan di cache L1 inti prosesor di mana utas berjalan. Ini mengarah pada crowding out dari data lain yang berpotensi berguna. Dengan sejumlah besar alokasi, data yang berguna juga dapat didorong keluar dari cache L3. Lain kali utas mengakses data ini, akan terjadi cache yang hilang, yang akan menyebabkan keterlambatan dalam pelaksanaan program. Selain itu, karena cache L3 adalah umum untuk semua core dalam prosesor yang sama, aliran sampah akan mendorong data dan utas / aplikasi lain dari cache L3, dan mereka sudah akan mengalami kegagalan cache yang lebih mahal, bahkan jika mereka ditulis dalam bare C dan jangan membuat sampah. Tidak ada pengaturan, tidak ada pengumpul sampah (baik C4, maupun ZGC) tidak akan membantu mengatasi masalah ini. Satu-satunya cara untuk memperbaiki situasi secara keseluruhan adalah dengan tidak membuat objek yang tidak perlu. Java, tidak seperti C ++, tidak memiliki banyak sekali mekanisme untuk bekerja dengan memori, namun demikian, ada sejumlah cara untuk meminimalkan alokasi. Mereka akan dibahas.


Penyimpangan liris

Tentu saja, Anda tidak perlu menulis semua kode bebas sampah. Hal tentang bahasa Jawa adalah Anda dapat sangat menyederhanakan hidup Anda hanya dengan membuang sumber sampah utama. Anda juga tidak dapat menangani reklamasi memori yang aman saat menulis algoritma bebas kunci. Jika kode tertentu dijalankan hanya sekali pada saat startup aplikasi, maka kode tersebut dapat mengalokasikan sebanyak yang Anda suka, dan itu bukan masalah besar. Dan tentu saja, alat kerja utama untuk membuang kelebihan sampah adalah pengalokasian alokasi.


Menggunakan tipe primitif


Hal paling sederhana yang dapat dilakukan dalam banyak kasus adalah menggunakan tipe primitif daripada tipe objek. JVM memiliki sejumlah optimisasi untuk meminimalkan overhead tipe objek, seperti caching nilai kecil tipe integer dan inlining kelas sederhana. Tetapi optimisasi ini tidak selalu layak untuk diandalkan, karena mereka mungkin tidak berhasil: nilai integer mungkin tidak di-cache, dan inlining mungkin tidak terjadi. Selain itu, ketika bekerja dengan Integer bersyarat, kami dipaksa untuk mengikuti tautan, yang berpotensi mengarah pada cache miss. Juga, semua objek memiliki tajuk yang mengambil ruang ekstra di cache, memadatkan data lain dari sana. Mari kita ambil: int primitif membutuhkan 4 byte. Object Integer menempati 16 byte + ukuran tautan ke Integer ini adalah minimum 4 byte (dalam kasus oops terkompresi). Secara total, ternyata Integer membutuhkan lima (!) Kali lebih banyak ruang daripada int . Karena itu, lebih baik menggunakan tipe primitif sendiri. Saya akan memberikan beberapa contoh.


Contoh 1. Perhitungan konvensional


Katakanlah kita memiliki fungsi reguler yang hanya menghitung sesuatu.


 Integer getValue(Integer a, Integer b, Integer c) { return (a + b) / c; } 

Kode seperti itu cenderung menjadi sebaris (baik metode dan kelas) dan tidak akan mengarah pada alokasi yang tidak perlu, tetapi Anda tidak dapat memastikan hal ini. Bahkan jika ini terjadi, akan ada masalah dengan fakta bahwa NullPointerException bisa terbang keluar dari sini. Salah satu cara atau yang lain, JVM harus memasukkan cek null bawah tenda, atau entah bagaimana memahami dari konteks bahwa null tidak dapat datang sebagai argumen. Bagaimanapun, lebih baik menulis kode yang sama pada primitif.


 int getValue(int a, int b, int c) { return (a + b) / c; } 

Contoh 2. Lambdas


Terkadang benda diciptakan tanpa sepengetahuan kita. Misalnya, jika kita meneruskan tipe primitif ke tempat tipe objek diharapkan. Ini sering terjadi ketika menggunakan ekspresi lambda.
Bayangkan kita memiliki kode ini:


 void calculate(Consumer<Integer> calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Terlepas dari kenyataan bahwa variabel x adalah primitif, objek bertipe Integer akan dibuat, yang akan diteruskan ke kalkulator. Untuk menghindarinya, gunakan IntConsumer sebagai ganti Consumer<Integer> :


 void calculate(IntConsumer calculator) { int x = System.currentTimeMillis(); calculator.accept(x); } 

Kode semacam itu tidak akan lagi mengarah pada penciptaan objek tambahan. Java.util.function memiliki seluruh rangkaian antarmuka standar yang disesuaikan untuk menggunakan tipe primitif: DoubleSupplier , LongFunction , dll. Nah, jika ada sesuatu yang hilang, maka Anda selalu dapat menambahkan antarmuka yang diinginkan dengan primitif. Misalnya, alih-alih BiConsumer<Integer, Double> Anda bisa menggunakan antarmuka buatan.


 interface IntDoubleConsumer { void accept(int x, double y); } 

Contoh 3. Koleksi


Menggunakan tipe primitif bisa jadi sulit karena variabel tipe ini ada dalam koleksi. Misalkan kita memiliki beberapa List<Integer> dan kami ingin mengetahui angka apa yang ada di dalamnya dan menghitung berapa kali masing-masing angka diulang. Untuk ini, kami menggunakan HashMap<Integer, Integer> . Kode ini terlihat seperti ini:


 List<Integer> numbers = new ArrayList<>(); // fill numbers somehow Map<Integer, Integer> counters = new HashMap<>(); for (Integer x : numbers) { counters.compute(x, (k, v) -> v == null ? 1 : v + 1); } 

Kode ini buruk dalam beberapa cara sekaligus. Pertama, ia menggunakan struktur data perantara, yang mungkin bisa dilakukan tanpa. Baiklah, oke, untuk kesederhanaan, kita mengasumsikan bahwa daftar ini akan dibutuhkan nanti, mis. Anda tidak dapat sepenuhnya menghapusnya. Kedua, objek Integer digunakan di kedua tempat bukan int primitif. Ketiga, ada banyak alokasi dalam metode compute . Keempat, iterator dialokasikan. Alokasi ini cenderung menjadi inline, namun demikian. Bagaimana mengubah kode ini menjadi kode bebas sampah? Anda hanya perlu menggunakan koleksi pada primitif dari beberapa perpustakaan pihak ketiga. Ada sejumlah perpustakaan yang berisi koleksi seperti itu. Sepotong kode berikut menggunakan pustaka agrona .


 IntArrayList numbers = new IntArrayList(); // fill numbers somehow Int2IntCounterMap counters = new Int2IntCounterMap(0); for (int i = 0; i < numbers.size(); i++) { counters.incrementAndGet(numbers.getInt(i)); } 

Objek yang dibuat di sini adalah dua koleksi dan dua int[] , yang terletak di dalam koleksi ini. Kedua koleksi dapat digunakan kembali dengan memanggil metode clear() pada mereka. Menggunakan koleksi pada primitif, kami tidak mempersulit kode kami (dan bahkan menyederhanakannya dengan menghapus metode komputasi dengan lambda kompleks di dalamnya) dan menerima bonus tambahan berikut dibandingkan dengan menggunakan koleksi standar:


  1. Hampir tidak ada alokasi sama sekali. Jika koleksi digunakan kembali, maka tidak akan ada alokasi sama sekali.
  2. Penghematan memori yang signifikan ( IntArrayList membutuhkan ruang sekitar lima kali lebih sedikit daripada ArrayList<Integer> . Seperti yang telah disebutkan, kami peduli tentang penggunaan cache prosesor yang ekonomis, bukan RAM.
  3. Akses serial ke memori. Banyak yang telah ditulis pada topik mengapa ini penting, jadi saya tidak akan berhenti di situ. Berikut adalah beberapa artikel: Martin Thompson dan Ulrich Drepper .

Komentar kecil lainnya tentang koleksi. Mungkin ternyata koleksi tersebut berisi nilai dari tipe yang berbeda, dan oleh karena itu tidak mungkin untuk menggantinya dengan koleksi dengan primitif. Menurut pendapat saya, ini adalah tanda desain yang buruk dari struktur data atau algoritma secara keseluruhan. Kemungkinan besar dalam hal ini, alokasi objek tambahan bukanlah masalah utama.


Benda yang bisa berubah


Tetapi bagaimana jika primitif tidak dapat ditiadakan? Sebagai contoh, jika metode yang kita butuhkan harus mengembalikan beberapa nilai. Jawabannya sederhana - gunakan benda yang bisa berubah.


Penyimpangan kecil

Beberapa bahasa menekankan penggunaan objek yang tidak dapat diubah, misalnya dalam Scala. Argumen utama yang mendukung mereka adalah bahwa penulisan kode multithread sangat disederhanakan. Namun, ada juga overhead yang terkait dengan alokasi sampah yang berlebihan. Jika kita ingin menghindarinya, maka kita seharusnya tidak membuat objek abadi yang berumur pendek.


Seperti apa praktiknya? Misalkan kita perlu menghitung hasil bagi dan sisa divisi. Dan untuk ini kami menggunakan kode berikut.


 class IntPair { int x; int y; } IntPair divide(int value, int divisor) { IntPair result = new IntPair(); result.x = value / divisor; result.y = value % divisor; return result; } 

Bagaimana cara menyingkirkan alokasi dalam kasus ini? Itu benar, lulus IntPair sebagai argumen dan tulis hasilnya di sana. Dalam hal ini, Anda perlu menulis javadoc terperinci, dan bahkan lebih baik lagi, menggunakan semacam konvensi untuk nama variabel, di mana hasilnya ditulis. Misalnya, mereka dapat mulai dengan awalan keluar. Kode bebas sampah dalam hal ini akan terlihat seperti ini:


 void divide(int value, int divisor, IntPair outResult) { outResult.x = value / divisor; outResult.y = value % divisor; } 

Saya ingin mencatat bahwa metode divide tidak boleh menyimpan tautan untuk memasangkan di mana saja atau meneruskannya ke metode yang dapat melakukan ini, jika tidak kita mungkin memiliki masalah besar. Seperti yang dapat kita lihat, objek yang dapat berubah lebih sulit digunakan daripada tipe primitif, jadi jika Anda dapat menggunakan primitif, maka lebih baik melakukannya. Faktanya, dalam contoh kami, kami memindahkan masalah alokasi dari dalam metode pembagian ke luar. Di semua tempat di mana kita memanggil metode ini, kita perlu memiliki beberapa boneka IntPair , yang akan kita divide untuk divide . Cukup sering untuk menyimpan boneka ini di bidang final objek, dari mana kita memanggil metode divide . Izinkan saya memberi Anda sebuah contoh yang tidak masuk akal: misalkan program kami hanya berurusan dengan menerima aliran angka melalui jaringan, membaginya, dan mengirimkan hasilnya ke soket yang sama.


 class SocketListener { private final IntPair pair = new IntPair(); private final BufferedReader in; private final PrintWriter out; SocketListener(final Socket socket) throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); divide(value, divisor, pair); out.print(pair.x); out.print(pair.y); } } } 

Untuk singkatnya, saya tidak menulis kode "ekstra" untuk penanganan kesalahan, penghentian program yang benar, dll. Ide utama dari potongan kode ini adalah bahwa objek IntPair kita IntPair dibuat satu kali dan disimpan di bidang final .


Kolam objek


Ketika kita menggunakan objek yang bisa berubah, pertama-tama kita harus mengambil objek kosong dari suatu tempat, kemudian menulis data yang kita butuhkan, menggunakannya di suatu tempat, dan kemudian mengembalikan objek "di tempat". Dalam contoh di atas, objek selalu "di tempat", mis. di bidang final . Sayangnya, ini tidak selalu mungkin dilakukan dengan cara yang sederhana. Sebagai contoh, kita mungkin tidak tahu sebelumnya berapa banyak objek yang kita butuhkan. Dalam hal ini, kumpulan objek datang untuk membantu kami. Ketika kita membutuhkan objek kosong, kita mendapatkannya dari kumpulan objek, dan ketika itu tidak lagi dibutuhkan, kita mengembalikannya ke sana. Jika tidak ada objek gratis di kolam, maka kolam menciptakan objek baru. Ini sebenarnya manajemen memori manual dengan semua konsekuensi berikutnya. Dianjurkan untuk tidak menggunakan metode ini jika dimungkinkan untuk menggunakan metode sebelumnya. Apa yang bisa salah?


  • Kita bisa lupa mengembalikan objek ke kolam, dan kemudian sampah ("kebocoran memori") akan dibuat. Ini adalah masalah kecil - kinerja akan sedikit menurun, tetapi GC akan berhasil dan program akan terus bekerja.
  • Kita dapat mengembalikan objek ke kolam, tetapi menyimpan tautan ke suatu tempat. Maka orang lain akan mendapatkan objek dari kolam, dan pada titik ini dalam program kami sudah ada dua tautan ke objek yang sama. Ini adalah masalah klasik setelah penggunaan gratis. Sulit untuk debut karena tidak seperti C ++, program tidak akan macet dan akan terus bekerja secara salah .

Untuk mengurangi kemungkinan melakukan kesalahan di atas, Anda dapat menggunakan konstruk try-with-resources standar. Ini mungkin terlihat seperti ini:


 public interface Storage<T> { T get(); void dispose(T object); } class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} public static IntPair create() { return STORAGE.get(); } @Override public void close() { STORAGE.dispose(this); } } 

Metode pembagian mungkin terlihat seperti ini:


 IntPair divide(int value, int divisor) { IntPair result = IntPair.create(); result.x = value / divisor; result.y = value % divisor; return result; } 

Dan metode listenSocket seperti ini:


 void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); try (IntPair pair = divide(value, divisor)) { out.print(pair.x); out.print(pair.y); } } } 

Dalam IDE, Anda biasanya dapat mengonfigurasi sorotan semua kasus ketika objek AutoCloseable digunakan di luar blok coba-dengan-sumber daya. Tapi ini bukan pilihan mutlak, karena menyoroti dalam IDE hanya bisa dimatikan. Oleh karena itu, ada cara lain untuk menjamin kembalinya objek ke inversi kontrol kolam. Saya akan memberi contoh:


 class IntPair implements AutoCloseable { private static final Storage<IntPair> STORAGE = new StorageImpl(IntPair::new); int x; int y; private IntPair() {} private static void apply(Consumer<IntPair> consumer) { try(IntPair pair = STORAGE.get()) { consumer.accept(pair); } } @Override public void close() { STORAGE.dispose(this); } } 

Dalam hal ini, pada dasarnya kami tidak dapat mengakses objek dari kelas IntPair luar. Sayangnya, metode ini juga tidak selalu berhasil. Misalnya, itu tidak akan berfungsi jika satu utas mengambil objek dari pool dan menempatkannya dalam antrian, dan utas lainnya mengeluarkannya dari antrian dan kembali ke pool.


Jelas, jika kita tidak menyimpan objek generik di kumpulan, tetapi beberapa objek perpustakaan yang tidak mengimplementasikan AutoCloseable , maka opsi coba-dengan-sumber daya tidak akan berfungsi baik.


Masalah tambahan di sini adalah multithreading. Implementasi pool objek harus sangat cepat, yang cukup sulit untuk dicapai. Kolam yang lambat bisa lebih merusak kinerja daripada kebaikan. Pada gilirannya, alokasi objek baru di TLAB sangat cepat, jauh lebih cepat daripada malloc di C. Menulis kumpulan objek cepat adalah topik terpisah yang tidak ingin saya kembangkan sekarang. Saya hanya bisa mengatakan bahwa saya belum melihat implementasi "siap pakai" yang bagus.


Alih-alih sebuah kesimpulan


Singkatnya, menggunakan kembali objek dengan kolam objek adalah wasir yang serius. Untungnya, hampir selalu Anda dapat melakukannya tanpanya. Pengalaman pribadi saya adalah bahwa penggunaan objek yang berlebihan menandakan masalah dengan arsitektur aplikasi. Sebagai aturan, satu contoh objek yang di-cache di bidang final sudah cukup bagi kita. Tetapi bahkan ini berlebihan jika memungkinkan untuk menggunakan tipe primitif.


Perbarui:


Ya, saya ingat cara lain bagi mereka yang tidak takut dengan perubahan bitwise: mengemas beberapa tipe primitif kecil menjadi yang besar. Misalkan kita perlu mengembalikan dua int . Dalam kasus khusus ini, Anda tidak dapat menggunakan objek IntPair , tetapi mengembalikan satu long , 4 byte pertama di mana akan sesuai dengan int pertama, dan yang kedua 4 byte ke yang kedua. Kode mungkin terlihat seperti ini:


 long combine(int left, int right) { return ((long)left << Integer.SIZE) | (long)right & 0xFFFFFFFFL; } int getLeft(long value) { return (int)(value >>> Integer.SIZE); } int getRight(long value) { return (int)value; } long divide(int value, int divisor) { int x = value / divisor; int y = value % divisor; return combine(left, right); } void listenSocket() throws IOException { while (true) { int value = in.read(); int divisor = in.read(); long xy = divide(value, divisor); out.print(getLeft(xy)); out.print(getRight(xy)); } } 

Metode seperti itu, tentu saja, perlu diuji secara menyeluruh, karena cukup mudah untuk menuliskannya. Tapi kemudian gunakan saja.

Source: https://habr.com/ru/post/id436024/


All Articles