Java I/O: BufferedReader e FileInputStream VS. Scanner, confronto su lettura e parsing di un file da 200k linee

In questo post eseguiamo un confronto delle prestazioni tra due diversi meccanismi di lettura di un file in Java, rappresentati da una parte dal costrutto di chained class BufferedReader, InputStreamReader e FileInputStream e dall’altra dalla semplice classe Scanner. L’utilizzo combinato di BufferedReader e InputStreamReader viene indicato dalla documentazione java come il metodo più efficiente per la lettura di dati testuali. Tale soluzione permette di leggere un file riga per riga per mezzo del metodo readline(), che restituisce una String. Qualora siano necessarie delle operazioni per interpretare il contenuto delle righe (divisione in token, conversioni di tipo, ecc..), esso sono completamente a carico dello sviluppatore.
La classe Scanner invece offre una soluzione flessibile proprio per questo ultimo caso, in quanto permmette di suddividere il contenuto di una riga in tokens in base ad un determinato separatore ed offre dei metodi per convertire automaticamente il token letto nel tipo primitivo che ci si aspetta esso rappresenti, eventualmente dopo aver effettuato un test per verificare che fosse effettivamente di quel tipo.
Ad esempio Scanner fornisce il metodo

	boolean hasNextInt()

che restituisce true se il prossimo token può essere interpretato come un intero. In caso di esito positivo si può allora invocare il metodo

	int nextInt()

che legge il token dal file e lo converte in un intero.

L’obbiettivo ora è quello di effettuare un test per vedere quale dei due metodi offre le prestazioni migliori nella lettura ed interpretazione di un file di testo contenente 200 mila righe, formate da diversi token che devono essere convertiti in formati numerici. L’esigenza è nata dopo aver creato un file di testo di esempio per testare la mia soluzione ad un puzzle di programmazione di Spotify.

Il file che dobbiamo processare ha la seguente struttura:

  • Un intero N che indica il numero di righe seguenti che rappresentano degli intervalli
  • N righe formate da 3 token dei seguenti tipi: long, long, int
  • Un intero M che indica il numero di righe seguenti che rappresentano delle queries
  • M righe formate da 2 token dei seguenti tipi: long, long

Per i nostri test utilizziamo un valore di 100.000 per gli interi N ed M che rappresentano il numero di righe per ciascuna delle due tipologie. Di seguito un esempio di come si presenta il file che andremo a leggere:

100000
1325338338022 320412 160
1325338361231 441201 320
1325338341231 474123 96
1325338312302 234123 312
1325338331141 623132 147
. . .
. . .
100000
1325300000000 1325400000000
1325338300000 1325338500000
1325338336412 1325338339612
1325338312302 1325338341231
1325338320000 1325338340000
. . .
. . .

Le due strategie che mettiamo a confronto sono:

  • Leggere le righe con BufferedReader, fare lo split della String ottenuta e convertire le singole parti nei corretti datatype con i metodi parse forniti dalle classi wrapper
    dei tipi primitivi(Long.parseLong, Integer.parseInt, ecc.. ).
  • Utilizzare uno Scanner e leggere ogni token direttamente con il metodo relativo al tipo che ci aspettiamo di acquisire (nextLong(), nextInt(), ecc..)

Con i valori letti creiamo delle istanze di due classi che chiamiamo StatsRecord per la prima tipologia di riga ed Interval per la seconda ed inseriamo queste istanze in due ArrayList. Al termine dell’esecuzione, se tutto è andato come ci aspettiamo, entrambi gli ArrayList devono avere una dimensione pari a 100.000 elementi. Per semplicità supponiamo infatti che tutti i valori siano del tipo che ci si aspetta, per cui non ci siano errori di parsing o di conversione.

Procediamo con la soluzione 1, quella basata sul BufferedReader. Quello che facciamo è “concatenare” un BufferedReader con un InputStreamReader ed un FileInputStream per leggere poi il file riga per riga. Nella prima riga abbiamo solo un valore, per cui lo leggiamo come stringa e ne facciamo il parsing ad int. A questo punto dobbiamo leggere le N righe successive che, come abbiamo visto, sono costituite da tre parti (token) ciascuna. Eseguiamo quindi su ogni riga il metodo split ed otteniamo un array di String con i vari pezzi della stringa, ottenuti dividendola in corrispondenza del carattere ” ” (spazio). I token ottenuti con la split li convertiamo in due long ed un int con i metodi parse delle relative classi wrapper dei tipi primitivi e li utilizziamo per creare gli oggetti della classe StatsRecord. Dopo aver letto le N righe, troviamo un altro intero, che indica che a seguire ci saranno le M righe della seconda tipologia. Su queste righe eseguiamo lo stesso procedimento, cioè le splittiamo, convertiamo i token e creiamo gli oggetti della classe Interval.
Per misurare le performance, utilizziamo il metodo currentTimeMillis() di System per ottenere gli istanti di inizio e di fine della procedura di lettura e parsing dei dati contenuti nel file e poi li sottraiamo in modo da calcolare il numero di millisecondi impiegati.
Di seguito il codice della prima soluzione:

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;

class Interval {

    private long start;
    private long end;

    public Interval(long start, long end) {

        this.start = start;
        this.end = end;
    }
}

class StatsRecord {

    private long   end;
    private long   duration;
    private int rate;

    public StatsRecord(long end, long duration, int rate) {

        this.end = end;
        this.duration = duration;
        this.rate = rate;
    }
}

public class ReadInputTest {

    List stats   = new ArrayList<>();
    List    queries = new ArrayList<>();

    public static void main(String[] args) {

        ReadInputTest streams = new ReadInputTest();

        long startTime = System.currentTimeMillis();

        int nLines = 0, nQueries = 0;
        int size = 0;
        String input = null;
        String[] tmp = null;
        try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream("C:\\Users\\MolinariD\\Desktop\\input2.txt")))) {

            input = br.readLine();
            nLines = Integer.parseInt(input);

            if ((nLines >= 1) && (nLines <= 100_000)) {

                for (int i = 0; i < nLines; i++) {
                    input = br.readLine();
                    tmp = input.split(" ");
                    StatsRecord sr = new StatsRecord(Long.parseLong(tmp[0]), Long.parseLong(tmp[1]), Integer.parseInt(tmp[2]));
                    streams.stats.add(sr);
                }

            }

            input = br.readLine();
            nQueries = Integer.parseInt(input);

            if ((nQueries >= 1) && (nQueries <= 100_000)) {

                tmp = null;
                for (int i = 0; i < nQueries; i++) {
                    input = br.readLine();
                    tmp = input.split(" ");
                    Interval in = new Interval(Long.parseLong(tmp[0]), Long.parseLong(tmp[1]));
                    streams.queries.add(in);
                }

            }

            System.out.println("Streams: " + streams.stats.size());
            System.out.println("Queries: " + streams.queries.size());

            long endTime = System.currentTimeMillis();
            System.out.println("Tempo di esecuzione:  " + ((endTime - startTime)) + " millisecondi");
        }
        catch (IOException | NumberFormatException e) {
            System.out.println("Errore nel processamento del file!");
        }
    }
}

Eseguendo il programma otteniamo un output di questo tipo:

Streams: 100000
Queries: 100000
Tempo di esecuzione:  159 millisecondi

Per prima cosa abbiamo stampato la dimensione dei due ArrayList utilizzati per contenere gli oggetti, in modo da verificare che tutte le righe siano state effettivamente lette ed i valori siano stati interpretati correttamente. Poi abbiamo stampato il tempo impiegato dal programma per leggere l'intero file.
Eseguendolo per 5 volte i risultati ottenuti sono stati i seguenti:

159 millisecondi
159 millisecondi
157 millisecondi
157 millisecondi
158 millisecondi

Passiamo ora ad analizzare la soluzione 2, ovvero quella basata sull'utilizzo della classe Scanner. Apriamo quindi lo Scanner, direttamente all'interno del costrutto try-with-resources di Java7, con l'istruzione

Scanner sc = new Scanner(new File("C:\\Users\\MolinariD\\Desktop\\input2.txt"))

ed invochiamo il metodo nextInt() per acquisire l'intero della prima riga. A questo punto, per ciascuna delle N righe successive, acquisiamo direttamente i valori utilizzando i metodi next relativi ai tipi che vogliamo leggere. Per ciascuna riga della prima tipologia abbiamo visto che ci aspettiamo due long ed un int, per cui li leggiamo invocando sullo Scanner per due volte il metodo nextLong() ed una volta il metodo nextInt(). I valori letti li utilizziamo direttamente nella chiamata al costruttore della classe StatsRecord per istanziare gli oggetti. Al termine dell N righe leggiamo il nuovo valore intero e ripetiamo l'operazione di acquisizione delle M righe successive.
In questo caso, come abbiamo detto in apertura, supponiamo che i valori che leggiamo non siano soggetti ad errori e che quindi troviamo sempre dati della tipologia che ci aspettiamo quando effettuiamo un'acquisizione con i metodi next dello Scanner. Il corretto utilizzo, in realtà, consisterebbe nel testare che i token che stiamo per leggere siano effettivamente di quel tipo, con i metodi hasNextInt(), hasNextLong(), ecc..
Nel caso in cui un token letto fosse di un tipo diverso da quello del metodo utilizzato, ad esempio fosse una stringa alfanumerica e la stessimo leggendo con nextInt(), verrebbe scatenata una InputMismatchException.
Di seguito vediamo il codice della soluzione 2, dove anche questa volta inseriamo le istruzioni per la stampa del numero di oggetti creati e del tempo di esecuzione impiegato.

import java.io.File;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.InputMismatchException;
import java.util.List;
import java.util.Scanner;

class Interval {

	private long start;
	private long end;

	public Interval(long start, long end) {

		this.start = start;
		this.end = end;
	}
}

class StatsRecord {

	private long end;
	private long duration;
	private int rate;

	public StatsRecord(long end, long duration, int rate) {

		this.end = end;
		this.duration = duration;
		this.rate = rate;
	}
}

public class ReadInputTest {

	List stats = new ArrayList<>();
	List queries = new ArrayList<>();

	public static void main(String[] args) {

		ReadInputTest streams = new ReadInputTest();

		long startTime = System.currentTimeMillis();

		int nLines = 0, nQueries = 0;
		int size = 0;
		String input = null;
		String[] tmp = null;

		try (Scanner sc = new Scanner(new File("C:\\Users\\MolinariD\\Desktop\\input2.txt"))){
			
			nLines = sc.nextInt();

			if ((nLines >= 1) && (nLines <= 100_000)) {

				for (int i = 0; i < nLines; i++) {
					StatsRecord sr = new StatsRecord(sc.nextLong(), sc.nextLong(), sc.nextInt());
					streams.stats.add(sr);
				}
			}

			nQueries = sc.nextInt();

			if ((nQueries >= 1) && (nQueries <= 100_000)) {

				tmp = null;
				for (int i = 0; i < nQueries; i++) {
					Interval in = new Interval(sc.nextLong(), sc.nextLong());
					streams.queries.add(in);
				}
			}

			System.out.println("Streams: " + streams.stats.size());
			System.out.println("Queries: " + streams.queries.size());

			long endTime = System.currentTimeMillis();
			System.out.println("Tempo di esecuzione:  " + ((endTime - startTime)) + " millisecondi");
			
		} 
		catch (InputMismatchException | FileNotFoundException e) {
                        System.out.println("Errore nel processamento del file!");
		}
	}
}

Eseguendo il programma otteniamo il risultato seguente:

Streams: 100000
Queries: 100000
Tempo di esecuzione:  801 millisecondi

Anche questa volta il numero di oggetti creati è corretto, il che significa che la lettura di tutte le righe è andata a buon fine e non ci sono stati problemi sulle tipologie dei token letti e nelle conversioni.
Il tempo impiegato da questa soluzione con lo Scanner è però di 801 millisecondi, contro i 159 millisecondi della soluzione basata sul BufferedReader.
Eseguendo anche in questo caso 5 run, otteniamo i tempi seguenti:

801 millisecondi
788 millisecondi
789 millisecondi
800 millisecondi
794 millisecondi

Da questo test sulla lettura di un file di testo con 200mila righe, composte da 2 o 3 token ciascuna e con i token di diversi datatype, emerge che la soluzione basata sul BufferedReader con lo split e le conversioni di tipo eseguite "a mano" resta significativamente più efficiente della soluzione basata sullo Scanner con la gestione del parsing dei dati automatica.

Le classi ed il file di esempio da 200k linee utilizzato come test sono scaricabili qui:

2 thoughts on “Java I/O: BufferedReader e FileInputStream VS. Scanner, confronto su lettura e parsing di un file da 200k linee

  1. Per curiosità sarebbe interessante fare anche dei test usando altre tecniche per il parsing, come ad esempio l’uso delle classi Pattern e Matcher.

    • Il metodo split() della classe String internamente utilizza Pattern per suddividere la stringa nei vari tokens. Matcher in questo caso non ci serve in quanto non dobbiamo ricercare le occorrenze di sequenze particolari ma prendiamo tutto l’input e le uniche elaborazioni che facciamo sono split e conversioni di tipo. In fondo il focus è più incentrato sulla lettura del file che sull’interpretazione dei dati. Detto questo si, si potrebbe complicare a piacere la struttura del file e del problema e fare altre prove interessanti.

Leave a Reply to unnikked Cancel reply

Your email address will not be published. Required fields are marked *