TCP,(Transmission Control Protocol,缩写为TCP)。TCP是传输控制协议;是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793定义。TCP与UDP意义完成第四层传输所指定的功能与职责。
(1)TCP的机制:
(2)三次握手图解:
(2)TCP能做的功能
(1)客户端Socket创建流程
(2)服务器端Socket创建流程
(1)三次握手
第一次握手:Client将标志位SYN置为1,随机产生一个值seq=J,并将该数据包发送给Server,Client进入SYN_SENT状态,等待Server确认。
第二次握手:Server收到数据包后由标志位SYN=1知道Client请求建立连接,Server将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给Client以确认连接请求,Server进入SYN_RCVD状态。
第三次握手:Client收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给Server,Server检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,Client和Server进入ESTABLISHED状态,完成三次握手,随后Client与Server之间可以开始传输数据了。
(2)为什么需要三次握手,而不是二次握手?
主要是为了防止两次握手情况下已失效的连接请求报文段突然又传送到服务端,而产生的错误。举例如下:
Client向Server发出TCP连接请求,第一个连接请求报文在网络的某个节点长时间滞留,Client超时后认为报文丢失,于是再重传一次连接请求,Server收到后建立连接。
数据传输完毕后双方断开连接。而此时,前一个滞留在网络中的连接请求到达了服务端Server,而Server认为Client又发来连接请求,若采用的是“两次握手”,则这种情况下Server认为传输连接已经建立,并一直等待Client传输数据,而Client此时并无连接请求,因此不予理睬,这样就造成了Server的资源白白浪费了。
但此时若是使用“三次握手”,则Server向Client返回确认报文段,由于是一个失效的请求,因此Client不予理睬,建立连接失败。第三次握手的作用:防止已失效的连接请求报文段突然又传送到了服务器。
另一种说法: 为了初始化 Sequence Number 的初始值。
(3)四次挥手
所谓四次挥手(Four-Way Wavehand)即终止TCP连接,就是指断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开。整个流程如下图所示:
第一次挥手:Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。
第二次挥手:Server收到FIN后,发送一个ACK给Client,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号),Server进入CLOSE_WAIT状态。
第三次挥手:Server发送一个FIN,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。
第四次挥手:Client收到FIN后,Client进入TIME_WAIT状态,接着发送一个ACK给Server,确认序号为收到序号+1,Server进入CLOSED状态,完成四次挥手。
(4)为什么连接的时候是三次握手,关闭的时候却是四次握手?
Server在LISTEN状态下,收到建立连接请求的SYN报文后,可以直接把ACK和SYN放在一个报文里发送给Client。而关闭连接时,当收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,己方也未必全部数据都发送给对方了,所以己方可以立即close,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接,因此,己方ACK和FIN一般都会分开发送。
(1)排序、顺序发送、顺序组装
进行数据发送时,TCP会将数据拆分成不同的片段,将片段进行排序,再将数据根据片段的顺序进行发送,从而保证数据发送的有序性和完整
(2)丢弃、超时
一旦在发送过程中,发送的数据片发生超时,客户端就会收到该数据被废弃的消息。随后客户端就会将该数据进行重新发送。
(3)重发机制-定时器
服务器在收到信息后,会进行定时器,会定时回收数据片,如果这些数据片没有被回送,就需要进行重新发送。
如上图,客户端往服务器端发送消息,该消息被拆分为数据片发送,共五片,客户端把每一个数据片都发送了,但是服务器端在接收第三个数据段时没有接收或者是丢失了。而服务端每次收到数据段都会进行回送该数据段,所以第三个数据段直到接收完第五个数据段都没有回送。当发送方在发送完所有数据段后发送timeout时间之后第三个数据段还没有回送,就会认为该数据段丢失,就会重新发送给服务器端。
TCP传输初始化配置
5.1 TCP客户端Client初始化配置
(1)客户端Socket创建方式
在实际项目实操中,创建客户端Socket时,使用无参数的Socket构造,或者通过Socket(Proxy proxy)构造,这样Socket对象创建成功后,是一个未连接Socket,就可以通过Socket对象进行初始化配置。
private static final int PORT = 20001;
private static final int LOCAL_PORT = 30001;
private static Socket createSocket() throws IOException {
// 创建一个未连接的Socket对象
Socket socket = new Socket();
// 或者使用无代理(忽略任何其他代理配置)的构造函数,等效于空构造函数
//Socket socket = new Socket(Proxy.NO_PROXY);
// 将Socket绑定到本地IP地址和端口号
socket.bind(new InetSocketAddress(Inet4Address.getLocalHost(), LOCAL_PORT));
return socket;
}
也可以在创建Socket时,指定应该使用什么样的代理转发数据。
// 创建一个通过指定的HTTP代理服务器连接的Socket,数据通过指定的代理转发
Proxy proxy = new Proxy(
Proxy.Type.HTTP,
new InetSocketAddress("www.baidu.com", 1080)
);
Socket socket = new Socket(proxy);
下面几种方式创建Socket对象时,在创建时就连接到指定的服务器上,不能做一些初始化配置。
// 创建Socket,并将其连接到指定主机上和指定端口号的服务器上
Socket socket = new Socket("localhost", PORT);
//Socket(InetAddress address, int port)
//创建流套接字并将其连接到指定IP地址的指定端口号。
Socket socket = new Socket(Inet4Address.getLocalHost(), PORT);
//Socket(InetAddress address, int port, InetAddress localAddr, int localPort)
//创建套接字并将其连接到指定的远程端口上指定的远程地址。
Socket socket = new Socket(
Inet4Address.getLocalHost(),
PORT,
Inet4Address.getLocalHost(),
LOCAL_PORT
);
//Socket(String host, int port, InetAddress localAddr, int localPort)
//创建套接字并将其连接到指定远程端口上的指定远程主机。
Socket socket = new Socket(
"localhost",
PORT,
Inet4Address.getLocalHost(),
LOCAL_PORT
);
(2)Socket初始化配置
在设置Socket一些初始化配置时,需要注意,在Socket连接后配置将不起作用,必须在连接之前调用。
private static void configSocket(Socket socket) throws SocketException {
// 设置读取超时时间,单位:毫秒。timeout=0时,无限超时;timeout>0时,与此Socket相关联的InputStream上的read()调用将仅阻止此时间.
// 如果超时超时,则引发java.net.SocketTimeoutException
socket.setSoTimeout(2000);
//Nagle的算法,true启用TCP_NODELAY, false禁用。
socket.setTcpNoDelay(true);
// 是否需要在长时无数据响应时发送确认数据(类似心跳包),时间大约为2小时
socket.setKeepAlive(true);
// 设置逗留时间(以秒为单位),最大超时值是平台特定的,该设置仅影响关Socket关闭。默认为false,0
// false, 0: 默认情况,关闭时立即返回,底层系统接管输出流,将缓冲区的数据发送完成
// true, 0: 立即关闭返回,缓存区数据抛弃,直接发送RST结束命令到对方,并无需经过2MSL等待
// true, 2: 关闭时最长堵塞2秒,随后按照第二种情况处理
socket.setSoLinger(true, 2);
// 是否接收TCP紧急数据,默认为false,禁止接收,在Socket接收的TCP紧急数据被静默地丢弃。
socket.setOOBInline(true);
// 设置接收缓冲区区大小
// 增加接收缓冲区大小可以提高大容量连接的网络I / O的性能,同时可以帮助减少输入数据的积压
// 需要注意:1.对于客户端Socket,在将Socket连接到服务器之前,必须调用setReceiveBufferSize()
// 2. 对于ServerSocket接受的Socket,必须通过在ServerSocket绑定到本地地址之前调用ServerSocket.setReceiveBufferSize(int)来完成。
socket.setReceiveBufferSize(64 * 1024 * 1024);
// 设置发送缓冲区大小的大小,该值必须大于0
socket.setSendBufferSize(64 * 1024 * 1024);
// 注意:在此连Socket连接后调用此方法将不起作用,必须在连接之前调用
// 设置此Socket的性能参数:
// connectionTime :一个 int表达短连接时间的相对重要性
// latency :一个 int表达低延迟的相对重要性
// bandwidth :一个 int表达高带宽的相对重要性
// 这三个值只是简单的比较,哪个参数设置的值大偏向谁
socket.setPerformancePreferences(1, 1, 0);
}
(3)最后在创建Socket并配置后,将此Socket连接到具有指定的服务器。
public static void main(String[] args) throws IOException, UnknownHostException {
Socket socket = createSocket();
//超时时间
socket.setSoTimeout(3000);
//连接本地,端口2000;超时时间3000ms
socket.connect(new InetSocketAddress(Inet4Address.getLocalHost(),PORT),3000);
System.out.println("已发起服务器连接,并进入后续流程~");
System.out.println("客户端信息:" + socket.getLocalAddress() + " P:"+socket.getLocalPort());
System.out.println("服务端信息:" + socket.getInetAddress() + " P:"+socket.getPort());
try {
todo(socket);
}catch (Exception e){
System.out.println("异常关闭");
}
//释放资源
socket.close();
System.out.println("客户端退出");
}
(4)键盘输入与流传输消息:
OutputStream outputStream = client.getOutputStream();
PrintStream socketPrintStream = new PrintStream(outputStream);
// 得到Socket输入流
InputStream inputStream = client.getInputStream();
BufferedReader socketBufferedReader = new BufferedReader(new InputStreamReader(inputStream));
boolean flag = true;
do {
// 键盘读取一行
String str = input.readLine();
// 发送到服务器
socketPrintStream.println(str);
// 从服务器读取一行
String echo = socketBufferedReader.readLine();
if("bye".equalsIgnoreCase(echo)){
flag = false;
}else{
System.out.println(echo);
}
}while (flag);
// 资源释放
socketPrintStream.close();
socketBufferedReader.close();
}
以上就是TCP传输Client初始化逻辑代码。
5.2 TCP服务端Server初始化配置
(1)服务端Server的创建
private static ServerSocket createServerSocket() throws IOException {
// 创建未绑定的服务器套接字
ServerSocket server = new ServerSocket();
return server;
}
(2)服务端的参数配置
private static void initServerSocket(ServerSocket server) throws SocketException {
// 是否复用未完全关闭的地址端口
server.setReuseAddress(true);
// 等效Socket#setReceiveBufferSize
server.setReceiveBufferSize(64 * 1024 * 1024);
// 设置serverSocket#accept超时时间,超过timeout未accept到消息,则报timeout
// server.setSoTimeout(20000);
// 设置性能参数: 短链接,延迟,带宽的相对重要性
server.setPerformancePreferences(1,1,1);
}
参数说明:
① setReuseAddress(true) :
在网络应用中(如Java Socket Server),当服务关掉立马重启时,很多时候会提示端口仍被占用(因端口上有处于TIME_WAIT的连接)。此时可通过 SO_REUSEADDR 参数( socket.setReuseAddress(true); )来使得服务关掉重启时立马可使用该端口,而不是提示端口占用。如果端口忙,但TCP状态位于 TIME_WAIT ,可以重用 端口。如果端口忙,而TCP状态位于其他状态,重用端口时依旧得到一个错误信息, 抛出“Address already in use: JVM_Bind”。如果你的服务程序停止后想立即重启,不等60秒,而新套接字依旧 使用同一端口,此时 SO_REUSEADDR 选项非常有用。
② setSendBufferSize与setReceiveBufferSize :分别设置发送端和接收端缓冲区的大小,而且在不同的操作系统中默认值有所不同。不管发送端和接收端缓冲区的大小如何设置,最后生效的是两者之间值最小的那个大小。
③setSoTimeout :用来设置与socket的inputStream相关的read操作阻塞的等待时间,超过设置的时间了,假如还是阻塞状态,会抛出异常java.net.SocketTimeoutException: Read timed out 这里的阻塞不是指read的时间长短,可以理解为没有数据可读,线程一直在这等待。
(3)创建好Socket服务端后,将该服务端绑定到特定地址进行accept
private static final int PORT = 20001;
public static void main(String[] args) throws IOException {
ServerSocket server = createServerSocket();
initServerSocket(server);
//将 ServerSocket绑定到特定地址(IP地址和端口号)
server.bind(new InetSocketAddress(Inet4Address.getLocalHost(), PORT),50);
System.out.println("服务器准备就绪~");
System.out.println("服务器信息: " + server.getInetAddress() + "\tP:" + server.getLocalPort());
//等待客户端连接
for(;;){
//得到客户端
Socket socket = server.accept();
// 客户端构建异步线程
Server.ClientHandler clientHandler = new Server.ClientHandler(socket);
// 启动线程
clientHandler.start();
}
}
(4)处理客户端传输过来的消息
/**
* 客户端消息处理
*/
private static class ClientHandler extends Thread {
private Socket socket;
private boolean flag = true;
ClientHandler(Socket socket){
this.socket = socket;
}
@Override
public void run(){
super.run();
System.out.println("新客户端连接:" + socket.getInetAddress() + " P:"+ socket.getPort());
try {
// 得到打印流,用于数据输出;服务器回送数据使用
PrintStream socketOutput = new PrintStream(socket.getOutputStream());
// 得到输入流,用于接收数据
BufferedReader socketInput = new BufferedReader(new InputStreamReader(socket.getInputStream()));
do {
// 拿到一条客户端数据
String str = socketInput.readLine();
if("bye".equalsIgnoreCase(str)){
flag = false;
// 回送
socketOutput.println("bye");
}else {
// 打印到屏幕。并回送数据长度
System.out.println(str);
socketOutput.println("回送:" + str.length());
}
}while (flag);
}catch (Exception e){
System.out.println("连接异常断开");
}finally {
// 连接关闭
try {
socket.close();
}catch (IOException e){
e.printStackTrace();
}
}
System.out.println("客户端已退出:" + socket.getInetAddress() + " P:"+ socket.getPort() );
}
}
5.3 代码测试上述 Server/Client
服务端 Server :
客户端Client:
上述截图,Server启动后,Client 向Server发起连接并连接成功后,由键盘输入的数据作为消息报给Server,并由Server进行回送该消息的长度。以上便是一个基础的TCP传输实例。
5.4 TCP基础传输-套接字流
TCP客户端:
private static void todo(Socket client) throws IOException {
// 得到Socket输出流,并转换为打印流
OutputStream outputStream = client.getOutputStream();
// 得到Socket输入流
InputStream inputStream = client.getInputStream();
byte[] buffer = new byte[128];
boolean flag = true;
outputStream.write(new byte[]{1});
int read = inputStream.read(buffer);
if(read>0){
System.out.println("收到数量: "+ read + " 数据:" + Array.getByte(buffer,0));
}else{
System.out.println("没有收到: " + read);
}
// 资源释放
outputStream.close();
inputStream.close();
}
原来使用的键盘输入方式写入数据,现在采用Socket的常用数据类型 byte 来作为数据传输。基于OutputStream/InputStream 套接字流实现。
TCP服务端:
/**
* 客户端消息处理
*/
private static class ClientHandler extends Thread {
private Socket socket;
private boolean flag = true;
ClientHandler(Socket socket){
this.socket = socket;
}
@Override
public void run(){
super.run();
System.out.println("新客户端连接:" + socket.getInetAddress() + " P:"+ socket.getPort());
try {
// 得到套接字流
OutputStream outputStream = socket.getOutputStream();
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[128];
int readCount= inputStream.read(buffer);
if(readCount > 0 ){
System.out.println("收到数量: " + readCount + " 数据:" + Array.getByte(buffer,0));
outputStream.write(buffer,0,readCount);
}else{
System.out.println("没有收到: " + readCount);
outputStream.write(new byte[]{0});
}
outputStream.close();
inputStream.close();
}catch (Exception e){
System.out.println("连接异常断开");
}finally {
// 连接关闭
try {
socket.close();
}catch (IOException e){
e.printStackTrace();
}
}
System.out.println("客户端已退出:" + socket.getInetAddress() + " P:"+ socket.getPort() );
}
}
现在采用Socket的常用数据类型 byte 来作为数据传输。基于OutputStream/InputStream 套接字流实现。
基于套接字流的TCP基础数据传输结果:
不同于前面的案例,这里是使用byte作为传输的数据类型,创建的为 byte[128] 作为输入流的写入对象。输出流的数据则是 new byte[]{1} 也就是 byte[] 索引为0的值为1的数据作为传输对象。
5.5 TCP基础数据-工具类实现数据类型转换
byte 的范围为 -128~127之间,可传输的数据有限。超过127(比如128)的数据就会丢失。
如果想要传输其他的数据类型,比如: int 值,而int 是可以超过127的。这里可以通过一些工具来实现。
(1) Tools 工具类:
public class Tools {
/**
* byte[]转int
* @param b
* @return
*/
public static int byteArrayToInt(byte[] b){
return b[3] & 0xEF |
(b[2] & 0xEF) << 8 |
(b[1] & 0xEF) << 16 |
(b[0] & 0xEF) << 24;
}
/**
* int转byte[]
* @param a
* @return
*/
public static byte[] intToByteArray(int a){
return new byte[] {
(byte) ((a >> 24) & 0xEF),
(byte) ((a >> 16) & 0xEF),
(byte) ((a >> 8) & 0xEF),
(byte) (a & 0xEF)
};
}
}
(2)TCP进行int数据的传输:
TCP服务端改造:
// 使用 Tools工具类将byte[]转为int读取,client传输过来的为byte[4]
int value = Tools.byteArrayToInt(buffer);
System.out.println("收到数量: " + readCount + " 数据:" + value);
TCP客户端改造:
byte[] buffer = new byte[128];
// 将长度超过byte的int值转为byte[4]进行传输
byte[] ints = Tools.intToByteArray(2323123);
boolean flag = true;
outputStream.write(ints);
int read = inputStream.read(buffer);
if(read>0){
System.out.println("收到数量: "+ read + " 数据:" + Tools.byteArrayToInt(buffer));
}else{
System.out.println("没有收到: " + read);
}
为了方便int的传输,将其转为byte[]是很方便的。一个int为4个byte,共32个字节。
5.6 TCP基础数据传输 - 基于ByteBuffer 数据传输
(1)TCP 客户端:
byte[] buffer = new byte[128];
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
//byte
byteBuffer.put((byte) 126);
// char
char c = 'a';
byteBuffer.put((byte)c);
// int
int i = 2323123;
byteBuffer.putInt(i);
// bool
boolean b = true;
byteBuffer.put(b?(byte)1:(byte)0);
// Long
long l = 298789739;
byteBuffer.putLong(l);
// float
float f = 12.345f;
byteBuffer.putFloat(f);
// double
double d = 13.31241248782973;
byteBuffer.putDouble(d);
// String
String str = "Hello你好! ";
byteBuffer.put(str.getBytes());
// 发送到服务器
outputStream.write(buffer,0,byteBuffer.position() + 1);
(2)TCP服务端:
byte[] buffer = new byte[128];
int readCount= inputStream.read(buffer);
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
//byte
byte be = byteBuffer.get();
// char
char c = byteBuffer.getChar();
// int
int i = byteBuffer.getInt();
// bool
boolean b = byteBuffer.get() == 1;
// Long
long l = byteBuffer.getLong();
// float
float f = byteBuffer.getFloat();
// double
double d = byteBuffer.getDouble();
// String
int pos = byteBuffer.position();
String str = new String(buffer,pos,readCount - pos - 1);
// 发送到服务器
outputStream.write(buffer,0,byteBuffer.position() + 1);
System.out.println("收到数量: " + readCount + " 数据:"
+ "byte:" + be + "\n"
+ "char:" + c + "\n"
+ "int:" + i + "\n"
+ "boolean:" + b + "\n"
+ "long:" + l + "\n"
+ "float:" + f + "\n"
+ "double:" + d + "\n"
+ "string:" + str + "\n"
);
测试结果:
(3)ByteBuffer 简述:
ByteBuffer是对byte数组的一种封装,所以可以使用静态方法wrap(byte[] data)手动封装数组,也可以通过另一个静态的allocate(int size)方法初始化指定长度的ByteBuffer。初始化后,ByteBuffer的position就是0;其中的数据就是初始化为0的字节数组。
(4)ByteBuffer写数据:
可以手动通过put(byte b)或put(byte[] b)方法向ByteBuffer中添加一个字节或一个字节数组。ByteBuffer也方便地提供了几种写入基本类型的put方法:putChar(char val)、putShort(short val)、putInt(int val)、putFloat(float val)、putLong(long val)、putDouble(double val)。执行这些写入方法之后,就会以当前的position位置作为起始位置,写入对应长度的数据,并在写入完毕之后将position向后移动对应的长度。
(5)ByteBuffer读取数据:
现在ByteBuffer容器中已经存有数据,那么现在就要从ByteBuffer中将这些数据取出来解析。由于position就是下一个读写操作的起始位置,故在读取数据后直接写出数据肯定是不正确的,要先把position复位到想要读取的位置。
在将position复位之后,我们便可以从ByteBuffer中读取有效数据了。类似put()方法,ByteBuffer同样提供了一系列get方法,从position开始读取数据。get()方法读取1个字节,getChar()、getShort()、getInt()、getFloat()、getLong()、getDouble()则读取相应字节数的数据,并转换成对应的数据类型。如getInt()即为读取4个字节,返回一个Int。在调用这些方法读取数据之后,ByteBuffer还会将position向后移动读取的长度,以便继续调用get类方法读取之后的数据。
(6)ByteBuffer读写规则简述: 按照put() 或者putxx() 的方式写,并按照put的顺序使用get()/getXX() 进行读。也可以指定 position进行对指定长度位置的数据进行读取。