Java网络编程编程之TCP编程和UDP编程
Posted 活跃的咸鱼
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java网络编程编程之TCP编程和UDP编程相关的知识,希望对你有一定的参考价值。
1.TCP编程
1.1 网络相关基础概念
我们在学习网络编程前先来复习一下IP地址端口号协议,套接字的相关概念。
IP地址:
用来唯一标识全球连接在互联网上的主机的接口的标识符
端口:
端口是指软件端口,是应用层的各种协议进程与运输实体进行层间交互的一种地址,主机通过端口将数据报分发给不同的进程,一个主机总共有65535个端口,
协议:
协议,网络协议的简称,网络协议是通信计算机双方必须共同遵从的一组约定。
套接字:
所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口
基本介绍:
- 套接字(Socket)开发网络应用程序被广泛采用,以至于成为事实上的标准。
- 通信的两端都要有Socket,是两台机器间通信的端点
- 网络通信其实就是Socket间的通信。
- Socket允许程序把网络连接当成个流,数据在两个Socket间通过IO传输。
- 一般主动发起通信的应用程序属客户端,等待通信请求的为服务端
1.2TCP协议介绍
TCP协议:
传输控制协议(TCP,Transmission Control Protocol)是为了在不可靠的互联网络上提供可靠的端到端字节流而专门设计的一个传输协议。
TCP协议特点:
- 基于流的方式;
- 面向连接;
- 可靠通信方式;
- 在网络状况不佳的时候尽量降低系统由于重传带来的带宽开销;
- 通信连接维护是面向通信的两个端点的,而不考虑中间网段和节点。
TCP 的包头格式:
TCP 的包头有哪些内容,分别有什么用
-
首先,源端口和目标端口是不可少的。源端口和目的端口,各占2个字节。
-
接下来是包的序号占4个字节。主要是为了解决乱序问题。不编好号怎么知道哪个先来,哪个后到
-
确认序号占4个字节。发出去的包应该有确认,这样能知道对方是否收到,如果没收到就应该重新发送,这个解决的是不丢包的问题
-
数据偏移,占4位,它指出TCP报文的数据距离TCP报文段的起始处有多远;保留,占6位,保留今后使用;
-
紧急URG,当URG=1,表明紧急指针字段有效。告诉系统此报文段中有紧急数据;
确认ACK,仅当ACK=1时,确认号字段才有效。TCP规定,在连接建立后所有报文的传输都必须把ACK置1; -
推送PSH,当两个应用进程进行交互式通信时,有时在一端的应用进程希望在键入一个命令后立即就能收到对方的响应,这时候就将PSH=1;
复位RST,当RST=1,表明TCP连接中出现严重差错,必须释放连接,然后再重新建立连接; -
同步SYN,在连接建立时用来同步序号。当SYN=1,ACK=0,表明是连接请求报文,若同意连接,则响应报文中应该使SYN=1,ACK=1;
终止FIN,用来释放连接。当FIN=1,表明此报文的发送方的数据已经发送完毕,并且要求释放; -
窗口大小,TCP 要做流量控制,需要通信双方各声明一个窗口,标识自己当前的处理能力。
-
检验和,占2字节,校验首部和数据这两部分;
紧急指针,占2字节,指出本报文段中的紧急数据的字节数;
选项,长度可变,定义一些其他的可选的参数。
通过对 TCP 头的解析,我们知道要掌握 TCP 协议,应该重点关注以下问题:
- 顺序问题
- 丢包问题
- 连接维护
- 流量控制
- 拥塞控制
TCP 的三次握手:
所有的问题,首先都要建立连接,所以首先是连接维护的问题
TCP 的建立连接称为三次握手,可以简单理解为下面这种情况
A:您好,我是 A
B:您好 A,我是 B
A:您好 B
对于 A 来说它发出请求,并收到了 B 的响应,对于 B 来说它响应了 A 的请求,并且也接收到了响应。
TCP 的三次握手除了建立连接外,主要还是为了沟通 TCP 包的序号问题。
A 告诉 B,我发起的包的序号是从哪个号开始的,B 同样也告诉 A,B 发起的 包的序号是从哪个号开始的。
双方建立连接之后需要共同维护一个状态机,在建立连接的过程中,双方的状态变化时序图如下所示:
这是网上经常见到的一张图,刚开始的时候,客户端和服务器都处于 CLOSED 状态,先是服务端主动监听某个端口,处于 LISTEN 状态。然后客户端主动发起连接 SYN,之后处于 SYN-SENT 状态。服务端接收到了发起的连接,返回 SYN,并且 ACK ( 确认 ) 客户端的 SYN,之后处于 SYN-SENT 状态。客户端接收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,之后就处于 ESTAVLISHED 状态,因为它一发一收成功了。服务端收到 ACK 的 ACK 之后,也处于 ESTABLISHED 状态,因为它也一发一收了。
为什么是三次握手两次或者四次不行吗?
一句话,主要防止已经失效的连接请求报文突然又传送到了服务器,从而产生错误。
如果使用的是两次握手建立连接,假设有这样一种场景,客户端发送了第一个请求连接并且没有丢失,只是因为在网络结点中滞留的时间太长了,由于TCP的客户端迟迟没有收到确认报文,以为服务器没有收到,此时重新向服务器发送这条报文,此后客户端和服务器经过两次握手完成连接,传输数据,然后关闭连接。此时此前滞留的那一次请求连接,网络通畅了到达了服务器,这个报文本该是失效的,但是,两次握手的机制将会让客户端和服务器再次建立连接,这将导致不必要的错误和资源的浪费。
如果采用的是三次握手,就算是那一次失效的报文传送过来了,服务端接受到了那条失效报文并且回复了确认报文,但是客户端不会再次发出确认。由于服务器收不到确认,就知道客户端并没有请求连接。
因为三次握手已经能够建立连接四次握手的话就会浪费资源显得多余了。
TCP 四次挥手
说完建立连接,再说下断开连接,也被称为四次挥手,可以简单理解如下
A:B 啊,我不想玩了
B:哦,你不想玩了啊,我知道了 这个时候,只是 A 不想玩了,即不再发送数据,但是 B可能还有未发送完的数据,所以需要等待 B 也主动关闭。
B:A 啊,好吧,我也不玩了,拜拜
A:好的,拜拜
这样整个连接就关闭了,当然上面只是正常的状态,也有些非正常的状态(比如 A 说完不玩了,直接跑路,B 发起的结束得不到 A 的回答,不知道该怎么办或则 B 直接跑路 A 不知道该怎么办),TCP 协议专门设计了几个状态来处理这些非正常状态
断开的时候,当 A 说不玩了,就进入 FIN_WAIT_1 的状态,B 收到 A 不玩了的消息后,进入 CLOSE_WAIT 的状态。
A 收到 B 说知道了,就进入 FIN_WAIT_2 的状态,如果 B 直接跑路,则 A 永远处与这个状态。TCP 协议里面并没有对这个状态的处理,但 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间。
如果 B 没有跑路,A 接收到 B 的不玩了请求之后,从 FIN_WAIT_2 状态结束,按说 A 可以跑路了,但是如果 B 没有接收到 A 跑路的 ACK 呢,就再也接收不到了,所以这时候 A 需要等待一段时间,因为如果 B 没接收到 A 的 ACK 的话会重新发送给 A,所以 A 的等待时间需要足够长。
累计确认
TCP 如何实现可靠传输?
首先为了保证顺序性,每个包都有一个 ID。在建立连接的时候会商定起始 ID 是什么,然后按照 ID 一个个发送,为了保证不丢包,需要对发送的包都要进行应答,当然,这个应答不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式成为累计应答或累计确认。
为了记录所有发送的包和接收的包,TCP 需要发送端和接收端分别来缓存这些记录,发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分
- 发送并且确认的
- 发送尚未确认的
- 没有发送等待发送的
- 没有发送并且暂时不会发送的
这里的第三部分和第四部分就属于流量控制的内容
在 TCP 里,接收端会给发送端报一个窗口大小,叫 Advertised window。这个窗口应该等于上面的第二部分加上第三部分,超过这个窗口,接收端做不过来,就不能发送了
于是,发送端要保持下面的数据结构
对于接收端来讲,它的缓存里面的内容要简单一些
- 接收并且确认过的
- 还没接收,但是马上就能接收的
- 还没接收,但也无法接收的
对应的数据结构如下
顺序问题和丢包问题
结合上面的图看,在发送端,1、2、3 已发送并确认;4、5、6、7、8、9 都是发送了还没确认;10、11、12 是还没发出的;13、14、15 是接收方没有空间,不准备发的。
在接收端来看,1、2、3、4、5 是已经完成 ACK 但是还没读取的;6、7 是等待接收的;8、9 是已经接收还没有 ACK 的。
发送端和接收端当前的状态如下:
- 1、2、3 没有问题,双方达成了一致
- 4、5 接收方说 ACK 了,但是发送方还没收到
- 6、7、8、9 肯定都发了,但是 8、9 已经到了,6、7 没到,出现了乱序,缓存着但是没办法 ACK。
根据这个例子可以知道顺序问题和丢包问题都有可能存在,所以我们先来看确认与重传机制。
假设 4 的确认收到了,5 的 ACK 丢了,6、7 的数据包丢了,该怎么办?
一种方法是超时重试,即对每一个发送了但是没有 ACK 的包设定一个定时器,超过了一定的事件就重新尝试。这个时间必须大于往返时间,但也不宜过长,否则超时时间变长,访问就变慢了。
如果过一段时间,5、6、7 都超时了就会重新发送。接收方发现 5 原来接收过,于是丢弃 5;6 收到了,发送 ACK,要求下一个是 7,7 不幸又丢了。当 7 再次超时的时候,TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会讲下一次超时时间间隔设为先前值的两倍。
超时重传的机制是超时周期可能相对较长,是否有更快的方式呢?
有一个快速重传的机制,即当接收方接收到一个序号大于期望的报文段时,就检测到了数据流之间的间隔,于是发送三个冗余的 ACK,客户端接收到之后,知道数据报丢失,于是重传丢失的报文段。
例如,接收方发现 6、8、9 都接收了,但是 7 没来,所以肯定丢了,于是发送三个 6 的 ACK,要求下一个是 7。客户端接收到 3 个,就会发现 7 的确又丢了,不等超时,马上重发。
流量控制的问题
在流量控制的机制里面,在对于包的确认中,会携带一个窗口的大小
简单的说一下就是接收端在发送 ACK 的时候会带上缓冲区的窗口大小,但是一般在窗口达到一定大小才会更新窗口,因为每次都更新的话,刚空下来就又被填满了
拥塞控制的问题
也是通过窗口的大小来控制的,但是检测网络满不满是个挺难的事情,所以 TCP 发送包经常被比喻成往谁管理灌水,所以拥塞控制就是在不堵塞,不丢包的情况下尽可能的发挥带宽。
水管有粗细,网络有带宽,即每秒钟能发送多少数据;水管有长度,端到端有时延。理想状态下,水管里面的水 = 水管粗细 * 水管长度。对于网络上,通道的容量 = 带宽 * 往返时延。
如果我们设置发送窗口,使得发送但未确认的包为通道的容量,就能撑满整个管道。
如图所示,假设往返时间为 8 秒,去 4 秒,回 4 秒,每秒发送一个包,已经过去了 8 秒,则 8 个包都发出去了,其中前四个已经到达接收端,但是 ACK 还没返回,不能算发送成功,5-8 后四个包还在路上,还没被接收,这个时候,管道正好撑满,在发送端,已发送未确认的 8 个包,正好等于带宽,也即每秒发送一个包,也即每秒发送一个包,乘以来回时间 8 秒。
如果在这个基础上调大窗口,使得单位时间可以发送更多的包,那么会出现接收端处理不过来,多出来的包会被丢弃,这个时候,我们可以增加一个缓存,但是缓存里面的包 4 秒内肯定达不到接收端课,它的缺点会增加时延,如果时延达到一定程度就会超时重传
TCP 拥塞控制主要来避免两种现象,包丢失和超时重传,一旦出现了这些现象说明发送的太快了,要慢一点。
具体的方法就是发送端慢启动,比如倒水,刚开始倒的很慢,渐渐变快。然后设置一个阈值,当超过这个值的时候就要慢下来
慢下来还是在增长,这时候就可能水满则溢,出现拥塞,需要降低倒水的速度,等水慢慢渗下去。
拥塞的一种表现是丢包,需要超时重传,这个时候,采用快速重传算法,将当前速度变为一半。所以速度还是在比较高的值,也没有一夜回到解放前。
1.3 TCP编程案例
Java提供了一个包:java.net下的类都是用于网络通信。Java提供了基于套接字(端口)Socket的网络通信模式,我们基于这种模式就可以直接实TCP通信。只要用Socket通信,那么就是基于TCP可靠传输通信。
TCP编程常用API
InetAddress:此类表示Internet协议(IP)地址,常用方法如下:
方法名 | 功能 |
---|---|
getAllByName(String host) | 给定主机的名称,根据系统上配置的名称服务返回其IP地址数组。 |
getAddress() | 返回此 InetAddress对象的原始IP地址。 |
getByAddress(byte[] addr) | 给出原始IP地址的 InetAddress对象。 |
getByAddress(String host, byte[] addr) | 根据提供的主机名和IP地址创建InetAddress。 |
getByName(String host) | 确定主机名称的IP地址。 |
getHostName() | 获取此IP地址的主机名。 |
getLocalHost() | 返回本地主机的地址。 |
Socket:该类实现客户端套接字(也称为“套接字”)。常用方法如下
方法名 | 功能 |
---|---|
Socket() | 创建一个未连接的套接字,并使用系统默认类型的SocketImpl。 |
Socket(String host, int port) | 创建流套接字并将其连接到指定主机上的指定端口号。 |
Socket(InetAddress address, int port) | 创建流套接字并将其连接到指定IP地址的指定端口号。 |
bind(SocketAddress bindpoint) | 将套接字绑定到本地地址。 |
close() | 关闭此套接字。 |
connect(SocketAddress endpoint) | 将此套接字连接到服务器。 |
getChannel() | 返回与此套接字相关联的唯一的SocketChannel对象(如果有)。 |
getInetAddress() | 返回套接字所连接的地址。 |
getInputStream() | 返回此套接字的输入流。 |
getLocalAddress() | 获取套接字所绑定的本地地址。 |
getLocalPort() | 返回此套接字绑定到的本地端口号。 |
getLocalSocketAddress() | 返回此套接字绑定到的端点的地址。 |
getOutputStream() | 返回此套接字的输出流。 |
getPort() | 返回此套接字连接到的远程端口号。 |
isClosed() | 返回套接字的关闭状态。 |
shutdownInput() | 将此套接字的输入流放置在“流的末尾”。 |
shutdownOutput() | 禁用此套接字的输出流。 |
ServerSocket:这个类实现了服务器套接字。 服务器套接字等待通过网络进入的请求。 它根据该请求执行一些操作,然后可能将结果返回给请求者。 常用方法如下:
方法名 | 功能 |
---|---|
ServerSocket(int port) | 创建绑定到指定端口的服务器套接字。 |
accept() | 侦听要连接到此套接字并接受它。 |
close() | 关闭此套接字。 |
getChannel() | 返回与此套接字相关联的唯一的ServerSocketChannel对象(如果有)。 |
getInetAddress() | 返回此服务器套接字的本地地址。 |
getLocalPort() | 返回此套接字正在侦听的端口号。 |
isClosed() | 返回ServerSocket的关闭状态。 |
TCP编程分为客户端和服务端的开发:
客户端:
(1)创建一个Socket的通信管道,请求与服务端的端口连接。
(2)从Socket管道中得到一个字节输出流。
(3)把字节流改装成自己需要的流进行数据的发送
(4)通信结束关闭流
服务端:
(1)注册端口
(2)开始等待接收客户端的连接,得到一个端到端的Socket管道
(3)从Socket管道中得到一个字节输入流。
(4)把字节输入流包装成自己需要的流进行数据的读取。
(5)通信结束关闭流
单向通信
public class Server {
public static void main(String[] args) throws Exception{
//注册端口
ServerSocket serverSocket = new ServerSocket(8888);
//开始等待接收客户端的连接,得到一个端到端的Socket管道
Socket socket = serverSocket.accept();
// 从Socket管道中得到一个字节输入流。把字节输入流包装成自己需要的流进行数据的读取。
BufferedInputStream bis=new BufferedInputStream(socket.getInputStream());
byte[] bytes=new byte[1024];
int length=0;
while((length= bis.read(bytes))>0){
System.out.println(new String(bytes,0, length));
}
//关闭流
socket.close();
serverSocket.close();
bis.close();
}
}
public class Client {
public static void main(String[] args) {
Socket socket=null;
BufferedOutputStream bos=null;
try {
//创建一个Socket的通信管道,请求与服务端的端口连接。
socket=new Socket(InetAddress.getLocalHost(),8888);
//从Socket管道中得到一个字节输出流。
// 把字节流改装成自己需要的流进行数据的发送
bos=new BufferedOutputStream(socket.getOutputStream());
bos.write("hello server".getBytes());
bos.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
bos.close();
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
双向通信
public class Server {
public static void main(String[] args) throws Exception{
//1.socket套接字
ServerSocket serverSocket=new ServerSocket(8888);
//2.监听端口
Socket socket=serverSocket.accept();
//3.响应
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is);
System.out.println(dis.readUTF());
//4.回信
OutputStream os= socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
dos.writeUTF("收到");
//5.关闭流
socket.close();
serverSocket.close();
dos.close();
dis.close();
}
}
public class Client{
public static void main(String[] args) throws Exception{
//1.连接socket
Socket socket=new Socket(InetAddress.getLocalHost(),8888);
//2.发送信息
OutputStream os= socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
dos.writeUTF("你好,收到请回复");
//3.接受服务端的信息
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is);
System.out.println(dis.readUTF());
//4.关闭连接
dos.close();
dis.close();
socket.close();
}
}
注意:如果这边双方通过BufferedOutputStream
通信在发送完消息之后需要有一个结束标记,否则双方会阻塞,因为对方不知道是否通信结束。
BufferedOutputStream bos=new BufferedOutputStream(socket.getOutputStream());
bos.write("hello client ".getBytes(StandardCharsets.UTF_8));
bos.flush();
socket.shutdownOutput();
文件上传
要求如下:
- 编写一个服务端,和一个客户端
- 服务器端在8888端口监听
- 客户端连接到服务端,发送一张图片e:\\hzg.png
- 服务器端接收到客户端发送的图片,保存到src下,发送"收到图片”再退出
- 客户端接收到服务端发送的"收到图片", 再退出
- 示意图如下:
思路分析:客户端先通过FileInputStream流从磁盘上读取文件到一个字节数组中,然后通过socket获得一个输出流将字节传输到服务端,服务端通过socket获得一个输入流将客户端发来的文件读取到字节数组中,然后通过文件输出流将字节数组中的内容写到指定目录下。
public class Server {
public static void main(String[] args) throws Exception{
ServerSocket serverSocket = new ServerSocket(8888);
Socket socket = serverSocket.accept();
BufferedInputStream bis=new BufferedInputStream(socket.getInputStream());
FileOutputStream fos=new FileOutputStream("d:\\\\hzg.png");
byte[] bytes=new byte[1024*10];
int length=0;
while ((length= bis.read(bytes))!=-1){
fos.write(bytes,0,length);
}
DataOutputStream dos=new DataOutputStream(socket.getOutputStream());
dos.writeUTF("收到图片");
serverSocket.close();
socket.close();
bis.close();
dos.close();
dos.close();
}
}
public class Client {
public static void main(String[] args) throws Exception{
Socket socket = new Socket("127.0.0.1", 8888);
FileInputStream fis=new FileInputStream("e:\\\\hzg.png");
BufferedOutputStream bos = new BufferedOutputStream(socket.getOutputStream());
byte[] bytes=new byte[1024*10];
int length= 0;
while ((length=fis.read(bytes))!=-1){
bos.write(bytes,0,length);
}
socket.shutdownOutput();
DataInputStream dis=new DataInputStream(socket.getInputStream());
String msg = dis.readUTF();
System.out.println("server:"+msg);
fis.close();
bos.close();
dis.close();
socket.close();
}
}
文件下载
要求如下:
-
编写客户端程序和服务器端程序
-
客户端可以输入一个文件名,服务端收到文件名后,可以给客户端如果有则返回这个文件,如果服务器没有这个文件,则返回一个默认的文件.
-
客户端收到文件后,保存到本地磁盘中。
思路分析:客户端通过socket获得输出流把要下载的文件名发送给服务端,服务端接收到文件名后再服务器的目录下查找是否有该文件名的文件,如果有就把该文件发送给客户端,如果没有则不发送文件给客户端,服务端在发送文件前先发送一个消息告诉客户端是否有该文件,客户端解析服务端发来的消息,如果是1则将收到的文件保存到指定目录下,如果为0则输出一句话。
public class Server {
public static void main(String[] args) throws Exception{
ServerSocket serverSocket = new ServerSocket(8888);
//监听连接
Socket socket = serverSocket.accept();
//创建数据输入流来接收客户端发来要下载的文件名
DataInputStream bis=new DataInputStream(socket.getInputStream());
String filename = bis.readUTF();
//遍历服务端的文件目录,假设服务端的文件夹为e:\\\\server
File file = new File("e:\\\\server");
//得到该文件夹下的所有文件和目录名
String[] files = file.list();
//用来标记是否找到该文件
boolean flag=false;
for (String s : files) {
if(s.equals(filename)){
//找到了该文件
flag=true;
}
}
//将结果告诉客户端
DataOutputStream dos=new DataOutputStream(socket.getOutputStream());
dos.writeBoolean(flag);
FileInputStream fis=null;
BufferedOutputStream bos=new BufferedOutputStream(socket.getOutputStream());
if(flag){
//有该文件将该文件读取到字节数组中
fis=new FileInputStream("e:\\\\server"JAVA高并发网络编程之TCP和UDP协议