Java >> Programma Java >  >> Java

La disparità generazionale nella raccolta dei rifiuti

Nell'ultimo anno, ho aiutato la startup Instana a creare un agente Java che traccia le esecuzioni all'interno di un'applicazione Java. Questi dati di esecuzione vengono raccolti e uniti per generare tracce delle richieste degli utenti, nonché la conseguente comunicazione tra servizi all'interno dell'emisfero del proprietario del sistema. In questo modo, è possibile visualizzare una comunicazione non strutturata che semplifica notevolmente il funzionamento di un sistema distribuito composto da più servizi interagenti.

Per generare queste tracce, l'agente Java riscrive tutto il codice che legge una richiesta esterna o ne avvia una. Ovviamente, queste entrate e uscite in entrata o in uscita da un sistema devono essere registrate e, inoltre, i metadati vengono scambiati per identificare una richiesta in modo univoco tra i sistemi. Ad esempio, durante la traccia delle richieste HTTP, l'agente aggiunge un'intestazione contenente un ID univoco che viene quindi registrato dal server ricevente come prova dell'origine di una richiesta. In generale, è simile a ciò che Zipkin sta modellando, ma senza richiedere agli utenti di modificare il proprio codice.

Nello scenario più semplice, tale tracciamento è semplice da implementare. Grazie alla mia libreria Byte Buddy che fa il lavoro pesante, tutto il codice iniettato viene scritto in un semplice vecchio Java e quindi copiato nei metodi pertinenti in fase di esecuzione utilizzando l'API della strumentazione Java. Ad esempio, quando si strumenta un servlet, sappiamo che viene effettuata una voce in una JVM ogni volta che viene invocato il metodo di servizio. Sappiamo anche che la voce è completata quando questo stesso metodo esce. Pertanto, è sufficiente aggiungere del codice all'inizio e alla fine del metodo per registrare qualsiasi voce di questo tipo in un processo VM. Ed è stata la maggior parte del mio lavoro esplorare le numerose librerie e framework Java per aggiungere supporto alle loro modalità di comunicazione. Da Akka a Zookeeper, nell'ultimo anno mi sono fatto strada attraverso l'intero ecosistema Java; Ho anche avuto modo di scrivere EJB per tutti i server! E dovevo dare un senso all'implementazione CORBA di Sun. (Spoiler:Non ha senso.)

Tuttavia, le cose diventano rapidamente più difficili quando si tracciano esecuzioni asincrone. Se una richiesta viene ricevuta da un thread ma riceve risposta dall'interno di un altro thread, non è più sufficiente tracciare solo le entrate e le uscite. Pertanto, il nostro agente deve anche tenere traccia di tutti i cambi di contesto nei sistemi simultanei realizzati tramite pool di thread, attività di join fork o framework di concorrenza personalizzati. E allo stesso modo in cui il debug dell'esecuzione asincrona è difficile, anche per noi è un bel po' di lavoro. Penso di dedicare tanto tempo alla simultaneità quanto alla registrazione di entrate e uscite.

L'impatto sulla raccolta dei rifiuti

Ma come influisce tutto questo sulla raccolta dei rifiuti? Quando si implementa un monitoraggio delle prestazioni, ci si trova di fronte a un compromesso tra l'interpretazione del lavoro di una macchina virtuale e il lavoro per questa macchina in questo modo. Sebbene la maggior parte dell'elaborazione venga eseguita nel back-end del monitor a cui l'agente segnala i propri dati, dobbiamo fare un minimo all'interno del processo Java che condividiamo con l'applicazione monitorata. E lo puoi già intuire:allocando gli oggetti, abbiamo inevitabilmente un impatto sulla garbage collection della VM. Fortunatamente, i moderni algoritmi di garbage collection stanno facendo un lavoro eccellente ed evitando per lo più l'allocazione di oggetti e campionando in modo adattivo i nostri sforzi di tracciamento, l'effetto delle nostre modifiche al codice è trascurabile per la stragrande maggioranza degli utenti. Idealmente, masterizziamo solo pochi cicli del processore inutilizzati per svolgere il nostro lavoro. In effetti, pochissime applicazioni utilizzano il loro pieno potenziale di elaborazione e siamo felici di aver afferrato una piccola parte di questo eccesso.

Scrivere un'applicazione per la raccolta dei rifiuti in genere non è troppo difficile. È ovvio che il modo più semplice per evitare la spazzatura è evitare del tutto l'allocazione degli oggetti. Tuttavia, anche l'allocazione degli oggetti di per sé non è male. L'allocazione della memoria è un'operazione piuttosto economica e poiché ogni processore possiede il proprio buffer di allocazione, un cosiddetto TLAB, non imponiamo una sincronizzazione non necessaria quando si alloca solo un po' di memoria dai nostri thread. Se un oggetto vive solo nell'ambito di un metodo, la JVM può anche cancellare del tutto l'allocazione degli oggetti come se i campi degli oggetti fossero inseriti direttamente nello stack. Ma anche senza questa analisi di fuga, gli oggetti di breve durata vengono catturati da uno speciale circolo di raccolta dei rifiuti chiamato raccolta di giovani generazioni che viene elaborato in modo abbastanza efficiente. Ad essere onesti, è qui che finisce la maggior parte dei miei oggetti poiché spesso stimo la leggibilità del codice rispetto ai piccoli miglioramenti offerti dall'analisi di fuga. Attualmente, l'analisi della fuga raggiunge rapidamente i suoi limiti. Tuttavia, spero che i futuri HotSpot migliorino per ottenere il meglio da entrambi i mondi anche senza modificare il mio codice. Dita incrociate!

Quando scrivo programmi Java, in genere non penso all'impatto sulla raccolta dei rifiuti, ma le linee guida di cui sopra tendono a manifestarsi nel mio codice. Per la maggior parte del nostro agente, questo ha funzionato molto bene. Stiamo eseguendo un sacco di applicazioni di esempio e test di integrazione per garantire un buon comportamento del nostro agente e tengo d'occhio anche il GC durante l'esecuzione di esempi. Nei nostri tempi moderni, utilizzando strumenti come il registratore di volo e l'orologio JIT, l'analisi delle prestazioni è diventata abbastanza accessibile.

La relatività della vita breve

Con una prima versione del nostro agente, un giorno ho notato un'applicazione per attivare cicli di riscossione titolari che non si attivava senza di essa. Di conseguenza, le pause di raccolta sono aumentate di molto. Gli oggetti che sono finiti nella collezione di proprietà erano tuttavia solo oggetti dell'applicazione monitorata stessa. Ma poiché il nostro agente viene eseguito per lo più isolato dai thread dell'applicazione e all'inizio, all'inizio questo non aveva senso per me.

Scavando più a fondo, ho scoperto che la nostra analisi degli oggetti utente ha attivato alcune fughe aggiuntive di oggetti, ma l'impatto è stato minimo. L'applicazione ha già prodotto una discreta quantità di oggetti, principalmente utilizzando NIO e utilizzando i pool di join fork. Una cosa che questi ultimi framework hanno in comune è che si basano sull'allocazione di molti oggetti di breve durata. Ad esempio, un'attività fork-join spesso si divide in più attività secondarie che ripetono questa procedura finché il carico utile di ciascuna attività è sufficientemente piccolo da poter essere calcolato direttamente. Ciascuno di questi compiti è rappresentato da un singolo oggetto con stato. Un pool di join fork attivo può generare milioni di tali oggetti ogni minuto. Ma poiché i compiti si calcolano velocemente, l'oggetto rappresentativo può essere raccolto rapidamente e quindi catturato dal giovane collezionista.

Allora come sono finiti all'improvviso questi oggetti nella collezione di proprietà? In questo momento, stavo prototipando una nuova strumentazione di stitching per tenere traccia dei cambi di contesto tra tali attività di join fork. Seguire il percorso di un fork di attività di join non è banale. Ogni thread di lavoro di un pool di join fork applica il furto di lavoro e potrebbe estrarre attività dalla coda di qualsiasi altra attività. Inoltre, le attività potrebbero fornire un feedback all'attività principale al completamento. Di conseguenza, tracciare l'espansione e l'interazione delle attività è un processo piuttosto complesso, anche a causa dell'esistenza di cosiddetti thread di continuazione in cui una singola attività potrebbe inviare lavori a centinaia di thread in pochi millisecondi. Ho trovato una soluzione piuttosto elegante che si basava sull'allocazione di molti oggetti di breve durata che venivano allocati a raffica ogni volta che si tornava indietro di un compito alla sua origine. Si è scoperto che queste esplosioni hanno innescato alcune giovani collezioni.

E questo è ciò che non ho considerato:ogni raccolta di giovani generazioni aumenta l'età di qualsiasi oggetto che a questo punto non è idoneo alla raccolta dei rifiuti. Un oggetto non invecchia per il tempo ma per la quantità di giovani raccolte innescate. Questo non è vero per tutti gli algoritmi di raccolta ma per molti di essi come per tutti i raccoglitori predefiniti di HotSpot. E attivando così tante raccolte, l'agente esegue il thread degli oggetti "maturati prematuramente" dell'applicazione monitorata nonostante tali oggetti non siano correlati agli oggetti dell'agente. In un certo senso, l'esecuzione dell'agente ha "maturato prematuramente" l'oggetto dell'applicazione di destinazione.

Come aggirare il problema

All'inizio non sapevo come risolvere questo. Alla fine, non c'è modo di dire a un garbage collector di trattare i "tuoi oggetti" separatamente. Finché i thread dell'agente allocavano oggetti di durata più breve a una velocità maggiore rispetto al processo host, rovinerebbero gli oggetti originali nella raccolta di proprietà causando un aumento delle pause di raccolta dei rifiuti. Per evitare ciò, ho quindi iniziato a mettere in comune gli oggetti che stavo utilizzando. Con il pooling, ho rapidamente maturato i miei oggetti nella raccolta di proprietà e il comportamento della raccolta dei rifiuti è tornato al suo stato normale. Tradizionalmente, il pooling veniva utilizzato per evitare i costi di allocazione che diventavano economici ai nostri giorni. L'ho riscoperto per cancellare l'impatto del nostro "processo estero" sulla raccolta dei rifiuti al costo di pochi kilobyte di memoria.

Il nostro tracciante sta già raggruppando oggetti in altri luoghi. Ad esempio, rappresentiamo le entrate e le uscite come valori locali del thread che contengono una serie di valori primitivi che mutiamo senza allocare un singolo oggetto. E mentre tale programmazione mutevole, spesso procedurale e di pooling di oggetti non è più di moda, risulta essere molto favorevole alle prestazioni. Alla fine, i bit mutanti sono più vicini a ciò che sta effettivamente facendo un processore. E utilizzando array preallocati di una dimensione fissa invece di raccolte immutabili, ci risparmiamo un bel po' di viaggi di andata e ritorno in memoria preservando al contempo il nostro stato in modo che sia contenuto solo in poche righe di cache.

È un problema del "mondo reale"?

Potresti pensare che questo sia un problema piuttosto specifico di cui la maggior parte delle persone non ha bisogno di preoccuparsi. Ma in realtà, il problema che descrivo si applica a un gran numero di applicazioni Java. Ad esempio, all'interno dei contenitori di applicazioni, in genere distribuiamo più applicazioni in un unico processo Java. Proprio come nel caso precedente, l'algoritmo di Garbage Collection non raggruppa gli oggetti per applicazione poiché non ha alcuna nozione di questo modello di distribuzione. Pertanto, le allocazioni di oggetti da parte di due applicazioni isolate che condividono un contenitore interferiscono con i modelli di raccolta previsti l'uno dell'altro. Se ogni applicazione fa affidamento sui suoi oggetti per morire giovane, la condivisione di un mucchio provoca una forte relatività sulla durata della vita breve.

Non sono un sostenitore dei microservizi. In effetti, penso che siano una cattiva idea per la maggior parte delle applicazioni. A mio parere, le routine che possono esistere solo nell'interazione dovrebbero idealmente essere implementate insieme a meno che non ci siano buone ragioni tecniche per non farlo. E anche se le applicazioni isolate facilitano lo sviluppo, ne paghi rapidamente il prezzo nelle operazioni. Sto solo menzionando questo per evitare un'errata interpretazione della morale dell'esperienza di cui sopra.

Ciò che questa esperienza mi ha insegnato è che distribuire più applicazioni in un unico processo Java può essere una cattiva idea se tali applicazioni sono eterogenee. Ad esempio, quando si esegue un processo batch parallelo a un server Web, è consigliabile eseguire ciascuno nel proprio processo anziché distribuirli entrambi nello stesso contenitore. In genere, un processo batch alloca oggetti a una velocità molto diversa rispetto a un server Web. Tuttavia, molti framework aziendali pubblicizzano ancora soluzioni all-in-one per affrontare tali problemi che non dovrebbero condividere un processo per cominciare. Nel 2016, il sovraccarico di un processo aggiuntivo non è in genere un problema e poiché la memoria è economica, aggiorna piuttosto il tuo server invece di condividere un heap. In caso contrario, potresti ritrovarti con modelli di raccolta che non avevi previsto durante lo sviluppo, l'esecuzione e il test delle tue applicazioni in isolamento.

Etichetta Java