Day683.AprEndpoint组件:Tomcat APR提高I/O性能的秘密 -深入拆解 Tomcat & Jetty

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day683.AprEndpoint组件:Tomcat APR提高I/O性能的秘密 -深入拆解 Tomcat & Jetty相关的知识,希望对你有一定的参考价值。

AprEndpoint组件:Tomcat APR提高I/O性能的秘密

Hi,阿昌来也,今天学习关于的是针对Tomcat APR提高I/O性能的秘密,也就是AprEndpoint组件的实现。

使用 Tomcat 时,会在启动日志里看到这样的提示信息:

The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: ***

这句话的意思就是推荐你去安装 APR 库,可以提高系统性能。那什么是APR呢?

APR(Apache Portable Runtime Libraries)是 Apache 可移植运行时库,它是用 C 语言实现的,其目的是向上层应用程序提供一个跨平台的操作系统接口库。Tomcat 可以用它来处理包括文件和网络 I/O,从而提升性能。

Tomcat 支持的连接器有 NIO、NIO.2 和 APR。跟 NioEndpoint 一样,AprEndpoint 也实现了非阻塞 I/O,它们的区别是:

  • NioEndpoint 通过调用 Java 的 NIO API 来实现非阻塞 I/O
  • AprEndpoint 是通过 JNI 调用 APR 本地库而实现非阻塞 I/O 的。

那同样是非阻塞 I/O,为什么 Tomcat 会提示使用 APR 本地库的性能会更好呢?

这是因为在某些场景下,比如需要频繁与操作系统进行交互,Socket 网络通信就是这样一个场景,特别是如果你的 Web 应用使用了 TLS 来加密传输,TLS 协议在握手过程中有多次网络交互,在这种情况下 Java 跟 C 语言程序相比还是有一定的差距,而这正是 APR 的强项。

Tomcat 本身是 Java 编写的,为了调用 C 语言编写的 APR,需要通过 JNI 方式来调用。

JNI(Java Native Interface) 是 JDK 提供的一个编程接口,它允许 Java 程序调用其他语言编写的程序或者代码库,其实 JDK 本身的实现也大量用到 JNI 技术来调用本地 C 程序库。

一、AprEndpoint 工作过程

AprEndpoint 的工作过程

跟 NioEndpoint 的图很像,从左到右有 LimitLatch、Acceptor、Poller、SocketProcessor 和 Http11Processor,只是 AcceptorPoller 的实现和 NioEndpoint 不同。

1、Acceptor

Accpetor 的功能就是监听连接,接收并建立连接。它的本质就是调用了四个操作系统 API:Socket、Bind、Listen 和 Accept。

那 Java 语言如何直接调用 C 语言 API 呢?

答案就是通过 JNI。具体来说就是两步:

先封装一个 Java 类,在里面定义一堆用 native 关键字修饰的方法,像下面这样。

public class Socket 
  ...
  //用native修饰这个方法,表明这个函数是C语言实现
  public static native long create(int family, int type,
                                 int protocol, long cont)
                                 
  public static native int bind(long sock, long sa);
  
  public static native int listen(long sock, int backlog);
  
  public static native long accept(long sock)

接着用 C 代码实现这些方法,比如 Bind 函数就是这样实现的:

//注意函数的名字要符合JNI规范的要求
JNIEXPORT jint JNICALL 
Java_org_apache_tomcat_jni_Socket_bind(JNIEnv *e, jlong sock,jlong sa)
  
      jint rv = APR_SUCCESS;
      tcn_socket_t *s = (tcn_socket_t *)sock;
      apr_sockaddr_t *a = (apr_sockaddr_t *) sa;
  
        //调用APR库自己实现的bind函数
      rv = (jint)apr_socket_bind(s->sock, a);
      return rv;
  

JNI 的细节,你可以扩展阅读获得更多信息和例子

我们要注意的是函数名字要符合 JNI 的规范,以及 Java 和 C 语言如何互相传递参数,比如在 C 语言有指针,Java 没有指针的概念,所以在 Java 中用 long 类型来表示指针。

AprEndpoint 的 Acceptor 组件就是调用了 APR 实现的四个 API。

2、Poller

Acceptor 接收到一个新的 Socket 连接后,按照 NioEndpoint 的实现,它会把这个 Socket 交给 Poller 去查询 I/O 事件

AprEndpoint 也是这样做的,不过 AprEndpoint 的 Poller 并不是调用 Java NIO 里的 Selector 来查询 Socket 的状态,而是通过 JNI 调用 APR 中的 poll 方法,而 APR 又是调用了操作系统的 epoll API 来实现的。

这里有个特别的地方是在 AprEndpoint 中,我们可以配置一个叫deferAccept的参数,它对应的是 TCP 协议中的TCP_DEFER_ACCEPT,设置这个参数后,当 TCP 客户端有新的连接请求到达时,TCP 服务端先不建立连接,而是再等等,直到客户端有请求数据发过来时再建立连接。

这样的好处是服务端不需要用 Selector 去反复查询请求数据是否就绪。

这是一种 TCP 协议层的优化,不是每个操作系统内核都支持,因为 Java 作为一种跨平台语言,需要屏蔽各种操作系统的差异,因此并没有把这个参数提供给用户;

但是对于 APR 来说,它的目的就是尽可能提升性能,因此它向用户暴露了这个参数。


二、APR 提升性能的秘密

APR 连接器之所以能提高 Tomcat 的性能,除了 APR 本身是 C 程序库之外,还有哪些提速的秘密呢?

1、JVM 堆 VS 本地内存

Java 的类实例一般在 JVM 堆上分配,而 Java 是通过 JNI 调用 C 代码来实现 Socket 通信的,那么 C 代码在运行过程中需要的内存又是从哪里分配的呢?

C 代码能否直接操作 Java 堆?为了回答这些问题,我先来说说 JVM 和用户进程的关系。

如果你想运行一个 Java 类文件,可以用下面的 Java 命令来执行。

java my.class

这个命令行中的java其实是一个可执行程序,这个程序会创建 JVM 来加载和运行你的 Java 类。

操作系统会创建一个进程来执行这个java可执行程序,而每个进程都有自己的虚拟地址空间,JVM 用到的内存(包括堆、栈和方法区)就是从进程的虚拟地址空间上分配的。

请你注意的是,JVM 内存只是进程空间的一部分,除此之外进程空间内还有代码段、数据段、内存映射区、内核空间等。从 JVM 的角度看,JVM 内存之外的部分叫作本地内存,C 程序代码在运行过程中用到的内存就是本地内存中分配的。


Tomcat 的 Endpoint 组件在接收网络数据时需要预先分配好一块 Buffer,所谓的 Buffer 就是字节数组byte[],Java 通过 JNI 调用把这块 Buffer 的地址传给 C 代码,C 代码通过操作系统 API 读取 Socket 并把数据填充到这块 Buffer。

Java NIO API 提供了两种 Buffer 来接收数据:HeapByteBufferDirectByteBuffer,下面的代码演示了如何创建两种 Buffer。

//分配HeapByteBuffer
ByteBuffer buf = ByteBuffer.allocate(1024);

//分配DirectByteBuffer
ByteBuffer buf = ByteBuffer.allocateDirect(1024);

创建好 Buffer 后直接传给 Channel 的 read 或者 write 函数,最终这块 Buffer 会通过 JNI 调用传递给 C 程序。

//将buf作为read函数的参数
int bytesRead = socketChannel.read(buf);

那 HeapByteBuffer 和 DirectByteBuffer 有什么区别呢?

  • HeapByteBuffer 对象本身在 JVM 堆上分配,并且它持有的字节数组byte[]也是在 JVM 堆上分配。但是如果用 HeapByteBuffer 来接收网络数据,需要把数据从内核先拷贝到一个临时的本地内存,再从临时本地内存拷贝到 JVM 堆,而不是直接从内核拷贝到 JVM 堆上。这是为什么呢?这是因为数据从内核拷贝到 JVM 堆的过程中,JVM 可能会发生 GC,GC 过程中对象可能会被移动,也就是说 JVM 堆上的字节数组可能会被移动,这样的话 Buffer 地址就失效了。如果这中间经过本地内存中转,从本地内存到 JVM 堆的拷贝过程中 JVM 可以保证不做 GC。如果使用 HeapByteBuffer,你会发现 JVM 堆和内核之间多了一层中转,而 DirectByteBuffer 用来解决这个问题
  • DirectByteBuffer 对象本身在 JVM 堆上,但是它持有的字节数组不是从 JVM 堆上分配的,而是从本地内存分配的。DirectByteBuffer 对象中有个 long 类型字段 address,记录着本地内存的地址,这样在接收数据的时候,直接把这个本地内存地址传递给 C 程序,C 程序会将网络数据从内核拷贝到这个本地内存,JVM 可以直接读取这个本地内存,这种方式比 HeapByteBuffer 少了一次拷贝,因此一般来说它的速度会比 HeapByteBuffer 快好几倍。

Tomcat 中的 AprEndpoint 就是通过 DirectByteBuffer 来接收数据的,而 NioEndpoint 和 Nio2Endpoint 是通过 HeapByteBuffer 来接收数据的。

你可能会问,NioEndpoint 和 Nio2Endpoint 为什么不用 DirectByteBuffer 呢?

这是因为本地内存不好管理,发生内存泄漏难以定位,从稳定性考虑,NioEndpoint 和 Nio2Endpoint 没有去冒这个险。

2、sendfile

我们再来考虑另一个网络通信的场景,也就是静态文件的处理。

浏览器通过 Tomcat 来获取一个 html 文件,而 Tomcat 的处理逻辑无非是两步:

  1. 从磁盘读取 HTML 到内存。
  2. 将这段内存的内容通过 Socket 发送出去。

但是在传统方式下,有很多次的内存拷贝:

  • 读取文件时,首先是内核把文件内容读取到内核缓冲区。
  • 如果使用 HeapByteBuffer,文件数据从内核到 JVM 堆内存需要经过本地内存中转。
  • 同样在将文件内容推入网络时,从 JVM 堆到内核缓冲区需要经过本地内存中转。
  • 最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。

从下面的图你会发现这个过程有 6 次内存拷贝,并且 read 和 write 等系统调用将导致进程从用户态到内核态的切换,会耗费大量的 CPU 和内存资源。

而 Tomcat 的 AprEndpoint 通过操作系统层面的 sendfile 特性解决了这个问题,sendfile 系统调用方式非常简洁。

sendfile(socket, file, len);

它带有两个关键参数:Socket 和文件句柄。

将文件从磁盘写入 Socket 的过程只有两步:

  • 第一步:将文件内容读取到内核缓冲区。
  • 第二步:数据并没有从内核缓冲区复制到 Socket 关联的缓冲区,只有记录数据位置和长度的描述符被添加到 Socket 缓冲区中;接着把数据直接从内核缓冲区传递给网卡。

这个过程你可以看下面的图。


三、总结

对于一些需要频繁与操作系统进行交互的场景,比如网络通信,Java 的效率没有 C 语言高,特别是 TLS 协议握手过程中需要多次网络交互,这种情况下使用 APR 本地库能够显著提升性能。除此之外,APR 提升性能的秘密还有:

  • 通过 DirectByteBuffer 避免了 JVM 堆与本地内存之间的内存拷贝;

  • 通过 sendfile 特性避免了内核与应用之间的内存拷贝以及用户态和内核态的切换。

其实很多高性能网络通信组件,比如 Netty,都是通过 DirectByteBuffer 来收发网络数据的。由于本地内存难于管理,Netty 采用了本地内存池技术,感兴趣的同学可以深入了解一下。


以上是关于Day683.AprEndpoint组件:Tomcat APR提高I/O性能的秘密 -深入拆解 Tomcat & Jetty的主要内容,如果未能解决你的问题,请参考以下文章

别急着学框架,Tomcat基础先看看

DAY67-前端入门-javascript(十三) vue03

重修课程day60(django之form组件)

day85 ModuleForm

Day472.Netty 核心模块组件 -netty

Vue.js 2.0 由浅入深,第三天 day03