简栈文化

Java技术人的成长之路~

1. 背景

1.1. 惊人的性能数据

最近一个圈内朋友通过私信告诉我,通过使用 Netty4 + Thrift 压缩二进制编解码技术,他们实现了 10W TPS(1K 的复杂 POJO 对象)的跨节点远程服务调用。相比于传统基于 Java 序列化 +BIO(同步阻塞 IO)的通信框架,性能提升了 8 倍多。

事实上,我对这个数据并不感到惊讶,根据我 5 年多的 NIO 编程经验,通过选择合适的 NIO 框架,加上高性能的压缩二进制编解码技术,精心的设计 Reactor 线程模型,达到上述性能指标是完全有可能的。

下面我们就一起来看下 Netty 是如何支持 10W TPS 的跨节点远程服务调用的,在正式开始讲解之前,我们先简单介绍下 Netty。

1.2. Netty 基础入门

Netty 是一个高性能、异步事件驱动的 NIO 框架,它提供了对 TCP、UDP 和文件传输的支持,作为一个异步 NIO 框架,Netty 的所有 IO 操作都是异步非阻塞的,通过 Future-Listener 机制,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果。

作为当前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,一些业界著名的开源组件也基于 Netty 的 NIO 框架构建。

2. Netty 高性能之道

2.1. RPC 调用的性能模型分析

2.1.1. 传统 RPC 调用性能差的三宗罪

网络传输方式问题:传统的 RPC 框架或者基于 RMI 等方式的远程服务(过程)调用采用了同步阻塞 IO,当客户端的并发压力或者网络时延增大之后,同步阻塞 IO 会由于频繁的 wait 导致 IO 线程经常性的阻塞,由于线程无法高效的工作,IO 处理能力自然下降。

下面,我们通过 BIO 通信模型图看下 BIO 通信的弊端:

Netty系列之Netty高性能之道

图 2-1 BIO 通信模型图

采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,接收到客户端连接之后为客户端连接创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。该架构最大的问题就是不具备弹性伸缩能力,当并发访问量增加后,服务端的线程个数和并发访问数成线性正比,由于线程是 JAVA 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能急剧下降,随着并发量的继续增加,可能会发生句柄溢出、线程堆栈溢出等问题,并导致服务器最终宕机。

序列化方式问题:Java 序列化存在如下几个典型问题:

  1. Java 序列化机制是 Java 内部的一种对象编解码技术,无法跨语言使用;例如对于异构系统之间的对接,Java 序列化后的码流需要能够通过其它语言反序列化成原始对象(副本),目前很难支持;

  2. 相比于其它开源的序列化框架,Java 序列化后的码流太大,无论是网络传输还是持久化到磁盘,都会导致额外的资源占用;

  3. 序列化性能差(CPU 资源占用高)。

线程模型问题:由于采用同步阻塞 IO,这会导致每个 TCP 连接都占用 1 个线程,由于线程资源是 JVM 虚拟机非常宝贵的资源,当 IO 读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降,严重的甚至会导致虚拟机无法创建新的线程。

2.1.2. 高性能的三个主题

  1. 传输:用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,IO 模型在很大程度上决定了框架的性能。

  2. 协议:采用什么样的通信协议,HTTP 或者内部私有协议。协议的选择不同,性能模型也不同。相比于公有协议,内部私有协议的性能通常可以被设计的更优。

  3. 线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,Reactor 线程模型的不同,对性能的影响也非常大。

Netty系列之Netty高性能之道

图 2-2 RPC 调用性能三要素

2.2. Netty 高性能之道

2.2.1. 异步非阻塞通信

在 IO 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者 IO 多路复用技术进行处理。IO 多路复用技术通过把多个 IO 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程 / 多进程模型比,I/O 多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。

JDK1.4 提供了对非阻塞 IO(NIO)的支持,JDK1.5_update10 版本使用 epoll 替代了传统的 select/poll,极大的提升了 NIO 通信的性能。

JDK NIO 通信模型如下所示:

Netty系列之Netty高性能之道

图 2-3 NIO 的多路复用模型图

与 Socket 类和 ServerSocket 类相对应,NIO 也提供了 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式正好相反。开发人员一般可以根据自己的需要来选择合适的模式,一般来说,低负载、低并发的应用程序可以选择同步阻塞 IO 以降低编程复杂度。但是对于高负载、高并发的网络应用,需要使用 NIO 的非阻塞模式进行开发。

Netty 架构按照 Reactor 模式设计和实现,它的服务端通信序列图如下:

Netty系列之Netty高性能之道

图 2-3 NIO 服务端通信序列图

客户端通信序列图如下:

Netty系列之Netty高性能之道

图 2-4 NIO 客户端通信序列图

Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端 Channel,由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 IO 阻塞导致的线程挂起。另外,由于 Netty 采用了异步通信模式,一个 IO 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 IO 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

2.2.2. 零拷贝

很多用户都听说过 Netty 具有“零拷贝”功能,但是具体体现在哪里又说不清楚,本小节就详细对 Netty 的“零拷贝”功能进行讲解。

Netty 的“零拷贝”主要体现在如下三个方面:

  1. Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

  2. Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。

  3. Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。

下面,我们对上述三种“零拷贝”进行说明,先看 Netty 接收 Buffer 的创建:

Netty系列之Netty高性能之道

图 2-5 异步消息读取“零拷贝”

每循环读取一次消息,就通过 ByteBufAllocator 的 ioBuffer 方法获取 ByteBuf 对象,下面继续看它的接口定义:

Netty系列之Netty高性能之道

图 2-6 ByteBufAllocator 通过 ioBuffer 分配堆外内存

当进行 Socket IO 读写的时候,为了避免从堆内存拷贝一份副本到直接内存,Netty 的 ByteBuf 分配器直接创建非堆内存避免缓冲区的二次拷贝,通过“零拷贝”来提升读写性能。

下面我们继续看第二种“零拷贝”的实现 CompositeByteBuf,它对外将多个 ByteBuf 封装成一个 ByteBuf,对外提供统一封装后的 ByteBuf 接口,它的类定义如下:

Netty系列之Netty高性能之道

图 2-7 CompositeByteBuf 类继承关系

通过继承关系我们可以看出 CompositeByteBuf 实际就是个 ByteBuf 的包装器,它将多个 ByteBuf 组合成一个集合,然后对外提供统一的 ByteBuf 接口,相关定义如下:

Netty系列之Netty高性能之道

图 2-8 CompositeByteBuf 类定义

添加 ByteBuf,不需要做内存拷贝,相关代码如下:

Netty系列之Netty高性能之道

图 2-9 新增 ByteBuf 的“零拷贝”

最后,我们看下文件传输的“零拷贝”:

Netty系列之Netty高性能之道

图 2-10 文件传输“零拷贝”

Netty 文件传输 DefaultFileRegion 通过 transferTo 方法将文件发送到目标 Channel 中,下面重点看 FileChannel 的 transferTo 方法,它的 API DOC 说明如下:

Netty系列之Netty高性能之道

图 2-11 文件传输 “零拷贝”

对于很多操作系统它直接将文件缓冲区的内容发送到目标 Channel 中,而不需要通过拷贝的方式,这是一种更加高效的传输方式,它实现了文件传输的“零拷贝”。

2.2.3. 内存池

随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制。下面我们一起看下 Netty ByteBuf 的实现:

Netty系列之Netty高性能之道

图 2-12 内存池 ByteBuf

Netty 提供了多种内存管理策略,通过在启动辅助类中配置相关参数,可以实现差异化的定制。

下面通过性能测试,我们看下基于内存池循环利用的 ByteBuf 和普通 ByteBuf 的性能差异。

用例一,使用内存池分配器创建直接内存缓冲区:

Netty系列之Netty高性能之道

图 2-13 基于内存池的非堆内存缓冲区测试用例

用例二,使用非堆内存分配器创建的直接内存缓冲区:

Netty系列之Netty高性能之道

图 2-14 基于非内存池创建的非堆内存缓冲区测试用例

各执行 300 万次,性能对比结果如下所示:

Netty系列之Netty高性能之道

图 2-15 内存池和非内存池缓冲区写入性能对比

性能测试表明,采用内存池的 ByteBuf 相比于朝生夕灭的 ByteBuf,性能高 23 倍左右(性能数据与使用场景强相关)。

下面我们一起简单分析下 Netty 内存池的内存分配:

Netty系列之Netty高性能之道

图 2-16 AbstractByteBufAllocator 的缓冲区分配

继续看 newDirectBuffer 方法,我们发现它是一个抽象方法,由 AbstractByteBufAllocator 的子类负责具体实现,代码如下:

Netty系列之Netty高性能之道

图 2-17 newDirectBuffer 的不同实现

代码跳转到 PooledByteBufAllocator 的 newDirectBuffer 方法,从 Cache 中获取内存区域 PoolArena,调用它的 allocate 方法进行内存分配:

Netty系列之Netty高性能之道

图 2-18 PooledByteBufAllocator 的内存分配

PoolArena 的 allocate 方法如下:

Netty系列之Netty高性能之道

图 2-18 PoolArena 的缓冲区分配

我们重点分析 newByteBuf 的实现,它同样是个抽象方法,由子类 DirectArena 和 HeapArena 来实现不同类型的缓冲区分配,由于测试用例使用的是堆外内存,

Netty系列之Netty高性能之道

图 2-19 PoolArena 的 newByteBuf 抽象方法

因此重点分析 DirectArena 的实现:如果没有开启使用 sun 的 unsafe,则

Netty系列之Netty高性能之道

图 2-20 DirectArena 的 newByteBuf 方法实现

执行 PooledDirectByteBuf 的 newInstance 方法,代码如下:

Netty系列之Netty高性能之道

图 2-21 PooledDirectByteBuf 的 newInstance 方法实现

通过 RECYCLER 的 get 方法循环使用 ByteBuf 对象,如果是非内存池实现,则直接创建一个新的 ByteBuf 对象。从缓冲池中获取 ByteBuf 之后,调用 AbstractReferenceCountedByteBuf 的 setRefCnt 方法设置引用计数器,用于对象的引用计数和内存回收(类似 JVM 垃圾回收机制)。

2.2.4. 高效的 Reactor 线程模型

常用的 Reactor 线程模型有三种,分别如下:

  1. Reactor 单线程模型;

  2. Reactor 多线程模型;

  3. 主从 Reactor 多线程模型

Reactor 单线程模型,指的是所有的 IO 操作都在同一个 NIO 线程上面完成,NIO 线程的职责如下:

  1. 作为 NIO 服务端,接收客户端的 TCP 连接;

  2. 作为 NIO 客户端,向服务端发起 TCP 连接;

  3. 读取通信对端的请求或者应答消息;

  4. 向通信对端发送消息请求或者应答消息。

Reactor 单线程模型示意图如下所示:

Netty系列之Netty高性能之道

图 2-22 Reactor 单线程模型

由于 Reactor 模式使用的是异步非阻塞 IO,所有的 IO 操作都不会导致阻塞,理论上一个线程可以独立处理所有 IO 相关的操作。从架构层面看,一个 NIO 线程确实可以完成其承担的职责。例如,通过 Acceptor 接收客户端的 TCP 连接请求消息,链路建立成功之后,通过 Dispatch 将对应的 ByteBuffer 派发到指定的 Handler 上进行消息解码。用户 Handler 可以通过 NIO 线程将消息发送给客户端。

对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发的应用却不合适,主要原因如下:

  1. 一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线程的 CPU 负荷达到 100%,也无法满足海量消息的编码、解码、读取和发送;

  2. 当 NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量消息积压和处理超时,NIO 线程会成为系统的性能瓶颈;

  3. 可靠性问题:一旦 NIO 线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。

为了解决这些问题,演进出了 Reactor 多线程模型,下面我们一起学习下 Reactor 多线程模型。

Rector 多线程模型与单线程模型最大的区别就是有一组 NIO 线程处理 IO 操作,它的原理图如下:

Netty系列之Netty高性能之道

图 2-23 Reactor 多线程模型

Reactor 多线程模型的特点:

  1. 有专门一个 NIO 线程 -Acceptor 线程用于监听服务端,接收客户端的 TCP 连接请求;

  2. 网络 IO 操作 - 读、写等由一个 NIO 线程池负责,线程池可以采用标准的 JDK 线程池实现,它包含一个任务队列和 N 个可用的线程,由这些 NIO 线程负责消息的读取、解码、编码和发送;

  3. 1 个 NIO 线程可以同时处理 N 条链路,但是 1 个链路只对应 1 个 NIO 线程,防止发生并发操作问题。

在绝大多数场景下,Reactor 多线程模型都可以满足性能需求;但是,在极特殊应用场景中,一个 NIO 线程负责监听和处理所有的客户端连接可能会存在性能问题。例如百万客户端并发连接,或者服务端需要对客户端的握手消息进行安全认证,认证本身非常损耗性能。在这类场景下,单独一个 Acceptor 线程可能会存在性能不足问题,为了解决性能问题,产生了第三种 Reactor 线程模型 - 主从 Reactor 多线程模型。

主从 Reactor 线程模型的特点是:服务端用于接收客户端连接的不再是个 1 个单独的 NIO 线程,而是一个独立的 NIO 线程池。Acceptor 接收到客户端 TCP 连接请求处理完成后(可能包含接入认证等),将新创建的 SocketChannel 注册到 IO 线程池(sub reactor 线程池)的某个 IO 线程上,由它负责 SocketChannel 的读写和编解码工作。Acceptor 线程池仅仅只用于客户端的登陆、握手和安全认证,一旦链路建立成功,就将链路注册到后端 subReactor 线程池的 IO 线程上,由 IO 线程负责后续的 IO 操作。

它的线程模型如下图所示:

Netty系列之Netty高性能之道

图 2-24 Reactor 主从多线程模型

利用主从 NIO 线程模型,可以解决 1 个服务端监听线程无法有效处理所有客户端连接的性能不足问题。因此,在 Netty 的官方 demo 中,推荐使用该线程模型。

事实上,Netty 的线程模型并非固定不变,通过在启动辅助类中创建不同的 EventLoopGroup 实例并通过适当的参数配置,就可以支持上述三种 Reactor 线程模型。正是因为 Netty 对 Reactor 线程模型的支持提供了灵活的定制能力,所以可以满足不同业务场景的性能诉求。

2.2.5. 无锁化的串行设计理念

在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会导致性能的下降。为了尽可能的避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。

为了尽可能提升性能,Netty 采用了串行无锁化设计,在 IO 线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列 - 多个工作线程模型性能更优。

Netty 的串行化设计工作原理图如下:

Netty系列之Netty高性能之道

图 2-25 Netty 串行化工作原理图

Netty 的 NioEventLoop 读取到消息之后,直接调用 ChannelPipeline 的 fireChannelRead(Object msg),只要用户不主动切换线程,一直会由 NioEventLoop 调用到用户的 Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。

2.2.6. 高效的并发编程

Netty 的高效并发编程主要体现在如下几点:

  1. volatile 的大量、正确使用 ;

  2. CAS 和原子类的广泛使用;

  3. 线程安全容器的使用;

  4. 通过读写锁提升并发性能。

如果大家想了解 Netty 高效并发编程的细节,可以阅读之前我在微博分享的《多线程并发编程在 Netty 中的应用分析》,在这篇文章中对 Netty 的多线程技巧和应用进行了详细的介绍和分析。

2.2.7. 高性能的序列化框架

影响序列化性能的关键因素总结如下:

  1. 序列化后的码流大小(网络带宽的占用);

  2. 序列化 & 反序列化的性能(CPU 资源占用);

  3. 是否支持跨语言(异构系统的对接和开发语言切换)。

Netty 默认提供了对 Google Protobuf 的支持,通过扩展 Netty 的编解码接口,用户可以实现其它的高性能序列化框架,例如 Thrift 的压缩二进制编解码框架。

下面我们一起看下不同序列化 & 反序列化框架序列化后的字节数组对比:

Netty系列之Netty高性能之道

图 2-26 各序列化框架序列化码流大小对比

从上图可以看出,Protobuf 序列化后的码流只有 Java 序列化的 1/4 左右。正是由于 Java 原生序列化性能表现太差,才催生出了各种高性能的开源序列化技术和框架(性能差只是其中的一个原因,还有跨语言、IDL 定义等其它因素)。

2.2.8. 灵活的 TCP 参数配置能力

合理设置 TCP 参数在某些场景下对于性能的提升可以起到显著的效果,例如 SO_RCVBUF 和 SO_SNDBUF。如果设置不当,对性能的影响是非常大的。下面我们总结下对性能影响比较大的几个配置项:

  1. SO_RCVBUF 和 SO_SNDBUF:通常建议值为 128K 或者 256K;

  2. SO_TCPNODELAY:NAGLE 算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;

  3. 软中断:如果 Linux 内核版本支持 RPS(2.6.35 以上版本),开启 RPS 后可以实现软中断,提升网络吞吐量。RPS 根据数据包的源地址,目的地址以及目的和源端口,计算出一个 hash 值,然后根据这个 hash 值来选择软中断运行的 cpu,从上层来看,也就是说将每个连接和 cpu 绑定,并通过这个 hash 值,来均衡软中断在多个 cpu 上,提升网络并行处理性能。

Netty 在启动辅助类中可以灵活的配置 TCP 参数,满足不同的用户场景。相关配置接口定义如下:

Netty系列之Netty高性能之道

图 2-27 Netty 的 TCP 参数配置定义

2.3. 总结

通过对 Netty 的架构和性能模型进行分析,我们发现 Netty 架构的高性能是被精心设计和实现的,得益于高质量的架构和代码,Netty 支持 10W TPS 的跨节点服务调用并不是件十分困难的事情。

参考地址

读书笔记

链接:https://pan.baidu.com/s/1vf2f4KRdpZlKkrBTReP-gA 密码:yvoy

背景

为了后续的沟通方便,在20200213的早上创建了一个**《简栈-Java技术交流群》**,也方便大家通过扫二维码积极的参与进来。

http://static.cyblogs.com/简栈-Java技术交流群.JPG

如果是二维码已经过期,大家可以添加简栈文化-小助手的微信号(lastpass4u),然后让他拉大家进群进群。我们保持着小而美的精神,宁缺毋滥。

http://static.cyblogs.com/简栈文化-小助手.jpg

然后早上群里就有人提了一个问题:

执行计划里面的扫描函数跟执行时间不匹配,比如查询优化器发现,扫描a索引行数更多,所以更慢,因此优化器选择了索引b, 但实际上走b索引的时候比a更慢,走a索引大概是4秒左右,b是8秒。

这个问题激发起了大家的讨论,有的人建议说:

1、这种可以强制指定索引执行的吧

2、这个扫描行数都是预估的不一定准的,能操作shell的话执行analyse table看看。

3、看一下你的index,DDL,explain等等

但提问者明显这些都是已经自己搞清楚了的,他关心的是底层的优化器成本规则等。这类我才意识到EXPLAIN出来的是结果,其实数据库底层本身是有优化器的,而最终选择谁,是否过索引等都是有它的规则的。这其中都涉及到效率与成本问题。

Explain执行计划详解

使用explain关键字可以模拟优化器执行SQL查询语句,从而知道MySQL是如何处理你的SQL语句的,分析你的查询语句或是表结构的性能瓶颈。

Explain执行计划包含的信息

![http://static.cyblogs.com/SouthEast (6).png](http://static.cyblogs.com/SouthEast (6).png)

其中最重要的字段为:id、type、key、rows、Extra

id字段

select查询的序列号,包含一组数字,表示查询中执行select子句或操作表的顺序

  • 三种情况:
    • 1、id相同:执行顺序由上至下

![http://static.cyblogs.com/SouthEast (4).png](http://static.cyblogs.com/SouthEast (4).png)

  • 2、id不同:如果是子查询,id的序号会递增,id值越大优先级越高,越先被执行

![http://static.cyblogs.com/SouthEast (8).png](http://static.cyblogs.com/SouthEast (8).png)

  • 3、id相同又不同(两种情况同时存在):id如果相同,可以认为是一组,从上往下顺序执行;在所有组中,id值越大,优先级越高,越先执行

![http://static.cyblogs.com/SouthEast (5).png](http://static.cyblogs.com/SouthEast (5).png)

select_type字段

查询的类型,主要是用于区分普通查询、联合查询、子查询等复杂的查询

1、SIMPLE:简单的select查询,查询中不包含子查询或者union
2、PRIMARY:查询中包含任何复杂的子部分,最外层查询则被标记为primary
3、SUBQUERY:在select 或 where列表中包含了子查询
4、DERIVED:在from列表中包含的子查询被标记为derived(衍生),mysql或递归执行这些子查询,把结果放在零时表里
5、UNION:若第二个select出现在union之后,则被标记为union;若union包含在from子句的子查询中,外层select将被标记为derived
6、UNION RESULT:从union表获取结果的select

![http://static.cyblogs.com/SouthEast (9).png](http://static.cyblogs.com/SouthEast (9).png)

type字段

访问类型,sql查询优化中一个很重要的指标,结果值从好到坏依次是:

system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

一般来说,好的sql查询至少达到range级别,最好能达到ref

1、system:表只有一行记录(等于系统表),这是const类型的特例,平时不会出现,可以忽略不计

2、const:表示通过索引一次就找到了,const用于比较primary key 或者 unique索引。因为只需匹配一行数据,所有很快。如果将主键置于where列表中,mysql就能将该查询转换为一个const。

http://static.cyblogs.com/SouthEast.png

3、eq_ref:唯一性索引扫描,对于每个索引键,表中只有一条记录与之匹配。常见于主键 或 唯一索引扫描。

注意:ALL全表扫描的表记录最少的表如t1表

![http://static.cyblogs.com/SouthEast (2).png](http://static.cyblogs.com/SouthEast (2).png)

4、ref:非唯一性索引扫描,返回匹配某个单独值的所有行。本质是也是一种索引访问,它返回所有匹配某个单独值的行,然而他可能会找到多个符合条件的行,所以它应该属于查找和扫描的混合体

![http://static.cyblogs.com/SouthEast (3).png](http://static.cyblogs.com/SouthEast (3).png)

5、range:只检索给定范围的行,使用一个索引来选择行。key列显示使用了那个索引。一般就是在where语句中出现了bettween、<、>、in等的查询。这种索引列上的范围扫描比全索引扫描要好。只需要开始于某个点,结束于另一个点,不用扫描全部索引

![http://static.cyblogs.com/SouthEast (7).png](http://static.cyblogs.com/SouthEast (7).png)

6、index:Full Index Scan,index与ALL区别为index类型只遍历索引树。这通常为ALL块,应为索引文件通常比数据文件小。(Index与ALL虽然都是读全表,但index是从索引中读取,而ALL是从硬盘读取)

![http://static.cyblogs.com/SouthEast (1).png](http://static.cyblogs.com/SouthEast (1).png)

7、ALL:Full Table Scan,遍历全表以找到匹配的行

![http://static.cyblogs.com/SouthEast (10).png](http://static.cyblogs.com/SouthEast (10).png)

possible_keys字段

查询涉及到的字段上存在索引,则该索引将被列出,但不一定被查询实际使用

key字段

实际使用的索引,如果为NULL,则没有使用索引。
查询中如果使用了覆盖索引,则该索引仅出现在key列表中

![http://static.cyblogs.com/SouthEast (11).png](http://static.cyblogs.com/SouthEast (11).png)

![http://static.cyblogs.com/SouthEast (2).png](http://static.cyblogs.com/SouthEast (12).png)

key_len字段

表示索引中使用的字节数,查询中使用的索引的长度(最大可能长度),并非实际使用长度,理论上长度越短越好。key_len是根据表定义计算而得的,不是通过表内检索出的

ref字段

显示索引的那一列被使用了,如果可能,是一个常量const。

rows字段

根据表统计信息及索引选用情况,大致估算出找到所需的记录所需要读取的行数

Extra字段

不适合在其他字段中显示,但是十分重要的额外信息

1、Using filesort

mysql对数据使用一个外部的索引排序,而不是按照表内的索引进行排序读取。也就是说mysql无法利用索引完成的排序操作成为“文件排序”

由于索引是先按email排序、再按address排序,所以查询时如果直接按address排序,索引就不能满足要求了,mysql内部必须再实现一次“文件排序”

![http://static.cyblogs.com/SouthEast (13).png](http://static.cyblogs.com/SouthEast (13).png)

2、Using temporary

使用临时表保存中间结果,也就是说mysql在对查询结果排序时使用了临时表,常见于order by 和 group by

![http://static.cyblogs.com/SouthEast (14).png](http://static.cyblogs.com/SouthEast (14).png)

3、Using index

表示相应的select操作中使用了覆盖索引(Covering Index),避免了访问表的数据行,效率高
如果同时出现Using where,表明索引被用来执行索引键值的查找(参考上图)
如果没用同时出现Using where,表明索引用来读取数据而非执行查找动作

覆盖索引(Covering Index):也叫索引覆盖。就是select列表中的字段,只用从索引中就能获取,不必根据索引再次读取数据文件,换句话说查询列要被所建的索引覆盖。
注意:
a、如需使用覆盖索引,select列表中的字段只取出需要的列,不要使用select *
b、如果将所有字段都建索引会导致索引文件过大,反而降低crud性能

![http://static.cyblogs.com/SouthEast (15).png](http://static.cyblogs.com/SouthEast (15).png)

4、Using where

使用了where过滤

5、Using join buffer

使用了链接缓存

6、Impossible WHERE

where子句的值总是false,不能用来获取任何元祖

7、select tables optimized away

在没有group by子句的情况下,基于索引优化MIN/MAX操作或者对于MyISAM存储引擎优化COUNT(*)操作,不必等到执行阶段在进行计算,查询执行计划生成的阶段即可完成优化

8、distinct

优化distinct操作,在找到第一个匹配的元祖后即停止找同样值得动作

优化器代价模型

指标
  1. 代价模型:RBO(基于规则的优化)、CBO(基于成本的优化)
  2. SQL的每一种执行路径,均可计算一个对应的执行代价,代价越小,执行效率越高
CBO方式成本的计算
1
Total cost = CPU cost + IO cost
CPU cost计算模型

CPU cost = rows/5 + (rows/10 if comparing key)

CPU cost:

  1. MySQL上层,处理返回记录所花开销
  2. CPU Cost=records/TIME_FOR_COMPARE=Records/5
  3. 每5条记录处的时间,作为1 Cost
IO cost计算模型

IO cost以聚集索引叶子节点的数量进行计算

  • 全扫描
    IO Cost = table stat_clustered_index_size
    聚簇索引page总数,一个page作为1 cost
  • 范围扫描
    IO Cost = [(ranges+rows)/total_rows]*全扫描IO Cost
    聚簇索引范围扫描与返回记录成比率

若需要回表,则IO cost以预估的记录数量进行计算,开销相当巨大

  • 二级索引之索引覆盖扫描
    • 索引覆盖扫描,减少返回聚簇索引的IO代价
      keys_per_block=(stats_block_size/2)/(key_info[keynr].key_lenth+ref_length+1)
      stats_block_size/2 = 索引页半满
    • IO Cost:(records+keys_per_block-1)/keys_per_block
    • 计算range占用多少个二级索引页面,既为索引覆盖扫描的IO Cost
  • 二级索引之索引非覆盖扫描
    • 索引非覆盖扫描,需要回聚簇索引读取完整记录,增加IO代价
    • IO Cost = (range+rows)
    • range:多少个范围
      对于IN查询,就会转换为多个索引范围查询
    • row:为范围中一共有多少记录
      由于每一条记录都需要返回聚簇索引,因此每一条记录都会产生1 Cost
Cost模型分析
  • 聚簇索引扫描代价为索引页面总数量
  • 二级索引覆盖扫描代价较小
  • 二级索引非覆盖扫描,代价巨大
  • Cost模型的计算,需要统计信息的支持
    • stat_clustered_index_size
    • ranges
    • records/rows
    • stats_block_size
    • key_info[keynr].key_length
    • rec_per_key
    • ……
实战如何看日志确定Cost的选择
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
drop database lyj;
create database lyj;
use lyj;

create table t1 (
c1 int(11) not null default '0',
c2 varchar(128) default null,
c3 varchar(64) default null,
c4 int(11) default null,
primary key (c1),
key ind_c2 (c2),
key ind_c4 (c4));

insert into t1 values(1,'a','A',10);
insert into t1 values(2,'b','B',20);
insert into t1 values(3,'b','BB',20);
insert into t1 values(4,'b','BBB',30);
insert into t1 values(5,'b','BBB',40);
insert into t1 values(6,'c','C',50);
insert into t1 values(7,'d','D',60);
commit;

select * from t1;
+----+------+------+------+
| c1 | c2 | c3 | c4 |
+----+------+------+------+
| 1 | a | A | 10 |
| 2 | b | B | 20 |
| 3 | b | BB | 20 |
| 4 | b | BBB | 30 |
| 5 | b | BBB | 40 |
| 6 | c | C | 50 |
| 7 | d | D | 60 |
+----+------+------+------+

执行以下SQL为什么不走索引ind_c2?

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
mysql> explain select * from t1 where c4=20 and c2='b'\G;
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
type: ref
possible_keys: ind_c2,ind_c4,ind_c2_c4
key: ind_c4
key_len: 5
ref: const
rows: 2
Extra: Using where

set optimizer_trace='enabled=on';
set optimizer_trace_max_mem_size=1000000;
set end_markers_in_json=on;

select * from t1 where c4=20 and c2='b';

mysql> select * from information_schema.optimizer_trace\G;
*************************** 1. row ***************************
QUERY: select * from t1 where c4=20 and c2='b'
TRACE: {
......
"potential_range_indices": [ # 列出备选索引
{
"index": "PRIMARY",
"usable": false, # 本行表明主键索引不可用
"cause": "not_applicable"
},
{
"index": "ind_c2",
"usable": true,
"key_parts": [
"c2",
"c1"
] /* key_parts */
},
{
"index": "ind_c4",
"usable": true,
"key_parts": [
"c4",
"c1"
] /* key_parts */
}
] /* potential_range_indices */,
"setup_range_conditions": [
] /* setup_range_conditions */,
"group_index_range": {
"chosen": false,
"cause": "not_group_by_or_distinct"
} /* group_index_range */,
"analyzing_range_alternatives": { # 开始计算每个索引做范围扫描的花费
"range_scan_alternatives": [
{
"index": "ind_c2",
"ranges": [
"b <= c2 <= b"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 4, # c2=b的结果有4
"cost": 5.81,
"chosen": false, # 这个索引没有被选中,原因是cost
"cause": "cost"
},
{
"index": "ind_c4",
"ranges": [
"20 <= c4 <= 20"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 2,
"cost": 3.41,
"chosen": true # 这个索引的代价最小,被选中
}
......
"chosen_range_access_summary": { # 总结:因为cost最小选择了ind_c4
"range_access_plan": {
"type": "range_scan",
"index": "ind_c4",
"rows": 2,
"ranges": [
"20 <= c4 <= 20"
] /* ranges */
} /* range_access_plan */,
"rows_for_plan": 2,
"cost_for_plan": 3.41,
"chosen": true
} /* chosen_range_access_summary */
......

因为ind_c4范围扫描的cost要小于ind_c2,所以索引不走ind_c2

where条件中字段c2和c4换个位置,索引还是不走ind_c2?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
explain select * from t1 where  c2='b' and c4=20\G;
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
type: ref
possible_keys: ind_c2,ind_c4,ind_c2_c4
key: ind_c4
key_len: 5
ref: const
rows: 2
Extra: Using where

因为ind_c4范围扫描的cost要小于ind_c2,所以索引不走ind_c2,跟c2和c4的位置无关。验证方法同上。

如下语句,换个条件c2=’c’,为什么可以走索引ind_c2?

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
mysql> explain select * from t1 where c2='c' and c4=20\G;
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
type: ref
possible_keys: ind_c2,ind_c4,ind_c2_c4
key: ind_c2
key_len: 387
ref: const
rows: 1
Extra: Using index condition; Using where

mysql> select * from information_schema.optimizer_trace\G;
*************************** 1. row ***************************
QUERY: select * from t1 where c2='c' and c4=20
TRACE: {
......
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "ind_c2",
"ranges": [
"c <= c2 <= c"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 1, # c2=c 的结果集有1
"cost": 2.21,
"chosen": true # 这个索引的代价最小,被选中
},
{
"index": "ind_c4",
"ranges": [
"20 <= c4 <= 20"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 2,
"cost": 3.41,
"chosen": false, # 这个索引没有被选中,原因是cost
"cause": "cost"
}
......

创建复合索引

1
ALTER TABLE t1 ADD KEY ind_c2_c4(c2,c4);

下面语句为什么不走复合索引ind_c2_c4?

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
explain select * from t1 where  c2='b' and c4=20\G;
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
type: ref
possible_keys: ind_c2,ind_c4,ind_c2_c4
key: ind_c4
key_len: 5
ref: const
rows: 2
Extra: Using where

set optimizer_trace='enabled=on';
set optimizer_trace_max_mem_size=1000000;
set end_markers_in_json=on;
select * from t1 where c2='b' and c4=20;
select * from information_schema.optimizer_trace\G;
*************************** 1. row ***************************
QUERY: select * from t1 where c2='b' and c4=20
TRACE: {
......
"potential_range_indices": [
{
"index": "PRIMARY",
"usable": false,
"cause": "not_applicable"
},
{
"index": "ind_c2",
"usable": true,
"key_parts": [
"c2",
"c1"
] /* key_parts */
},
{
"index": "ind_c4",
"usable": true,
"key_parts": [
"c4",
"c1"
] /* key_parts */
},
{
"index": "ind_c2_c4",
"usable": true,
"key_parts": [
"c2",
"c4",
"c1"
] /* key_parts */
}
] /* potential_range_indices */,
"setup_range_conditions": [
] /* setup_range_conditions */,
"group_index_range": {
"chosen": false,
"cause": "not_group_by_or_distinct"
} /* group_index_range */,
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "ind_c2",
"ranges": [
"b <= c2 <= b"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 4,
"cost": 5.81,
"chosen": false,
"cause": "cost"
},
{
"index": "ind_c4",
"ranges": [
"20 <= c4 <= 20"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 2,
"cost": 3.41,
"chosen": true
},
{
"index": "ind_c2_c4",
"ranges": [
"b <= c2 <= b AND 20 <= c4 <= 20"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 2,
"cost": 3.41,
"chosen": false,
"cause": "cost"
}
......
"chosen_range_access_summary": {
"range_access_plan": {
"type": "range_scan",
"index": "ind_c4",
"rows": 2,
"ranges": [
"20 <= c4 <= 20"
] /* ranges */
} /* range_access_plan */,
"rows_for_plan": 2,
"cost_for_plan": 3.41,
"chosen": true
} /* chosen_range_access_summary */
} /* range_analysis */
}
......

索引ind_c4和ind_c2_c4都是非覆盖扫描,而ind_c4和ind_c2_c4的cost是一样的,mysql会选择叶子块数量较少的那个索引,很明显ind_c4叶子块数量较少。

下面语句为什么又可以走复合索引ind_c2_c4?

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
explain select c2,c4 from t1 where c2='b' and c4=20\G;
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
type: ref
possible_keys: ind_c2,ind_c4,ind_c2_c4
key: ind_c2_c4
key_len: 392
ref: const,const
rows: 2
Extra: Using where; Using index

set optimizer_trace='enabled=on';
set optimizer_trace_max_mem_size=1000000;
set end_markers_in_json=on;
select c2,c4 from t1 where c2='b' and c4=20;
select * from information_schema.optimizer_trace\G;

*************************** 1. row ***************************
QUERY: select c2,c4 from t1 where c2='b' and c4=20
TRACE: {
......
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "ind_c2",
"ranges": [
"b <= c2 <= b"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 4,
"cost": 5.81,
"chosen": false,
"cause": "cost"
},
{
"index": "ind_c4",
"ranges": [
"20 <= c4 <= 20"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 2,
"cost": 3.41,
"chosen": false,
"cause": "cost"
},
{
"index": "ind_c2_c4",
"ranges": [
"b <= c2 <= b AND 20 <= c4 <= 20"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": true, # 索引覆盖扫描
"rows": 2,
"cost": 3.41,
"chosen": false,
"cause": "cost"
}
] /* range_scan_alternatives */,
"analyzing_roworder_intersect": {
"intersecting_indices": [
{
"index": "ind_c2_c4",
"index_scan_cost": 1.0476,
"cumulated_index_scan_cost": 1.0476,
"disk_sweep_cost": 0,
"cumulated_total_cost": 1.0476,
"usable": true,
"matching_rows_now": 2,
"isect_covering_with_this_index": true,
"chosen": true
}
] /* intersecting_indices */,
"clustered_pk": {
"clustered_pk_added_to_intersect": false,
"cause": "no_clustered_pk_index"
} /* clustered_pk */,
"chosen": false,
"cause": "too_few_indexes_to_merge"
} /* analyzing_roworder_intersect */
} /* analyzing_range_alternatives */
} /* range_analysis */
}
] /* rows_estimation */
},
{
"considered_execution_plans": [
{
"plan_prefix": [
] /* plan_prefix */,
"table": "`t1`",
"best_access_path": {
"considered_access_paths": [
{
"access_type": "ref",
"index": "ind_c2",
"rows": 4,
"cost": 2.8,
"chosen": true
},
{
"access_type": "ref",
"index": "ind_c4",
"rows": 2,
"cost": 2.4,
"chosen": true
},
{
"access_type": "ref",
"index": "ind_c2_c4",
"rows": 2,
"cost": 1.4476,
"chosen": true
},
{
"access_type": "scan",
"cause": "covering_index_better_than_full_scan",
"chosen": false
}
] /* considered_access_paths */
} /* best_access_path */,
"cost_for_plan": 1.4476,
"rows_for_plan": 2,
"chosen": true
}
......

因为语中ind_c2_c4是索引覆盖扫描,不需要回表,代价较小。

总结

**我们看执行计划(Explain)仅仅只是结果,而看代价模型(Cost)才是过程。**如果我们真的想了解数据库是如何优化我们的SQL或者真的是如何执行的,需要深入深入的理解底层才行。

参考地址

init-method方法

init-method方法,初始化bean的时候执行,可以针对某个具体的bean进行配置。init-method需要在applicationContext.xml配置文档中bean的定义里头写明。例如:

1
<bean id="TestBean" class="nju.software.xkxt.util.TestBean" init-method="init"></bean>

这样,当TestBean在初始化的时候会执行TestBean中定义的init方法。

afterPropertiesSet方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface InitializingBean {

/**
* Invoked by the containing {@code BeanFactory} after it has set all bean properties
* and satisfied {@link BeanFactoryAware}, {@code ApplicationContextAware} etc.
* <p>This method allows the bean instance to perform validation of its overall
* configuration and final initialization when all bean properties have been set.
* @throws Exception in the event of misconfiguration (such as failure to set an
* essential property) or if initialization fails for any other reason
*/
void afterPropertiesSet() throws Exception;

}

afterPropertiesSet方法,初始化bean的时候执行,可以针对某个具体的bean进行配置。afterPropertiesSet 必须实现 InitializingBean接口。实现 InitializingBean接口必须实现afterPropertiesSet方法。

BeanPostProcessor类

BeanPostProcessor,针对所有Spring上下文中所有的bean,可以在配置文档applicationContext.xml中配置一个BeanPostProcessor,然后对所有的bean进行一个初始化之前和之后的代理。BeanPostProcessor接口中有两个方法: postProcessBeforeInitializationpostProcessAfterInitializationpostProcessBeforeInitialization方法在bean初始化之前执行, postProcessAfterInitialization方法在bean初始化之后执行。

前置后置处理器

Spirng中BeanPostProcessorInstantiationAwareBeanPostProcessorAdapter两个接口都可以实现对bean前置后置处理的效果,那这次先讲解一下BeanPostProcessor处理器的使用

先看一下BeanPostProcessor接口的源码,它定义了两个方法,一个在bean初始化之前,一个在bean初始化之后

1
2
3
4
5
6
7
8
9
10
11
public interface BeanPostProcessor {
   @Nullable
   default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
       return bean;
  }

   @Nullable
   default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
       return bean;
  }
}

下面,我们来实现这个类,测试一下Spring中的前置后置处理器吧

首先是pom.xml,增加Spring相关的依赖

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

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 <modelVersion>4.0.0</modelVersion>

 <groupId>com.myspring</groupId>
 <artifactId>myspring</artifactId>
 <version>0.0.1-SNAPSHOT</version>
 <packaging>jar</packaging>

 <name>myspring</name>
 <url>http://maven.apache.org</url>

 <properties>
   <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 </properties>

 <dependencies>
   <dependency>
     <groupId>junit</groupId>
     <artifactId>junit</artifactId>
     <version>3.8.1</version>
     <scope>test</scope>
   </dependency>
   <!-- Spring 5.0 核心工具包 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<!-- Spring 5.0 Bean管理工具包 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<!-- Spring 5.0 context管理工具包 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<!-- Spring 5.0 aop支持包 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
 </dependencies>
</project>

定义一个测试接口:

1
2
3
4
public interface BaseService {
String doSomething();
   String eat();
}

定义接口实现类:

1
2
3
4
5
6
7
8
9
10
public class ISomeService implements BaseService {

public String doSomething() {
// 增强效果:返回内容全部大写
return "Hello i am kxm";
}
public String eat() {
return "eat food";
}
}

实现BeanPostProcessor接口

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
public class MyBeanPostProcessor implements BeanPostProcessor  {
// 前置处理器
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
Class beanClass = bean.getClass();
if (beanClass == ISomeService.class) {
System.out.println("bean 对象初始化之前······");
}
       return bean;
  }

// 后置处理器 --- 此处具体的实现用的是Java中的动态代理
   public Object postProcessAfterInitialization(final Object beanInstance, String beanName) throws BeansException {
       // 为当前 bean 对象注册监控代理对象,负责增强 bean 对象方法的能力
       Class beanClass = beanInstance.getClass();
       if (beanClass == ISomeService.class) {
           Object proxy = Proxy.newProxyInstance(beanInstance.getClass().getClassLoader(),beanInstance.getClass().getInterfaces(), new InvocationHandler() {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("ISomeService 中的 doSome() 被拦截了···");
                   String result = (String) method.invoke(beanInstance, args);
                   return result.toUpperCase();
}
});
           return proxy;
      }
       return beanInstance;
  }
}

Spring的配置文件如下:

1
2
3
4
<!-- 注册 bean:被监控的实现类 -->
<bean id="iSomeService" class="com.my.spring.beanprocessor.ISomeService"></bean>
<!-- 注册代理实现类 -->
<bean class="com.my.spring.beanprocessor.MyBeanPostProcessor"></bean>

测试类如下:

1
2
3
4
5
6
7
8
9
10
11
public class TestBeanPostProcessor {

public static void main(String[] args) {
/**
* BeanPostProcessor 前置后置处理器
*/
ApplicationContext factory = new ClassPathXmlApplicationContext("spring_config.xml");
BaseService serviceObj = (BaseService) factory.getBean("iSomeService");
System.out.println(serviceObj.doSomething());
}
}

测试结果截图:

https://img-blog.csdn.net/20180924211535371?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MDgzNDQ2NA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70

可以观察到,我们明明在代码中对于doSomething方法定义的是小写,但是通过后置处理器,拦截了原本的方法,而是通过动态代理的方式把方法的结果进行了一定程度的改变,这就是Spring中的前置后置处理器—-BeanPostProcessor

总之,afterPropertiesSetinit-method之间的执行顺序是afterPropertiesSet 先执行,init-method 后执行。从BeanPostProcessor的作用,可以看出最先执行的是postProcessBeforeInitialization,然后afterPropertiesSet,然后是init-method,然后是postProcessAfterInitialization

参考文章

作者:吃饭睡觉撸代码

来源:https://fangjian0423.github.io/2017/05/10/springboot-context-refresh/

前言

Spring容器创建之后,会调用它的refresh方法,refresh的时候会做很多事情:比如完成配置类的解析、各种BeanFactoryPostProcessor和BeanPostProcessor的注册、国际化配置的初始化、web内置容器的构造等等。

我们来分析一下这个refresh过程。

还是以web程序为例,那么对应的Spring容器为AnnotationConfigEmbeddedWebApplicationContext。它的refresh方法调用了父类AbstractApplicationContext的refresh方法:

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
public void refresh() throws BeansException, IllegalStateException {
// refresh过程只能一个线程处理,不允许并发执行
synchronized (this.startupShutdownMonitor) {
prepareRefresh();
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
prepareBeanFactory(beanFactory);
try {
postProcessBeanFactory(beanFactory);
invokeBeanFactoryPostProcessors(beanFactory);
registerBeanPostProcessors(beanFactory);
initMessageSource();
initApplicationEventMulticaster();
onRefresh();
registerListeners();
finishBeanFactoryInitialization(beanFactory);
finishRefresh();
}
catch (BeansException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Exception encountered during context initialization - " +
"cancelling refresh attempt: " + ex);
}
destroyBeans();
cancelRefresh(ex);
throw ex;
}
finally {
resetCommonCaches();
}
}
}

prepareRefresh方法

表示在真正做refresh操作之前需要准备做的事情:

  1. 设置Spring容器的启动时间,撤销关闭状态,开启活跃状态。
  2. 初始化属性源信息(Property)
  3. 验证环境信息里一些必须存在的属性

prepareBeanFactory方法

从Spring容器获取BeanFactory(Spring Bean容器)并进行相关的设置为后续的使用做准备:

  1. 设置classloader(用于加载bean),设置表达式解析器(解析bean定义中的一些表达式),添加属性编辑注册器(注册属性编辑器)
  2. 添加ApplicationContextAwareProcessor这个BeanPostProcessor。取消ResourceLoaderAware、ApplicationEventPublisherAware、MessageSourceAware、ApplicationContextAware、EnvironmentAware这5个接口的自动注入。因为ApplicationContextAwareProcessor把这5个接口的实现工作做了
  3. 设置特殊的类型对应的bean。BeanFactory对应刚刚获取的BeanFactory;ResourceLoader、ApplicationEventPublisher、ApplicationContext这3个接口对应的bean都设置为当前的Spring容器
  4. 注入一些其它信息的bean,比如environment、systemProperties等

postProcessBeanFactory方法

BeanFactory设置之后再进行后续的一些BeanFactory操作。

不同的Spring容器做不同的操作。比如GenericWebApplicationContext容器会在BeanFactory中添加ServletContextAwareProcessor用于处理ServletContextAware类型的bean初始化的时候调用setServletContext或者setServletConfig方法(跟ApplicationContextAwareProcessor原理一样)。

AnnotationConfigEmbeddedWebApplicationContext对应的postProcessBeanFactory方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
// 调用父类EmbeddedWebApplicationContext的实现
super.postProcessBeanFactory(beanFactory);
// 查看basePackages属性,如果设置了会使用ClassPathBeanDefinitionScanner去扫描basePackages包下的bean并注册
if (this.basePackages != null && this.basePackages.length > 0) {
this.scanner.scan(this.basePackages);
}
// 查看annotatedClasses属性,如果设置了会使用AnnotatedBeanDefinitionReader去注册这些bean
if (this.annotatedClasses != null && this.annotatedClasses.length > 0) {
this.reader.register(this.annotatedClasses);
}
}

父类EmbeddedWebApplicationContext的实现:

1
2
3
4
5
6
@Override
protected void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
beanFactory.addBeanPostProcessor(
new WebApplicationContextServletContextAwareProcessor(this));
beanFactory.ignoreDependencyInterface(ServletContextAware.class);
}

invokeBeanFactoryPostProcessors方法

在Spring容器中找出实现了BeanFactoryPostProcessor接口的processor并执行。Spring容器会委托给PostProcessorRegistrationDelegate的invokeBeanFactoryPostProcessors方法执行。

介绍两个接口:

  1. BeanFactoryPostProcessor:用来修改Spring容器中已经存在的bean的定义,使用ConfigurableListableBeanFactory对bean进行处理
  2. BeanDefinitionRegistryPostProcessor:继承BeanFactoryPostProcessor,作用跟BeanFactoryPostProcessor一样,只不过是使用BeanDefinitionRegistry对bean进行处理

基于web程序的Spring容器AnnotationConfigEmbeddedWebApplicationContext构造的时候,会初始化内部属性AnnotatedBeanDefinitionReader reader,这个reader构造的时候会在BeanFactory中注册一些post processor,包括BeanPostProcessor和BeanFactoryPostProcessor(比如ConfigurationClassPostProcessor、AutowiredAnnotationBeanPostProcessor):

1
AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry);

invokeBeanFactoryPostProcessors方法处理BeanFactoryPostProcessor的逻辑如下:

从Spring容器中找出BeanDefinitionRegistryPostProcessor类型的bean(这些processor是在容器刚创建的时候通过构造AnnotatedBeanDefinitionReader的时候注册到容器中的),然后按照优先级分别执行,优先级的逻辑如下:

  1. 实现PriorityOrdered接口的BeanDefinitionRegistryPostProcessor先全部找出来,然后排序后依次执行
  2. 实现Ordered接口的BeanDefinitionRegistryPostProcessor找出来,然后排序后依次执行
  3. 没有实现PriorityOrdered和Ordered接口的BeanDefinitionRegistryPostProcessor找出来执行并依次执行

接下来从Spring容器内查找BeanFactoryPostProcessor接口的实现类,然后执行(如果processor已经执行过,则忽略),这里的查找规则跟上面查找BeanDefinitionRegistryPostProcessor一样,先找PriorityOrdered,然后是Ordered,最后是两者都没。

这里需要说明的是ConfigurationClassPostProcessor这个processor是优先级最高的被执行的processor(实现了PriorityOrdered接口)。这个ConfigurationClassPostProcessor会去BeanFactory中找出所有有@Configuration注解的bean,然后使用ConfigurationClassParser去解析这个类。ConfigurationClassParser内部有个Map<ConfigurationClass, ConfigurationClass>类型的configurationClasses属性用于保存解析的类,ConfigurationClass是一个对要解析的配置类的封装,内部存储了配置类的注解信息、被@Bean注解修饰的方法、@ImportResource注解修饰的信息、ImportBeanDefinitionRegistrar等都存储在这个封装类中。

这里ConfigurationClassPostProcessor最先被处理还有另外一个原因是如果程序中有自定义的BeanFactoryPostProcessor,那么这个PostProcessor首先得通过ConfigurationClassPostProcessor被解析出来,然后才能被Spring容器找到并执行。(ConfigurationClassPostProcessor不先执行的话,这个Processor是不会被解析的,不会被解析的话也就不会执行了)。

在我们的程序中,只有主类RefreshContextApplication有@Configuration注解(@SpringBootApplication注解带有@Configuration注解),所以这个配置类会被ConfigurationClassParser解析。解析过程如下:

  1. 处理@PropertySources注解:进行一些配置信息的解析
  2. 处理@ComponentScan注解:使用ComponentScanAnnotationParser扫描basePackage下的需要解析的类(@SpringBootApplication注解也包括了@ComponentScan注解,只不过basePackages是空的,空的话会去获取当前@Configuration修饰的类所在的包),并注册到BeanFactory中(这个时候bean并没有进行实例化,而是进行了注册。具体的实例化在finishBeanFactoryInitialization方法中执行)。对于扫描出来的类,递归解析
  3. 处理@Import注解:先递归找出所有的注解,然后再过滤出只有@Import注解的类,得到@Import注解的值。比如查找@SpringBootApplication注解的@Import注解数据的话,首先发现@SpringBootApplication不是一个@Import注解,然后递归调用修饰了@SpringBootApplication的注解,发现有个@EnableAutoConfiguration注解,再次递归发现被@Import(EnableAutoConfigurationImportSelector.class)修饰,还有@AutoConfigurationPackage注解修饰,再次递归@AutoConfigurationPackage注解,发现被@Import(AutoConfigurationPackages.Registrar.class)注解修饰,所以@SpringBootApplication注解对应的@Import注解有2个,分别是@Import(AutoConfigurationPackages.Registrar.class)和@Import(EnableAutoConfigurationImportSelector.class)。找出所有的@Import注解之后,开始处理逻辑:
    1. 遍历这些@Import注解内部的属性类集合
    2. 如果这个类是个ImportSelector接口的实现类,实例化这个ImportSelector,如果这个类也是DeferredImportSelector接口的实现类,那么加入ConfigurationClassParser的deferredImportSelectors属性中让第6步处理。否则调用ImportSelector的selectImports方法得到需要Import的类,然后对这些类递归做@Import注解的处理
    3. 如果这个类是ImportBeanDefinitionRegistrar接口的实现类,设置到配置类的importBeanDefinitionRegistrars属性中
    4. 其它情况下把这个类入队到ConfigurationClassParser的importStack(队列)属性中,然后把这个类当成是@Configuration注解修饰的类递归重头开始解析这个类
  4. 处理@ImportResource注解:获取@ImportResource注解的locations属性,得到资源文件的地址信息。然后遍历这些资源文件并把它们添加到配置类的importedResources属性中
  5. 处理@Bean注解:获取被@Bean注解修饰的方法,然后添加到配置类的beanMethods属性中
  6. 处理DeferredImportSelector:处理第3步@Import注解产生的DeferredImportSelector,进行selectImports方法的调用找出需要import的类,然后再调用第3步相同的处理逻辑处理

这里@SpringBootApplication注解被@EnableAutoConfiguration修饰,@EnableAutoConfiguration注解被@Import(EnableAutoConfigurationImportSelector.class)修饰,所以在第3步会找出这个@Import修饰的类EnableAutoConfigurationImportSelector,这个类刚好实现了DeferredImportSelector接口,接着就会在第6步被执行。第6步selectImport得到的类就是自动化配置类。

EnableAutoConfigurationImportSelector的selectImport方法会在spring.factories文件中找出key为EnableAutoConfiguration对应的值,有81个,这81个就是所谓的自动化配置类(XXXAutoConfiguration)。

ConfigurationClassParser解析完成之后,被解析出来的类会放到configurationClasses属性中。然后使用ConfigurationClassBeanDefinitionReader去解析这些类。

这个时候这些bean只是被加载到了Spring容器中。下面这段代码是ConfigurationClassBeanDefinitionReader的解析bean过程:

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
public void loadBeanDefinitions(Set<ConfigurationClass> configurationModel) {
TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator();
for (ConfigurationClass configClass : configurationModel) {
// 对每一个配置类,调用loadBeanDefinitionsForConfigurationClass方法
loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator);
}
}

private void loadBeanDefinitionsForConfigurationClass(ConfigurationClass configClass,
TrackedConditionEvaluator trackedConditionEvaluator) {
// 使用条件注解判断是否需要跳过这个配置类
if (trackedConditionEvaluator.shouldSkip(configClass)) {
// 跳过配置类的话在Spring容器中移除bean的注册
String beanName = configClass.getBeanName();
if (StringUtils.hasLength(beanName) && this.registry.containsBeanDefinition(beanName)) {
this.registry.removeBeanDefinition(beanName);
}
this.importRegistry.removeImportingClassFor(configClass.getMetadata().getClassName());
return;
}

if (configClass.isImported()) {
// 如果自身是被@Import注释所import的,注册自己
registerBeanDefinitionForImportedConfigurationClass(configClass);
}
// 注册方法中被@Bean注解修饰的bean
for (BeanMethod beanMethod : configClass.getBeanMethods()) {
loadBeanDefinitionsForBeanMethod(beanMethod);
}
// 注册@ImportResource注解注释的资源文件中的bean
loadBeanDefinitionsFromImportedResources(configClass.getImportedResources());
// 注册@Import注解中的ImportBeanDefinitionRegistrar接口的registerBeanDefinitions
loadBeanDefinitionsFromRegistrars(configClass.getImportBeanDefinitionRegistrars());
}

invokeBeanFactoryPostProcessors方法总结来说就是从Spring容器中找出BeanDefinitionRegistryPostProcessor和BeanFactoryPostProcessor接口的实现类并按照一定的规则顺序进行执行。 其中ConfigurationClassPostProcessor这个BeanDefinitionRegistryPostProcessor优先级最高,它会对项目中的@Configuration注解修饰的类(@Component、@ComponentScan、@Import、@ImportResource修饰的类也会被处理)进行解析,解析完成之后把这些bean注册到BeanFactory中。需要注意的是这个时候注册进来的bean还没有实例化。

下面这图就是对ConfigurationClassPostProcessor后置器的总结:

img

registerBeanPostProcessors方法

从Spring容器中找出的BeanPostProcessor接口的bean,并设置到BeanFactory的属性中。之后bean被实例化的时候会调用这个BeanPostProcessor。

该方法委托给了PostProcessorRegistrationDelegate类的registerBeanPostProcessors方法执行。这里的过程跟invokeBeanFactoryPostProcessors类似:

  1. 先找出实现了PriorityOrdered接口的BeanPostProcessor并排序后加到BeanFactory的BeanPostProcessor集合中
  2. 找出实现了Ordered接口的BeanPostProcessor并排序后加到BeanFactory的BeanPostProcessor集合中
  3. 没有实现PriorityOrdered和Ordered接口的BeanPostProcessor加到BeanFactory的BeanPostProcessor集合中

这些已经存在的BeanPostProcessor在postProcessBeanFactory方法中已经说明,都是由AnnotationConfigUtils的registerAnnotationConfigProcessors方法注册的。这些BeanPostProcessor包括有AutowiredAnnotationBeanPostProcessor(处理被@Autowired注解修饰的bean并注入)、RequiredAnnotationBeanPostProcessor(处理被@Required注解修饰的方法)、CommonAnnotationBeanPostProcessor(处理@PreDestroy、@PostConstruct、@Resource等多个注解的作用)等。

如果是自定义的BeanPostProcessor,已经被ConfigurationClassPostProcessor注册到容器内。

这些BeanPostProcessor会在这个方法内被实例化(通过调用BeanFactory的getBean方法,如果没有找到实例化的类,就会去实例化)。

initMessageSource方法

在Spring容器中初始化一些国际化相关的属性。

initApplicationEventMulticaster方法

在Spring容器中初始化事件广播器,事件广播器用于事件的发布。

SpringBoot源码分析之SpringBoot的启动过程中分析过,EventPublishingRunListener这个SpringApplicationRunListener会监听事件,其中发生contextPrepared事件的时候EventPublishingRunListener会把事件广播器注入到BeanFactory中。

所以initApplicationEventMulticaster不再需要再次注册,只需要拿出BeanFactory中的事件广播器然后设置到Spring容器的属性中即可。如果没有使用SpringBoot的话,Spring容器得需要自己初始化事件广播器。

onRefresh方法

一个模板方法,不同的Spring容器做不同的事情。

比如web程序的容器AnnotationConfigEmbeddedWebApplicationContext中会调用createEmbeddedServletContainer方法去创建内置的Servlet容器。

目前SpringBoot只支持3种内置的Servlet容器:

  1. Tomcat
  2. Jetty
  3. Undertow

registerListeners方法

把Spring容器内的时间监听器和BeanFactory中的时间监听器都添加的事件广播器中。

然后如果存在early event的话,广播出去。

finishBeanFactoryInitialization方法

实例化BeanFactory中已经被注册但是未实例化的所有实例(懒加载的不需要实例化)。

比如invokeBeanFactoryPostProcessors方法中根据各种注解解析出来的类,在这个时候都会被初始化。

实例化的过程各种BeanPostProcessor开始起作用。

finishRefresh方法

refresh做完之后需要做的其他事情。

  1. 初始化生命周期处理器,并设置到Spring容器中(LifecycleProcessor)
  2. 调用生命周期处理器的onRefresh方法,这个方法会找出Spring容器中实现了SmartLifecycle接口的类并进行start方法的调用
  3. 发布ContextRefreshedEvent事件告知对应的ApplicationListener进行响应的操作
  4. 调用LiveBeansView的registerApplicationContext方法:如果设置了JMX相关的属性,则就调用该方法
  5. 发布EmbeddedServletContainerInitializedEvent事件告知对应的ApplicationListener进行响应的操作

总结

Spring容器的refresh过程就是上述11个方法的介绍。内容还是非常多的,本文也只是说了个大概,像bean的实例化过程没有具体去分析,这方面的内容以后会看情况去做分析。

这篇文章也是为之后的文章比如内置Servlet容器的创建启动、条件注解的使用等打下基础。

Shadowsocks PAC规则

ShadowSocks默认使用GFWList规则和使用adblock plus的引擎。要想自己添加自定义的用户规则,最好熟悉一下其规则:

中文版:Adblock Plus过滤规则

自定义代理规则的设置语法与GFWlist相同,语法规则如下:

  • 通配符支持。
    • 比如 *.example.com/*
    • 实际书写时可省略 * , 如.example.com/*.example.com/* 效果一样
  • 正则表达式支持。
    • \ 开始和结束, 如 \[\w]+:\/\/example.com\
  • 例外规则 @@
    • @@*.example.com/* 满足 @@ 后规则的地址不使用代理
  • 匹配地址开始和结尾 |
    • |http://example.comexample.com| 分别表示以 http://example.com 开始和以 example.com 结束的地址
  • ||标记
    • ||example.comhttp://example.comhttps://example.comftp://example.com 等地址址满足条件。
  • 注释 !
    • !我是注释
  • 分隔符^
    • 表示除了字母、数字或者 _ - . % 之外的任何字符。如 http://example.com^http://example.com/http://example.com:8000/ 均满足条件,而 http://example.com.ar/ 不满足条件。

什么是PAC

  • 维基百科摘录的关于PAC的解释:
    • 代理自动配置(英语:Proxy auto-config,简称PAC)是一种网页浏览器技术,用于定义浏览器该如何自动选择适当的代理服务器来访问一个网址。
    • 一个PAC文件包含一个JavaScript形式的函数FindProxyForURL(url, host)
    • 这个函数返回一个包含一个或多个访问规则的字符串。
    • 用户代理根据这些规则适用一个特定的代理其或者直接访问。
    • 当一个代理服务器无法响应的时候,多个访问规则提供了其他的后备访问方法。
    • 浏览器在访问其他页面以前,首先访问这个PAC文件。
    • PAC文件中的URL可能是手工配置的,也可能是是通过网页的网络代理自发现协议(Web Proxy Autodiscovery Protocol)自动配置的。
  • 源自网络的图解:

http://static.cyblogs.com/PAC.png

简单说来,PAC就是一种配置规则,它能让你的浏览器智能判断哪些网站走代理,哪些不需要走代理。

pac.txt

shadowsocks 目录下有一个 pac.txt 文件,而pac.txt这个文件是可以使用在线PAC或通过本地的GWFlist去更新的。所以并不建议用户自定义的规则直接加在pac.txt上面。

http://static.cyblogs.com/ShadowSocks_PAC.jpg

打开 pac.txt 文件,可以看到头部是如下内容:

http://static.cyblogs.com/ShadowSocks_PAC01.jpg

可以看出pac配置文件使用的是JavaScript语法,里面定义了一个变量rules,是一个JSon数组格式的数据类型,数组里面存放的是各种URL的通配符。

那么在pac模式下,如果当访问符合这个数组里面任意一个URL通配符的网址时,系统会走代理,反之直连。比如访问谷歌搜索首页时,会走代理,而访问百度、新浪等国内网站则会选择直连方式。

PAC脚本

  • PAC,一个自动代理配置脚本,包含了很多使用 JavaScript 编写的规则,它能够决定网络流量走默认通道还是代理服务器通道,控制的流量类型包括:HTTP、HTTPS 和 FTP。
  • 一段 JavaScript 脚本:
1
2
3
function FindProxyForURL(url, host) {
return "DIRECT";
}

上面就是一个最简洁的 PAC 文件,意思是所有流量都直接进入互联网,不走代理。

PAC的优势

PAC自动代理属于智能判断模式,相比全局代理,它的优点有:

  • 不影响国内网站的访问速度,防止无意义的绕路;
  • 节省Shadowsocks服务的流量,节省服务器资源;
  • 控制方便。
  • 自动容灾。

PAC 语法和函数

PAC不但使用在ShadowSocks上很方便,实际上它也可以配合其它代理服务端(如Squid),运用在浏览器上,不过需要你去弄懂它的语法和函数。

url 字段表示浏览器地址栏输入的待访问地址,host 为该地址对应的 hostname,return 语句有三种指令:

  • DIRECT,表示无代理直接连接
  • PROXY host:port,表示走host:port 的 proxy 服务
  • SOCKS host:port,表示走host:port 的 socks 服务

而返回的接口可以是多个代理串联:

1
return "PROXY 222.20.74.89:8800; SOCKS 222.20.74.89:8899; DIRECT";

上面代理的意思是,默认走222.20.74.89:8800 的 proxy 服务;如果代理挂了或者超时,则走 222.20.74.89:8899的 socks 代理;如果 socks 也挂了,则无代理直接连接。

从这里可以看出 PAC 的一大优势:自动容灾。

PAC 提供了几个内置的函数,下面一一介绍下:

dnsDomainIs

类似于 ==,但是对大小写不敏感,

1
2
3
4
if (dnsDomainIs(host, "google.com") || 
dnsDomainIs(host, "www.google.com")) {
return "DIRECT";
}

shExpMatch

Shell 正则匹配,* 匹配用的比较多,可以是*.http://example.com,也可以是下面这样:

1
2
3
4
if (shExpMatch(host, "vpn.domain.com") ||
shExpMatch(url, "http://abcdomain.com/folder/*")) {
return "DIRECT";
}

isInNet

判断是否在网段内容,比如 10.1.0.0 这个网段,10.1.1.0 就在网段中,

1
2
3
if (isInNet(dnsResolve(host), "172.16.0.0", "255.240.0.0")) {
return "DIRECT";
}

myIpAddress

返回主机的 IP,

1
2
3
if (isInNet(myIpAddress(), "10.10.1.0", "255.255.255.0")) {
return "PROXY 10.10.5.1:8080";
}

dnsResolve

通过 DNS 查询主机 ip,

1
2
3
4
5
6
if (isInNet(dnsResolve(host), "10.0.0.0", "255.0.0.0") ||
isInNet(dnsResolve(host), "172.16.0.0", "255.240.0.0") ||
isInNet(dnsResolve(host), "192.168.0.0", "255.255.0.0") ||
isInNet(dnsResolve(host), "127.0.0.0", "255.255.255.0")) {
return "DIRECT";
}

isPlainHostName

判断是否为诸如barret/,server-name/ 这样的主机名。

1
2
3
if (isPlainHostName(host)) {
return "DIRECT";
}

isResolvable

判断主机是否可访问。

1
2
3
if (isResolvable(host)) {
return "PROXY proxy1.example.com:8080";
}

dnsDomainLevels

返回是几级域名,比如 dnsDomainLevels 返回的结果就是 1。

1
2
3
4
5
if (dnsDomainLevels(host) > 0) {
return "PROXY proxy1.example.com:8080";
} else {
return "DIRECT";
}

weekdayRange

周一到周五

1
2
3
4
5
if (weekdayRange("MON", "FRI")) {
return "PROXY proxy1.example.com:8080";
} else {
return "DIRECT";
}

dateRange

一月到五月

1
2
3
4
5
if (dateRange("JAN", "MAR"))  {
return "PROXY proxy1.example.com:8080";
} else {
return "DIRECT";
}

timeRange

八点到十八点,

1
2
3
4
5
if (timeRange(8, 18)) {
return "PROXY proxy1.example.com:8080";
} else {
return "DIRECT";
}

alert

这个弹窗警报信息函数可以用来配合浏览器的控制台进行调试

1
2
resolved_host = dnsResolve(host);
alert(resolved_host);

PAC 文件的安装和注意事项

在 Windows 系统中,通过「Internet选项 -> 连接 -> 局域网设置 -> 使用自动配置脚本」可以找到配置处,下方的地址栏填写 PAC 文件的 URI,这个 URI 可以是本地资源路径(file:///),也可以是网络资源路径。

Chrome 中可以在「chrome://settings/ -> 显示高级设置 -> 更改代理服务器设置」中找到 PAC 填写地址。

需要注意的几点:

  • PAC 文件被访问时,返回的文件类型(Content-Type)应该为:application/x-ns-proxy-autoconfig,当然,如果你不写,一般浏览器也能够自动辨别。
  • FindProxyByUrl(url, host)中的 host 在上述函数对比时无需转换成小写,对大小写不敏感。
  • 没必要对 dnsResolve(host)的结果做缓存,DNS 在解析的时候会将结果缓存到系统中。

PAC文件及user-rule文件的语法规则

当一个网站被墙,如何添加到PAC里面让其能够正常访问呢?以MDN web doc这个网站为例,在Shadowsocks里面,可以有如下两个方式:

1. 添加到 pac.txt 文件中

编辑 pac.txt 文件,模仿里面的一些URL通配符,在rules(中括号)列表中再添加一个,例如"||developer.mozilla.org^", ,注意不要忘记了 , 半角逗号,当然如果你是在最后添加的就可以不加半角逗号。这样配置下来就是所有 developer.mozilla.org域名下的网址都将走Shadowsocks代理。

http://static.cyblogs.com/hotlink.jpg

2. 添加到 user-rule.txt 文件中(推荐)

编辑 user-rule.txt 文件,这里和 pac.txt 文件语法不完全相同,user-rule文件中,每一行表示一个URL通配符,但是通配符语法类似。例如添加一行||developer.mozilla.org^ ,然后记得右键shadowsocks小飞机图标-PAC-从GFWList更新本地PAC。

推荐使用添加到user-rule.txt的这种自定义用户规则的方法,因为pac文件里的规则是有可能被在线更新掉的。

http://static.cyblogs.com/3vjn79vf2uit5prto2asqoa39s.png

注意末尾不要忘记 ^ 符号,意思是要么在这个符号的地方结束,要么后面跟着?,/等符号。

参考地址

前言

通过我之前的Tomcat系列文章,相信看我博客的同学对Tomcat应该有一个比较清晰的了解了,在前几篇博客我们讨论了Tomcat在SpringBoot框架中是如何启动的,讨论了Tomcat的内部组件是如何设计以及请求是如何流转的,那么我们这篇博客聊聊Tomcat的异步Servlet,Tomcat是如何实现异步Servlet的以及异步Servlet的使用场景。

手撸一个异步的Servlet

我们直接借助SpringBoot框架来实现一个Servlet,这里只展示Servlet代码:

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
@WebServlet(urlPatterns = "/async",asyncSupported = true)
@Slf4j
public class AsyncServlet extends HttpServlet {

ExecutorService executorService =Executors.newSingleThreadExecutor();

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//开启异步,获取异步上下文
final AsyncContext ctx = req.startAsync();
// 提交线程池异步执行
executorService.execute(new Runnable() {


@Override
public void run() {
try {
log.info("async Service 准备执行了");
//模拟耗时任务
Thread.sleep(10000L);
ctx.getResponse().getWriter().print("async servlet");
log.info("async Service 执行了");
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
//最后执行完成后完成回调。
ctx.complete();
}
});
}

上面的代码实现了一个异步的Servlet,实现了doGet方法注意在SpringBoot中使用需要再启动类加上@ServletComponentScan注解来扫描Servlet。既然代码写好了,我们来看看实际运行效果。

img

我们发送一个请求后,看到页面有响应,同时,看到请求时间花费了10.05s,那么我们这个Servlet算是能正常运行啦。有同学肯定会问,这不是异步servlet吗?你的响应时间并没有加快,有什么用呢?对,我们的响应时间并不能加快,还是会取决于我们的业务逻辑,但是我们的异步servlet请求后,依赖于业务的异步执行,我们可以立即返回,也就是说,Tomcat的线程可以立即回收,默认情况下,Tomcat的核心线程是10,最大线程数是200,我们能及时回收线程,也就意味着我们能处理更多的请求,能够增加我们的吞吐量,这也是异步Servlet的主要作用。

异步Servlet的内部原理

了解完异步Servlet的作用后,我们来看看,Tomcat是如何是先异步Servlet的。其实上面的代码,主要核心逻辑就两部分,final AsyncContext ctx = req.startAsync() ctx.complete()那我们来看看他们究竟做了什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public AsyncContext startAsync(ServletRequest request,
ServletResponse response) {
if (!isAsyncSupported()) {
IllegalStateException ise =
new IllegalStateException(sm.getString("request.asyncNotSupported"));
log.warn(sm.getString("coyoteRequest.noAsync",
StringUtils.join(getNonAsyncClassNames())), ise);
throw ise;
}

if (asyncContext == null) {
asyncContext = new AsyncContextImpl(this);
}

asyncContext.setStarted(getContext(), request, response,
request==getRequest() && response==getResponse().getResponse());
asyncContext.setTimeout(getConnector().getAsyncTimeout());

return asyncContext;
}

我们发现req.startAsync()只是保存了一个异步上下文,同时设置一些基础信息,比如Timeout,顺便提一下,这里设置的默认超时时间是30S,如果你的异步处理逻辑超过30S,此时执行ctx.complete()就会抛出IllegalStateException 异常。

我们来看看ctx.complete()的逻辑

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
  public void complete() {
if (log.isDebugEnabled()) {
logDebug("complete ");
}
check();
request.getCoyoteRequest().action(ActionCode.ASYNC_COMPLETE, null);
}
//类:AbstractProcessor
public final void action(ActionCode actionCode, Object param) {
case ASYNC_COMPLETE: {
clearDispatches();
if (asyncStateMachine.asyncComplete()) {
processSocketEvent(SocketEvent.OPEN_READ, true);
}
break;
}
}
//类:AbstractProcessor
protected void processSocketEvent(SocketEvent event, boolean dispatch) {
SocketWrapperBase<?> socketWrapper = getSocketWrapper();
if (socketWrapper != null) {
socketWrapper.processSocket(event, dispatch);
}
}
//类:AbstractEndpoint
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
SocketEvent event, boolean dispatch) {
//省略部分代码
SocketProcessorBase<S> sc = null;
if (processorCache != null) {
sc = processorCache.pop();
}
if (sc == null) {
sc = createSocketProcessor(socketWrapper, event);
} else {
sc.reset(socketWrapper, event);
}
Executor executor = getExecutor();
if (dispatch && executor != null) {
executor.execute(sc);
} else {
sc.run();
}

return true;
}

所以,这里最终会调用AbstractEndpointprocessSocket方法,之前看过我前面博客的同学应该有印象,EndPoint是用来接受和处理请求的,接下来就会交给Processor去进行协议处理。

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
类:AbstractProcessorLight
public SocketState process(SocketWrapperBase<?> socketWrapper, SocketEvent status)
throws IOException {
//省略部分diam
SocketState state = SocketState.CLOSED;
Iterator<DispatchType> dispatches = null;
do {
if (dispatches != null) {
DispatchType nextDispatch = dispatches.next();
state = dispatch(nextDispatch.getSocketStatus());
} else if (status == SocketEvent.DISCONNECT) {

} else if (isAsync() || isUpgrade() || state == SocketState.ASYNC_END) {
state = dispatch(status);
if (state == SocketState.OPEN) {
state = service(socketWrapper);
}
} else if (status == SocketEvent.OPEN_WRITE) {
state = SocketState.LONG;
} else if (status == SocketEvent.OPEN_READ){
state = service(socketWrapper);
} else {
state = SocketState.CLOSED;
}

} while (state == SocketState.ASYNC_END ||
dispatches != null && state != SocketState.CLOSED);

return state;
}

这部分是重点,AbstractProcessorLight会根据SocketEvent的状态来判断是不是要去调用service(socketWrapper),该方法最终会去调用到容器,从而完成业务逻辑的调用,我们这个请求是执行完成后调用的,肯定不能进容器了,不然就是死循环了,这里通过isAsync() 判断,就会进入dispatch(status),最终会调用CoyoteAdapterasyncDispatch方法

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
64
public boolean asyncDispatch(org.apache.coyote.Request req, org.apache.coyote.Response res,
SocketEvent status) throws Exception {
//省略部分代码
Request request = (Request) req.getNote(ADAPTER_NOTES);
Response response = (Response) res.getNote(ADAPTER_NOTES);
boolean success = true;
AsyncContextImpl asyncConImpl = request.getAsyncContextInternal();
try {
if (!request.isAsync()) {
response.setSuspended(false);
}

if (status==SocketEvent.TIMEOUT) {
if (!asyncConImpl.timeout()) {
asyncConImpl.setErrorState(null, false);
}
} else if (status==SocketEvent.ERROR) {

}

if (!request.isAsyncDispatching() && request.isAsync()) {
WriteListener writeListener = res.getWriteListener();
ReadListener readListener = req.getReadListener();
if (writeListener != null && status == SocketEvent.OPEN_WRITE) {
ClassLoader oldCL = null;
try {
oldCL = request.getContext().bind(false, null);
res.onWritePossible();//这里执行浏览器响应,写入数据
if (request.isFinished() && req.sendAllDataReadEvent() &&
readListener != null) {
readListener.onAllDataRead();
}
} catch (Throwable t) {

} finally {
request.getContext().unbind(false, oldCL);
}
}
}
}
//这里判断异步正在进行,说明这不是一个完成方法的回调,是一个正常异步请求,继续调用容器。
if (request.isAsyncDispatching()) {
connector.getService().getContainer().getPipeline().getFirst().invoke(
request, response);
Throwable t = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
if (t != null) {
asyncConImpl.setErrorState(t, true);
}
}
//注意,这里,如果超时或者出错,request.isAsync()会返回false,这里是为了尽快的输出错误给客户端。
if (!request.isAsync()) {
//这里也是输出逻辑
request.finishRequest();
response.finishResponse();
}
//销毁request和response
if (!success || !request.isAsync()) {
updateWrapperErrorCount(request, response);
request.recycle();
response.recycle();
}
}
return success;
}

上面的代码就是ctx.complete()执行最终的方法了(当然省略了很多细节),完成了数据的输出,最终输出到浏览器。

这里有同学可能会说,我知道异步执行完后,调用ctx.complete()会输出到浏览器,但是,第一次doGet请求执行完成后,Tomcat是怎么知道不用返回到客户端的呢?关键代码在CoyoteAdapter中的service方法,部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
postParseSuccess = postParseRequest(req, request, res, response);
//省略部分代码
if (postParseSuccess) {
request.setAsyncSupported(
connector.getService().getContainer().getPipeline().isAsyncSupported());
connector.getService().getContainer().getPipeline().getFirst().invoke(
request, response);
}
if (request.isAsync()) {
async = true;
} else {
//输出数据到客户端
request.finishRequest();
response.finishResponse();
if (!async) {
updateWrapperErrorCount(request, response);
//销毁request和response
request.recycle();
response.recycle();
}

这部分代码在调用完Servlet后,会通过request.isAsync()来判断是否是异步请求,如果是异步请求,就设置 async = true。如果是非异步请求就执行输出数据到客户端逻辑,同时销毁requestresponse。这里就完成了请求结束后不响应客户端的操作。

为什么说Spring Boot的@EnableAsync注解不是异步Servlet

因为之前准备写本篇文章的时候就查询过很多资料,发现很多资料写SpringBoot异步编程都是依赖于@EnableAsync注解,然后在Controller用多线程来完成业务逻辑,最后汇总结果,完成返回输出。这里拿一个掘金大佬的文章来举例《新手也能看懂的 SpringBoot 异步编程指南》,这篇文章写得很通俗易懂,非常不错,从业务层面来说,确实是异步编程,但是有一个问题,抛开业务的并行处理来说,针对整个请求来说,并不是异步的,也就是说不能立即释放Tomcat的线程,从而不能达到异步Servlet的效果。这里我参考上文也写了一个demo,我们来验证下,为什么它不是异步的。

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
@RestController
@Slf4j
public class TestController {
@Autowired
private TestService service;

@GetMapping("/hello")
public String test() {
try {
log.info("testAsynch Start");
CompletableFuture<String> test1 = service.test1();
CompletableFuture<String> test2 = service.test2();
CompletableFuture<String> test3 = service.test3();
CompletableFuture.allOf(test1, test2, test3);
log.info("test1=====" + test1.get());
log.info("test2=====" + test2.get());
log.info("test3=====" + test3.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
return "hello";
}
@Service
public class TestService {
@Async("asyncExecutor")
public CompletableFuture<String> test1() throws InterruptedException {
Thread.sleep(3000L);
return CompletableFuture.completedFuture("test1");
}

@Async("asyncExecutor")
public CompletableFuture<String> test2() throws InterruptedException {
Thread.sleep(3000L);
return CompletableFuture.completedFuture("test2");
}

@Async("asyncExecutor")
public CompletableFuture<String> test3() throws InterruptedException {
Thread.sleep(3000L);
return CompletableFuture.completedFuture("test3");
}
}
@SpringBootApplication
@EnableAsync
public class TomcatdebugApplication {

public static void main(String[] args) {
SpringApplication.run(TomcatdebugApplication.class, args);
}

@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(3);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("AsynchThread-");
executor.initialize();
return executor;
}

这里我运行下,看看效果

img

这里我请求之后,在调用容器执行业务逻辑之前打了一个断点,然后在返回之后的同样打了一个断点,在Controller执行完之后,请求才回到了CoyoteAdapter中,并且判断request.isAsync(),根据图中看到,是为false,那么接下来就会执行 request.finishRequest()response.finishResponse() 来执行响应的结束,并销毁请求和响应体。很有趣的事情是,我实验的时候发现,在执行request.isAsync()之前,浏览器的页面上已经出现了响应体,这是SpringBoot框架已经通过StringHttpMessageConverter类中的writeInternal方法已经进行输出了。

以上分析的核心逻辑就是,Tomcat的线程执行CoyoteAdapter调用容器后,必须要等到请求返回,然后再判断是否是异步请求,再处理请求,然后执行完毕后,线程才能进行回收。而我一最开始的异步Servlet例子,执行完doGet方法后,就会立即返回,也就是会直接到request.isAsync()的逻辑,然后整个线程的逻辑执行完毕,线程被回收。

聊聊异步Servlet的使用场景

分析了这么多,那么异步Servlet的使用场景有哪些呢?其实我们只要抓住一点就可以分析了,就是异步Servlet提高了系统的吞吐量,可以接受更多的请求。假设web系统中Tomcat的线程不够用了,大量请求在等待,而此时Web系统应用层面的优化已经不能再优化了,也就是无法缩短业务逻辑的响应时间了,这个时候,如果想让减少用户的等待时间,提高吞吐量,可以尝试下使用异步Servlet。

举一个实际的例子:比如做一个短信系统,短信系统对实时性要求很高,所以要求等待时间尽可能短,而发送功能我们实际上是委托运营商去发送的,也就是说我们要调用接口,假设并发量很高,那么这个时候业务系统调用我们的发送短信功能,就有可能把我们的Tomcat线程池用完,剩下的请求就会在队列中等待,那这个时候,短信的延时就上去了,为了解决这个问题,我们可以引入异步Servlet,接受更多的短信发送请求,从而减少短信的延时。

总结

这篇文章我从手写一个异步Servlet来开始,分析了异步Servlet的作用,以及Tomcat内部是如何实现异步Servlet的,然后我也根据互联网上流行的SpringBoot异步编程来进行说明,其在Tomcat内部并不是一个异步的Servlet。最后,我谈到了异步Servlet的使用场景,分析了什么情况下可以尝试异步Servlet。

当Leader崩溃或者Leader失去大多数的Follower,这时候zk进入恢复模式,恢复模式需要重新选举出一个新的Leader,让所有的Server都恢复到一个正确的状态。Zookeeper中Leader的选举采用了三种算法:

  • LeaderElection
  • FastLeaderElection
  • AuthFastLeaderElection

并且在配置文件中是可配置的,对应的配置项为electionAlg。

背景知识

Zookeeper Server的状态可分为四种:

  • LOOKING:寻找Leader
  • LEADING:Leader状态,对应的节点为Leader。
  • FOLLOWING:Follower状态,对应的节点为Follower。
  • OBSERVING:Observer状态,对应节点为Observer,该节点不参与Leader选举。

成为Leader的必要条件: Leader要具有最高的zxid;当集群的规模是n时,集群中大多数的机器(至少n/2+1)得到响应并follow选出的Leader。

心跳机制:Leader与Follower利用PING来感知对方的是否存活,当Leader无法相应PING时,将重新发起Leader选举。

术语

zxid:zookeeper transaction id, 每个改变Zookeeper状态的操作都会形成一个对应的zxid,并记录到transaction log中。 这个值越大,表示更新越新。

electionEpoch/logicalclock:逻辑时钟,用来判断是否为同一次选举。每调用一次选举函数,logicalclock自增1,并且在选举过程中如果遇到election比当前logicalclock大的值,就更新本地logicalclock的值。

peerEpoch: 表示节点的Epoch。

LeaderElection选举算法

LeaderElection是Fast Paxos最简单的一种实现,每个Server启动以后都询问其它的Server它要投票给谁,收到所有Server回复以后,就计算出zxid最大的哪个Server,并将这个Server相关信息设置成下一次要投票的Server。该算法于Zookeeper 3.4以后的版本废弃。

选举算法流程如下:

  1. 选举线程首先向所有Server发起一次询问(包括自己);
  2. 选举线程收到回复后,验证是否是自己发起的询问(验证xid是否一致),然后获取对方的id(myid),并存储到当前询问对象列表中,最后获取对方提议的leader相关信息(id,zxid),并将这些信息存储到当次选举的投票记录表中;
  3. 收到所有Server回复以后,就计算出zxid最大的那个Server,并将这个Server相关信息设置成下一次要投票的Server;
  4. 线程将当前zxid最大的Server设置为当前Server要推荐的Leader,如果此时获胜的Server获得多数Server票数, 设置当前推荐的leader为获胜的Server,将根据获胜的Server相关信息设置自己的状态,否则,继续这个过程,直到leader被选举出来。

leader-election

通过流程分析我们可以得出:要使Leader获得多数Server的支持,则Server总数必须是奇数2n+1,且存活的Server的数目不得少于n+1.

异常问题的处理:

  1. 选举过程中,Server的加入
    当一个Server启动时它都会发起一次选举,此时由选举线程发起相关流程,那么每个 Serve r都会获得当前zxi d最大的哪个Serve r是谁,如果当次最大的Serve r没有获得n/2+1 个票数,那么下一次投票时,他将向zxid最大的Server投票,重复以上流程,最后一定能选举出一个Leader。
  2. 选举过程中,Server的退出
    只要保证n/2+1个Server存活就没有任何问题,如果少于n/2+1个Server 存活就没办法选出Leader。
  3. 选举过程中,Leader死亡
    当选举出Leader以后,此时每个Server应该是什么状态(FLLOWING)都已经确定,此时由于Leader已经死亡我们就不管它,其它的Fllower按正常的流程继续下去,当完成这个流程以后,所有的Fllower都会向Leader发送Ping消息,如果无法ping通,就改变自己的状为(FLLOWING ==> LOOKING),发起新的一轮选举。
  4. 选举完成以后,Leader死亡
    处理过程同上。
  5. 双主问题
    Leader的选举是保证只产生一个公认的Leader的,而且Follower重新选举与旧Leader恢复并退出基本上是同时发生的,当Follower无法ping同Leader是就认为Leader已经出问题开始重新选举,Leader收到Follower的ping没有达到半数以上则要退出Leader重新选举。

FastLeaderElection选举算法

由于LeaderElection收敛速度较慢,所以Zookeeper引入了FastLeaderElection选举算法,FastLeaderElection也成了Zookeeper默认的Leader选举算法。

FastLeaderElection是标准的Fast Paxos的实现,它首先向所有Server提议自己要成为leader,当其它Server收到提议以后,解决 epoch 和 zxid 的冲突,并接受对方的提议,然后向对方发送接受提议完成的消息。FastLeaderElection算法通过异步的通信方式来收集其它节点的选票,同时在分析选票时又根据投票者的当前状态来作不同的处理,以加快Leader的选举进程。

算法流程

数据恢复阶段

每个ZooKeeper Server读取当前磁盘的数据(transaction log),获取最大的zxid。

发送选票

每个参与投票的ZooKeeper Server向其他Server发送自己所推荐的Leader,这个协议中包括几部分数据:

  • 所推举的Leader id。在初始阶段,第一次投票所有Server都推举自己为Leader。
  • 本机的最大zxid值。这个值越大,说明该Server的数据越新。
  • logicalclock。这个值从0开始递增,每次选举对应一个值,即在同一次选举中,这个值是一致的。这个值越大说明选举进程越新。
  • 本机的所处状态。包括LOOKING,FOLLOWING,OBSERVING,LEADING。

处理选票

每台Server将自己的数据发送给其他Server之后,同样也要接受其他Server的选票,并做一下处理。

如果Sender的状态是LOOKING
  • 如果发送过来的logicalclock大于目前的logicalclock。说明这是更新的一次选举,需要更新本机的logicalclock,同事清空已经收集到的选票,因为这些数据已经不再有效。然后判断是否需要更新自己的选举情况。首先判断zxid,zxid大者胜出;如果相同比较leader id,大者胜出。
  • 如果发送过来的logicalclock小于于目前的logicalclock。说明对方处于一个比较早的选举进程,只需要将本机的数据发送过去即可。
  • 如果发送过来的logicalclock等于目前的logicalclock。根据收到的zxid和leader id更新选票,然后广播出去。

当Server处理完选票后,可能需要对Server的状态进行更新:

  • 判断服务器是否已经收集到所有的服务器的选举状态。如果是根据选举结果设置自己的角色(FOLLOWING or LEADER),然后退出选举。
  • 如果没有收到没有所有服务器的选举状态,也可以判断一下根据以上过程之后更新的选举Leader是不是得到了超过半数以上服务器的支持。如果是,那么尝试在200ms内接收下数据,如果没有心数据到来说明大家已经认同这个结果。这时,设置角色然后退出选举。
如果Sender的状态是FOLLOWING或者LEADER
  • 如果LogicalClock相同,将数据保存早recvset,如果Sender宣称自己是Leader,那么判断是不是半数以上的服务器都选举它,如果是设置角色并退出选举。
  • 否则,这是一条与当前LogicalClock不符合的消息,说明在另一个选举过程中已经有了选举结果,于是将该选举结果加入到OutOfElection集合中,根据OutOfElection来判断是否可以结束选举,如果可以也是保存LogicalClock,更新角色,退出选举。

fast-leader-election

具体实现

数据结构

本地消息结构:

1
2
3
4
5
6
7
8
9
10
static public class Notification {
long leader; //所推荐的Server id

long zxid; //所推荐的Server的zxid(zookeeper transtion id)

long epoch; //描述leader是否变化(每一个Server启动时都有一个logicalclock,初始值为0)

QuorumPeer.ServerState state; //发送者当前的状态
InetSocketAddress addr; //发送者的ip地址
}

网络消息结构:

1
2
3
4
5
6
7
8
9
10
11
12
static public class ToSend {

int type; //消息类型
long leader; //Server id
long zxid; //Server的zxid
long epoch; //Server的epoch
QuorumPeer.ServerState state; //Server的state
long tag; //消息编号

InetSocketAddress addr;

}
线程处理

每个Server都一个接收线程池和一个发送线程池, 在没有发起选举时,这两个线程池处于阻塞状态,直到有消息到来时才解除阻塞并处理消息,同时每个Server都有一个选举线程(可以发起选举的线程担任)。

  • 接收线程的处理
    notification: 首先检测当前Server上所被推荐的zxid,epoch是否合法(currentServer.epoch <= currentMsg.epoch && (currentMsg.zxid > currentServer.zxid || (currentMsg.zxid == currentServer.zxid && currentMsg.id > currentServer.id))) 如果不合法就用消息中的zxid,epoch,id更新当前Server所被推荐的值,此时将收到的消息转换成Notification消息放入接收队列中,将向对方发送ack消息。
    ack: 将消息编号放入ack队列中,检测对方的状态是否是LOOKING状态,如果不是说明此时已经有Leader已经被选出来,将接收到的消息转发成Notification消息放入接收对队列
  • 发送线程池的处理
    notification: 将要发送的消息由Notification消息转换成ToSend消息,然后发送对方,并等待对方的回复,如果在等待结束没有收到对方法回复,重做三次,如果重做次还是没有收到对方的回复时检测当前的选举(epoch)是否已经改变,如果没有改变,将消息再次放入发送队列中,一直重复直到有Leader选出或者收到对方回复为止。
    ack: 主要将自己相关信息发送给对方
  • 选举线程的处理
    首先自己的epoch加1,然后生成notification消息,并将消息放入发送队列中,系统中配置有几个Server就生成几条消息,保证每个Server都能收到此消息,如果当前Server的状态是LOOKING就一直循环检查接收队列是否有消息,如果有消息,根据消息中对方的状态进行相应的处理。

AuthFastLeaderElection选举算法

AuthFastLeaderElection算法同FastLeaderElection算法基本一致,只是在消息中加入了认证信息,该算法在最新的Zookeeper中也建议弃用。

Example

下面看一个Leader选举的例子以加深对Leader选举算法的理解。

  1. 服务器1启动,此时只有它一台服务器启动了,它发出去的报没有任何响应,所以它的选举状态一直是LOOKING状态.
  2. 服务器2启动,它与最开始启动的服务器1进行通信,互相交换自己的选举结果,由于两者都没有历史数据,所以id值较大的服务器2胜出,但是由于没有达到超过半数以上的服务器都同意选举它(这个例子中的半数以上是3),所以服务器1,2还是继续保持LOOKING状态.
  3. 服务器3启动,根据前面的理论分析,服务器3成为服务器1,2,3中的Leader,而与上面不同的是,此时有三台服务器选举了它,所以它成为了这次选举的Leader.
  4. 服务器4启动,根据前面的分析,理论上服务器4应该是服务器1,2,3,4中最大的,但是由于前面已经有半数以上的服务器选举了服务器3,所以它只能是Follower.
  5. 服务器5启动,同4一样,Follower.

参考资料

Zookeeper使用了一种称为Zab(Zookeeper Atomic Broadcast)的协议作为其一致性的核心。Zab协议是Paxos协议的一种变形,下面将展示一些协议的核心内容。

考虑到Zookeeper的主要操作数据状态,为了保证一致性,Zookeeper提出了两个安全属性:

  • 全序(Total Order):如果消息A在消息B之前发送,则所有Server应该看到相同结果。
  • 因果顺序(Causal Order):如果消息A在消息B之前发生(A导致了B),并且一起发送,则消息A始终在消息B之前被执行。

为了保证上述两个安全属性,Zookeeper使用了TCP协议和Leader。通过使用TCP协议保证了消息的全序的特性(先发先到),通过Leader解决了因果顺序(先到Leader先执行)。因为有了Leader,Zookeeper的架构就变成为:Master-Slave模式,但在该模式中Master(Leader)会Crash,因此,Zookeeper引入Leader选举算法,以保证系统的健壮性。

当Zookeeper Server收到写操作,Follower会将其转发给Leader,由Leader执行操作。Client可以直接从Follower上读取数据,如果需要读取最新数据,则需要从Leader节点读取,Zookeeper设计的读写比大致为2:1。

Leader执行写操作可以简化为一个两段式提交的transaction:

  1. Leader发送proposal给所有的Follower。
  2. 收到proposal后,Follower回复ACK给Leader,接受Leader的proposal.
  3. 当Leader收到大多数的Follower的ACK后,将commit其proposal。

broadcast

在这个过程中,proposal的确认不需要所有节点都同意,如果有2n+1个节点,那么只要有n个节点同意即可,也就是说Zookeeper允许n个节点down掉。任何两个多数派必然有交集,在Leader切换(Leader down)时,这些交集依然保持着最新的系统状态。如果集群节点个数少于n+1个时,Zookeeper将无法进行同步,也就无法继续工作。

Zab与Paxos

Zab的作者认为Zab与paxos并不相同,只所以没有采用Paxos是因为Paxos保证不了全序顺序:

Because multiple leaders can propose a value for a given instance two problems arise.
First, proposals can conflict. Paxos uses ballots to detect and resolve conflicting proposals.
Second, it is not enough to know that a given instance number has been committed, processes must also be able to figure out which value has been committed.

举个例子。假设一开始Paxos系统中的Leader是P1,他发起了两个事务{t1, v1}(表示序号为t1的事务要写的值是v1)和{t2, v2},过程中Leader挂了。新来个Leader是P2,他发起了事务{t1, v1’}。而后又来个新Leader是P3,他汇总了一下,得出最终的执行序列{t1, v1’}和{t2, v2}。

这样的序列为什么不能满足ZooKeeper的需求呢?ZooKeeper是一个树形结构,很多操作都要先检查才能确定能不能执行,比如P1的事务t1可能是创建节点“/a”,t2可能是创建节点“/a/aa”,只有先创建了父节点“/a”,才能创建子节点“/a/aa”。而P2所发起的事务t1可能变成了创建“/b”。这样P3汇总后的序列是先创建“/b”再创建“/a/aa”,由于“/a”还没建,创建“a/aa”就搞不定了。

为了保证这一点,ZAB要保证同一个leader的发起的事务要按顺序被apply,同时还要保证只有先前的leader的所有事务都被apply之后,新选的leader才能在发起事务。

参考文章

背景

对于JVM这块儿的知识,我估计大部分的都是只有在需要面试的时候才会拿出来复习一下,然后就又放下来。也是因为这块儿是Java最底层的部分,非常难懂。其实如果真的说认真、细心的去撸一下,了解透彻,应该就不会那么容易忘记。

今天的主要目的也是根据Oracle的官方文档来一步一步的理解与学习,并且用用一些demo来验证理论。

Java虚拟机内存结构

我们先来看一下JVM一个大概的物理结构图(请注意,不叫内存模型):

http://static.cyblogs.com/QQ截图20191121231823.png

堆的划分

我们首先看一下官方地址对于运行时数据区域的一个划分:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

2.5. Run-Time Data Areas

堆存放:对象、数组 (官方证明: The heap is the run-time data area from which memory for all class instances and arrays is allocated. )

方法区

方法区存放:静态成员变量、常量、类的信息、常量池 (官方证明: It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.)

Java虚拟机栈

然后我们看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方法一
public void a(){
System.out.println("this is a methd");
b();
}
// 方法二
private void b() {
System.out.println("this is b methd");
c();
}
// 方法三
private void c() {
System.out.println("this is c methd");
}

简单画一个方法压栈与出栈的过程:

http://static.cyblogs.com/WX20200130-133454@2x.png

其实在这就可以看到,因为有相互调用的情况,这里利用栈的原理(FILO=frist in last out)。

  • a()入栈发现调用了b()b()入栈发现调用c()
  • c()执行完毕出栈,然后b()出栈,最后c()方法出栈;

如果一直压栈的话,如果是无穷的递归会怎么办?所以栈是需要规定深度的,对应的就是栈的大小-Xss来控制的,如果是超过大小是会OOM的。

The following exceptional conditions are associated with native method stacks:

  • If the computation in a thread requires a larger native method stack than is permitted, the Java Virtual Machine throws a StackOverflowError.
  • If native method stacks can be dynamically expanded and native method stack expansion is attempted but insufficient memory can be made available, or if insufficient memory can be made available to create the initial native method stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError.
本地方法栈

执行本地方法,native方法属于C的方法。 (官方证明:An implementation of the Java Virtual Machine may use conventional stacks, colloquially called “C stacks,” to support native methods (methods written in a language other than the Java programming language).)

程序计数器

因为在多线程的情况下,CPU通过轮询来去提高执行效率,线程之间会进行切换。如果从离开到下一次再进来,一定要知道上一次的一个状态。所以它应该是来干这件事情的。

(官方证明:The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter) register. )

类加载过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。

http://static.cyblogs.com/WX20200130-123334@2x.png

加载

加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  • 1、通过一个类的全限定名来获取其定义的二进制字节流。

  • 2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  • 3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。这里也就是Java的开放之处,给程序员更多的选择。

说到加载,不得不提到类加载器,下面就具体讲述下类加载器。

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

http://static.cyblogs.com/类加载器.jpg

  • 启动类加载器:Bootstrap ClassLoader,它负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
  • 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。

验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。

  • 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。这里需要对编译后的字节码文件结构有一个深入了解才能明白。

符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中。

直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。

2、字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下:

本类 → 接口 → 父接口 → …→ 父类 → 祖父类→… 我们看一段代码:

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.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:40 AM
*/
public class Super {
public static int m = 11;
static {
System.out.println("执行了super类静态语句块");
}
}
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:40 AM
*/
public class Super {
public static int m = 11;
static {
System.out.println("执行了super类静态语句块");
}
}
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:44 AM
*/
public class Child extends Father{
static {
System.out.println("执行了子类静态语句块");
}
}
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:27 AM
*/
public class ClassLoaderCase {

public static void main(String[] args) {
System.out.println(Child.m);
}
}

执行结果如下:

1
2
3
4
5
Connected to the target VM, address: '127.0.0.1:62747', transport: 'socket'
执行了super类静态语句块
执行了父类静态语句块
33
Disconnected from the target VM, address: '127.0.0.1:62747', transport: 'socket'
初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
这里简单说明下<clinit>()方法的执行规则:
1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

总结

类加载这块儿部分其实是非常重要的知识点,它让我们了解到Java的开放、包容以及一个类是如何被加载、被分配的。其中这也是为什么说Java是一次编译,到底执行的原因,其中包含了字节码部分,还有如何做一些字节码增强的技术,后续还有对于GC部分的知识。总之,越往基础越是重要!

参考地址

前言

最近线上出现了JVM 频繁FGC的问题,查询了很多GC相关的资料,做了一些整理翻译。文章比较长可以收藏后慢慢阅读。

一、什么是垃圾回收?(Garbage Collection)

一个垃圾回收器有一下三个职责

  • 分配内存
  • 确保有引用的对象能够在内存中保留。
  • 能够在正在执行的代码环境中回收已经死亡对象的内存。

这里提到的有引用是指存活的对象,后面会提到一些算法用来判断对象是否存活。不在有引用的对象将被认为是死亡的,也就是常说的垃圾garbage。找到并释放这些垃圾对象占用的空间的过程就被称作是垃圾回收garbage collection

垃圾回收可以解决很多内存分配的问题,但并不意味这全部。 比如:你可以不断地创建对象并保持对它们的引用直到没有可用的内存分配。垃圾回收本身就是一项非常复杂和消耗资源的过程。

二、理想的垃圾收集器需要哪些特性?

  1. 垃圾收集器必须是安全和全面的。这就意味着,存活的对象绝对不能被释放,相反垃圾对象在很少的垃圾回收循环里必须被回收。
  2. 垃圾回收必须是高效的,不允许出现正在运行的程序长时间暂停。
  3. 内存碎片整理,垃圾被收集以后内存会存在很多不连续的内存碎片,可能导致大对象无法分配到足够连续的内存。
  4. 扩展性,在多处理器系统、多线程应用中,内存分配和垃圾收集不能成为性能瓶颈。

三、设计选择

在设计一款垃圾收集器时,有一些选择可供选择:

  • 串行 vs 并行

串行收集,即使在多cpu环境中也是单线程处理垃圾收集工作。当使用并行收集时,垃圾收集任务就会被分为几子任务由不同的线程的执行,不仅仅是在多CPU环境中使用,在单核的系统中也可以使用,只是收集效果可能比使用串行效率还低。所以再单核的环境下尽量使用串行收集。

  • 并发 vs 暂停(stop-the-word)

并发是指垃圾收集线程和应用线程同时执行,并发和stop-the-word并不是互斥的,在一个执行一次垃圾收集的过程中两种情况都可能存在。例如CMSG1垃圾搜集器。并发式GC会并发执行其垃圾收集任务,但是,可能也会有一些步骤需要以stop-the-world方法执行,导致应用程序暂停。与并发式GC相比,Stop-the-world式的GC更简单.

  • 整理 vs 不整理 vs 复制

这个描述的主要是垃圾被收集以后,对内存碎片的处理方式。

整理、不整理,垃圾回收以后是否将存活的对象统一移动到一个地方。整理后的内存空间方便后续的对象分配内存,但是更消耗资源和时间,而不整理效率更高存在内存碎片的风险。

复制,首先将内存分割成两块一样大小的区域,垃圾收集后会将存活的对象拷贝到另一块不同的内存区域。这样做的好处是,拷贝后,源内存区域可以作为一块空的、立即可用的区域对待,方便后续的内存分配,但是这种方法的缺点是需要用额外的时间、空间来拷贝对象。

四、对象是否存活?

JVM要对回收一个对象必须知道这个对象是否存活,即是否有有效的引用?介绍几种判断对象是否死亡的算法。

  1. 引用计数法 给对象添加一个引用计数器,每次引用到它时引用计数器加一,当引用失效时引用计时器减一。当引用计数器为0时即表示当前对象可以被回收。 这个算法实现简单、判定效率也很高,但是无法处理循环引用的问题,即 A 对象引用了 B, B 对象也引用了 A,那么A、B都有引用,他们的应用计数都为一,但实际他们是可以被回收的。

  2. 可达性分析算法 算法规定了一些称为GC Root的根对象,当对象没有引用链到达这些GC Root时就被判定为可回收的对象。

    http://static.cyblogs.com/WX20200131-153715@2x.png

五、分代收集算法

当使用称为分代收集的技术时,内存将被分为不同的几代,即,会将对象按其年龄分别存储在不同的对象池中。例如,目前最广泛使用的是分代是将对象分为年轻代对象和老年代对象。

在分代内存管理中,使用不同算法对不同代的对象执行垃圾收集的工作,每种算法都是基于对某代对象的特性进行优化的。考虑到应用程序可以是用包括Java在内的不同的程序语言编写,分代垃圾收集使用了称为 弱代理论(weak generational hypothesis)的方法,具体描述如下:

大多数分配了内存的对象并不会存活太长时间,在处于年轻代时就会死掉; 很少有对象会从老年代变成年轻代。 年轻代对象的垃圾收集相对频繁一些,同时会也更有效率,更快一些,因为年轻代对象所占用的内存通常较小,也比较容易确定哪些对象是已经无法再被引用的。

当某些对象经过几次年轻代垃圾收集后依然存活,则这些对象会被 提升(promoted)到老年代。典型情况下,老年代所占用的内存会比年轻代大,而且还会随时渐渐慢慢增大。这样的结果是,对老年代的垃圾收集就不能频繁进行,而且执行时间也会长很多。

http://static.cyblogs.com/WX20200131-153928@2x.png

选择年轻代的垃圾收集算法时会更看重执行速度,因为年轻代的垃圾收集工作会频繁执行。另一方面,管理老年代的算法则更注重空间效率,因为老年代会占用堆中的大部分空间,这要求算法必须要处理好垃圾收集的工作,尽量降低堆中的垃圾内存的密度。

六、HotSpot 分代收集

主要介绍几种常见的垃圾收集器串行收集器(Serial Collector)并行垃圾收集器(Parallel Collector)并行整理收集器(Parallel Compacting Collector)并发标记清理垃圾收集器(Concurrent Mark-Sweep,CMS)Garbage-First (G1) 图中有连线的表示可以组合使用。

http://static.cyblogs.com/WX20200131-154328@2x.png

6.1 HotSpot中的代的划分

在Java HotSpot虚拟机中,内存被分为3代:年轻代、老年代和永生代(java8已经取消永久代)。大多数对象最初都是分配在年轻代内存中的,年轻代中对象经过几次垃圾收集后还存活的,会被转到老年代。一些体积比较大的对象在创建的时候可能就会在老年代中。 在年轻代中包含三个分区,一个 Eden区和两个 Survivor区(FROM、TO),如图所示。大部分对象最初是分配在Eden区中的(但是,如前面所述,一些较大的对象可能会直接分配在老年代中)。Survivor始终保持一个区域为空,当经过一定次数(-XX:MaxTenuringThreshold=n来指定默认值为15)的年轻代GC后依然存活的对象可以被晋升到老年代。

http://static.cyblogs.com/WX20200131-154416@2x.png

6.2 垃圾收集分类

当年轻代被填满时,开始执行年轻代的垃圾收集(minor collection)。当老年代被填满时,也会执行老年代垃圾收集(full GCmajor collection),一般来说,年轻代GC会先执行,执行多次young GC 会触发FGC,当然这不是绝对的,因为大对象会直接分配到老年代,当老年代的分配的内存不足时就可能触发频繁的FGC。目前除了CMS收集器外,在执行FGC的时候都会对整个堆进行垃圾收集。

6.3 串行收集器(Serial Collector)

使用串行收集器,年轻代和老年代的垃圾收集工作会串行完成(在单一CPU系统上),这时是stop-the-world模式的。即,当执行垃圾收集工作时,应用程序必须停止运行。

6.3.1 使用串行收集器的年轻代垃圾收集

图3展示了使用串行收集器的年轻代垃圾收集的执行过程。EdenSurvivor FROM区存活的对象会被拷贝到初始为空的另一个Survivor区(图中标识为To的区)中,这其中,那些体积过大以至于Survivor区装不下的对象会被直接拷贝到老年代中。相对于已经被拷贝到To区的对象,源Survivor区(图中标识为From的区)中的存活对象仍然比较年轻,而被拷贝到老年代中对象则相对年纪大一些。

http://static.cyblogs.com/WX20200131-154513@2x.png

在年轻代垃圾收集完成后,Eden区和From区会被清空,只有To区会继续持有存活的对象。此时,From区和To区在逻辑上交换,To区变成From区,原From区变成To区,如图4所示。

http://static.cyblogs.com/WX20200131-154558@2x.png

6.3.2 使用串行收集器的老年代垃圾收集

对于串行收集器,老年代和永生代会在进行垃圾收集时使用标记-清理-整理(Mark-Sweep-Compact)算法。在标记阶段,收集器会标识哪些对象是live状态的。清理阶段会跨代清理,标识垃圾对象。然后,收集器执行整理(sliding compaction),将存活对象移动到老年代内存空间的起始部分(永生代中情况于此类似),这样在老年代内存空间的尾部会产生一个大的连续空间。如图5所示。这种整理可以使用碰撞指针完成。

http://static.cyblogs.com/WX20200131-154708@2x.png

6.3.3 什么时候使用串行垃圾收集器

大多数运行在客户机上的应用程序会选择使用并行垃圾收集器,因为这些应用程序对低暂停时间并没有较高的要求。对于当今的硬件来说,串行垃圾收集器已经可以有效的管理许多具有64M堆的重要应用程序,并且执行一次完整垃圾收集也不会超过半秒钟。

6.3.4 选择串行垃圾收集器

在J2SE 5.0的发行版中,在非服务器类使用的机器上,默认选择的是串行垃圾收集器。在其他类型使用的机器上,可以通过添加参数 -XX:+UseSerialGC来显式的使用串行垃圾收集器。

6.4 并行垃圾收集器(Parallel Collector)

当前,很多的Java应用程序都跑在具有较大物理内存和多CPU的机器上。并行垃圾收集器,也称为吞吐量垃圾收集器,被用于垃圾收集工作。该收集器可以充分的利用多CPU的特点,避免一个CPU执行垃圾收集,其他CPU空闲的状态发生。

6.4.1 使用并行垃圾收集器的年轻代垃圾收集

这里,对年轻代的并行垃圾收集使用的串行垃圾收集算法的并行版本。它仍然会stop-the-world,拷贝对象,但执行垃圾收集时是使用多CPU并行进行的,减少了垃圾收集的时间损耗,提高了应用程序的吞吐量。图6展示了串行垃圾收集器和并行垃圾收集器对年轻代进行垃圾收集时的区别。

http://static.cyblogs.com/WX20200131-154849@2x.png

6.4.2 使用并行垃圾收集器的老年代垃圾收集

老年代中的并行垃圾收集使用了与串行垃圾收集器相同的串行 标记-清理-整理(mark-sweep-compact)算法。

6.4.3 什么时候使用并行垃圾收集器

当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,使用并行垃圾收集器会有较好的效果,因为虽不频繁,但可能时间会很长的老年代垃圾收集仍然会发生。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序更适合使用并行垃圾收集。

可能你会想用并行整理垃圾收集器(会在下一节介绍)来替代并行收集器,因为前者对所有代执行垃圾收集,而后者指对年轻代执行垃圾收集。

6.4.4 选择并行垃圾收集器

在J2SE 5.0的发行版中,若应用程序是运行在服务器类的机器上,则会默认使用并行垃圾收集器。在其他机器上,可以通过 -XX:+UseParallelGC参数来显式启用并行垃圾收集器。

6.5 并行整理整理收集器(Parallel Compacting Collector)

并行整理垃圾收集器是在J2SE 5.0 update 6中被引入的,其与并行垃圾收集器的区别在于,并行整理垃圾收集器使用了新的算法对老年代进行垃圾收集。注意,最终,并行整理垃圾收集器会取代并行垃圾收集器。

6.5.1 使用并行整理垃圾收集器的年轻代垃圾收集

年轻代中,并行整理垃圾收集器使用了与并行垃圾收集器相同的垃圾收集算法。

6.5.2 使用并行整理垃圾收集器的老年代垃圾收集

当使用并行整理垃圾收集时,老年代和永生代会使用stop-the-world的方式执行垃圾收集,大多数的并行模式都会使用移动整理(sliding compaction)。垃圾收集分为三个阶段。首先,将每一个代从逻辑上分为固定大小的区域。

在 标记阶段(mark phase),应用程序代码可以直接到达的live对象的初始集合会被划分到各个垃圾收集线程中,然后,所有的live对象会被并行标记。若一个对象被标记为live,则会更新该对象所在的区域中与该对象的大小和位置相关的数据。

在 总结阶段(summary phase)会对区域,而非单独的对象进行操作。由于之前的垃圾收集执行了整理,每一代的左侧部分的对象密度会较高,包含了大部分live对象。这些对象密度较高的区域被恢复为可用后,就不值得再花时间去整理了。所以,在总结阶段要做的第一件事是从最左端对象开始检查每个区域的live对象密度,直到找到了一个恢复其本区域和恢复其右侧的空间的开销都比较小时停止。找到的区域的左侧所有区域被称为dense prefix,不会再有对象被移动到这些区域里了。这个区域后侧的区域会被整理,清除所有已死的空间(清理垃圾对象占用的空间)。总结阶段会计算并保存每个整理后的区域中对象的新地址。注意,在当前实现中,总结阶段是串行的;当然总结阶段也可以实现为并行的,但相对于性能总结阶段的并行不及标记整理阶段来得重要。

在 整理阶段(compaction phase),垃圾收集线程使用总结阶段收集到的数据决定哪些区域课余填充数据,然后各个线程独立的将数据拷贝到这些区域中。这样就产生了一个底端对象密度大,连一端是一个很大的空区域块的堆。

6.5.3 什么时候使用并行整理垃圾收集器

相对于并行垃圾收集器,使用并行整理垃圾收集器对那些运行在多CPU的应用程序更有好处。此外,老年代垃圾收集的并行操作可以减少应用程序的暂停时间,对于那些对暂停时间有较高要求的应用程序来说,并行整理垃圾程序比并行垃圾收集更加适用。并行整理垃圾收集程序可能并不适用于那些与其他很多应用程序并存于一台机器的应用程序上,这种情况下,没有一个应用程序可以独占所有的CPU。在这样的机器上,需要考虑减少执行垃圾收集的线程数(使用-XX:ParallelGCThreads=n命令行选项),或者使用另一种垃圾收集器。

5.5.4 选择并行整理垃圾收集选项

若你想使用并行整理垃圾收集器,你必须显式指定-XX:+UseParallelOldGC命令行选项。

6.6 并发标记清理(Concurrent Mark-Sweep,CMS)垃圾收集器

对于很多应用程序来说,点到点的吞吐量并不如快速响应来的重要。典型情况下,年轻代的垃圾收集并不会引起较长时间的暂停。但是,老年代的垃圾收集,虽不频繁,却可能引起长时间的暂停,特别是使用了较大的堆的时候。为了应付这种情况,HotSpot JVM使用了CMS垃圾收集器,也称为低延迟(low-latency)垃圾收集器。

6.6.1 使用CMS垃圾收集器的年轻代垃圾收集

CMS垃圾收集器只对老年代进行收集,年轻代实际默认使用ParNewGC(一种年轻代的并行垃圾收集器)收集。

6.6.2 使用CMS垃圾收集器的老年代垃圾收集

大部分老年代的垃圾收集使用了CMS垃圾收集器,垃圾收集工作是与应用程序的执行并发进行的。

过程 描述
初始标记 标记老年代的存活对象,也可能包括年轻代的存活对象。暂停应用线程stop-the world
并发标记 和应用程序一起执行,标记应用程序运行过程中产生的存活的对象。
重标记 标记由于应用程序更新导致遗漏的对象,暂停应用线程stop-the world
并发清理 清理没有被标记的对象,不会进行内存整理,可能导致内存碎片问题。
复位 清理数据等待下一次收集执行。

图7展示了使用串行化的标记清理垃圾收集器和使用CMS垃圾收集器对老年代进行垃圾收集的区别。

http://static.cyblogs.com/WX20200131-155134@2x.png

不进行内存空间整理节省了时间,但是可用空间不再是连续的了,垃圾收集也不能简单的使用指针指向下一次可用来为对象分配内存的地址了。相反,这种情况下,需要使用可用空间列表。即,会创建一个指向未分配区域的列表,每次为对象分配内存时,会从列表中找到一个合适大小的内存区域来为新对象分配内存。这样做的结果是,老年代上的内存的分配比简单实用碰撞指针分配内存消耗大。这也会增加年轻代垃圾收集的额外负担,因为老年代中的大部分对象是在新生代垃圾收集的时候从新生代提升为老年代的。

使用CMS垃圾收集器的另一个缺点是它所需要的对空间比其他垃圾收集器大。在标记阶段,应用程序可以继续运行,可以继续分配内存,潜在的可能会持续的增大老年代的内存使用。此外,尽管垃圾收集器保证会在标记阶段标记出所有的live对象,但是在此阶段中,某些对象可能会变成垃圾对象,这些对象不会被回收,直到下一次垃圾收集执行。这些对象成为 浮动垃圾对象(floating garbage)。

最后,由于没有使用整理,会造成内存碎片的产生。为了解决这个问题,CMS垃圾收集器会跟踪常用对象的大小,预估可能的内存需要,可能会差分或合并内存块来满足需要。

与其他的垃圾收集器不同,当老年代被填满后,CMS垃圾收集器并不会对老年代进行垃圾收集。相反,它会在老年代被填满之前就执行垃圾收集工作。否则这就与串行或并行垃圾收集器一样会造成应用程序长时间地暂停。为了避免这种情况,CMS垃圾收集器会基于统计数字来来定执行垃圾收集工作的时间,这个统计数字涵盖了前几次垃圾收集的执行时间和老年代中新增内存分配的速率。当老年代中内存占用率超过了称为初始占用率的阀值后,会启动CMS垃圾收集器进行垃圾收集。初始占用率可以通过命令行选项-XX:CMSInitiatingOccupancyFraction=n进行设置,其中n是老年代占用率的百分比的值,默认为68。

总体来看,与平行垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间,但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间。

七、G1 收集器

G1最为新一代的垃圾回收器,设计之初就是为了取代CMS的。具备以下优点:

  • 并发执行垃圾收集
  • 很短的时间进行内存整理
  • GC的暂停时间可控
  • 不需要牺牲吞吐量
  • 不需要占用额外的java堆空间 什么需要使用G1收集器呢?
  • 频繁的FGC
  • 对象分配率或提升的速率差异很大。
  • 无法接受过长的GC暂停和内存整理时间

G1收集器和之前垃圾收集器拥有完全不同的内存结构,虽然从逻辑上也存在年轻代、老年代,但是物理空间上不在连续而是散列在内存中的一个个regions。内存空间分割成很多个相互独立的空间,被乘称作regions。当jvm启动时regins的大小就被确定了。jvm会创建大概2000个regions,每个region的大小在1M~32M之间。内存结构如下图:

http://static.cyblogs.com/WX20200131-155507@2x.png

7.1 使用G1进行年轻代收集

当年轻代GC被触发时,Eden中存活的对象将会被复制或者移动evacuated到幸存区的regions,在幸存复制的次数到达阈值的存活对象将会晋升到老年区。这个过程也是一个Stop-the-world 暂停。eden和survivor的大小将会在下一次年轻代GC前重新计算。

http://static.cyblogs.com/WX20200131-155627@2x.png

总而言之,G1在年轻代的手机行为包括以下几点:

  • 1、内存被分割成相互独立的大小相等的regions。

  • 2、年轻代散列在整个内存空间中,这样做的好处是当需要重新分配年轻代大小时会非常方便。

  • 3、stop-the-word 暂停所有线程。

  • 4、实际上也是并行回收算法,多线程并行收集。

  • 5、存活的对象将被复制到新的 survivor或老年代 regions。

7.2 使用G1进行老年代收集
过程 描述
初始标记 stop-the world 通常伴随在年轻代GC后面,标记有被老年代对象关联的幸存区 regions
扫描根 Regions 和应用线程并发执行,扫描幸存区regions
并发标记 并发标记整个堆存活的对象
重标记 完成整个堆的存活对象标记,使用snapshot-at-the-beginning (SATB)算法标记存活对象,该算法比CMS中使用的更快。stop-the-word
并行清理 并行清理死亡的的对象,返回空的regoins到可用列表。
复制 复制存活的对象到新的regions,This can be done with young generation regions which are logged as [GC pause (young)]. Or both young and old generation regions which are logged as [GC Pause (mixed)].
最佳实践

1、不要指定年轻代大小 -Xmn,G1每次垃圾收集结束后都会从新计算并设置年轻代的大小,将会影响全局的暂停时间 2、响应时间配置 -XX:MaxGCPauseMillis= 3、如何解决清理 or 复制失败问题,通过增加-XX:G1ReservePercent=n配置预留空间的大小,防止Evacuation Failure,默认值是10.也可以使用-XX:ConcGCThreads=n增加并发标记的线程数来解决

八、相关命令

选择垃圾回收

1
2
3
4
5
-XX:+UseSerialGC            串行垃圾收集器
-XX:+UseParallelGC 并行垃圾收集器
-XX:+UseParallelOldGC 并行整理垃圾收集器
-XX:+UseConcMarkSweepGC 并发标记清理(CMS)垃圾收集年轻代默认使用-XX:+ParNewGC
-XX:+UserG1GC

查看垃圾收集日志

1
2
3
-XX:+PrintGC                
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps

对大小配置:

1
2
3
4
5
6
-Xmsn                           堆最小值
-Xmxn 堆最大值
-Xmn 年轻代大小
-XX:NewRatio=n 年清代比例 Client_JVM=2 Server_JVM=8
-XX:SurvivorRatio=n 幸存去比例
-XX:MaxPermSize=n 依赖于不同平台的实现永生代的最大值(java 8 以后启用)。

G1可用的配置

1
2
3
4
5
6
7
8
9
10
-XX:+UseG1GC	Use the Garbage First (G1) Collector
-XX:MaxGCPauseMillis=n Sets a target for the maximum GC pause time. This is a soft goal, and the JVM will make its best effort to achieve it.
-XX:InitiatingHeapOccupancyPercent=n Percentage of the (entire) heap occupancy to start a concurrent GC cycle. It is used by GCs that trigger a concurrent GC cycle based on the occupancy of the entire heap, not just one of the generations (e.g., G1). A value of 0 denotes 'do constant GC cycles'. The default value is 45.
-XX:NewRatio=n Ratio of new/old generation sizes. The default value is 2.
-XX:SurvivorRatio=n Ratio of eden/survivor space size. The default value is 8.
-XX:MaxTenuringThreshold=n Maximum value for tenuring threshold. The default value is 15.
-XX:ParallelGCThreads=n Sets the number of threads used during parallel phases of the garbage collectors. The default value varies with the platform on which the JVM is running.
-XX:ConcGCThreads=n Number of threads concurrent garbage collectors will use. The default value varies with the platform on which the JVM is running.
-XX:G1ReservePercent=n Sets the amount of heap that is reserved as a false ceiling to reduce the possibility of promotion failure. The default value is 10.
-XX:G1HeapRegionSize=n

九、参考

0%