Programmation Concurrente en Java : Concepts Fondamentaux et Synchronisation

Un prgoramme est un ensemble statique d'instructions. Un processus représente une instance d'exécution d'un programme, possédant son propre cycle de vie et des ressources allouées par le système. Un thread (fil d'exécution) constitue l'unité de base d'exécution au sein d'un processus.

La distinction clé réside dans le fait que les processus servent d'unités d'allocation de ressources, tandis que les threads agissent comme unités de scheduling et d'exécution. Plusieurs threads peuvent coexister dans un même processus, partageant ainsi leur espace mémoire.

Cycle de Vie d'un Thread

Un thread passe par plusieurs états au cours de son existence : NEW (créé), RUNNABLE (prêt à s'exécuter), BLOCKED (en attente d'un verrou), WAITING (en attente indéfinie), TIMED_WAITING (en attente temporisée), et TERMINATED (terminé).

Méthodes de Création de Threads

Héritage de la classe Thread

public class WorkerThread extends Thread {
    private final String threadId;

    public WorkerThread(String id) {
        this.threadId = id;
    }

    @Override
    public void run() {
        for (int counter = 0; counter < 5; counter++) {
            System.out.println(threadId + " exécute l'itération " + counter);
        }
    }
}

// Utilisation
WorkerThread worker = new WorkerThread("T-001");
worker.start();

Implémentation de Runnable

public class TaskRunner implements Runnable {
    private final String taskId;

    public TaskRunner(String id) {
        this.taskId = id;
    }

    @Override
    public void run() {
        for (int step = 0; step < 5; step++) {
            System.out.println(taskId + " - étape " + step);
        }
    }
}

// Utilisation
TaskRunner runner = new TaskRunner("R-001");
new Thread(runner).start();

Utilisation de Callable<V>

public class AsyncProcessor implements Callable<String> {
    private final String processId;

    public AsyncProcessor(String id) {
        this.processId = id;
    }

    @Override
    public String call() throws Exception {
        for (int iteration = 0; iteration < 5; iteration++) {
            System.out.println(processId + " - traitement " + iteration);
        }
        return "Traitement terminé avec succès";
    }
}

// Utilisation
AsyncProcessor processor = new AsyncProcessor("C-001");
FutureTask<String> futureTask = new FutureTask<>(processor);
new Thread(futureTask).start();

Utilisation d'un Pool de Threads

ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new WorkerThread("Pool-1"));
pool.submit(new TaskRunner("Pool-2"));
pool.submit(new AsyncProcessor("Pool-3"));
pool.shutdown();

Sécurité Concurernte

Le mot-clé synchronized

Java offre le mot-clé synchronized pour assurer un accès exclusif à une section critique. Il peut s'appliquer sur un bloc d'instructions ou sur une méthode entière.

Bloc synchronisé :

synchronized(monitorObject) {
    // section critique
}

Méthode synchronisée :

public synchronized void executeSafely() {
    // section critique - verrou sur 'this'
}

L'interface Lock

Depuis Java 5, l'interface Lock offre un mécanisme plus flexible que synchronized, permettant notamment des tentatives d'acquisition temporisées.

Lock mutex = new ReentrantLock();
mutex.lock();
try {
    // section critique
} catch (Exception ex) {
    // gestion d'erreur
} finally {
    mutex.unlock();
}

Exemple : Accès Concurrent à un Compte Bancaire

public class BankAccount {
    private int availableFunds = 1000;

    public int getAvailableFunds() {
        return availableFunds;
    }

    public void setAvailableFunds(int amount) {
        this.availableFunds = amount;
    }
}

Version avec synchronized :

public class WithdrawalTask implements Runnable {
    private final String clientName;
    private final BankAccount account;

    public WithdrawalTask(BankAccount account, String clientName) {
        this.account = account;
        this.clientName = clientName;
    }

    @Override
    public void run() {
        for (int attempt = 0; attempt < 5; attempt++) {
            synchronized (account) {
                if (account.getAvailableFunds() >= 200) {
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    account.setAvailableFunds(account.getAvailableFunds() - 200);
                    System.out.println(clientName + " a retiré 200€. Solde restant : " + account.getAvailableFunds());
                } else {
                    System.out.println(clientName + " : retrait impossible, solde insuffisant");
                }
            }
        }
    }
}

Version avec Lock :

public class ConcurrentWithdrawal implements Runnable {
    private final String user;
    private final BankAccount targetAccount;
    private static final Lock accountLock = new ReentrantLock();

    public ConcurrentWithdrawal(BankAccount account, String user) {
        this.targetAccount = account;
        this.user = user;
    }

    @Override
    public void run() {
        for (int operation = 0; operation < 5; operation++) {
            accountLock.lock();
            try {
                if (targetAccount.getAvailableFunds() >= 200) {
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    targetAccount.setAvailableFunds(targetAccount.getAvailableFunds() - 200);
                    System.out.println(user + " a retiré 200€. Nouveau solde : " + targetAccount.getAvailableFunds());
                } else {
                    System.out.println(user + " : fonds insuffisants");
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                accountLock.unlock();
            }
        }
    }
}

Test :

public static void main(String[] args) {
    BankAccount sharedAccount = new BankAccount();
    new Thread(new WithdrawalTask(sharedAccount, "Conjoint")).start();
    new Thread(new WithdrawalTask(sharedAccount, "Conjointe")).start();
}

Interblocage (Deadlock)

L'interblocage survient lorsque plusieurs threads se bloquent mutuellement en attendant des ressources détenues par les autres. Quatre conditions doivent être réunies simultanément :

  1. Exclusion mutuelle : une ressource ne peut être utilisée que par un thread à la fois
  2. Détention et attente : un thread détient une ressource tout en demandant une autre
  3. Non-préemption : les ressources ne peuvent être retirées de force
  4. Attente circulaire : une chaîne circulaire de threads en attente existe

Synchronisation avec CountDownLatch

L'utilisation de CountDownLatch permet de synchroniser l'attente de completion de multiples tâches par le thread principal.

public static void main(String[] args) {
    ExecutorService threadPool = Executors.newFixedThreadPool(3);
    int totalTasks = 10;
    CountDownLatch completionGate = new CountDownLatch(totalTasks);

    try {
        System.out.println("[" + Thread.currentThread().getName() + "] Démarrage des traitements parallèles");

        for (int taskNum = 1; taskNum <= totalTasks; taskNum++) {
            threadPool.submit(new ParallelTask(completionGate, String.valueOf(taskNum)));
        }

        completionGate.await(5, TimeUnit.MINUTES);
        System.out.println("[" + Thread.currentThread().getName() + "] Tous les traitements sont achevés");
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        threadPool.shutdown();
    }
}

static class ParallelTask implements Runnable {
    private final CountDownLatch latch;
    private final String taskIdentifier;

    public ParallelTask(CountDownLatch latch, String id) {
        this.latch = latch;
        this.taskIdentifier = id;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(500);
            System.out.println("[" + Thread.currentThread().getName() + "] Tâche #" + taskIdentifier + " terminée");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            latch.countDown();
        }
    }
}

Paramètres de Configuration d'un ThreadPoolExecutor

  • corePoolSize : nombre de threads conservés en permanence, même inactifs (sauf si allowCoreThreadTimeOut est activé)
  • maximumPoolSize : nombre maximal de threads pouvant être créés
  • keepAliveTime : durée de survie des threads excédentaires au-delà de corePoolSize
  • unit : unité de temps pour keepAliveTime
  • workQueue : file d'attente pour les tâches en attente d'exécution
  • threadFactory : fabrique pour la création de threads (nommage, configuration daemon, etc.)
  • handler : stratégie de rejet lorsque la file et le pool sont saturés

Thread.sleep() vs Object.wait()

Thread.sleep() met le thread en pause pour une durée déterminée tout en conservant les verrous détenus. Le thread libère le CPU mais ne relâche pas les moniteurs.

Object.wait() doit être appelé dans un bloc synchronisé. Le thread relâche le verrou sur l'objet et entre en attente jusqu'à recevoir une notification via notify() ou notifyAll(). Après réveil, il doit réacquérir le verrou avant de reprendre son exécution.

La différence fondamentale : Object.wait() libère à la fois le CPU et le verrou, tandis que Thread.sleep() ne libère que le CPU.

Étiquettes: Java Concurrency Multi-threading synchronized Lock

Publié le 28 juin à 18h27