前言

本篇将分为BIO、NIO概念介绍、javaNIO组件、并发NIO简易模型实例三部分组成,为读者和我自己增加对NIO的理解。

一、概念认知

这里主要是介绍NIO,但是在介绍NIO的时候难免会提到BIO,所以这里也顺带介绍一下什么是BIO,由此切入NIO。

1 什么是IO

在介绍BIO之前先来了解一下更加普遍的概念IO,IO是输入流和输出流的统称,在IO的世界里,有几个重要的概念,分别是同步、异步、阻塞、非阻塞,在这一小部分主要也是介绍这四个的概念。

同步与异步

同步异步中有一个主线程的概念问题,就是当前处理逻辑的线程,假设一种场景,在主线程中依次执行方法一、方法二

那么同步就是在调用方法一时,方法二必须等方法一调用返回后才能继续执行后续的行为。顺序执行

异步就是在调用方法一时,立即返回,主线程不需要等待方法一内部代码执行完成,就可以继续执行方法二。并行执行,方法一交给了另外一个线程去处理,不由主线程执行,因此也不会阻塞主线程。

阻塞与非阻塞

在单个线程内遇到同步等待时,是否在原地不做任何操作

阻塞是指遇到同步等待后,一直在原地等待同步方法处理完成;

非阻塞是指遇到同步等待后,不在原地等待,先去处理其他的操作,隔段时间再来判断同步方法是否完成

举个老王烧水的例子

同步阻塞(BIO): 老王烧水,老王需要在水壶旁一直等待水烧开才能去做其他事情;

同步非阻塞(NIO): 老王烧水,老王将水放入水壶并且开始烧水后,可以先去做其他事情,但是每隔一段时间就去查看一下水是否烧开了;

异步非阻塞(AIO): 老王烧水,老王开始烧水后直接去做其他事情,水壶会在水烧开后自动鸣笛通知老王继续执行下一步操作。

2 什么是BIO

Blocking IO 是JDK1.4之前的传统IO模型,本身是同步阻塞模型,线程发起IO请求后,会一直阻塞IO直到缓存区数据就绪后,再进入下一步的操作。针对于网络通信都是一个请求一个应答的方法,虽然简化了上层的应用开发,但在性能和可靠性方面存在着巨大瓶颈,试想一下如果每个请求都需要新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽。

对于BIO的资源方面的优化,最早是使用线程池来管理IO响应线程,最大限度上的节约线程方面的消耗,但是这种方面依然不能避免高并发情况下的线程损耗问题。

而这也是NIO诞生的原因,任何技术诞生都是为了解决某个已知的问题的。

3 什么是NIO

Non-blocking IO是在jdk1.4被提出,同步非阻塞模型,线程发起IO请求后立即返回。同步指的是必须等待IO缓冲区内的数据就绪,而非阻塞指的是,用户线程不必原地等待IO缓冲区,可以先做一些其他操作,但是要定时轮询检查IO缓冲区数据是否就绪。

由此来看,BIO模型中每个线程都可以处理多个请求,这样就可以解决资源消耗严重问题。但是因此可以想到一个线程处理多个请求时,如果出现请求过多,那么必然会出现请求堆积排队处理,那么响应的速度肯定会下降,这个也是NIO的弊端,相较于这点BIO是响应速度最快的,因为它是一直在等待响应。

在并发下,NIO的弊端并不是不可以避免,这就要看采用的BIO模型是什么样的了,合理一些的应该根据服务器的CPU内核、内存等分配一些线程,多线程的方式处理各个NIO 多路复用器的事件。

在BIO和NIO之间如何选择?

BIO和NIO同时存在他们的优缺点,并不是一定NIO一定优于BIO,这样形容就有些片面了。如何选择要看业务定义和资源的支持情况,从这里也可以看出一个二八原则,是否要为百分之二十的响应时间而浪费百分之百的线程资源。

4 什么是AIO 扩展

关于什么是AIO的问题,研究不多,只引入概念

AIO,Asynchronous IO,在进行 I/O 编程中,通常用到两种模式:Reactor 和 Proactor 。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理。

AIO 叫做异步非阻塞的 I/O,引入了异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才会启动线程,特点就是先由操作系统完成后才通知服务端程序启动线程去处理,一般用于连接数较多且连接时长较长的应用。

可以理解有一个回调函数,当有I/O请求并已经准备好时,操作系统直接调用回调函数完成请求,由此可以看出这个AIO是依赖于操作系统的。

二、NIO组件

java中NIO的三个组件,分别是Channel、buffer、selector。

1 Channel

NIO 使用信道 (Channel) 来发送和接收数据,而不使用传统的流 (InputStream/OutputStream)。

Channel 实例代表了打开一个实体的连接,这些实体包括硬件设备,文件,网络套接字等等。 Channel 有个特色,在 Channel 上的操作,例如读写,都是线程安全的。

1.1 SelectableChannel

SelectableChannel 是一个抽象类,它实现了 Channel 接口,这个类比较特殊。

首先 SelectableChannel 可以是阻塞或者非阻塞模式。如果是阻塞模式,在这个信道上的任何 I/O 操作都是阻塞的直到 I/O 完成。 而如果是非阻塞模式,任何在这个信道上的 I/O 都不会阻塞,但是传输的字节数可能比原本请求的字节数要少,甚至一个也没有。这里主要是指NIO非阻塞立即返回结果,所以读取的数据有多有少。

其次呢 SelectableChannel 可以被 Selector 用来多路复用,不过首先需要调用 selectableChannel.configureBlocking(false) 调整为非阻塞模式(nonblocking mode),这一点很重要。然后进行注册

SelectionKey register(Selector sel, int ops)
SelectionKey register(Selector sel, int ops, Object att)

第一个参数代表要注册的 Selector 实例。关于 Selector 后面再讲。

第二个参数代表本通道感兴趣的操作,这些都定义在 SelectionKey 类中,如下

public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

对于 SocketChannel ,它感兴趣的操作只有 OP_READ, OP_WIRTEOP_CONNECT,然而它并不包括 OP_ACCEPT。 而 ServerSocketChannel可以对这四个操作都感兴趣。为何?因为只有 ServerSocketChannelaccpet() 方法。

DatagramChannelSocketChannelServerSocketChannel 都是 SelectableChannel 的子类。

第三个参数 Object att 是注册时的附件,也就是可以在注册的时候带点什么东西过去。这里在后续也可以使用SelectionKey的attach方法

register() 方法会返回一个 SelectionKey 实例。SelectionKey 相当于一个 Java Bean,其实就是 register() 的三个参数的容器,它可以返回和设置这些参数

Selector selector();
int interestOps();
Object attachment()

1.2 其他Channel

  • **FileChannel:**从文件中读写数据。

  • DatagramChannel: 通过UDP读写网络中的数据,继承自SelectableChannel。

  • **SocketChannel:**通过TCP读写网络中的数据,继承自SelectableChannel。

  • **ServerSocketChannel:**可以监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel,继承自SelectableChannel。

ServerSocketChannel和SocketChannel在下面的实例中会用到,他们是基于TCP进行连接,这里也着重介绍下

1.2.1 ServerSocketChannel

ServerSocketChannel 类代表服务器端套接字通道(server-socket channel)。

ServerSocketChannelSocktChannel 一样,需要通过静态方法 open() 来创建一个实例,创建后,还需要通过 bind() 方法来绑定到本地的 IP 地址和端口

ServerSocketChannel bind(SocketAddress local)
ServerSocketChannel bind(SocketAddress local, int limitQueue)

参数 SocketAddress local 代表本地 IP 地址和端口号,参数 int limitQueue 限制了连接的数量。

对每一个新进来的连接都会创建一个SocketChannel去管理这个连接

1.2.2 SocketChannel

SocketChannel 代表套接字通道(socket channel)。

SocketChannel 实例是通过它的静态的方法 open() 创建的

open() 方法仅仅是创建一个 SocketChannel 对象,而 open(SocketAddress remote) 就更进一步,它还调用了 connect(addr) 来连接服务器。

SocketChannelSelectableChannel 的子类,还记得前面 SelectableChannel 的特性吗?如果不配置阻塞模式,那么 SocketChannel 对象默认就是阻塞模式,那么 open(SocketAddress remote) 方法其实就是阻塞式打开服务器连接。而且在 SocketChannel 上任何 I/O 操作都是阻塞式的。

那么既然 SelectableChannel 可以在非阻塞模式下的任何 I/O 操作都不阻塞,那么我们可以先调用无参的 open() 方法,然后再配置为非阻塞模式,再进行连接,而这个连接就是非阻塞式连接,伪代码如下

// 创建 SocketChannel 实例
SocketChannel sc = SocketChannel.open();
// 调整为非阻塞模式
sr.configureBlocking(false);
// 连接服务器
sr.connect(remoteAddr);

此时的 connect() 方法是非阻塞式的,我们可以通过 isConnectionPending() 方法来查询是否还在连接中,如果还在连接中我们可以做点其它事,而不用像创建 Socket 一样一起阻塞走到连接建立,在这里我们可以看到使用 NIO 的好处了。

如果 isConnectionPending() 返回了 false,那就代表已经建立连接了,但是我们还要调用 finishConnect() 来完成连接,这点需要注意。

2 Buffer

关键的Buffer实现 ByteBuffer,CharBuffer,DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer

Buffer两种模式、三个属性:

capacity

作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。

position

当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1. 当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. limit会置为写模式中position的位置,当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。

limit

在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。 当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)

缓存区基础:https://www.cnblogs.com/chenpi/p/6475510.html

3 Selector

SelectorSelectableChannel 的多路复用器,可以用一个 Selector 管理多个 SelectableChannel。例如,可以用 Selector 在一个线程中管理多个 ServerSocketChannel,那么我们就可以在单线程中同时监听多个端口的请求,这简直是美不可言。 从这里我们也可以看出使用 NIO 的好处。

Selector 实例也需要通过静态方法 open() 创建。

3.1 注册 SelectableChannel

前面说过,我们需要调用 SelectableChannelregister() 来向 Selector 注册,它会返回一个 SelctionKey 来代表这次注册。

3.2 选择通道

可以通过 Selector 管理多个 SelectableChannel,它的 select() 方法可以监测哪些信道已经准备好进行 I/O 操作了,返回值代表了这些 I/O 的数量。

int select()
int select(long timeout)
int selectNow()

当调用 select() 方法后,它会把代表已经准备好 I/O 操作的信道的 SelectionKey 保存在一个集合中,可以通过 selectedKeys() 返回。

select() 的三个方法,从命名就可以看出这几个方法的不同之处,第一个方法是阻塞式调用,第三个方法设置了一个超时时间,第三个方法是立即返回。如果是多线程模型,最好使用wakeup去主动唤醒阻塞Selector,否则将响应变慢。

3.3 wakeUp()

如果调用 selcet() 方法会导致线程阻塞,甚至无限阻塞,wakeUp() 方法是唤醒那些调用 select() 方法而处于阻塞状态的线程。

Selector原理:https://www.iteye.com/blog/zhhphappy-2032893

三、并发NIO简易模型

1 概述

简易并发模型,使用一个线程管理器来分别管理IO线程和业务处理线程。将NIO中读和写分别在不同的线程进行以避免并发请求过多造成排队问题,将具体业务处理和IO隔离开,以免影响接受IO请求。

2 服务端

以下对于线程池、Selector管理器、读写Selector代码详细介绍,其他内容省略,具体可看GitHub

2.1 线程池管理器

public class CustomizedThreadPool {private final static ExecutorService SOCKED_THREAD_POOL = new  ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),Runtime.getRuntime().availableProcessors()*2,500, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(), Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());private final static ExecutorService READ_WRITE_THREAD_POOL = new  ThreadPoolExecutor(Runtime.getRuntime().availableProcessors()*2,Runtime.getRuntime().availableProcessors()*5,500, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(), Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());public static void sockedHandlerSubmit(Runnable task) {SOCKED_THREAD_POOL.submit(task);}public static void writeHandlerSubmit(Runnable task) {READ_WRITE_THREAD_POOL.submit(task);}public static void shutdown() {// 先关闭端口监听,不在接受请求// 处理完剩下所有请求后关闭SOCKED_THREAD_POOL.shutdown();READ_WRITE_THREAD_POOL.shutdown();}}

线程池这里,我将IO线程和业务线程隔离开了,这是为了不会因为业务处理的问题而导致建立连接超时。

这里有一个细节,就是建立线程池的核心线程数和最大线程数应该如何选取?

这个主要要根据业务来进行区分,可以分为两类,CPU密集型和IO密集型

  • CPU密集型: CPU利用率比较大,那么应该尽量少的线程数量,一般为CPU的核数+1;

  • IO密集型: 因为IO阻塞的时间比较多,所以可以多分配一点线程数,公式是:CPU核数/(1-阻塞系数),其中阻塞系数在0.8~0.9之间。

可在java中使用Runtime.getRuntime().availableProcessors()来查询jvm可使用的CPU核心数

如何判断线程池线程数?

https://blog.csdn.net/weixin_43975771/article/details/113099180

https://juejin.cn/post/6844903990870671374

2.2 Selector管理构建器

public class SelectorManagerBuilder {public static SelectorManager build(int port, int readAndWriteSelectorAccount) throws IOException {if (readAndWriteSelectorAccount == 0) {throw new IllegalArgumentException("readAndWriteSelectorAccount 不可以为0");}return new SelectorManager(port, readAndWriteSelectorAccount);}}

2.3 Selector管理器

public class SelectorManager {// 关注read事件的selector的集合private final static List<Selector> readSelectors = new ArrayList<>();// 关注write事件的selector的集合private final static List<Selector> writeSelectors = new ArrayList<>();private final int PORT;public SelectorManager(int port, int readAndWriteSelectorAccount) throws IOException {this.PORT = port;for (int i = 0; i < readAndWriteSelectorAccount; i++) {// 初始化写相关多路复用器Selector writeSelector = Selector.open();CustomizedThreadPool.sockedHandlerSubmit(new WriteSelector(writeSelector));writeSelectors.add(writeSelector);// 初始化读相关多路复用器Selector readSelector = Selector.open();CustomizedThreadPool.sockedHandlerSubmit(new ReadSelector(readSelector, writeSelector));readSelectors.add(readSelector);}}public void startNIO() throws IOException {// 多路复用器Selector acceptSelector = Selector.open();// 服务端通道ServerSocketChannel ssc = ServerSocketChannel.open();// 设置为非阻塞ssc.configureBlocking(false);// 监听本地端口ssc.bind(new InetSocketAddress(this.PORT));ssc.register(acceptSelector, SelectionKey.OP_ACCEPT);int i = 0;while (true) {// 返回已经准备好并且感兴趣的selectedKeys数量if (acceptSelector.selectNow() == 0) {continue;}// 返回已经准备好并且感兴趣的selectedKeys集合Iterator<SelectionKey> keyIterator = acceptSelector.selectedKeys().iterator();while (keyIterator.hasNext()) {SelectionKey key = keyIterator.next();if (key.isAcceptable()) {ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();// 接受客户端请求SocketChannel socketChannel = serverChannel.accept();socketChannel.configureBlocking(false);Selector readSelector = readSelectors.get(i % readSelectors.size());// 关注Read事件socketChannel.register(readSelector,SelectionKey.OP_READ);// 将当前的selectorKey从selectedKeys移除,就不会重复触发accept事件了;// 除非再次有请求到达触发该强求keyIterator.remove();// 唤醒read多路复用器所在线程,减少线程等待时间readSelector.wakeup();i++;if (i == Integer.MAX_VALUE - 1) {i = 0;}}}}}}

在Selector管理器中预生成若干个读和写的Selector,并将他们分别放入到不同的线程中,进行开始接受状态。这里有一个技术点没有实现,应该是可以根据不同并发情况动态调整Selector所占用的线程数量。

提供StartNIO方法,关注Accept事件开始处理请求

2.4 读Selector

public class ReadSelector implements Runnable{private final Selector thisSelector;private final Selector writeSelector;public ReadSelector(Selector thisSelector, Selector  writeSelector) {this.thisSelector = thisSelector;this.writeSelector = writeSelector;}@Overridepublic void run() {while (true) {try {// 返回已经准备好并且感兴趣的selectedKeys数量if (thisSelector.select(1000) == 0) {continue;}// 返回已经准备好并且感兴趣的selectedKeys集合Set<SelectionKey> selectionKeys = this.thisSelector.selectedKeys();Iterator<SelectionKey> keyIterator = selectionKeys.iterator();while (keyIterator.hasNext()) {SelectionKey key = keyIterator.next();if (key.isReadable()) {SocketChannel clientChannel = (SocketChannel) key.channel();// 分配缓存区ByteBuffer buffer = ByteBuffer.allocate(1024);StringBuilder readData = new StringBuilder();// 一次性将请求内容全部读取到buffer中,在进行处理和写入操作;一边处理一边处理一边写暂未实现while (clientChannel.read(buffer) > 0) {readData.append(new String(buffer.array()));buffer.clear();}CustomizedThreadPool.writeHandlerSubmit(new SelectedServiceHandler(readData.toString(), this.writeSelector, clientChannel));// 解除该selectionKey和Selector之间的关系,并将它加入到该selector的cancelled-key set中,随后下次Selector操作将这个key从key sets中移除key.cancel();keyIterator.remove();}}} catch (IOException e) {e.printStackTrace();}}}
}

2.5 写Selector

public class WriteSelector implements Runnable{private final Selector thisSelector;public WriteSelector(Selector thisSelector) {this.thisSelector = thisSelector;}@Overridepublic void run() {while (true) {try {// 返回已经准备好并且感兴趣的selectedKeys数量if (thisSelector.select(1000) == 0) {continue;}} catch (IOException e) {e.printStackTrace();}Set<SelectionKey> selectionKeys = thisSelector.selectedKeys();Iterator<SelectionKey> keyIterator = selectionKeys.iterator();while (keyIterator.hasNext()) {SelectionKey key = keyIterator.next();try {// 该key的附件,这里放逻辑处理后的返回值String responseData = String.valueOf(key.attachment());if (key.isValid() && key.isWritable() && (!isStringEmpty(responseData))) {SocketChannel clientChannel = (SocketChannel) key.channel();clientChannel.write(ByteBuffer.wrap(responseData.getBytes()));}key.cancel();keyIterator.remove();} catch (IOException e) {e.printStackTrace();} finally {key.cancel();try {System.out.println("closed.......");key.channel().close();} catch (IOException e) {e.printStackTrace();}}}}}private Boolean isStringEmpty(String data) {return null == data || "".equals(data);}
}

在读Selector一次性读取来自客户端的数据,这里也没有一边读一边写的功能,读取到数据后交给业务处理器进行处理,然后再由业务处理器统一调用写Selector将响应结果返回给客户端。

在多线程下,注意要是用wakeup()去唤醒下一个处理线程,否则它也会自己唤醒,但是响应太慢了。

将key从SelectionKey集合中移除后,除非重复发生它感兴趣的事件,否则不会重复触发。

更多细节关注GitHub

不合理处望指正

参考

https://juejin.cn/post/6844903601261936653#heading-3

https://juejin.cn/post/6844903821198508040

https://segmentfault.com/a/1190000037714804

https://juejin.cn/post/6844903985158045703#heading-2

https://www.jianshu.com/p/5bb812ca5f8e

Java NIO 详解相关推荐

  1. Java基础——Java NIO详解(一)

    一.基本概念 1.I/0简介 I/O即输入输出,是计算机与外界世界的一个借口.IO操作的实际主题是操作系统.在java编程中,一般使用流的方式来处理IO,所有的IO都被视作是单个字节的移动,通过str ...

  2. Java基础——Java NIO详解(二)

    一.简介 在我的上一篇文章Java NIO详解(一)中介绍了关于标准输入输出NIO相关知识, 本篇将重点介绍基于网络编程NIO(异步IO). 二.异步IO 异步 I/O 是一种没有阻塞地读写数据的方法 ...

  3. java nio详解,Java NIO API详解

    Java NIO API详解 在JDK 1.4以前,Java的IO操作集中在java.io这个包中,是基于流的阻塞(blocking)API.对于大多数应用来说,这样的API使用很方 便,然而,一些对 ...

  4. java NIO详解

    http://zalezone.cn/2014/09/17/NIO%E7%B2%BE%E7%B2%B9/ 1. 前言 我们在写java程序的时候,为了进行优化,把全部的精力用在了处理效率上,但是对IO ...

  5. Java基础——Java IO详解

    一.概述 1.Java IO Java IO即Java 输入输出系统.不管我们编写何种应用,都难免和各种输入输出相关的媒介打交道,其实和媒介进行IO的过程是十分复杂的,这要考虑的因素特别多,比如我们要 ...

  6. Apache Thrift - java开发详解

    2019独角兽企业重金招聘Python工程师标准>>> Apache Thrift - java开发详解 博客分类: java 架构 中间件 1.添加依赖 jar <depen ...

  7. 【Java网络编程与IO流】Java之Java Servlet详解

    Java网络编程与IO流目录: [Java网络编程与IO流]Java中IO流分为几种?字符流.字节流.缓冲流.输入流.输出流.节点流.处理流 [Java网络编程与IO流]计算机网络常见面试题高频核心考 ...

  8. Java泛型详解-史上讲解最详细的,没有之一

    目录 1. 概述 2. 一个栗子 3. 特性 4. 泛型的使用 4.1 泛型类 4.2 泛型接口 4.3 泛型通配符 4.4 泛型方法 4.4.1 泛型方法的基本用法 4.4.2 类中的泛型方法 4. ...

  9. Java虚拟机详解----JVM常见问题总结

    [正文] 声明:本文只是做一个总结,有关jvm的详细知识可以参考本人之前的系列文章,尤其是那篇:Java虚拟机详解04----GC算法和种类.那篇文章和本文是面试时的重点. 面试必问关键词:JVM垃圾 ...

  10. java 泛型详解、Java中的泛型方法、 java泛型详解

    本文参考java 泛型详解.Java中的泛型方法. java泛型详解 概述 泛型在java中有很重要的地位,在面向对象编程及各种设计模式中有非常广泛的应用. 什么是泛型?为什么要使用泛型? 泛型,即& ...

最新文章

  1. 中奖名单,老读者请看过来!
  2. MS SQLSERVER通用存储过程分页
  3. Android 控件布局常用属性
  4. sublime 3143 注册码
  5. ASP.NET调用Oracle分页存储过程并结合ASPnetpager分页控件 实现分页功能
  6. mysql 循环_MySQL存储过程中的3种循环【转载】
  7. 《个人信息安全规范 (2019-6-21) 》征求意见稿的最新变化
  8. 安全事件应急响应工具箱
  9. 解决git clone fatal: port 443: Timed out
  10. 阿里云服务器导出方案
  11. cs285深度强化学习课程笔记-lec1
  12. mysql 升级mariadb_mariadb升级
  13. 国内的智能家居品牌有哪些
  14. mui12搭载鸿蒙,MUI系统最新资讯
  15. 《Java程序设计》第二周学习总结
  16. 生产级搭建openresty+waf防火墙
  17. 第26课:个人高效的秘籍 OKR 工作法
  18. TF-IDF算法解析与Python实现
  19. 过年回家和小朋友玩什么
  20. JMeter(十三):借用Jmeter连接数据库 ,获取短信验证码

热门文章

  1. echarts动态显示某个省或某个市
  2. Unity 导出obj模型
  3. 极域课堂管理系统软件V6.0 2016 豪华版
  4. 宇视手机客户端共享/分享设备配置操作
  5. JavaScript格式化时间与日期
  6. python语言程序设计实验题p181答案_2010年新版教材自考网络操作系统02335_复习笔记...
  7. CAD/CAM技术的现状分析
  8. 【Jenkins】windows系统下Jenkins的下载、安装与启动
  9. macbook WIN10系统安装教程
  10. JUCE学习笔记04-LookAndFeel类自定义Slider颜色