Java I/O Streams and Buffered Readers and Writers
Move exact bytes with input and output streams, or decode characters when you intend to process text. Learn short reads, buffering, and ownership, then verify a binary copy separately from a line-numbering transformation.
Before you start: review text encoding and write policy, resource ownership, and array bounds. Every example is a complete Java 21 Main.java program with no input required.
What you will be able to do
- Distinguish byte streams from decoded text readers and writers.
- Handle -1, short reads, and the actual count returned by read.
- Copy bytes without decoding or writing unused buffer space.
- Process text incrementally with an explicit charset and line-ending policy.
- Explain buffering, flush, close, and borrowed-stream ownership.
- Verify binary identity separately from a deliberate text transformation.
Choose bytes for exact data and characters for text meaning
InputStream and OutputStream move bytes. Reader and Writer work with character data, with a charset bridge when bytes need decoding or encoding. An image, archive, or arbitrary binary file should not be decoded as text merely because text methods look convenient.
The single-byte read method returns an int in the range 0 through 255, or -1 when the stream has ended. Keeping that int distinguishes a real zero byte and a real 255 byte from the end marker. Casting to byte too early can confuse unsigned 255 with -1.
The following file examples use real disposable files inside the isolated Java container. They do not mount your project, persist course progress, or operate on valuable documents. Retain the freshly owned temporary paths if you copy the code to a local JDK.
Main.java · Byte values use int so EOF can be -1
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
0
255
-1
File streams open resources with a lifetime and a write policy
FileInputStream reads file bytes and FileOutputStream writes them. The example opens two distinct owned temporary files, loops over the input, and closes both streams before checking output. No charset is involved because the operation is an exact binary copy.
FileOutputStream without append mode can create a missing target or truncate an existing one. Opening the source and target as the same file can therefore destroy the source before copying begins. Our later helper checks same-file identity before opening the destination and requires an existing destination instead of silently creating one.
toFile is appropriate for these default-filesystem Paths, but not every custom Path provider supports it. Files.newInputStream and Files.newOutputStream offer Path-based alternatives. Stream APIs describe operations and ownership; they do not provide automatic backup, atomic copy, or rollback.
Main.java · File streams preserve binary data without a charset
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Copied bytes: 5
Exact copy: true
Use the actual read count, not the buffer capacity
read(buffer) returns how many bytes were placed into the array for that call. A read is not a promise to fill the array; near EOF or with other stream types, the count can be smaller. Only that returned prefix contains bytes from the current read.
Our five-byte input and four-byte buffer produce counts four and one. Writing the entire array after the second read would append stale bytes from the previous read as well. output.write(buffer, 0, count) writes exactly the valid portion and prevents that corruption.
Use -1 as the end marker for the read loop. available is an estimate of bytes readable without blocking, not a reliable file length or EOF test for general streams. The helper uses a positive bounded buffer so it never deliberately requests a zero-length read.
Main.java · A final read may fill only part of the buffer
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Read count: 4
Read count: 1
Trace the final partial read
- The four-byte array first receives 10, 20, 30, and 40.
- The next read returns 1 and puts 50 in position zero.
- The other three positions still contain old bytes.
- Writing offset zero/count one copies only 50; writing the whole array repeats stale data.
The next read returns -1. No extra output belongs to that end-of-stream marker.
Buffering reduces small underlying operations, not every cost
BufferedInputStream and BufferedOutputStream wrap byte streams; BufferedReader and BufferedWriter buffer characters. They can reduce repeated small requests to an underlying source or sink, but do not change whether data is binary or textual.
StreamTools.copyFiles uses byte buffering and a reusable 4096-byte application buffer. Its memory use does not grow with the complete file size. This is a useful default for the exercise, not proof that this exact buffer size is optimal for every device or that buffering always makes a program faster.
Line-oriented reading is incremental across lines, but one enormous line can still require a large String. Avoid claiming that readLine alone enforces a strict input memory bound. Production tools need explicit size, time, and cancellation policies for untrusted inputs.
Decode with a charset, then distinguish empty lines from EOF
InputStreamReader is the bridge from byte input to character input. The example explicitly decodes UTF-8 and wraps the reader for readLine. An empty line returns an empty String; EOF returns null. Stopping on an empty String would incorrectly drop valid lines after a blank.
readLine removes its line separator, so it is not an exact byte-copy operation. CRLF, CR, and LF boundaries may become equivalent logical lines. The final line can be returned without a terminator; a trailing terminator does not necessarily add an extra empty final record.
Decoder error policy also belongs to the chosen API. InputStreamReader constructed with a charset uses replacement for malformed input, while Files.newBufferedReader reports decoding problems. Use a configured CharsetDecoder when a different strictness policy is needed rather than assuming every text wrapper treats bad bytes identically.
Main.java · A reader decodes bytes before processing text
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
1: [Java]
2: []
3: [café]
Flush makes buffered work advance; close ends ownership
The buffered writer may retain characters before forwarding them through its encoder. flush pushes buffered work toward the underlying destination without closing the writer, so the example can append an exclamation mark afterward. It does not create a record separator.
Closing a BufferedWriter attempts to flush it and close the underlying writer. A successful body return can still be interrupted by a closing failure, as the previous resource lesson showed. Read a file back after its owning writer closes rather than assuming content is complete while buffered output is still active.
Neither flush nor close is a general promise of physical-storage durability or a transaction boundary. A ByteArrayOutputStream is just an in-memory observation target here; its behavior does not prove a network peer received data or a disk persisted it. newLine uses the platform separator, while our lab deliberately writes LF.
Main.java · Flush forwards buffered text without closing the writer
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
café
café!
A text transformation should say what it preserves and changes
numberLines decodes UTF-8, reads logical lines, prefixes a one-based number, and writes UTF-8 with an explicit LF after every output record. Blank input lines are retained as numbered empty lines rather than skipped.
This output intentionally changes separators and adds a terminator to a final unterminated line. It is therefore not an exact copy of the original text bytes. By contrast, copyFiles must preserve every byte, including bytes that are invalid as text.
The helper opens its reader and writer in one resource header, and closing is attempted in reverse order. If opening the destination fails after the source opened, the source is still closed. The output can be partial after a body or closing failure; this teaching transformer is not an atomic publishing system.
Main.java · Number logical lines with an explicit LF output policy
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Lines: 3
1: Java
2:
3: café
What does the text operation intentionally change?
It removes input line separators through readLine, adds a number prefix, preserves blank logical lines, and writes LF after each output line. It can add a final terminator that the source did not have. That is a defined transformation, not a byte-preserving copy.
A helper receiving streams must not silently claim their lifetime
copyBytes receives already-open streams and borrows them. It copies until EOF but does not close or flush them, allowing its caller to decide whether more data should follow and when the output should be finalized. The example appends another byte after the helper returns.
copyFiles instead opens its own streams from Paths and is responsible for closing them. Closing an outer buffered wrapper normally closes its underlying stream, so a clear outer-wrapper owner avoids duplicate or contradictory closing responsibilities.
ByteArrayOutputStream permits access after close, so its example alone is not sufficient evidence that every borrowed stream stays open. The automated checks use close-tracking wrappers too. Never infer a general lifetime rule from a special in-memory implementation, or close shared System.in or System.out just to imitate a file example.
Main.java · A borrowed-stream helper leaves lifetime to its caller
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Copied: 3
Caller added another byte: true
References can outlive the resource they refer to
The existing reader variable remains visible after try(reader), but the reader is closed. Reading again fails rather than reopening the text. A reference's lexical scope and the resource's usable lifetime are different concepts.
The file helper checks Files.isSameFile before opening a truncating output, catching equal paths and existing aliases such as hard links in our sequential workspace. That check is not a hardened defense against concurrent path replacement or hostile symbolic-link races. Supplied operations use only freshly owned local targets.
Expected I/O failure should remain visible. Do not catch every exception and announce that a copy completed, and do not treat a partial target as a valid report. Closing and cleanup errors need retained evidence rather than a silent fallback result.
Main.java · A visible reference does not mean an open reader
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Java
Reader is closed.
Verify exact binary copying separately from text processing
For binary copies, compare complete content with Files.mismatch and verify the count of copied bytes. Test empty input, zero and 255 bytes, data shorter than a buffer, exact buffer multiples, and partial final reads. A matching file length alone cannot prove that the bytes are correct.
For numbered text, test Unicode, blank lines, CRLF input, a final unterminated line, and empty input. Verify the documented LF output rather than comparing against the original source bytes. Also test malformed UTF-8 through the strict Files-based reader.
Finally verify ownership: borrowed helpers must not close or flush the caller's streams, owned wrappers must close on failure, and temporary files must be removed after every example. Keep same-file rejection and missing-file cases so a later refactor cannot silently turn an input into its own truncated output.
Repair exercise: a five-byte copy became eight bytes
The program copies five bytes through a four-byte buffer, but writes the whole array after every read. Explain the second read's count, then repair the write to use only valid bytes. Do not change the input or buffer size to hide the partial-read case.
Main.java · Find the stale buffer bytes
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
The starter runs but currently prints:
Bytes: 8
Exact: false
Reveal the explained repair
The second read places one new byte at the start of the array; the other positions still contain earlier data. Passing offset zero and the returned count copies only the current bytes, preserving both content and total length.
Main.java · Write only the returned prefix
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Bytes: 5
Exact: true
Independent lab: exact binary copy, then a numbered UTF-8 report
Build an exact binary copy and a separate UTF-8 line-numbering transformation. Use only owned temporary files, explicit resource scopes, and contract-specific verification. Do not claim the text transformation preserves original separators or that a successful copy is atomic durable storage.
- Implement OwnedFile with one private final Path from Files.createTempFile, a file() getter, and AutoCloseable cleanup that deletes only that owned target.
- Implement StreamTools.copyBytes(InputStream, OutputStream, int). Reject null streams with Streams must not be null. and buffer sizes outside 1..65536 with Buffer size must be between 1 and 65536., using IllegalArgumentException before copying. Loop until -1, write only offset 0/count, and return a long total. Borrow streams without closing or flushing them.
- Implement copyFiles(Path, Path) throws IOException. Reject null paths with Paths must not be null. and same-file identity with Source and target must be different files. Both paths must already exist. Open owned buffered byte streams, use WRITE plus TRUNCATE_EXISTING without CREATE for output, delegate to copyBytes with 4096, and close through try-with-resources.
- Implement numberLines(Path, Path) with the same path guard. Use Files.newBufferedReader and newBufferedWriter with explicit UTF-8, output WRITE/TRUNCATE_EXISTING, a null-terminated readLine loop, one-based long numbering, and an explicit LF after each output line. Return the number of logical lines.
- In main create three temporary files. Save UTF-8 Java plus an empty line plus café, using CRLF input and no final terminator. Copy the binary source exactly, verify copied byte count and Files.mismatch, then number the copied text into the report and print its LF-terminated output.
- Verify empty and partial reads, Unicode lines, invalid arguments, missing paths, same-file aliases, borrowed-stream ownership, and cleanup after failure. Separate binary identity from intentional text normalization and keep unrelated host files out of scope.
The source contains thirteen UTF-8 bytes. Verify byte identity first, then verify the deliberately transformed text:
Copied bytes: 13
Binary exact: true
Lines: 3
1: Java
2:
3: café
Main.java · Build the stream copy and report
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Stage 1 hints: implement a borrowed byte loop
Validate arguments before allocating or reading. Use a positive bounded byte array, read until -1, write only the returned count, and accumulate a long total. Do not close or flush streams supplied by the caller.
Stage 2 hints: own file wrappers explicitly
Check distinct existing file identities before opening a truncating output. Own buffered byte wrappers in try-with-resources, delegate to the byte helper, and verify with Files.mismatch after closing finishes.
Stage 3 hints: give text a separate contract
Use explicit UTF-8 buffered readers and writers. Stop readLine at null, not an empty String. Number every logical line and write explicit LF. Read the report only after its writer has closed, and compare with the intended transformed output.
Reveal the complete explained solution
Main.java · Complete byte copy and text transformation
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
The copy uses byte streams and retains the exact thirteen UTF-8 bytes, including CRLF separators. The numbered report uses a strict UTF-8 reader and an explicit LF writer, so its three logical lines are transformed intentionally. Closing the owned writer finishes output before the caller reads the report back; outer OwnedFile scopes remove the disposable files afterward.
Test changes and boundaries
- Copy byte arrays of sizes 0, 1, 3, 4, 5, 4096, and 4097 with several buffer sizes. Include zero and 255, forced short reads, and exact content comparisons rather than size-only checks.
- Use close/flush-tracking stream wrappers to verify copyBytes borrows lifetime. Test invalid stream and buffer arguments before any read or write.
- Verify empty text, blank lines, mixed separators, Unicode, and no final terminator. Expect the documented numbered LF output, not binary equality with the source.
- Reject the same existing file through equal paths or aliases before opening truncating output. Missing source/target paths and malformed UTF-8 must surface as failures, not invented empty reports.
- Trigger body failures and confirm owned temporary files are removed afterward. Preserve unrelated data and keep resource-close evidence rather than swallowing every IOException.
Common mistakes and how to diagnose them
Writing the entire buffer after a short read
Only the returned count identifies current data. Write buffer, offset zero, and count, not the entire array with leftover bytes.
Using available as a general EOF test
available estimates immediately readable bytes rather than total input length. Read until the API's actual EOF marker instead.
Decoding arbitrary binary data as text
Character decoding can replace or reject bytes and change content. Use byte streams for exact copies and text readers only when text interpretation is intended.
Opening a truncating output on the input file
The destination can destroy the source before copying begins. Guard distinct existing identities and use only controlled targets; a precheck is not a complete concurrent-filesystem security boundary.
Closing a borrowed caller-owned stream
A helper receiving streams should honor the ownership contract. This copy helper neither closes nor flushes them; the owner decides when buffered output is complete.
Treating flush as close or durable storage
Flush advances buffers but leaves the writer usable. Neither flush nor normal close automatically supplies physical-storage durability, rollback, or safe concurrent publication.
Frequently asked questions
When should I choose a byte stream instead of a reader?
Use bytes for exact content such as images, archives, or a binary copy. Use readers and writers when the operation deliberately interprets and transforms text with a defined charset.
Can read(buffer) return fewer bytes than buffer.length?
Yes. Only the returned prefix belongs to that read. The partial final buffer is a common case, and other stream sources can return short reads earlier too.
Is an empty line the end of a BufferedReader?
No. readLine returns an empty String for an empty logical line and null for EOF. Keep blank lines when the text contract requires them.
Does closing a wrapper also close its underlying stream?
The standard buffered wrappers used here normally propagate close to their underlying resource. Give that outer wrapper a clear owner and do not close shared streams without agreement.
Does flush make the data durable?
No. It advances buffered data toward the underlying destination without promising a transaction, disk durability, or remote receipt. Those guarantees require a separate storage or protocol design.
Are these Java streams the same as the Stream API?
No. InputStream and OutputStream are I/O abstractions for bytes. java.util.stream.Stream is a different API for processing elements; a file-backed element stream can also require closing, but it is not the byte-copy API taught here.
Copy bytes exactly; interpret text deliberately
A byte stream has counts and EOF; a text reader adds decoding and line semantics. Buffering changes how work is grouped, not the data contract. Keep lifetime with the owner, copy only valid read prefixes, and distinguish flushing from closing and durability.
Explain why the repaired byte loop returns five bytes, why blank lines are not EOF, and why a numbered LF report is not an exact source copy. Next: collections, generics, and wrapper classes, where typed containers hold groups of values safely.
Can you choose bytes or characters and keep ownership clear?
Answer six questions and read the explanations. Quiz answers are temporary and do not save course progress.
References and related learning
These examples, explanations, and exercises are original SovranCode material. The official Java 21 APIs document stream counts, decoding, buffering, and closing.
- Java 21 InputStream: read counts and EOF
- Java 21 BufferedReader: character and line reading
- Java 21 BufferedWriter: buffering, flush, and close
- Java 21 Files: buffered readers and writers
Review text files · Review resource lifetimes · Java course roadmap.
Last updated: