Byte Streams and Binary Data in Java
InputStreamReader and OutputStreamWriter wrap an existing byte stream so that a program can read and write characters instead of raw bytes.
Why Programs Need Characters Instead of Bytes
Java's byte streams move raw binary data, one byte at a time. That is a good fit for images, compressed archives, and other data whose meaning is not text. Many programs, however, care about text: names, chat messages, and the lines of a document. A program that handles text wants to work with characters, not with the raw numbers that happen to represent them.
The question this article answers is how a byte stream can be connected to a character stream, so that text can be read from and written to a source that only understands bytes. The short answer is that you wrap the byte stream. You hand an existing byte stream to a constructor and receive back a character stream that performs the conversion for you. The program then reads and writes characters, and the wrapper handles the bytes underneath.
Wrapping a Byte Stream
InputStreamReader and OutputStreamWriter are the two wrappers that bridge the gap. An InputStreamReader wraps an InputStream so that the program reads characters from it. An OutputStreamWriter wraps an OutputStream so that the program writes characters into it. In both cases the wrapper is the only place where the conversion between characters and bytes happens. The byte stream itself still moves bytes, and the program does not touch those bytes directly once the wrapper is in place.
Byte count: 5
Decoded: caféByteArrayOutputStreamis a byte stream that collects bytes in memoryOutputStreamWriterturns the characters written into it into bytes before they reach the byte streamByteArrayInputStreamis a byte stream that supplies the stored bytesInputStreamReaderturns those bytes back into characters for the program
Read the program line by line and notice what it never does. It never calls a method that handles individual bytes when it writes or reads the text. It calls write with a String and read to receive characters. The byte values appear only in the stored array, which the program prints as a length. The count of 5 is a clue: the four characters of café took five bytes to store, because the letter é needed more than one byte under UTF-8. The charset section below explains why.
Tracing a Read Through the Wrappers
When data is read, the flow runs from the byte source upward. Bytes come out of the byte stream, the InputStreamReader turns them into characters, and your code receives characters. The diagram below traces that path for a single read. Follow the arrows and note at which step the value stops being a byte and becomes a char.
Writing runs the other way. The program supplies characters, the OutputStreamWriter turns them into bytes, and the byte stream delivers those bytes to its destination. In both directions the wrapper sits between the program and the byte stream, and the program has no direct handle on the bytes once they are wrapped. Everything it sees passes through the character stream.
Create the byte stream first, then build the wrapper around it. The wrapper needs something to read from or write to, so the byte stream must exist before the wrapper is constructed.
Reading Standard Input as Text
System.in is the standard input stream, and its declared type is InputStream. That makes it a byte stream, which is an unusual arrangement for something a program reads from the keyboard as text. Because of that, it is common to wrap System.in in a Reader when you want to read character data from standard input. The wrapping is a single line of code.
(If you type a and press Enter, the first value read is 97, the code for the letter a.)System.inis passed in as the byte stream to wrap- read returns an int holding the character value, or -1 at end of input
The same pattern applies to network connections. The input and output streams tied to a network connection are byte streams, and wrapping them lets a program send and receive character data over the network without handling bytes directly. Network input and output is treated in a later section of the course, so this article focuses on the wrapping itself and on the charset that the wrapper relies on.
What a Charset Decides
A charset is a named encoding that decides how characters map to binary data. Given a character, the charset says which bytes represent it. Given bytes, the charset says which characters they stand for. UTF-16, UTF-8, and ISO-8859-1 are three examples of charsets.
The bytes produced by a writer are only meaningful if a reader interprets them with the same charset. The charset is therefore the missing piece in any wrapped stream. It is not a detail to ignore when data crosses from one program or machine to another, because the bytes alone do not say which characters they were meant to be.
The charset decides the size of each stored character. One character does not always equal one byte. A program that assumes it does will be wrong under some charsets.
Comparing UTF-8, UTF-16, and ISO-8859-1
The three charsets store the same character in different amounts of space. The diagram groups each charset into a column and shows the unit or units that represent one character. Read the columns from left to right to see how the amount of storage changes with the encoding. The letter é is a useful test case because it is a plain Latin letter with an accent, which every one of these charsets can represent.
The three columns show the same character taking different amounts of storage. Under ISO-8859-1 the letter é is one byte. Under UTF-8 it takes two bytes, which is why the earlier café example stored five bytes for four characters. Under UTF-16 the letter is also written in a two-byte unit, shown here in big-endian order. The lesson is not to memorize these byte values, but to see that the charset, not the character, determines how much space it takes.
| Charset | Example use | Storage for é | Key lesson |
|---|---|---|---|
| UTF-8 | Text shared between programs and machines | Two bytes | Characters can take a varying number of bytes |
| UTF-16 | Java strings in memory | Two-byte unit | Each common character uses a fixed two-byte unit |
| ISO-8859-1 | Western European single-byte text | One byte | One byte per character, but limited to its own repertoire |
Three charsets compared on how they store the same character
Choosing a Charset Explicitly
Readers and Writers use the default charset of the computer they run on unless you choose otherwise. You can name a different charset in the constructor. The wrapper in the demo passed the name of the charset as a second argument, which overrides the default for that stream only. The same two-argument form works for both wrappers.
- The first argument is the byte stream to wrap
- The second argument names the charset used for this stream only
- StandardCharsets.UTF_8 is a predefined charset constant
Specifying the charset is a choice you make at the point where the stream is created. Once the wrapper exists, it converts characters to bytes, or bytes to characters, using that charset. The choice is fixed for that stream, so you make it when you build the wrapper rather than later.
Default Charset Versus Explicit Charset
If you do not pass a charset, the wrapper uses the default charset of the machine the code runs on. The result then depends on that machine. Code that writes bytes on one computer and reads them on another can produce different text if the two defaults differ. This is the reason to name the charset whenever the bytes will be read somewhere else.
Locating a Charset Mismatch
A mismatch appears when the bytes were written with one charset and read with another. The reader does not know the bytes came from a different charset, so it interprets each byte using its own rules. The wrong text appears on the reading side, at the point where the reader's wrapper converts bytes to characters. The writing side may have produced perfectly correct bytes.
What do you think happens?
The text café is written with UTF-8 and read back with ISO-8859-1. What text does the reader produce?
Reveal answer
café
UTF-8 stores é as the two bytes C3 A9. ISO-8859-1 maps C3 to à and A9 to ©, so the reader sees four characters that look like café. The bytes were fine; only the reading charset was wrong.
café- The write side uses UTF-8, so é becomes two bytes
- The read side names a different charset, so the same two bytes become two characters
- The mismatch is at the reader's constructor, not in the write call
Mistakes Beginners Make with Charsets
Common mistake Assuming one character always equals one byte
What it looks likeStoring café as UTF-8 and assuming the stored length is 4
Why it failsThe charset decides how many bytes each character takes, and UTF-8 used two bytes for é
Do this insteadMeasure the bytes after encoding rather than counting characters
Common mistake Writing with one charset and reading with another
What it looks likeWriter uses UTF-8, reader uses ISO-8859-1, and the output shows café
Why it failsThe reader interprets the bytes with rules that did not produce them
Do this insteadUse the same charset constant on both the writing and reading wrappers
Common mistake Leaving out the charset and trusting the default
What it looks likenew InputStreamReader(in) on a machine with a different default than the writer
Why it failsThe default depends on the machine, so the bytes may decode differently elsewhere
Do this insteadName the charset explicitly whenever the bytes leave the current machine
Common mistake Building the wrapper before the byte stream exists
Why it failsThe wrapper needs a byte stream to read from or write to
Do this insteadCreate the byte stream first and pass it to the wrapper constructor
Practice: Trace the Round Trip
Write the word naïve using UTF-8 with an OutputStreamWriter into a ByteArrayOutputStream. Then read the stored bytes back using an InputStreamReader with UTF-8. Before running the code, predict the byte count and the decoded text. Next, change only the reader to ISO-8859-1 and predict what text appears. Explain at which line the mismatch happens and why the write side was not at fault.
Hints
- The letter ï is an accented letter like é, so think about how many bytes UTF-8 uses for it
- Only the reader's charset argument changes in the second run
- The bytes are created by the writer, so the mismatch is in the reading wrapper
Key Takeaways
InputStreamReaderandOutputStreamWriterwrap an existing byte stream so the program reads and writes characters while the wrapper performs the conversion to and from bytes.- On read, bytes flow from the byte source through the
InputStreamReaderinto characters; on write, characters flow through theOutputStreamWriterinto bytes. - A charset is a named encoding such as UTF-8, UTF-16, or ISO-8859-1, and it decides how many bytes each character occupies.
- Name the charset explicitly in the constructor, because the default depends on the machine and a mismatch between writing and reading produces wrong text on the reading side.