Java 9'dan önce, javac derleyicisi her string birleştirme ifadesini StringBuilder tahsisi ve append çağrılarından oluşan bir diziye mekanik olarak dönüştürerek, bunun sonunda bir toString() çağrısıyla sonuçlanıyordu. Bu yaklaşım, her birleştirme noktasında verbos, monomorfik bytecode üretiyor ve uygulama stratejisini kesinlikle derleme zamanı kararlarına bağlı kılıyordu. Bu statik çevirimin temel sorunu, yöntem boyutlarını HotSpot'un inline eşiğinin üzerine çıkarması ve JVM'in, mantığın bytecode akışında donmuş olmasından dolayı, daha iyi çalışma zamanı stratejilerini, örneğin birleştirilmiş dizi kopyaları veya vektörleştirilmiş işlemler gibi, seçmesini engellemesiydi. Java 9 (JEP 280) ile, derleyici StringConcatFactory'yi referans alan bir invokedynamic komutunu yaydı; bu fabrika, başlangıç bağlantısından sonra değişmez olan bir ConstantCallSite döndürüyor ve JVM'ye hedef MethodHandle'ın asla değişmeyeceğini belirterek, agresif inline ve kaçış analizi uygulanabilecek doğrudan, devritileştirilmiş bir çağrı olarak ele alınabileceğini bildiriyor.
Yüksek frekanslı bir ticaret platformu, etiket-değer çiftleri için geniş çapta string birleştirme kullanarak saniyede milyonlarca FIX protokol mesajı üretme gereksinimine sahipti. Java 8'de yapılan profilleme, kritik yolda StringBuilder tahsisinin toplam yığın hafızasının %18'ini tükettiğini ortaya koyarak sık sık GC duraklamalarına yol açtı, ayrıca karmaşık mesajlar için üretilen bytecode, C2 derleyicisinin 325 byte'lık inline eşiğini aştı ve bu da kritik döngü optimizasyonlarını engelleyerek düzensiz gecikme artışlarına neden oldu.
Çözüm 1: Manuel ThreadLocal havuzlama. Bu yaklaşım, tahsis yükünü ortadan kaldırmak için her bir thread için StringBuilder örneklerini önbelleğe aldı. Artılar: Kısa ömürlü nesneler için GC baskısını kaldırdı ve nesne değişimini azalttı. Eksiler: Karmaşık yaşam döngüsü yönetimi gerektiriyordu, ThreadLocal haritalarında bellek sızıntılarını önlemek için titiz bir temizleme gerektiriyordu ve havuzlama şablonuyla iş mantığını belirsiz hale getiriyordu.
Çözüm 2: Yığın dışı ByteBuffer oluşturma. Bu strateji, mesajları yönetilen yığın dışına inşa etmek için ByteBuffer.allocateDirect kullandı. Artılar: Mesaj oluşturma sırasında sıfır GC baskısı elde edildi ve doğrudan soket yazımına NIO üzerinden izin verildi. Eksiler: Aşırı karmaşıklık getirdi, String değişmezlik garantilerini feda etti, manuel bellek güvenliği riskleri tanıttı ve ham byte manipülasyonu nedeniyle hata ayıklamayı zorlaştırdı.
Çözüm 3: invokedynamic birleştirme ile Java 11'e geçiş. Bu, uygulama kodunu değiştirmeden StringConcatFactory'yi kullanmak üzere çalışma zamanını geçirmeyi içeriyordu. Artılar: Her bir birleştirme için bytecode ayak izini ~200 byte'tan ~5 byte'a düşürdü ve ConstantCallSite'ın değişmezliği, HotSpot'un birleştirme mantığını doğrudan ticaret döngülerine inline etmesine olanak sağladı. Eksiler: Kapsamlı regresyon testleri ve eski bytecode manipülasyon ajanlarıyla geçici uyumsuzluk gerektirdi.
Seçilen çözüm ve sonuç. Çözüm 3, bir kanarya dağıtımı sonrası tahsis oranında %35'lik bir azalma ve GC kaynaklı gecikme artışlarının ortadan kaldırıldığını gösterdikten sonra seçildi. Sistem, artık önceki çıktısının iki katı ile alt milisaniye p99 gecikmesi ile sürek sağlıyor, çünkü JIT derleyicisi birleştirmeyi içsel bir işlem olarak ele alarak yöntem çağrısı yükünü tamamen ortadan kaldırıyor.
Neden StringConcatFactory bir ConstantCallSite kullanıyor ve değiştirilebilirlik sağlansaydı hangi optimizasyon kaybedilirdi?
Başlangıç mekanizması, birleştirme stratejisinin tamamen statik argüman türleri ve çağrı noktasındaki sabit tarifle belirlendiği için bir ConstantCallSite döndürüyor; bağlantıdan sonra dinamik yeniden hedefleme gerektirmiyor. Eğer bir MutableCallSite kullanılsaydı, JVM her çağrıda potansiyel hedef değişikliklerini ele almak için hafıza engelleri veya sanal dağıtım kontrolleri eklemek zorunda kalacaktı, bu da JIT'in inline ve sabit yayılmayı uygulamasını engelleyecek ve invokedynamic'in ortadan kaldırmak için tasarlanmış olduğu tam çağrı yükünü yeniden getirecekti.
makeConcatWithConstants başlangıç yönteminin, makeConcat ile string literallerini ele almadaki farkı nedir ve bu ayrım çağrı noktası performansı için neden önemlidir?
makeConcatWithConstants yöntemi, sabit parçaların işaretleyiciler kullanılarak gömüldüğü bir "tarif" dizesi kabul eder ve bu, başlangıç mekanizmasının sabitleri üretilen MethodHandle'a emmesini sağlar, dinamik yığın argümanları olarak geçmek yerine. Bu, çağrı noktasındaki dinamik argüman sayısını azaltarak yığın trafiğini ve kayıt baskısını düşürürken, makeConcat tüm operatörleri dinamik olarak ele alır. Tarif tabanlı yaklaşım, JVM'nin bağlantı sırasında kısmi sabit katlama yapmasını sağlar, muhtemelen sabit ön ekleri üretilen koda önceden hesaplar.
Belirli bir koşul altında JVM nasıl invokedynamic çağrı yükünü tamamen ortadan kaldırabilir, bunu no-op veya saf sabit olarak ele alır?
Eğer birleştirme ifadesine tüm operatörler derleme zamanı sabit ifadeleri, yani literal dizeler veya static final sabitleri gibi olursa, javac tamamıyla derleme zamanında sabit katlama yapabilir; ifadeyi sabit havuzundaki tek bir String literali ile değiştirebilir ve invokedynamic komutunu tamamen kaldırabilir. Eğer bir operatör bile dinamikse, indy çağrısı kalır; ancak JIT bunu optimizasyon sırasında sabit katlayabilir eğer girdi değişmezliğini karmaşık kaçış analizleriyle kanıtlayabiliyorsa, bu da derleme zamanı katlamasından farklıdır.