Socket 是 Java 网络编程的基础,了解还是有好处的,
这篇文章主要讲解 Socket 的基础编程。Socket 用在哪呢,主要用在进程间,网络间通信。本篇比较长,特别做了个目录:
一、Socket 通信基本示例
二、消息通信优化
三、服务端优化
四、Socket 的其它知识
五、关于 Socket 的理解
这种模式是基础,必须掌握,后期对 Socket 的优化都是在这个基础上的,也是为以后学习 NIO 做铺垫。
- package yiwangzhibujian.onlysend;
- import java.io.InputStream;
- import java.net.ServerSocket;
- import java.net.Socket;
- public class SocketServer {
- public static voidmain(String[] args)throws Exception {
- // 监听指定的端口
- intport = 55533;
- ServerSocket server =new ServerSocket(port);
- // server将一直等待连接的到来System.out.println("server将一直等待连接的到来");
- Socket socket = server.accept();
- // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取InputStream inputStream = socket.getInputStream();
- byte[] bytes =new byte[1024];
- int len;
- StringBuilder sb =new StringBuilder();
- while((len = inputStream.read(bytes)) != -1) {
- //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8sb.append(newString(bytes, 0, len,"UTF-8"));
- }
- System.out.println("get message from client: " + sb);
- inputStream.close();
- socket.close();
- server.close();
- }
- }
服务端监听一个端口,等待连接的到来。
- package yiwangzhibujian.onlysend;
- import java.io.OutputStream;
- import java.net.Socket;
- public class SocketClient {
- public static voidmain(String args[])throws Exception {
- // 要连接的服务端IP地址和端口String host = "127.0.0.1";
- intport = 55533;
- // 与服务端建立连接Socket socket =new Socket(host, port);
- // 建立连接后获得输出流OutputStream outputStream = socket.getOutputStream();
- String message="你好 yiwangzhibujian";
- socket.getOutputStream().write(message.getBytes("UTF-8"));
- outputStream.close();
- socket.close();
- }
- }
客户端通过 ip 和端口,连接到指定的 server,然后通过 Socket 获得输出流,并向其输出内容,服务器会获得消息。最终服务端控制台打印如下:
- server将一直等待连接的到来
- get message from client: 你好 yiwangzhibujian
通过这个例子应该掌握并了解:
这个例子做为学习的基本例子,实际开发中会有各种变形,比如客户端在发送完消息后,需要服务端进行处理并返回,如下。
这个也是做为 Socket 编程的基本,应该掌握,例子如下:
- package yiwangzhibujian.waitreceive;
- import java.io.InputStream;
- import java.io.OutputStream;
- import java.net.ServerSocket;
- import java.net.Socket;
- public class SocketServer {
- public static voidmain(String[] args)throws Exception {
- // 监听指定的端口
- intport = 55533;
- ServerSocket server =new ServerSocket(port);
- // server将一直等待连接的到来System.out.println("server将一直等待连接的到来");
- Socket socket = server.accept();
- // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取InputStream inputStream = socket.getInputStream();
- byte[] bytes =new byte[1024];
- int len;
- StringBuilder sb =new StringBuilder();
- //只有当客户端关闭它的输出流的时候,服务端才能取得结尾的-1
- while((len = inputStream.read(bytes)) != -1) {
- // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8sb.append(newString(bytes, 0, len, "UTF-8"));
- }
- System.out.println("get message from client: " + sb);
- OutputStream outputStream = socket.getOutputStream();
- outputStream.write("Hello Client,I get the message.".getBytes("UTF-8"));
- inputStream.close();
- outputStream.close();
- socket.close();
- server.close();
- }
- }
与之前 server 的不同在于,当读取完客户端的消息后,打开输出流,将指定消息发送回客户端,客户端程序为:
- package yiwangzhibujian.waitreceive;
- import java.io.InputStream;
- import java.io.OutputStream;
- import java.net.Socket;
- public class SocketClient {
- public static voidmain(String args[])throws Exception {
- // 要连接的服务端IP地址和端口String host = "127.0.0.1";
- intport = 55533;
- // 与服务端建立连接Socket socket =new Socket(host, port);
- // 建立连接后获得输出流OutputStream outputStream = socket.getOutputStream();
- String message = "你好 yiwangzhibujian";
- socket.getOutputStream().write(message.getBytes("UTF-8"));
- //通过shutdownOutput高速服务器已经发送完数据,后续只能接受数据
- socket.shutdownOutput();
- InputStream inputStream = socket.getInputStream();
- byte[] bytes =new byte[1024];
- int len;
- StringBuilder sb =new StringBuilder();
- while((len = inputStream.read(bytes)) != -1) {
- //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8sb.append(newString(bytes, 0, len,"UTF-8"));
- }
- System.out.println("get message from server: " + sb);
- inputStream.close();
- outputStream.close();
- socket.close();
- }
- }
客户端也有相应的变化,在发送完消息时,调用关闭输出流方法,然后打开输出流,等候服务端的消息。
这个模式的使用场景一般用在,客户端发送命令给服务器,然后服务器相应指定的命令,如果只是客户端发送消息给服务器,然后让服务器返回收到消息的消息,这就有点过分了,这就是完全不相信 Socket 的传输安全性,要知道它的底层可是 TCP,如果没有发送到服务器端是会抛异常的,这点完全不用担心。
其实这个问题还是比较重要的,正常来说,客户端打开一个输出流,如果不做约定,也不关闭它,那么服务端永远不知道客户端是否发送完消息,那么服务端会一直等待下去,直到读取超时。所以怎么告知服务端已经发送完消息就显得特别重要。
这个是第一章介绍的方式,当 Socket 关闭的时候,服务端就会收到响应的关闭信号,那么服务端也就知道流已经关闭了,这个时候读取操作完成,就可以继续后续工作。
但是这种方式有一些缺点
这种方式调用的方法是:
- socket.shutdownOutput();
而不是(outputStream 为发送消息到服务端打开的输出流):
- outputStream.close();
如果关闭了输出流,那么相应的 Socket 也将关闭,和直接关闭 Socket 一个性质。
调用 Socket 的 shutdownOutput() 方法,底层会告知服务端我这边已经写完了,那么服务端收到消息后,就能知道已经读取完消息,如果服务端有要返回给客户的消息那么就可以通过服务端的输出流发送给客户端,如果没有,直接关闭 Socket。
这种方式通过关闭客户端的输出流,告知服务端已经写完了,虽然可以读到服务端发送的消息,但是还是有一点点缺点:
这个缺点,在访问频率比较高的情况下将是一个需要优化的地方。
这种方式的用法,就是双方约定一个字符或者一个短语,来当做消息发送完成的标识,通常这么做就需要改造读取方法。
假如约定单端的一行为 end,代表发送完成,例如下面的消息,end 则代表消息发送完成:
- hello yiwangzhibujian
- end
那么服务端响应的读取操作需要进行如下改造:
- Socket socket = server.accept();
- // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取BufferedReader read=newBufferedReader(newInputStreamReader(socket.getInputStream(),"UTF-8"));
- String line;
- StringBuilder sb =new StringBuilder();
- while((line = read.readLine()) !=null&& "end".equals(line)) {
- //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
- sb.append(line);
- }
可以看见,服务端不仅判断是否读到了流的末尾,还判断了是否读到了约定的末尾。
这么做的优缺点如下:
经过了这么多的优化还是有缺点,难道就没有完美的解决方案吗,答案是有的,看接下来的内容。
如果你了解一点 class 文件的结构(后续会写,敬请期待),那么你就会佩服这么设计方式,也就是说我们可以在此找灵感,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。
现在首要的问题就是用几个字节指定长度呢,我们可以算一算:
这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,其实如果你稍微了解一点 UTF-8 的编码方式(字符编码后续会写,敬请期待),那么你就应该能想到为什么一定要固定表示长度字节的长度呢,我们可以使用变长方式来表示长度的表示,比如:
上面提到的这种用法适合高富帅的程序员使用,一般呢,如果用作命名发送,两个字节就够了,如果还不放心 4 个字节基本就能满足你的所有要求,下面的例子我们将采用 2 个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾:
服务端程序:
- package yiwangzhibujian.waitreceive2;
- import java.io.InputStream;
- import java.net.ServerSocket;
- import java.net.Socket;
- public class SocketServer {
- public static voidmain(String[] args)throws Exception {
- // 监听指定的端口
- intport = 55533;
- ServerSocket server =new ServerSocket(port);
- // server将一直等待连接的到来System.out.println("server将一直等待连接的到来");
- Socket socket = server.accept();
- // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取InputStream inputStream = socket.getInputStream();
- byte[] bytes;
- // 因为可以复用Socket且能判断长度,所以可以一个Socket用到底
- while(true) {
- // 首先读取两个字节表示的长度
- intfirst = inputStream.read();
- //如果读取的值为-1 说明到了流的末尾,Socket已经被关闭了,此时将不能再去读取
- if(first==-1){
- break;
- }
- intsecond = inputStream.read();
- intlength = (first << 8) + second;
- // 然后构造一个指定长的byte数组bytes =new byte[length];
- // 然后读取指定长度的消息即可
- inputStream.read(bytes);
- System.out.println("get message from client: " +newString(bytes, "UTF-8"));
- }
- inputStream.close();
- socket.close();
- server.close();
- }
- }
此处的读取步骤为,先读取两个字节的长度,然后读取消息,客户端为:
- package yiwangzhibujian.waitreceive2;
- import java.io.OutputStream;
- import java.net.Socket;
- public class SocketClient {
- public static voidmain(String args[])throws Exception {
- // 要连接的服务端IP地址和端口String host = "127.0.0.1";
- intport = 55533;
- // 与服务端建立连接Socket socket =new Socket(host, port);
- // 建立连接后获得输出流OutputStream outputStream = socket.getOutputStream();
- String message = "你好 yiwangzhibujian";
- //首先需要计算得知消息的长度
- byte[] sendBytes = message.getBytes("UTF-8");
- //然后将消息的长度优先发送出去outputStream.write(sendBytes.length >>8);
- outputStream.write(sendBytes.length);
- //然后将消息再次发送出去
- outputStream.write(sendBytes);
- outputStream.flush();
- //==========此处重复发送一次,实际项目中为多个命名,此处只为展示用法message = "第二条消息";
- sendBytes = message.getBytes("UTF-8");
- outputStream.write(sendBytes.length >>8);
- outputStream.write(sendBytes.length);
- outputStream.write(sendBytes);
- outputStream.flush();
- //==========此处重复发送一次,实际项目中为多个命名,此处只为展示用法message = "the third message!";
- sendBytes = message.getBytes("UTF-8");
- outputStream.write(sendBytes.length >>8);
- outputStream.write(sendBytes.length);
- outputStream.write(sendBytes);
- outputStream.close();
- socket.close();
- }
- }
客户端要多做的是,在发送消息之前先把消息的长度发送过去。
这种事先约定好长度的做法解决了之前提到的种种问题,Redis 的 Java 客户端 Jedis 就是用这种方式实现的这种方式的缺点:
当然如果是需要服务器返回结果,那么也依然使用这种方式,服务端也是先发送结果的长度,然后客户端进行读取。
在上面的例子中,服务端仅仅只是接受了一个 Socket 请求,并处理了它,然后就结束了,但是在实际开发中,一个 Socket 服务往往需要服务大量的 Socket 请求,那么就不能再服务完一个 Socket 的时候就关闭了,这时候可以采用循环接受请求并处理的逻辑:
- package yiwangzhibujian.multiserver;
- import java.io.IOException;
- import java.io.InputStream;
- import java.net.ServerSocket;
- import java.net.Socket;
- public class SocketServer {
- public static voidmain(String args[])throws IOException {
- // 监听指定的端口
- intport = 55533;
- ServerSocket server =new ServerSocket(port);
- // server将一直等待连接的到来System.out.println("server将一直等待连接的到来");
- while(true){
- Socket socket = server.accept();
- // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取InputStream inputStream = socket.getInputStream();
- byte[] bytes =new byte[1024];
- int len;
- StringBuilder sb =new StringBuilder();
- while((len = inputStream.read(bytes)) != -1) {
- // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8sb.append(newString(bytes, 0, len, "UTF-8"));
- }
- System.out.println("get message from client: " + sb);
- inputStream.close();
- socket.close();
- }
- }
- }
这种一般也是新手写法,但是能够循环处理多个 Socket 请求,不过当一个请求的处理比较耗时的时候,后面的请求将被阻塞,所以一般都是用多线程的方式来处理 Socket,即每有一个 Socket 请求的时候,就创建一个线程来处理它。
不过在实际生产中,创建的线程会交给线程池来处理,为了:
- package yiwangzhibujian.threadserver;
- import java.io.InputStream;
- import java.net.ServerSocket;
- import java.net.Socket;
- import java.util.concurrent.ExecutorService;
- import java.util.concurrent.Executors;
- public class SocketServer {
- public static voidmain(String args[])throws Exception {
- // 监听指定的端口
- intport = 55533;
- ServerSocket server =new ServerSocket(port);
- // server将一直等待连接的到来System.out.println("server将一直等待连接的到来");
- //如果使用多线程,那就需要线程池,防止并发过高时创建过多线程耗尽资源ExecutorService threadPool = Executors.newFixedThreadPool(100);
- while(true) {
- Socket socket = server.accept();
- Runnable runnable=()->{
- try {
- // 建立好连接后,从socket中获取输入流,并建立缓冲区进行读取InputStream inputStream = socket.getInputStream();
- byte[] bytes =new byte[1024];
- int len;
- StringBuilder sb =new StringBuilder();
- while((len = inputStream.read(bytes)) != -1) {
- // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8sb.append(newString(bytes, 0, len, "UTF-8"));
- }
- System.out.println("get message from client: " + sb);
- inputStream.close();
- socket.close();
- } catch (Exception e) {
- e.printStackTrace();
- }
- };
- threadPool.submit(runnable);
- }
- }
- }
使用线程池的方式,算是一种成熟的方式。可以应用在生产中。
ServerSocket 有以下 3 个属性。
具体详细的解释可以参照下面。
当现在的性能还不能满足需求的时候,就需要考虑使用 NIO,这不是本篇的内容,后续会贴出。
其实如果经常看有关网络编程的源码的话,就会发现 Socket 还是有很多设置的,可以学着用,但是还是要有一些基本的了解比较好。下面就对 Socket 的 Java API 中涉及到的进行简单讲解。首先呢 Socket 有哪些可以设置的选项,其实在 SocketOptions 接口中已经都列出来了:
上面只是简单介绍了下(来源 Java API),下面有对其中的某些的详细讲解,没讲到的后续如果用到会补上。
服务端绑定端口是可以理解的,因为要监听指定的端口,但是客户端为什么要绑定端口,说实话我觉得这么做的人有点 2,或许有的网络安全策略配置了端口访出,使用户只能使用指定的端口,那么这样的配置也是挺 2 的,直接说就可以不要留面子。
当然首先要理解的是,如果没有指定端口的话,Socket 会自动选取一个可以用的端口,不用瞎操心的。
但是你非得指定一个端口也是可以的,做法如下,这时候就不能用 Socket 的构造方法了,要一步一步来:
- // 要连接的服务端IP地址和端口String host = "localhost";
- intport = 55533;
- // 与服务端建立连接Socket socket =new Socket();
- socket.bind(newInetSocketAddress(55534));
- socket.connect(newInetSocketAddress(host, port));
这样做就可以了,但是当这个程序执行完成以后,再次执行就会报,端口占用异常:
- java.net.BindException: Address already in use: connect
明明上一个 Socket 已经关闭了,为什么再次使用还会说已经被占用了呢?如果你是用 netstat 命令来查看端口的使用情况:
- netstat -n|findstr "55533"
- TCP 127.0.0.1:55534 127.0.0.1:55533 TIME_WAIT
就会发现端口的使用状态为 TIME_WAIT,说到这你需要有一点 TCP 连接的基本常识,建议看《》,这是其中的一点摘抄笔记,或许对理解有一些帮助。
简单来说,当连接主动关闭后,端口状态变为 TIME_WAIT,其他程序依然不能使用这个端口,防止服务端因为超时重新发送的确认连接断开对新连接的程序造成影响。
TIME_WAIT 的时间一般有底层决定,一般是 2 分钟,还有 1 分钟和 30 秒的。
所以,客户端不要绑定端口,不要绑定端口,不要绑定端口。
读超时这个属性还是比较重要的,当 Socket 优化到最后的时候,往往一个 Socket 连接会一直用下去,那么当一端因为异常导致连接没有关闭,另一方是不应该持续等下去的,所以应该设置一个读取的超时时间,当超过指定的时间后,还没有读到数据,就假定这个连接无用,然后抛异常,捕获异常后关闭连接就可以了,调用方法为:
- public voidsetSoTimeout(int timeout)
- throwsSocketException
timeout - 指定的以毫秒为单位的超时值。设置 0 为持续等待下去。建议根据网络环境和实际生产环境选择。
这个选项设置的值将对以下操作有影响:
这个连接超时和上面说的读超时不一样,读超时是在建立连接以后,读数据时使用的,而连接超时是在进行连接的时候,等待的时间。
当需要判断一个 Socket 是否可用的时候,不能简简单单判断是否为 null,是否关闭,下面给出一个比较全面的判断 Socket 是否可用的表达式,摘自 Jedis 源码:
- socket !=null&& socket.isBound() && !socket.isClosed() && socket.isConnected()&& !socket.isInputShutdown() && !socket.isOutputShutdown()
建议如此使用。
首先,创建 Socket 时,默认是禁止的,设置 true 有什么作用呢,Java API 中是这么介绍的:
关闭 TCP 连接时,该连接可能在关闭后的一段时间内保持超时状态(通常称为 TIME_WAIT 状态或 2MSL 等待状态)。对于使用已知套接字地址或端口的应用程序而言,如果存在处于超时状态的连接(包括地址和端口),可能不能将套接字绑定到所需的 SocketAddress 上。
使用 bind(SocketAddress) 绑定套接字前启用 SO_REUSEADDR 允许在上一个连接处于超时状态时绑定套接字。
一般是用在绑定端口的时候使用,但是经过我的测试建议如下:
综上所述,不建议绑定端口,也没必要设置 ReuseAddress,当然 ReuseAddress 的底层还是和硬件有关系的,或许在你的机器上测试结果和我不一样,若是如此和平台相关性差异这么大配置更是不建议使用了。
Java API 的介绍是:启用 / 禁用具有指定逗留时间(以秒为单位)的 SO_LINGER。最大超时值是特定于平台的。 该设置仅影响套接字关闭。
大家都是这么说的,当调用 Socket 的 close 方法后,没有发送的数据将不再发送,设置这个值的话,Socket 会等待指定的时间发送完数据包。说实话,经过我简单的测试,对于一般数据量来说,几十 K 左右,即便直接关闭 Socket 的连接,服务端也是可以收到数据的。
所以对于一般应用没必要设置这个值,当数据量发送过大抛出异常时,再来设置这个值也不晚。那么到达逗留超时值时,套接字将通过 TCP RST 强制性 关闭。启用超时值为零的选项将立即强制关闭。如果指定的超时值大于 65,535,则其将被减少到 65,535。
一般来说当客户端想服务器发送数据的时候,会根据当前数据量来决定是否发送,如果数据量过小,那么系统将会根据 Nagle 算法(暂时还没研究),来决定发送包的合并,也就是说发送会有延迟,这在有时候是致命的,比如说对实时性要求很高的消息发送,在线对战游戏等,即便数据量很小也要求立即发送,如果稍有延迟就会感觉到卡顿,默认情况下 Nagle 算法是开启的,所以如果不打算有延迟,最好关闭它。这样一旦有数据将会立即发送而不会写入缓冲区。
但是对延迟要求不是特别高下还是可以使用的,还是可以提升网络传输效率的。
默认都是 8K,如果有需要可以修改,通过相应的 set 方法。不建议修改的太小,设置太小数据传输将过于频繁。太大了将会造成消息停留。
不过我对这个经过测试后有以下结论:
虽然说当设置连接连接的读超时为 0,即无限等待时,Socket 不会被主动关闭,但是总会有莫名其妙的软件来检测你的连接是否有数据发送,长时间没有数据传输的连接会被它们关闭掉。
因此通过设置这个选项为 true,可以有如下效果:当 2 个小时(具体的实现而不同)内在任意方向上都没有跨越套接字交换数据,则 TCP 会自动发送一个保持存活的消息到对面。将会有以下三种响应:
所以对于构建长时间连接的 Socket 还是配置上 SO_KEEPALIVE 比较好。
这个异常的含义是,我正在写数据的时候,你把连接给关闭了。这个异常在一般正常的编码是不会出现这个异常的,因为用户通常会判断是否读到流的末尾了,读到末尾才会进行关闭操作,如果出现这个异常,那就检查一下判断是否读到流的末尾逻辑是否正确。
最近在看《TCP/IP 详解 卷 1:协议》,关于 TCP/IP 我觉得讲解的非常详细,我做了点摘抄,可以大致看看,非常建议大家阅读下这本书。通常 TCP/IP 分为四层:
也就是说 Socket 实际上是归属于应用层,使用的事运输层的 TCP,使用 SocketServer 监听的端口,也是可以被 Telnet 连接的。可以看下面两行代码:
- ServerSocket server =new ServerSocket(port);
- Socket socket = server.accept();
在什么情况获取到这个 Socket 呢,通过理论加测试,结论是在三次握手操作后,系统才会将这个连接交给应用层,ServerSocket 才知道有一个连接过来了。那么系统当接收到一个 TCP 连接请求后,如果上层还没有接受它(假如 SocketServer 循环处理 Socket,一次一个),那么系统将缓存这个连接请求,既然是缓存那么就是有限度的,书上介绍的是缓存 3 个,但是经过我的本机测试是 50 个,也就是说,系统将会为应用层的 Socket 缓存 50 和 TCP 连接(这是和系统底层有关系的),当超过指定数量后,系统将会拒绝连接。
假如缓存的 TCP 连接请求发送来数据,那么系统也会缓存这些数据,等待 SocketServer 获得这个连接的时候一并交给它,这个会在后期学习 NIO 进行详解。
换句话说,系统接收 TCP 连接请求放入缓存队列,而 SocketServer 从缓存队列获取 Socket。
而上面例子中的为了让服务端知道发送完消息的,关闭输出流的操作:
- socket.shutdownOutput();
其实是对应着四次挥手的第一次:
也就是上面说的主动关闭,FIN_WAIT_1,这样服务端就能得知客户端发送完消息,此时服务端可以选择关闭连接,也可以选择发送数据后关闭连接:
这就是 TCP 所说的半关闭。其实很多知识都是想通的,多学点基础知识还是有必要的。
RMI 基础知识就不多介绍了(后续会写,敬请期待),现在假定你对 RMI 有所了解,那么一般就会对这两种技术有所比较。或者说在应用的时候就会想用那种技术比较好。
RMI 全称:Remote Method Invocation-远程方法调用,通过名字其实就能对这种技术有个初步的了解。现在我就简单说说我对这两种技术的想法。
这个待写,等我写完 RMI 博客的时候补上,那时候会更细致的了解下。
这一段涉及到 UDP,依然和上面一样,后续会补上。
单单关于 Java 的 Socket 编程已经基本介绍完成了,当然还有更深层次的知识没有涉及到,后续如果能有接触也会写出来,希望我的文章能帮助到有需要的人,如果有什么不对的地方请指出,禁止转载。
来源: http://www.cnblogs.com/yiwangzhibujian/p/7107785.html