Bug della Java Virtual Machine

Non mi ero mai imbattuto in un bug della Java Virtual Machine fino alla scorsa estate. Stavo collaudando l’applicazione RouterLogger, la quale basa il suo funzionamento su un ciclo di richieste di rete che può essere infinito oppure limitato ad un numero di iterazioni configurabile. La prima implementazione prevedeva un ciclo quasi infinito:

private void loop() throws IOException, InterruptedException {

    /* Determinazione numero di iterazioni... */
    int iterations = configuration.getInt("logger.iterations", -1);
    if (iterations <= 0) {
        iterations = Integer.MAX_VALUE;
    }

    for (int iteration = 1; iteration <= iterations && !exit; iteration++) {
        /* Richiesta di rete... */

        if (iteration != iterations) {
            /* Thread.sleep(waitTimeInMillis); */
        }
    }
}

Nel caso in cui la proprietà di configurazione logger.iterations fosse stata impostata ad un valore negativo (tipicamente -1), il programma avrebbe impostato come numero di iterazioni da eseguire Integer.MAX_VALUE (ossia 2.147.483.647), determinando un ciclo praticamente infinito considerando che, nel caso di RouterLogger, un’iterazione dura normalmente qualche secondo.

Ebbene, eseguendo un test con logger.iterations=-1, notavo che il loop terminava dopo un certo numero di iterazioni (intorno a sedicimila), sempre lo stesso, e senza alcun errore o eccezione, come se la condizione di permanenza nel ciclo iteration <= iterations && !exit non fosse più verificata, cosa evidentemente falsa. Controllato il codice e verificatane la correttezza, iniziavo a non capire cosa stesse succedendo. La confusione è aumentata quando ho notato che facendo girare l’applicazione direttamente da Eclipse, quindi utilizzando il compilatore integrato in Eclipse (JDT) invece di quello del JDK 6u45, il problema non si presentava affatto. Decidevo quindi fare il reverse engineering dei due compilati (.class) per scongiurare un eventuale difetto del compilatore del JDK, ma anche quest’indagine non portava da nessuna parte: i due bytecode, pur essendo differenti, non presentavano anomalie. Tanto per curiosità, pubblico il bytecode generato prima con il JDK e poi con Eclipse, da me reinterpretato in C per maggiore chiarezza:

JDK 6u45:

14:  int iterations = 2147483647;
20:  int iteration = 0;
22:  if (iteration > iterations) goto 152;
28:  if (exit) goto 152;
35:  /* Richiesta di rete... */
62:  if (iteration == iterations) goto 146;
143: /* Thread.sleep(waitTimeInMillis); */
146: iteration++;
149: goto 22;
152: return;

Eclipse JDT:

18:  int iterations = 2147483647;
22:  int iteration = 0;
24:  goto 144;
27:  /* Richiesta di rete... */
54:  if (iteration == iterations) goto 141;
138: /* Thread.sleep(waitTimeInMillis); */
141: iteration++;
144: if (iteration > iterations) goto 157;
154: if (!exit) goto 27;
157: return;

Seppur diversi, entrambi i codici sono logicamente corretti. Escluso un problema legato al compilatore, resta quasi solo l’ipotesi di un bug della JVM. Effettivamente aggiornando il JRE alla versione 7u80, il problema scompariva del tutto, indipendentemente dal compilatore utilizzato, tuttavia mi faceva piacere mantenere la compatibilità con il JRE 6. Inoltre la cosa è inquietante perché il codice è di una semplicità unica e il comportamento della JVM risulta totalmente illogico.

Dopo alcune ricerche, ho individuato che esiste effettivamente un bug della JVM (JDK-5091921) legato ad un’ottimizzazione che viene eseguita a runtime. In pratica, a un certo momento non ben precisato durante l’esecuzione del programma, la JVM decide di ottimizzare il loop modificando la condizione di permanenza nel seguente modo: i <= j diventa i < j + 1, probabilmente perché per il processore è più semplice eseguire un controllo di minoranza stretta; la cosa non sarebbe un problema se non fosse che, nel caso j = Integer.MAX_VALUE, la somma Integer.MAX_VALUE + 1, a causa di un overflow, risulti in un bel numerone negativo (per la precisione Integer.MIN_VALUE ossia -2.147.483.648). Per questo motivo l’indice dell’iterazione, qualsiasi esso fosse, diventava immediatamente maggiore di -2.147.483.648, e il loop terminava come se niente fosse e senza alcun errore.

Per rendere il mio codice compatibile con il JRE 6, ho deciso di modificarlo rimuovendo il riferimento ad Integer.MAX_VALUE, pervenendo tra l’altro ad una soluzione più semplice e pulita:

private void loop() throws IOException, InterruptedException {

    /* Determinazione numero di iterazioni... */
    int iterations = configuration.getInt("logger.iterations", -1);

    for (int iteration = 1; (iterations <= 0 || iteration <= iterations) && !exit; iteration++) {
        /* Richiesta di rete... */

        if (iteration != iterations) {
            /* Thread.sleep(waitTimeInMillis); */
        }
    }
}

Questo bug, scoperto nel 2004 e risegnalato numerose volte alla Sun (acquisita nel frattempo da Oracle nel 2010), era stato classificato a bassa priorità (no comment), ed è stato risolto solo nel 2011 con l’aggiunta di un controllo sull’overflow. In ogni caso, per maggiore sicurezza, conviene prestare particolare attenzione quando si ha a che fare con i MAX_VALUE.

Il massacro delle date in linguaggio C

Il C ha ormai più di quarant’anni sulle spalle, ciò nonostante è ancora molto utilizzato in alcuni ambiti, devo dire sempre più ristretti. Nel corso dei decenni, ANSI e ISO hanno anche redatto delle specifiche al fine di rendere standard il linguaggio e garantirne la portabilità, e così abbiamo i vari C89/C90, C95, C99 e C11.

Coloro che hanno esperienza di programmazione potranno confermare che i tipi di dato utilizzati nella stragrande maggioranza delle applicazioni sono tre: numerico, testo e data. Consideriamo ad esempio la seguente struttura:

struct paziente {
    char cognome[100];
    char nome[100];
    char codiceFiscale[17];
    int altezza;
    float peso;
    time_t dataNascita;
};

Non era certo nelle intenzioni di Kernighan e Ritchie rendere il C adatto alla realizzazione di applicazioni gestionali; si tratta piuttosto di un linguaggio pensato per la scrittura di sistemi operativi, BIOS, driver e programmi per microcontrollori. Nulla vieta comunque di utilizzarlo per scrivere qualsiasi genere di programma.

Per quanto riguarda il trattamento dei numeri, il C non è male, tra short, int, long, float, double (e anche char), sia signed che unsigned, non c’è che l’imbarazzo della scelta; inoltre, per le operazioni più complesse è presente la libreria <math.h>.
Le strighe sono già più fastidiose, ma una volta compresi i meccanismi che le governano, non sono un problema; le librerie <string.h> e <ctype.h>, poi, forniscono un buon numero di funzioni per la loro manipolazione.

Le date, come vedremo, sono semplicemente un incubo.
Nella libreria <time.h> esiste il tipo time_t che non è altro che un intero a 32 bit con segno, il che significa che può assumere valori compresi tra -2.147.483.648 e 2.147.483.647. Come fa un numero di questo genere a rappresentare una data? Semplice: si tratta del numero di secondi trascorsi dalla mezzanotte del 01/01/1970; quest’ultima data è anche detta epoca (epoch). È chiaro che un valore del genere risulta tutt’altro che intellegibile, per cui tipicamente occorre convertirlo in una data del tipo dd/MM/yyyy HH:mm:ss. La libreria <time.h> include una struttura che risulta molto utile per questa conversione; è definita come segue:

/* A structure for storing all kinds of useful information about the current (or another) time. */
struct tm {
    int tm_sec;   /* Seconds: 0-59 (K&R says 0-61?) */
    int tm_min;   /* Minutes: 0-59 */
    int tm_hour;  /* Hours since midnight: 0-23 */
    int tm_mday;  /* Day of the month: 1-31 */
    int tm_mon;   /* Months *since* january: 0-11 */
    int tm_year;  /* Years since 1900 */
    int tm_wday;  /* Days since Sunday (0-6) */
    int tm_yday;  /* Days since Jan. 1: 0-365 */
    int tm_isdst; /* +1 Daylight Savings Time, 0 No DST, -1 don't know */
};

Come si nota, il fatto di esprimere il mese a cominciare da zero ha radici molto lontane, visto che lo ritroviamo ancora oggi nel Calendar di Java, ciò però rende semplici le operazioni sulle date. Orribile è invece la formula utilizzata per il campo tm_year.

Per convertire un valore di tipo time_t in questa struttura, si possono utilizzare le funzioni struct tm * localtime (const time_t *time) (tiene conto del fuso orario) e struct tm * gmtime (const time_t *time) (ora di Greenwich) incluse in <time.h>.
La seguente funzione, ad esempio, prende come parametro un valore time_t e restituisce una stringa contenente la relativa data/ora formattata:

char* formatDateTime(const time_t mytime) {
    char* dateTimeStr = malloc(20 * sizeof(char));
    struct tm date = *localtime(&mytime);
    sprintf(dateTimeStr, "%.2d/%.2d/%4d %.2d:%.2d:%.2d", date.tm_mday, date.tm_mon + 1, date.tm_year + 1900, date.tm_hour, date.tm_min, date.tm_sec);
    return dateTimeStr;
}

Notare l’operatore di “dereferenziazione” (*) applicato alla chiamata alla funzione localtime, la quale restituisce un puntatore alla struttura. È importante sapere che tale struttura è statica, per cui chiamate successive a localtime o gmtime modificano sempre la stessa struttura; ecco perché non è il caso di tenersi il puntatore ma è opportuno dereferenziare e copiare la struttura statica in una struttura dichiarata da noi.

C’è poi un’altra operazione importante: determinare il giusto valore di time_t a partire da una data in formato umano; il caso tipico è quando prendiamo in ingresso una data digitata dall’utente. Questa operazione è possibile grazie alla funzione time_t mktime (struct tm *brokentime) di <time.h>:

time_t newDateTime(const int year, const int month, const int date, const int hrs, const int min, const int sec) {
    time_t time;
    struct tm tmStruct = { 0 };
    tmStruct.tm_mday = date;
    tmStruct.tm_mon = month - 1;
    tmStruct.tm_year = year - 1900;
    tmStruct.tm_hour = hrs;
    tmStruct.tm_min = min;
    tmStruct.tm_sec = sec;
    time = mktime(&tmStruct);
    if (time == -1) {
        printf("Data non supportata.");
        exit(1);
    }
    return time;
}

Un grande vantaggio della struct tm è che tramite essa è possibile manipolare facilmente le date, ad esempio aggiungendo o sottraendo giorni, mesi, anni, insomma le stesse cose che in Java si possono fare con il metodo add dell’oggetto Calendar. La funzione mktime provvede infatti a normalizzare i contenuti della struttura (ad esempio 32/01 diventa 01/02):

void modificaData() {
    struct tm tmStruct;
    time_t dataDaModificare;
    time_t dataModificata;

    dataDaModificare = newDateTime(1990, 11, 12, 14, 10, 55);
    printf("Data da modificare: %s\n", formatDateTime(dataDaModificare));

    tmStruct = *localtime(&dataDaModificare);
    tmStruct.tm_mon -= 104;
    dataModificata = mktime(&tmStruct);
    if (dataModificata == -1) {
        printf("Data non supportata.");
        exit(1);
    }
    printf("Data modificata: %s\n", formatDateTime(dataModificata));
}

Fin qui la situazione sembrerebbe più che accettabile, ma purtroppo non è così.
Il tipo time_t presenta un grande problema, talmente grave da essere stato citato addirittura da John Titor (si scherza, ovviamente), e conosciuto come bug dell’anno 2038. Dato l’intervallo di valori gestiti, il tipo time_t nella migliore delle ipotesi non può rappresentare date successive al 19/01/2038, né precedenti al 13/12/1901. Considerando inoltre che non tutti i sistemi accettano valori negativi per questo tipo, non è raro che non siano rappresentabili date precedenti al 01/01/1970; è evidente che in quest’ultimo caso, il tipo time_t non va bene nemmeno per rappresentare date di nascita di persone attualmente in vita.

Possibili soluzioni

Tra le possibili e più o meno retrocompatibili vie d’uscita, vi è l’adozione di un intero a 64 bit al posto di quello attuale; il progetto y2038 è basato proprio su quest’idea, e consiste in una nuova libreria "time64.h" che estende quella standard. È interessante come, nel caso di date che ricadano nell’intervallo sicuro (13/12/1901-19/01/2038), questa libreria deleghi tutte le operazioni all’originale <time.h>.
Purtroppo però non mancano i problemi: uno di essi è che le operazioni sulle date non vengono sempre eseguite come ci si aspetterebbe; in particolare, se si tenta di modificare una data che non ricade nell’intervallo sicuro operando sui campi della relativa struct tm, la nuova funzione mktime64 non aggiorna correttamente il campo dell’anno, ad esempio sommando 12 al campo dei mesi, l’anno non viene incrementato; in pratica l’effetto è simile a quello del metodo roll del Calendar di Java.
Altro grande problema è che l’algoritmo che verifica se una data ricade nell’intervallo sicuro non sempre opera correttamente: se ad esempio si parte da una data che effettivamente ricade nell’intervallo sicuro (ad es. 01/01/2038) e gli si somma un valore tale per cui il risultato ricadrà al di fuori dell’intervallo sicuro (ad es. aggiungendo 6 al campo tm_mon della struct tm relativa alla data di partenza), la libreria delega comunque le operazioni all’originale <time.h> che ovviamente fallisce.

In alternativa, è sempre possibile gestire la data e l’ora come valori interi separati (giorno, mese, anno, ore, minuti e secondi), magari sotto forma di struttura (la stessa struct tm potrebbe andare bene) senza passare per time_t; ma ancora una volta ciò rende complicate le operazioni sulle date, le quali devono essere implementate manualmente.

Articolo consigliato: Time, Clock, and Calendar Programming In C, di Eric S. Raymond.