Problem: yeniləmələri itirən sayğac
Servis emal olunan sorğuları adi sahədə sayır və dəyəri status endpoint-ində göstərir. Yük testində bir milyon sorğu çoxlu thread ilə emal olunur, amma sayğac bir milyondan azdır. Heç nə çökmür, heç bir exception loglanmır və rəqəm hər işə salınmada fərqlidir. Bu, race condition-dur, ən tipik concurrency səhvidir: düzgünlük thread-lərin necə növbələşməsindən asılıdır.
class RequestCounter { private long count; void increment() { count++; } // oxu, əlavə et, yaz: üç addım long get() { return count; }}count++ bir əməliyyat kimi görünür, amma oxuma, toplama və yazmadır. İki thread eyni dəyəri oxuya, hər ikisi bir əlavə edə və eyni nəticəni yaza bilər, beləliklə bir yeniləmə itir. İkinci, ayrı problem isə budur ki, bir thread başqasının yazdığını heç görməyə də bilər. Hər ikisi üçün aydın zehni model lazımdır.
Thread və ExecutorService
Thread-i birbaşa new Thread(task).start() ilə yarada bilərsiniz. Demo üçün işləyir, amma servislərdə məhdudiyyətsiz thread yaradılmasına, təkrar istifadənin olmamasına, adlandırma, xəta idarəsi və dayandırma üçün mərkəzi yerin olmamasına gətirir.
ExecutorService nəyi işlətməyi (Runnable və ya Callable) necə işləməsindən (pool siyasəti) ayırır.
ExecutorService pool = Executors.newFixedThreadPool(8);try { Future<Report> f = pool.submit(() -> reportService.build(customerId)); Report report = f.get(5, TimeUnit.SECONDS);} catch (ExecutionException e) { throw new ReportFailedException(e.getCause()); // tapşırığın öz exception-u} finally { pool.shutdown();}Yadda saxlayın:
- Təqdim olunmuş
Callabledaxilində atılan exceptionget()çağırana qədər təqdim edənə çatmır və oradaExecutionExceptioniçində gəlir.execute(Runnable)ilə tutulmamış exception thread-in uncaught-exception handler-inə gedir, o da default olaraq sadəcə çap edir. - Pool ölçüsünü şüurlu seçin. CPU-ya bağlı iş nüvə sayına yaxın thread istəyir; bloklayan I/O daha çox istəyir, amma sonsuz yox. Pool həm də paralellik limitidir və aşağı axındakı sistemi qoruya bilər.
- Pool-ları həmişə dayandırın və
ThreadFactoryilə thread-lərə mənalı ad verin ki, thread dump oxunaqlı olsun. - Spring-də kodun müxtəlif yerlərinə səpələnmiş xam
Executorsçağırışları əvəzinə konfiqurasiya olunmuşTaskExecutorvə ya adlandırılmış executor ilə@Asyncistifadə edin. CompletableFutureasinxron addımları birləşdirir.supplyAsync-ə öz executor-unuzu verin, əks halda tapşırıqlar ümumi pool-dan istifadə edir.
Visibility və happens-before
Müasir CPU-lar və JVM sürət üçün əməliyyatları yenidən sıralayır və keşləyir. Sinxronizasiya olmadan Java Memory Model (JLS 17-ci fəsil) A thread-inin yazdığının B thread-i tərəfindən heç görüləcəyini və ya yazıların sırasının hər ikisinə eyni görünəcəyini demir. Model happens-before münasibətini təyin edir: X əməliyyatı Y-dən əvvəl happens-before münasibətindədirsə, X-in təsirləri Y üçün görünəndir və ondan əvvəl sıralanıb.
Tətbiq kodu üçün vacib happens-before halları:
- Monitoru açmaq (unlock) eyni monitorun sonrakı hər bağlanmasından (lock) əvvəl happens-before-dur (
synchronized). volatilesahəyə yazı həmin sahənin sonrakı hər oxunuşundan əvvəl happens-before-dur.Thread.start()başladılan thread-dəki hər əməliyyatdan əvvəl happens-before-dur, thread-dəki bütün əməliyyatlar isə başqa thread həmin thread üzərindəjoin()-dən uğurla qayıdana qədər happens-before-dur.- Tapşırığı executor-a verməzdən əvvəlki əməliyyatlar tapşırığın işləməsindən əvvəl, tapşırığın əməliyyatları isə
Future.get()ilə qaytarılan nəticədən əvvəl happens-before-dur. - Tək thread daxilində proqram sırası tətbiq olunur.
Yazı ilə oxuma arasında belə əlaqə yoxdursa, data race var və proqramın davranışını əsaslandırmaq mümkün deyil. Klassik nümunə flag-dır:
class Worker implements Runnable { private boolean stopped; // SƏHV: volatile deyil public void run() { while (!stopped) { doWork(); } // sonsuz dövr edə bilər } void stop() { stopped = true; }}JIT kompilyatoruna stopped-i bir dəfə oxuyub registrdə saxlamaq icazəlidir, çünki dövrdə başqa thread-in onu dəyişə biləcəyini deyən heç nə yoxdur. Sahəni volatile elan etmək lazım olan əlaqəni yaradır.
volatile və synchronized
volatile tək dəyişən üçün visibility və sıra verir. Mürəkkəb əməliyyatlar üçün atomiklik vermir. volatile long count; count++; hələ də yeniləmə itirən race-dir.
synchronized həm qarşılıqlı istisna (eyni anda yalnız bir thread monitoru tutur), həm də visibility verir (yuxarıdakı happens-before). Birdən çox dəyişəni əhatə edən invariantı və ya yoxla-sonra-et ardıcıllığını qorumaq üçün onu, ya da ReentrantLock işlədin.
class Account { private long balance; synchronized void withdraw(long amount) { if (balance < amount) throw new IllegalStateException("insufficient"); balance -= amount; // yoxlama və yeniləmə vahid kimi } synchronized long balance() { return balance; } // oxuma da kilid almalıdır}Təcrübə qaydaları: sadə vəziyyət flag-ları və təhlükəsiz dərc olunmuş referanslar üçün volatile; bir neçə addım vahid kimi baş verməli olanda synchronized və ya kilidlər; kritik bölmələri qısa saxlayın; kilid tutarkən heç vaxt naməlum kodu (callback, override oluna bilən metod) çağırmayın, deadlock belə yaranır.
Atomic siniflər
Tək dəyişənli sayğac və yığıcılar üçün java.util.concurrent.atomic compare-and-set əsasında kilidsiz atomik əməliyyatlar verir və volatile ilə eyni yaddaş görünürlüyünə malikdir.
class RequestCounter { private final AtomicLong count = new AtomicLong(); void increment() { count.incrementAndGet(); } long get() { return count.get(); }}Bu, giriş nümunəsinin düzəlişidir. Güclü rəqabət altında sırf sayğaclar üçün LongAdder çox vaxt daha yaxşı miqyaslanır, amma sum() daha bahadır. Atomic-lər bir dəyişənə kömək edir; ardıcıl yenilənən iki atomic atomik cüt deyil, ona görə çoxsahəli invariant yenə kilid tələb edir.
ConcurrentHashMap
Thread-lər arasında paylaşılan adi HashMap təhlükəlidir: itirilmiş yeniləmələr və pozulmuş daxili vəziyyət mümkündür. ConcurrentHashMap paralel müraciət üçün hazırlanıb və ayrı-ayrı əməliyyatlar thread-safe-dir. Amma çağırışlar ardıcıllığı atomik deyil:
// Race: iki thread də "yoxdur" görüb hər ikisi put edə bilərif (!cache.containsKey(key)) { cache.put(key, load(key));}// Açar üzrə atomikcache.computeIfAbsent(key, k -> load(k));Tək açar üzərində yoxla-sonra-et üçün computeIfAbsent, merge və compute işlədin, lambdanı qısa saxlayın, içində bloklayan iş və ya eyni map-in dəyişdirilməsi olmasın. ConcurrentHashMap null açar və dəyərləri qəbul etmir, bu da get-in null qaytarmasındakı qeyri-müəyyənliyi aradan qaldırır. Daha geniş qərar üçün kolleksiyaların seçilməsinə baxın.
Tez-tez edilən səhvlər
volatilevə ya "thread-safe" sahənin mürəkkəb əməliyyatı təhlükəsiz etdiyini fərz etmək.- Dəyişən sahə, string literal və ya boxed dəyər üzərində sinxronizasiya; private final kilid obyekti işlədin.
- Kilid tutarkən I/O etmək; kilidi gözləyən bütün thread-lər dayanır.
- Müxtəlif yerlərdə bir neçə kilidi fərqli sırada almaq; bu, deadlock yaradır.
SimpleDateFormatkimi paylaşılan dəyişən köməkçini thread-lər arasında işlətmək;java.timekimi immutable tiplərə üstünlük verin.- Başqa thread-i "gözləmək" üçün düzgün siqnal (
CountDownLatch,Future,BlockingQueue) əvəzinəThread.sleepçağırmaq. - Kəsilməni görməzdən gəlmək. Ləğv üçün
InterruptedException-a hörmət edin. - Bloklayan iş üçün paralel stream-lərə güvənmək; stream tələlərinə baxın.
Güzəştlər
Ən təhlükəsiz paralel dizayn heç nəyi paylaşmayandır: datanı bir thread-ə məhdudlaşdırın və ya immutable obyektlər ötürün. Paylaşmaq məcburidirsə, əl ilə kilidləmə əvəzinə yüksək səviyyəli konstruksiyalara (concurrent kolleksiyalar, növbələr, executor-lar) üstünlük verin. Kilidləri əsaslandırmaq asandır, amma bloklaya və deadlock yarada bilər; atomic-lər və kilidsiz strukturlar bloklamadan qaçır, amma sadə vəziyyətlə məhduddur və incə ola bilər. Daha incə kilidləmə rəqabət altında ötürmə qabiliyyətini də, səhv ehtimalını da artırır.
Java 21-dən virtual thread-lər thread-ləri bloklamağın qiymətini dəyişir, amma yaddaş modelini və təhlükəsiz paylaşım ehtiyacını dəyişmir; virtual thread-lərə baxın.
Yoxlama siyahısı
- Paylaşılan dəyişən vəziyyət müəyyən edilib və hər birinin adlı qorunması var (kilid, atomic, məhdudlaşdırma, immutability).
- Xam thread əvəzinə executor-lar, adlı thread-lər və dayandırma yolu ilə.
- Mürəkkəb əməliyyatlar
synchronized, kilid və yacomputetipli metodlar işlədir. - Dövrdə oxunan flag-lar
volatile-dır. - Kilidlərin sənədləşdirilmiş sırası var və I/O zamanı tutulmur.
- Kodu çoxlu thread ilə sınayan test var, amma keçməsi düzgünlüyü sübut etmir.
Növbəti addım
Java 21-in thread-per-request modelini necə dəyişdiyini görmək üçün virtual thread-ləri, heap-də nə baş verdiyini bilmək üçün yaddaş və garbage collection məqaləsini oxuyun. Middle Java developer yol xəritəsi concurrency-nin ətrafdakı bacarıqlar arasında yerini göstərir.