Sorunun Geçmişi
Java PhantomReference, çöp toplama sırasında öngörülemeyen gecikmeler ve dirilme tehlikelerini önlemek için Object.finalize()'ın fatal kusurlarını gidermek amacıyla tanıtıldı. Erken JVM tasarımcıları, bir nesnenin, onu yeniden canlandırmadan veya toplama işlemini engellemeden ulaşılmaz hale geldiğini tespit etmek için bir mekanizma aradı. Bu, referansın kendisinin nesneye erişim aracı yerine bir bildirim token'ı olarak hizmet ettiği hayali referans kavramına yol açtı.
Problem
SoftReference veya WeakReference'ten farklı olarak, bir PhantomReference üzerinde get() çağrısı yapmak şartlı olarak null döner, hatta nesne toplanmadan önce bile. Bu tasarım, programcının nesneyi finalizasyon sırasında yanlışlıkla yeniden canlandırmasını önlemek için referente erişimi kasıtlı olarak kesmektedir. Sonuç olarak, nesnenin durumunu doğrudan inceleyemez veya referans örneği aracılığıyla temizlik mantığını tetikleyemezsiniz, böylece bir paradoks oluşur: nesnenin toplanmak üzere olduğunu bilirsiniz, ancak buna müdahale edemezsiniz.
Çözüm
ReferenceQueue, JVM'nin referent finalize edildikten ve toplanmak üzere hazır olduktan sonra PhantomReference örneğini kuyruğa eklediği bir iletişim kanalı gibi çalışır. Bu kuyruğu anket yaparak veya bloklayarak, arka planda bir iş parçacığı referans nesnesini alır ve ilişkili yerel kaynaklar için temizlik mantığını yürütür. Bu, kaynak geri kazanımını çöp toplayıcının kritik yolundan ayırarak, finalizasyon gecikmelerini ortadan kaldırırken, yığın dışındaki bellek veya dosya kollarının zamanında serbest bırakılmasını sağlar.
public class NativeResourceCleaner { private static final ReferenceQueue<Object> queue = new ReferenceQueue<>(); private static final Set<ResourcePhantomRef> pendingRefs = ConcurrentHashMap.newKeySet(); static { Thread cleaner = new Thread(() -> { while (!Thread.interrupted()) { try { ResourcePhantomRef ref = (ResourcePhantomRef) queue.remove(); ref.cleanup(); pendingRefs.remove(ref); } catch (InterruptedException e) { break; } } }); cleaner.setDaemon(true); cleaner.start(); } static class ResourcePhantomRef extends PhantomReference<Object> { private final long nativePtr; ResourcePhantomRef(Object referent, long ptr) { super(referent, queue); this.nativePtr = ptr; pendingRefs.add(this); } void cleanup() { // Yerel belleği serbest bırak: free(nativePtr); System.out.println("Yerel kaynak serbest bırakıldı: " + nativePtr); } } }
Bir yüksek frekanslı ticaret uygulamasının, sıfır kopya ağ işlemleri için ByteBuffer.allocateDirect() ile yığın dışı bellekleri terabaytlarca tahsis ettiğini hayal edin. Bu tamponlarla ilişkili yerel bellek, Java yığını tarafından yönetilmez, ancak standart Cleaner örnekleri, uygulamanın özel kaynak muhasebesi veya süreçler arası paylaşılan bellek temizlik gereksinimini karşılamakta yetersiz kalabilir. Geliştirme ekibi, traderların dalgalı piyasa koşullarında tamponları açıkça kapatmayı unutmaları durumunda yerel bellek sızıntılarını önlemek için sağlam bir mekanizmaya ihtiyaç duydu.
Çözüm 1: Finalizasyonu geçersiz kılma
Bir yaklaşım, ByteBuffer'ı genişletmek ve bellek tahliyesi için Unsafe rutinlerini çağırmak üzere finalize()'ı geçersiz kılmaktır. Bu, basit görünse de, finalizasyonun iki toplama döngüsünü gerektirmesi ve iş parçacıklarını engellemesi nedeniyle Full GC olayları sırasında ciddi gecikme artışları getirir. Ayrıca, dirilme riski, nihai nesne dış durumları referans alıyorsa güvenlik açıkları oluşturur.
Çözüm 2: Açık try-with-resources
Geliştiriciler, her tampon tahsisi için sıkı try-with-resources blokları talep edebilir, böylece anında close() çağrıları sağlanır. Bu, GC bağımlılığını tamamen ortadan kaldırır ve belirleyici temizlik sağlar, ancak mükemmel programcı disiplinine güvenmektedir. Büyük bir kod tabanında, asenkron geri aramalarla unutulan kapanış çağrıları, işletim sisteminin daha fazla tahsisi reddettiğinde JVM'yi çökerten yığından dışı bellek sızıntısına yol açar.
Çözüm 3: PhantomReference ile ReferenceQueue izleme
Ekip, yerel adresleri tutan özel PhantomReference alt sınıflarını takip eden bir daemon iş parçacığı tarafından anket edilen özel bir ReferenceQueue uyguladı. GC, bir tamponun ulaşılmaz olduğunu belirlediğinde, referans kuyrukta yer alır, bu da toplama işlemini engellemeden yerel tahliyeyi tetikler. Bu yaklaşım, programcı hatalarını aşarken alt milisaniyelik GC duraklamalarını sürdürdüğü için tercih edildi; bu, ticaret algoritmaları için kritik öneme sahiptir.
Sonuç
Sistem, yerel yığın bölgeleri için OutOfMemoryError olmadan saniyede 50,000 tahsis aldı ve GC duraklama sürelerini 200ms piklerinden tutarlı 5ms işlemlerine düşürdü. Arka plandaki iş parçacığı, %1'den az CPU aşırılığı tüketti, bu da hayali referans izlemenin kaynak açısından yoğun uygulamalarda finalizasyondan daha iyi ölçeklendiğini kanıtladı. Bellek profil analizi, 72 saatlik stres testleri boyunca sıfır yerel bellek sızıntısı doğruladı.
Neden PhantomReference.get() tasarım gereği referansı değil de null döner?
Bu davranış, hayali erişilebilir nesnelerin yeniden canlandırılmasını önler. Eğer get(), toplayıcı onu finalizasyona işaretlediğinde nesneyi döndürseydi, programcı statik bir alanda güçlü bir referans saklayabilir ve onu aktif kullanımda yeniden canlandırabilirdi. Bu, toplayıcının, hayali olarak erişilebilir nesnelerin zaten finalize edilmiş ve geri kazanıma hazır olduğu ilkesini ihlal ederek yerel kodda kullanıldıktan sonra serbest hatalar veya çift finalizasyon senaryolarına yol açabilir.
Cleaner API'si, PhantomReference ve ReferenceQueue'yi manuel yönetmekten nasıl farklıdır?
Cleaner, aslında PhantomReference, ReferenceQueue ve Java 9'da tanıtılan özel bir sistem iş parçacığı etrafında bir kolaylık sargısıdır. Temel mekanizma aynı kalmasına rağmen, Cleaner, iş parçacığı yaşam döngüsü yönetimini ve istisnai durumları soyutlayarak temizlik işlemi çalıştıktan sonra referansı otomatik olarak temizler. Manuel yönetim, iş parçacığı önceliği ve kuyruk anket stratejileri üzerinde kontrol sağlar, ancak Cleaner, referansı kuyruktan kaldırma gibi yaygın hataları önler; bu da referans kümesinde bellek sızıntısına neden olabilir.
PhantomReference kullanırken ReferenceQueue yeterince sık anket edilmezse ne olur?
Her PhantomReference örneği, kuyruktan açıkça kaldırılana ve referanssız hale gelene kadar bellek (yaklaşık 32-64 byte) tüketir. Eğer tüketici iş parçacığı duraksarsa veya çökerse, kuyruk sonsuz şekilde yığılır ve referans sızıntısı oluşturur; bu da referentler toplanmasına rağmen sonunda Java yığınını tüketir. Referans, referentten farklı olarak, kuyrukta köklü bir güçlü nesnedir ve uzun süreli hizmetlerde bellek yetersizliklerini önlemek için açıkça temizlenmesi gerekir.