前言
大家好,一直以来我都本着 用最通俗的话理解核心的知识点, 我认为所有的难点都离不开 基础知识 的铺垫
背景
本节给大家讲讲 Java的IO类 ,之前我们学习的知识都是自己跟自己玩,没有涉及到跟系统交互,举个例子比如我们需要将我们定义的一段文字保存到文件中,那么如何做呢,这就是本节所讲的主要内容JAVA的 IO 相关操作~
长文预警 耐心看完哦 ~
什么是IO
在学习Java IO操作之前,先理解一下什么是 IO 操作?
之前我们学习Java语法的时候,比如命名变量,调用类方法执行一些计算等等,这些其实都是在计算机内存里玩的。很显然在我们平时使用的一些软件,它们都需要跟各种各样的硬件打交道,比如视频通话就需要跟我们的摄像头、音频之类的硬件进行交互,再比如我们打字的键盘,电脑的显示器等,这些通常叫做外设。
计算机语言通常都要提供与它们操作的接口,所以就引申了 IO框架 这么个东西。那么到底是怎么进行交互的呢?举个键盘的例子,通常我们的键盘都通过一个USB口,那么连着它的是数据线,它们的交互是通过数据线进行数据的传输,当然,如果是无线设备,那么通常都是通过蓝牙传输数据。那么这个数据的形式是怎么样的呢?它就是 比特流,单位就是我们常说的字节,通常有8位0或1组成的一段数据。
既然有传输,好比自来水管,有输入端和输出端, 那么肯定有 输入流(InputStream) 和 输出流(OutputStream),还是刚刚的例子,那么输入端和输出端分别是什么呢?
其实也很好判断,它既然是一个有方向性的,那么 输入流(InputStream) 就是由外向内, 输出流(OutputStream) 就是由内向外。那么例子中的键盘就是输入设备, 计算机就是输出端,细讲就是数据通过内存向外输出。好了,有了这些的基本概念,我们在看Java的 IO 相关操作就有一个很好的理解了。
还是那句话,不要死记硬背相关类和方法,要知道它的原理,下面我们就进入正题~
File操作
File 即文件操作,通常和计算机的磁盘打交道,这也是最基本的操作了。在我们平时操作文件的时候,有文件夹和文件操作,基本的创建、修改、删除等操作,在Java中是 File 类去做的一些相关操作,所以我们需要先实例化它。
文件操作
在当前目录创建一个文件
File txt = new File("test.txt"); // 判断是否存在 if(txt.exists()) { Log.info("File 存在"); }else { try { // 创建文件 if(txt.createNewFile()) Log.info("创建成功"); } catch(IOException e) { Log.info("err-->" + e.getMessage()); } } 复制代码
重命名文件:
if(txt.exists()) { if(txt.renameTo(new File("test1.txt"))) Log.info("重命名成功"); } 复制代码
删除文件:
if(txt.exists()) { if(txt.delete()) Log.info("txt 删除成功"); } 复制代码
创建文件夹:
File dir = new File("test"); if(!dir.exists()) { if(dir.mkdir()) Log.info("文件夹创建成功"); // 判断是否是文件夹 if(dir.isDirectory()) Log.info("是文件夹"); } 复制代码
创建嵌套文件夹:
if(!dir1.exists()) { if(dir1.mkdirs()) Log.info("文件夹创建成功1"); // 判断是否是文件夹 if(dir1.isDirectory()) Log.info("是文件夹1"); } 复制代码
属性相关操作:
File txt1 = new File("test1.txt"); Log.info("获取父路径 ---->" + new File(txt1.getAbsolutePath()).getParent()); Log.info("文件大小---->" + txt1.length() + "byte"); // 0 byte Log.info("文件是否隐藏----->" + txt1.isHidden()); // false Log.info("文件是否可读----->" + txt1.canRead()); // true Log.info("文件是否可写----->" + txt1.canWrite()); // true Log.info("获取文件路径---->" + txt1.getPath()); // test1.txt // 属性设置 txt1.setReadable(false); // 是否可读 txt1.setWritable(false); // 是否可写 txt1.setExecutable(false); // 是否可执行 txt1.setLastModified(new Date().getTime()); // 最后修改时间 txt1.setReadOnly(); // 设置只读 复制代码
以上是大部分常用的文件相关操作 ~
字节操作:InputStream 和 OutputStream
上边我们讲述了文件的相关操作,我们创建的文件都是空白的,现在需要我们往文件里写一些文字怎么操作呢?这就需要用到我们之前讲的 IO 操作流了~
文件复制
还是以一个例子来讲,我们复制一个文件,点点鼠标右键就解决了,那么在Java中,我们如何使用程序来进行文件拷贝呢?
主要过程分为以下过程:
- 定义输入的文件路径和输出的文件路径(由路径+文件名含文件格式后缀组成)
- 定义 文件输入流(FileInputStream) 和 文件输出流(FileOutputStream)
- 读取文件内容,写入目标文件
- 关闭流通道
对应的代码如下:
/** * 文件复制 * @param src 输入的文件路径 * @param dest 输出的文件路径 */ public static void copyFile(String src, String dest) throws IOException { FileInputStream fis = new FileInputStream(src); FileOutputStream oos = new FileOutputStream(dest); // 初始化buffer 缓冲区 byte[] buffer = new byte[1024 * 2]; int cnt; /** * read() and write() * * @param b 读取数据的缓冲区。 @param off 目标数组中的起始偏移量 <code>b<code> @param len 读取的最大字节数。 * @return 读入缓冲区的总字节数,或者 <code>-1<code> 如果由于已到达文件末尾而没有更多数据。 */ while((cnt = fis.read(buffer, 0, buffer.length)) != -1) { // 写入文件 oos.write(buffer, 0, cnt); } // 关闭通道 fis.close(); oos.close(); } 复制代码
字符操作
上边我们实现了一个文件复制的例子,我们只是拷贝源文件,没有涉及到里边的内容操作,接下来学习一下字符操作
编码与解码
::: tip 编码就是把字符转换为字节,而解码是把字节重新组合成字符。 :::
通常呢,我们对文件进行编码和解码的时候使用的是同一种格式,不然就会出现乱码,有时候你打开一个文件呢出现一堆看不懂的字符,很有可能是你的工具使用了错误的编码格式。
认识一下常见的编码格式:
- GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
- UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节;
- UTF-16be 编码中,中文字符和英文字符都占 2 个字节。
::: tip char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储 :::
public static void stringCodec() throws UnsupportedEncodingException { String hello = "Hello"; // 编码 UTF-8 byte[] bytes = hello.getBytes("UTF-8"); // 解码 UTF-8 String hello1 = new String(bytes, "UTF-8"); Log.info(hello1); // Hello } 复制代码
在调用无参数 getBytes() 方法时,默认的编码方式不是 UTF-16be。双字节编码的好处是可以使用一个 char 存储中文和英文,而将 String 转为 bytes[] 字节数组就不再需要这个好处,因此也就不再需要双字节编码。 getBytes() 的默认编码方式与平台有关,一般为 UTF-8。
文件内容操作 InputStreamReader & OutputStreamWriter
不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符。但是在程序中操作的通常是字符形式的数据,因此需要提供对字符进行操作的方法。涉及两个类:
- InputStreamReader 实现从字节流解码成字符流;
- OutputStreamWriter 实现字符流编码成为字节流。
来看一下实操代码:
public static void readContent(String src) throws IOException { FileReader reader = new FileReader(src); // 装饰器(缓冲区) BufferedReader reader2 = new BufferedReader(reader); String line; // 逐行读取 while ((line = reader2.readLine()) != null) { Log.info(line); } reader2.close(); // writer实现 字符转为字节写入到目标文件 // 创建一个输出流 FileOutputStream output = new FileOutputStream(src); // 创建一个写入流 -> 写入到输出流,目标文件中 OutputStreamWriter writer = new OutputStreamWriter(output); // 写入的数据 String data = "这是我追加的数据"; // write会导致全部重写 (如果是写入新文件可以这么写) writer.write(data); // 强制将写入器中存在的所有数据写入相应的目的地 // writer.flush(); // 将指定字符插入当前编写器 在尾部添加 writer.append("\n" + data); // 先关闭写入流 writer.close(); // 后关闭输出流 output.close(); } 复制代码
对象操作 Serializable & Externalizable
下边的内容将会逐渐加深,涉及的知识点也是比较核心的,要耐心看完哦 ~
说到 Serializable ,我们工作中经常会看到同事的代码,实体类 implements Serializable
加上了这么个东东, 第一印象肯定联想到是 对象序列化 ,那你知道为什么要这样做吗?
序列化 & 反序列化
在讲解之前,我们还是先去理解这两个概念性的东西~
- 序列化 字面意思,将对象存转为字节序列
- 饭序列化 将字节序列转为原来的标准对象
为什么要进行对象序列化?
如果你有这样的需求,因为程序一关闭数据就会从内存中消失, 现在需要把对象存储到磁盘中,那你就需要进行序列化操作,因为文件操作涉及到字节操作。再或者你要把对象进行传输到其它的地方执行,通常是通过网络传输,这也引申了一个小知识点 rpc 远程调用,如果你做过微服务肯定也不陌生了,这里不展开讲了,后边会有专门文章说一下这个实现。
在Java中实现序列化主要通过两个接口:
- Serializable 接口
- Externalizable 接口
Serializable
一个对象想要被序列化,那么它的类就要实现此接口或者它的子接口。这个对象的所有属性(包括private属性、包括其引用的对象)都可以被序列化和反序列化来保存、传递。不想序列化的字段可以使用transient修饰。由于Serializable对象完全以它存储的二进制位为基础来构造,因此并不会调用任何构造函数,因此Serializable类无需默认构造函数,但是当Serializable类的父类没有实现Serializable接口时,反序列化过程会调用父类的默认构造函数,因此该父类必需有默认构造函数,否则会抛异常。使用transient关键字阻止序列化虽然简单方便,但被它修饰的属性被完全隔离在序列化机制之外,导致了在反序列化时无法获取该属性的值,而通过在需要序列化的对象的Java类里加入writeObject()方法与readObject()方法可以控制如何序列化各属性,甚至完全不序列化某些属性或者加密序列化某些属性。
Externalizable
它是Serializable接口的子类,用户要实现的writeExternal()和readExternal() 方法,用来决定如何序列化和反序列化。因为序列化和反序列化方法需要自己实现,因此可以指定序列化哪些属性,而transient在这里无效。对Externalizable对象反序列化时,会先调用类的无参构造方法,这是有别于默认反序列方式的。如果把类的不带参数的构造方法删除,或者把该构造方法的访问权限设置为private、默认或protected级别,会抛出java.io.InvalidException: no valid constructor异常,因此Externalizable对象必须有默认构造函数,而且必需是public的。
动手实现
好,有了以上基础知识的铺垫,下面我们通过例子来直观感受一下:
先定义一个类:
class Apple implements Serializable { /** * serialVersionUID 是实现 Serializable 接口而来的,而 Serializable 则是应用于Java 对象序列化/反序列化。 * 对象的序列化主要有两种用途: * * 1. 把对象序列化成字节码,保存到指定介质上(如磁盘等) * 2. 用于网络传输 * * * serialVersionUID 是 Java 为每个序列化类产生的版本标识,可用来保证在反序列时,发送方发送的和接受方接收的是可兼容的对象。 * 如果接收方接收的类的 serialVersionUID 与发送方发送的 serialVersionUID 不一致,进行反序列时会抛出 InvalidClassException * */ private static final long serialVersionUID = 1L; private int x; private String y; transient String z; Apple(int x, String y) { this.x = x; this.y = y; this.z = "z"; } @Override public String toString() { return "x = " + x + " " + "y = " + y + " " +z; } } 复制代码
通过代码可以看到 transient 修饰的一个属性, 那么它有什么作用呢?我们通过代码来验证一下:
public static void objSerializable() throws IOException, ClassNotFoundException { String objFile = "test/apple.txt"; Apple apple = new Apple(1, "apple"); /** * 序列化: * 1. 创建出输出流,即目标对象,比如磁盘 * 2. 创建对象输出流 * 3. 写入对象 * 4. 关闭对象输出流 */ FileOutputStream fileOutputStream = new FileOutputStream(objFile); ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream); objectOutputStream.writeObject(apple); objectOutputStream.close(); // 发现apple.txt文件打开后是乱码,因为被序列化了,对于敏感信息的对象可以使用序列化操作 /** * 反序列化 * 与序列化相反: * 1. 创建输入流 * 2. 创建对象输入流 * 3. 读取对象 * 4. 关闭对象输入流 */ FileInputStream fileInputStream = new FileInputStream(objFile); ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream); // 读取对象 Apple appleRead = (Apple) objectInputStream.readObject(); objectInputStream.close(); Log.info(appleRead); // x = 1 y = apple null 这里打印的是 Apple.toString() 方法 // 发现 transient 修饰的z 并没有被序列化 ,所以是null } } 复制代码
结果已经在注释中标记了~ 另外需要提醒大家的是: 序列化不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态
我们之前学习的集合 ArrayList
底层数据结构中的数组 elementData变量还记得是什么修饰的吗?没错,就是 transient, 这么做主要是因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据
private transient Object[] elementData; 复制代码
网络操作
上边的内容都还没有涉及到网络操作,但是我们的日常操作都是在和网络打交道,下面我们就开始学习一下网络相关的IO操作,在Java中的支持主要有以下几个方面:
- InetAddress 用于表示网络上的硬件资源,即 IP 地址
- URL 统一资源定位符
- Socket 使用 TCP 协议实现网络通信
- Datagram 使用 UDP 协议实现网络通信;
InetAddress
这个类没有公有的构造函数,只能通过静态方法来创建实例。
public static void ipAddress() throws UnknownHostException { InetAddress netAddress = InetAddress.getByName("www.baidu.com"); Log.info(netAddress); // www.baidu.com/180.101.49.11 // 获取ip地址 - 先从本地缓存获取, 没有走dns服务器 Log.info("ipv4: " + netAddress.getHostAddress()); // ipv4: 180.101.49.11 InetAddress ipAddress = InetAddress.getByName("xx.xxx.xxx.xxx"); Log.info(ipAddress); // /xx.xxx.xxx.xxx } 复制代码
URL实现下载
这个也是我们日常使用频率比较高的类,那么它的作用是啥呢?它可以直接从 URL 中读取字节流数据,通常是一个网络通道地址, 下面我们来结合之前学习的知识动手实现一个下载功能
/** * URL * 可以直接从 URL 中读取字节流数据。 */ public static void readByteByUrl() throws IOException { // 创建url读取对象 注意要加协议头 URL url = new URL("https://www.baidu.com"); // 创建输入流 - 字节 InputStream inputStream = url.openStream(); // 创建读取流 - 字符 InputStreamReader reader = new InputStreamReader(inputStream, "UTF-8"); // 创建缓冲区读取对象 BufferedReader readerReader = new BufferedReader(reader); String line; // 写入文件 FileOutputStream fileOutputStream = new FileOutputStream("test/baidu.html"); OutputStreamWriter writer = new OutputStreamWriter(fileOutputStream); while ((line = readerReader.readLine()) != null) { Log.info(line); writer.write(line); } // 关闭写入流 writer.close(); // 关闭读取流 readerReader.close(); /** * 下载图片: * 1. 通过 网络地址获取网络资源的字节流 * 2. 写入文件 - 创建文件输出流 * 3. 通过 url打开输入流通道 imgUrl.openStream() * 4. 创建buffer缓冲区, 将字节写入buffer, * 5. 最后通过buffer缓冲区将内容写入目标输出流 * 6. 关闭输出流通道 */ URL imgUrl = new URL("https://t7.baidu.com/it/u=4162611394,4275913936&fm=193&f=GIF"); InputStream imgInputStream = new BufferedInputStream(imgUrl.openStream()); FileOutputStream imgStream = new FileOutputStream("test/dog.jpg"); byte[] buffer = new byte[1024 * 2]; int cnt; while ((cnt = imgInputStream.read(buffer, 0 , buffer.length)) != -1) { imgStream.write(buffer, 0, cnt); } writer.close(); Log.info("文件下载成功"); } 复制代码
是不是发现之前学习的知识点都是串在一起的,所以平时工作也是一样的,可能一个不起眼的功能或许就是你实现复杂功能的小关键点,所以要多去做总结
socket
听到 socket,大家都不陌生,但是到底啥是 socket
呢?我们平时日常开发大部分都是处理 http 请求,这都是应用层的。先看百度百科是怎么描述的
::: tip 所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口 :::
好了,我们知道了平时所说的套接字就是我们的 socket,还记得网络七层模型是哪七个吗?从下往上:
::: tip 物理层(硬件) -> 数据链路层(网卡) -> 网络层(ip) -> 传输层 (tcp/udp ...) -> 会话层 -> 表示层 -> 应用层 :::
其中 socket 属于传输层(基于tcp/ip),好了有多了一个概念,啥是tcp?
TCP
是一种面向连接的、可靠的、基于字节流的传输层通信协议,TCP 层是位于 IP 层之上,应用层之下的中间层。TCP保障了两个应用程序之间的可靠通信。通常用于互联网协议,被称 TCP / IP。
socket是基于应用服务与TCP/IP通信之间的一个抽象,他将TCP/IP协议里面复杂的通信逻辑进行分装,对用户来说,只要通过一组简单的API就可以实现网络的连接。
UDP
既然tcp
是可靠的链接,那么有不可靠的吗?有
其中UDP是一种面向无连接的传输层协议。UDP不关心对端是否真正收到了传送过去的数据。如果需要检查对端是否收到分组数据包,或者对端是否连接到网络,则需要在应用程序中实现。UDP常用在分组数据较少或多播、广播通信以及视频通信等多媒体领域。
socket 编程实现
套接字使用TCP
提供了两台计算机之间的通信机制。 客户端程序创建一个套接字,并尝试连接服务器的套接字。当连接建立时,服务器会创建一个 Socket 对象。客户端和服务器现在可以通过对 Socket 对象的写入和读取来进行通信。
java.net.Socket
类代表一个套接字,并且 java.net.ServerSocket
类为服务器程序提供了一种来监听客户端,并与他们建立连接的机制。实现步骤:
- 服务器实例化一个
ServerSocket
对象,表示通过服务器上的端口通信 - 服务器调用
ServerSocket
类的accept()
方法,该方法将一直等待,直到客户端连接到服务器上给定的端口 - 服务器正在等待时,一个客户端实例化一个
Socket
对象,指定服务器名称和端口号来请求连接 - Socket 类的构造函数试图将客户端连接到指定的服务器和端口号。如果通信被建立,则在客户端创建一个
Socket
对象能够与服务器进行通信 - 在服务器端,
accept()
方法返回服务器上一个新的socket
引用,该socket
连接到客户端的socket
连接建立后,通过使用 I/O
流在进行通信,每一个socket都有一个输出流和一个输入流,客户端的输出流连接到服务器端的输入流,而客户端的输入流连接到服务器端的输出流。
TCP
是一个双向的通信协议,因此数据可以通过两个数据流在同一时间发送.以下是一些类提供的一套完整的有用的方法来实现 socket。
下面我们手动实现一下,我们先实现 ````客户端``` :
// 客户端socket public static void clientSocket() throws IOException { int port = 1000; String host = "localhost"; // 创建套接字 Socket socket = new Socket(host, port); // 如果链接不上会报错 Connection refused (Connection refused) Log.info("链接到远程主机地址: " + socket.getRemoteSocketAddress()); // 获取套接字输出流 OutputStream outputStream = socket.getOutputStream(); // 建立数据输出流 DataOutputStream dataOutputStream = new DataOutputStream(outputStream); // 写入数据 - 写入字符数据 dataOutputStream.writeUTF("我是客户端 socket"); // 获取服务端的响应 // 获取输入流 InputStream inputStream = socket.getInputStream(); // 创建数据输入流 DataInputStream dataInputStream = new DataInputStream(inputStream); // 读取数据 Log.info(dataInputStream.readUTF()); // 关闭客户端 socket.close(); } 复制代码
服务端:
// 服务端socket public static void serverSocket() throws IOException { // 创建服务端 ServerSocket serverSocket = new ServerSocket(1000); // 设置超时时间 ms - 30s 内未收到消息自动退出 serverSocket.setSoTimeout(30000); while(true) { try { Log.info("等待远程连接---->" + serverSocket.getLocalPort()); // 等待远程连接---->1000 // 获取服务端套接字 Socket socket = serverSocket.accept(); Log.info("远程主机地址: ---->" + socket.getRemoteSocketAddress()); // 获取客户端响应 // 创建输入流 DataInputStream dataInputStream = new DataInputStream(socket.getInputStream()); Log.info("客户端说了: ---->" + dataInputStream.readUTF()); // 发送消息给客户端 OutputStream outputStream = socket.getOutputStream(); DataOutputStream dataOutputStream = new DataOutputStream(outputStream); dataOutputStream.writeUTF("我收到了你的消息 / server Socket"); socket.close(); // 相关输出: // 服务端: // 等待远程连接---->1000 // 远程主机地址: ---->/127.0.0.1:59773 // 客户端说了: ---->我是客户端 socket // 等待远程连接---->1000 // 远程主机地址: ---->/127.0.0.1:59778 // 客户端说了: ---->我是客户端 socket // 等待远程连接---->1000 // 客户端: // 链接到远程主机地址: localhost/127.0.0.1:1000 // 我收到了你的消息 / server Socket }catch (IOException e) { Log.info("服务端出错了 ----> " + e.getMessage()); break; } } } 复制代码
UDP实现
我们之前提到过 udp
, 所以这里我们也动手实现一下,核心类主要是以下:
- Datagram
- DatagramSocket:通信类
- DatagramPacket:数据包类(平时说的丢包啥的,丢的就是数据包)
纸上得来终觉浅啊
,这次呢,我们挑战一下自己,我们不传字符串了,我们来传文件,这怎么去实现呢, 这就考验我们之前学的怎么样了, 还是一样的,我们先实现 客户端
public static void dataGramClient() throws IOException { // 创建一个数据包对象 并绑定到内核ip DatagramSocket socket = new DatagramSocket(1001); Log.info("client: " + socket.getLocalAddress()); // 写入数据到包 FileInputStream fileInputStream = new FileInputStream("test/dog.jpg"); byte[] buffer = new byte[1024 * 2]; // 如果是数据量大, 比如文件可以利用 buffer stream 借用缓冲区去读取数据 BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream); int cnt; while((cnt = bufferedInputStream.read(buffer, 0, buffer.length)) != -1) { // 封包 DatagramPacket dp = new DatagramPacket(buffer, 0, cnt); // 发送包 - Message too long (sendto failed) udp 支持的包最大是 64k // dp.setData(buffer); // 如果初始化好的, 就不用setData dp.setAddress(InetAddress.getLocalHost()); dp.setPort(1002); socket.send(dp); Log.info("传输中..."); // cnt 等于buffer.length Log.info("buffer read index --->"); } Log.info("传输完毕"); // 关闭输入流 - 不然会导致阻塞 bufferedInputStream.close(); Log.info("close"); // 发完后发送一个结束的指令 byte[] successData = "FILE OK".getBytes(StandardCharsets.UTF_8); DatagramPacket successPak = new DatagramPacket(successData, 0, successData.length); successPak.setAddress(InetAddress.getLocalHost()); successPak.setPort(1002); socket.send(successPak); Log.info("send"); // 创建一个包 1M 大小 DatagramPacket packet = new DatagramPacket(new byte[1024], 1024); // 设置远程主机 - 用于数据上报和接收 packet.setPort(1002); packet.setAddress(InetAddress.getLocalHost()); // 接收包 - 如果没接收到 会阻塞 socket.receive(packet); // 获取数据 Log.info(packet.getAddress() + " ---> "+ new String(packet.getData(), "UTF-8")); // 关闭客户端 socket.close(); } 复制代码
在看一下 服务端
// 服务端 public static void dataGramServer() throws IOException { // 创建一个数据包对象 并绑定到内核ip DatagramSocket socket = new DatagramSocket(1002); Log.info("server: " + socket.getLocalAddress()); // 创建文件输出流 FileOutputStream fos = new FileOutputStream("test/dog1.jpg"); // 创建一个输出流 BufferedOutputStream bos = new BufferedOutputStream(fos); byte[] buffer = new byte[1024 * 2]; while (true) { // 解包 DatagramPacket fileDp = new DatagramPacket(buffer, 0, buffer.length); fileDp.setPort(1001); fileDp.setAddress(InetAddress.getLocalHost()); socket.receive(fileDp); // 判断是否接收到文件上传完毕指令 if(new String(fileDp.getData(), "UTF-8").contains("FILE OK")) { Log.info("文件接收完毕"); break; } // 写入文件 bos.write(buffer, 0, buffer.length); bos.flush(); Log.info("正在接收数据 ->"); } // 关闭输出流 bos.close(); Log.info("文件句柄关闭"); // 设置数据 byte[] result = "我已经下载好了文件".getBytes(StandardCharsets.UTF_8); DatagramPacket resPck = new DatagramPacket(result, 0, result.length); resPck.setPort(1001); resPck.setAddress(InetAddress.getLocalHost()); // 发送数据给客户端 - 发包 socket.send(resPck); Log.info("发送结果"); // 关闭服务端 socket.close(); } 复制代码
由此,我们这个小功能就算实现了~
NIO
之前的案例中,有提到阻塞
, 没有怎么细讲,下面我们就说说几种IO模式,在了解这个之前呢,我们先来聊聊什么是同步
和 异步
同步 & 异步
同步和异步是针对应用程序和内核的交互而言的,同步指的是用户进程触发IO 操作并等待或者轮询的去查看IO 操作是否就绪,而异步是指用户进程触发IO 操作以后便开始做自己的事情,而当IO 操作已经完成的时候会得到IO 完成的通知。
大白话讲就是,举个银行的例子:
同步
: 自己亲自出马持银行卡到银行取钱(使用同步 IO 时,Java 自己处理IO 读写);异步
: 委托一小弟拿银行卡到银行取钱,然后给你(使用异步IO 时,Java 将 IO 读写委托给OS 处理,需要将数据缓冲区地址和大小传给OS(银行卡和密码),OS 需要支持异步IO操作API)
阻塞 & 异步
阻塞和非阻塞是针对于进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作方法的实现方式,阻塞方式下读取或者写入函数将一直等待,而非阻塞方式下,读取或者写入方法会立即返回一个状态值。
还是上边的例子:
- 阻塞 : ATM排队取款,你只能等待
- 非阻塞 : 柜台取款,取个号,然后坐在椅子上做其它事,等号广播会通知你办理,没到号你就不能去,你可以不断问服务人员排到了没有,服务人员如果说还没到你就不能去
好了,有了上面概念的理解,大概你会猜到有哪些模式了。没错,我们来一起组词
同步阻塞 BIO (Blocking IO)
这是我们比较常见的方式, BIO编程方式通常是在JDK1.4版本之前常用的编程方式。编程实现过程为:首先在服务端启动一个ServerSocket来监听网络请求,客户端启动Socket发起网络请求,默认情况下ServerSocket回建立一个线程来处理此请求,如果服务端没有线程可用,客户端则会阻塞等待或遭到拒绝。且建立好的连接,在通讯过程中,是同步的。在并发处理效率上比较低。大致结构如下:
同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理, 如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。
异步非阻塞的编程方式 AIO (Asynchronous IO)
当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。
在JDK1.7中,这部分内容被称作NIO.2,主要在java.nio.channels包下增加了下面四个异步通道:AsynchronousSocketChannel、AsynchronousServerSocketChannel、AsynchronousFileChannel、AsynchronousDatagramChannel
nodejs
它就是使用这种模式,既然聊到,就简单讲讲,它不是本节主要内容
它呢,在主线程里维护了一个事件队列,当接到请求后,就将该请求作为一个事件放入这个队列中,然后继续接收其他请求。当主线程空闲时(没有请求接入时),就开始循环事件队列,检查队列中是否有要处理的事件,这时要分两种情况:如果是非 I/O 任务,就通过主线程处理,并通过回调函数返回到上层调用;如果是 I/O 任务,就从 线程池 中拿出一个线程来处理这个事件,并通过观察者指定回调函数,然后继续循环队列中的其他事件。 当线程中的 I/O 任务完成以后,通过观察者执行指定的回调函数,并把这个完成的事件放到事件队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用
同步非阻塞的编程方式 NIO (Unblocking IO)
终于到我们重点关注的对象了, 随着我们业务的不断扩张,程序同样也迎来了巨大的挑战,各种高并发的场景也随之而来, 所以很多技术的出现都是有它存在的道理的,本节可能有点难懂,我们本节的任务就是要动手实现一个 NIO
,耐心看完哦 ~
NIO本身是基于事件驱动思想来完成的,其主要想解决的是BIO
的大并发问题,NIO
基于Reactor
,当socket
有流可读或可写入socket时,操作系统会相应的通知引用程序进行处理,应用再将流读取到缓冲区或写入操作系统。也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。强大的通信框架 netty
就是基于它
同样的,我们理解完基本概念后,先补充一下前置知识, 我们先聊下 流与块
流与块
I/O
与 NIO
最重要的区别是数据打包和传输的方式,I/O
以流的方式处理数据,而 NIO
以块的方式处理数据。
- 面向流的 I/O 一次处理一个字节数据:一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢
- 面向块的 I/O 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
NIO
主要是面向块的结构
通道 channel
通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。通道包括以下类型:
- FileChannel:从文件中读写数据
- DatagramChannel:通过 UDP 读写网络中数据
- SocketChannel:通过 TCP 读写网络中数据
- ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel
缓冲区 Buffer
发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
缓冲区主要包括以下类型:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
这里实现一个文件复制
的小例子来直观感受一下通道和缓冲区的概念
// 简单示例- 文件快速复制 // 双向 public static void fastCopy(String dst) throws IOException { String src = "test/dog.jpg"; // 创建文件输入流 FileInputStream fis = new FileInputStream(src); // 获取输入通道 FileChannel fileInputChannel = fis.getChannel(); // 创建文件输出流 FileOutputStream fos = new FileOutputStream(dst); FileChannel fileOutChannel = fos.getChannel(); // 设置缓冲区 分配 1024字节大小的缓存区 ByteBuffer buffer = ByteBuffer.allocateDirect(1024); while(true) { // 读取文件 int r = fileInputChannel.read(buffer); // -1 表示 EOF if (r == -1) { break; } // 切换通道 buffer.flip(); // 将buffer写入 fileOutChannel.write(buffer); // 清空缓冲区 - 以便下次读写 buffer.clear(); } Log.info("复制完毕"); } 复制代码
NIO 选择器
NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel上的事件,从而让一个线程就可以处理多个事件。通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。
动手实现 NIO
好了,有了上边的基本概念后,我们来实现就有了思路了~, 同样的,我们先实现一个客户端
// 客户端 // 客户端主要就是socket, nio主要用于服务端的一种通信模式 public static void nioClient() throws IOException, InterruptedException { Socket socket = new Socket("127.0.0.1", 8000); OutputStream outputStream = socket.getOutputStream(); DataOutputStream out = new DataOutputStream(outputStream); // 为了测试 - 只写数据 int i = 1; while(i > 0) { out.writeUTF("client send num:" + i); i --; Thread.sleep(3000); Log.info("send data ok"); } socket.close(); } 复制代码
重点在服务端:
public static void noiServer() throws IOException { // 1. 创建选择器 Selector selector = Selector.open(); // 2. 将通道注册到选择器上 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); // 获取socket 绑定监听 ServerSocket serverSocket = serverSocketChannel.socket(); InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8000); serverSocket.bind(address); Log.info("监听数据:" + 8000); // 循环监听事件 while(true) { selector.select(); Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> keyIterator = keys.iterator(); while(keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if(key.isAcceptable()) { ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel(); // 服务器为每一个新的链接创建一个channel SocketChannel sChannel = ssChannel.accept(); sChannel.configureBlocking(false); // 这个新链接用于从客户端读取数据 - 读 sChannel.register(selector, SelectionKey.OP_READ); }else if(key.isReadable()) { // 获取读事件 SocketChannel readChannel = (SocketChannel) key.channel(); /** * 读取数据 * 1. 创建缓冲区 * 2. 读取channel的数据 * 3. 返回数据 */ ByteBuffer buffer = ByteBuffer.allocate(1024); StringBuilder data = new StringBuilder(); // 如果通道一直有数据产生, 循环会一直执行, 直到结束 while(true) { // 先清一下缓冲区 buffer.clear(); int cnt = readChannel.read(buffer); if(cnt == -1) break; // 转换为写 buffer.flip(); int limit = buffer.limit(); char[] chr = new char[limit]; for(int i = 0;i < limit; i ++) { chr[i] = (char) buffer.get(i); Log.info("char [i] -----> " + chr[i]); } data.append(chr); buffer.clear(); } //"get client: \u0000" // byte空数组中会包含空格 这里转化一下 String res = data.toString().replaceAll("([\\u0000]*)", ""); Log.info("get client: " + res + "\n"); if(res.equals("get client: client send num:1")) { Log.info("equal"); // "get client: \u0000\u0011client send num:1" \u0000 表示空格 } // 关闭通道 readChannel.close(); } // 消费完成后移除掉 keyIterator.remove(); } } } 复制代码
相关注释呢,都在代码体现了,这里来总结主要做了哪些事:
- 创建选择器
- 将通道注册到选择器上
- 监听事件
- 获取到达的事件
- 事件循环
结束语
写到这里,本节文章就到此结束了,整体看下来,文章篇幅较长,建议收藏观看,如果觉得写的不错,用你发财的小手帮我点个赞呗 🌹~
给大家一个比较中肯的建议,平时学习的时候一定要做笔记,最终要的是要去总结它,形成自己的知识体系,加大自己对知识点的理解,不断的丰富自己,积累自己, 在项目中去应用,不断的踩坑, 这样知识才是你的~