İçeriğe geç
academia.sh

Ders 06 / 11

Java Bellek Modeli

Sekiz yayımlama düzeneğinden dördü hem yazanın hem okuyanın harekete geçmesini istiyor, biri yükü tek başına yazana (final alan), biri tek başına okuyana (join) yüklüyor, ikisi ise görünürlük sorusunu hiç sormuyor. Senkronizasyonsuz bir alanın görünürlük kusuru koşturularak gösterilmiyor; kural olarak modelleniyor.

İçindekiler

Bir önceki ders bir değeri hep aynı iş parçacığının hem yazıp hem okuduğu kurulumlarla çalıştı; basladi.await() ile t.join() yalnız sırayı kurdu, görünürlüğü sağlayan mekanizma hiç adı konmadan zaten oradaydı. Bu ders o mekanizmanın adını koyuyor. Bir değeri bir iş parçacığı yazdığında ve başka bir iş parçacığı okuduğunda, okuyanın yazılan değeri görmesi otomatik değildir — görülmesini sağlayan şey, iki eylem arasında dilin tanıdığı bir sıra kurulmuş olmasıdır. Bu sıraya önce-olur bağıntısı deniyor ve kursun bu dersteki sorusu şu: sekiz farklı yayımlama düzeneğinin her birinde, bu bağıntıyı kurmak için hangi taraf ne yapmak zorunda — yalnız yazan mı, yalnız okuyan mı, ikisi de mi, yoksa hiçbiri mi?

Sorunun “otomatik değildir” kısmı önceki dersteki örneklerle çelişiyor gibi görünebilir; orada hiçbir ek çaba harcanmadan her değer doğru okundu. Çelişki yüzeyseldir: önceki dersteki her kurulum zaten CountDownLatch ya da join kullanıyordu, ve bu iki araç — aşağıda görüleceği gibi — kendi başlarına birer önce-olur kaynağıdır. Görünürlük orada da otomatik değildi; yalnız gerekli bağıntı zaten kuruluydu ve bu ders o kuruluşun neden yeterli olduğunu adlandırıyor.

Önce-Olur Bağıntısı: Bir Kural Olarak

  • EZ5 — Önce-olur bağıntısı bir zamanlama iddiası değildir; iki eylemin hangi sırayla gerçekleştiğini söylemez, yalnız biri gerçekleştikten sonra ötekinin sonuçlarının görünür olacağını garanti eder. Bu derste bağıntı hiçbir yerde ölçülmüyor, yalnız kural olarak kullanılıyor.

Dilin tanıdığı önce-olur kuralları arasında bu derste kullanılanlar şunlar: bir iş parçacığındaki eylemler program sırasına göre birbirinden önce-olur; bir izleyici kilidinin serbest bırakılması, aynı kilidi sonra alan her eylemden önce-olur; uçucu (volatile) bir alana yazmak, aynı alanı sonra okuyan her eylemden önce-olur; bir iş parçacığının başlatılması (start()), o iş parçacığındaki bütün eylemlerden önce-olur; bir iş parçacığındaki bütün eylemler, başka bir iş parçacığının o iş parçacığında join()’den başarıyla dönmesinden önce-olur; ve bir final alanın kurucudaki değeri, kurucu tamamlandıktan sonra o nesneye güvenli yollarla ulaşan her iş parçacığından önce-olur. Bu liste, aşağıdaki sekiz düzeneği kategorize etmek için yeterli.

Bu kuralların hepsi geçişlidir: A eylemi B’den önce-olur ve B eylemi C’den önce-olursa, A da C’den önce-olur — ara halka farklı bir mekanizma kullansa bile. Bu yüzden bir değeri main’de yazıp bir iş parçacığını start() eden, o iş parçacığın içinde değeri bir CountDownLatch ile ikinci bir iş parçacığına ileten bir zincirde, en baştaki yazı en sondaki okumaya tek bir zıplamayla değil, halka halka ulaşır; her halka kendi kuralını uygular, zincirin bütünü ayrıca kanıtlanmaz. Aşağıdaki ölçümlerin her biri bu zincirin tek bir halkasını — yazan ile okuyan arasındaki doğrudan bağı — izole ediyor.

Yazanın Bedeli: final Alan ve Güvenli Yayımlama

  • EZ6 — Ölçüm okuyan tarafın kod akışına hiçbir ek adım koymuyor; okuyan yalnızca alanı okuyor. Bedel tamamen kurucunun tamamlanma anına yükleniyor.
// GuvenliYayimlama.java — final alanin bedeli kurulum aninda odenir, okuyan hicbir sey yapmaz
public class GuvenliYayimlama {
    static final class Paket {
        final int deger;
        Paket(int deger) { this.deger = deger; }
    }

    public static void main(String[] args) throws InterruptedException {
        Paket kutu = new Paket(5);
        Thread okuyan = new Thread(() -> {
            System.out.println("okuyanin hicbir eklemesi yok, sadece Paket.deger okuyor: " + kutu.deger);
        });
        okuyan.start();
        okuyan.join();
    }
}
okuyanin hicbir eklemesi yok, sadece Paket.deger okuyor: 5

Paket kurucu tamamlanana kadar deger alanı yazılmamış sayılır; kurucu bittiği an bu alan dondurulur. okuyan iş parçacığı kutu.deger’i okurken ne bir kilit alıyor ne bir uçucu alanı denetliyor — hiçbir ek disiplin uygulamıyor, çünkü uygulaması gereken disiplin zaten yazan tarafta, kurucunun içinde tüketildi. Bu derste kutu referansı okuyan’a start() üzerinden, yani zaten önce-olur bağıntısı kuran bir kanaldan ulaşıyor; final alanın kendine özgü güvencesi bundan da geniştir — kurucu this referansını erken kaçırmadığı sürece, referans hangi kanaldan ulaşırsa ulaşsın alanın doğru değeri görülür. Bu genişletilmiş güvence burada ayrıca koşturulmuyor: kanıtlamak için referansı kasıtlı olarak eşleşimsiz yayımlamak gerekirdi, ve bu, tam olarak aşağıdaki sınırlayıcı ölçümün kaçındığı türde bir kurgudur.

Güvencenin bir de koşulu var: kurucu this referansını erken kaçırmamalıdır. Bir kurucu, kendi tamamlanması bitmeden this’i bir statik alana yazar ya da bir dinleyiciye kaydederse, başka bir iş parçacığı o nesneyi kurucu hâlâ çalışırken görebilir — ve o anda final alan henüz atanmamış ya da yarım atanmış olabilir. Bu ders böyle bir kaçışı kurmuyor; yalnız final alanın güvencesinin koşulsuz olmadığını, kurucunun disiplinine bağlı olduğunu kaydediyor. GuvenliYayimlama bu koşulu sağlıyor: Paket kurucusu this’i hiçbir yere yazmıyor, deger atandıktan hemen sonra kurucu bitiyor.

Sekiz Düzenek, Dört Kategori

  • EZ7 — Aşağıdaki altı düzenek bekle adlı sınırlı bir döngüyle ölçülüyor: döngü koşulu gerçekleşene kadar en çok 200 milyon kez sınanıyor, süresi hiçbir yerde yazdırılmıyor. Ölçülen tek şey, koşulun gerçekleşip gerçekleşmediği ve gerçekleştiğinde okunan değerdir.
// Yayimlama.java — alti duzenekte yazanin ve okuyanin ayni mekanizmayi kullanmasi yeterli mi
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;

public class Yayimlama {
    static volatile int ucucuDeger = 0;
    static volatile boolean ucucuHazir = false;

    static final Object kilit = new Object();
    static int kilitliDeger = 0;
    static boolean kilitliHazir = false;

    static final AtomicInteger atomikDeger = new AtomicInteger(-1);

    static int joinDeger = 0;
    static int mandalDeger = 0;

    static final ThreadLocal<Integer> ozel = ThreadLocal.withInitial(() -> 0);

    static boolean bekle(BooleanSupplier kosul) {
        for (long i = 0; i < 200_000_000L; i++) if (kosul.getAsBoolean()) return true;
        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        // 1. volatile: yazan ucucu alana yazar, okuyan ayni ucucu alani okur
        Thread t1 = new Thread(() -> { ucucuDeger = 5; ucucuHazir = true; });
        t1.start();
        boolean g1 = bekle(() -> ucucuHazir);
        System.out.println("volatile       : gorundu=" + g1 + " deger=" + ucucuDeger);
        t1.join();

        // 2. synchronized: yazan ve okuyan ayni kilidi kullanir
        Thread t2 = new Thread(() -> { synchronized (kilit) { kilitliDeger = 5; kilitliHazir = true; } });
        t2.start();
        boolean g2 = bekle(() -> { synchronized (kilit) { return kilitliHazir; } });
        int d2; synchronized (kilit) { d2 = kilitliDeger; }
        System.out.println("synchronized   : gorundu=" + g2 + " deger=" + d2);
        t2.join();

        // 3. AtomicInteger: yazan set eder, okuyan get eder
        Thread t3 = new Thread(() -> atomikDeger.set(5));
        t3.start();
        boolean g3 = bekle(() -> atomikDeger.get() != -1);
        System.out.println("AtomicInteger  : gorundu=" + g3 + " deger=" + atomikDeger.get());
        t3.join();

        // 4. join: govde biter, okuyan ancak join'den sonra okur — duz alan, baska hicbir sey yok
        Thread t4 = new Thread(() -> joinDeger = 5);
        t4.start();
        t4.join();
        System.out.println("join           : gorundu=true deger=" + joinDeger);

        // 5. CountDownLatch: yazan countDown eder, okuyan await eder — duz alan
        CountDownLatch mandal = new CountDownLatch(1);
        Thread t5 = new Thread(() -> { mandalDeger = 5; mandal.countDown(); });
        t5.start();
        mandal.await();
        System.out.println("CountDownLatch : gorundu=true deger=" + mandalDeger);
        t5.join();

        // 6. ThreadLocal: her is parcacigi kendi kopyasini gorur, baskasininkini hic gormez
        Thread t6 = new Thread(() -> ozel.set(5));
        t6.start();
        t6.join();
        System.out.println("ThreadLocal    : cagiranin kendi kopyasi=" + ozel.get()
                + " (olusturulanin yazdigi 5 bu kopyada hic yok)");
    }
}
volatile       : gorundu=true deger=5
synchronized   : gorundu=true deger=5
AtomicInteger  : gorundu=true deger=5
join           : gorundu=true deger=5
CountDownLatch : gorundu=true deger=5
ThreadLocal    : cagiranin kendi kopyasi=0 (olusturulanin yazdigi 5 bu kopyada hic yok)

Altı satır da aynı biçimde okunuyor ama gerekçeleri farklı. volatile ve synchronized için hem yazanın hem okuyanın aynı alana ya da aynı kilide dokunması gerekiyor — okuyan başka bir alanı ya da başka bir kilidi denetleseydi hiçbir garanti kalmazdı; bu ikisi ikisinin de sütununa yazılıyor. AtomicInteger aynı gerekçeyle üçüncü örnek: yazan set, okuyan get çağırıyor, ikisi de aynı nesne üzerinden. CountDownLatch da öyle — yazan countDown, okuyan await çağırmak zorunda; biri eksik olsa bağıntı kurulmaz. join farklı bir yükü taşıyor: yazan tarafın yapması gereken hiçbir ekstra şey yok, sadece normal biçimde bitmesi yetiyor; bütün yük okuyanın join() çağırmasına biniyor — bu yüzden yalnız okuyan sütununda. final alan ise tam tersi: yükün tamamı kurucunun tamamlanmasında, yani yalnız yazan tarafta. ThreadLocal görünürlük sorusunu hiç sormuyor — her iş parçacığının kendi kopyası olduğu için ne yazanın ne okuyanın birbirine görünür olması diye bir beklenti yok; bu yüzden hiçbiri sütununda. Ötekilerin yedisi bir değeri paylaşarak güvenli hâle getirmenin yollarıydı, ThreadLocal ise paylaşmamayı bizzat araç hâline getiriyor — aynı adı taşıyan bir değişkenin iki iş parçacığında iki ayrı yaşam sürmesi, görünürlük sorununu çözmüyor, sorudan tamamen çıkarıyor.

Kategori Düzenekler Sayı
yalnız yazan final alan 1
yalnız okuyan join 1
ikisinin de volatile, synchronized, AtomicInteger, CountDownLatch 4
hiçbiri senkronizasyonsuz alan, ThreadLocal 2

Sekiz düzeneğin dördü her iki tarafın da harekete geçmesini istiyor, biri yükü tek başına yazana, biri tek başına okuyana yüklüyor, ikisi ise görünürlük sorusunun dışında kalıyor — biri çünkü hiçbir kural onu kurmuyor, öteki çünkü kural kurmaya gerek duymayacak kadar izole.

join ile CountDownLatch yan yana konunca ilk bakışta aynı işi yapıyor gibi görünüyorlar, ama tablodaki yerleri farklı — biri yalnız okuyan, öteki ikisinin de sütununda. Ayrım şuradan geliyor: join’in kurduğu önce-olur bağıntısı iş parçacığının kendi doğal sonuna bağlıdır, yazan tarafın ayrıca çağırdığı bir yöntem yoktur, gövde nasıl biterse bitsin bağıntı kurulur. CountDownLatch ise böyle bir doğal sona bağlı değildir — sayaç, gövde bitmeden de bitikten sonra da, yazan tarafın açıkça countDown çağırmasıyla düşer. Bu fark CountDownLatch’i join’den daha esnek kılıyor (bir iş parçacığı bitmeden de ilerleme sinyali verebiliyor) ama esnekliğin bedeli, yazan tarafa bir çağrı daha yüklemek.

Sınırlayıcı Ölçüm: Modellenen İki Sınır

  • EZ8 — Bu bölümde hiçbir kod koşturulmuyor. İki iddia da kural referansıyla ve önceki kurslara atıfla destekleniyor, ölçülerek değil — çünkü ölçmenin kendisi ya yanıltıcı ya da tanım gereği imkânsız.

Tablodaki hiçbiri satırının ilk kalemi, senkronizasyonsuz sıradan bir alan, kasıtlı olarak koşturulmuyor. Bunun nedeni bir tembellik değil, bir sınırdır: senkronizasyonsuz bir alanı yazan bir iş parçacığı ile onu bir döngüde bekleyerek okuyan bir başka iş parçacığı, aynı program aynı makinede bile bazen görünürlüğü hemen gösterir, bazen göstermez — sonuç derleyicinin o an verdiği optimizasyon kararına bağlıdır ve bu ders böyle bir kararı ölçmüyor, çünkü M08/K04’ün kendi ölçü ekseni bu değildir. Böyle bir döngüyü burada çalıştırıp “görünmedi” ya da “göründü” yazmak, bir kerelik bir gözlemi genel bir kural gibi sunmak olurdu; oysa kural zaten yukarıda kuruldu ve bu alan için hiçbir madde onu kapsamıyor. Doğru okuma şudur: bir alan bu listedeki yedi düzenekten birine girmiyorsa, o alanın görünürlüğü konusunda hiçbir garanti yoktur — ne “görünür” ne “görünmez” garanti edilir, yalnız garanti edilmez.

Bu, kısa süre çalışan bir denemenin her zaman görünürlüğü gösterip programcıyı yanıltabileceği anlamına geliyor. Yayimlama’daki bekle döngüsü gerçek bir sınama olarak kalabilmesinin nedeni, denetlediği alanların (ucucuHazir, kilitliHazir, atomikDeger) üçünün de bir kural tarafından kapsanmasıdır; aynı döngü senkronizasyonsuz bir boolean alanı denetleseydi, muhtemelen yine gorundu=true yazdırırdı — çünkü kısa ömürlü bir programda derleyicinin döngüyü yeniden düzenlemesi için henüz fırsat doğmamış olabilir. Bu görünür başarı bir kanıt değildir; yalnızca o çalıştırmada kuralın çiğnenmesinin bir bedeli ödenmemiştir. Anında Derleme dersinin ölçtüğü karar burada da geçerlidir: aynı kaynak, hiç değişmeden, çalışma zamanındaki bir kararla farklı davranabilir — fark şu ki o ders kararın komut sayısını ölçtü, bu ders kararın gözlemlenebilirliği bozabileceğini yalnızca belirtiyor.

İkinci sınır volatile alanı ilgilendiriyor ve ölçülen görünürlük garantisiyle karıştırılmaya en açık olanıdır. volatile’ın verdiği şey görünürlük ve sıralamadır, atomikliğin kendisi değildir. oku-artır-yaz üçlemesi kavram olarak M01/K05 İşletim Sistemi Kavramları kursunda kuruldu; aynı üçleme bir volatile alanda da bölünebilir — iki iş parçacığı aynı volatile sayaca sayac++ yazdığında, her iki taraf da işlemi görüyor olsa bile, biri ötekinin artırdığı değeri henüz okumadan kendi artırmasını yapabilir ve bir artırma kaybolur. Bu ders bu iki taraf arasındaki gerçek bir çakışmayı koşturmuyor, çünkü sonucu ortama bağlı olurdu ve kayıp artırmanın kaç kez gerçekleştiği bu dersin ölçü ekseninde hiçbir yere bağlanmıyor. İddia bir kural olarak kalıyor: volatile, tek bir okuma ya da tek bir yazma için görünürlük sağlar; okuma ve yazmayı birleştiren bir işlem için hiçbir şey sağlamaz. Bunun asıl aracı, sonraki derste ölçülen AtomicInteger’ın karşılaştır-ve-ata yöntemidir.

Özet

  • Görünürlük otomatik değildir; bir yazının bir okumaya görünmesi, ikisi arasında dilin tanıdığı bir önce-olur bağıntısının kurulmuş olmasına bağlıdır.
  • Sekiz yayımlama düzeneğinden dördü (volatile, synchronized, AtomicInteger, CountDownLatch) hem yazanın hem okuyanın harekete geçmesini istiyor, biri (final alan) yükü yalnız yazana, biri (join) yükü yalnız okuyana yüklüyor, ikisi (senkronizasyonsuz alan, ThreadLocal) görünürlük sorusunun dışında kalıyor.
  • final alanın bedeli kurulum anında ödenir: kurucu tamamlanınca okuyan taraf hiçbir ek disiplin uygulamadan doğru değeri görür.
  • Senkronizasyonsuz bir alanın görünürlük kusuru koşturularak gösterilmez; bu ders bunu kasıtlı olarak modelliyor, çünkü sonucu ortama bağlı olurdu.
  • volatile görünürlük ve sıralama verir, atomiklik vermez; oku-artır-yaz üçlemesi bir volatile alanda da bölünebilir ve bu ders bunu koşturarak değil kural olarak anlatıyor.

Sonraki Adım

Bu derste synchronized bloğu tabloya yalnız bir satır olarak girdi ve okuyanla yazanın aynı kilidi kullanması gerektiği söylendi, ama “aynı kilit” ifadesinin kendisi hiç sorgulanmadı — kilit bu derste tek bir statik alandı ve iki iş parçacığının aynı nesneyi kullandığı baştan verilmişti. Sıradaki ders bu varsayımı kaldırıyor ve izleyici kilidinin kime ait olduğunu ölçüyor: aynı senkronize yöntem iki ayrı örnekte çağrıldığında kaç ayrı kilit devreye giriyor, ve bir iş parçacığı kendi tuttuğu kilide yeniden girdiğinde bu kaç kez sayılıyor?

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat