Bütün yazılara qayıt
MƏQALƏ

Java Memory Model (JMM): Əsas anlayışlardan JVM-in daxili işinə qədər

Görünürlük, happens-before, təhlükəsiz paylaşma və JVM-in daxili işi: praktik Java nümunələri və interaktiv simulyasiyalarla izah.

30 dəq oxu
-
A axınını, ortaq əsas yaddaşı və B axınını, eləcə də görünürlük, sıralama, atomiklik və happens-before əlaqəsini göstərən Java Memory Model təsviri.

Əsas anlayışlardan JVM-in daxili işinə qədər

Dərinləşdirilmiş Java • Çoxaxınlı proqramlaşdırma • CPU arxitekturası • Eynizamanlılıq • İstehsal mühitində proqram mühəndisliyi

Java Memory Model təcrübəli Java mühəndisinin dərindən bilməli olduğu əsas anlayışlardan biridir. Bu, xüsusilə eynizamanlı tətbiqlər, Spring Boot servisləri, axın hovuzları, ortaq keşlər və kilidsiz alqoritmlərlə işləyərkən vacibdir.

Bu bələdçinin sonunda volatile, synchronized və AtomicInteger vasitələrinin yalnız nə etdiyini deyil, niyə lazım olduğunu, hansı zəmanətləri verdiyini və JVM-in bu zəmanətləri necə təmin etdiyini də başa düşəcəksiniz.

1. Java Memory Model nədir?

Java Memory Model (JMM) axınların ortaq yaddaşı oxuyub yazarkən hansı dəyərləri müşahidə edə biləcəyini müəyyən edən formal spesifikasiyadır.

JMM aşağıdakılar üçün qaydalar müəyyən edir:

  • Görünürlük: Bir axın dəyişəni yenilədikdə digər axın hansı dəyəri görə bilər?
  • Sıralama: Əməliyyatlar mənbə kodundakından fərqli ardıcıllıqla icra olunmuş kimi görünə bilərmi?
  • Atomiklik: Hansı əməliyyatlar bölünməzdir, hansılarının addımları arasına başqa əməliyyatlar daxil ola bilər?
  • Happens-before: Hansı əlaqələr görünürlük və sıralama zəmanətlərini yaradır?
  • Sinxronizasiya: Java-nın eynizamanlılıq mexanizmləri bu zəmanətləri necə təmin edir?

Spesifikasiya əsasən Java dili spesifikasiyasının 17-ci fəslində müəyyən edilib.

Vacib fərq: JMM və JVM-in yaddaş sahələri

Bunlar proqramçıların tez-tez qarışdırdığı iki ayrı mövzudur.

JVM-in icra zamanı yaddaşı

Heap, metaspace və üç ayrı axının steki icra zamanı verilənlərin harada saxlandığını göstərir.
JVM-in icra zamanı yaddaşı

İcra zamanı verilənlərin harada saxlandığı və yaddaşın necə təşkil olunduğu.

Java yaddaş modeli

Görünürlük və sıralama qaydalarına əsasən A axını yazır, B axını oxuyur.
Java yaddaş modeli

Axınlar arasında hansı müşahidələrin və əməliyyat sıralarının mümkün olduğu.

Heap/stack modeli obyektlərin və stek kadrlarının konseptual olaraq harada yerləşdiyini izah edir. JMM isə başqa bir axının ortaq sahələrə yazılmış dəyərləri təhlükəsiz şəkildə müşahidə edib-edə bilməyəcəyini müəyyən edir.

2. Yaddaş modeli nə üçün lazımdır?

Sadə bir Java proqramına baxaq:

Java
class SharedState {
    int value = 0;
    boolean ready = false;
}

İki axın eyni obyektə müraciət edir.

A axını - Yazan

Java
state.value = 42;
state.ready = true;

B axını - Oxuyan

Java
if (state.ready) {
    System.out.println(state.value);
}

B axını ready == true dəyərini müşahidə etdikdə mütləq value == 42 dəyərini də görəcəyini düşünə bilərsiniz.

Buna zəmanət verilmir.

Sinxronizasiya olmadıqda proqramda verilənlər yarışı yaranır. Oxuyan axın ready == true dəyərini görsə də, value == 0 dəyərini müşahidə edə bilər.

Niyə?

Çünki Java mənbə kodu bir neçə icra qatından keçir:

  1. Java mənbə kodu

    Proqram sırasının semantikası

  2. javac → Baytkod

    Sinif faylları və baytkod təlimatları

  3. JVM interpretatoru / JIT kompilyatoru

    Optimallaşdırma, təlimatların planlaşdırılması, yaddaş baryerləri

  4. CPU-da icra

    Registrlər, yazma buferləri, keşlər, yaddaş əməliyyatlarının sırası

  5. Müşahidə olunan nəticə

    Nəticəni yalnız kod sətirlərinin sırası deyil, JMM qaydaları müəyyən edir

Həm kompilyatorlar, həm də prosessorlar icranı optimallaşdıra bilər. JMM bu optimallaşdırmaların hansı müşahidə edilə bilən nəticələrinə icazə verildiyini müəyyən edir.

Əsas prinsip: JMM bir qaydalar toplusudur, CPU keşlərinin və ya JVM implementasiyasının hərfi sxemi deyil. JMM-in icazə verdiyi nəticəni görmək onun konkret CPU keşi və ya kompilyator optimallaşdırması səbəbindən yarandığını sübut etmir.

3. İnteraktiv animasiya: Görünürlük xətası necə yaranır?

Yazan və oxuyan axınların işini sadələşdirilmiş konseptual modellə simulyasiya edək. B axınının uyğunsuz vəziyyəti necə müşahidə edə biləcəyini görmək üçün addımları düymə ilə keçin.

İNTERAKTİV / 01

Yaddaş görünürlüğü simulyatoru

Sinxronlaşdırılıb
AXIN A / YAZAN
value = 0
ready = false
AXIN B / OXUYAN
oxunan value = 0
oxunan ready = false
volatile: buraxma → əldəetmə

İlkin vəziyyət

ready sahəsi volatile kimi elan edilib. Hər iki axın ilkin vəziyyətdən başlayır.

Sinxronlaşdırma dəyişəni volatile ready sahəsidir.
Addım 1 / 5

Sxem mümkün müşahidələri göstərir. O, Java heap obyektlərinin hər axına aid ayrıca nüsxələrini və ya aparatın sabit icra ardıcıllığını təsvir etmir.

4. JVM-in daxili işi: Arxa planda nə baş verir?

Müasir JVM Java axınlarını kompilyator optimallaşdırmalarının, prosessorun yaddaş əməliyyatlarını sıralama qaydalarının və keş uyğunluğunun iştirak etdiyi bir sistem üzərində icra edir.

Sadələşdirilmiş aparat arxitekturası

Hər birində registrlər, yazma buferi və L1/L2 keşləri olan iki CPU nüvəsi ortaq keşə və əsas yaddaşa qoşulur.
Sadələşdirilmiş aparat arxitekturası

4.1 CPU registrləri

Prosessor dəyərləri registrlərdə saxlaya bilər. Registrlərə müraciət RAM-a müraciətdən xeyli sürətlidir.

Proqramçı baxımından bu nümunəyə baxaq:

Java
while (!stopped) {
    performWork();
}

Əgər stopped adi ortaq sahədirsə və bu kod düzgün sinxronlaşdırılmayıbsa, JIT onun hər iterasiyada yenidən oxunmasını aradan qaldıra bilər. Başqa axın stopped = true yazdıqdan sonra da proqram dövrü davam etdirə bilər.

Burada əsas məsələ sıralama və görünürlük zəmanətinin olmamasıdır. Bu davranış konkret bir dəyərin fiziki olaraq CPU registrində qaldığını sübut etmir.

4.2 CPU keşləri və keş uyğunluğu

Müasir CPU-larda bir neçə keş səviyyəsi var. Müxtəlif nüvələrdə işləyən axınlar eyni yaddaş ünvanına fərqli keş iyerarxiyaları vasitəsilə müraciət edə bilər.

MESI kimi aparat səviyyəli keş uyğunluğu protokolları keş sətrinə sahibliyi və onun görünürlüyünü əlaqələndirir.

Tipik MESI vəziyyətləri:

VəziyyətMənası
M - Modified (dəyişdirilmiş)Keş sətri dəyişdirilib və onun ən son verilənləri bu keşdədir
E - Exclusive (tək sahiblik)Keş sətri dəyişdirilməyib və yalnız bu keşdə saxlanılır
S - Shared (ortaq)Bir neçə keş bu dəyişdirilməmiş sətri saxlaya bilər
I - Invalid (etibarsız)Keş sətrindən istifadə edilməməlidir

Keş uyğunluğu düzgün sinxronlaşdırılmamış Java proqramını avtomatik olaraq düzgün etmir.

Keş uyğunluğu və yaddaş tutarlılığı fərqli problemləri həll edir. Keş uyğunluğu ayrı-ayrı keş sətirlərinin dəyərləri üzərində razılaşmaya aiddir. Yaddaş əməliyyatlarının sıralanması isə müxtəlif əməliyyatlar və yaddaş ünvanları arasındakı əlaqələri də əhatə edir.

4.3 Yazma buferləri

CPU yazma əməliyyatlarını digər nüvələr tərəfindən müşahidə edilə bilən hala gəlməzdən əvvəl yazma buferində növbəyə qoya bilər.

Nüvə, arxitekturasının sıralama qaydaları çərçivəsində, əvvəlki yazmalar hələ buferdə olarkən sonrakı oxumaları icra edə bilər.

Bu, Java-da eynizamanlı icra zamanı rast gəlinən Store Buffering (yazmaların buferlənməsi) hadisəsini izah etməyə kömək edir.

Nümunəyə baxaq:

Java
// Initially x = 0, y = 0

// Thread 1
x = 1;
r1 = y;

// Thread 2
y = 1;
r2 = x;

Belə nəticə mümkündürmü?

Struktur
r1 = 0
r2 = 0

Bəli. JMM adi, volatile olmayan sahələr üçün bu nəticəyə icazə verir.

Hər iki axın öz dəyişəninə yazmış olsa da, hər biri digər dəyişənin başlanğıc dəyərini oxuya bilər.

Bir qədər sonra bunu icra simulyatorunda araşdıracağıq.

4.4 Kompilyator və CPU yaddaş baryerləri

Java kodu sinxronizasiyadan istifadə etdikdə JVM uyğun JMM zəmanətlərini qorumalıdır.

Bunun üçün aşağıdakılar lazım ola bilər:

  • Müəyyən optimallaşdırmaların və yenidən sıralamaların qarşısını alan kompilyator məhdudiyyətləri.
  • CPU səviyyəsində yaddaş əməliyyatlarını sıralayan təlimatlar və ya yaddaş baryerləri.
  • CAS kimi əməliyyatlar üçün atomik oxu-dəyişdir-yaz təlimatları.
  • Qarşılıqlı istisna üçün kilid əməliyyatları.

Konkret maşın təlimatları JVM-dən, CPU arxitekturasından və əməliyyatın kontekstindən asılıdır.

Məsələn, x86-64 və ARM64 yaddaş əməliyyatlarını fərqli qaydalarla sıralayır. Eyni JMM zəmanətini təmin etmək üçün hər arxitekturada fərqli maşın təlimatları ardıcıllığı lazım ola bilər.

volatile bütün CPU keşlərini RAM-a yazmaq demək deyil. Bu, geniş yayılmış, amma yanlış təsəvvür yaradan izahdır.

5. Happens-before: JMM-in əsas qaydası

Happens-before əlaqəsini dərindən başa düşmək Java-da eynizamanlılığın əsasını anlamaq deməkdir.

Happens-before hərəkətlər arasındakı formal əlaqədir.

A hərəkəti B hərəkətindən happens-before əlaqəsi ilə əvvəldirsə, Java B-nin nəyi müşahidə edə biləcəyini məhdudlaşdıran sıralama və görünürlük zəmanətləri verir.

Bunu kimi yazırıq.

Bu əlaqəni yaratmağın bir neçə vacib yolu var.

5.1 Proqram ardıcıllığı

Bir axın daxilində proqram ardıcıllığına görə əvvəl gələn hərəkətlərlə sonrakı hərəkətlər arasında happens-before əlaqəsi var.

Java
int x = 10;    // A
int y = 20;    // B
int z = x + y; // C

Proqram ardıcıllığına görə, .

Bu qayda maşın təlimatlarının istənilən şəkildə yenidən sıralanmasını qadağan etmir. Proqramın tələb olunan müşahidə edilə bilən davranışını qoruyan optimallaşdırmaya icazə verilir.

Həmçinin A axını daxilindəki proqram ardıcıllığı öz-özlüyündə B axını üçün axınlararası görünürlük yaratmır.

5.2 Monitorun kilidinin açılması → kilidlənməsi

Java
final Object lock = new Object();
int balance = 0;

// Thread A
synchronized (lock) {
    balance = 100;
}

// Thread B
synchronized (lock) {
    System.out.println(balance);
}

Monitorun kilidinin açılması ilə sinxronizasiya ardıcıllığında həmin monitorun sonrakı kilidlənməsi arasında happens-before əlaqəsi var.

B axını monitoru A axını onu buraxdıqdan sonra əldə edirsə və arada nəticəyə təsir edən başqa yazmalar yoxdursa, 100 dəyərini görür.

5.3 Volatile yazma → oxuma

Java
class State {
    int data;
    volatile boolean ready;
}
Java
// Thread A
state.data = 42;
state.ready = true;

// Thread B
if (state.ready) {
    System.out.println(state.data);
}
A axınıdata = 42ready = truevolatile ilə buraxma
B axınıOxu: ready == truevolatile ilə əldəetməOxu: data == 42
volatile yazması sonrakı volatile oxuması ilə sinxronlaşır

Oxuyan axının paylaşılmış true dəyərini gördüyü və data sahəsinin eyni vaxtda başqa axınlar tərəfindən dəyişdirilmədiyi fərz edilir.

Burada əsas xüsusiyyət tranzitivlikdir: .

Buna görə, .

Məhz buna görə volatile hazırlıq bayrağı ondan əvvəl yazılmış verilənləri təhlükəsiz paylaşa bilir.

Vacib məqam: Bir volatile yazma ilə sinxronizasiya ardıcıllığında eyni dəyişənin sonrakı volatile oxumaları arasında happens-before əlaqəsi var. Bu, zamanlamadan və araya girən yazmalardan asılı olmayaraq istənilən oxuyanın yazan axının nəzərdə tutduğu ən son vəziyyəti mütləq görəcəyi demək deyil.

5.4 Axının başladılması və bitməsinin gözlənilməsi

Java
class Example {
    public static void main(String[] args) throws InterruptedException {
        int[] data = {0};
        data[0] = 42;

        Thread worker = new Thread(() -> {
            System.out.println(data[0]); // 42
            data[0] = 100;
        });

        worker.start();
        worker.join();

        System.out.println(data[0]); // 100
    }
}

Bu nümunə niyə təhlükəsizdir?

Thread.start() çağırışı axını başladan axının əvvəlki hərəkətləri ilə yeni başladılan axının hərəkətləri arasında happens-before əlaqəsi yaradır.

Uğurla tamamlanan Thread.join() işçi axının hərəkətləri ilə join() çağırışından qayıdan axın arasında happens-before əlaqəsi yaradır.

Massiv ortaqdır, amma bu nümunədə axının həyat dövrünə əsaslanan lazımi sinxronizasiya mövcuddur.

Bu qaydalar və kəsilmə ilə bağlı əlavə əlaqələr JLS §17.4-də göstərilib.

6. Eynizamanlılığın üç əsas problemi

Görünürlük

Başqa axın yazılan qiyməti görürmü?

Məsələn, işçi axın dayandırma bayrağının dəyişdiyini görmür.

Atomiklik

Başqa axın əməliyyatın addımları arasına daxil ola bilərmi?

Məsələn, iki axın eyni sayğacı artırır və yeniləmələrdən biri itir.

Sıralama

Digər axınlar əməliyyatları hansı ardıcıllıqla müşahidə edə bilər?

Məsələn, axın hazırlıq bayrağını görür, lakin ona aid verilənləri görmür.

Bu xüsusiyyətlər bir-biri ilə əlaqəlidir, amma biri digərini əvəz etmir.

7. Volatile necə işləyir: Görünürlük və sıralama verir, mürəkkəb əməliyyatları atomik etmir

Əvvəlcə düzgün istifadə nümunəsinə baxaq.

Nümunə A: Arxa plan işçisinin normal dayandırılması

Java
public final class BackgroundWorker {
    private volatile boolean running = true;

    public void start() {
        Thread.ofPlatform().start(() -> {
            while (running) {
                doWork();
            }

            System.out.println("Worker stopped");
        });
    }

    public void stop() {
        running = false;
    }

    private void doWork() {
        Thread.onSpinWait(); // Demo only
    }
}

Burada volatile uyğundur, çünki dəyişən sadə vəziyyət bayrağıdır və onun yenilənməsi bir neçə addımdan ibarət atomik əməliyyat tələb etmir.

Oxuyan axın volatile oxumalar, yazan axın isə volatile yazma yerinə yetirir.

Real tətbiqdə bloklanan işçi axın üçün CPU-nu yükləyən gözləmə dövrü əvəzinə kəsilmədən və uyğun ləğvetmə protokolundan istifadə edin.

Nümunə B: Sayğac üçün volatile niyə kifayət etmir?

Java
class Counter {
    volatile int count = 0;

    void increment() {
        count++;
    }
}

Bir çox Java proqramçısı count volatile olduğu üçün bu kodun axınlar üçün təhlükəsiz olduğunu düşünür.

Amma belə deyil.

count++ oxu-dəyişdir-yaz ardıcıllığıdır:

Java
int previous = count;
int updated = previous + 1;
count = updated;

Fərz edək ki, sayğacın başlanğıc dəyəri 5-dir.

İNTERAKTİV / 02

İtirilmiş yeniləmə simulyatoru

Oxu → dəyiş → yaz
ORTAQ SAYĞAC52 artırmadan sonra gözlənilən qiymət: 7
AXIN ALokal hesablanmış qiymət-
AXIN BLokal hesablanmış qiymət-

Hər iki axın ortaq volatile int sayğacını artırmağa hazırdır.

Addım 1 / 5

Düzgün implementasiya:

Java
import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private final AtomicInteger count = new AtomicInteger();

    public int increment() {
        return count.incrementAndGet();
    }

    public int get() {
        return count.get();
    }
}

Volatile sahə üzərindəki mürəkkəb əməliyyatdan fərqli olaraq, AtomicInteger.incrementAndGet() atomik oxu-dəyişdir-yaz yeniləməsi yerinə yetirir.

Daxildə nə baş verir: Compare-And-Set (CAS)

CAS konseptual olaraq belə müəyyən edilən ibtidai əməliyyatdır:

Struktur
CAS(address, expected, newValue)

if current_value == expected:
    current_value = newValue
    return success
else:
    return failure

Sadələşdirilmiş CAS dövrü:

Java
int previous;
int next;

do {
    previous = counter.get();
    next = previous + 1;
} while (!counter.compareAndSet(previous, next));

İki axın eyni anda 5 oxuyarsa, yalnız bir uğurlu CAS 5 → 6 dəyişməsini həyata keçirə bilər. Digər axın yenilənmiş dəyərlə yenidən cəhd etməlidir.

JVM və platforma atomik yeniləmələri birlikdə təmin edir. Bunun üçün çox vaxt daha ağır qarşılıqlı istisna kilidləri əvəzinə CPU-nun atomik təlimatlarından istifadə olunur.

8. Synchronized necə işləyir: Qarşılıqlı istisna və happens-before

Bank hesabı nümunəsinə baxaq.

Java
public final class BankAccount {
    private long balance = 1_000;

    public synchronized boolean withdraw(long amount) {
        if (amount < 0) {
            throw new IllegalArgumentException();
        }

        if (balance < amount) {
            return false;
        }

        balance -= amount;
        return true;
    }

    public synchronized long getBalance() {
        return balance;
    }
}

Burada əsas zəmanət yalnız balance sahəsinin görünən olması deyil.

Balansın yoxlanılması və məbləğin çıxılması eyni kritik bölmədə baş verir.

Digər axınlar eyni obyektin monitoru ilə qorunan sinxronlaşdırılmış metodu eyni vaxtda icra edə bilməz.

JVM synchronized konstruksiyasını necə təqdim edir?

Sinxronlaşdırılmış blok:

Java
synchronized (lock) {
    balance -= amount;
}

Konseptual olaraq monitor əməliyyatları olan bayt-koda çevrilir:

Struktur
aload          // Load lock reference
monitorenter   // Enter monitor
...
monitorexit    // Exit monitor

Kompilyator blokdan qeyri-normal çıxış zamanı da monitorun buraxılması üçün istisnaları emal edən məntiq yaradır.

synchronized instansiya metodunda metodun gövdəsində ayrıca monitorenter əməliyyat koduna ehtiyac olmur. Bunun əvəzinə metod ACC_SYNCHRONIZED bayrağını daşıyır.

JVM monitor sinxronizasiyasının semantikasından istifadə edir, amma kilidləmə mexanizmini əhəmiyyətli dərəcədə optimallaşdıra bilər. Başqa axınların əldə etməyə çalışmadığı bir kilid həmişə əməliyyat sistemi mutex-i və ya nüvə rejiminə keçid tələb etmir.

Synchronized hansı zəmanətləri verir?

Xüsusiyyətsynchronized
GörünürlükBəli, monitor sinxronizasiyası vasitəsilə
SıralamaBəli
Qarşılıqlı istisnaBəli, eyni monitor üçün
Mürəkkəb əməliyyatların atomikliyiBəli, invariant bütövlükdə qorunursa
Kilidsiz irəliləyişXeyr

İstehsal mühitindəki kod üçün faydalı qayda: dəyişən vəziyyətin invariantına aid bütün müraciətlər vahid sinxronizasiya qaydasına əməl etməlidir.

Yazan axını sinxronlaşdırıb oxuyanlara sinxronizasiyasız müraciət etməyə icazə vermək çox vaxt yenə də yanlışdır.

9. Yenidən sıralama: Java sətirləri başqa ardıcıllıqla icra edə bilərmi?

Bəli, icra JMM-in qaydalarına uyğun qaldığı müddətdə.

İki adi dəyişənlə klassik nümunəyə baxaq.

Java
static int x = 0;
static int y = 0;

static int r1;
static int r2;

A axını:

Java
x = 1;
r1 = y;

B axını:

Java
y = 1;
r2 = x;

Dəyişənlər volatile deyilsə və sinxronizasiya yoxdursa, JMM bu nəticə cütlərinin hamısına icazə verir:

r1r2İcazə verilir?
00Bəli
01Bəli
10Bəli
11Bəli

Hər iki oxuma necə sıfır qaytara bilər?

JMM bu axınlardakı müstəqil, verilənlər yarışına məruz qalan yazma və oxumaların vahid, ardıcıl tutarlı icra təşkil etməsini tələb etmir. Yenidən sıralamanın və buferləmənin təsiri (0, 0) kimi nəticələrin müşahidə edilməsinə imkan verə bilər.

Volatile istifadə etdikdə nə dəyişir?

Java
static volatile int x = 0;
static volatile int y = 0;

Dörd müraciətin hamısı volatile olduqda (0, 0) nəticəsi qadağandır.

Volatile hərəkətlər hər axının proqram ardıcıllığını qoruyan ümumi sinxronizasiya ardıcıllığına uyğun gəlməlidir.

Bu nümunə formal yaddaş modeli səviyyəsində adi sahələrlə sinxronizasiya hərəkətləri arasındakı fərqi göstərir.

10. Praktik təcrübə: Yaddaş əməliyyatlarının sıralanması problemini təkrarlamaq

Yazmaların buferlənməsi nəticəsini müşahidə etməyə çalışmaq üçün aşağıdakı Java proqramını işə sala bilərsiniz.

Java
public class ReorderingExperiment {
    static int x, y;
    static int r1, r2;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 1; i <= 100_000; i++) {
            x = y = r1 = r2 = 0;

            Thread t1 = new Thread(() -> {
                x = 1;
                r1 = y;
            });

            Thread t2 = new Thread(() -> {
                y = 1;
                r2 = x;
            });

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            if (r1 == 0 && r2 == 0) {
                System.out.println("Observed (0, 0) at iteration " + i);
                break;
            }
        }
    }
}

İşə salın:

Terminal
javac ReorderingExperiment.java
java ReorderingExperiment

(0, 0) nəticəsini görə də bilərsiniz, heç görməyə də bilərsiniz.

Bu təcrübə deterministik deyil. Hər iterasiyada axın yaratmaq baha başa gəlir və planlaşdırmaya təsir edir. Bu da nəticəni etibarlı şəkildə təkrarlamağı çətinləşdirir.

join() çağırışları hər iki işçi axın bitdikdən sonra əsas axının nəticə dəyişənlərini təhlükəsiz oxumasını təmin edir. Onlar işçi axınların adi sahə müraciətləri arasında çatışmayan happens-before əlaqəsini yaratmır.

Peşəkar eynizamanlılıq testləri üçün xüsusi test çərçivəsindən istifadə edin.

JCStress: OpenJDK-nın eynizamanlılıq test aləti

JCStress idarə olunan yük testləri vasitəsilə eynizamanlı icranın nəticələrini araşdırmaq üçün hazırlanıb.

Eyni nümunə üçün kiçik yoxlama testi:

Java
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;

@JCStressTest
@Outcome(id = {"0, 1", "1, 0", "1, 1"}, expect = Expect.ACCEPTABLE,
         desc = "Ordinary interleavings")
@Outcome(id = "0, 0", expect = Expect.ACCEPTABLE_INTERESTING,
         desc = "Store-buffering outcome")
@State
public class StoreBufferingTest {
    int x;
    int y;

    @Actor
    public void actor1(II_Result result) {
        x = 1;
        result.r1 = y;
    }

    @Actor
    public void actor2(II_Result result) {
        y = 1;
        result.r2 = x;
    }
}

JCStress test iştirakçılarını təkrar-təkrar icra edir və müşahidə olunan nəticələri təsnif edir.

Bir nəticənin heç vaxt görünməməsi yük testinin onun mümkünsüzlüyünü sübut etməsi demək deyil. Bu testlərin faydası icazə verilən və qadağan edilən eynizamanlı davranışları araşdırmaqdır.

11. Final açar sözü və təhlükəsiz paylaşma

final açar sözü eynizamanlılıq üçün ilk baxışdan göründüyündən daha vacibdir.

Nümunəyə baxaq:

Java
public final class UserProfile {
    private final String username;
    private final int age;

    public UserProfile(String username, int age) {
        this.username = username;
        this.age = age;
    }

    public String username() {
        return username;
    }

    public int age() {
        return age;
    }
}

Obyekt düzgün yaradılıbsa, JMM final sahələrə ilkinləşdirmə təhlükəsizliyi ilə bağlı xüsusi zəmanətlər verir.

Konkret olaraq, konstruktor obyektin yaradılması tamamlanmamış onun istinadının kənara çıxmasına imkan verməməlidir.

JVM/JMM obyektin yaradılmasının sonunda final sahələrin semantikasına aid dondurma hərəkəti (freeze action) müəyyən edir.

Bu, adi dəyişən sahələrə sadəcə dəyər mənimsətməkdən daha güclü zəmanətlər verir.

this istinadının kənara çıxması nə deməkdir?

Təhlükəli nümunə:

Java
public class UnsafeService {
    static UnsafeService instance;

    private final int timeout;

    public UnsafeService() {
        instance = this; // Escapes too early
        timeout = 30;
    }
}

Başqa bir axın obyektin yaradılması tamamlanmadan instance sahəsini oxuya bilər.

Yaradılma zamanı istinadı düzgün olmayan şəkildə kənara çıxan obyektə final sahələrin yaradılması ilə bağlı xüsusi zəmanətlər normal qaydada tətbiq olunmur.

Daha təhlükəsiz yanaşma:

Java
public final class SafeService {
    private final int timeout;

    private SafeService(int timeout) {
        this.timeout = timeout;
    }

    public static SafeService create() {
        return new SafeService(30);
    }

    public int timeout() {
        return timeout;
    }
}

Bu yanaşma this istinadını konstruktordan kənara çıxarmır. Başqa dəyişən vəziyyət və ya sıralama tələbləri olduqda, nəticədə alınan istinad yenə də uyğun paylaşma strategiyası ilə digər axınlara ötürülməlidir.

Digər vacib fərq: final obyektin dərin dəyişməzliyini təmin etmir.

Java
final List<String> names = new ArrayList<>();
names.add("Alice");

İstinada yenidən dəyər mənimsətmək olmaz, amma onun göstərdiyi obyekt hələ də dəyişdirilə bilər. Dəyişdirilə bilən kolleksiyaya final istinad kolleksiya üzərində eynizamanlı əməliyyatları təhlükəsiz etmir.

12. Təhlükəsiz paylaşma: Obyekti axınlar arasında necə ötürmək olar?

Fərz edək ki, bir axın konfiqurasiya obyekti yaradır:

Java
record Configuration(String host, int port, boolean tls) {}

Başqa axın bu obyekti necə əldə etməlidir?

Praktik yanaşmalardan biri volatile vasitəsilə paylaşmaqdır.

Java
public final class ConfigManager {
    private volatile Configuration current = new Configuration("localhost", 8080, false);

    public void reload(Configuration next) {
        current = next;
    }

    public Configuration snapshot() {
        return current;
    }
}

Bu implementasiyanın bir neçə mühüm xüsusiyyəti var:

  1. Konfiqurasiya obyekti dəyişməzdir.
  2. Volatile istinad təhlükəsiz paylaşmanı təmin edir.
  3. Oxuyanlar dəyişən sahələri ayrı-ayrılıqda oxumaq əvəzinə konfiqurasiyanın bütöv vəziyyətini görürlər.
  4. Konfiqurasiyanı əvəz etmək əvvəlki obyektin dəyişdirilməsini tələb etmir.

Oxuyan axın konfiqurasiyanın tutarlı bir versiyasını götürə bilər:

Java
Configuration config = manager.snapshot();

String host = config.host();
int port = config.port();
boolean tls = config.tls();

Bu yanaşma funksiya bayraqları, marşrutlaşdırma konfiqurasiyası, qiymətləndirmə qaydaları və yaddaşdakı konfiqurasiyanın yenilənməsi üçün xüsusilə faydalıdır.

Bu sahələri bir-birindən müstəqil yeniləməkdən daha təhlükəsizdir:

Java
volatile String host;
volatile int port;
volatile boolean tls;

Niyə? Çünki sahələrin ayrı-ayrılıqda görünən olması bir neçə sahənin vahid vəziyyətinin atomik oxunmasını təmin etmir.

13. İkiqat yoxlamalı kilidləmə: Volatile niyə vacibdir?

Klassik müsahibə sualı:

Java
public final class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        Singleton local = instance;

        if (local == null) {
            synchronized (Singleton.class) {
                local = instance;

                if (local == null) {
                    local = new Singleton();
                    instance = local;
                }
            }
        }

        return local;
    }
}

Bu yanaşma Double-Checked Locking (DCL), yəni ikiqat yoxlamalı kilidləmə adlanır.

volatile nə üçün lazımdır?

Onsuz obyektin istinadı həmin obyektin ilkinləşdirilmiş adi sahələri üçün lazımi sıralama və görünürlük zəmanətləri yaradılmadan paylaşıla bilər.

Riskə konseptual baxış:

Struktur
A axını:
    Obyekt üçün yaddaş ayır
    Obyekti ilkinləşdir
    İstinadı paylaş

B axını:
    İstinadın null olmadığını müşahidə et
    Obyektin sahələrini oxu

Əsas sual paylaşma ilə digər axının obyekt sahələrini oxuması arasında happens-before əlaqəsinin olub-olmamasıdır.

Düzgün sinxronizasiya olmadıqda başqa axın null olmayan istinadı görə bilər, amma konstruktorun ilkinləşdirdiyi vəziyyəti proqramçının gözlədiyi kimi müşahidə etməyə bilər.

Paylaşma üçün volatile sahə istifadə edildikdə tələb olunan sıralama təmin olunur.

Daha sadə həll

Yalnız lazım olduqda yaradılan adi singleton üçün initialization-on-demand holder yanaşması adətən daha münasibdir:

Java
public final class Singleton {
    private Singleton() {}

    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

JVM sinif ilkinləşdirilməsini öz ilkinləşdirmə qaydalarına uyğun sinxronlaşdırır.

Bu implementasiyada ayrıca volatile sahəyə və ikiqat yoxlamalı kilidləmə məntiqinə ehtiyac qalmır.

14. VarHandle: Yaddaşa aşağı səviyyəli müraciət rejimləri

Java mürəkkəb eynizamanlı alqoritmlər üçün VarHandle təqdim edir. Bu vasitə yaddaşa müraciətin semantikasına daha dəqiq nəzarət etməyə imkan verir.

Onu yaddaş əməliyyatları üçün fərqli sıralama səviyyələrini ifadə edən alət kimi düşünün.

Müraciət rejimiÜmumi xüsusiyyət
Adi get / setAdi sahə müraciəti
OpaqueEyni dəyişən üzrə məhdud uyğunluq və sıralama zəmanətləri olan atomik müraciət
Acquire oxumaSonrakı müraciətlərin acquire əməliyyatından sonra gəlməsini təmin edir
Release yazmaƏvvəlki müraciətlərin release əməliyyatından əvvəl gəlməsini təmin edir
VolatileDaha güclü, ümumi ardıcıllıqla sıralanan volatile semantikası
CASMüəyyən edilmiş yaddaş təsirləri olan atomik şərti yeniləmə

Bu rejimlər Java VarHandle API sənədlərində və OpenJDK implementasiyasında təsvir olunub. Mənbə məqalədəki Java SE 16 VarHandle istinadına da baxa bilərsiniz.

Praktik nümunə: Release/acquire vasitəsilə paylaşma

Java
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public final class ReleaseAcquireExample {
    private int data;
    private boolean ready;

    private static final VarHandle READY;

    static {
        try {
            READY = MethodHandles.lookup().findVarHandle(
                    ReleaseAcquireExample.class, "ready", boolean.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public void publish() {
        data = 42;

        READY.setRelease(this, true);
    }

    public int consume() {
        if ((boolean) READY.getAcquire(this)) {
            return data;
        }

        return -1;
    }
}

İstehlakçının acquire oxuması uyğun release yazmasının dəyərini müşahidə etdikdə istehsalçının əvvəlki yazmaları istehlakçının sonrakı oxumalarından əvvəl sıralanır.

Bu, volatile vasitəsilə paylaşma ilə əlaqəli yanaşmadır, amma yaddaş əməliyyatlarının sıralanması üçün daha dəqiq seçilmiş semantikadan istifadə etməyə imkan verir.

Adi biznes məntiqi üçün aşağı səviyyəli VarHandle-lardan ümumiyyətlə istifadə etməməlisiniz. Onlar kitabxanalar, sinxronizasiya mexanizmləri, növbələr və diqqətlə yoxlanılmış kilidsiz strukturlar üçün daha uyğundur.

Daxili mexanizm: Yaddaş baryerləri

VarHandle bu kimi metodlar da təqdim edir:

Java
VarHandle.acquireFence();
VarHandle.releaseFence();
VarHandle.fullFence();

Bunlar müəyyən yenidən sıralamaları məhdudlaşdırır, amma tək bir yaddaş baryeri avtomatik olaraq tam sinxronizasiya protokolu yaratmır.

Düzgün kilidsiz alqoritm dəyərlərin necə paylaşıldığını, oxuyanların onları necə müşahidə etdiyini və bütün icranın tələb olunan yaddaş sıralama əlaqələrini niyə ödədiyini müəyyən etməlidir.

15. İstehsal mühitindən nümunə: Axınlar üçün təhlükəsiz balans köçürməsi

Hesablar arasında köçürmələri emal edən bir Spring Boot tətbiqi təsəvvür edin.

Balans sahələrini volatile elan etməklə düzgünlüyün təmin olunduğunu düşünmək geniş yayılmış səhvdir.

Java
class Account {
    volatile long balance;
}

Bu kod köçürmə üçün kifayət etmir:

Java
if (source.balance >= amount) {
    source.balance -= amount;
    destination.balance += amount;
}

Bir neçə axın balans yoxlamasından eyni vaxtda keçə bilər və oxu-dəyişdir-yaz əməliyyatları atomik deyil.

İnvariantı bütövlükdə əhatə edən qarşılıqlı istisnaya ehtiyac var.

Bir proses daxilində düzgün kilidləmə nümunəsi

Java
public final class Account {
    private final long id;
    private long balance;

    public Account(long id, long initialBalance) {
        this.id = id;
        this.balance = initialBalance;
    }

    public synchronized long getBalance() {
        return balance;
    }

    public static void transfer(Account from, Account to, long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }

        if (from == to) {
            return;
        }

        Account first = from.id < to.id ? from : to;
        Account second = from.id < to.id ? to : from;

        if (first.id == second.id) {
            throw new IllegalArgumentException("Duplicate account IDs");
        }

        synchronized (first) {
            synchronized (second) {
                if (from.balance < amount) {
                    throw new IllegalStateException("Insufficient funds");
                }

                from.balance = Math.subtractExact(from.balance, amount);
                to.balance = Math.addExact(to.balance, amount);
            }
        }
    }
}

Bu implementasiyanı maraqlı edən nədir?

Kilidlər yalnız ayrı-ayrı oxuma və yazmaları deyil, bütün köçürməni qoruyur. Kilidləmə ardıcıllığının tərsinə çevrilməsinin və qarşılıqlı bloklanmanın qarşısını almaq üçün hər iki hesab eyni ID ardıcıllığına uyğun kilidlənir.

İmplementasiya hesab ID-lərinin unikal olduğunu və balansların yalnız eyni kilidləmə qaydasına uyğun dəyişdirildiyini fərz edir.

Bu yanaşma bir JVM prosesi daxilindəki ortaq obyektlər üçün işləyir. Bir neçə Spring Boot instansiyası arasında paylanmış tutarlılıq təmin etmir. Verilənlər bazasında saxlanılan pul köçürmələrində invariantı verilənlər bazasının tranzaksiyası və kilidləmə/izolyasiya strategiyası qorumalıdır.

16. JMM haqqında geniş yayılmış yanlış təsəvvürlər

Yanlış təsəvvürDüzgün izah
volatile hər əməliyyatı atomik edirMürəkkəb yeniləmələr yenə də atomik əməliyyatlar və ya kilidlər tələb edir
volatile verilənləri dərhal RAM-a yazırJMM RAM-a hərfi yazılmanı deyil, sıralama və görünürlüyü müəyyən edir
synchronized yalnız eyni vaxtda icranın qarşısını alırO, happens-before zəmanətlərini də yaradır
CPU keşlərinin uyğunluğu ortaq Java sahələrini təhlükəsiz edirTəkcə keş uyğunluğu Java sinxronizasiya protokolu deyil
final obyekti dəyişməz edirFinal sahələr dəyişdirilə bilən obyektlərə istinad edə bilər
Eynizamanlı kolleksiya bütün çoxaddımlı iş axınlarını atomik edirAyrı-ayrı axınlar üçün təhlükəsiz əməliyyatlar avtomatik olaraq vahid atomik tranzaksiya təşkil etmir
Thread.sleep() başqa axının yazmalarının görünməsini təmin edirAxının yatması sinxronizasiya əlaqəsi yaratmır
Virtual axınlar verilənlər yarışını aradan qaldırırVirtual və platforma axınları eyni JMM qaydalarına tabedir

Sonuncu məqam müasir Java üçün xüsusilə vacibdir: virtual axınlar eynizamanlılığın resurs xərcini və miqyaslanma xüsusiyyətlərini dəyişir, ortaq yaddaşla düzgün işləməyin əsas qaydalarını isə dəyişmir.

17. Uyğun eynizamanlılıq mexanizmini seçmək

TələbBaşlanğıc üçün tövsiyə
Ortaq dayandırma bayrağıvolatile və ya kəsilmə
Ortaq ədədi sayğacAtomicInteger, AtomicLong
Çoxlu axının eyni anda yenilədiyi statistika sayğacıLongAdder
Bir neçə əlaqəli sahənin qorunmasısynchronized və ya ReentrantLock
Dəyişməz konfiqurasiyanın paylaşılmasıvolatile istinad və ya AtomicReference
Ortaq açar/dəyər saxlanmasıConcurrentHashMap
İstehsalçıdan istehlakçıya ötürməBlockingQueue
Müstəqil asinxron əməliyyatlarCompletableFuture / icraçılar
Aşağı səviyyəli sıralamaya dəqiq nəzarətVarHandle
İnstansiyalar arasında verilənlər bazası tutarlılığıVerilənlər bazası tranzaksiyaları, kilidlər, məhdudiyyətlər

Bunlar mütləq qaydalar deyil, seçimə başlamaq üçün istiqamətlərdir. Uyğun mexanizm hansı invariantın qorunmalı olduğundan və hansı tutarlılıq zəmanətlərinin tələb edildiyindən asılıdır.

18. Eynizamanlı davranışı özünüz necə araşdıra bilərsiniz?

Java kodunun necə kompilyasiya olunduğunu araşdırmaq üçün JVM alətlərindən istifadə edə bilərsiniz.

Sinxronlaşdırılmış metod üçün:

Java
public class LockExample {
    private int count;

    public synchronized void increment() {
        count++;
    }
}

Kompilyasiya edin və bayt-koda baxın:

Terminal
javac LockExample.java
javap -c -v -p LockExample

Metodun ACC_SYNCHRONIZED bayrağını axtarın.

Sinxronlaşdırılmış blok üçün:

Java
public class BlockExample {
    private final Object lock = new Object();
    private int count;

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
}

Yoxlayın:

Terminal
javac BlockExample.java
javap -c -p BlockExample

monitorenter və monitorexit təlimatlarını görəcəksiniz.

İncə, amma vacib detal: javap JIT-in yaratdığı son CPU təlimatlarını deyil, bayt-kodu göstərir. Maşın kodunu araşdırmaq üçün əlavə JVM imkanları, məsələn, uyğun diaqnostika və disassembler quruluşu ilə assembly çıxışı lazımdır.

19. Təcrübəli mühəndis üçün düşüncə modeli

İstənilən çoxaxınlı Java kodunu nəzərdən keçirərkən bu beş sualı verin:

PRAKTİKADA YOXLA

Eynizamanlılıq üzrə kod yoxlama siyahısı

0/5 yoxlanılıb

Bu yoxlama siyahısının əsaslandığı formal prinsip belədir:

Proqram düzgün sinxronlaşdırılıbsa və verilənlər yarışı yoxdursa, onun icraları ardıcıl tutarlı davranış göstərir. Bu, bir neçə əməliyyatdan ibarət hər biznes tranzaksiyasının avtomatik atomik olduğu demək deyil. Bunlar ayrı tələblərdir.

Əlavə texniki mənbələr

Yekun

Java Memory Model-i dərindən anlamaq üçün dörd qatı bir-birinə bağlamaq lazımdır:

Java kodu - Sahələr, obyektlər, kilidlər, atomik əməliyyatlar, axının həyat dövrü.

JMM qaydaları - Oxumaların ala biləcəyi dəyərlər, happens-before, sinxronizasiya və sıralama.

JVM və CPU - JIT optimallaşdırmaları, keşlər, yazma buferləri, yaddaş baryerləri və atomik təlimatlar.

Düzgün eynizamanlı davranış - Proqnozlaşdırıla bilən görünürlük, təhlükəsiz paylaşma və qorunan invariantlar.

Bundan sonra dərindən öyrənmək üçün ən faydalı mövzu Java Memory Model-in daxili mexanizmləridir: yazma buferləri, CPU keşlərinin uyğunluğu, yaddaş baryerləri, x86 və ARM64 fərqləri, eləcə də volatile/CAS əməliyyatları üçün JIT-in yaratdığı real assembly kodu. Bu mövzu formal JMM-i HotSpot və prosessor daxilində baş verənlərlə birbaşa əlaqələndirir.