Friday, October 21, 2022

Curly Braces #4: Network data transmission and compression in Java


I find that writing messaging middleware allows me to build robust distributed enterprise software systems. Once I even built my own Java Message Service (JMS) implementation and learned a lot about Java network I/O and related performance.

One important lesson I learned is high-performance messaging software requires more than fast, efficient code. You also need a strong fundamental understanding of networking—and knowledge about the I/O limitations of the systems you run on. For example, when data is transmitted between distributed components, there comes a point where even the fastest code waits on network I/O—what’s called being I/O bound. Figure 1 shows what I mean.

Oracle Java, Java Exam, Java Exam Prep, Java Tutorial and Material, Java Guides, Java Certification, Java Prep, Java Preparation, Java Learning

Figure 1. An I/O-bound thread

Before getting into data compression as one possible solution to addressing I/O delays, I’ll review some basics of Java network programming.

Java network programming


The foundation for Java networking is the combination of the java.net.Socket and java.net.ServerSocket classes. In short, Socket is for client code, while ServerSocket is for servers, which clients connect to.

As with most Java I/O programming, data is exchanged through a combination of the java.io.InputStream and java.io.OutputStream classes. Note that the use of server and client classes doesn’t dictate data direction. A Java server application can sit and listen for a client to send it data after connecting, or the server can serve data to a client that just listens. And of course, data can flow both ways between the two in a request and response paradigm, as with a web browser and server.

Here is a sample implementation of ServerSocket that waits for a client to connect. A client can be any application, regardless of implementation language, that connects to the correct IP address and port.

try {
        ServerSocket clientConnect = new ServerSocket(8080);
        Socket client = clientConnect.accept(); // blocking call!
        InputStream instream = client.getInputStream();
        DataInputStream dis = new DataInputStream( instream );
        while ( true ) {
            String msg = dis.readUTF();
            System.out.println("Message: " + msg);
        }
    }
    catch ( Exception e ) {
        e.printStackTrace();
    }

The code above creates a ServerSocket that listens on port 8080 on the host it runs on. The call to accept() blocks and waits until a network client connects on the listening port, at which point a Socket connection to the client is returned.

In this implementation, the server listens for String messages and outputs them to the command line. To do this, the client’s InputStream is passed to the DataInputStream constructor to instantiate a listener. The subsequent call to readUTF blocks until a String message arrives in its entirety.

Here’s the simplified client code that connects to the server and sends a String message.

try {
        Socket sender = new Socket("localhost", 8080);
        if ( sender.isConnected() ) {
            DataOutputStream outputStream =
                    new DataOutputStream( conn.getOutputStream() );
            
            outputStream.writeUTF("I love Oracle Java Magazine!");
        }
    }
    catch ( Exception e ) {
        e.printStackTrace();
    }

At this point, it’s important to know the expected application-level protocol. In the example above, Java String data is sent over the network. However, other Java Object data can be serialized and sent over the network using ObjectOutputStream and ObjectInputStream classes, as follows:

try {
        Socket sender = new Socket("localhost", 8080);
        if ( sender.isConnected() ) {
            ObjectOutputStream oos = 
              new ObjectOutputStream( 
                new BufferedOutputStream( sender.getOutputStream() ));
                    
            MyObject myObj = new MyObject();
            myObj.message = "I love Java!";
            myObj.messageId = getMessageId();
            // ...
            oos.writeObject( myObj );
            oos.flush();
        }
    }
    catch ( Exception e ) {
        e.printStackTrace();
    }

The listener on the other side connects as shown earlier, but it makes the blocking call wait for a serialized Java object to be returned.

ObjectInputStream ois =
    new ObjectInputStream( 
        new BufferedInputStream( client.getInputStream() ));
                
    MyObject myObject = (MyObject)ois.readObject();

Again, the protocol here is that both client and server agree to send serialized instances of MyObject objects over the network. The use of buffered I/O—using the BufferedOutputStream object—generally improves performance because the JVM efficiently handles the assembly of bytes into an Object internally.

Let’s talk about performance. My experience shows that as an application spends more time sending data over the network, CPU utilization will decrease, which means tuning your network application on a faster server won’t do much good. Instead, you need to improve your network I/O. A server with faster I/O capabilities might help, but that too will become saturated. You need to improve the design, and that means improving the code.

One improvement is to compress the data before it’s sent using a lossless algorithm (so you get back the original bytes). If you have an I/O-bound server, you can afford to spend some CPU processing time compressing the data, which will result in reduced network I/O.

By the way, this is one reason why web servers typically transmit images in compressed formats such as JPEG, because the picture consumes less I/O and bandwidth. When JPEG is used, however, the compression is lossy, so the uncompressed image is not precisely the same as the original. Lossy compression is fine for casual website viewing but is not acceptable for data processing.

Compressing the bytes

The JDK java.util.zip package provides classes for compressing and decompressing data, creating .zip and .gzip files, and much more. For this project, the appropriate classes are Deflater and Inflater, which compress and decompress bytes respectively. I’ll start by choosing the following compression algorithm:

Deflater compressor = new Deflater(Deflater.BEST_SPEED);

This compression algorithm prioritizes speed of execution—which uses minimal CPU resources but also results in less compression, that is, a larger output file. If you want as much compression as possible, which may require more processing time to compress the bytes, use Deflater.BEST_COMPRESSION. These compression options are part of a range you can use to balance the compression-to-speed ratio depending on your application, data type, data size, or other factors; you can see them all here in the “Field Summary” section.

Here is a sample sender that uses data compression.

DataOutputStream dos = 
    new DataOutputStream( conn.getOutputStream() );
byte[] bytes = messageTxt.getBytes("UTF-8");

// Compress the bytes
Deflater compressor = new Deflater(Deflater.BEST_SPEED);
compressor.setInput(bytes);
compressor.finish();
byte[] compressed = new byte[bytes.length];
length = compressor.deflate(compressed);

// Send the compressed data 
dos.write(compressed, 0, length);
dos.flush();

The code begins in a straightforward fashion, with a DataOutputStream and some message text. Assume the message is long, so there are many bytes to transmit.

Then it creates a Deflater set for best processing speed. The example above calls set Input to add bytes, and then it calls the finish() method. The class can also work with data streams. The subsequent call to deflate() compresses the bytes into the provided array, and the new (smaller) length is returned. Finally, the compressed bytes are sent over the network.

In one test application, I created messages of around 100 KB, and they each compressed down to just over 500 bytes. This is a significant savings in terms of network I/O time and bandwidth!

The following code reads and decompresses the bytes on the receiving end:

// Read the bytes
DataInputStream dis = new DataInputStream( instream );
byte[] compressed = new byte[ dis.available() ];
dis.readFully(compressed);

// Decompress the bytes
Inflater decompressor = new Inflater();
decompressor.setInput(compressed);
byte[] msgBytes = new byte[DEFAULT_SIZE];
decompressor.inflate(msgBytes);

String msg = new String(msgBytes);
System.out.println(msg);

First, a byte array is created to store the incoming bytes. Next, the Inflater class is used. The setInput() method is called to provide the compressed bytes, and then inflate() is called to decompress the bytes into the provided array. The resulting bytes are used to re-create the original string.

Adding flexibility and predictability

The process above works fine, but I have ideas for two improvements. The first is to add flexibility to compress the data only when that makes sense and not when it’s unnecessary. The second is to transmit the size of the byte array required for decompressing the string.

In my opinion, using getRemaining() and other Inflater methods to read the data in chunks is inefficient and complicated. I find it’s best to send both the uncompressed and compressed data sizes as int values in the data stream itself. In other words, the bits that arrive look like what’s shown in Table 1.

Table 1. Starting and ending bit sizes

Oracle Java, Java Exam, Java Exam Prep, Java Tutorial and Material, Java Guides, Java Certification, Java Prep, Java Preparation, Java Learning

Determining the sizes allows you to provide runtime flexibility in terms of compressing data only under the right conditions. For example, you can base your compression decisions on message size; if it’s only a few bytes, you don’t need to bother.

The enhanced sender code looks like the following:

DataOutputStream dos = 
    new DataOutputStream( conn.getOutputStream() );
byte[] bytes = messageTxt.getBytes("UTF-8");

// Write the original message length
int length = bytes.length;
dos.writeInt(length);

if ( length > LENGTH_THRESHOLD ) {
    // Compress the bytes
    Deflater compressor = new Deflater(Deflater.BEST_SPEED);
    compressor.setInput(bytes);
    compressor.finish();
    byte[] compressed = new byte[bytes.length];
    length = compressor.deflate(compressed);
}
else {
    compressed = bytes;
}

// Write the length again. If it was compressed, the
// sizes will vary, and this is the indicator that
// the data needs to be decompressed by the receiver
dos.writeInt(length);

// Write the data bytes
dos.write(compressed, 0, length);
dos.flush();

Of course, the receiver needs to change as well. The updated code is shown below.

DataInputStream dis = new DataInputStream( instream );

// Get the length of the next message
int msgSize = dis.readInt();

// Get the compressed size (if it's compressed
// this size will vary from the size above)
int compressedSize = dis.readInt();

// Read the bytes
byte[] compressed = new byte[compressedSize];
dis.readFully(compressed);

byte[] msgBytes = compressed;
if (compressedSize != msgSize) {
    // Decompress the bytes
    Inflater decompressor = new Inflater();
    decompressor.setInput(compressed);
    msgBytes = new byte[DEFAULT_SIZE];
    decompressor.inflate(msgBytes);
}

String msg = new String(msgBytes);
System.out.println(msg);

As you can see, the changes are minimal, but the result is very flexible and efficient code to decidedly and deterministically compress data to reduce I/O and network overhead when optimization criteria are met.

Source: oracle.com

Related Posts

0 comments:

Post a Comment