Programmation Java
Java I/O et NIO.2 : flux, Readers, Path, Files, channels et buffers
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Java propose deux API de fichiers qui cohabitent : les flux et readers historiques de java.io, et l'API NIO.2 Path/Files qui a remplacé java.io.File. Ce guide explique le modèle des flux d'octets et de caractères et leurs décorateurs, comment lire et écrire des fichiers avec Files, comment parcourir, surveiller et copier des répertoires, quand les channels, les buffers ou les fichiers mappés en mémoire en valent la peine, et où l'encodage et la gestion des ressources tournent mal. Vous saurez choisir la bonne API et l'utiliser sans laisser fuir de descripteurs ni corrompre de texte.
Ce que vous allez apprendre
- Expliquer la différence entre flux d'octets, flux de caractères et décorateurs de mise en tampon
- Lire, écrire, copier, déplacer et supprimer des fichiers avec java.nio.file.Files et Path
- Parcourir des arborescences et réagir aux changements du système de fichiers avec Files.walk et WatchService
- Gérer explicitement les encodages de caractères et éviter les surprises du charset par défaut de la plateforme
- Déterminer quand les channels, ByteBuffer et les fichiers mappés en mémoire justifient leur complexité
Lire et écrire des fichiers, c'est là qu'un programme rencontre le système d'exploitation, et le JDK expose cette frontière à travers deux générations d'API. java.io (1996) vous donne les flux et les readers : composables, simples, et toujours à la base de toutes les bibliothèques réseau et d'archives. java.nio.file, dit NIO.2 (Java 7), vous donne Path et Files, un vrai modèle du système de fichiers avec attributs, liens, parcours de répertoires et notification des changements. Le code moderne utilise Files pour les fichiers et les abstractions de flux pour tout ce qui circule. Ce guide couvre les deux, avec les règles d'encodage et de gestion des ressources qui distinguent des E/S robustes d'un code qui marche jusqu'au jour où il rencontre un vrai système de fichiers. Le code cible Java 21.
Le modèle de flux de java.io
java.io comporte deux hiérarchies parallèles. Les flux d'octets (InputStream, OutputStream) transportent des octets bruts et sont l'outil adapté aux données binaires. Les flux de caractères (Reader, Writer) transportent du texte décodé. Le pont entre les deux est un charset : InputStreamReader décode des octets en caractères, OutputStreamWriter encode dans l'autre sens.
Chaque hiérarchie a ses sources et destinations (FileInputStream, ByteArrayOutputStream, Socket.getInputStream()) et ses décorateurs, qui enveloppent un autre flux pour lui ajouter un comportement. C'est le pattern Décorateur dans son habitat naturel :
| Décorateur | Apporte |
|---|---|
BufferedInputStream / BufferedReader | Un tampon en mémoire, pour appeler l'OS une fois par bloc au lieu d'une fois par octet ; BufferedReader ajoute readLine() |
DataInputStream / DataOutputStream | Les types primitifs dans un format binaire portable |
ObjectInputStream / ObjectOutputStream | La sérialisation Java (à éviter pour tout ce qui franchit une frontière de confiance) |
GZIPInputStream / InflaterInputStream | La décompression |
PrintWriter / PrintStream | La sortie de texte formaté avec println et printf |
try (InputStream raw = new FileInputStream("archive.gz");
InputStream gunzip = new GZIPInputStream(raw);
Reader decoded = new InputStreamReader(gunzip, StandardCharsets.UTF_8);
BufferedReader lines = new BufferedReader(decoded)) {
String line;
while ((line = lines.readLine()) != null) {
process(line);
}
}
En lisant de droite à gauche : un fichier, décompressé, décodé en UTF-8, mis en tampon, livré ligne par ligne. Fermer le flux le plus externe ferme toute la chaîne. Lire un FileInputStream octet par octet sans tampon est l'une des façons classiques de faire passer Java pour lent ; un tampon de 8 Ko (la valeur par défaut) règle le problème.
La méthode read(byte[]) renvoie le nombre d'octets réellement lus, qui peut être inférieur à celui demandé, et -1 en fin de flux. Un code qui ignore cette valeur de retour est faux ; en pratique, utilisez readAllBytes(), readNBytes(n) ou transferTo(out) (Java 9), qui gèrent la boucle à votre place.
Path : une adresse immuable dans le système de fichiers
Un Path est une valeur qui décrit un emplacement, lequel n'a pas besoin d'exister. On le crée avec Path.of("data", "in.csv") (Java 11) ou Paths.get(...), et on le manipule avec des méthodes qui renvoient de nouveaux chemins :
Path base = Path.of("/var/app");
Path config = base.resolve("conf/app.yml"); // /var/app/conf/app.yml
Path parent = config.getParent(); // /var/app/conf
Path name = config.getFileName(); // app.yml
Path rel = base.relativize(config); // conf/app.yml
Path clean = Path.of("/var/app/../log/./x").normalize(); // /var/log/x
Path abs = Path.of("x").toAbsolutePath(); // resolved against the working directory
Path real = config.toRealPath(); // follows symlinks; throws if it does not exist
resolve assemble les chemins ; relativize est son inverse ; normalize supprime . et .. de façon purement lexicale (sans toucher au disque) ; toRealPath interroge le système de fichiers. Path implémente Comparable et Iterable<Path> sur ses éléments de nom. Les séparateurs sont gérés par le FileSystem auquel appartient le chemin, si bien que le même code tourne sous Windows et sous Linux : Path.of("a", "b") est l'écriture portable.
Ne construisez jamais de chemins par concaténation de chaînes à partir d'une saisie utilisateur. base.resolve(userInput).normalize(), suivi d'une vérification que le résultat satisfait toujours startsWith(base), est la défense minimale contre une traversée du type ../../etc/passwd.
Files : les opérations de tous les jours
Files est une classe de méthodes statiques qui opèrent sur des Path. Elle lève une IOException (généralement un sous-type précis : NoSuchFileException, AccessDeniedException, FileAlreadyExistsException) au lieu de renvoyer false, et elle couvre presque tout ce que l'ancien File ne savait pas faire.
Path file = Path.of("report.txt");
// Whole-file text (Java 11); always UTF-8 unless you pass another charset
String text = Files.readString(file);
Files.writeString(file, "hello\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
// Whole-file lines and bytes
List<String> lines = Files.readAllLines(file);
byte[] bytes = Files.readAllBytes(file);
Files.write(file, List.of("a", "b"));
// Streaming, for large files: closes the file only when the stream is closed
try (Stream<String> stream = Files.lines(file)) {
long errors = stream.filter(l -> l.contains("ERROR")).count();
}
// Buffered reader/writer, when you want the classic loop
try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
w.write("first line");
w.newLine();
}
// Metadata and existence
boolean exists = Files.exists(file);
long size = Files.size(file);
Instant modified = Files.getLastModifiedTime(file).toInstant();
boolean dir = Files.isDirectory(file);
// Structure
Files.createDirectories(Path.of("out/reports")); // mkdir -p, no error if present
Files.copy(file, Path.of("backup.txt"), StandardCopyOption.REPLACE_EXISTING);
Files.move(file, Path.of("archive/report.txt"), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(Path.of("tmp.txt"));
Path temp = Files.createTempFile("upload-", ".bin");
StandardOpenOption contrôle la façon dont un fichier est ouvert : CREATE, CREATE_NEW (échoue si le fichier existe), APPEND, TRUNCATE_EXISTING, WRITE. Par défaut, les méthodes d'écriture créent le fichier ou le tronquent. ATOMIC_MOVE garantit que le renommage se fait en tout ou rien sur un même système de fichiers, et c'est ainsi qu'on publie un fichier en toute sécurité : écrire sous un nom temporaire, puis déplacer.
Préférez les méthodes de Files à la construction directe de FileInputStream. Elles choisissent des tailles de tampon raisonnables, traitent les charsets de manière uniforme, et newInputStream/newOutputStream sont là quand une autre API exige un flux brut.
Attributs et liens
Files.readAttributes(path, BasicFileAttributes.class) renvoie la taille, les horodatages et le type en un seul appel système, ce qui compte quand on analyse des milliers de fichiers. PosixFileAttributes ajoute le propriétaire, le groupe et les bits de permission sous Unix ; Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-r-----")) les modifie. Les liens symboliques sont suivis par défaut ; passez LinkOption.NOFOLLOW_LINKS pour inspecter le lien lui-même, et utilisez Files.createSymbolicLink et readSymbolicLink pour les gérer.
Répertoires : lister, parcourir, surveiller
Files.list(dir) produit un flux des enfants directs ; Files.walk(dir) parcourt toute l'arborescence en profondeur ; Files.find(dir, depth, matcher) filtre avec les attributs sous la main. Tous trois renvoient un Stream<Path> adossé à un descripteur de répertoire ouvert : enveloppez-les dans un try-with-resources.
try (Stream<Path> paths = Files.walk(Path.of("src"))) {
List<Path> javaFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.toList();
}
// Glob matching, the shell way
PathMatcher logs = FileSystems.getDefault().getPathMatcher("glob:**/*.log");
Files.walkFileTree(start, visitor) en est la forme plus ancienne et plus contrôlable : un FileVisitor reçoit des callbacks avant et après chaque répertoire, pour chaque fichier et pour chaque échec, peut sauter des sous-arborescences, et c'est le bon outil pour une suppression ou une copie récursive où les erreurs doivent être traitées fichier par fichier.
WatchService émet des événements quand des entrées d'un répertoire enregistré sont créées, modifiées ou supprimées. C'est la base du « recharger la configuration quand le fichier change » et des intégrations par dépôt de fichiers :
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
Path inbox = Path.of("inbox");
inbox.register(watcher, StandardWatchEventKinds.ENTRY_CREATE);
while (true) {
WatchKey key = watcher.take(); // blocks until an event
for (WatchEvent<?> event : key.pollEvents()) {
Path created = inbox.resolve((Path) event.context());
handleNewFile(created);
}
if (!key.reset()) break; // directory no longer accessible
}
}
La spécification signale deux limites : l'enregistrement se fait par répertoire et n'est pas récursif (parcourez l'arborescence et enregistrez chaque sous-répertoire), et l'implémentation macOS a longtemps fonctionné par polling plutôt qu'avec des événements natifs, si bien que la latence s'y compte en secondes plutôt qu'en millisecondes. Un événement ENTRY_CREATE peut aussi arriver avant que le processus qui écrit ait terminé ; attendez un ENTRY_MODIFY associé, ou que la taille cesse de changer, avant de traiter le fichier.
Encodages de caractères
Le texte est fait d'octets et d'un charset, et se tromper de charset produit du mojibake ou une exception loin de la cause réelle. Jusqu'à Java 17, toute API appelée sans charset utilisait le charset par défaut de la plateforme : UTF-8 sous Linux, Windows-1252 sur beaucoup de machines Windows. Le même JAR se comportait donc différemment d'un serveur à l'autre. La JEP 400 a fait d'UTF-8 la valeur par défaut en Java 18 pour new FileReader, new InputStreamReader(in), String.getBytes(), Scanner et les autres API sans charset (les méthodes texte de Files utilisaient déjà UTF-8 par défaut). Deux exceptions subsistent : System.out/System.err et Console utilisent l'encodage du terminal, et l'on peut encore passer -Dfile.encoding=COMPAT pour retrouver l'ancien comportement.
Soyez explicite malgré tout. Écrire StandardCharsets.UTF_8 ne coûte rien et rend l'intention visible pour le lecteur. Pour les fichiers qui arrivent dans d'autres encodages, nommez-les : Files.newBufferedReader(path, Charset.forName("windows-1252")). Quand vous décodez des octets invalides pour le charset, le comportement par défaut est de les remplacer par U+FFFD ; configurez un CharsetDecoder avec CodingErrorAction.REPORT si vous préférez échouer.
Un détail lié : String.length() compte les unités de code UTF-16, pas les caractères, si bien qu'un emoji a une longueur de 2. Utilisez codePointCount quand la différence compte, et l'outil Base64 ou HexFormat (Java 17) quand vous devez examiner des octets bruts pendant un débogage.
Channels, buffers et fichiers mappés en mémoire
java.nio (Java 1.4) a introduit les channels, des connexions bidirectionnelles vers un fichier ou une socket, et les buffers, des conteneurs de taille fixe dotés d'une position, d'une limite et d'une capacité. Un FileChannel sait faire ce que les flux ne savent pas : lire et écrire à une position arbitraire, verrouiller des régions, transférer directement vers un autre channel et mapper le fichier en mémoire.
try (FileChannel channel = FileChannel.open(Path.of("data.bin"), StandardOpenOption.READ)) {
ByteBuffer header = ByteBuffer.allocate(16);
channel.read(header, 0); // positional read, does not move the channel position
header.flip(); // switch from writing into the buffer to reading from it
int magic = header.getInt();
long recordCount = header.getLong();
// Map the rest for random access without copying into the heap
MappedByteBuffer data = channel.map(FileChannel.MapMode.READ_ONLY, 16, channel.size() - 16);
byte first = data.get(0);
}
// Zero-copy file to socket or file to file
try (FileChannel in = FileChannel.open(src); FileChannel out = FileChannel.open(dst, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
long position = 0, size = in.size();
while (position < size) position += in.transferTo(position, size - position, out);
}
Le ballet flip()/clear()/compact() sur ByteBuffer, c'est le nid à bugs : oublier flip() après avoir rempli un buffer revient à lire des données parasites en fin de buffer. Les buffers directs (allocateDirect) vivent hors du heap et évitent une copie lors des E/S, au prix d'une allocation plus lente ; réservez-les aux buffers à longue durée de vie, pas à un buffer par requête. Les fichiers mappés en mémoire sont excellents pour de gros jeux de données surtout lus, et désastreux pour les petits fichiers (le coût du mappage l'emporte), et il n'existe aucun moyen portable de démapper un fichier avant que le buffer ne soit ramassé par le garbage collector.
Les sockets non bloquantes avec Selector font aussi partie de java.nio, mais le code applicatif y touche rarement : Netty, le HttpClient du JDK et tous les frameworks serveur gèrent cette couche. Pour les fichiers, AsynchronousFileChannel existe mais n'apporte pas grand-chose par rapport à un pool de threads ; et avec les virtual threads, de simples E/S fichier bloquantes sur un virtual thread sont la manière la plus simple d'obtenir de la concurrence.
Gestion des ressources et des erreurs
Chaque fichier, channel, flux de répertoire ou flux Files.lines ouvert détient un descripteur du système d'exploitation. Les processus ont une limite (souvent 1 024 ou 65 536 sous Linux), et une fuite d'un descripteur par requête se transforme en IOException: Too many open files quelques heures après le déploiement. La règle est mécanique : tout ce qui implémente AutoCloseable va dans un try-with-resources, comme l'explique le guide sur les exceptions. Les outils d'analyse statique signalent l'oubli ; activez cette règle.
IOException est checked, et la plupart des appelants n'ont aucun moyen de se remettre de l'erreur. Traduisez-la à votre frontière en UncheckedIOException avec le chemin dans le message, new UncheckedIOException("Cannot read " + path, e), pour que le log dise quel fichier a échoué. Files lève déjà des sous-types dont le message contient le chemin ; conservez-le quand vous enveloppez l'exception.
Les écritures partielles sont l'autre mode de défaillance. Un crash en pleine écriture laisse un fichier tronqué que l'exécution suivante lira sans broncher. Le pattern des écritures durables : écrire dans un fichier temporaire du même répertoire, appeler force(true) sur le channel ou faire un fsync, puis Files.move avec ATOMIC_MOVE. Les lecteurs voient soit l'ancien fichier, soit le nouveau complet.
Erreurs courantes
Lire octet par octet sans tampon. FileInputStream.read() dans une boucle fait un appel système par octet. Ajoutez un tampon ou utilisez readAllBytes.
Ignorer la valeur de retour de read(byte[]). Elle peut ne remplir qu'une partie du tableau. Bouclez, ou utilisez readNBytes/transferTo.
Files.lines, list, walk sans try-with-resources. Ils gardent le répertoire ou le fichier ouvert jusqu'à leur fermeture ; dans un service qui tourne longtemps, c'est une fuite de descripteurs.
Les charsets par défaut. Jusqu'à Java 17 inclus, new FileReader(f) utilise ce que dit l'OS. Passez StandardCharsets.UTF_8 partout, et ajoutez -Dfile.encoding=UTF-8 sur les anciennes JVM.
File.delete() qui renvoie false en silence. Utilisez Files.delete, qui lève une exception en donnant la raison.
Des chemins construits par concaténation. dir + "/" + name casse sous Windows et ouvre la porte aux traversées de répertoires. Utilisez resolve et validez par rapport à la base.
Lire d'un bloc un fichier potentiellement énorme. readAllBytes sur un log de 4 Go, c'est une OutOfMemoryError. Traitez-le en flux.
La sérialisation Java pour la persistance ou le transport. Fragile d'une version à l'autre et vecteur bien connu d'exécution de code à distance. Utilisez JSON, Protobuf ou une base de données.
Points clés à retenir
- Les flux d'octets transportent des données binaires, les flux de caractères du texte décodé, et des décorateurs comme
BufferedReaderouGZIPInputStreamse composent autour ; fermez le plus externe dans un try-with-resources. - Utilisez
PathetFilespour tout travail sur le système de fichiers ;java.io.Fileest une API héritée dont les échecs n'expliquent rien. Files.readString,writeString,lines,walk,copy,moveetcreateDirectoriescouvrent l'essentiel des besoins ; unATOMIC_MOVEaprès écriture dans un fichier temporaire donne des mises à jour résistantes aux crashs.- Les flux renvoyés par
Files.lines,listetwalkdétiennent des descripteurs : fermez-les toujours. - Nommez le charset explicitement, même si UTF-8 est la valeur par défaut depuis Java 18.
- Les channels et
ByteBufferjustifient leur complexité pour l'accès positionnel, le verrouillage, le mappage en mémoire et le transfert zero-copy ; pour tout le reste, les méthodes deFilessont plus simples et bien assez rapides.
Sources et lectures complémentaires
Questions fréquentes
- Faut-il utiliser java.io.File ou java.nio.file.Path ?
- Path et Files, pour tout nouveau code. Les méthodes de File renvoient false en cas d'échec sans dire pourquoi, gèrent mal les liens symboliques et les attributs de fichier, et ne savent ni parcourir ni surveiller un répertoire. Path est une valeur immuable qui connaît son système de fichiers, et Files lève des IOException explicites. Convertissez avec file.toPath() quand une bibliothèque vous fournit encore un File.
- Quelle différence entre un InputStream et un Reader ?
- Un InputStream fournit des octets bruts ; un Reader fournit des caractères décodés. Le texte doit être décodé avec un charset, et c'est précisément le rôle d'InputStreamReader entre les deux. Utilisez les flux pour les données binaires comme les images, les archives ou les charges utiles de protocoles, et les readers pour le texte, en nommant toujours le charset.
- Files.lines, est-ce la même chose que Files.readAllLines ?
- Non. readAllLines charge tout le fichier dans une List<String> ; lines renvoie un Stream<String> paresseux qui lit à la demande et doit être fermé par un try-with-resources, car il garde le fichier ouvert. Prenez lines pour les gros fichiers ou quand vous vous arrêtez en cours de route, readAllLines pour les petits fichiers dont vous avez de toute façon besoin en mémoire.
- Quand ai-je besoin des channels et de ByteBuffer plutôt que des flux ?
- Rarement. Les channels offrent l'accès aléatoire, le verrouillage de fichiers, le mappage en mémoire, le scatter/gather et transferTo, et servent de socle aux E/S réseau non bloquantes. Pour lire et écrire des fichiers au quotidien, les méthodes utilitaires de Files et les flux bufferisés sont plus simples et tout aussi rapides. Sortez FileChannel quand il vous faut des lectures positionnelles, le mmap d'un gros fichier ou un transfert zero-copy entre channels.