Programmazione Concorrente in Java

Edoardo Midali
Edoardo Midali

La programmazione concorrente permette l’esecuzione simultanea di multiple parti di un programma, sfruttando processori multi-core e migliorando responsiveness e throughput delle applicazioni. Java fornisce un ricco ecosistema di strumenti per la concorrenza, dal thread management di basso livello alle abstrazioni ad alto livello del package java.util.concurrent.

Concetti Fondamentali

La concorrenza si basa sulla capacità di coordinare l’esecuzione di thread multipli che condividono risorse comuni. Questo introduce sfide uniche come race conditions, deadlock e problemi di visibilità della memoria.

Concorrenza vs Parallelismo

Concorrenza è la capacità di gestire multiple attività contemporaneamente attraverso interleaving, anche su un singolo core. È principalmente una questione di design del programma.

Parallelismo è l’esecuzione simultanea effettiva di multiple attività su hardware multi-core. È una questione di esecuzione fisica.

Java permette di scrivere programmi concorrenti che possono beneficiare automaticamente del parallelismo quando l’hardware lo supporta.

Thread Safety

Un codice è thread-safe quando funziona correttamente durante l’accesso concorrente da parte di thread multipli, senza richiedere sincronizzazione aggiuntiva da parte del codice chiamante.

Livelli di Thread Safety:

  • Immutable: Oggetti che non possono essere modificati dopo la creazione
  • Thread-safe: Operazioni atomiche e sincronizzazione interna
  • Conditionally thread-safe: Thread-safe per alcune operazioni ma non altre
  • Not thread-safe: Richiede sincronizzazione esterna
  • Thread-hostile: Non sicuro anche con sincronizzazione esterna
// Immutable - inherently thread-safe
public final class ImmutableCounter {
    private final int value;

    public ImmutableCounter(int value) {
        this.value = value;
    }

    public ImmutableCounter increment() {
        return new ImmutableCounter(value + 1);
    }

    public int getValue() { return value; }
}

// Thread-safe con sincronizzazione interna
public class ThreadSafeCounter {
    private final AtomicInteger value = new AtomicInteger(0);

    public void increment() { value.incrementAndGet(); }
    public int getValue() { return value.get(); }
}

Problemi della Concorrenza

Race Conditions

Si verificano quando il risultato dipende dal timing relativo di eventi non controllabili. Il problema classico è il check-then-act pattern.

// Problematico - race condition
if (!map.containsKey(key)) {
    map.put(key, value); // Altro thread potrebbe aver inserito la chiave
}

// Corretto - operazione atomica
map.putIfAbsent(key, value);

Deadlock

Situazione dove due o più thread si bloccano indefinitamente, ognuno aspettando che l’altro rilasci una risorsa.

Condizioni per il Deadlock:

  1. Mutua esclusione
  2. Hold and wait
  3. No preemption
  4. Circular wait

Prevenzione del Deadlock:

  • Ordinamento dei lock per prevenire circular wait
  • Timeout sui lock acquisition
  • Detection e recovery algorithms

Livelock e Starvation

Livelock: Thread che cambiano continuamente stato in risposta ad altri thread ma non progrediscono.

Starvation: Thread che non riescono mai ad accedere alle risorse necessarie perché altri thread le monopolizzano.

Strumenti di Sincronizzazione

Synchronized

Il meccanismo più basilare per la sincronizzazione, garantisce mutua esclusione e visibilità della memoria.

Caratteristiche:

  • Reentrant: un thread può acquisire più volte lo stesso lock
  • Visibility guarantees: modifiche in un blocco synchronized sono visibili al prossimo thread che acquisisce lo stesso lock
  • Overhead: relativamente costoso a causa del context switching

Volatile

Fornisce garanzie di visibilità senza mutua esclusione, impedisce caching locale delle variabili.

Uso appropriato:

  • Flag booleani per interruzione
  • State fields che non richiedono atomicity
  • Reference fields dove la reference change è l’unica operazione critica

Atomic Classes

Implementano operazioni lock-free usando hardware-level atomic operations come compare-and-swap.

Vantaggi:

  • Performance superiore in scenari di high contention
  • Freedom da deadlock
  • Progress guarantees

Collezioni Concurrent

Java fornisce implementazioni thread-safe delle collezioni principali ottimizzate per accesso concorrente.

ConcurrentHashMap

Utilizza segmentazione e lock-free techniques per permettere high concurrency.

Caratteristiche:

  • Weak consistency iterators: non lanciano ConcurrentModificationException
  • Atomic compound operations: putIfAbsent, replace, remove
  • Scalabilità: performance migliora con il numero di thread fino a un certo punto

BlockingQueue

Interfaccia per code che supportano operazioni bloccanti, fondamentale per pattern producer-consumer.

Implementazioni principali:

  • ArrayBlockingQueue: Capacità fissa, fairness opzionale
  • LinkedBlockingQueue: Capacità opzionalmente limitata
  • PriorityBlockingQueue: Ordinamento per priorità
  • SynchronousQueue: Capacità zero, handoff diretto

Pattern Concorrenti

Producer-Consumer

Disaccoppia produzione e consumo di dati attraverso una queue condivisa.

public class ProducerConsumerExample {
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();

    // Producer thread
    public void produce(String item) throws InterruptedException {
        queue.put(item); // Blocca se queue piena
    }

    // Consumer thread
    public String consume() throws InterruptedException {
        return queue.take(); // Blocca se queue vuota
    }
}

Thread Pool

Riutilizza thread per ridurre overhead di creazione/distruzione e limitare resource consumption.

Vantaggi:

  • Riduzione overhead di thread creation
  • Controllo del resource usage
  • Miglioramento responsiveness

Configurazione chiave:

  • Core pool size
  • Maximum pool size
  • Keep-alive time
  • Work queue type

Fork-Join

Pattern per problemi che possono essere decomposti ricorsivamente in sottoproblemi paralleli.

Algoritmo:

  1. Fork: divide il problema in sottoproblemi
  2. Join: combina i risultati dei sottoproblemi
  3. Work stealing: thread idle “rubano” lavoro da thread occupati

Coordination Mechanisms

CountDownLatch

Permette a uno o più thread di aspettare che un set di operazioni venga completato.

Uso tipico: Aspettare che tutti i worker thread completino prima di procedere.

CyclicBarrier

Punto di sincronizzazione dove thread aspettano che tutti raggiungano la barrier prima di procedere.

Differenza da CountDownLatch: Riutilizzabile, tutti i thread aspettano reciprocamente.

Semaphore

Controlla l’accesso a una risorsa attraverso permits limitati.

Applicazioni:

  • Limitare connessioni concurrent a un database
  • Implementare object pools
  • Rate limiting

Phaser

Generalizzazione più flessibile di CountDownLatch e CyclicBarrier per synchronization a fasi multiple.

Lock Framework

Il package java.util.concurrent.locks fornisce meccanismi di locking più flessibili di synchronized.

ReentrantLock

Lock esplicito con funzionalità avanzate:

  • Interruptibility: Possibilità di interrompere thread in attesa
  • Timeout: Tentativo di acquisizione con timeout
  • Fairness: Garantisce ordinamento FIFO dei thread in attesa
  • Condition variables: Multiple wait sets per thread coordination

ReadWriteLock

Permette accesso concorrente per letture e accesso esclusivo per scritture.

Vantaggi: Migliore performance quando le letture sono molto più frequenti delle scritture.

Considerazioni: Overhead aggiuntivo e possibile starvation degli scrittori.

Async Programming

CompletableFuture

Rappresenta computazioni asincrone con capacità di composition e exception handling.

Caratteristiche principali:

  • Completion callbacks
  • Combination di multiple futures
  • Exception propagation
  • Timeout support
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetchData())
    .thenApply(this::transform)
    .thenCompose(this::enrichAsync)
    .exceptionally(this::handleError);

Reactive Programming

Paradigma basato su stream asincroni di dati con gestione di backpressure.

Principi chiave:

  • Responsiveness: sistema risponde in tempo ragionevole
  • Resilience: sistema rimane responsivo di fronte a failure
  • Elasticity: sistema rimane responsivo sotto carico variabile
  • Message-driven: componenti comunicano tramite messaggi asincroni

Performance Considerations

Contention

Alta contention su shared resources può degradare performance più di esecuzione single-thread.

Strategie di riduzione:

  • Minimize shared state
  • Use thread-local storage
  • Apply lock-free algorithms
  • Reduce critical section size

Context Switching

Overhead del cambio tra thread può essere significativo.

Ottimizzazioni:

  • Thread pool sizing appropriato
  • Minimize blocking operations
  • Use asynchronous I/O
  • Consider fiber/lightweight thread models

Memory Consistency

Overhead delle operazioni di sincronizzazione per garantire memory visibility.

Bilanciamento:

  • Volatile per simple state
  • Atomic operations per counters
  • Synchronized per compound operations
  • Lock-free data structures per high-contention scenarios

Testing Concurrent Code

Challenges

  • Non-determinism: Risultati possono variare tra esecuzioni
  • Timing dependencies: Bug che si manifestano solo sotto specifiche condizioni di timing
  • Heisenbugs: Bug che cambiano comportamento quando osservati

Strategies

Stress Testing: Eseguire con high load e multiple thread per esporre race conditions.

Deterministic Testing: Controllare thread scheduling per test riproducibili.

Property-Based Testing: Verificare invarianti che devono rimanere vere sotto concorrenza.

Static Analysis: Tool per detectare potential deadlock e race conditions.

Best Practices

Immutability First: Preferisci oggetti immutabili quando possibile per eliminare synchronization needs.

Minimize Shared State: Riduci la quantità di stato condiviso tra thread.

Use High-Level Constructs: Preferisci concurrent collections e executor framework a primitive synchronization.

Avoid Premature Optimization: Inizia con design semplice e ottimizza basandosi su measurement.

Document Thread Safety: Chiarisci thread safety guarantees nelle API.

Test Under Load: Performance e correttezza possono differire significativamente sotto high concurrency.

Monitor and Measure: Usa profiling per identificare contention hotspots e bottleneck.

Conclusione

La programmazione concorrente in Java offre strumenti potenti per costruire applicazioni scalabili e responsive, ma richiede comprensione approfondita dei trade-off tra correttezza e performance. La scelta degli strumenti appropriati - da synchronized blocks ad atomic operations, da thread pools a reactive streams - dipende dal carico specifico e dai requisiti dell’applicazione.

L’evoluzione di Java continua ad introdurre nuovi meccanismi come Virtual Threads (Project Loom) che promettono di semplificare ulteriormente la programmazione concorrente, ma i principi fondamentali di thread safety, coordination e performance optimization rimangono essenziali per sviluppatori Java professionali.