Revolution in Java's I/O Libraries

Network servers (and particularly game servers) must handle a large number of simultaneous clients. They must be capable of efficiently reading and writing a huge volume of messages to and from those clients. Before the release of the JDK 1.4, Java did not have a built-in mechanism for handling nonblocking I/O operations. Server apps were forced to have at least one thread per connected client, sitting there waiting on a blocking socket read() method. The drawbacks of this are fairly obvious, but let's enumerate a few of them. Operating systems have limits on the number of threads, either just by policy or intrinsically due to performance of the scheduler. Also, there is a minimum stack memory overhead for each thread, time associated with thread creation, and the overhead of saving and restoring thread state during context switches. All of these are good reasons to want to keep the number of threads to a minimum. When you need to serve hundreds or thousands of simultaneous users, one thread per user just won't cut it. Fortunately, and only after significant pressure from the Java community, Sun introduced a new I/O system with the release of JDK 1.4. These New I/O (NIO) APIs reside in the java.nio package and its subpackages java.nio.channels and java.nio.charset.

Overview of the JDK 1.4 NIO Libraries

Java's NIO packages consist of a number of new classes designed specifically to provide a high-performance I/O system, as has long been available in C and other languages. In addition to the new classes, several of the original networking classes (such as java.net.Socket) were updated to cooperate with the new APIs. Java's NIO classes fall into several categories: channels, buffers, and selectors. It is important to get comfortable with these classes because they are used to form the foundation of our apps. Let's start with an overview of these classes and then move on to an example of their use in an app.

Channels

Channels are abstract representations of operating system resources, such as files, pipes, and, most important for our discussion, network sockets. Channels and buffers replace the functionality that Java's Stream classes provided in the earlier JDKs for dealing with network and file resources.

Interfaces

At the root of all channel classes is the java.nio.channel.Channel interface, which contains only two method prototypes:

public void close();
public boolean isOpen();


The Channel interface is extended by a number of subinterfaces. The first two of interest are ReadableByteChannel and WritableByteChannel, which add the following two methods:

public int read(ByteBuffer dst);
public int write(ByteBuffer src);


The first is used to read data from the channel into the given buffer, and the second is used to write data to the channel from the given buffer. The ByteChannel interface merely combines ReadableByteChannel and WritableByteChannel into a single interface. GatheringByteChannel and ScatteringByteChannel add the capability to write from or read to multiple buffers in a single operation. This is useful when reading a message's header and payload into two separate buffers. For instance, if you were reading HTTP post responses, you might want the HTTP header in one buffer and the POST data in another. The method signatures are shown here:

long write(ByteBuffer[] srcs);
long write(ByteBuffer[] srcs, long offset, long length);
long read(ByteBuffer[] dsts);
long read(ByteBuffer[] dsts, long offset, long length);


The return values from the write methods are the number of bytes actually written, which can be 0. The read methods return the number of bytes read, or -1 to indicate end-of-stream. In many situations when the socket connection is terminated, this -1 return value is your first indication of failure.

Classes

The following is a brief description of the classes in the java.nio.channels package that are of interest for server programming. For further details and descriptions of the remaining classes, see the API documentation.

ServerSocketChannel

This class is used in place of (and wraps an underlying) java.net.ServerSocket. You'll use a ServerSocketChannel for accepting incoming client socket connections. A new ServerSocketChannel is created by invoking the static open() method. To put it into action, however, you must first configure its blocking mode and then bind it to an address and port. A SelectableChannel can be in either blocking or nonblocking mode. If set to blocking, all I/O operations on the channel block until they complete. In nonblocking mode, I/O operations return immediately, possibly without actually performing any I/O. A ServerSocketChannel defaults to blocking operation. As such, the ServerSocketChannel.accept() method acts just like the old ServerSocket.accept(), blocking the current thread until a connection is made. Here is a quick example of setting up a ServerSocketChannel for nonblocking operations:

// open a ServerSocketChannel and configure for non-blocking mode ServerSocketChannel sSockChan;
sSockChan = ServerSocketChannel.open();
sSockChan.configureBlocking(false);
// bind to localhost on designated port InetAddress addr = InetAddress.getLocalHost();
sSockChan.socket().bind(new InetSocketAddress(addr, PORT));


Later, in the ChatterBox sample app, you'll see how to register the channel with a selector and start accepting connections. Access to the ServerSocketChannel's underlying ServerSocket object is achieved via the socket() method:

public ServerSocket socket();


One additional method of note is the validOps() method, inherited from SelectableChannel, which returns the operations for which the channel has been registered. For ServerSocketChannels, this always returns SelectionKey.OP_ACCEPT. Refer to the java.nio.channels.SelectionKey API documentation for further discussion of socket operations.

SocketChannel

A SocketChannel is used to represent each individual client's socket connection to the server. Similar to ServerSocketChannel, SocketChannel replaces and wraps an underlying java.net.Socket object. NOTE ServerSocketChannel and SocketChannel are for TCP connections only; for UDP connections, see the description of DatagramChannel in the next section.


SocketChannel has all the methods of ServerSocketChannel, plus additional methods for reading and writing, and for methods for managing the connection of the socket. On the server, sockets are opened when you accept a connection from your ServerSocketChannel; on the client side, you use either the open(SocketAddress address) method or the open() method followed by a call to connect(SocketAddress address). We'll use SocketChannels for all of the read and write operations between the client and server, as you'll see later in this chapter.

DatagramChannel

This class is used to send and receive data using the User Datagram Protocol (UDP) instead of TCP. The User Datagram Protocol is defined in RFC 768 (www.faqs.org/ftp/rfc/rfc768.txt). As opposed to TCP, UDP is an unreliable protocol. This means that there is no guarantee that any packet sent will actually arrive at the destination, nor that packets sent will arrive in the same order in which they were sent. Most of the discussion in this chapter covers TCP because it is the easiest to deal with, and most of our game messages need to be reliably delivered for the game to function correctly. UDP has its place, though, and can be very useful when a large number of events need to be sent very rapidly and you don't care whether they all make it. For example, in a multi-player FPS-style game, the position of each player must be continuously communicated to other players. The delay involved in TCP transmissions would be unacceptable for this task, especially in the hostile network conditions common on the Internet, where packets routinely are dropped and need to be resent. With UDP, however, you can blast the location data out and if the server gets packets that are out of order, it can simply dump them in the bit bucket and continue on because the player has already moved on, so to speak. Additionally, UDP packets are much smaller than TCP packets and, thus, make more efficient use of UDP bandwidth.

Buffers

A buffer is a finite block of memory used for temporary storage of primitive data types. You'll use buffers to hold data as it is moved in and out of SocketChannels.

Classes

The java.nio package is home to a number of buffer classes, one for each of Java's primitive data types:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer (not to be confused with double-buffering, explained in , "2D Graphics and Animation")
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

Additionally, there is a special buffer class for memory-mapped files called MappedByteBuffer, which is useful when handling large files. A FileChannel's map() method is used to attach the buffer to the file. All of the buffer classes belong to the java.nio package and derive from the abstract base class Buffer. Buffer defines methods for querying and manipulating the capacity, position, limit, and mark of the buffer:

  • The capacity is the number of elements of the given primitive type that it can contain and is set at creation time using the allocate() methods of the subclasses. We discuss buffer allocation when we get to the specifics of ByteBuffers later.
  • The limit is the roadblock for reading from and writing to the buffer. When reading, it is the index of the first element that cannot be read. When writing, it is the index of the first element that cannot be written.
  • The position is, of course, the current position in the buffer, where the next element will be read or written.
  • The mark is used for moving the position back to a set point when the reset() method is called.

These indicators are shown in Screenshot, in which the buffer is in a random valid state.

Screenshot Buffer with mark, position, limit, and capacity indicated.

Java graphics 06fig01.gif


Filling and Draining

Buffers can be filled either by reading from a channel or with the various put() methods defined in the subclasses. For example, if you wanted to read a number of bytes length at offset o from a file into a ByteBuffer, you would use the FileChannel.read(ByteBuffer src, int o, int length) method. Buffers can be drained either by writing from the buffer to a channel or with the various get() methods defined in the subclasses. For example, to write data to a SocketChannel from a ByteBuffer, you would call the SocketChannel.write(ByteBuffer src) method. Remember that buffers are of fixed capacity, so they don't actually grow and shrink when filling or draining. Instead, only the aforementioned indicators (position, limit, and mark) are altered.

Manipulating Buffers

You should familiarize yourself with a few key methods to work with buffers:

  • clear()
  • flip()
  • rewind()
  • compact()

To prepare a buffer for filling, the clear() method is called, which sets the position to 0 and the limit to the capacity, as seen in Screenshot. Thus, following the clear() call, the buffer is ready to accept capacity elements from a channel read or put() operation.

Screenshot The buffer after calling Buffer.clear().

Java graphics 06fig02.gif


After a buffer is full (or at least contains as much data as required), the flip() method can be called to prepare the buffer for draining with a get() or channel write() operation. Flip() sets the limit to the current position and sets the position to 0. Screenshot shows a buffer after data has been placed in it with a put() or channel write() and then shows the same buffer after calling flip().

Screenshot The buffer after calling Buffer.flip().

Java graphics 06fig03.gif


Buffer.rewind() simply resets the position to 0, preparing the buffer for another draining, assuming it has already been flipped. This would be used, for example, to write some data to both a socket and a file, or to write the same data to multiple client channels. This scenario is depicted in Screenshot.

Screenshot The buffer after calling Buffer.rewind().

Java graphics 06fig04.gif


The compact() method moves the elements between the current position and the limit to the beginning of the buffer. The position is left at the end of the data after the move, and the limit is set to the capacity. We'll use this method later in the server, so keep an eye out for it.

Direct Versus Nondirect

One last thing to understand about Java's buffers is that they can be either direct or nondirect. Direct buffers are allocated as a native data structure that can transfer data to or from a native resource such as a socket without having to copy the data into a data structure inside the Java VM. This can have enormous positive impact on performance. Likewise, when allocating buffers that will not interact with native resources, a nondirect buffer should be used. Other than allocation using either allocate() or allocateDirect(), there is no difference in the API when using a direct or nondirect buffer.

Selectors and SelectionKeys

One of the main features of the NIO libraries is the capability to perform nonblocking read and write operations on sockets. To take advantage of this, however, you need a way to handle multiple connections in an organized fashion. Selectors are the key to this, providing a mechanism for multiplexing access to the channels. Multiplexing is a method of combining multiple communication signals into a single data stream by interleaving the signals in either the frequency or the time domain. In the case of channels and selectors, the selector listens for incoming data from all registered channels and presents it serially through the results of the select() method. This way, you need to talk to only a single Selector object to find out when any of the channels have data ready. Channels are first registered with a selector, along with a set of operations that the selector should watch for, using the SelectableChannel.register() method. The operations are OP_ACCEPT, OP_CONNECT, OP_READ, and OP_WRITE; they are defined as constants in the SelectionKey class. NOTE It is okay to use a single selector for both the ServerSocketChannel and client SocketChannels, to listen for new connections and channels with data ready to read from a single source. However, in practice, we will separate the two types for clarity of the design.


When you are ready to look for activity on your registered channels, call one of the select methods of the selector. Three versions of the select method exist:

  • select() Blocks until at least one channel has activity in the interest set. The interest set is the set of operations (any of OP_ACCEPT, OP_CONNECT, OP_READ, or OP_WRITE) that the selector will watch for on the channel.
  • select(long timeout) Same as select(), but blocks for only a maximum of timeout milliseconds.
  • selectNow() Returns immediately, regardless of how many channels are ready—possibly zero.

When a select method is called, the operating system is queried about the registered channels, and any that have activity have their SelectionKeys added to the Selector's set of selected keys. The select methods all return an integer to indicate the number of selected keys, or 0 if none is ready. After a select call, the ready SelectionKeys can be retrieved by calling Selector.selectedKeys(), which returns a Set. SelectionKeys are little more than holders for the channels they are linked to, but they provide one important feature that we'll use throughout your code. Each SelectionKey can hold on to any object as an attachment. We will use the attachment simply to store partially read messages from each channel, but you might find other good uses for it as well. Attachments can be provided when the channel is registered with the selector, or later using SelectionKey.attach(), and are retrieved using the attachment() method. One other method of the Selector class bears mentioning: wakeup(). The wakeup() method is one of several ways to break a selector out of a blocking select() call. The other ways are either to call Thread.interrupt() on the blocked thread or to call the selector's close() method.

Screenshot


   
Comments