Java programming language

Basic client-server communications

First steps with Java sockets

  

1. Introduction to sockets

Java version note: The classic socket APIs used in this unit are still supported in current Java releases. The main examples use long-established APIs; the optional virtual-thread example in section 3 requires Java 21 or later.

A socket is an endpoint that allows two processes to exchange information through a network. The processes may run on the same computer or on different computers. In practice, a socket is associated with a network address and a port number, and the operating system uses this information to deliver data to the appropriate process.

1.1. Sockets and port numbers

A port number identifies a communication endpoint inside a machine. The combination of an IP address and a port number identifies one endpoint of a network communication.

For example, a server application may listen on TCP port 6000. When a packet addressed to that machine and port reaches the operating system, it can be delivered to the application that owns that socket.

Port numbers range from 0 to 65535. Ports from 0 to 1023 are known as well-known ports and are traditionally assigned to standard services. For our own classroom applications, we will normally choose a higher port that is not already being used by another application.

For instance, if a machine A has a process PA listening on port 6000, a remote process can contact it by using machine A’s IP address (or host name) and port 6000.

1.2. Client-server communication

Client-server communication is a common type of network communication where:

When a TCP client connects to a server, both sides establish a communication channel. The client can send data to the server, and the server can send data back to that particular client.

For instance, web servers commonly use port 80 for HTTP and port 443 for HTTPS. A browser normally connects to the server using its domain name, which is resolved to an IP address, and the corresponding service port.

The server normally listens on a known port, whereas the client’s local port is usually selected automatically by the operating system from an ephemeral port range.

For example, a client may connect from local port 52431 to a server listening on port 6000. The connection can then be identified by the two endpoints: client-address:52431 and server-address:6000.

When the server calls accept(), Java creates a new Socket object dedicated to that client. However, the accepted socket still uses the same local server port (for example, 6000). The listening ServerSocket remains available to accept more clients. Different TCP connections are distinguished by their combination of local and remote addresses and ports.

1.3. Socket types

The two transport protocols that we will mainly use are TCP and UDP:

Therefore, we normally choose TCP when reliable ordered delivery is required, and UDP when independent datagrams and low overhead are more appropriate for the application.

1.4. Common communication models in distributed applications

Distributed applications can use different communication models. Some of the most common are:

Model Main idea Typical example
Client-server One or more clients request a service from a server that listens on a known address and port. Web applications, database servers, multiplayer game servers
Peer-to-peer (P2P) Each participant can act as both client and server, communicating directly with other peers. File-sharing or decentralized applications
Unicast One sender communicates with one receiver. A normal TCP connection between one client and one server
Broadcast One sender sends a datagram to all hosts in a broadcast domain. Local network discovery protocols
Multicast One sender sends data to a group of interested receivers. Group notifications or multimedia distribution

We can also distinguish between request-response communication, where one side sends a request and waits for a response, and more asynchronous communication, where messages can arrive independently of a previous request. The appropriate model depends on the number of participants, reliability requirements, latency and the way the application is designed.

2. Basic usage of Java sockets

In this section we are going to learn how to deal with TCP and UDP sockets in Java. The main networking classes are defined in the java.net package. We will use the classic blocking socket API because it is simple and appropriate for learning the basic concepts of network programming.

You can also download here the source code of the examples that we are going to explain in this section, so you can test them easily.

2.1. Using TCP sockets

If we want to work with TCP sockets, we will mainly use these two classes:

In order to connect a client to a server, we will follow these steps.

On the server side:

  1. Create a ServerSocket specifying the desired port number.
  2. Wait until a client connects by calling accept(). This method blocks until a connection is available, unless a timeout has been configured.
  3. accept() returns a Socket object dedicated to that client.
  4. Create input and/or output streams from that socket to receive or send data.
  5. When the communication finishes, close the streams and socket. Close the ServerSocket when the server is no longer going to accept new connections.

For example:

try (
    ServerSocket server = new ServerSocket(portNumber);
    Socket service = server.accept();
    DataInputStream socketIn =
        new DataInputStream(service.getInputStream());
    DataOutputStream socketOut =
        new DataOutputStream(service.getOutputStream())
)
{
    // Communication process
} catch (IOException e) {
    System.out.println(e.getMessage());
}

On the client side:

  1. Create a Socket with the server address and port.
  2. If the connection succeeds, the socket is ready to exchange data. If the host cannot be resolved, the server is unavailable, or the connection is refused, an exception will be thrown.
  3. Create input and/or output streams from the socket.

For example:

try (
    Socket mySocket = new Socket("server-address", portNumber);
    DataInputStream socketIn =
        new DataInputStream(mySocket.getInputStream());
    DataOutputStream socketOut =
        new DataOutputStream(mySocket.getOutputStream())
)
{
    // Communication process
} catch (IOException e) {
    System.out.println(e.getMessage());
}
2.1.1. Example

Let’s implement our first complete example. We are going to create a client that sends "Hello" to the server, and a server that receives this message and sends "Goodbye" back to the client.

A real project would normally keep the client and server in separate modules or projects, because each side can be distributed and executed independently.

Client:

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

public class Greet_Client
{
    public static void main(String[] args)
    {
        try (
            Socket mySocket = new Socket("localhost", 2000);
            DataInputStream socketIn =
                new DataInputStream(mySocket.getInputStream());
            DataOutputStream socketOut =
                new DataOutputStream(mySocket.getOutputStream())
        )
        {
            socketOut.writeUTF("Hello");
            String response = socketIn.readUTF();
            System.out.println("Received: " + response);

        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

Server:

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class Greet_Server
{
    public static void main(String[] args)
    {
        try (
            ServerSocket server = new ServerSocket(2000);
            Socket service = server.accept();
            DataInputStream socketIn =
                new DataInputStream(service.getInputStream());
            DataOutputStream socketOut =
                new DataOutputStream(service.getOutputStream())
        )
        {
            String message = socketIn.readUTF();
            System.out.println("Received: " + message);

            socketOut.writeUTF("Goodbye");

        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

To run this example, start the server first. When it is waiting for connections, run the client. The server console should show Received: Hello, and the client console should show Received: Goodbye.

The address localhost refers to the local computer. It is useful for testing both applications on the same machine. To test them on two different machines, the client must use a host name or IP address that reaches the server, and the operating system/firewall must allow the selected port.

2.1.2. Some implementation issues

In the previous examples we used DataInputStream and DataOutputStream. They are convenient when both sides are Java applications and we want to send Java primitive data types or short strings in a defined binary format.

We can also use other stream classes. For example, BufferedReader together with PrintWriter or BufferedWriter is useful for a text protocol that sends one message per line.

It is important to remember that TCP is a byte stream, not a message protocol. If an application sends several values, both sides must agree on their order and format. For example, if one side sends data with writeUTF(), the other side should read it with readUTF(). Mixing incompatible reading and writing methods may make the receiver block or interpret the data incorrectly.

try-with-resources can and should be used whenever the lifetime of the resources matches the corresponding block. In a simple one-client server, all resources can be opened and closed in the same block. In a multi-client server, the listening ServerSocket belongs to the main server loop, while each accepted Socket belongs to the code that handles that particular client.

For long-lived graphical or event-driven applications, the socket may need to remain open across several event handlers. In that case, the application must keep the socket as part of its state and close it explicitly when the connection or application ends.

2.1.3. Some useful methods

Regarding the classes that we have just learnt, these constructors and methods are useful:

Method(s) Description
ServerSocket(int port) Creates a server socket bound to the specified local port. Port 0 can be used when we want the system to select an available port automatically.
ServerSocket(int port, int backlog) Creates a server socket with a requested maximum queue length for pending incoming connections. backlog is not the maximum number of clients that the application can serve simultaneously.
Socket(String host, int port) / Socket(InetAddress addr, int port) Creates a TCP socket and connects it to the specified remote host and port.
int getLocalPort(), int getPort() For a connected Socket, they return the local port and the remote port, respectively.
setSoTimeout(int milliseconds) Sets a maximum blocking time for some read operations. If the timeout expires, a SocketTimeoutException is thrown.
readByte(), readInt(), readDouble(), readUTF() Methods of DataInputStream for reading values written in the corresponding binary format. readUTF() reads the modified UTF-8 format used by writeUTF().
writeByte(...), writeInt(...), writeDouble(...), writeUTF(String) Methods of DataOutputStream for writing values in a portable binary format. writeUTF() and readUTF() should be used as a matching pair.
println(String) A method commonly used with PrintWriter to send a line of text.
readLine() A method of BufferedReader that reads a line of text. It is useful when the sender also uses a line-oriented text protocol.

Exercise 1:

Create an “echo” client-server application using Java sockets, by creating two separate projects (one for the client and another one for the server):

2.2. Using UDP sockets

UDP does not establish a reliable byte-stream connection like TCP. Applications send and receive independent datagrams. Each outgoing datagram identifies its destination address and port, and each received datagram contains information about the sender.

We will mainly work with the DatagramSocket and DatagramPacket classes.

The basic steps are:

  1. Create a DatagramSocket and bind it to a local port when necessary:
    • Any available local port: DatagramSocket socket = new DatagramSocket();
    • A specific local port: DatagramSocket socket = new DatagramSocket(portNumber);
    • A specific local address and port: DatagramSocket socket = new DatagramSocket(portNumber, localAddress);

    Notice that the address in the third constructor is a local bind address, not the remote destination. The remote destination is normally specified in the outgoing DatagramPacket.

  2. Create a DatagramPacket with the data and destination, and send it by calling send() on the DatagramSocket. To receive data, create a packet backed by a buffer and call receive() on the socket.
String text = "Hello";
byte[] message = text.getBytes(StandardCharsets.UTF_8);

DatagramPacket packetS = new DatagramPacket(
    message,
    message.length,
    InetAddress.getLoopbackAddress(),
    2000
);
socket.send(packetS);

byte[] buffer = new byte[1024];
DatagramPacket packetR = new DatagramPacket(buffer, buffer.length);
socket.receive(packetR);

String receivedText = new String(
    packetR.getData(),
    packetR.getOffset(),
    packetR.getLength(),
    StandardCharsets.UTF_8
);

Using packetR.getLength() is important because the receive buffer may be larger than the datagram that actually arrived.

  1. We can specify a receive timeout in milliseconds with setSoTimeout(). If no datagram is received before the timeout expires, Java throws a SocketTimeoutException.
socket.setSoTimeout(2000); // 2 seconds

try {
    socket.receive(packetR);
} catch (SocketTimeoutException e) {
    System.out.println("Timeout while waiting for a response");
}
  1. When the communication finishes, close the socket. Since DatagramSocket implements AutoCloseable, it can be used with try-with-resources.
2.2.1. Example

We are going to implement the same greeting example using UDP.

Client:

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;

public class GreetUDP_Client
{
    public static void main(String[] args)
    {
        try (DatagramSocket mySocket = new DatagramSocket())
        {
            String text = "Hello";
            byte[] message = text.getBytes(StandardCharsets.UTF_8);

            DatagramPacket packetS = new DatagramPacket(
                message,
                message.length,
                InetAddress.getLoopbackAddress(),
                2000
            );
            mySocket.send(packetS);

            byte[] buffer = new byte[1024];
            DatagramPacket packetR = new DatagramPacket(buffer, buffer.length);
            mySocket.receive(packetR);

            String response = new String(
                packetR.getData(),
                packetR.getOffset(),
                packetR.getLength(),
                StandardCharsets.UTF_8
            );
            System.out.println("Received: " + response);

        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

Server:

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;

public class GreetUDP_Server
{
    public static void main(String[] args)
    {
        try (DatagramSocket mySocket = new DatagramSocket(2000))
        {
            byte[] buffer = new byte[1024];
            DatagramPacket packetR = new DatagramPacket(buffer, buffer.length);
            mySocket.receive(packetR);

            String received = new String(
                packetR.getData(),
                packetR.getOffset(),
                packetR.getLength(),
                StandardCharsets.UTF_8
            );
            System.out.println("Received: " + received);

            int destPort = packetR.getPort();
            InetAddress destAddr = packetR.getAddress();

            String text = "Goodbye";
            byte[] message = text.getBytes(StandardCharsets.UTF_8);
            DatagramPacket packetS = new DatagramPacket(
                message,
                message.length,
                destAddr,
                destPort
            );
            mySocket.send(packetS);

        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

Binding the server with new DatagramSocket(2000) allows it to receive datagrams sent to port 2000 on the local interfaces accepted by the operating system. Binding explicitly to a particular local address is only necessary when we want to restrict the socket to that interface/address.

2.2.2. Some implementation issues

An outgoing UDP datagram includes:

When a datagram is received, Java makes the sender’s address and port available through getAddress() and getPort().

For text messages, it is a good practice to use an explicit character encoding such as StandardCharsets.UTF_8 on both sides. When converting a received datagram to a String, use the packet’s actual offset and length instead of converting the complete receive buffer.

Also remember that UDP does not guarantee that a datagram will arrive, arrive only once, or arrive in the same order as previous datagrams. If the application needs those guarantees, it must implement them itself or use a protocol such as TCP.

Finally, the receive buffer must be large enough for the expected datagram. If the incoming datagram is larger than the buffer supplied to the DatagramPacket, the received data may be truncated.

Exercise 2:

Create a UDP client-server application with the following projects:

2.3. More about the InetAddress class

The InetAddress class represents an IP address and provides methods for resolving host names through the system-wide name resolver.

A common way to obtain an address is:

InetAddress address = InetAddress.getByName("www.example.com");

If the argument is a host name, Java asks the configured resolver for its address. The exact resolution mechanism may involve DNS, local configuration and caching, depending on the operating system and Java runtime.

We can also create an InetAddress from a textual IP address:

InetAddress address = InetAddress.getByName("203.0.113.10");

In this case, the textual IP address itself can be parsed without requiring a forward DNS lookup. Calling getHostName() later may trigger name resolution in order to obtain a host name.

Some useful methods are:

2.4. Securing communications with TLS sockets

Sending data through a normal TCP socket does not provide encryption by itself. TLS (Transport Layer Security) adds confidentiality, integrity and authentication to a TCP connection. The older SSL protocols are obsolete, although Java’s API still uses historical class names such as SSLSocket and SSLServerSocket.

Current Java implementations support modern TLS versions such as TLS 1.2 and TLS 1.3.

2.4.1. Using Java SSLSocket and SSLServerSocket

The javax.net.ssl package includes the SSLSocket and SSLServerSocket classes. They extend the normal socket classes with TLS functionality.

On the server side:

  1. Configure the server’s key material. For a simple classroom example, this can be a keystore containing a private key and certificate.
  2. Create an SSLServerSocket using an SSLServerSocketFactory.
  3. Accept client connections. The accepted sockets are SSLSocket objects.

On the client side:

  1. Configure which certificates the client trusts. Public certificates are normally validated using the runtime’s trust material; for a self-signed classroom certificate, a custom truststore can be used.
  2. Create an SSLSocket using an SSLSocketFactory.
  3. Exchange data through the socket after the TLS handshake has established the secure session.

2.4.2. Example: secure “Hello-World” communication

The following example is intentionally simple and intended for local classroom testing with the keystore and truststore created in the annex.

Server code:

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.SSLSocket;

public class SecureServer {
    public static void main(String[] args) throws Exception {
        System.setProperty("javax.net.ssl.keyStore", "server.keystore");
        System.setProperty("javax.net.ssl.keyStorePassword", "password");

        SSLServerSocketFactory ssf =
            (SSLServerSocketFactory) SSLServerSocketFactory.getDefault();

        try (SSLServerSocket serverSocket =
                 (SSLServerSocket) ssf.createServerSocket(8443)) {

            System.out.println("Secure server running on port 8443...");

            try (SSLSocket clientSocket =
                     (SSLSocket) serverSocket.accept();
                 BufferedReader in = new BufferedReader(
                     new InputStreamReader(
                         clientSocket.getInputStream(),
                         StandardCharsets.UTF_8));
                 PrintWriter out = new PrintWriter(
                     new OutputStreamWriter(
                         clientSocket.getOutputStream(),
                         StandardCharsets.UTF_8),
                     true)) {

                System.out.println("Client connected!");
                String message = in.readLine();
                System.out.println("Received: " + message);
                out.println("Goodbye");
            }
        }
    }
}

Client code:

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;

public class SecureClient {
    public static void main(String[] args) throws Exception {
        System.setProperty("javax.net.ssl.trustStore", "client.truststore");
        System.setProperty("javax.net.ssl.trustStorePassword", "password");

        SSLSocketFactory ssf =
            (SSLSocketFactory) SSLSocketFactory.getDefault();

        try (SSLSocket socket =
                 (SSLSocket) ssf.createSocket("localhost", 8443);
             BufferedReader in = new BufferedReader(
                 new InputStreamReader(
                     socket.getInputStream(),
                     StandardCharsets.UTF_8));
             PrintWriter out = new PrintWriter(
                 new OutputStreamWriter(
                     socket.getOutputStream(),
                     StandardCharsets.UTF_8),
                 true)) {

            out.println("Hello");
            String response = in.readLine();
            System.out.println("Received: " + response);
        }
    }
}

2.4.3. Key considerations

Exercise 3:

Implement a secure client-server application using SSLSocket and SSLServerSocket. Create the keystore and truststore files using the keytool utility and test the application locally.

Use this document to create the keystore and truststore.

3. Connecting multiple clients. Sockets and threads

A server that accepts only one client at a time is not suitable for many real applications. A simple way to serve several TCP clients concurrently is to keep the ServerSocket in the main server thread and create a separate thread for each accepted client.

try (ServerSocket server = new ServerSocket(PORT))
{
    System.out.println("Listening...");

    while (true)
    {
        Socket service = server.accept();
        System.out.println(
            "Connection established with " +
            service.getRemoteSocketAddress()
        );

        ServerThread st = new ServerThread(service);
        st.start();
    }
} catch (IOException e) {
    System.out.println(e.getMessage());
}

The thread owns the socket corresponding to its client. Therefore, it can safely close that socket when the client session finishes:

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

public class ServerThread extends Thread
{
    private final Socket service;

    public ServerThread(Socket service)
    {
        this.service = service;
    }

    @Override
    public void run()
    {
        try (
            Socket client = service;
            DataInputStream socketIn =
                new DataInputStream(client.getInputStream());
            DataOutputStream socketOut =
                new DataOutputStream(client.getOutputStream())
        )
        {
            // Communication with this client

        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}

The important idea is resource ownership: the main server loop owns the ServerSocket, while each worker thread owns one accepted client Socket.

The thread-per-client model is easy to understand and is appropriate for this unit. In modern Java (Java 21 and later), virtual threads are another option for applications that need to handle many blocking client connections. For example, instead of creating a traditional platform thread explicitly, a server can start a virtual thread for a client task:

Socket service = server.accept();
Thread.startVirtualThread(() -> handleClient(service));

Virtual threads do not change the socket protocol or the client-server design; they are a more scalable way of running many blocking tasks. The classic Thread solution above is still perfectly valid for learning the concepts required in this unit.

With UDP, the server does not need a separate connection object for every client because every received datagram already contains the sender address and port. A simple UDP server can therefore receive and process datagrams sequentially in a loop. Threads may still be useful if processing each datagram is expensive or must run concurrently, but they are not required merely to distinguish one UDP client from another.

while (true)
{
    byte[] received = new byte[1024];
    DatagramPacket datagramReceived =
        new DatagramPacket(received, received.length);

    mySocket.receive(datagramReceived);

    InetAddress remoteIP = datagramReceived.getAddress();
    int remotePort = datagramReceived.getPort();

    byte[] sent = createResponse(datagramReceived);
    DatagramPacket datagramSent = new DatagramPacket(
        sent,
        sent.length,
        remoteIP,
        remotePort
    );

    mySocket.send(datagramSent);
}

Exercise 4:

Improve Exercise 1 with these changes:

4. Testing, debugging and documenting socket applications

Network applications can fail even when the program compiles correctly. The client and server run independently, depend on addresses and ports, and may block while waiting for data. For this reason, systematic testing and useful diagnostic information are especially important.

4.1. A basic testing strategy

A useful sequence is:

  1. Test locally first. Run server and client on the same machine using localhost or the loopback address.
  2. Verify the normal case. Check that every request produces the expected response and that the connection finishes correctly.
  3. Test several messages. Do not test only the first request. Verify loops and termination conditions such as "bye".
  4. Test failure situations. Try to start the client when the server is not running, use a wrong port, stop a client unexpectedly, or force a timeout.
  5. Test on two machines. Once the application works locally, use the server’s reachable network address and check firewall/network configuration.
  6. Test concurrency. For a multi-client server, connect several clients and verify that one client’s messages do not interfere with another client’s responses.

4.2. Common exceptions and what they usually indicate

Exception Typical cause
UnknownHostException The host name cannot be resolved.
ConnectException The TCP connection cannot be established, for example because no server is listening on that address/port or the connection is refused.
BindException The local address/port cannot be bound, often because the port is already in use.
SocketTimeoutException A configured timeout expired while waiting for a connection or data.
EOFException / end of stream The peer closed the connection while the application was expecting more data.
SocketException A general socket-level problem, including some cases where a connection has been closed or reset.

Do not hide every exception with an empty catch block. During development, print enough information to understand what failed. For example, log the exception message together with the client address, port and operation being performed.

For larger applications, a logging framework or java.util.logging is preferable to many unrelated System.out.println() calls, but console messages are sufficient for the introductory exercises in this unit.

4.3. Debugging communication protocols

When debugging a socket application, check both sides of the protocol:

Breakpoints are particularly useful immediately before and after blocking operations such as accept(), read...() and receive(). If execution reaches a read operation and never continues, inspect what the other side is expected to send next.

4.4. Documenting a network application

Every exercise or project should document at least:

Classes and non-obvious methods should also include meaningful comments or JavaDoc. Documentation should explain why the communication is organised in that way, not simply repeat what each Java instruction already says.

Exercise 5 — Debugging and documentation:

Use EchoImproved_Server and EchoImproved_Client from Exercise 4.

  1. Test and record the result of at least these situations: normal connection, wrong server port, server not running, client closing unexpectedly, and two clients connected simultaneously.
  2. Improve the exception handling so that the user receives meaningful messages instead of an unexplained stack trace or a program that appears to freeze.
  3. Use the debugger to follow at least one complete request-response exchange and identify where the server waits for a connection and where it waits for client data.
  4. Add a short README.md documenting the protocol, server port, how to run both projects, the "bye" termination rule, and the tests performed.
  5. Add appropriate JavaDoc or comments to the classes and methods responsible for network communication.