Java Language Piège - Les petites lectures / écritures sur les flux non tamponnés sont inefficaces


Exemple

Considérez le code suivant pour copier un fichier vers un autre:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new FileInputStream(args[0]);
             OutputStream os = new FileOutputStream(args[1])) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

(Nous avons délibérément omis de vérifier les arguments normaux, de signaler les erreurs, etc., car ils ne sont pas pertinents pour le point de cet exemple.)

Si vous compilez le code ci-dessus et l'utilisez pour copier un fichier volumineux, vous remarquerez qu'il est très lent. En fait, il sera au moins deux fois plus lent que les utilitaires de copie de fichiers standard.

( Ajouter des mesures de performances réelles ici! )

La principale raison pour laquelle l'exemple ci-dessus est lent (dans le cas des fichiers volumineux) est qu'il effectue des lectures d'un octet et des écritures d'un octet sur les flux d'octets sans tampon. La manière simple d'améliorer les performances consiste à envelopper les flux avec des flux tamponnés. Par exemple:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new BufferedInputStream(
                     new FileInputStream(args[0]));
             OutputStream os = new BufferedOutputStream(
                     new FileOutputStream(args[1]))) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

Ces petits changements amélioreront le taux de copie des données d’ au moins deux ordres de grandeur, en fonction de divers facteurs liés à la plate-forme. Les wrappers de flux en mémoire tampon entraînent la lecture et l'écriture des données en gros morceaux. Les instances ont toutes deux des tampons implémentés en tant que tableaux d'octets.

  • Avec is , les données sont lues quelques kilo - octets à la fois du fichier dans la mémoire tampon. Lorsque read() est appelée, l'implémentation retourne généralement un octet du tampon. Il ne lira que dans le flux d'entrée sous-jacent si le tampon a été vidé.

  • Le comportement de os est analogue. Les appels à os.write(int) écrivent des octets simples dans le tampon. Les données ne sont écrites dans le flux de sortie que lorsque le tampon est plein ou lorsque os est vidé ou fermé.

Qu'en est-il des flux basés sur des caractères?

Comme vous devez le savoir, Java I / O fournit différentes API pour lire et écrire des données binaires et textuelles.

  • InputStream et OutputStream sont les API de base pour les E / S binaires basées sur les flux
  • Reader et Writer sont les API de base pour les E / S de texte basées sur les flux.

Pour le texte I / O, BufferedReader et BufferedWriter sont les équivalents de BufferedInputStream et BufferedOutputStream .

Pourquoi les flux tamponnés font-ils autant de différence?

La véritable raison pour laquelle les flux mis en mémoire tampon aident les performances est liée à la manière dont une application communique avec le système d'exploitation:

  • La méthode Java dans une application Java ou les appels de procédure natifs dans les bibliothèques d'exécution natives de la JVM sont rapides. Ils prennent généralement quelques instructions de la machine et ont un impact minimal sur les performances.

  • En revanche, les appels d'exécution JVM au système d'exploitation ne sont pas rapides. Ils impliquent quelque chose appelé un "syscall". Le schéma type d'un appel système est le suivant:

    1. Placez les arguments syscall dans des registres.
    2. Exécutez une instruction d'interruption SYSENTER.
    3. Le gestionnaire d'interruptions passe à l'état privilégié et modifie les mappages de mémoire virtuelle. Ensuite, il envoie au code pour gérer l'appel système spécifique.
    4. Le gestionnaire syscall vérifie les arguments en veillant à ne pas avoir accès à la mémoire que le processus utilisateur ne doit pas voir.
    5. Le travail spécifique à l'appel système est effectué. Dans le cas d'un appel système en read , cela peut impliquer:
      1. vérifier qu'il y a des données à lire à la position actuelle du descripteur de fichier
      2. appeler le gestionnaire de système de fichiers pour qu'il récupère les données requises sur le disque (ou partout où il est stocké) dans le cache tampon,
      3. copier des données du cache tampon vers l'adresse fournie par la JVM
      4. ajuster la position du descripteur de fichier pointé thstream
    6. Revenez de l'appel système. Cela implique de modifier à nouveau les mappages de VM et de sortir de l'état privilégié.

Comme vous pouvez l'imaginer, exécuter un seul appel système peut contenir des milliers d'instructions de machine. De manière conservatrice, au moins deux ordres de grandeur plus longs qu'un appel de méthode régulier. (Probablement trois ou plus.)

Compte tenu de cela, la raison pour laquelle les flux en mémoire tampon font une grande différence est qu'ils réduisent considérablement le nombre d'appels système. Au lieu de faire un appel système pour chaque appel read() , le flux d'entrée en mémoire tampon lit une grande quantité de données dans un tampon, selon les besoins. La plupart des appels read() sur le flux en mémoire tampon effectuent des vérifications simples et renvoient un byte lu précédemment. Un raisonnement similaire s'applique dans le cas du flux de sortie, ainsi que dans les cas de flux de caractères.

(Certaines personnes pensent que les performances d'E / S mises en mémoire tampon proviennent de l'incompatibilité entre la taille de la requête de lecture et la taille d'un bloc de disque, la latence de rotation des disques et d'autres facteurs. l'application n'a généralement pas besoin d'attendre le disque, ce n'est pas la vraie explication.

Les flux tamponnés sont-ils toujours une victoire?

Pas toujours. Les flux en mémoire tampon sont certainement une victoire si votre application va faire beaucoup de "petites" lectures ou écritures. Cependant, si votre application n'a besoin que d'effectuer des lectures ou des écritures importantes sur / à partir d'un grand byte[] ou char[] , alors les flux mis en mémoire tampon ne vous apporteront aucun avantage réel. En effet, il pourrait même y avoir une pénalité de performance (minuscule).

Est-ce le moyen le plus rapide de copier un fichier en Java?

Non ce n'est pas Lorsque vous utilisez les API basées sur les flux Java pour copier un fichier, vous devez assumer le coût d'au moins une copie de la mémoire vers la mémoire supplémentaire des données. Il est possible d'éviter cela si vous utilisez les ByteBuffer NIO ByteBuffer et Channel . ( Ajouter un lien vers un exemple séparé ici. )