Differenza tra thread e thread virtuali in Java

1. Introduzione

In questo tutorial, mostreremo la differenza tra i thread tradizionali in Java e i thread virtuali introdotti in Project Loom.

Successivamente, condivideremo diversi casi d'uso per i thread virtuali e le API introdotte dal progetto.

Prima di iniziare, dobbiamo notare che questo progetto è in fase di sviluppo attivo. Eseguiremo i nostri esempi sulla VM del telaio ad accesso anticipato: openjdk-15-loom + 4-55_windows-x64_bin.

Le versioni più recenti delle build sono libere di modificare e interrompere le API correnti. Detto questo, c'era già un importante cambiamento nell'API, poiché la classe java.lang.Fiber precedentemente utilizzata è stata rimossa e sostituita con la nuova classe java.lang.VirtualThread .

2. Panoramica di alto livello di thread e thread virtuali

Ad alto livello, un thread è gestito e pianificato dal sistema operativo, mentre un thread virtuale è gestito e pianificato da una macchina virtuale . Ora, per creare un nuovo thread del kernel, dobbiamo fare una chiamata di sistema, e questa è un'operazione costosa .

Ecco perché stiamo utilizzando pool di thread invece di riallocare e deallocare i thread secondo necessità. Successivamente, se desideriamo ridimensionare la nostra applicazione aggiungendo più thread, a causa del cambio di contesto e del loro footprint di memoria, il costo di mantenimento di quei thread potrebbe essere significativo e influire sul tempo di elaborazione.

Quindi, di solito, non vogliamo bloccare quei thread e questo si traduce in utilizzo di API di I / O non bloccanti e API asincrone, che potrebbero ingombrare il nostro codice.

Al contrario, i thread virtuali sono gestiti dalla JVM . Pertanto, la loro allocazione non richiede una chiamata di sistema e sono privi del cambio di contesto del sistema operativo . Inoltre, i thread virtuali vengono eseguiti sul thread portante, che è il thread del kernel effettivo utilizzato sotto il cofano. Di conseguenza, poiché siamo liberi dal cambio di contesto del sistema, potremmo generare molti più thread virtuali di questo tipo.

Successivamente, una proprietà chiave dei thread virtuali è che non bloccano il nostro thread portante. Con ciò, il blocco di un thread virtuale sta diventando un'operazione molto più economica, poiché la JVM pianificherà un altro thread virtuale, lasciando il thread del vettore sbloccato.

In definitiva, non avremmo bisogno di raggiungere le API NIO o Async. Ciò dovrebbe risultare in un codice più leggibile che è più facile da capire e da eseguire il debug. Tuttavia, la continuazione può potenzialmente bloccare un thread portante , in particolare quando un thread chiama un metodo nativo ed esegue operazioni di blocco da lì.

3. Nuova API Thread Builder

In Loom, abbiamo ottenuto la nuova API builder nella classe Thread , insieme a diversi metodi di fabbrica. Vediamo come possiamo creare factory standard e virtuali e utilizzarle per l'esecuzione dei nostri thread:

Runnable printThread = () -> System.out.println(Thread.currentThread()); ThreadFactory virtualThreadFactory = Thread.builder().virtual().factory(); ThreadFactory kernelThreadFactory = Thread.builder().factory(); Thread virtualThread = virtualThreadFactory.newThread(printThread); Thread kernelThread = kernelThreadFactory.newThread(printThread); virtualThread.start(); kernelThread.start();

Ecco l'output dell'esecuzione precedente:

Thread[Thread-0,5,main] VirtualThread[,ForkJoinPool-1-worker-3,CarrierThreads]

Qui, la prima voce è l' output toString standard del thread del kernel.

Ora, vediamo nell'output che il thread virtuale non ha nome ed è in esecuzione su un thread di lavoro del pool Fork-Join dal gruppo di thread CarrierThreads .

Come possiamo vedere, indipendentemente dall'implementazione sottostante, l'API è la stessa e ciò implica che potremmo facilmente eseguire il codice esistente sui thread virtuali .

Inoltre, non è necessario apprendere una nuova API per utilizzarli.

4. Composizione del filo virtuale

È una continuazione e uno scheduler che, insieme, costituiscono un thread virtuale. Ora, il nostro pianificatore in modalità utente può essere un'implementazione dell'interfaccia Executor . L'esempio precedente ci ha mostrato che, per impostazione predefinita, si esegue sul ForkJoinPool .

Ora, analogamente a un thread del kernel - che può essere eseguito sulla CPU, quindi parcheggiato, riprogrammato e quindi riprende la sua esecuzione - una continuazione è un'unità di esecuzione che può essere avviata, quindi parcheggiata (ceduta), riprogrammata e ripresa la sua esecuzione nello stesso modo da dove era stata interrotta ed essere ancora gestita da una JVM invece di fare affidamento su un sistema operativo.

Tieni presente che la continuazione è un'API di basso livello e che i programmatori dovrebbero utilizzare API di livello superiore come l'API del generatore per eseguire thread virtuali.

Tuttavia, per mostrare come funziona sotto il cofano, ora eseguiremo la nostra continuazione sperimentale:

var scope = new ContinuationScope("C1"); var c = new Continuation(scope, () -> { System.out.println("Start C1"); Continuation.yield(scope); System.out.println("End C1"); }); while (!c.isDone()) { System.out.println("Start run()"); c.run(); System.out.println("End run()"); }

Ecco l'output dell'esecuzione precedente:

Start run() Start C1 End run() Start run() End C1 End run()

In questo esempio, abbiamo eseguito la nostra continuazione e, a un certo punto, abbiamo deciso di interrompere l'elaborazione. Quindi, una volta rieseguito, la nostra continuazione è continuata dal punto in cui era stata interrotta. Dall'output, vediamo che il metodo run () è stato chiamato due volte, ma la continuazione è stata avviata una volta e poi ha continuato la sua esecuzione alla seconda esecuzione dal punto in cui era stata interrotta.

Questo è il modo in cui le operazioni di blocco devono essere elaborate dalla JVM. Una volta eseguita un'operazione di blocco, la continuazione cederà, lasciando il thread di supporto sbloccato.

Quindi, quello che è successo è che il nostro thread principale ha creato un nuovo stack frame nel suo stack di chiamate per il metodo run () e ha proceduto con l'esecuzione. Quindi, dopo che la continuazione ha ceduto, la JVM ha salvato lo stato corrente della sua esecuzione.

Successivamente, il thread principale ha continuato la sua esecuzione come se il metodo run () fosse tornato e avesse continuato con il ciclo while . Dopo la seconda chiamata al metodo di esecuzione della continuazione , la JVM ha ripristinato lo stato del thread principale fino al punto in cui la continuazione ha ceduto e ha terminato l'esecuzione.

5. conclusione

In questo articolo, abbiamo discusso la differenza tra il thread del kernel e il thread virtuale. Successivamente, abbiamo mostrato come è possibile utilizzare una nuova API di creazione di thread da Project Loom per eseguire i thread virtuali.

Infine, abbiamo mostrato cos'è una continuazione e come funziona sotto il cofano. Possiamo esplorare ulteriormente lo stato di Project Loom ispezionando la VM ad accesso anticipato. In alternativa, possiamo esplorare altre API di concorrenza Java già standardizzate.