Vai al contenuto principale

Cerca su CooLWJ

选择搜索范围,输入关键词后按回车。

Lingua

La tecnologia da una prospettiva globale: storia dell'evoluzione del multithreading in Java

Prefazione

Il 22/09/2022 è stato rilasciato JDK19. La novità principale di questa versione è il supporto ai thread virtuali, che aggiunge un nuovo protagonista alla famiglia dei thread leggeri. I thread virtuali liberano la JVM dal vincolo della pianificazione dei thread tramite il sistema operativo, consentendo alla JVM stessa di pianificarli. In realtà, agli inizi Sun aveva già implementato la pianificazione dei thread da parte della JVM nella macchina virtuale per Solaris; per ragioni di complessità e manutenibilità, si tornò infine al modello in cui i thread vengono pianificati dal sistema operativo.

长安归来锦衣客,昨日城南起新宅。 — Tornato da Chang'an in vesti sontuose, ieri ha eretto una nuova dimora a sud della città. Ripensando al cammino percorso, i concetti del multithreading abbagliano come fuochi d'artificio e le spiegazioni disponibili online sono innumerevoli, eppure sembra sempre mancare una prospettiva d'insieme. Per questo ho ricostruito sistematicamente la storia dell'evoluzione del multithreading in Java, nella speranza che vi aiuti a padroneggiare l'argomento.

Argomenti non trattati in questo articolo:

1 Non vengono illustrati in dettaglio i principi di implementazione di alcuni aspetti tecnici, non viene analizzato il codice sorgente e non vengono disegnati diagrammi; se in questo articolo trovi concetti e tecnologie che ti interessano, puoi cercarli autonomamente 2 Non vengono trattati librerie e framework che supportano la concorrenza, come Quasar, Akka, Guava e altri

Di cosa parla questo articolo

1 Illustrare la storia dell'evoluzione del multithreading nel JDK 2 Illustrare le funzioni, i principi e il contesto di alcuni aspetti tecnici di questa evoluzione, nonché i problemi che hanno risolto 3 Presentare le opinioni dell'autore su alcuni aspetti tecnici; chi ha opinioni diverse è invitato a discuterne nei commenti

Traguardi

Come al solito, iniziamo con una tabella riepilogativa che raccoglie i concetti fondamentali relativi ai thread nelle varie versioni del JDK. Ricorrendo a un paragone forse non del tutto appropriato, possiamo associare l'evoluzione del multithreading a quella dell'automobile: l'era del cambio manuale (JDK1.4 e precedenti), l'era del cambio automatico (JDK5-JDK18) e l'era della guida autonoma (JDK19 e successivi). Questo paragone serve soltanto a spiegare al lettore che, a partire da JDK5, si può gestire il multithreading in modo più agevole; da JDK19 si va ben oltre la semplice comodità, con un salto qualitativo nelle prestazioni dei servizi ad alta intensità di I/O.

EpocaVersioneData di pubblicazioneConcetti fondamentali
Cambio manualeJDK1.01996-01-23Thread e Runnable
Cambio manualeJDK1.21998-12-04ThreadLocal、Collections
Modalità automaticaJDK1.5/5.02004-09-30Definizione precisa del modello di memoria Java, introduzione del pacchetto per la concorrenza
Modalità automaticaJDK1.6/6.02006-12-11Ottimizzazione di synchronized
Modalità automaticaJDK1.7/7.02011-07-28Framework Fork/Join
Modalità automaticaJDK1.8/8.02014-03-18CompletableFuture、Stream
Modalità automaticaJDK1.9/9.02014-09-08Migliorare il meccanismo di contesa dei lock
Modalità automaticaJDK102018-03-21Controllo locale al thread
Modalità automaticaJDK152020-09-15Disabilitare e deprecare il biased locking
Guida autonomaJDK192022-09-22Thread virtuali

2f98c71c0436418d8d260d75cb921c81~tplv-73owjymdk6-jj-mark-v1_0_0_0_0_5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Lqs5Lic5LqR5byA5Y-R6ICF_q75.webp

L'era del cambio manuale

L'autore definisce l'epoca di JDK1.4 e delle versioni precedenti come l'era del multithreading «a cambio manuale», chiamata anche era del multithreading nativo. Le operazioni sui thread erano ancora relativamente primitive e non erano disponibili pool di thread. Gli sviluppatori dovevano scrivere da sé strumenti per evitare lo spreco di risorse dovuto alla frequente creazione di thread e applicare manualmente i lock alle risorse condivise. Proprio in quell'epoca presero forma molti ottimi framework per il multithreading, e il più famoso fu adottato da JDK5.0.

JDK 1.0 Thread e Runnable

La versione JDK1.0, del mese 1 dell'anno 1996, definì fin dall'inizio il modello di threading fondamentale di Java. Questo modello non subì modifiche sostanziali nei successivi aggiustamenti: si può dire che fosse una buona progettazione, capace di durare nel tempo. La schedulazione con prelazione e quella cooperativa sono due modalità comuni di schedulazione dei processi/thread. Per un sistema operativo, la prelazione è particolarmente adatta a schedulare i processi: assegna intervalli di tempo ai diversi processi e può sottrarre risorse a quelli che non rispondono per molto tempo, arrivando persino ad arrestarli forzatamente. La modalità cooperativa richiede invece che i processi rilascino le risorse spontaneamente e di propria iniziativa; con questo tipo di schedulazione, un thread dall'esecuzione molto lunga potrebbe far «morire di fame» tutti gli altri thread che richiedono la CPU. La macchina virtuale Java HotSpot utilizza una schedulazione con prelazione, perciò Java ha adottato fin dall'inizio questa modalità per i thread. In JDK 1.0, i modi principali per creare un thread consistevano nell'estendere la classe Thread o implementare l'interfaccia Runnable. Il thread veniva avviato tramite il metodo start dell'istanza dell'oggetto, il codice da eseguire in parallelo veniva inserito nel metodo run e la cooperazione e la comunicazione tra thread si affidavano a metodi semplici e brutali come stop/resume/suspend.

Schema degli stati dei thread Java e delle relative transizioni

Come spiegare i concetti di stop/resume/suspend? Il thread principale può chiamare direttamente i metodi per terminare, riprendere e sospendere i thread secondari. Se da piccoli avete usato un lettore portatile, ricorderete i tre pulsanti: arresto, pausa e ripresa. Immaginate di ascoltare contemporaneamente 3 lettori: i tre lettori sono tre thread secondari e voi siete il thread principale, libero di controllare l'avvio e l'arresto dei tre dispositivi.

Questo meccanismo presenta un problema fatale: può facilmente causare un deadlock. Il motivo è che, quando il thread A ha bloccato una risorsa e non l'ha ancora rilasciata, viene sospeso dal thread principale (il metodo suspend non rilascia i lock). A quel punto, se il thread B vuole acquisire quella risorsa, può soltanto attendere che il thread A riprenda l'esecuzione (resume) e la rilasci; altrimenti non potrà mai ottenerla, causando un deadlock.

JDK 1.2

In questa versione è stato vietato l’uso dei meccanismi bruschi stop/resume/suspend, passando a modalità come wait/notify/sleep, che permettono a più thread di coordinarsi. Va segnalato che in questa versione erano già stati progettati gli oggetti atomici AtomicityXXX, principalmente per risolvere il problema della non atomicità di i++. L’introduzione di ThreadLocal e Collections ha ampliato le modalità di utilizzo del multithreading: per queste due tecnologie, chiamo questa fase l’era del turbo di Java.

ThreadLocal

ThreadLocal è una soluzione che consente a più thread di condividere oggetti non sicuri per i thread senza usare lock. Non può risolvere problemi come “aumentare o diminuire il saldo di un conto bancario o le scorte di magazzino”; è invece adatto a eseguire in sicurezza i metodi di classi con funzione di “strumento”, attraverso copie separate. Esempi tipici sono SimpleDateFormat e le connessioni al database. Il suo progetto è molto ingegnoso: immaginate di doverlo progettare voi; un’idea semplice e comune sarebbe mantenere in ThreadLocal una Map globale sicura per i thread, con il thread come chiave e l’oggetto condiviso come valore. Questo approccio presenta però un inconveniente: le perdite di memoria, perché la Map crescerebbe senza limiti con l’aggiunta di sempre più thread. Per risolvere la perdita di memoria, bisognerebbe ripulire la Map al termine di ogni thread, rendendo necessario potenziare il GC; evidentemente il rapporto tra costi e benefici non sarebbe conveniente. Perciò ThreadLocal è stato progettato in modo che la Map sia posseduta dal Thread stesso, anziché da ThreadLocal. La chiave è la variabile ThreadLocal e il valore è il valore associato. Ogni Thread vi inserisce tutti i ThreadLoacl che utilizza (naturalmente questo progetto comporta anche altri problemi derivati, che qui non vengono trattati; chi è interessato può cercare autonomamente).

Collections

In questa versione fu progettata la classe di utilità Collections, che forniva wrapper per collezioni sicure per i thread come SynchronizedList. In un'epoca in cui le collezioni sicure per i thread si limitavano a Hashtable, Vector, Stack e poche altre, la sua comparsa segnò un passaggio significativo. L'idea di fondo di Collections è: mi occupo io di avvolgere una collezione non sicura per i thread per renderla sicura, così non dovrai dedicare molto tempo ad aggiornare e modificare il codice esistente; ti basterà inizializzare la collezione, al momento della creazione, con uno dei metodi che ti fornisco. È un po' come la tecnologia del turbocompressore nelle automobili, che aumenta potenza e coppia del motore senza modificarne la cilindrata. È arrivata l'era del turbo per Java ^\_^

L'era del cambio automatico

JDK 5.0

Importare il pacchetto per la concorrenza

Doug Lea, il cui nome in cinese è 道格·利, è un docente universitario statunitense, una figura di eccezionale autorevolezza: J.U.C è opera sua. Prima di JDK1.5, per sincronizzare l'accesso concorrente al codice potevamo utilizzare soltanto synchronized, le cui prestazioni all'epoca non erano ancora state ottimizzate e risultavano scarse; per controllare i thread potevamo usare solo i metodi wait e notify di Object. Fu allora che Doug Lea presentò al JCP la proposta JSR-166. Prima di presentare JSR-166, Doug Lea utilizzava già da oltre tre anni codice con funzionalità simili a quelle del pacchetto J.U.C: quel codice ne costituiva il prototipo.

J.U.C offre diverse grandi categorie di strumenti, tra cui oggetti atomici, lock e strumenti di supporto, pool di thread e contenitori thread-safe. Gli sviluppatori possono utilizzare con flessibilità qualsiasi funzionalità per costruire i propri prodotti: possono spingersi fino a creare framework di base con ReentrantLock, oppure utilizzare direttamente strumenti o contenitori già pronti per scrivere il codice applicativo. In prospettiva storica, nel 2004 J.U.C poteva essere definito senza alcun dubbio un “prodotto tecnologico all'avanguardia”. Ha dato un contributo enorme alla diffusione di Java. Era arrivata l'era del cambio automatico di Java: proprio come le auto con cambio automatico rendono più accessibile la guida, J.U.C ha reso molto più accessibile ai programmatori l'uso del multithreading. È un prodotto che ha inaugurato un'epoca.

Naturalmente anche J.U.C presenta alcuni difetti:

Elevato carico sulla CPU:se il ciclo di attesa attiva CAS continua a fallire a lungo, comporta un carico molto elevato per la CPU.

Soluzione: in alcuni punti di JUC il numero di iterazioni di spin CAS è limitato, per esempio in SynchronousQueue, una BlockingQueue.

Il problema ABA:se un valore inizialmente è A, diventa B e poi torna ad A, il controllo CAS rileverà che non è cambiato, benché in realtà sia cambiato: questo è il problema ABA. Nella maggior parte dei casi, il problema ABA non compromette la correttezza del programma in condizioni di concorrenza.

Soluzione: aggiungere un numero di versione a ogni variabile e incrementarlo di 1 a ogni modifica; così A —> B —> A diventa 1A —> 2B —> 3A. Java fornisce AtomicStampedReference per risolvere il problema. AtomicStampedReference contrassegna la versione degli oggetti con un timbro (stamp) incapsulando una tupla [E,Integer], evitando così il problema ABA.

Può garantire soltanto l'atomicità delle operazioni su una singola variabile condivisa:il meccanismo CAS garantisce soltanto l’atomicità dell’operazione su una singola variabile, ma non quella dell’intero blocco di codice.

Soluzione: per esempio, se occorre garantire l'aggiornamento atomico congiunto di 3 variabili, è necessario usare Synchronized. Si può anche valutare l'uso di AtomicReference per racchiudere più variabili, gestendo così il caso di più variabili condivise.

Chiarire il modello di memoria Java

Questa versione del JDK ha ridefinito il modello di memoria di Java. Prima di allora, i modelli di memoria più comuni comprendevano il modello a consistenza sequenziale e il modello happens-before. Nel modello a consistenza sequenziale, l'ordine di esecuzione del programma coincide esattamente con quello indicato nel codice. Per le moderne CPU multicore, che ottimizzano l'esecuzione delle istruzioni, è molto difficile garantirlo. Inoltre, garantire la consistenza sequenziale limita fortemente le ottimizzazioni del codice che la JVM può effettuare durante l'esecuzione.

Tuttavia, la relazione di precedenza (Happens-before) definita dalla specifica JSR 133 in questa versione rende flessibile l'ordine di esecuzione delle istruzioni:

All'interno dello stesso thread, secondo l'ordine di esecuzione del codice (ossia l'ordine della sua semantica), un'operazione precedente avviene prima di una successiva Lo sblocco di un oggetto monitor avviene prima di un successivo blocco dello stesso oggetto monitor La scrittura di un campo volatile avviene prima di una successiva lettura di quel campo L'avvio di un thread (la chiamata al metodo start() dell'oggetto thread) avviene prima di qualsiasi altra operazione di quel thread Tutte le operazioni di un thread avvengono prima che un qualsiasi altro thread completi una chiamata al metodo join() su quel thread Se l'operazione A avviene prima di B e B avviene prima di C, allora A avviene prima di C

Quanto all’allocazione della memoria, separare dalla memoria principale la memoria di lavoro di ciascun thread offre alla JVM un ampio margine per ottimizzare l’esecuzione delle istruzioni all’interno dei thread. Le variabili nella memoria principale possono essere copiate nella memoria di lavoro del thread ed elaborate separatamente; al termine dell’esecuzione, i risultati possono essere riscritti nella memoria principale in un momento successivo: Ma come garantire la coerenza dei dati tra i diversi thread? La soluzione fornita dalla JLS (Java Language Specification) è che, per impostazione predefinita, non si può garantire la coerenza dei dati in qualsiasi istante, ma è possibile ottenerla usando le parole chiave synchronized, volatile e final, la cui semantica è stata rafforzata.

Ottimizzazione di synchronized in JDK 6.0

La parola chiave synchronized, da “figlio primogenito della Repubblica”, nella versione 5.0 si era vista rubare la scena da ReentrantLock. In questa versione doveva assolutamente rifarsi, così JDK 6.0 ha introdotto alcune ottimizzazioni dei lock, tra cui lo spinning, l’eliminazione dei lock, l’accorpamento dei lock, i lock leggeri e i lock con preferenza. Questa ottimizzazione è un’interpretazione del concetto di “gestione di precisione”. Prima dell’ottimizzazione, un oggetto sincronizzato con synchronized poteva trovarsi solo in due stati: senza lock oppure con lock (lock pesante). Dopo l’ottimizzazione, i lock presentano complessivamente 4 stati, in ordine crescente: senza lock, lock con preferenza, lock leggero e lock pesante. Questi stati aumentano progressivamente di livello in base alla contesa, ma non possono retrocedere, allo scopo di rendere più efficienti l’acquisizione e il rilascio dei lock (secondo l’autore, in realtà la cosa è troppo complessa e gli sviluppatori della JVM si sono lasciati scoraggiare).

Questa ottimizzazione ha permesso a synchronized di rialzare la testa: da allora non avrebbe più tollerato che qualcuno dicesse che le sue prestazioni erano inferiori a quelle di ReentrantLock. Ma il bello doveva ancora venire: il biased locking è stato deprecato in JDK 15 (─.─||). Secondo l'autore, synchronized è svantaggiato dal fatto di essere soltanto una parola chiave: la JVM ne gestisce le operazioni sottostanti e deve «indovinare» quale modalità di acquisizione dei lock sia più adatta all'applicazione. Con ReentrantLock è diverso: la questione viene affidata direttamente al programmatore. Se desideri equità, usi un lock equo; se preferisci un comportamento non equo, usi un lock non equo. Dal punto di vista progettuale è una sorta di pigrizia, ma anche una forma di flessibilità.

Framework Fork/Join di JDK 7.0

Anche Fork/Join è nato come una soluzione piuttosto avanzata: il suo principale punto di forza consiste nel supportare la scomposizione ricorsiva delle attività e la successiva combinazione dei relativi risultati. È però una tecnologia al tempo stesso familiare e poco conosciuta: familiare perché viene impiegata in molti contesti, come CompletableFuture e Stream di JDK8.0, di cui parleremo più avanti; poco conosciuta perché sembra che la utilizziamo raramente nello sviluppo di applicazioni aziendali.

C'è persino chi lo considera poco utile. A mio parere, se sviluppate funzionalità legate alle esigenze aziendali, è poco utile perché praticamente non serve: per grandi volumi di dati esistono gli strumenti dei data warehouse, mentre negli altri casi si possono usare i pool di thread; se invece sviluppate middleware o framework, può essere utile e potreste averne bisogno. Nel web in lingua cinese sono pochi a mettere in discussione questa tecnologia, ma all'estero se ne sta già discutendo; chi è interessato può seguire direttamente il collegamento per approfondire Il framework Fork-Join di Java è difettoso?

JDK 8.0

Il rilascio di questa versione ha segnato un’epoca per Java, tanto che oggi rappresenta oltre la metà dei programmi Java in esecuzione nel mondo. Gli aggiornamenti relativi al multithreading, però, non sono stati rivoluzionari quanto quelli di JDK5.0. Oltre all’aggiunta di alcuni oggetti atomici, le novità più rilevanti di questa versione sono le due seguenti.

CompletableFuture

Online si trovano molte presentazioni di CompletableFuture, perlopiù dedicate ai suoi principi e al suo utilizzo. Ma c'è una domanda che non riuscivo a chiarire: perché è nato CompletableFuture, quando esistevano già così tanti strumenti per i pool di thread? Quale problema risolve? Qual è davvero il suo principale punto di forza? Credo che, se ci avete riflettuto, vi siate posti anche voi questa domanda. Nessun problema: ho già trovato la risposta per voi.

Conclusione: il principale punto di forza di CompletableFuture è Orchestrazione delle attività。CompletableFuture eredita le caratteristiche dell’interfaccia Future e offre capacità come l’esecuzione concorrente delle attività, tutte sostituibili con altre soluzioni. La sua capacità di orchestrare le attività, invece, è insostituibile: le sue API principali, tra cui quelle per costruire catene di attività e unire i risultati, sono progettate proprio per l’orchestrazione. Il motivo principale per cui JDK ha introdotto questo framework in tale versione è quindi rispondere all’esigenza di orchestrare le attività, sempre più pressante nello sviluppo di applicazioni aziendali.

Un'ultima osservazione: l'implementazione interna di CompletableFuture utilizza il framework Fork/Join.

Stream

In «Clean Architecture» vengono citati tre paradigmi di programmazione: programmazione strutturata (procedurale), programmazione orientata agli oggetti e programmazione funzionale. Stream è una manifestazione della programmazione funzionale nel linguaggio Java. A mio parere, padroneggiare Stream è un passaggio obbligato per chi vuole passare da programmatore principiante a intermedio. Il primo incontro con Stream sarà sicuramente disorientante, ma una volta acquisita familiarità si aprirà un nuovo modo di concepire la programmazione. Noi sviluppatori confondiamo spesso tre concetti, programmazione funzionale, Stream ed espressioni Lambda, pensando sempre che indichino la stessa cosa. Ecco come li interpreto:

•La programmazione funzionale è un paradigma di programmazione che trova applicazione in vari linguaggi

•Stream è una nuova funzionalità di JDK8.0, che si può anche considerare un concetto introdotto per aderire all'idea della programmazione funzionale;tramite Stream è possibile applicare la programmazione funzionale alle classi delle collezioni

•Un’espressione lambda(lambda expression)è una funzione anonima che consente di esprimere la programmazione funzionale in modo più conciso ed efficiente

Dopo tutto questo discorso, che rapporto c’è tra Stream e il multithreading? I metodi paralleli di Stream sono implementati tramite il framework Fork/Join. In 《Effective Java》 compare il consiglio «Usare con cautela gli Stream paralleli», perché tutte le operazioni parallele vengono eseguite in un pool Fork/Join comune: il funzionamento anomalo di una pipeline può compromettere le prestazioni di altre parti non correlate.

JDK 9.0

Migliorare il meccanismo di contesa dei lock

La contesa sui lock limita le prestazioni di molte applicazioni Java multithread. Il nuovo meccanismo di contesa sui lock migliora le prestazioni dei monitor degli oggetti Java ed è stato convalidato da diversi benchmark (come Volano), che consentono di stimare il throughput massimo della JVM. Nella pratica, il nuovo meccanismo ha ottenuto risultati eccellenti in 22 benchmark diversi. Se venisse adottato in Java 9, le prestazioni delle applicazioni migliorerebbero notevolmente. In termini semplici, quando più thread si contendono un lock, prima dell'ottimizzazione tutti i thread arrivati dopo seguivano la stessa procedura standard di attesa. Dopo l'ottimizzazione, quando la JVM riconosce alcuni scenari ottimizzabili, permette direttamente ai thread arrivati dopo di contendersi il lock tramite una sorta di “corsia VIP”.

Per una spiegazione dettagliata, consulta: Spiegazione dei lock contesi – un approccio orientato alle prestazioni

Flussi reattivi

Reactive Streams è uno standard per elaborare flussi di dati asincroni mediante contropressione non bloccante; fornisce un insieme minimo di interfacce, metodi e protocolli per descrivere le operazioni e le entità necessarie.

Che cosa significa contropressione non bloccante? 背压 è la forma abbreviata di back pressure: in parole semplici, il produttore invia dati al consumatore e, quando quest'ultimo non riesce più a elaborarli, avvisa il produttore, che a quel punto riduce il ritmo di produzione. Il modo più semplice di implementare questo meccanismo è quello bloccante, restituendo direttamente i dati sulla pressione al momento dell'invio. L'implementazione non bloccante aumenta la complessità progettuale e, al tempo stesso, migliora le prestazioni. PS: ho l'impressione che la traduzione 背压 non sia felice: il significato non si può ricavare dai soli caratteri. Non sarebbe meglio 反压, «pressione inversa»?^\_^

Per evitare che il consumatore possa andare in crash a causa dell’enorme pressione sulle risorse (pressure), occorre controllare la velocità del flusso di dati, ossia applicare il controllo di flusso (flow control), così da impedire che un flusso troppo rapido sovraccarichi la destinazione. Serve quindi una contropressione, detta backpressure (back pressure): produttore e consumatore devono interoperare mediante un apposito meccanismo. Questo meccanismo deve essere asincrono e non bloccante; se fosse sincrono e bloccante, il produttore dovrebbe attendere mentre il consumatore elabora i dati, causando problemi di prestazioni.

Reactive Streams definisce uno standard per implementare la contropressione non bloccante attraverso un insieme di entità, interfacce e metodi d'interoperabilità. Le terze parti seguono questo standard per realizzare soluzioni concrete; tra quelle più diffuse figurano Reactor, RxJava, Akka Streams e Ratpack.

JDK 10 Controllo locale ai thread

Safepoint e relative limitazioni:

Safepoint è un meccanismo della JVM HotSpot che consente di arrestare tutte le applicazioni. Per svolgere alcune operazioni di basso livello, la JVM deve ricorrere allo Stop The World, fermando tutti i thread applicativi. Non può però arrestarli bruscamente: invia ai thread applicativi un segnale di comando che dice loro di fermarsi. Quando un thread applicativo raggiunge un Safepoint durante l'esecuzione, obbedisce al comando e risponde. Ecco perché si chiama Safepoint. Il termine safe sottolinea che la JVM deve svolgere alcune operazioni globali in sicurezza, da cui l'aggiunta di safe al nome di questo punto.

Le operazioni che richiedono la sicurezza globale comprendono: 1、pause della garbage collection 2、deottimizzazione del codice(Code deoptimization). 3、svuotamento della cache del codice. 4、ridefinizione delle classi(Class redefinition, per esempio aggiornamenti a caldo o instrumentation). 5、revoca dei lock con bias(Biased lock revocation). 6、varie operazioni di debug(per esempio: rilevamento dei deadlock o dump delle tracce dello stack).

Tuttavia, portare tutti i thread a fermarsi al safepoint più vicino richiede già di per sé parecchio tempo. E fermare tutti i thread non sembra forse una misura troppo brusca e arbitraria? Per questo Java 10 ha introdotto un meccanismo che consente di evitare di arrestarli tutti: il controllo locale dei thread (Thread Local Handshake).

Ecco, per esempio, alcuni scenari gestibili senza arrestare tutti i thread: 1、Revoca dei lock con preferenza per un thread (biased locking). Per revocare questi lock basta arrestare un singolo thread, senza fermarli tutti. 2、Riduzione dell'impatto complessivo sulla latenza della VM delle diverse interrogazioni diagnostiche; per esempio, ottenere le tracce dello stack di tutti i thread in una VM con molti thread Java può essere un'operazione lenta. 3、Campionamento più sicuro delle tracce dello stack riducendo la dipendenza dai segnali (signals). 4、Eliminazione di alcune barriere di memoria tramite handshake con i thread Java, usando la cosiddetta tecnica di sincronizzazione asimmetrica di Dekker. Per esempio, il “codice di marcatura condizionale delle card” (conditional card mark code) usato in G1 e CMS non avrà più bisogno delle “barriere di memoria”. In questo modo sarà possibile ottimizzare la “barriera di scrittura (write barrier)” emessa da G1 e rimuovere anche i rami che cercano di evitare le “barriere di memoria”.

JDK 15 disabilita e depreca il biased locking

Perché deprecare il biased locking? I miglioramenti delle prestazioni che il biased locking offriva in passato oggi non sono più così evidenti. Le applicazioni che ne traggono vantaggio sono spesso programmi che utilizzano le prime API delle collezioni Java(JDK 1.1), nelle quali Hashtable e Vector eseguono la sincronizzazione a ogni accesso. JDK 1.2 ha introdotto collezioni non sincronizzate per scenari a thread singolo(HashMap e ArrayList), mentre JDK 1.5 ha introdotto strutture dati concorrenti più performanti per scenari multithread. Ciò significa che le applicazioni che traggono vantaggio dal biased locking a causa di sincronizzazioni superflue potrebbero ottenere grandi miglioramenti delle prestazioni aggiornando il codice per utilizzare classi più recenti. Inoltre, le applicazioni costruite attorno a code di pool di thread e thread di lavoro tendono a offrire prestazioni migliori quando il biased locking è disabilitato.

Di seguito, un'implementazione tramite API che utilizzano Hashtable e Vector: java.lang.Classloader usa Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL utilizza Hashtable java.net.URConnection utilizza Hashtable java.util.ZipOutputStream usa Vector javax.management.timer.TimerMBean ha Vector nell'interfaccia

L'era della guida autonoma

I thread virtuali hanno portato Java nell'era della guida autonoma. Molti linguaggi dispongono di tecnologie simili ai “thread virtuali”, tra cui Go, C#, Erlang e Lua, chiamate “coroutine”. Questa volta Java non ha aggiunto alcuna parola chiave, né introdotto nuovi concetti: rispetto alle goroutine e alle coroutine, i thread virtuali sono molto più facili da capire; già dal nome si intuisce più o meno cosa facciano.

Thread virtuali di JDK 19

Nel modello tradizionale di Java, i thread corrispondono a quelli del sistema operativo secondo un rapporto 1:1; crearli e cambiarne l'esecuzione ha un costo elevato e i limiti del sistema operativo consentono di crearne soltanto un numero limitato. Quando la concorrenza è molto elevata, non è possibile creare un thread per ogni richiesta. Un pool di thread può attenuare il problema riducendo il costo della creazione dei thread, ma non ne aumenta il numero disponibile. Se le richieste concorrenti sono 2000 e il pool contiene soltanto 1000 thread, in un dato momento si possono elaborare solo 1000 richieste; altre 1000 non possono essere elaborate e possono essere rifiutate oppure messe in attesa finché un thread non si libera.

Prima dei thread virtuali, la soluzione adottata era lo stile asincrono. Molti framework avevano già implementato la programmazione concorrente asincrona (come Reactor di Spring5), ottenendo una maggiore disponibilità attraverso la condivisione dei thread. Il principio consiste nel ridurre i cambi di thread mediante la loro condivisione, diminuendo il consumo di risorse ed evitando al contempo il blocco: i thread vengono usati solo durante l'esecuzione del programma, mentre non vengono occupati quando il programma deve attendere. Lo stile asincrono apporta effettivamente diversi miglioramenti, ma presenta anche degli svantaggi. La maggior parte dei framework asincroni usa una sintassi concatenata, suddividendo il programma in molti passaggi, ciascuno dei quali potrebbe essere eseguito su un thread diverso. Non è più possibile usare le consuete API di programmazione concorrente, come ThreadLocal, altrimenti potrebbero verificarsi errori. Anche lo stile di programmazione cambia notevolmente: è molto più complesso di quello tradizionale, richiede un apprendimento impegnativo e potrebbe rendere necessario modificare molti moduli esistenti del progetto per adattarli al modello asincrono.

Il principio di implementazione dei thread virtuali è simile a quello di alcuni framework asincroni: anche qui si condividono i thread, quindi naturalmente non serve un pool. Quando li usate, potete considerare i thread virtuali come una risorsa illimitata: potete crearne quanti volete senza preoccuparvi di problemi. Inoltre, i thread virtuali supportano il debug e sono supportati dagli strumenti di monitoraggio per Java, un aspetto molto importante. I thread virtuali riducono notevolmente l'occupazione di memoria del programma; tutte le applicazioni ad alta intensità di I/O, come i server web, possono aumentare sensibilmente il throughput di I/O a parità di hardware. Prima, con 1 GB di memoria, si potevano gestire contemporaneamente 1000 accessi; usando i thread virtuali, secondo quanto dichiarato ufficialmente, si possono gestire facilmente 100 × 10.000 accessi simultanei. La capacità effettiva nei singoli scenari applicativi va comunque verificata con test di carico, ma anche ridimensionando questa stima, 10 × 10.000 dovrebbero essere facilmente raggiungibili, senza alcun costo aggiuntivo e forse senza nemmeno modificare il codice. I thread virtuali permettono infatti di mantenere lo stile di programmazione tradizionale, cioè un thread per ogni richiesta: si usano come i normali thread e il programma richiede solo poche modifiche. Inoltre non introducono nuova sintassi, per cui si può dire che i costi di apprendimento e migrazione siano estremamente bassi.

Vale la pena ricordare che i thread virtuali continuano a usare il framework Fork/Join ai livelli sottostanti.


Commenti

Invio in corso

Scegli il tema

Il tema si applica a tutto il sito.