# Catatan. Perhatian operasi atom di ConcurrentHashMap



Sejak dahulu kala, Java telah memiliki antarmuka Peta yang luar biasa dan implementasinya, khususnya, HashMap . Dan mulai dengan Java 5, ada juga ConcurrentHashMap . Pertimbangkan dua implementasi ini, evolusi mereka dan apa yang evolusi ini dapat menyebabkan pengembang lalai.

Peringatan: artikel ini menggunakan kutipan dari kode sumber OpenJDK 8, didistribusikan di bawah GNU General Public License versi 2.

Waktu Sebelum Jawa 8


Mereka yang menemukan waktu tunggu yang sangat lama, pertama Java 7, dan kemudian Java 8 (tidak seperti sekarang, setiap enam bulan versi baru), ingat operasi dengan Map mana yang paling populer. Ini adalah:


Jika Anda perlu memasukkan nilai dalam koleksi, kami menggunakan metode pertama, dan untuk mendapatkan nilai yang ada, gunakan yang kedua.

Tetapi bagaimana jika inisialisasi malas diperlukan? Kemudian kode semacam ini muncul:

String getOrPut(String key) { String result = map.get(key); //(1) if (result == null) { //(2) result = createValue(key); //(3) map.put(key, result); //(4) } return result; } 

  1. kami mendapatkan nilai dengan kunci
  2. periksa apakah nilai yang diinginkan ditemukan
  3. jika tidak ada nilai yang ditemukan, maka buatlah
  4. menambah nilai koleksi dengan kunci

Ternyata sedikit merepotkan, bukan? Selain itu, dalam kasus ketika HashMap sederhana digunakan, maka ini hanya kode yang tidak nyaman untuk dibaca, karena dia tidak di utas. Tetapi dalam kasus ConcurrentHashMap, fitur tambahan muncul: metode createValue (2) dapat dipanggil beberapa kali jika beberapa utas mengatur untuk memeriksa kondisi (1) sebelum salah satu dari mereka menulis nilai ke koleksi (3). Perilaku seperti itu seringkali dapat menimbulkan konsekuensi yang tidak diinginkan.

Sebelum Java 8, tidak ada pilihan yang elegan. Jika Anda perlu menghindari beberapa kreasi nilai, maka Anda harus menggunakan kunci tambahan.
Java 8 membuat segalanya lebih mudah. Tampaknya ...

Java 8 mendatangi kami ...


Apa fitur yang paling diantisipasi yang datang kepada kami dengan Java 8? Benar, lambda. Dan bukan hanya llama, tetapi dukungan mereka di semua variasi API dari perpustakaan standar. Struktur data peta belum diabaikan. Secara khusus, muncul metode seperti:


Karena metode ini, dimungkinkan untuk menulis ulang kode yang diberikan sebelumnya jauh lebih sederhana:

 String getOrPut(String key) { return map.computeIfAbsent(key, this::createValue); } 

Jelas bahwa tidak ada yang akan memberikan kesempatan untuk menyederhanakan kode mereka. Selain itu, dalam kasus ConcurrentHashMap, metode computeIfAbsent juga dijalankan secara atom. Yaitu createValue akan dipanggil tepat sekali dan hanya jika nilai yang diinginkan tidak ada.

IDE juga tidak lewat. Jadi IntelliJ IDEA menawarkan penggantian otomatis versi lama dengan yang baru:




Jelas bahwa penyederhanaan kode dan petunjuk IDE mendorong pengembang untuk menggunakan API baru ini. Akibatnya, computeIfAbsent yang sama mulai muncul di banyak tempat dalam kode.
Sampai jumpa ...

Tiba-tiba!


Sampai saatnya tiba untuk pengujian beban berikutnya. Dan kemudian sesuatu yang mengerikan muncul:



Aplikasi bekerja pada versi Java berikut:

 versi openjdk "1.8.0_222"
 OpenJDK Runtime Environment (build 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 OpenJDK 64-Bit Server VM (build 25.222-b10, mode campuran)


Bagi mereka yang tidak terbiasa dengan alat luar biasa seperti YourKit .

Dalam tangkapan layar, garis lebar horizontal menunjukkan pengoperasian utas aplikasi tepat waktu. Tergantung pada keadaan aliran pada saat tertentu, strip dicat dengan warna yang sesuai:

  • kuning - alirannya diam, menunggu pekerjaan;
  • hijau - utas sedang berjalan, menjalankan kode program;
  • merah - utas ini diblokir oleh utas lainnya.

Artinya, ternyata hampir semua utas (dan sebenarnya ada lebih banyak dari apa yang ditampilkan dalam tangkapan layar) hampir sepanjang waktu dalam keadaan diblokir. Dan untuk semua, kuncinya ada di computeIfAbsent dari ConcurrentHashMap! Dan ini terlepas dari kenyataan bahwa karena spesifik dari uji beban khusus ini, tidak lebih dari 6-8 nilai dapat disimpan dalam koleksi ini. Yaitu hampir semua operasi di tempat tertentu secara eksklusif merupakan pembacaan nilai-nilai yang ada.

Tapi tunggu, bagaimana? Memang, bahkan dalam dokumentasi untuk metode pemblokiran, dikatakan hanya dalam lampiran pembaruan:
+ Msgstr "Jika kunci yang ditentukan belum dikaitkan dengan nilai, cobalah menghitung nilainya menggunakan fungsi pemetaan yang diberikan dan masukkan ke dalam peta ini kecuali nol. Seluruh doa metode dilakukan secara atom, sehingga fungsi diterapkan paling banyak sekali per kunci. Beberapa upaya memperbarui operasi pada peta ini oleh utas lain mungkin diblokir saat perhitungan sedang berlangsung, sehingga perhitungannya harus singkat dan sederhana, dan tidak boleh mencoba memperbarui pemetaan lain dari peta ini. "

Padahal, semuanya tidak begitu. Jika Anda melihat kode sumber metode ini, ternyata mengandung dua blok sinkronisasi yang sangat tebal:

Implementasi ConcurrentHashMap.computeIfAbsent
 public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) { if (key == null || mappingFunction == null) throw new NullPointerException(); int h = spread(key.hashCode()); V val = null; int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { Node<K,V> r = new ReservationNode<K,V>(); synchronized (r) { if (casTabAt(tab, i, null, r)) { binCount = 1; Node<K,V> node = null; try { if ((val = mappingFunction.apply(key)) != null) node = new Node<K,V>(h, key, val, null); } finally { setTabAt(tab, i, node); } } } if (binCount != 0) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { boolean added = false; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; V ev; if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { val = e.val; break; } Node<K,V> pred = e; if ((e = e.next) == null) { if ((val = mappingFunction.apply(key)) != null) { added = true; pred.next = new Node<K,V>(h, key, val, null); } break; } } } else if (f instanceof TreeBin) { binCount = 2; TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> r, p; if ((r = t.root) != null && (p = r.findTreeNode(h, key, null)) != null) val = p.val; else if ((val = mappingFunction.apply(key)) != null) { added = true; t.putTreeVal(h, key, val); } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (!added) return val; break; } } } if (val != null) addCount(1L, binCount); return val; } 


Dari contoh di atas, dapat dilihat bahwa hasilnya hanya dapat dibentuk pada enam titik, dan hampir semua tempat ini berada di dalam blok sinkronisasi. Tak disangka-sangka. Selain itu, get sederhana tidak mengandung sinkronisasi sama sekali:

Implementasi ConcurrentHashMap.get
 public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; } 


Jadi apa yang harus dilakukan? Faktanya, hanya ada dua opsi: baik kembali ke kode asli, atau menggunakannya, tetapi dalam versi yang sedikit dimodifikasi:

 String getOrPut(String key) { String result = map.get(key); return (result != null) ? result : map.computeIfAbsent(key, this::createValue); } 

Kesimpulan


Secara umum, konsekuensi fatal seperti itu dari refactoring yang tampaknya dangkal ternyata sangat tak terduga. Situasi diselamatkan hanya dengan adanya tes stres, yang berhasil mengungkapkan degradasi.

Untungnya, versi Java yang lebih baru memperbaiki masalah ini: JDK-8161372 .

Jadi hati-hati, jangan percaya tip menggoda dan menulis tes. Sangat menegangkan.

Jawa untuk semua orang!

UPD1: sebagaimana dicatat dengan benar oleh coldwind , masalahnya diketahui: JDK-8161372 . Dan, tampaknya, itu diperbaiki untuk Java 9. Tetapi pada saat publikasi artikel di Java 8, Java 11, dan bahkan Java 13, metode ini tetap tidak berubah.

UPD2: vkovalchuk menangkap saya karena kecerobohan. Memang, untuk Java 9 dan yang lebih baru, masalahnya diperbaiki dengan menambahkan kondisi lain dengan kembalinya hasil tanpa memblokir:

  else if (fh == h // check first node without acquiring lock && ((fk = f.key) == key || (fk != null && key.equals(fk))) && (fv = f.val) != null) return fv; 


Awalnya, saya mengalami situasi di versi Java berikutnya:

 versi openjdk "1.8.0_222"
 OpenJDK Runtime Environment (build 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 OpenJDK 64-Bit Server VM (build 25.222-b10, mode campuran)


Dan ketika saya melihat sumber-sumber versi yang lebih baru, saya dengan jujur ​​melewatkan baris-baris ini, yang membuat saya tersesat.

Jadi demi keadilan saya mengoreksi teks utama artikel tersebut.

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


All Articles