avatar


6.网络编程

上一章《5.IO流》,我们的主题是IO,讨论的是数据在内存和硬盘之间的传输。这一章,我们讨论数据在网络之间的传输。
网络编程。

网络编程基本要素

网络编程的基本要素有三个。

  1. IP
  2. 端口
  3. 协议

我们分别讨论。

IP

什么是IP

要想让网络中的计算机能够互相通信,必须为每台计算机指定一个标识号,通过这个标识号来指定要接收数据的计算机和识别发送数据的计算机,而IP地址就是这个标识号。也就是设备的标识。

IP地址分为两大类

  1. IPv4
  2. IPv6

IPv4是我们最常见的一类IP地址,比如我们设置路由器时候的"192.168.0.1",其特点是用32位(4字节)来表示地址,比如:“11000000 10101000 00000000 00000001”。这也不是我们常见的"192.168.0.1"的形式啊。再将其表示成十进制的形式,中间使用符号"."分隔不同的字节。

IPv6,这种我们或许不常见。IPv6诞生的原因是,随着互联网的蓬勃发展,IP地址的需求量愈来愈大,IPv4不够用了,所以有了IPv6。IPv6采用128位地址长度,每16个字节一组,分成8组十六进制数。例如:ABCD:EF01:2345:6789:ABCD:EF01:2345:6789。

我们可以通过命令的方式查看我们本机的IP地址。
不同的操作系统略有区别。

  • 在Windows是ipconfig
  • 在Mac和Linux是ifconfig

(关于Linux中的ifconfig,可以参考我们在《Linux操作系统使用入门:2.命令》中关于"网络"部分的讨论。)

回送地址

大家或许都听过这个特殊的IP地址,127.0.0.1,这是回送地址,可以代表本机地址。此外,大家或许还听过localhost。
那么localhost和127.0.0.1是什么关系呢?
localhost就是127.0.0.1,127.0.0.1就是localhost?

localhost是个域名,不是地址,它可以被配置为任意的IP地址,只不过通常情况下都指向127.0.0.1(ipv4)和::1(ipv6)。

InetAddress

上述,我们提到了,可以通过命令的方式获取机器的IP地址。除了这种方法,Java也直接给我们提供了工具类来获取。
java.net.InetAddress
常用方法:

方法名 说明
static InetAddress getByName(String host) 确定主机名称的IP地址。主机名称可以是机器名称,也可以是IP地址。
String getHostName() 获取此IP地址的主机名。
String getHostAddress() 返回文本显示中的IP地址字符串。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.kakawanyifan;

import java.net.InetAddress;
import java.net.UnknownHostException;

public class InetAddressDemo {
public static void main(String[] args) throws UnknownHostException {
InetAddress address = InetAddress.getByName("localhost");

String name = address.getHostName();
String ip = address.getHostAddress();

System.out.println("主机名:" + name);
System.out.println("IP地址:" + ip);
}
}

运行结果:

1
2
主机名:localhost
IP地址:127.0.0.1

端口

网络编程的基本要素,第二个,端口。
通过上文的讨论,我们知道IP地址是机器的唯一表示。但是机器上有很多的应用程序,怎么才能被我们希望的那个应用程序接收到呢?
通过端口号,端口号是应用程序在设备上的唯一标识。
端口号,用两个字节表示的整数,它的取值范围是[0,65535][0,65535]。如果端口号被另外一个服务或应用所占用,会导致当前程序启动失败。

题外话。65535?怎么这么熟悉?这也是基本数据类型中的字符型的取值范围。
《1.基础语法》,我们讨论过。
字符类型,char,2字节,取值范围[0,65535][0,65535]

协议

网络编程的基本要素,第三个,协议。
现在我们知道了,用IP地址来标识设备,用端口号来标识设备上的应用程序。但还有一件事情没有说清楚,怎么沟通?大家得定一个规则。
就像打电话一样,别拿起电话就说话,先说声喂喂,确认是否接通。另外,别说方言,请讲普通话。
这既是通信协议,是位于同一个网络中的计算机在进行连接和通信时需要遵守一定的规则。这些协议对数据的传输格式、传输速率、传输步骤等都做了统一规定,通信双方必须同时遵守才能完成数据交换。
在这里,我们讨论UDP协议和TCP协议

UDP协议,用户数据报协议(User Datagram Protocol)。
UDP是无连接通信协议。无连接的含义是在数据传输时,数据的发送端和接收端不建立逻辑连接,简单来说,当一台计算机向另外一台计算机发送数据时,发送端不会确认接收端是否存在,同样接收端在收到数据时,也不会向发送端反馈是否收到数据。
由于使用UDP协议消耗资源小,通信效率高,所以通常都会用于音频、视频和普通数据的传输。例如视频会议通常采用UDP协议,因为这种情况即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。
但是在使用UDP协议传送数据时,由于UDP的面向无连接性,不能保证数据的完整性,因此在传输重要数据时不建议使用UDP协议,比如交易指令。我要买入100手,这个数据包丢了?那就是严重的生产事故。

TCP协议,传输控制协议(Transmission Control Protocol)。
TCP协议是面向连接的通信协议,即传输数据之前,在发送端和接收端建立逻辑连接,然后再传输数据,它提供了两台计算机之间可靠无差错的数据传输。在TCP连接中必须要明确客户端与服务器端,由客户端向服务端发出连接请求,每次连接的创建都需要经过"三次握手"。

三次握手

  • 第一次握手,客户端向服务器端发出连接请求,等待服务器确认。
  • 第二次握手,服务器端向客户端回送一个响应,通知客户端收到了连接请求。
  • 第三次握手,客户端再次向服务器端发送确认信息,确认连接。
  • 完成三次握手,连接建立后,客户端和服务器就可以开始进行数据传输了。由于这种面向连接的特性,TCP协议可以保证传输数据的安全,所以应用十分广泛。例如上传文件、下载文件、浏览网页

至此,关于网络编程的三个基本要素,我们都讨论好了。接下来,我们分别基于UDP协议和TCP协议,实现几个网络通信的例子。

UDP通信程序

相关类和方法

正如UDP的英文名,User Datagram Protocol。
在Java中,UDP通信主要依赖两个类,这两个类的名字都以Datagram开头了。
一个类是Socket类,java.net.DatagramSocket。
还有一个是数据包类,java.net.DatagramPacket。

发送数据的步骤

  1. 创建发送端的Socket对象:DatagramSocket的构造方法
  2. 创建DatagramPacket对象:DatagramPacket的构造方法
  3. 调用DatagramSocket对象的方法发送数据:DatagramSocket的发送方法
  4. 关闭发送端:DatagramSocket的关闭方法

接收数据的步骤

  1. 创建接收端的Socket对象:DatagramSocket的构造方法
  2. 创建一个数据包,用于接收数据:DatagramPacket的构造方法
  3. 调用DatagramSocket对象的方法接收数据:DatagramSocket的接收方法
  4. 解析数据包:DatagramPacket的解析方法
  5. 关闭接收端:DatagramSocket的关闭方法

我们分别看看上述步骤提到的方法。
DatagramSocket的构造方法

方法名 说明
DatagramSocket() 创建数据报套接字并将其绑定到本机地址上的任何可用端口
DatagramPacket(byte[] buf,int len,InetAddress add,int port) 创建数据包,发送长度为len的数据包到指定主机的指定端口

DatagramSocket的发送、接收和关闭方法

方法名 说明
void send(DatagramPacket p) 发送数据报包
void receive(DatagramPacket p) 从此套接字接受数据报包
void close() 关闭数据报套接字

DatagramPacket的构造方法

方法名 说明
DatagramPacket(byte[] buf, int len) 创建一个DatagramPacket用于接收长度为len的数据包

DatagramPacket的解析方法

方法名 说明
byte[] getData() 返回数据缓冲区
int getLength() 返回要发送的数据的长度或接收的数据的长度

例子

来看个例子

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.kakawanyifan;

import java.io.IOException;
import java.net.*;

public class SendDemo {
public static void main(String[] args) throws IOException {
// 创建发送端的Socket对象(DatagramSocket)
// DatagramSocket() 构造数据报套接字并将其绑定到本地主机上的任何可用端口
DatagramSocket ds = new DatagramSocket();

// 创建数据,并把数据打包
// DatagramPacket(byte[] buf, int length, InetAddress address, int port)
// 构造一个数据包,发送长度为 length的数据包到指定主机上的指定端口号。
byte[] bys = "hello world".getBytes();
DatagramPacket dp = new DatagramPacket(bys,bys.length, InetAddress.getByName("localhost"),12345);

// 调用DatagramSocket对象的方法发送数据
// void send(DatagramPacket p) 从此套接字发送数据报包
ds.send(dp);

// 关闭发送端
// void close() 关闭此数据报套接字
ds.close();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.kakawanyifan;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class ReceiveDemo {
public static void main(String[] args) throws IOException {
//创建接收端的Socket对象(DatagramSocket)
DatagramSocket ds = new DatagramSocket(12345);

while (true) {
//创建一个数据包,用于接收数据
byte[] bys = new byte[1024];
DatagramPacket dp = new DatagramPacket(bys, bys.length);

//调用DatagramSocket对象的方法接收数据
ds.receive(dp);

//解析数据包,并把数据在控制台显示
System.out.println("数据是:" + new String(dp.getData(), 0, dp.getLength()));

}
}
}

运行结果:

1
数据是:hello world

解释说明:
在ReceiveDemo中,因为接收端不知道发送端什么时候结束,所以采用了死循环,一直去接收数据。
注意!是不知道什么时候结束,因为ds.receive(dp)实际上是一个阻塞方法,会在等到收到数据包后,再执行后续的操作。但可能收到一个数据包,发送端还会继续发数据包,所以用死循环。

TCP通信程序

接下来,我们再来讨论TCP通信程序。

相关方法

与UDP不同的是,在TCP中有客户端和服务端的概念(毕竟有握手的过程了)。

和客户端相关的类是java.net.Socket
构造方法

方法名 说明
Socket(InetAddress address,int port) 创建流套接字并将其连接到指定IP指定端口号
Socket(String host, int port) 创建流套接字并将其连接到指定主机上的指定端口号

和服务端相关的类是java.net.ServerSocket
构造方法

方法名 说明
ServletSocket(int port) 创建绑定到指定端口的服务器套接字

接收方法

方法名 说明
Socket accept() 监听要连接到此的套接字并接受它

服务端只有接收方法?没有发送方法吗?服务端的接收返回,返回的是什么?就是一个Socket,所以用这个Socket再发送内容给客户端。

发送与接收方法

方法名 说明
OutputStream getOutputStream() 返回此套接字的输出流
InputStream getInputStream() 返回此套接字的输入流

现在问谁是发送,谁是接收?
我们在写哪一端的代码,就站在哪一端的角度看,out是发送,in是接收。

例子

举个例子

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.kakawanyifan;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class ClientDemo {
public static void main(String[] args) throws IOException {
// 创建客户端的Socket对象(Socket)
// Socket(String host, int port) 创建流套接字并将其连接到指定主机上的指定端口号
Socket s = new Socket("127.0.0.1",12345);

// 获取输出流,写数据
// OutputStream getOutputStream() 返回此套接字的输出流
OutputStream os = s.getOutputStream();
os.write("hello world".getBytes());

// 接收服务器反馈
InputStream is = s.getInputStream();
byte[] bys = new byte[1024];
int len = is.read(bys);
String data = new String(bys, 0, len);
System.out.println(data);

// 释放资源
is.close();
os.close();
s.close();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.kakawanyifan;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerDemo {
public static void main(String[] args) throws IOException {
//创建服务器端的Socket对象(ServerSocket)
ServerSocket serverSocket = new ServerSocket(12345);
while (true) {
//监听客户端连接,返回一个Socket对象
Socket socket = serverSocket.accept();

//获取输入流,读数据,并把数据显示在控制台
InputStream is = socket.getInputStream();
byte[] bys = new byte[1024];
int len = is.read(bys);
String data = new String(bys, 0, len);
System.out.println("服务端收到数据:" + data);

//给出反馈
OutputStream os = socket.getOutputStream();
os.write(("数据已经收到:内容是" + data).getBytes());

//释放资源
socket.close();
}
}
}

运行结果:

1
数据已经收到:内容是hello world
1
服务端收到数据:hello world

例子加强:上传文件

我们再把这一章的内容和上一章《5.IO流》联合起来,做一个上传文件的例子。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.kakawanyifan;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerDemo {
public static void main(String[] args) throws IOException, InterruptedException {
//创建服务器端的Socket对象(ServerSocket)
ServerSocket serverSocket = new ServerSocket(12345);
while (true) {
//监听客户端连接,返回一个Socket对象
Socket socket = serverSocket.accept();

//接收数据
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
//把数据写入文本文件
BufferedWriter bw = new BufferedWriter(new FileWriter("server.txt"));
String line;
while ((line=br.readLine())!=null) {
bw.write(line);
bw.newLine();
bw.flush();
}
//释放资源
br.close();

//给出反馈
BufferedWriter bwServer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bwServer.write("文件上传成功");
bwServer.newLine();
bwServer.flush();

socket.close();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.kakawanyifan;

import java.io.*;
import java.net.Socket;

public class ClientDemo {
public static void main(String[] args) throws IOException {
//创建客户端Socket对象
Socket socket = new Socket("127.0.0.1",12345);

//封装文本文件的数据
BufferedReader br = new BufferedReader(new FileReader("射雕英雄传.txt"));
//封装输出流写数据
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));

String line;
while ((line=br.readLine())!=null) {
bw.write(line);
bw.newLine();
bw.flush();
}

// 使用shutdownOutput()方法告知服务端传输结束
// public void shutdownOutput()
socket.shutdownOutput();

//接收反馈
BufferedReader brClient = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String data = brClient.readLine(); //等待读取数据
System.out.println("服务器的反馈:" + data);、

//释放资源
br.close();
socket.close();
}
}

BIO的缺陷

阻塞

什么是BIO?
刚刚我们讨论的就是BIO。
为什么是这个名字,B是什么含义?
B的含义是Blocking,阻塞。
在UDP的例子中,我们就已经解释阻塞了。不收到数据包,就不往下执行。
那么,为什么阻塞会是一个缺陷呢?
举例子说明。
比如,我们从键盘录入,模拟我们使用百度的情况,从键盘录入信息发送给百度。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.kakawanyifan;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerDemo {
public static void main(String[] args) throws IOException {
// 创建服务器端的Socket对象(ServerSocket)
ServerSocket serverSocket = new ServerSocket(12345);
while (true) {
// 监听客户端连接,返回一个Socket对象
System.out.println("等待连接");
Socket socket = serverSocket.accept();
System.out.println("连接成功");

// 获取输入流,读数据,并把数据显示在控制台
System.out.println("等待数据");
InputStream is = socket.getInputStream();
byte[] bys = new byte[1024];
int len = is.read(bys);
String data = new String(bys, 0, len);
System.out.println("收到数据:" + data);

// 释放资源
socket.close();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.kakawanyifan;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class ClientDemo {
public static void main(String[] args) throws IOException {
// 创建客户端的Socket对象(Socket)
// Socket(String host, int port) 创建流套接字并将其连接到指定主机上的指定端口号
Socket socket = new Socket("127.0.0.1",12345);
Scanner scanner = new Scanner(System.in);
String text = scanner.next();
OutputStream os = socket.getOutputStream();
os.write(text.getBytes());
os.close();
socket.close();
}
}

运行结果:

1
2
3
4
5
等待连接
连接成功
等待数据
收到数据:1
等待连接

我们解释一下上述的运行结果。
首先,我们启动服务端,然后服务端打印等待连接
再启动我们的客户端,客户端会执行Socket socket = new Socket("127.0.0.1",12345),这时候服务端打印连接成功 等待数据
然后我们再录入数据,服务端打印收到数据:1,再执行socket.close(),并进入下一轮循环等待连接

整个过程似乎看起来没问题。
那么,假如两个客户端呢?我们把上述的客户端文件复制一份,重命名为ClientDemo2。
我们先执行ClientDemo、然后不录入数据。服务端的打印如下

1
2
3
等待连接
连接成功
等待数据

继续执行ClientDemo2,并录入2。这时候看看服务端的打印

1
2
3
等待连接
连接成功
等待数据

居然没有变?

再回到ClientDemo,录入1。服务端打印。

1
2
3
4
5
6
7
8
9
等待连接
连接成功
等待数据
收到数据:1
等待连接
连接成功
等待数据
收到数据:2
等待连接

为什么?
因为上述的Socket socket = serverSocket.accept()InputStream is = socket.getInputStream()会阻塞。会一直等待,直到收到连接、收到输入,才会进行后面的操作。

这就是BIO,Blocking,阻塞的缺陷。
那么?这个怎么办呢?

多线程解决方案

有一款很经典的GBA游戏《牧场物语:矿石镇的伙伴们》,这个游戏大概内容就是玩家经营一个牧场,养鸡、养牛、种菜等,如果玩家忙不过来了,可以把某些活委托给小矮人。

牧场物语:矿石镇的伙伴们

实际上这个例子存在不合理之处,这个例子会让大家误以为多线程就是快,实际上多线程不一定快,这个我们会在《8.多线程 [2/2]》做详细的讨论。
但在这里,有一个线程都已经阻塞了,用多线程就是会快。这个我们会在《7.多线程 [1/2]》进行解释。

仿照牧场物语的例子,现在"玩家"专门复制接收socket,在接收到socket之后,马上把socket交给小矮人,剩下的事情交给小矮人去处理。
即一个线程负责一个socket。
多线程

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.kakawanyifan;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerDemo {
public static void main(String[] args) throws IOException {
// 创建服务器端的Socket对象(ServerSocket)
ServerSocket serverSocket = new ServerSocket(12345);
while (true) {
// 监听客户端连接,返回一个Socket对象
System.out.println("等待连接");
Socket socket = serverSocket.accept();
System.out.println("连接成功");
// 为每一个客户端开启一个线程
new Thread(new ServerThread(socket)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.kakawanyifan;

import java.io.*;
import java.net.Socket;

public class ServerThread implements Runnable {
private Socket socket;

public ServerThread(Socket s) {
this.socket = s;
}

@Override
public void run() {
try {
// 获取输入流,读数据,并把数据显示在控制台
System.out.println(Thread.currentThread().getName() + " 等待数据");
InputStream is = socket.getInputStream();
byte[] bys = new byte[1024];
int len = is.read(bys);
String data = new String(bys, 0, len);
System.out.println(Thread.currentThread().getName() + " 收到数据:" + data);

// 释放资源
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

运行结果:

1
2
3
4
5
6
7
8
9
等待连接
连接成功
等待连接
Thread-0 等待数据
连接成功
等待连接
Thread-1 等待数据
Thread-1 收到数据:2
Thread-0 收到数据:1

但,该方案有缺陷。

  1. 如果客户端只连接,但是不做读写操作,会造成资源浪费。
  2. 如果线程很多,会导致服务器线程太多,压力太大。

更好的办法,是NIO。

NIO

NIO的含义是"Non Blocking IO",没有阻塞的IO。

在上述,我们已经讨论过,多线程的方案不好,会造成资源浪费,而且如果线程多,服务器压力大。
那么就不要多线程,就一个线程。
可以如果一个线程呢?有会阻塞。

站在挣钱

我只想用一个线程,还不会阻塞。
做不到。
但可以只用一个线程,看起来没有阻塞。

只用一个线程,解决并发。

NIO的设计思路

再来看看上述我们只用一个线程的过程,伪代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ServerDemo {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
while (true) {
// 阻塞
Socket socket = serverSocket.accept();

// 阻塞
InputStream is = socket.getInputStream();

// 处理数据
System.out.println(is);

// 释放资源
socket.close();
}
}
}

我们先处理socket.getInputStream()这个阻塞。
假设我们有一个方法,可以直接设置socket为不阻塞,比如,就是socket.set不阻塞()
则有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ServerDemo {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
while (true) {
// 阻塞
Socket socket = serverSocket.accept();

// 不阻塞
socket.set不阻塞()

InputStream is = socket.getInputStream();
if(null != is){
// 处理数据
System.out.println(is);
// 释放资源
socket.close();
}
}
}
}

如果不阻塞的话,那么InputStream is = socket.getInputStream()就会直接执行。如果收到了数据就处理数据,没有收到数据,就继续执行,进入下一轮循环。
如果进入了下一轮循环,这时候客户端发送数据了,能收到吗?收不到。因为阻塞在serverSocket.accept()
那就设置serverSocket也不阻塞,没有拿到新的socket连接对象,就直接continue。比如:serverSocket.set不阻塞()
则有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ServerDemo {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
serverSocket.set不阻塞()
while (true) {
Socket socket = serverSocket.accept();
if(socket == null){
continue;
}

// 不阻塞
socket.set不阻塞()

InputStream is = socket.getInputStream();
if(null != is){
// 处理数据
System.out.println(is);
// 释放资源
socket.close();
}
}
}
}

这样能解决问题吗?
不能,我们现在一直在获取新的socket对象,之前的客户端发的消息还是收不到。
那么,我们这样。每拿到一个socket对象,添加进一个list中。遍历那个list。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ServerDemo {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
serverSocket.set不阻塞()
List<Socket> sockstList = new ArrayList<>();
while (true) {
// 阻塞
Socket socket = serverSocket.accept();
if(socket != null){
// 不阻塞
socket.set不阻塞()
sockstList.add(socket)
}
for(Socket socket in sockstList){
// 不阻塞
InputStream is = socket.getInputStream();
if(null != is){
// 处理数据
System.out.println(is);
// 释放资源
socket.close();
}
}
}
}
}

这就是NIO的设计思路,并没有解决阻塞,但是看起来没有阻塞。
只用了一个线程,解决了并发。

基于NIO的实现

设置不阻塞的两个方法是:

  1. serverSocketChannel.configureBlocking(false);
  2. socket.configureBlocking(false);

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.kakawanyifan;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class NioServerDemo {
public static void main(String[] args) throws IOException {
// 保存客户端连接
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
SocketAddress socketAddress = new InetSocketAddress(12345);
serverSocketChannel.bind(socketAddress);
// 设置不阻塞
serverSocketChannel.configureBlocking(false);

List<SocketChannel> socketChannelList = new ArrayList<>();
while (true){
SocketChannel socket = serverSocketChannel.accept();
if (socket != null){
System.out.println("连接成功");
socket.configureBlocking(false);
socketChannelList.add(socket);
}
// Exception in thread "main" java.util.ConcurrentModificationException
// at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
// at java.util.ArrayList$Itr.next(ArrayList.java:861)
// at com.kakawanyifan.NioServerDemo.main(NioServerDemo.java:29)

// for (SocketChannel socketChannel : socketChannelList) {
// ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// int len = socketChannel.read(byteBuffer);
// if (len > 0){
// System.out.println("收到数据:" + new String(byteBuffer.array()));
// }else if (len == -1){
// // 客户端断开连接
// socketChannelList.remove(socketChannel);
// }
// }

// 迭代器遍历连接进行数据读取
Iterator<SocketChannel> iterator = socketChannelList.iterator();
while (iterator.hasNext()){
SocketChannel socketChannel = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int len = socketChannel.read(byteBuffer);
if (len > 0){
System.out.println("收到数据:" + new String(byteBuffer.array(),0,len));
}else if (len == -1){
// 客户端断开连接
iterator.remove();
System.out.println("客户端断开连接");
}
}
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.kakawanyifan;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class ClientDemo {
public static void main(String[] args) throws IOException {
// 创建客户端的Socket对象(Socket)
// Socket(String host, int port) 创建流套接字并将其连接到指定主机上的指定端口号
Socket socket = new Socket("127.0.0.1",12345);
Scanner scanner = new Scanner(System.in);
String text = scanner.next();
OutputStream os = socket.getOutputStream();
os.write(text.getBytes());
os.close();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
连接成功
连接成功
收到数据:2
客户端断开连接
收到数据:1
客户端断开连接
连接成功
收到数据:3
客户端断开连接

注意NioServerDemo的代码,我们把for循环改成了用迭代器,因为for循环会报错,关于其报错原因,我们在《4.集合》中有过讨论。

但是呢,上述方案还有改进空间,可能会有大量的无效遍历。
例如有10000个连接,其中只有1000个连接有写数据,但是由于其他9000个连接并没有断开,我们还是要每次轮询遍历一万次,其中有十分之九的遍历都是无效的。
解决方法是多路复用。

多路复用

NIO 有三大核心组件:

  1. Channel(通道)
  2. Buffer(缓冲区)
  3. Selector(多路复用器)

channel类似于流,每个channel对应一个buffer缓冲区,buffer底层就是个数组。channel会注册到selector上,由selector根据channel读写事件的发生将其交由某个空闲的线程处理。NIO的Buffer和channel都是既可以读也可以写。

最重要的三个方法

  1. Selector.open() //创建多路复用器
  2. socketChannel.register(selector, SelectionKey.OP_READ) //将channel注册到多路复用器上
  3. selector.select() //阻塞等待需要处理的事件发生

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.kakawanyifan;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class NioSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(12345));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
// 打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务启动成功");
while (true) {
// 阻塞等待需要处理的事件发生
selector.select();
// 获取selector中注册的全部事件的 SelectionKey
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历SelectionKey对事件进行处理
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);
// 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("客户端连接成功");
} else if (key.isReadable()) {
// 如果是OP_READ事件,则进行读取和打印
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
int len = socketChannel.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array(),0,len));
} else if (len == -1) {
// 如果客户端断开连接,关闭Socket
System.out.println("客户端断开连接");
socketChannel.close();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}

运行结果:

1
2
3
4
5
6
7
服务启动成功
客户端连接成功
客户端连接成功
接收到消息:2
客户端断开连接
接收到消息:1
客户端断开连接

解释说明:
NIO

NIO的多路复用方法,其本质是调用了操作系统的内核函数来创建Socket,获取到Socket的文件描述符,再创建一个Selector对象,对应操作系统的Epoll描述符,将获取到的Socket连接的文件描述符的事件绑定到Selector对应的Epoll文件描述符上,进行事件的异步通知,这样就实现了使用一条线程,并且不需要太多的无效的遍历,将事件处理交给了操作系统内核(操作系统中断程序实现),大大提高了效率。

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/10806
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

评论区