杰瑞科技汇

Java Socket如何实现网络通信?

Java Socket 编程完全指南

目录

  1. 第一部分:基础概念

    Java Socket如何实现网络通信?-图1
    (图片来源网络,侵删)
    • 什么是 Socket?
    • 网络通信模型:C/S (客户端/服务器)
    • 两种主要的 Socket 类型
    • Socket 通信流程
  2. 第二部分:核心 API 简介

    • java.net.ServerSocket
    • java.net.Socket
    • java.net.InetAddress
    • 输入/输出流:InputStreamOutputStream
  3. 第三部分:动手实践 - 简单的 Echo 服务器与客户端

    • Echo 服务器代码详解
    • Echo 客户端代码详解
    • 如何运行与测试
    • 代码流程总结
  4. 第四部分:处理多客户端连接

    • 为什么需要多线程?
    • 实现方式一:为每个客户端创建一个新线程
    • 实现方式二:使用线程池 (更优)
  5. 第五部分:高级主题与最佳实践

    • 使用 BufferedReaderPrintWriter 进行文本通信
    • 处理 IOException 和异常关闭
    • 半关闭 Socket (shutdownOutput)
    • 超时设置 (setSoTimeout)
    • NIO (New I/O) 简介
  6. 第六部分:总结与资源


第一部分:基础概念

什么是 Socket?

你可以把 Socket(套接字)想象成是两个程序之间进行网络通信的“电话插座”,一个程序将“听筒”(Socket)插入这个插座,另一个程序也将“听筒”插入同一个插座(通过 IP 地址和端口号定位),这样它们之间就可以通过“电话线”(网络)进行通话了。

在 Java 中,Socket 是网络通信的端点,它封装了复杂的底层网络协议(如 TCP/IP),让开发者可以方便地进行网络编程。

网络通信模型:C/S (客户端/服务器)

绝大多数网络应用都采用客户端/服务器模型:

  • 服务器:一个在已知地址(IP 地址和端口号)上运行并等待连接的程序,它被动地接收来自客户端的请求。
  • 客户端:一个主动发起连接请求到服务器的程序。

一个典型的交互流程是:

  1. 服务器启动,在某个端口上监听。
  2. 客户端知道服务器的 IP 和端口,发起连接请求。
  3. 服务器接受连接请求。
  4. 双方建立连接,开始通过 Socket 进行数据传输。
  5. 通信结束后,关闭连接。

两种主要的 Socket 类型

Java 主要支持两种类型的 Socket,对应两种不同的传输层协议:

  1. TCP Socket (流式 Socket)

    • 协议:TCP (Transmission Control Protocol)
    • 特点
      • 面向连接:通信前必须先建立连接(三次握手)。
      • 可靠传输:通过确认、重传、排序等机制确保数据无差错、不丢失、不重复地到达。
      • 面向字节流:数据被看作是一个无结构的字节流。
      • 全双工通信:双方可以同时发送和接收数据。
    • 类比:打电话,你必须先拨号并对方接听后才能开始通话,通话内容清晰可靠。
    • Java 类java.net.Socket (客户端), java.net.ServerSocket (服务器端)。
  2. UDP Socket (数据报 Socket)

    • 协议:UDP (User Datagram Protocol)
    • 特点
      • 无连接:发送数据前不需要建立连接,直接将数据打包(数据报)发送出去。
      • 不可靠传输:不保证数据是否到达,也不保证顺序,可能会丢失、重复或乱序。
      • 面向数据报:每次发送的数据都是一个独立的数据报。
      • 传输速度快,开销小
    • 类比:寄明信片,你写好地址扔进邮筒,但无法保证对方一定能按顺序收到。
    • Java 类java.net.DatagramSocket, java.net.DatagramPacket

本教程将重点介绍最常用、最可靠的 TCP Socket

Socket 通信流程

一个典型的 TCP Socket 通信流程如下:

服务器端:

  1. 创建 ServerSocket 对象,并绑定到一个具体的端口上,开始监听客户端连接。
  2. 调用 accept() 方法,这是一个阻塞方法,它会一直等待,直到有客户端连接上来。
  3. accept() 返回时,它会返回一个新的 Socket 对象,这个 Socket 代表了与那个特定客户端的连接通道。
  4. 通过这个新的 Socket 获取输入流和输出流,与客户端进行数据交互。
  5. 通信结束后,关闭 Socket

客户端:

  1. 创建 Socket 对象,指明需要连接的服务器的 IP 地址和端口号。
  2. 如果连接成功,客户端会获取到服务器的 Socket 对象。
  3. 通过这个 Socket 获取输入流和输出流,与服务器进行数据交互。
  4. 通信结束后,关闭 Socket

第二部分:核心 API 简介

java.net.ServerSocket

服务器端使用的类,用于监听客户端的连接请求。

  • ServerSocket(int port): 创建一个服务器套接字,并绑定到指定的端口。
  • Socket accept(): 监听并接受到此套接字的连接,此方法在连接建立之前会一直阻塞。
  • void close(): 关闭此套接字。

java.net.Socket

客户端使用的类,也可以被 ServerSocket.accept() 返回,代表一个连接。

  • Socket(String host, int port): 创建一个流套接字并将其连接到指定主机上的指定端口号。
  • InputStream getInputStream(): 返回此套接字的输入流。
  • OutputStream getOutputStream(): 返回此套接字的输出流。
  • void close(): 关闭此套接字。

java.net.InetAddress

InetAddress 类是 Java 对 IP 地址的抽象,它既可以是 IPv4 地址,也可以是 IPv6 地址。

  • static InetAddress getByName(String host): 通过主机名或 IP 地址字符串获取 InetAddress 实例。
    • 示例:InetAddress.getByName("www.google.com")InetAddress.getByName("127.0.0.1")

输入/输出流:InputStreamOutputStream

Socket 获取的流是原始的字节流 (InputStream/OutputStream),直接读写二进制数据比较麻烦,通常我们会将其包装成更高级的流,如 DataInputStream/DataOutputStreamBufferedReader/PrintWriter


第三部分:动手实践 - 简单的 Echo 服务器与客户端

“Echo”服务器是一个经典的教学示例,它的功能很简单:客户端发送什么消息,服务器就原样返回什么消息。

Echo 服务器代码

// EchoServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class EchoServer {
    public static void main(String[] args) {
        int port = 12345; // 定义服务器监听端口
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器已启动,等待客户端连接...");
            // accept() 方法会阻塞,直到有客户端连接
            Socket clientSocket = serverSocket.accept();
            // 获取客户端的地址信息
            InetAddress clientAddress = clientSocket.getInetAddress();
            System.out.println("客户端已连接: " + clientAddress.getHostAddress());
            // 获取输入流,用于读取客户端发送的数据
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            // 获取输出流,用于向客户端发送数据
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
            String inputLine;
            // 循环读取客户端发送的每一行数据
            while ((inputLine = in.readLine()) != null) {
                System.out.println("收到客户端消息: " + inputLine);
                // 将收到的消息回显给客户端
                out.println("服务器回显: " + inputLine);
                // 如果客户端发送 "bye",则结束循环
                if ("bye".equalsIgnoreCase(inputLine)) {
                    break;
                }
            }
            System.out.println("客户端断开连接。");
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

Echo 客户端代码

// EchoClient.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
public class EchoClient {
    public static void main(String[] args) {
        String hostname = "127.0.0.1"; // 服务器地址,localhost
        int port = 12345;             // 服务器端口
        try (Socket socket = new Socket(hostname, port);
             // 获取输入流,用于读取服务器返回的数据
             BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
             // 获取输出流,用于向服务器发送数据
             PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
             // 从控制台读取用户输入
             BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))) {
            System.out.println("已连接到服务器。");
            System.out.println("请输入消息,输入 'bye' 退出:");
            String userInput;
            // 循环读取用户输入
            while ((userInput = stdIn.readLine()) != null) {
                // 将用户输入发送给服务器
                out.println(userInput);
                // 读取并打印服务器的回显
                String response = in.readLine();
                System.out.println("服务器响应: " + response);
                if ("bye".equalsIgnoreCase(userInput)) {
                    break;
                }
            }
        } catch (UnknownHostException e) {
            System.err.println "未知的主机: " + hostname);
            e.printStackTrace();
        } catch (IOException e) {
            System.err.println("I/O 发生错误: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

如何运行与测试

  1. 编译代码:打开终端,进入两个 .java 文件所在的目录,运行:

    javac EchoServer.java EchoClient.java
  2. 启动服务器:在一个终端窗口中运行服务器:

    java EchoServer

    你会看到输出:服务器已启动,等待客户端连接...

  3. 启动客户端:在另一个终端窗口中运行客户端:

    java EchoClient

    你会看到输出:已连接到服务器。

  4. 交互:在客户端的终端中输入任何文本,然后按回车,你会在客户端的终端中看到服务器的回显消息。

    请输入消息,输入 'bye' 退出:
    你好,服务器!
    服务器响应: 服务器回显: 你好,服务器!
    Java Socket 很有趣
    服务器响应: 服务器回显: Java Socket 很有趣
    bye
    服务器响应: 服务器回显: bye
  5. 关闭:当客户端输入 bye 后,程序会退出,观察服务器的终端,它会显示 客户端断开连接。

  • 服务器

    1. 创建 ServerSocket 并绑定端口。
    2. accept() 阻塞等待连接。
    3. 连接建立后,通过 Socket 获取 InputStreamOutputStream
    4. 使用 BufferedReader 逐行读取客户端消息。
    5. 使用 PrintWriter 将消息回写给客户端。
    6. 循环直到收到结束信号或客户端断开。
  • 客户端

    1. 创建 Socket 并指定服务器的 IP 和端口。
    2. 连接成功后,通过 Socket 获取 InputStreamOutputStream
    3. 使用 BufferedReader 读取用户控制台输入。
    4. 使用 PrintWriter 将输入发送给服务器。
    5. 使用 BufferedReader 读取服务器返回的响应。
    6. 循环直到用户输入结束信号。

第四部分:处理多客户端连接

上面的示例只能处理一个客户端连接,当服务器在 accept() 处等待时,它无法响应其他客户端的连接请求,要处理多个客户端,最常用的方法是多线程

实现方式一:为每个客户端创建一个新线程

思想:主线程(服务器线程)只负责监听和接受连接,每当有一个新的客户端连接,就创建一个新的线程来处理这个客户端的所有通信,主线程则立即返回 accept() 状态,等待下一个客户端。

修改后的服务器代码 (MultiThreadEchoServer.java)

// MultiThreadEchoServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
public class MultiThreadEchoServer {
    public static void main(String[] args) {
        int port = 12345;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("多线程服务器已启动,等待客户端连接...");
            while (true) { // 无限循环,持续接受连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
                // 为每个客户端创建一个新的线程来处理
                ClientHandler clientHandler = new ClientHandler(clientSocket);
                new Thread(clientHandler).start();
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
        }
    }
}
// 定义一个专门的类来处理客户端的逻辑
class ClientHandler implements Runnable {
    private Socket clientSocket;
    public ClientHandler(Socket socket) {
        this.clientSocket = socket;
    }
    @Override
    public void run() {
        try (BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
             PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) {
            String inputLine;
            while ((inputLine = in.readLine()) != null) {
                System.out.println("[" + Thread.currentThread().getName() + "] 收到客户端消息: " + inputLine);
                out.println("服务器回显: " + inputLine);
                if ("bye".equalsIgnoreCase(inputLine)) {
                    break;
                }
            }
        } catch (IOException e) {
            // 客户端异常断开时,会打印此信息
            System.out.println("客户端处理异常或断开连接: " + e.getMessage());
        } finally {
            try {
                if (clientSocket != null && !clientSocket.isClosed()) {
                    clientSocket.close();
                    System.out.println("客户端连接已关闭。");
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

这种方式的问题:如果客户端数量巨大,会创建大量线程,每个线程都会占用一定的内存和 CPU 资源,可能会导致服务器资源耗尽。

实现方式二:使用线程池 (更优)

思想:创建一个固定大小的线程池,当有新客户端连接时,从线程池中取出一个空闲线程来处理它,如果所有线程都在忙,新连接的请求需要等待,直到有线程空闲,这可以避免无限制地创建线程,提高了系统的稳定性和资源利用率。

使用线程池的服务器代码 (ThreadPoolEchoServer.java)

// ThreadPoolEchoServer.java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolEchoServer {
    // 使用固定大小的线程池,10 个线程
    private static final ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static void main(String[] args) {
        int port = 12345;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("线程池服务器已启动,等待客户端连接...");
            while (true) {
                Socket clientSocket = serverSocket.accept();
                System.out.println("新客户端连接: " + clientSocket.getInetAddress().getHostAddress());
                // 提交任务到线程池,而不是创建新线程
                threadPool.execute(new ClientHandler(clientSocket));
            }
        } catch (IOException e) {
            System.err.println("服务器异常: " + e.getMessage());
        } finally {
            // 当服务器关闭时,关闭线程池
            System.out.println("服务器关闭,正在关闭线程池...");
            threadPool.shutdown();
        }
    }
}
// ClientHandler 类与上面的 MultiThreadEchoServer 中的完全相同
class ClientHandler implements Runnable {
    // ... (代码同上) ...
}

第五部分:高级主题与最佳实践

使用 BufferedReaderPrintWriter 进行文本通信

在上面的例子中,我们已经使用了这两个类,它们是进行文本行通信的最佳选择。

  • PrintWriter(socket.getOutputStream(), true):第二个参数 true 表示自动刷新,这样每当调用 println() 方法后,输出缓冲区会自动清空,确保数据能立即发送出去,这对于交互式应用非常重要,否则客户端可能会一直等待。
  • BufferedReader(new InputStreamReader(socket.getInputStream()))InputStreamReader 将字节流转换为字符流,BufferedReader 则提供按行读取的高效方法。

处理 IOException 和异常关闭

网络编程充满了不确定性,客户端可能会突然断开网络、关闭程序,或者服务器重启,必须妥善处理 IOException

  • SocketException: Connection reset:这是最常见的异常之一,通常表示客户端或另一方异常关闭了连接。
  • try-with-resources:强烈推荐使用 try-with-resources 语句(如 try (Socket socket = ...)),它会自动调用 close() 方法,确保流和 Socket 在使用完毕后被正确关闭,即使发生了异常。
  • finally:如果不使用 try-with-resources,应该在 finally 块中确保关闭资源,防止资源泄露。

半关闭 Socket (shutdownOutput)

我们可能想告诉对方:“我已经发送完所有数据了,但你还可以继续发给我”,这时可以只关闭输出流,而不关闭整个 Socket。

  • socket.shutdownOutput(): 关闭此 Socket 的输出流,调用此方法后,这个 Socket 的输出流就不能再写入数据了,如果尝试写入,会抛出 IOException,但输入流仍然可以正常使用。
  • 对应的,socket.shutdownInput() 用于关闭输入流。

这在需要区分“数据结束”和“连接结束”的场景中非常有用。

超时设置 (setSoTimeout)

Socketaccept(), read() 等方法都是阻塞的,可能会无限期等待,为了避免程序永久阻塞,可以设置超时时间。

  • socket.setSoTimeout(int timeout): 设置超时时间(毫秒)。
    • 对于 ServerSocketaccept() 方法如果在指定时间内没有连接,会抛出 SocketTimeoutException
    • 对于 Socketread() 方法如果在指定时间内没有数据可读,会抛出 SocketTimeoutException

NIO (New I/O) 简介

传统的 Socket 编程基于阻塞 I/O (Blocking I/O),当一个线程在 read()accept() 时,它会被阻塞,无法做任何其他事情,这就是为什么我们需要为每个连接创建一个线程。

Java NIO (New I/O) 引入了一种非阻塞 I/O (Non-blocking I/O) 模型,其核心是:

  • Channel (通道):类似流,但可以双向读写,并且支持异步操作。
  • Buffer (缓冲区):所有数据都是通过 Buffer 进行读写。
  • Selector (选择器):这是 NIO 的精髓,一个 Selector 可以同时监控多个 Channel 的状态(如:是否有连接到来、是否有数据可读),这样,一个线程就可以管理成百上千个连接,极大地提高了系统的并发性能。

NIO 的实现比传统 BIO 更复杂,但对于构建高性能、高并发的网络服务器(如聊天室、游戏服务器)是必不可少的,当你的应用需要处理成千上万的并发连接时,就应该考虑学习和使用 NIO。


第六部分:总结与资源

  • Socket 是 Java 网络编程的基础,它为程序提供了网络通信的端点。
  • TCP Socket 是最常用、最可靠的通信方式,它面向连接,提供字节流服务。
  • 基本流程:服务器 bind -> listen -> accept;客户端 connect -> 通信 -> close
  • 多客户端处理:通过多线程或线程池来避免主线程阻塞,实现并发处理。
  • 文本通信:优先使用 BufferedReaderPrintWriter,并开启自动刷新。
  • 异常处理:网络环境不稳定,必须妥善处理 IOException,并确保资源被正确关闭。
  • 性能优化:对于高并发场景,传统 BIO 的多线程模型效率低下,应考虑使用 NIO 技术。

学习资源

希望这份教程能帮助你顺利开启 Java Socket 编程之旅!祝你学习愉快!

分享:
扫描分享到社交APP
上一篇
下一篇