Un caso per mantenere le primitive in Java

Le primitive hanno fatto parte del linguaggio di programmazione Java sin dalla sua versione iniziale nel 1996, eppure rimangono una delle caratteristiche del linguaggio più controverse. John Moore fa un forte caso per mantenere le primitive nel linguaggio Java confrontando semplici benchmark Java, sia con che senza primitive. Quindi confronta le prestazioni di Java con quelle di Scala, C++ e JavaScript in un particolare tipo di applicazione, dove le primitive fanno una differenza notevole.

Domanda: Quali sono i tre fattori più importanti per l’acquisto di immobili?
Risposta: Posizione, posizione, posizione.

Questo vecchio e spesso usato adagio ha lo scopo di implicare che la posizione domina completamente tutti gli altri fattori quando si tratta di immobili. In un argomento simile, i tre fattori più importanti da considerare per l’utilizzo di tipi primitivi in Java sono prestazioni, prestazioni, prestazioni. Ci sono due differenze tra l’argomento per il settore immobiliare e l’argomento per le primitive. Innanzitutto, con il settore immobiliare, la posizione domina in quasi tutte le situazioni, ma i guadagni in termini di prestazioni derivanti dall’utilizzo di tipi primitivi possono variare notevolmente da un tipo di applicazione all’altra. In secondo luogo, con il settore immobiliare, ci sono altri fattori da considerare anche se di solito sono minori rispetto alla posizione. Con i tipi primitivi, c’è solo un motivo per usarli: le prestazioni; e quindi solo se l’applicazione è il tipo che può beneficiare del loro uso.

Le primitive offrono poco valore alla maggior parte delle applicazioni aziendali e Internet che utilizzano un modello di programmazione client-server con un database sul back-end. Ma le prestazioni delle applicazioni che sono dominate da calcoli numerici possono beneficiare notevolmente dall’uso di primitive.

L’inclusione di primitive in Java è stata una delle decisioni di progettazione del linguaggio più controverse, come evidenziato dal numero di articoli e post sul forum relativi a questa decisione. Simon Ritter ha notato nel suo discorso di apertura di JAX London nel novembre 2011 che si stava prendendo seriamente in considerazione la rimozione delle primitive in una versione futura di Java (vedere la diapositiva 41). In questo articolo introdurrò brevemente primitive e il sistema dual-type di Java. Usando esempi di codice e semplici benchmark, spiegherò perché le primitive Java sono necessarie per determinati tipi di applicazioni. Confronterò anche le prestazioni di Java con quelle di Scala, C++ e JavaScript.

Primitive versus objects

Come probabilmente già saprai se stai leggendo questo articolo, Java ha un sistema a doppio tipo, solitamente indicato come tipi primitivi e tipi di oggetti, spesso abbreviato semplicemente come primitive e oggetti. Esistono otto tipi primitivi predefiniti in Java e i loro nomi sono parole chiave riservate. Esempi comunemente usati includono int, doublee boolean. Essenzialmente tutti gli altri tipi in Java, inclusi tutti i tipi definiti dall’utente, sono tipi di oggetti. (Dico “essenzialmente” perché i tipi di array sono un po ‘ ibridi, ma sono molto più simili ai tipi di oggetti che ai tipi primitivi.) Per ogni tipo primitivo esiste una classe wrapper corrispondente che è un tipo di oggetto; esempi includono Integer per int, Double per double e Boolean per boolean.

I tipi primitivi sono basati sul valore, ma i tipi di oggetti sono basati sul riferimento, e qui sta sia la potenza che la fonte di controversie dei tipi primitivi. Per illustrare la differenza, considera le due dichiarazioni seguenti. La prima dichiarazione utilizza un tipo primitivo e la seconda utilizza una classe wrapper.

int n1 = 100;Integer n2 = new Integer(100);

Usando autoboxing, una funzionalità aggiunta a JDK 5, potrei abbreviare la seconda dichiarazione semplicemente

Integer n2 = 100;

ma la semantica sottostante non cambia. L’Autoboxing semplifica l’uso delle classi wrapper e riduce la quantità di codice che un programmatore deve scrivere, ma non cambia nulla in fase di runtime.

La differenza tra la primitiva n1 e l’oggetto wrapper n2 è illustrata dal diagramma in Figura 1.

John I. Moore, Jr.

Figura 1. Layout di memoria delle primitive rispetto agli oggetti

La variabile n1 contiene un valore intero, ma la variabile n2 contiene un riferimento a un oggetto ed è l’oggetto che contiene il valore intero. Inoltre, l’oggetto a cui fa riferimento n2 contiene anche un riferimento all’oggetto di classe Double.

Il problema con le primitive

Prima di provare a convincerti della necessità di tipi primitivi, dovrei riconoscere che molte persone non saranno d’accordo con me. Sherman Alpert in” Tipi primitivi considerati dannosi ” sostiene che i primitivi sono dannosi perché mescolano “semantica procedurale in un modello orientato agli oggetti altrimenti uniforme. Le primitive non sono oggetti di prima classe, eppure esistono in un linguaggio che coinvolge, principalmente, oggetti di prima classe.”Le primitive e gli oggetti (sotto forma di classi wrapper) forniscono due modi per gestire tipi logicamente simili, ma hanno una semantica sottostante molto diversa. Ad esempio, come dovrebbero essere confrontate due istanze per l’uguaglianza? Per i tipi primitivi, si utilizza l’operatore ==, ma per gli oggetti la scelta preferita è chiamare il metodo equals(), che non è un’opzione per le primitive. Allo stesso modo, esistono semantiche diverse quando si assegnano valori o si passano parametri. Anche i valori predefiniti sono diversi; ad esempio, 0 per int contro null per Integer.

Per ulteriori informazioni su questo tema, vedere il post sul blog di Eric Bruno, “A modern primitive discussion”, che riassume alcuni dei pro e dei contro delle primitive. Un certo numero di discussioni su Stack Overflow si concentrano anche sulle primitive, tra cui ” Perché le persone usano ancora i tipi primitivi in Java?”e” C’è un motivo per usare sempre gli oggetti invece delle primitive?.”Programmatori Stack Exchange ospita una discussione simile dal titolo” Quando usare primitive vs class in Java?”.

Utilizzo della memoria

A double in Java occupa sempre 64 bit in memoria, ma la dimensione di un riferimento dipende dalla Java Virtual machine (JVM). Risorse del computer esegue la versione a 64 bit di Windows 7 e una JVM a 64 bit, e quindi un riferimento sul mio computer occupa 64 bit. Basato sul diagramma in Figura 1 mi aspetterei un singolo double come n1 occupare di 8 byte (64 bit), e mi sarei aspettato un singolo Double come n2 occupare di 24 byte — 8 per il riferimento all’oggetto, 8 per la double valore memorizzato nell’oggetto, e 8 per il riferimento all’oggetto di classe per Double. Inoltre, Java utilizza memoria aggiuntiva per supportare la garbage collection per i tipi di oggetti ma non per i tipi primitivi. Diamo un’occhiata.

Utilizzando un approccio simile a quello di Glen McCluskey in ” Java primitive types vs. wrapper, ” il metodo mostrato nel listato 1 misura il numero di byte occupati da una matrice n-by-n (matrice bidimensionale) di double.

Elenco 1. Calcolando l’utilizzo della memoria di tipo double

Modificando il codice nel listato 1 con le ovvie modifiche di tipo (non mostrate), possiamo anche misurare il numero di byte occupati da una matrice n-by-n di Double. Quando provo questi due metodi sul mio computer usando matrici 1000 per 1000, ottengo i risultati mostrati nella Tabella 1 di seguito. Come illustrato, la versione per il tipo primitivo double equivale a poco più di 8 byte per voce nella matrice, più o meno quello che mi aspettavo. Tuttavia, la versione per il tipo di oggetto Double richiedeva poco più di 28 byte per voce nella matrice. Pertanto, in questo caso, l’utilizzo della memoria di Double è più di tre volte l’utilizzo della memoria di double, il che non dovrebbe essere una sorpresa per chiunque comprenda il layout della memoria illustrato nella Figura 1 sopra.

Prestazioni di runtime

Per confrontare le prestazioni di runtime per primitive e oggetti, abbiamo bisogno di un algoritmo dominato da calcoli numerici. Per questo articolo ho scelto la moltiplicazione della matrice e calcolo il tempo necessario per moltiplicare due matrici 1000 per 1000. Ho codificato la moltiplicazione della matrice per double in modo semplice come mostrato nel listato 2 di seguito. Mentre ci possono essere modi più veloci per implementare la moltiplicazione della matrice (forse usando la concorrenza), quel punto non è realmente rilevante per questo articolo. Tutto ciò di cui ho bisogno è un codice comune in due metodi simili, uno che utilizza la primitiva double e uno che utilizza la classe wrapper Double. Il codice per moltiplicare due matrici di tipo Double è esattamente come quello nel Listato 2 con le ovvie modifiche di tipo.

Elenco 2. Moltiplicando due matrici di tipo double

Ho eseguito i due metodi per moltiplicare due matrici 1000 per 1000 sul mio computer più volte e misurato i risultati. I tempi medi sono riportati nella Tabella 2. Pertanto, in questo caso, le prestazioni di runtime di double sono più di quattro volte più veloci di quelle di Double. Questa è semplicemente una differenza troppo grande da ignorare.

Il benchmark SciMark 2.0

Finora ho usato il singolo, semplice benchmark della moltiplicazione delle matrici per dimostrare che le primitive possono produrre prestazioni di calcolo significativamente maggiori rispetto agli oggetti. Per rafforzare le mie affermazioni userò un punto di riferimento più scientifico. SciMark 2.0 è un punto di riferimento Java per il calcolo scientifico e numerico disponibile dal National Institute of Standards and Technology (NIST). Ho scaricato il codice sorgente per questo benchmark e creato due versioni, la versione originale utilizzando primitive e una seconda versione utilizzando classi wrapper. Per la seconda versione ho sostituito int con Integer e double con Double per ottenere il pieno effetto dell’utilizzo delle classi wrapper. Entrambe le versioni sono disponibili nel codice sorgente di questo articolo.

scarica

John I. Moore, Jr.

Il benchmark SciMark misura le prestazioni di diverse routine computazionali e riporta un punteggio composito in Mflops approssimativi (milioni di operazioni in virgola mobile al secondo). Quindi, numeri più grandi sono migliori per questo benchmark. La tabella 3 fornisce i punteggi compositi medi di diverse esecuzioni di ciascuna versione di questo benchmark sul mio computer. Come mostrato, le prestazioni di runtime delle due versioni del benchmark SciMark 2.0 erano coerenti con i risultati di moltiplicazione della matrice sopra in quanto la versione con primitive era quasi cinque volte più veloce della versione che utilizzava le classi wrapper.

Hai visto alcune varianti di programmi Java che eseguono calcoli numerici, utilizzando sia un benchmark nostrano che uno più scientifico. Ma come si confronta Java con altre lingue? Concluderò con una rapida occhiata a come le prestazioni di Java sono paragonabili a quelle di altri tre linguaggi di programmazione: Scala, C++ e JavaScript.

Benchmarking Scala

Scala è un linguaggio di programmazione che gira sulla JVM e sembra stia guadagnando popolarità. Scala ha un sistema di tipi unificato, il che significa che non distingue tra primitive e oggetti. Secondo Erik Osheim nella classe di tipo numerico di Scala (Pt. 1), Scala usa i tipi primitivi quando possibile ma userà gli oggetti se necessario. Allo stesso modo, la descrizione di Martin Odersky degli array di Scala dice che”… un array Scala Array è rappresentato come Java int, un Array è rappresentato come Java double…”

Quindi questo significa che il sistema di tipi unificato di Scala avrà prestazioni di runtime paragonabili ai tipi primitivi di Java? Vediamo.

Non sono così abile con Scala come lo sono con Java, ma ho tentato di convertire il codice per il benchmark di moltiplicazione della matrice direttamente da Java a Scala. Il risultato è mostrato nel listato 3 qui sotto. Quando ho eseguito la versione Scala del benchmark sul mio computer, ha avuto una media di 12,30 secondi, il che pone le prestazioni di Scala molto vicine a quelle di Java con le primitive. Questo risultato è molto meglio di quanto mi aspettassi e supporta le affermazioni su come Scala gestisce i tipi numerici.

scarica

John I. Moore, Jr.

Elenco 3. Moltiplicando due matrici in Scala

Benchmarking C++

Poiché C++ viene eseguito direttamente su “bare metal” piuttosto che in una macchina virtuale, ci si aspetterebbe naturalmente che C++ funzioni più velocemente di Java. Inoltre, le prestazioni Java sono leggermente ridotte dal fatto che Java controlla gli accessi agli array per garantire che ogni indice sia entro i limiti dichiarati per l’array, mentre C++ no (una funzionalità C++ che può portare a overflow del buffer, che può essere sfruttata dagli hacker). Ho trovato C++ un po ‘ più imbarazzante di Java nel trattare con array bidimensionali di base, ma fortunatamente gran parte di questo imbarazzo può essere nascosto all’interno delle parti intime di una classe. Per C++, ho creato una versione semplice di una classe Matrix e ho sovraccaricato l’operatore * per moltiplicare due matrici, ma l’algoritmo di moltiplicazione della matrice di base è stato convertito direttamente dalla versione Java. Il codice sorgente C++ è mostrato nel listato 4.

scarica

John I. Moore, Jr.

Elenco 4. Moltiplicando due matrici in C++

Utilizzando Eclipse CDT (Eclipse for C++ Developers) con il compilatore MinGW C++, è possibile creare entrambe le versioni di debug e rilascio di un’applicazione. Per testare C++ ho eseguito la versione di rilascio più volte e ho calcolato la media dei risultati. Come previsto, C++ ha funzionato notevolmente più velocemente su questo semplice benchmark, con una media di 7,58 secondi sul mio computer. Se le prestazioni non elaborate sono il fattore principale per la selezione di un linguaggio di programmazione, il C++ è il linguaggio di scelta per le applicazioni ad alta intensità numerica.

Benchmarking JavaScript

Ok, questo mi ha sorpreso. Dato che JavaScript è un linguaggio molto dinamico, mi aspettavo che le sue prestazioni fossero le peggiori di tutte, anche peggiori di Java con classi wrapper. Ma in realtà, le prestazioni di JavaScript erano molto più vicine a quelle di Java con le primitive. Per testare JavaScript ho installato il nodo.js, un motore JavaScript con la reputazione di essere molto efficiente. I risultati hanno avuto una media di 15,91 secondi. Il listato 5 mostra la versione JavaScript del benchmark di moltiplicazione della matrice che ho eseguito sul nodo.js

scarica

John I. Moore, Jr.

Elenco 5. Moltiplicando due matrici in JavaScript

In conclusione

Quando Java è arrivato sulla scena circa 18 anni fa, non era il miglior linguaggio dal punto di vista delle prestazioni per le applicazioni dominate da calcoli numerici. Ma nel tempo, con i progressi tecnologici in aree come la compilazione just-in-time (JIT) (aka compilazione adattiva o dinamica), le prestazioni di Java per questo tipo di applicazioni sono ora paragonabili a quelle dei linguaggi compilati in codice nativo quando vengono utilizzati tipi primitivi.

Inoltre, le primitive eliminano la necessità di garbage collection, fornendo così un altro vantaggio in termini di prestazioni delle primitive rispetto ai tipi di oggetto. La tabella 4 riassume le prestazioni di runtime del benchmark di moltiplicazione della matrice sul mio computer. Altri fattori come la manutenibilità, la portabilità e l’esperienza degli sviluppatori rendono Java una scelta migliore per molte di queste applicazioni.

Come discusso in precedenza, Oracle sembra prendere in seria considerazione la rimozione delle primitive in una versione futura di Java. A meno che il compilatore Java non possa generare codice con prestazioni paragonabili a quelle delle primitive, penso che la loro rimozione da Java precluderebbe l’uso di Java per determinate classi di applicazioni; vale a dire, quelle applicazioni dominate da calcoli numerici. In questo articolo ho usato un semplice benchmark basato sulla moltiplicazione delle matrici e un benchmark più scientifico, SciMark 2.0, per argomentare questo punto.

Circa l’autore

John I. Moore, Jr., Professore di Matematica e Informatica presso La Cittadella, ha una vasta gamma di esperienza sia nel settore e nel mondo accademico, con competenze specifiche nei settori della tecnologia orientata agli oggetti, ingegneria del software, e matematica applicata. Per più di tre decenni ha progettato e sviluppato software utilizzando database relazionali e diversi linguaggi di alto livello, e ha lavorato a lungo in Java dalla versione 1.1. Inoltre, ha sviluppato e insegnato numerosi corsi accademici e seminari industriali su argomenti avanzati in informatica.

Ulteriori letture

  1. Paul Krill ha scritto sui piani a lungo raggio di Oracle per Java in “Oracle lays out long-range Java intentions” (JavaWorld, marzo 2012). Questo articolo, insieme al thread dei commenti associati, mi ha motivato a scrivere questa difesa delle primitive.
  2. Szymon Guz scrive dei suoi risultati nel benchmarking di tipi primitivi e classi wrapper in “Primitive and objects benchmark in Java” (SimonOnSoftware, gennaio 2011).
  3. Sul sito Web di supporto per la programmazione — Principi e pratiche che utilizzano C++ (Addison-Wesley, 2009), il creatore di C++ Bjarne Stroustrup fornisce un’implementazione per una classe matrix che è molto più completa di quella che accompagna questo articolo.
  4. John Rose, Brian Goetz e Guy Steele discutono un concetto chiamato tipi di valore nello “Stato dei valori” (OpenJDK.net, Aprile 2014). I tipi di valore possono essere considerati come tipi aggregati definiti dall’utente immutabili senza identità, combinando proprietà di oggetti e primitive. Il mantra per i tipi di valore è ” codici come una classe, funziona come un int.”

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.