Storia: Prima di Java 8, l'accumulo concorrente si basava su AtomicLong, la cui singola posizione di memoria diventava un collo di bottiglia di scalabilità sotto contesa dei thread a causa dell'eccessiva invalidazione delle linee di cache tra i core della CPU. LongAdder è stato introdotto come parte del pacchetto java.util.concurrent.atomic per affrontare questo problema attraverso una tecnica ispirata all'algoritmo Striped64, partizionando dinamicamente le operazioni di scrittura su più celle imbottite.
Problema: Quando numerosi thread tentano simultaneamente operazioni CAS su un AtomicLong condiviso, ogni fallimento attiva una trasmissione di coerenza della cache che serializza il traffico di memoria e degrada esponenzialmente il throughput con l'aumentare del numero di core. Questo fenomeno, noto come rimbalzo delle linee di cache, impedisce la scalabilità lineare anche nei compiti altrimenti imbarazzantemente paralleli.
Soluzione: LongAdder tenta inizialmente di eseguire aggiornamenti su un singolo campo base utilizzando CAS; solo al rilevamento di contesa—specificamente quando un thread non riesce ad acquisire il lock di base dopo una sequenza di probing probabilistica (tipicamente implementata tramite un contatore di collisioni e un hash locale al thread in Striped64)—viene allocato pigramente un array di oggetti Cell annotati con @Contended. Ogni thread quindi hash a una cella distinta, eseguendo aggiunte non contese su linee di cache isolate, mentre il metodo sum() aggrega pigramente questi valori solo quando è necessario uno snapshot consistente.
Una piattaforma di trading ad alta frequenza richiedeva un contatore globale per convalidare il throughput degli ordini su una distribuzione a 64 core, inizialmente implementato utilizzando AtomicLong. Durante i picchi di volatilità del mercato, il sistema mostrava un degrado della latenza non lineare in cui il tempo di risposta del 99° percentile aumentava di dieci volte, la profilazione rivelava che il 40% dei cicli della CPU veniva sprecato su protocolli di coerenza della cache in contesa per l'unico indirizzo di memoria del contatore.
Il team ingegneristico ha considerato tre soluzioni architettoniche. In primo luogo, hanno valutato una mappa di contatori locali al thread manuale in cui ogni thread manteneva un AtomicLong indipendente in un ConcurrentHashMap, aggregato periodicamente da un reporter in background; mentre questo eliminava la contesa, introduceva un notevole overhead di memoria per thread e una gestione del ciclo di vita complessa durante il ridimensionamento del pool di thread, rischiando perdite di memoria in esecutori a lungo termine. In secondo luogo, hanno prototipato una strategia di sharding personalizzata utilizzando un array di 64 istanze di AtomicLong indicizzate da Thread.currentThread().getId() % 64; questo riduceva il traffico della cache ma soffriva di una distribuzione irregolare quando i pool di thread riutilizzavano ID e richiedeva gestione manuale del ridimensionamento dell'array durante la crescita del traffico, aggiungendo oneri di manutenzione fragili. In terzo luogo, hanno valutato la migrazione a LongAdder, che offriva striping dinamico integrato con imbottitura automatica @Contended per prevenire la condivisione errata, sebbene con il compromesso che le operazioni di lettura avrebbero restituito approssimazioni debolmente consistenti anziché valori atomici esatti.
Il team ha infine selezionato LongAdder perché il requisito aziendale tollerava valori di lettura leggermente obsoleti per i dashboard di monitoraggio, mentre il percorso di convalida ad alta scrittura richiedeva il massimo throughput. L'euristica di espansione delle celle automatica garantiva che durante i periodi di bassa traffico l'oggetto rimanesse leggero (campo di base singolo), mentre l'alta contesa attivava la scalabilità trasparente su celle imbottite. Dopo il deployment, la latenza si stabilizzava, con il throughput che scalava linearmente fino a 64 core man mano che il traffico di invalidazione della cache si distribuiva su regioni di memoria distinte anziché concentrarsi su un singolo hotspot.
Domanda: Perché il polling frequente di LongAdder.sum() in un ciclo stretto può potenzialmente annullare i benefici delle performance dello striping, e quali garanzie di coerenza fornisce questo metodo?
Risposta: Il metodo sum() deve attraversare il campo base e ogni Cell attiva nell'array per calcolare un totale, richiedendo barriere di memoria che attivano la sincronizzazione della coerenza della cache tra tutti i core partecipanti; di conseguenza, carichi di lavoro a leggere pesante continuativi serializzano effettivamente le scritture a strisce e reintroduecono la contesa che LongAdder è stato progettato per evitare. Inoltre, sum() offre solo una coerenza debole, restituendo un valore accurato solo al momento dell'invocazione senza garanzie di atomicità relative agli aggiornamenti concorrenti, il che significa che il risultato può rappresentare uno stato transitorio in cui alcuni incrementi dei thread sono visibili mentre altri non lo sono.
Domanda: Come impedisce l'annotazione @Contended all'interno della classe interna Cell di LongAdder la condivisione errata, e quale flag JVM governa questo comportamento di imbottitura?
Risposta: @Contended istruisce il compilatore HotSpot ad iniettare 128 byte (o il valore specificato da -XX:ContendedPaddingWidth) di imbottitura attorno al campo value all'interno di ogni Cell, garantendo che gli elementi dell'array adiacenti risiedano su linee di cache distinte indipendentemente dalle ottimizzazioni del layout dell'oggetto. Senza questa imbottitura, celle sequenziali condivideranno una linea di cache da 64 byte, causando che le scritture su una cella invalidino le copie memorizzate in cache dei vicini in altri core e reintroducano il rimbalzo della cache; i candidati trascurano frequentemente che questa annotazione è riservata alle classi interne del JDK a meno che -XX:-RestrictContended non venga esplicitamente disabilitato per consentire l'uso del codice utente.
Domanda: In quali specifiche circostanze LongAdder mostrerebbe prestazioni peggiori rispetto a AtomicLong, e come influisce l'implementazione di longValue() su questo rischio?
Risposta: LongAdder incurre in sovraccarico di allocazione per il suo array di Cell e la logica di calcolo hash anche durante l'esecuzione singolo-thread non contesa, rendendo AtomicLong superiore per scenari a bassa contesa o contatori aggiornati esclusivamente da un thread. Inoltre, longValue() delega direttamente a sum(), il che significa che qualsiasi percorso di codice che controlla continuamente il valore del contatore—come un algoritmo di spin-lock o di backpressure—costringe a un'aggregazione globale ripetuta che sincronizza tutte le linee di cache, trasformando effettivamente la struttura a strisce in un singleton conteso e distruggendo la scalabilità.