Java中的Socket通信(TCP)

来源:互联网 发布:淘宝怎么删除评价 编辑:程序博客网 时间:2024/06/05 00:17

我的个人网站

Xuejianxin’s Blog

Google Blog

Xuejianxin’s Blog


Android自定义View学习

Android自定义View之常用工具源码分析

Android自定义View之onMeasure()源码分析

Android自定义View之onLayout()的源码分析


如果觉得我的文章还行的话,也可以关注我的公众号,里面也会第一时间更新,并且会有更多的关于技术的最新资讯和一些个人感想。

扫码关注

扫码关注


什么是Socket

Java中的数据通信主要为Socket通信,Socket又称套接字,在程序内部提供了与外界通信的端口,即端口通信。通过建立socket连接,可为通信双方的数据传输传提供通道。Socket通信又分为TCP/IP通信和UDP通信,本文将重点介绍基于TCP/IP通信,但也会简单讲解基于UDP的Socket通信。Socket通信基于TCP/IP协议的通信则是在双方建立起连接后就可以直接进行数据的传输,在连接时可实现信息的主动推送,而不需要每次由客户端想服务器发送请求。而UDP则是提供无连接的数据报服务,UDP在发送数据报前不需建立连接,不对数据报进行检查即可发送数据包。

数据报通信(UDP)

数据报通信协议,就是我们常说的UDP(User Data Protocol 用户数据报协议)。UDP是一种无连接的协议,这就意味着我们每次发送数据报时,需要同时发送本机的socket描述符和接收端的socket描述符。因此,我们在每次通信时都需要发送额外的数据。

流通信(TCP)

流通信协议,也叫做TCP(Transfer Control Protocol,传输控制协议)。和UDP不同,TCP是一种基于连接的协议。在使用流通信之前,我们必须在通信的一对儿socket之间建立连接。其中一个socket作为服务器进行监听连接请求。另一个则作为客户端进行连接请求。一旦两个socket建立好了连接,他们可以单向或双向进行数据传输。

注意事项

在UDP中,每次发送数据报时,需要附带上本机的socket描述符和接收端的socket描述符。而由于TCP是基于连接的协议,在通信的socket对之间需要在通信之前建立连接,因此会有建立连接这一耗时存在于TCP协议的socket编程。

在UDP中,数据报数据在大小上有64KB的限制。而TCP中也不存在这样的限制。一旦TCP通信的socket对建立了连接,他们之间的通信就类似IO流,所有的数据会按照接受时的顺序读取。

UDP是一种不可靠的协议,发送的数据报不一定会按照其发送顺序被接收端的socket接受。然后TCP是一种可靠的协议。接收端收到的包的顺序和包在发送端的顺序是一致的。

接下来我们通过一个例子来了解一下基于TCP/IP的Socket通信,首先是一个单线程的例子,然后我们再来看较为复杂的多线程例子。
首先我们还得了解一些知识,那就是什么是端口号和IP地址,localhost又是什么?


知识扩展

互联网地址(ip地址)

网络上每一个节点都必须有一个独立的Internet地址(也叫做IP地址)。现在,通常使用的IP地址是一个32bit的数字,也就是我们常说的IPv4标准,这32bit的数字分成四组,也就是常见的255.255.255.255的样式。IPv4标准上,地址被分为五类,我们常用的是B类地址。具体的分类请参考其他文档。需要注意的是IP地址是网络号+主机号的组合,这非常重要。

RFC

RFC是什么?RFC就是tcp/ip协议的标准文档,在这里我们可以看到RFC那长长的定义列表,现在它一共有4000多个协议的定义,当然,我们所要学习的,也就是那么十几个协议而已。

端口号(port)

注意,这个号码是用在TCP,UDP上的一个逻辑号码,并不是一个硬件端口,我们平时说把某某端口封掉了,也只是在IP层次把带有这个号码的IP包给过滤掉了而已。

localhost

localhost指的是自己电脑的IP地址,一般为127.0.0.1

TCP/ip协议

TCP通信过程包括三个步骤:建立TCP连接通道,传输数据,断开TCP连接通道。如图1所示,给出了TCP通信过程的示意图。

这里写图片描述

图一 TCP的三次握手,四次挥手

图1主要包括三部分:建立连接、传输数据、断开连接。

  • 1)建立TCP连接很简单,通过三次握手便可建立连接。

  • 2)建立好连接后,开始传输数据。TCP数据传输牵涉到的概念很多:超时重传、快速重传、流量控制、拥塞控制等等。

  • 3)断开连接的过程也很简单,通过四次握手完成断开连接的过程。

三次握手建立连接:

第一次握手:客户端发送syn包(seq=x)到服务器,并进入SYN_SEND状态,等待服务器确认;

第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(seq=y),即SYN+ACK包,此时服务器进入SYN_RECV状态;

第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。

握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。理想状态下,TCP连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP 连接都将被一直保持下去。

传输数据过程:

  • a.超时重传

超时重传机制用来保证TCP传输的可靠性。每次发送数据包时,发送的数据报都有seq号,接收端收到数据后,会回复ack进行确认,表示某一seq号数据已经收到。发送方在发送了某个seq包后,等待一段时间,如果没有收到对应的ack回复,就会认为报文丢失,会重传这个数据包。

  • b.快速重传

接受数据一方发现有数据包丢掉了。就会发送ack报文告诉发送端重传丢失的报文。如果发送端连续收到标号相同的ack包,则会触发客户端的快速重传。比较超时重传和快速重传,可以发现超时重传是发送端在傻等超时,然后触发重传;而快速重传则是接收端主动告诉发送端数据没收到,然后触发发送端重传。

  • c.流量控制

这里主要说TCP滑动窗流量控制。TCP头里有一个字段叫Window,又叫Advertised-Window,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。
滑动窗可以是提高TCP传输效率的一种机制。

  • d.拥塞控制

滑动窗用来做流量控制。流量控制只关注发送端和接受端自身的状况,而没有考虑整个网络的通信情况。拥塞控制,则是基于整个网络来考虑的。考虑一下这样的场景:某一时刻网络上的延时突然增加,那么,TCP对这个事做出的应对只有重传数据,但是,重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是,这个情况就会进入恶性循环被不断地放大。试想一下,如果一个网络内有成千上万的TCP连接都这么行事,那么马上就会形成“网络风暴”,TCP这个协议就会拖垮整个网络。为此,TCP引入了拥塞控制策略。拥塞策略算法主要包括:慢启动,拥塞避免,拥塞发生,快速恢复。

四次握手断开连接:

第一次挥手:主动关闭方发送一个FIN,用来关闭主动方到被动关闭方的数据传送,也就是主动关闭方告诉被动关闭方:我已经不会再给你发数据了(当然,在fin包之前发送出去的数据,如果没有收到对应的ack确认报文,主动关闭方依然会重发这些数据),但此时主动关闭方还可以接受数据。

第二次挥手:被动关闭方收到FIN包后,发送一个ACK给对方,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号)。

第三次挥手:被动关闭方发送一个FIN,用来关闭被动关闭方到主动关闭方的数据传送,也就是告诉主动关闭方,我的数据也发送完了,不会再给你发数据了。

第四次挥手:主动关闭方收到FIN后,发送一个ACK给被动关闭方,确认序号为收到序号+1,至此,完成四次挥手。

这里写图片描述

图2 TCP状态转移图

  • 1.CLOSED:起始点,在超时或者连接关闭时候进入此状态。

  • 2.LISTEN:服务端在等待连接过来时候的状态,服务端为此要调用socket,bind,listen函数,就能进入此状态。此称为应用程序被动打开(等待客户端来连接)。

  • 3.SYN_SENT:客户端发起连接,发送SYN给服务器端。如果服务器端不能连接,则直接进入CLOSED状态。

  • 4.SYN_RCVD:跟3对应,服务器端接受客户端的SYN请求,服务器端由LISTEN状态进入SYN_RCVD状态。同时服务器端要回应一个ACK,同时发送一个SYN给客户端;另外一种情况,客户端在发起SYN的同时接收到服务器端得SYN请求,客户端就会由SYN_SENT到SYN_RCVD状态。

  • 5.ESTABLISHED:服务器端和客户端在完成3次握手进入状态,说明已经可以开始传输数据了。

    以上是建立连接时服务器端和客户端产生的状态转移说明。相对来说比较简单明了,如果你对三次握手比较熟悉,建立连接时的状态转移还是很容易理解。

    下面,我们来看看连接关闭时候的状态转移说明,关闭需要进行4次双方的交互,还包括要处理一些善后工作(TIME_WAIT状态),注意,这里主动关闭的一方或被动关闭的一方不是指特指服务器端或者客户端,是相对于谁先发起关闭请求来说的:

  • 6.FIN_WAIT_1:主动关闭的一方,由状态5进入此状态。具体的动作是发送FIN给对方。

  • 7.FIN_WAIT_2:主动关闭的一方,接收到对方的FIN-ACK(即fin包的回应包),进入此状态。

  • 8.CLOSE_WAIT:接收到FIN以后,被动关闭的一方进入此状态。具体动作是接收到FIN,同时发送ACK。(之所以叫close_wait可以理解为被动关闭方此时正在等待上层应用发出关闭连接指令)

  • 9.LAST_ACK:被动关闭的一方,发起关闭请求,由状态8进入此状态。具体动作是发送FIN给对方,同时在接收到ACK时进入CLOSED状态。

  • 10.CLOSING:两边同时发起关闭请求时,会由FIN_WAIT_1进入此状态。具体动作是接收到FIN请求,同时响应一个ACK。

  • 11.TIME_WAIT:最纠结的状态来了。从状态图上可以看出,有3个状态可以转化成它,我们一一来分析:

  • a.由FIN_WAIT_2进入此状态:在双方不同时发起FIN的情况下,主动关闭的一方在完成自身发起的关闭请求后,接收到被动关闭一方的FIN后进入的状态。

  • b.由CLOSING状态进入:双方同时发起关闭,都做了发起FIN的请求,同时接收到了FIN并做了ACK的情况下,由CLOSING状态进入。

  • c.由FIN_WAIT_1状态进入:同时接受到FIN(对方发起),ACK(本身发起的FIN回应),与b的区别在于本身发起的FIN回应的ACK先于对方的FIN请求到达,而b是FIN先到达。这种情况概率最小。

    关闭的4次连接最难理解的状态是TIME_WAIT,存在TIME_WAIT的2个理由:

  • 1.可靠地实现TCP全双工连接的终止。

  • 2.允许老的重复分节在网络中消逝。

慢热启动算法 – Slow Start

首先,我们来看一下TCP的慢热启动。慢启动的意思是,刚刚加入网络的连接,一点一点地提速,不要一上来就像那些特权车一样霸道地把路占满。新同学上高速还是要慢一点,不要把已经在高速上的秩序给搞乱了。

慢启动的算法如下(cwnd全称Congestion Window):

1)连接建好的开始先初始化cwnd = 1,表明可以传一个MSS大小的数据。

2)每当收到一个ACK,cwnd++; 呈线性上升

3)每当过了一个RTT,cwnd = cwnd*2; 呈指数让升

4)还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”(后面会说这个算法)
所以,我们可以看到,如果网速很快的话,ACK也会返回得快,RTT也会短,那么,这个慢启动就一点也不慢。

拥塞避免算法 – Congestion Avoidance

前面说过,还有一个ssthresh(slow start threshold),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”。一般来说ssthresh的值是65535,单位是字节,当cwnd达到这个值时后,算法如下:

1)收到一个ACK时,cwnd = cwnd + 1/cwnd

2)当每过一个RTT时,cwnd = cwnd + 1

这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。

拥塞状态时的算法

前面我们说过,当丢包的时候,会有两种情况:

1)等到RTO超时,重传数据包。TCP认为这种情况太糟糕,反应也很强烈。

  • sshthresh = cwnd /2

  • cwnd 重置为 1

  • 进入慢启动过程

2)Fast Retransmit算法,也就是在收到3个duplicate ACK时就开启重传,而不用等到RTO超时。

  • TCP Tahoe的实现和RTO超时一样。

  • TCP Reno的实现是:

  • cwnd = cwnd /2

  • sshthresh = cwnd

  • 进入快速恢复算法——Fast Recovery

上面我们可以看到RTO超时后,sshthresh会变成cwnd的一半,这意味着,如果cwnd<=sshthresh时出现的丢包,那么TCP的sshthresh就会减了一半,然后等cwnd又很快地以指数级增涨爬到这个地方时,就会成慢慢的线性增涨。我们可以看到,TCP是怎么通过这种强烈地震荡快速而小心得找到网站流量的平衡点的。

快速恢复算法 – Fast Recovery

TCP Reno

这个算法定义在RFC5681。快速重传和快速恢复算法一般同时使用。快速恢复算法是认为,你还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。 注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:

  • cwnd = cwnd /2 ●sshthresh = cwnd

    然后,真正的Fast Recovery算法如下:

  • cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了)

  • 重传DuplicatedACKs指定的数据包

  • 如果再收到 duplicated Acks,那么cwnd = cwnd +1 ●如果收到了新的Ack,那么,cwnd = sshthresh ,然后就进入了拥塞避免的算法了。

如果你仔细思考一下上面的这个算法,你就会知道,上面这个算法也有问题,那就是——它依赖于3个重复的Acks。注意,3个重复的Acks并不代表只丢了一个数据包,很有可能是丢了好多包。但这个算法只会重传一个,而剩下的那些包只能等到RTO超时,于是,进入了恶梦模式——超时一个窗口就减半一下,多个超时会超成TCP的传输速度呈级数下降,而且也不会触发Fast Recovery算法了。

TCP New Reno

于是,1995年,TCP New Reno(参见 RFC 6582 )算法提出来,主要就是在没有SACK的支持下改进Fast Recovery算法的——

  • 当sender这边收到了3个Duplicated Acks,进入FastRetransimit模式,开发重传重复Acks指示的那个包。如果只有这一个包丢了,那么,重传这个包后回来的Ack会把整个已经被sender传输出去的数据ack回来。如果没有的话,说明有多个包丢了。我们叫这个ACK为PartialACK。

  • 一旦Sender这边发现了PartialACK出现,那么,sender就可以推理出来有多个包被丢了,于是乎继续重传sliding window里未被ack的第一个包。直到再也收不到了Partial Ack,才真正结束Fast Recovery这个过程。

我们可以看到,这个“Fast Recovery的变更”是一个非常激进的玩法,他同时延长了Fast Retransmit和Fast Recovery的过程。


单线程的Socket服务器和客户端程序

典型的TCP服务端执行如下两步操作:

  • 1.创建一个ServerSocket实例并指定本地端口,用来监听客户端在该端口发送的TCP连接请求;
  • 2.重复执行:
  • 1.调用ServerSocket的accept()方法以获取客户端连接,并通过其返回值创建一个Socket实例;
  • 2.为返回的Socket实例开启新的线程,并使用返回的Socket实例的I/O流与客户端通信;
  • 3.通信完成后,使用Socket类的close()方法关闭该客户端的套接字连接。

这里写图片描述

基于单线程的Socket服务器

package Socket;/* * 基于TCP的服务器 * */import java.net.ServerSocket;import java.net.Socket;import java.io.OutputStream;import java.io.PrintWriter;import java.io.InputStream;import java.io.InputStreamReader; import java.io.BufferedReader;import java.io.IOException;public class Server_3 {    public static void main(String[] args ) {        try {            //1.创建一个服务器Socket对象,即ServerSocket,并绑定端口号             ServerSocket serverSocket = new ServerSocket(8888);             //2.调用accept()方法监听客户端端口发送过来的请求              System.out.println("服务器已经启动,正在等待客户端的连接");              Socket socket = serverSocket.accept();              //3.获取输入流,并读取客户端信息                    InputStream is = socket.getInputStream();              InputStreamReader isr= new InputStreamReader(is);//将字节流转换为字符流              BufferedReader br= new BufferedReader(isr);//为输入流添加缓冲               String info = null;               while((info=br.readLine()) != null){ //循环读取客户端信息                  System.out.println("我是服务器,客户端说:" + info);              }              socket.shutdownInput();//关闭输入流            //4.获取输出流,对客户端的请求作出响应              OutputStream os = socket.getOutputStream();              PrintWriter pw = new PrintWriter(os);              pw.write("Welcom ");              pw.flush();              //5.关闭资源              pw.close();              os.close();              is.close();              br.close();               socket.close();              serverSocket.close();         } catch (IOException e) {             e.printStackTrace();        }      }}   

典型的TCP客户端要经过下面三步操作:

  • 1.创建一个Socket实例:构造函数向指定的远程主机和端口建立一个TCP连接;
  • 2.通过套接字的I/O流与服务端通信;
  • 3.使用Socket类的close方法关闭连接。

服务端的工作是建立一个通信终端,并被动地等待客户端的连接

这里写图片描述

基于单线程的Socket客户端

package Socket;import java.net.Socket; import java.io.OutputStream; import java.io.PrintWriter;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStream;import java.io.InputStreamReader; public class SocketClient_3 {    public static void main(String[] args)  {         try {             //1.创建一个客户端Socket对象            Socket socket = new Socket("localhost",8888);            //2.获取输出流,向服务器发送信息            OutputStream os = socket.getOutputStream();            PrintWriter pw = new PrintWriter(os);             pw.write("用户名:Administrator;密码:6666");            pw.flush();            socket.shutdownOutput();            //3.获取输入流,读取服务器的响应信息            InputStream is = socket.getInputStream();            BufferedReader br = new BufferedReader(new InputStreamReader(is));            String info = null;//为输入流添加缓冲             while((info=br.readLine()) != null){ //循环读取客户端信息                  System.out.println("我是客户端,服务器说:" + info);              }             //4.关闭资源            br.close();            is.close();            pw.close();              socket.close();        } catch (IOException e) {              e.printStackTrace();        }       }}

基于多线程的Socket服务器和客户端程序

首先创建一个服务器线程处理类,通过构造方法初始化和本线程相关的socket,线程执行的操作:响应客户端的请求
线程类代码如下:

package ServerThread;/* * 服务器线程处理类 * */import java.io.InputStream;import java.io.OutputStream;import java.io.PrintWriter;import java.io.InputStreamReader;import java.io.BufferedReader;import java.io.IOException;import java.net.Socket;public class ServerThread extends Thread{    //和本线程相关的socket,通过构造方法初始化socket    Socket socket = null;     public ServerThread(Socket socket){        this.socket=socket;    }    //线程执行的操作:响应客户端的请求    public void run(){         InputStream is=null;        InputStreamReader isr=null;        BufferedReader br=null;         OutputStream os=null;        PrintWriter pw=null;        try {            is = socket.getInputStream();            isr = new  InputStreamReader(is);            br = new BufferedReader(isr);             String info = null;            while((info = br.readLine())!= null){                 System.out.println("我是服务器,客户端说:" + info);            }            socket.shutdownInput();            os = socket.getOutputStream();            pw = new PrintWriter(os);            pw.write("欢迎您");            pw.flush();        } catch (IOException e) {             e.printStackTrace();        } finally{              try{            if(pw!=null)                pw.close();            if(is!=null)                is.close();            if(isr!=null)                isr.close();            if(br!=null)                br.close();            if(socket!=null)                socket.close();            }catch(Exception e){                e.printStackTrace();            }        }     }}

服务器代码稍作改动,添加执行线程类的方法

package ServerThread;  *   * 利用多线程实现服务器与多个客户端之间的通信   * */  import java.net.ServerSocket;  import java.net.Socket;  public class Server_1 {      public static void main(String[] args){          try{            ServerSocket serverSocket = new ServerSocket(8888);            Socket socket = null;            System.out.println("服务器已经启动,正在等待客户端的连接");             int count = 0;            while(true){            //循环监听客户端请求            serverSocket.accept();            //创建一个新的线程            ServerThread_1 serverThread_1 = new ServerThread_1(socket);            //启动该线程            serverThread_1.start();            count ++;            System.out.println("客户端的数量" + count);            }        } catch(Exception e){            e.printStackTrace();            }        }    }

多线程Socket通信的客户端由于线程类的存在则大大简化,直接看代码和注释

package ServerThread;import java.io.InputStream;import java.io.OutputStream;import java.io.PrintWriter;import java.io.BufferedReader;import java.io.InputStreamReader; import java.net.Socket;public class ClientSocket {    public static void main(String[] args){        try{            Socket socket = new Socket("localhost",8888);            OutputStream os = socket.getOutputStream();            PrintWriter pw = new PrintWriter(os);            pw.write("用户名:Administrator;密码:1234");            pw.flush();             socket.shutdownOutput();            InputStream is = socket.getInputStream();            BufferedReader br = new BufferedReader(new InputStreamReader(is));            String info = null;            while((info = br.readLine()) !=null){                System.out.println("我是客户端,服务器说:" + info);            }             socket.close();            pw.close();            is.close();            br.close();        }catch(Exception e){            e.printStackTrace();        }    }}

这里写图片描述

这里写图片描述


重写总结

重写这篇文章最主要是因为排版不够好,代码有一些问题,也有不满意之前编辑器的原因,当然了,最重要的原因是更加详细的讲解了TCP/ip协议。

1 0
原创粉丝点击