JavaProgramlamaKıdemli Java Geliştirici

**String.hashCode()**'un iç hash önbelleğini, birden fazla iş parçacığı tarafından eşzamanlı olarak doldurulmasına rağmen, **volatile** niteliklerini güvenli bir şekilde göz ardı etmesini sağlayan belirli atomiklik garantisi nedir?

Hintsage yapay zeka asistanı ile mülakatları geçin

Sorunun cevabı

Sorunun geçmişi

JSR 133 spesifikasyonundan (Java 5) önce, Java Bellek Modeli resmi olarak ânında gerçekleşen öncelik kurallarına sahip değildi, bu da zararsız veri yarışlarını tehlikeli hale getiriyordu. String, her zaman performans açısından kritik, değişmez bir sınıf olarak kalmış ve HashMap işlemlerinde yoğun bir şekilde kullanılmıştır. Erken JDK sürümleri, büyük dizeler için hash'in sürekli olarak yeniden hesaplanmasını önlemek için tembel hash önbelleği ekledi. hash alanında volatile'nın hariç tutulması, modern eşzamanlılık öğelerinden önce yapılan kasıtlı bir optimizasyondu; hesaplamanın idempotent doğasına ve Java 5'te JLS'ye eklenen belirli atomiklik garantilerine dayanıyordu.

Problemin tanımı

Birden fazla iş parçacığı yeni oluşturulmuş bir String üzerinde hashCode()'yu eşzamanlı olarak çağırdığında, hepsi hash alanında varsayılan 0 değerini görebilir. Senkronizasyon olmadan, bu, birkaç iş parçacığının aynı anda hash değerini hesaplayıp geri yazmaya çalışabileceği bir veri yarışına yol açar. Sorun, hiçbir iş parçacığının asla kısmen yazılmış (yırtılmış) bir hash değeri veya tutarsız bir durumu gözlemlemesini sağlamaktır, ayrıca her hashCode() çağrısında volatile okumaları ve yazmaları ile ilgili bellek engellerinin yasaklayıcı maliyetinden kaçınmaktır.

Çözüm

Çözüm, iki temel JMM özelliğine dayanır. Öncelikle, Java Dil Spesifikasyonu (§17.7), 32 bit ilkel değerler (int) için yazmaların atomik olduğunu garanti eder ve böylece kelime yırtılmasını engeller. İkinci olarak, String yapıcısı, final value alanı aracılığıyla bir ânında gerçekleşen öncelik ilişkisi oluşturur ve yedek dizinin referansı alan herhangi bir iş parçacığına tamamen görünür olmasını sağlar. Çünkü hash hesaplaması bu değişmez, güvenli olarak yayımlanmış verilere tamamen bağlı saf bir işlevdir, önbelleği doldurma yarışı zararsızdır. Eğer bir iş parçacığı eski bir 0 okursa, yalnızca aynı değeri yeniden hesaplar; eğer önbelleğe alınmış değeri okursa, onu kullanır. Atomik yazma, değerin ya tamamen gözlemlendiğini ya da gözlemlenmediğini, asla bozulmadığını garanti eder.

public int hashCode() { int h = hash; // İki yönlü olmayan okuma: 0 veya önbelleğe alınmış değeri görebilir if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // Atomik yazma: 32 bit atama bölünemez } return h; }

Hayattan bir durum

Saniyede milyonlarca CSV kaydını işleyen yüksek hacimli bir alma hizmeti tasarlıyorduk. Her kayıt için bir ConcurrentHashMap önbelleği için birden çok String anahtarı oluşturuldu. Profil oluşturma, büyük dize anahtarları nedeniyle hashCode() hesaplamalarının CPU zamanının %15'ini tükettiğini ortaya koydu.

Çözüm A: volatile hash alanı. hash alanına özel bir String sargısı eklemeyi düşündük. Artıları, tüm çekirdekler üzerinde anlık görünürlük ve katı sıralı tutarlılık içeriyordu. Ancak, dezavantajlar şiddetliydi: JMH kıyaslamaları, her harita işleminde bellek engeli maliyetleri ve önbellek uyum trafiği nedeniyle %400'lük bir verimlilik kaybı gösterdi.

Çözüm B: senkronize hashCode(). Hesaplamayı senkronize etmeyi denedik. Artıları basitlik ve mutlak doğruluktu. Dezavantajları ise felaket bir rekabetti; 32 iş parçacığı altında, gecikme, iş parçacıklarının izlemesi nedeniyle 2 nanosekondan her işlem için 800 nanosekonda yükseldi.

Çözüm C: Zararsız yarış (mevcut uygulama). Senkronize edilmemiş idempotent önbelleği koruduk. Artıları, sıfır senkronizasyon maliyeti ve çekirdek sayısı ile mükemmel ölçeklenebilirlikti. Dezavantajları teorikti: İş parçacıkları ilk erişimde yarıştığında, bazen gereksiz hesaplama yapılabilirdi. Çözüm C'yi seçtik çünkü bir hash'in (önbellek hatası) yeniden hesaplanmasının maliyeti, önbellek uyum protokollerinin (volatile) veya rekabet maliyetinin (synchronized) maliyetine kıyasla önemsizdi.

Sonuç: Sistem, hashCode()'nun en sıcak 100 yöntem arasında görünmediği saniyede çekirdek başına 2,5 milyon işlem sürdürdü ve zararsız veri yarışının, bu değişmez veri yapısı için doğru mimari denge olduğunu doğruladı.

Adayların genellikle kaçırdığı noktalar

Neden volatile eksikliği, String'i oluşturan iş parçacığı ile hash'ini hesaplayan iş parçacığı arasındaki ânında gerçekleşen öncelik ilişkisinin ihlaline neden olmuyor?

Ânında gerçekleşen öncelik ilişkisi aslında String nesnesinin kendisinin güvenli yayımlandığı tarafından kurulur, hash alanı tarafından değil. Bir String oluşturulduğunda, final value alanı, yedek dizinin içeriğinin referansı alan herhangi bir iş parçacığına görünür olmasını garanti eder. hash alanı yalnızca bir önbellektir; varsayılan 0 değerini gözlemlemek, yalnızca hesaplamayı tetikleyen geçerli bir program durumudur. JMM, değişmez value dizisinin tutarlı olduğunu garanti eder ve hash yalnızca bu görünür veriden türetildiğinden, hesaplama hangi iş parçacığının gerçekleştirdiğinden bağımsız olarak aynı sonucu verir.

Bu aynı optimizasyon, volatile kullanılmadan 64 bit uzun bir hash değeri için uygulanabilir mi?

Hayır. JMM, 32 bit ilkel değerler (int, float) için tüm mimarilerde atomiklik garanti eder. 64 bit ilkel değerler (long, double) için spesifikasyon, belirli mimarilerde veya 32 bit JVM'lerde volatile veya senkronizasyon olmadan kelime yırtılmasına izin verir. Bir iş parçacığı teorik olarak bir hesaplanan hash'in yüksek 32 bitini ve diğerinin düşük 32 bitini gözlemleyebilir, bu da tamamen yanlış, sıfır olmayan bir hash değerine yol açar ve HashMap kova yerleştirmesini bozar. Bu nedenle, 64 bit hash'lerin önbelleğe alınması volatile veya AtomicLong gerektirir.

Bu, singleton başlangıcı için bozulmuş "Çift Kontrol Kilidi" deyiminden nasıl farklıdır?

Kritik ayrım, güvenli yayımlama ve idempotens bağlıdır. Bozuk Çift Kontrol Kilidi'nde sorun, inşaatı tamamlanmamış bir nesneye referansı gözlemlemektir (referans atamasının yeniden sıralanması ile kurucu yürütülmesi arasındaki farklılık). String.hashCode()'de, String nesnesi zaten güvenli bir şekilde yayımlanmış ve tam olarak inşa edilmiştir; hash alanı yalnızca saf verilerin tembel bir şekilde başlatılan önbelleğidir. 0 (başlatılmamış) görmek, kısmi bir inşaat değil, geçerli bir başlangıç durumudur. Dahası, ifade idempotenttir—aynı hesaplanan değeri birden fazla iş parçacığının yazması, bir iş parçacığı tarafından gerçekleştirilenle aynı sonucu verir, oysa DCL'nin tam olarak bir örnek oluşturması gerekmektedir.