Un caso per mantenere le primitive in Java

Le primitive fanno parte del linguaggio di programmazione Java sin dal suo rilascio iniziale nel 1996, eppure rimangono una delle caratteristiche del linguaggio più controverse. John Moore sostiene con forza il mantenimento delle 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 notevole differenza.

Domanda : quali sono i tre fattori più importanti nell'acquisto di un immobile?

Risposta : posizione, posizione, posizione.

Questo vecchio e spesso usato adagio vuole implicare che la posizione domina completamente tutti gli altri fattori quando si tratta di proprietà immobiliari. 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 la proprietà immobiliare e l'argomento per le primitive. In primo luogo, nel settore immobiliare, la posizione domina in quasi tutte le situazioni, ma i vantaggi in termini di prestazioni derivanti dall'utilizzo di tipi primitivi possono variare notevolmente da un tipo di applicazione a un altro. In secondo luogo, nel settore immobiliare, ci sono altri fattori da considerare anche se di solito sono minori rispetto alla posizione. Con i tipi primitivi, c'è solo una ragione per usarli: le prestazioni; e quindi solo se l'applicazione è del tipo che può beneficiare del loro utilizzo.

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 dominate dai calcoli numerici possono trarre grandi vantaggi dall'uso delle 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 nel forum relativi a questa decisione. Simon Ritter ha osservato nel suo discorso programmatico al JAX di Londra nel novembre 2011 che si stava prendendo in seria considerazione la rimozione delle primitive in una futura versione di Java (vedere la diapositiva 41). In questo articolo introdurrò brevemente le primitive e il sistema dual-type di Java. Utilizzando 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.

Misurazione delle prestazioni del software

Le prestazioni del software vengono solitamente misurate in termini di tempo e spazio. Il tempo può essere il tempo di esecuzione effettivo, ad esempio 3,7 minuti, o l'ordine di crescita basato sulla dimensione dell'input, ad esempio O ( n 2). Esistono misure simili per le prestazioni dello spazio, che sono spesso espresse in termini di utilizzo della memoria principale ma possono estendersi anche all'utilizzo del disco. Migliorare le prestazioni di solito comporta un compromesso tempo-spazio in quanto i cambiamenti per migliorare il tempo spesso hanno un effetto dannoso sullo spazio e viceversa. Una misurazione dell'ordine di crescita dipende dall'algoritmo e il passaggio dalle classi wrapper alle primitive non cambierà il risultato. Ma quando si tratta di prestazioni temporali e spaziali effettive, l'uso di primitive invece di classi wrapper offre miglioramenti sia nel tempo che nello spazio simultaneamente.

Primitive contro oggetti

Come probabilmente già saprai se stai leggendo questo articolo, Java ha un sistema a doppio tipo, di solito indicato come tipi primitivi e tipi di oggetto, spesso abbreviati semplicemente come primitive e oggetti. Esistono otto tipi primitivi predefiniti in Java e i loro nomi sono parole chiave riservate. Comunemente gli esempi usati includono int, doublee boolean. Essenzialmente tutti gli altri tipi in Java, inclusi tutti i tipi definiti dall'utente, sono tipi di oggetto. (Dico "essenzialmente" perché i tipi di array sono un po 'ibridi, ma sono molto più simili ai tipi di oggetto che ai tipi primitivi.) Per ogni tipo primitivo c'è una classe wrapper corrispondente che è un tipo di oggetto; esempi includono Integerfor int, Doublefor doublee Booleanfor boolean.

I tipi primitivi sono basati su valori, ma i tipi di oggetti sono basati su riferimenti, e qui sta sia il potere che la fonte della controversia 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); 

Utilizzando l'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 esecuzione.

La differenza tra la primitiva n1e l'oggetto wrapper n2è illustrata dal diagramma nella Figura 1.

John I. Moore, Jr.

La variabile n1contiene un valore intero, ma la variabile n2contiene un riferimento a un oggetto ed è l'oggetto che contiene il valore intero. Inoltre, l'oggetto a cui fa riferimento n2contiene anche un riferimento all'oggetto classe Double.

Il problema con le primitive

Prima di provare a convincerti della necessità di tipi primitivi, dovrei riconoscere che molte persone non sono d'accordo con me. Sherman Alpert in "Tipi primitivi considerati dannosi" sostiene che le primitive sono dannose perché mescolano "semantica procedurale in un modello orientato agli oggetti altrimenti uniforme. Le primitive non sono oggetti di prima classe, tuttavia esistono in un linguaggio che coinvolge, principalmente, primo oggetti di 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 confrontare due istanze per l'uguaglianza? Per i tipi primitivi, si usa l' ==operatore, ma per gli oggetti la scelta preferita è chiamare ilequals()metodo, che non è un'opzione per le primitive. Allo stesso modo, esistono semantiche diverse durante l'assegnazione di valori o il passaggio di parametri. Anche i valori predefiniti sono diversi; ad esempio, 0per intcontro nullper Integer.

Per ulteriori informazioni su questo problema, vedere il post sul blog di Eric Bruno, "Una discussione primitiva moderna", che riassume alcuni dei pro e dei contro dei primitivi. Numerose 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 Oggetti invece di primitive?" I programmatori Stack Exchange ospita una discussione simile intitolata "Quando usare primitive vs class in Java?".

Utilizzo della memoria

A doublein Java occupa sempre 64 bit in memoria, ma la dimensione di un riferimento dipende dalla JVM (Java virtual machine). Il mio computer esegue la versione a 64 bit di Windows 7 e una JVM a 64 bit, quindi un riferimento sul mio computer occupa 64 bit. In base allo schema di Figura 1 mi aspetterei un singolo doubletale da n1occupare 8 byte (64 bit), e mi aspetto un singolo Doubletale da n2occupare 24 byte - 8 per il riferimento all'oggetto, 8 per il doublevalore memorizzato in l'oggetto e 8 per il riferimento all'oggetto classe per Double. Inoltre, Java utilizza memoria aggiuntiva per supportare la garbage collection per i tipi di oggetti ma non per i tipi primitivi. Controlliamolo.

Utilizzando un approccio simile a quello di Glen McCluskey in "Tipi primitivi Java contro wrapper", il metodo mostrato nel Listato 1 misura il numero di byte occupati da una matrice n per n (array bidimensionale) di double.

Listato 1. Calcolo dell'utilizzo della memoria di tipo double

 public static long getBytesUsingPrimitives(int n) { System.gc(); // force garbage collection long memStart = Runtime.getRuntime().freeMemory(); double[][] a = new double[n][n]; // put some random values in the matrix for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) a[i][j] = Math.random(); } long memEnd = Runtime.getRuntime().freeMemory(); return memStart - memEnd; } 

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 per n di Double. Quando provo questi due metodi sul mio computer utilizzando matrici 1000 per 1000, ottengo i risultati mostrati nella Tabella 1 di seguito. Come illustrato, la versione per il tipo primitivo doubleequivale 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 Doublerichiedeva 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.

Tabella 1. Utilizzo della memoria di double rispetto a Double

Versione Byte totali Byte per voce
Using double 8,380,768 8.381
Using Double 28,166,072 28.166

Runtime performance

To compare the runtime performances for primitives and objects, we need an algorithm dominated by numerical calculations. For this article I have chosen matrix multiplication, and I compute the time required to multiply two 1000-by-1000 matrices. I coded matrix multiplication for double in a straightforward manner as shown in Listing 2 below. While there may be faster ways to implement matrix multiplication (perhaps using concurrency), that point is not really relevant to this article. All I need is common code in two similar methods, one using the primitive double and one using the wrapper class Double. The code for multiplying two matrices of type Double is exactly like that in Listing 2 with the obvious type changes.

Listing 2. Multiplying two matrices of type double

 public static double[][] multiply(double[][] a, double[][] b) { if (!checkArgs(a, b)) throw new IllegalArgumentException("Matrices not compatible for multiplication"); int nRows = a.length; int nCols = b[0].length; double[][] result = new double[nRows][nCols]; for (int rowNum = 0; rowNum < nRows; ++rowNum) { for (int colNum = 0; colNum < nCols; ++colNum) { double sum = 0.0; for (int i = 0; i < a[0].length; ++i) sum += a[rowNum][i]*b[i][colNum]; result[rowNum][colNum] = sum; } } return result; } 

I ran the two methods to multiply two 1000-by-1000 matrices on my computer several times and measured the results. The average times are shown in Table 2. Thus, in this case, the runtime performance of double is more than four times as fast as that of Double. That is simply too much of a difference to ignore.

Table 2. Runtime performance of double versus Double

Version Seconds
Using double 11.31
Using Double 48.48

The SciMark 2.0 benchmark

Finora ho utilizzato il singolo, semplice benchmark della moltiplicazione di matrici per dimostrare che le primitive possono produrre prestazioni di elaborazione significativamente maggiori rispetto agli oggetti. Per rafforzare le mie affermazioni userò un benchmark più scientifico. SciMark 2.0 è un benchmark Java per il calcolo scientifico e numerico disponibile presso il National Institute of Standards and Technology (NIST). Ho scaricato il codice sorgente per questo benchmark e ho creato due versioni, la versione originale utilizzando primitive e una seconda versione utilizzando classi wrapper. Per la seconda versione ho sostituito intcon Integere doublecon Doubleper ottenere il pieno effetto dell'uso delle classi wrapper. Entrambe le versioni sono disponibili nel codice sorgente di questo articolo.

scarica Benchmarking Java: Scarica il codice sorgente John I. Moore, Jr.

The SciMark benchmark measures performance of several computational routines and reports a composite score in approximate Mflops (millions of floating point operations per second). Thus, larger numbers are better for this benchmark. Table 3 gives the average composite scores from several runs of each version of this benchmark on my computer. As shown, the runtime performances of the two versions of the SciMark 2.0 benchmark were consistent with the matrix multiplication results above in that the version with primitives was almost five times faster than the version using wrapper classes.

Table 3. Runtime performance of the SciMark benchmark

SciMark version Performance (Mflops)
Using primitives 710.80
Using wrapper classes 143.73

You've seen a few variations of Java programs doing numerical calculations, using both a homegrown benchmark and a more scientific one. But how does Java compare to other languages? I'll conclude with a quick look at how Java's performance compares to that of three other programming languages: Scala, C++, and JavaScript.

Benchmarking Scala

Scala è un linguaggio di programmazione che gira su JVM e sembra guadagnare popolarità. Scala ha un sistema di tipi unificato, il che significa che non distingue tra primitive e oggetti. Secondo Erik Osheim nella classe dei tipi numerici 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 Arrays di Scala dice che "... un array Scala Array[Int]è rappresentato come Java int[], un Array[Double]è 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.