Effective Java Madde 8: Finalizer (Sonlandırıcı) ve Cleaner (Temizleyici) Kullanmaktan Kaçının

Java’da sonlandırıcılar (finalizer) çoğu durumda gereksiz, tehlikeli ve tutarsız davranışlar sergileyen metotlardır. Kullandığınız taktirde anlam veremediğiniz hatalı davranışlar, kötü performans ve taşınabilirlik (portability) sorunlarıyla karşılaşabilirsiniz. Sonlandırıcı kullanmak sadece bir iki durumda işe yarayabilir, onları da yazıda göreceğiz. Ancak genel bir kural olarak sonlandırıcı kullanmaktan sakınmalıyız. Java 9 ile beraber, sonlandırıcılar kullanımdan kaldırılmıştır (deprecated) ancak hala Java kütüphaneleri tarafından kullanılmaktadır. Java 9 sonlandırıcılar yerine temizleyicileri (cleaner) kullanıma sunmuştur. Temizleyiciler her ne kadar sonlandırıcılar kadar tehlikeli olmasa da, yine de davranışlarını tahmin etmek zordur, yavaş çalışırlar ve çoğu zaman gereksizdirler.

Öncelikle sonlandırıcı derken tam olarak neyi kastettiğimizi söyleyelim. Java’da bütün sınıfların atası olan Object içerisinde aslında hiçbir iş yapmayan finalize() metodu vardır. Eğer yazılımcı nesne bellekten temizlenmeden önce bir takım kaynakları serbest bırakmak istiyorsa, bu metodu override edebilir. Teorik olarak çöp toplayıcı, bir nesne tamamen erişilemez duruma geldiğinde bu metodu çağıracak ve sistemden silinmeden önce finalize() metodunun içeriği işletilecektir. Ancak pratikte bu yaklaşım çalışmamakta ve uygulamada ciddi sorunlara yol açmaktadır.

C++ programcıları Java’daki sonlandırıcıları C++’taki destructor ile aynı zannedebilirler ancak bu yanlıştır. C++ için destructor kullanılan kaynakları serbest bırakmanın normal yoludur ve gereklidir. Java’da ise çöp toplayıcı mekanizması, bellekte kalmış artık kullanılmayan kaynakları algılar ve serbest bırakır, bunun için yazılımcının birşey yapmasına gerek yoktur. Bellek dışı kaynaklar için – mesela dosyalar – C++’da yine destructor kullanırken Java’da try-finally veya try-with-resources blokları kullanılır. (Madde 9)

Sonlandırıcıların ve temizleyicilerin en büyük dezavantajı ne zaman işletileceklerinin belli olmamasıdır. Bir nesnenin tamamen erişilemez duruma gelmesiyle, sonlandırıcı veya temizleyicinin çalıştırılması arasında uzun bir zaman geçebilir. Bu da demektir ki hemen çalıştırılmasını istediğiniz kodları asla sonlandırıcı veya temizleyici içerisinde kullanmayın! Örneğin, dosyalarla çalıştığınız bir uygulamada sık sık dosya açıyor ve kullandığınız dosyaları kapatmak için sonlandırıcı veya temizleyici kullanıyorsanız, programınız kısa bir süre sonra çökebilir çünkü dosyaların kapanması hemen gerçekleşmez. Çok sayıda dosya açık bırakıldığı zaman sistem yeni dosyalar açmanıza izin vermeyecektir.

Sonlandırıcı ve temizleyicilerin ne zaman çalışacağı tamamen çöp toplayıcı (garbage collector) algoritmasının insiyatifindedir. Bu da farklı JVM’lerde farklı şekillerde ele alınabilir. Sonlandırıcı veya temizleyici kullanarak yazdığınız bir uygulamanın sizin bilgisayarınızda mükemmel çalışıp, en önemli müşterinizin bilgisayarında çökmesi son derece olası bir senaryodur.

Burada anlatılanlar teorik bir problemden ibaret değildir. Bir sınıf için sonlandırıcı tanımlamak, nesnelerinin bellekten temizlenmesini uzun süre geciktirebilir. Bir iş arkadaşım, zaman zaman esrarengiz bir şekilde OutOfMemoryError hatası vererek çöken bir GUI uygulamasındaki hatayı bulmaya çalışırken, uygulama çöktüğü sırada binlerce grafik nesnesinin sonlandırılmak için sırada beklediğini farketti. Sonlandırıcı threadi uygulama threadlerinden daha düşük önceliğe sahip olduğu için, uygulamanın yarattığı nesneler aynı hızla temizlenmiyor, bu da belleğin bir süre sonra dolup uygulamanın çökmesine sebep oluyordu. Java dili, hangi threadin sonlandırıcıları çalıştıracağını belirlemediği için, bu tarz bir problemi engellemek için yapılabilecek tek şey sonlandırıcı kullanmaktan kaçınmaktır. Temizleyiciler bu konuda biraz daha avantajlıdır çünkü temizleyici threadleri sınıfı yazan kişinin kontrolü altındadır. Ancak yine de temizleyiciler arka planda, çöp toplayıcının kontrolünde çalışmaktadırlar. Bu yüzden de ne zaman çalıştırılacakları kestirilemez.

Java dili sonlandırıcıların ve temizleyicilerin ne zaman işletileceğine dair hiçbir garanti vermemenin yanında, işletip işletilmeyeceğini bile garanti etmez. Bir uygulama, sonlandırıcı ve temizleyicileri çalıştırmadan çıkış yapabilir. Bu sebeple, kalıcı olmasını istediğiniz güncellemeler için asla sonlandırıcı veya temizleyicilere güvenmeyin. Örneğin, bir veritabanı kilidini (database lock) serbest bırakmak için sonlandırıcı veya temizleyici kullanmak, dağıtık bir sistemi çalışmaz hale getirmek için çok iyi bir yoldur.

System.gc() ve System.runFinalization() metotları da gözünüzü boyamasın. Bu metotlar sonlarıcıların çalışma ihtimalini artırsa da asla bir garanti veremez. Sonlandırıcıların çalışmasını garanti edebilen iki tane şeytani metot vardır: System.runFinalizersOnExit() ve Runtime.runFinalizersOnExit. Bu iki metodu kullanmak uygulamada daha ciddi sorunlara yol açabileceği için deprecate edilmiştir.

Sonlandırıcılar ile ilgili bir başka problem de şudur: eğer sonlandırma esnasında bir istisna (exception) oluşursa ve bunu yakalamazsanız, bu istisna tamamen görmezden gelinir. Normalde bir istisna oluştuğunda işletim durur ve stack trace basılır, ancak sonlandırıcı kullanıldığında bir uyarı bile göremezsiniz. Temizleyiciler için bu problem söz konusu değildir çünkü yazan kişi bunu kontrol edebilir.

Sonlandırıcı kullanmak uygulama performansını da kötü yönde etkiler. Benim bilgisayarımda, basit bir AutoCloseable nesneyi yaratıp try-with-resources ile kapatmak, sonrasında da çöp toplayıcının bu nesneyi bellekten temizlemesi 12ns sürdü. Sonlandırıcı kullanınca ise 550ns sürdü, yani neredeyse 50 kat daha yavaş çalışıyor. Bunun sebebi, sonlandırıcıların çöp toplayıcının etkin çalışmasını engellemesidir. Temizleyiciler de, eğer sınıfın bütün nesnelerini temizlemek için kullanırsanız neredeyse sonlandırıcılar kadar yavaş çalışır. Ancak aşağıda anlatıldığı gibi sadece bir güvenlik önlemi olarak kullanılırsa, nesneyi yaratmak, temizleyiciyi çalıştırmak ve bellekten temizlemek 66ns sürmektedir, yani 50 kat yerine 5 katlık bir bedel ödemiş olursunuz.

Sonlandırıcılar aynı zamanda ciddi bir güvenlik açığına da sebebiyet verirler. Sonlandırıcı saldırısı (finalizer attack) olarak bilinen bu yöntemin çalışma mantığı çok basittir. Eğer bir sınıfın yapıcı metodu istisna (exception) fırlatıyorsa, bu sınıf kalıtılıp kötü niyetli bir çocuk sınıfta finalize() metodu yazılabilir. Bu finalize() metodu da yapıcı metodu tamamlanamamış olan ata sınıfın referansını statik bir alanda kaydederek nesnenin çöp toplayıcı tarafından bellekten silinmesini engelleyebilir. Böylece, hiç yaratılmaması gereken bu ata sınıfın bir referansını, çocuk sınıfta elde etmiş oluyoruz. Bu referansı kullanarak da yine ata sınıftaki istediğimiz metotları çağırabiliriz. Yapıcı metot içerisinde istisna fırlatmak, bir nesnenin yaratılmasını engellemek için yeterli olmalıdır, ancak sonlandırıcılar işin içine girince bu durum değişebiliyor. Bu saldırıdan korunmak için sınıfı final tanımlamak yeterli olacaktır, çünkü bu durumda hiç kimse sınıfı kalıtamayacaktır. Final olmayan sınıfları korumak istiyorsanız da hiçbir şey yapmayan final bir finalize() metodu yazabilirsiniz. (Bu konuyla ilgili daha geniş bilgi ve kod örnekleri için buraya bakabilirsiniz)

Peki, yazdığımız sınıf veritabanı bağlantısı gibi kaynakları kullanıyorsa ve bunların nesne bellekten silinmeden önce serbest bırakılması gerekiyorsa ne yapmalıyız? Böyle durumlarda yapılması gereken AutoClosable arayüzünü gerçekleştirip istemcilerin işleri bittiğinde close() metodunu çağırmalarını istemektir. Genellikle bu try-with-resources yapılır (Madde 9), böylece bir hata oluşsa bile (exception) close() metodu çağrılarak gerekli kaynaklar serbest bırakılır. Burada önemli bir detaydan bahsedebiliriz. close() metodu çağrıldıktan sonra, bu nesnenin artık geçerli olmadığı bilgisi kaydedilmelidir ve sınıfın diğer metotları da nesne eğer geçerli değilken çağırılırsa IllegalStateException fırlatmalıdır.

Peki o zaman sonlandırıcı veya temizleyici kullanmak ne zaman mantıklı olabilir? Bunun bir örneğini FileInputStream, FileOutputStream, java.sql.Connection sınıflarında görebilirsiniz. Bu sınıflar AutoCloseable olmalarına rağmen, sonlandırıcı da tanımlamışlardır. Bunun sebebi ise sınıfı kullanan istemcilerin close() metodunu çağırmayı unutmaları durumunda, her ne kadar çalışıp çalışmayacağı belli olmasa da ‘hiç yoktan iyidir’ mantığıdır. Eğer siz de böyle bir durum için sonladırıcı veya temizleyici yazmak isterseniz, uzun uzun düşünün çünkü bu aynı zamanda uygulama için bir ek maliyettir.

Temizleyici ve sonlandırıcıların ikinci ve son anlamlı kullanım yeri Java içerisinden C/C++ gibi bir dildeki kodları çalıştırdığınızda ortaya çıkar. Burada ‘native peer’ olarak adlandırılan nesneler normal Java nesneleri olmadığı için çöp toplayıcı bunları temizleyemez. Bu durumda sonlandırıcı veya temizleyici kullanmak işinize yarayabilir.

Sonlandırıcı yazarken dikkat edilmesi gereken kurallar kitabın 2. baskısında var ancak 3. baskıda çıkartılmış. Ben belki birilerine faydası dokunur diye o kısmı da ekledim.

Diyelim ki yukarıdaki iki durumdan birisi bizim için geçerli ve sonlandırıcı yazmamız gerekiyor. Bunu yaparken de dikkat edilmesi gereken noktalar var. Mesela bir ata sınıfta sonlandırıcı yazdıysanız ve çocuk sınıf bunu override ediyorsa, çocuk sınıftan ata sınıftaki sonlandırıcıyı kendiniz çağırmanız lazım. Aksi taktirde çocuk sınıfın sonlandırıcısı çalışacak ancak ata sınıftaki çalışmayacaktır. Ata sınıfın sonlandırıcısını çağırma işini de finally bloğu içerisinde yaparak her durumda çağrılmasını sağlayabilirsiniz.

// Zincirleme sonlandırıcı işletimi
@Override
protected void finalize() throws Throwable {
    try {
        ... // çocuk sınıfı sonlandıracak işlemler buraya..
        } finally {
            super.finalize();
        }
    }
}

Yukarıdaki gibi ata sınıf sonlandırıcısı çocuk sınıftan açıkça çağrılmazsa ata sınıfın sonlandırıcısı asla işletilmeyecektir. Bir dikkatsizlik sonucu kolayca yapılabilecek bu hatadan korunmak için finalizer guardian denilen teknik kullanılabilir. Aşağıdaki kodu inceleyelim:

// Finalizer Guardian tekniği
public class Foo {

    // bu nesnenin tek görevi dışarıdaki Foo nesnesini sonlandırmaktır
    private final Object finalizerGuardian = new Object() {
        @Override
        protected void finalize() throws Throwable {
            ... // dışarıdaki Foo nesnesini burada sonlandırın
        }
    };
    ...  // Foo sınıfının devamı
}

Yukarıdaki kod nasıl çalışmaktadır? Gördüğünüz üzere Foo sınıfının içerisinde bir nesne tanımlayıp finalize() metodunu override ediyoruz ancak Foo sınıfına ait bir sonlandırıcı yok. Bu durumda çöp toplayıcı Foo nesnesini temizleyeceği zaman finalizerGuardian nesnesini de temizlemesi gerekmektedir ve bu yüzden de finalizerGuardian içindeki finalize() işletilecektir. Biz normalde Foo sınıfının sonlandırıcısı içinde yapacağımız sonlandırma işlemlerini finalizerGuardian içerisinde taşırsak aynı işi yapmış oluyoruz, dolayısıyla bu sınıfı başka bir sınıf kalıtırsa çocuk sınıfın super.finalize() ile ata sınıfın sonlandırıcısını çağırmasına gerek kalmayacaktır. Böylece olası bir programlama hatasının önüne geçmiş oluyoruz.

Temizleyicilerin kullanımı biraz daha farklıdır. Farzedelim ki uygulamamızda odaları temsil eden sınıflar olsun (Room) ve odaların bellekten silinmeden önce temizlenmesi gerekiyor olsun. Tıpkı bir FileInputStream ile işimiz bittiğinde sistem kaynaklarını serbest bırakmak için close() metodunu çağırmamız gerektiği gibi. Bu sebeple, aşağıdaki Room sınıfı AutoClosable arayüzünü uygulamaktadır ancak bunun yanında önlem olsun diye bir de temizleyici bulundurmaktadır.

// Önlem amaçlı temizleyici kullanan, AutoCloseble bir sınıf
public class Room implements AutoCloseable {
    
    private static final Cleaner cleaner = Cleaner.create();

    // Temizlenmesi gereken kaynak, Room referansı içermemelidir
    private static class State implements Runnable {
        int numJunkPiles; // temizlenmesi gereken çöp sayısı
        State(int numJunkPiles) {
            this.numJunkPiles = numJunkPiles;
        }
        // close() metodu veya temizleyici tarafından işletilir
        @Override 
        public void run() {
            System.out.println("Oda temizleniyor");
            numJunkPiles = 0;
        }
    }
    
    // Odanın (Room) o anki durumunu temsil eder
    private final State state;
    
    private final Cleaner.Cleanable cleanable;
    public Room(int numJunkPiles) {
        state = new State(numJunkPiles);
        cleanable = cleaner.register(this, state);
    }
       
    @Override 
    public void close() {
        cleanable.clean();
    } 
}

Burada static State sınıfı temizleyici tarafından temizlenmesi gereken kaynakları tutmaktadır. Bu örnekte çok basit olarak numJunkPiles isminde, temizlenecek çöp sayısını tutan bir int değişkeni tutsa da, daha gerçekçi bir uygulamada final long bir değişken içerisinde, yukarıda bahsettiğimiz gibi bir ”native peer” değişkene gösterge tutabilir.

State sınıfı, Runnable arayüzünü uygulamaktadır. Buradan gelen run() metodu nesnenin temizlenmesi için gerekli kodu çalıştırmaktadır. Room yapıcı metodu içerisinde elde ettiğimiz cleanable referansı üzerinden clean() metodu çağrıldığında, aslında state.run()metodu işletilmektedir. Çünkü cleanable elde etmek için state nesnesini kullandık. (26. satır)

Burada run() metodunu işletmek için iki yol vardır. Birincisi ve daha yaygın olanı, Room nesnesi üzerinden close() metodunu çağırmak olacaktır. Bu sınıf AutoClosable olduğu için try-with-resources ile kullanıldığında bu otomatik olarak gerçekleşecektir. İkinci yol ise, istemci close() metodunu çağırmayı unutursa gerçekleşir. Bu durumda cleaner nesnesi, çöp toplama (garbage collection) yapılırken, run() metodunu işletecektir, ancak uygulama çöp toplanmadan önce çıkış yaparsa hiç çalışmayabilir.

Burada başka önemli bir nokta da, State nesnesinin herhangi bir Room referansı tutmamasıdır. Eğer tutsaydı, ikisi arasında oluşacak döngüsel bağdan ötürü Room nesnesi hiçbir zaman çöp toplayıcı tarafından temizlenemezdi. Bu sebeple, State mutlaka statik bir iç sınıf (static nested class) olmak zorundadır. Statik olmayan iç sınıflar, dışarıdaki sınıfın referanslarını tuttukları için bu durumda kullanılamazlar. (Madde 24) Aynı sebepten ötürü, lambda fonksiyonlarının kullanımı da tavsiye edilmez.

Daha önce de belirttiğimiz gibi, Room içerisindeki cleaner sadece ”en kötü durumda kullanılacak son çare” olmak üzere tasarlanmıştır. Eğer istemciler, Room nesnelerini aşağıdaki gibi try-with-resources blokları içerisinde yaratırlarsa, cleaner hiçbir zaman devreye girmeyecektir.

public class Adult {
    public static void main(String[] args) {
        try (Room myRoom = new Room(7)) {
            System.out.println("Güle güle");
        } 
    }
}

Tahmin edeceğiniz gibi, yukarıdaki sınıf önce "Güle güle" sonra da "Oda temizleniyor" çıktısını üretecektir. Peki ya aşağıdaki sınıf?

public class Teenager {
    public static void main(String[] args) {
        new Room(99);
        System.out.println("Güle güle");
    }
}

Yukarıdaki uygulama benim bilgisayarımda sadece "Güle güle" çıktısını üretip sonlanıyor, "Oda temizleniyor" asla üretilmiyor. Bunun sebebi daha önce de bahsettiğimiz belirsizlikten kaynaklanıyor. Uygulama sonlandığında, temizleyicilerin çalışıp çalışmayacağı tamamen JVM gerçekleştirime bağlıdır. Cleaner dokümantasyonu bunu açıkça belirtmektedir. Benim bilgisayarımda, yukarıdaki main metodu içerisine System.gc() ekleyerek çöp toplama işlemini tetikleyince, "Oda temizleniyor" çıktısını görebiliyorum. Ancak sizin de aynı davranışı göreceğinizin bir garantisi yoktur.

Özet olarak, yukarıda bahsettiğimiz iki durum haricinde sonlandırıcı veya temizleyici kullanmaktan kaçının. Eğer kullanmanız gerekirse de, uymanız gereken kuralları dikkatlice uygulayın ve belirsizliklere, performans kayıplarına karşı bilinçli olun.

Share