0%

前言

看了netty的一些课程,对课程中提到的零拷贝问题十分感兴趣,因为课程上讲的DMA以及MMAP技术了解的不是很清楚(原谅我本科操作系统和计算机组成原理学的特别垃圾),所以看了一些blog重新巩固一下知识。ps:一定要好好学习本科的专业课知识,现在真的是用到的时候才觉得自己太菜。下面的内容是我从多个博客总结的知识点。

零拷贝(Zero-copy)技术指在计算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域,从而可以减少上下文切换以及 CPU 的拷贝时间。它的作用是在数据报从网络设备到用户程序空间传递的过程中,减少数据拷贝次数,减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载。实现零拷贝用到的最主要技术是 DMA 数据传输技术和内存区域映射技术。

  • 零拷贝机制可以减少数据在内核缓冲区和用户进程缓冲区之间反复的 I/O 拷贝操作。
  • 零拷贝机制可以减少用户进程地址空间和内核地址空间之间因为上下文切换而带来的 CPU 开销。

零拷贝主要针对大型文件的传输、下载、上传功能,能够加快传输速率,减少cpu的无用功。

一. 物理内存和虚拟内存

由于操作系统的进程与进程之间是共享CPU和内存资源的,因此需要一套完整的内存管理机制防止进程之间的内存泄漏问题。为了更加有效的管理内存并较少出错,现代操作系统提供一种对主存的抽象概念,即是虚拟内存。虚拟内存为每个进程提供了一个一致的、私有的地址空间,他让每个进程产生了一种自己在独享主存的错觉。

可以想象如果没有使用虚拟内存技术会遇到很多问题,首先是多个进程对内存使用会导致内存的碎片化,其次由于各个进程没有独立的地址空间,一个进程由于执行错误的指令或者是恶意代码,可以直接修改其他进程的数据,甚至修改内核地址空间的数据,这个是操作系统不愿看到。所以操作系统希望能够自动内存系统,不希望用户能够直接访问内存。

1. 物理内存

物理内存(Physical memory)是相对于虚拟内存(Virtual Memory)而言的。物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。在应用中,自然是顾名思义,物理上,真实存在的插在主板内存槽上的内存条的容量的大小。

2. 虚拟内存

虚拟内存是计算机系统内存管理的一种技术。 它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。而实际上,虚拟内存通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换,加载到物理内存中来。 目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux 系统的交换空间等等。

虚拟内存地址和用户进程紧密相关,一般来说不同进程里的同一个虚拟地址指向的物理地址是不一样的,所以离开进程谈虚拟内存没有任何意义。每个进程所能使用的虚拟地址大小和 CPU 位数有关。在 32 位的系统上,虚拟地址空间大小是 2 ^ 32 = 4G,在 64位系统上,虚拟地址空间大小是 2 ^ 64 = 2 ^ 34G,而实际的物理内存可能远远小于虚拟内存的大小。每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。下面给出两个进程 A、B 各自的虚拟内存空间以及对应的物理内存之间的地址映射示意图:

upload successful

当进程执行一个程序时,需要先从先内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。

其中页表(Page Table)可以简单的理解为单个内存映射(Memory Mapping)的链表(当然实际结构很复杂),里面的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的地址空间(物理内存或者磁盘存储空间)。每个进程拥有自己的页表(Page Table),和其它进程的页表(Page Table)没有关系。
通过上面的介绍,我们可以简单的将用户进程申请并访问物理内存(或磁盘存储空间)的过程总结如下:

  1. 用户进程向操作系统发出内存申请请求
  2. 系统会检查进程的虚拟地址空间是否被用完,如果有剩余,给进程分配虚拟地址
  3. 系统为这块虚拟地址创建的内存映射(Memory Mapping),并将它放进该进程的页表(Page Table)
  4. 系统返回虚拟地址给用户进程,用户进程开始访问该虚拟地址
  5. CPU 根据虚拟地址在此进程的页表(Page Table)中找到了相应的内存映射(Memory Mapping),但是这个内存映射(Memory Mapping)没有和物理内存关联,于是产生缺页中断
  6. 操作系统收到缺页中断后,分配真正的物理内存并将它关联到页表相应的内存映射(Memory Mapping)。中断处理完成后 CPU 就可以访问内存了
  7. 当然缺页中断不是每次都会发生,只有系统觉得有必要延迟分配内存的时候才用的着,也即很多时候在上面的第 3 步系统会分配真正的物理内存并和内存映射(Memory Mapping)进行关联。

在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:

  • 地址空间:提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单
  • 进程隔离:不同进程的虚拟地址之间没有关系,所以一个进程的操作不会对其它进程造成影响
  • 数据保护:每块虚拟内存都有相应的读写属性,这样就能保护程序的代码段不被修改,数据块不能被执行等,增加了系统的安全性
  • 内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。这样可以做到物理内存延时分配,只有在需要读相应的文件的时候,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时候又可以将这部分内存清空掉,提高物理内存利用效率,并且所有这些对应用程序是都透明的
  • 共享内存:比如动态库只需要在内存中存储一份,然后将它映射到不同进程的虚拟地址空间中,让进程觉得自己独占了这个文件。进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享
  • 物理内存管理:物理地址空间全部由操作系统管理,进程无法直接分配和回收,从而系统可以更好的利用内存,平衡进程间对内存的需求

二. 内核空间和用户空间

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。 在 Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。

内核进程和用户进程所占的虚拟内存比例是 1:3,而 Linux x86_32 系统的寻址空间(虚拟存储空间)为 4G(2的32次方),将最高的 1G 的字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)供内核进程使用,称为内核空间;而较低的 3G 的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个用户进程使用,称为用户空间。下图是一个进程的用户空间和内核空间的内存布局:

upload successful

1. 内核空间

内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。上图左侧区域为内核进程对应的虚拟内存,按访问权限可以分为进程私有和进程共享两块区域。

  • 进程私有的虚拟内存:每个进程都有单独的内核栈、页表、task 结构以及 mem_map 结构等。
  • 进程共享的虚拟内存:属于所有进程共享的内存区域,包括物理存储器、内核数据和内核代码区域。

2. 用户空间

每个普通的用户进程都有一个单独的用户空间,处于用户态的进程不能访问内核空间中的数据,也不能直接调用内核函数的 ,因此要进行系统调用的时候,就要将进程切换到内核态才行。用户空间包括以下几个内存区域:

  • 运行时栈:由编译器自动释放,存放函数的参数值,局部变量和方法返回值等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存储到栈顶,调用结束后调用信息会被弹出弹出并释放掉内存。栈区是从高地址位向低地址位增长的,是一块连续的内在区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。

  • 运行时堆:用于存放进程运行中被动态分配的内存段,位于 BSS 和栈中间的地址位。由开发人员申请分配(malloc)和释放(free)。堆是从低地址位向高地址位增长,采用链式存储结构。频繁地 malloc/free 造成内存空间的不连续,产生大量碎片。当申请堆空间时,库函数按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

  • 代码段:存放 CPU 可以执行的机器指令,该部分内存只能读不能写。通常代码区是共享的,即其它执行程序可调用它。假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。

  • 未初始化的数据段:存放未初始化的全局变量,BSS 的数据在程序开始执行之前被初始化为 0 或 NULL。

  • 已初始化的数据段:存放已初始化的全局变量,包括静态全局变量、静态局部变量以及常量。

  • 内存映射区域:例如将动态库,共享内存等虚拟空间的内存映射到物理空间的内存,一般是 mmap 函数所分配的虚拟内存空间。

三. Linux的内部层级结构

内核态可以执行任意命令,调用系统的一切资源,而用户态只能执行简单的运算,不能直接调用系统资源。用户态必须通过系统接口(System Call),才能向内核发出指令。比如,当用户进程启动一个 bash 时,它会通过 getpid() 对内核的 pid 服务发起系统调用,获取当前用户进程的 ID;当用户进程通过 cat 命令查看主机配置时,它会对内核的文件子系统发起系统调用。

upload successful

  • 内核空间可以访问所有的 CPU 指令和所有的内存空间、I/O 空间和硬件设备。
  • 用户空间只能访问受限的资源,如果需要特殊权限,可以通过系统调用获取相应的资源。
  • 用户空间允许页面中断,而内核空间则不允许。
  • 内核空间和用户空间是针对线性地址空间的。
  • x86 CPU中用户空间是 0 - 3G 的地址范围,内核空间是 3G - 4G 的地址范围。x86_64 CPU 用户空间地址范围为0x0000000000000000 – 0x00007fffffffffff,内核地址空间为 0xffff880000000000 - 最大地址。
  • 所有内核进程(线程)共用一个地址空间,而用户进程都有各自的地址空间。

有了用户空间和内核空间的划分后,Linux 内部层级结构可以分为三部分,从最底层到最上层依次是硬件、内核空间和用户空间,如下图所示:

upload successful

四. Linux I/O读写方式

Linux 提供了轮询、I/O 中断以及 DMA 传输这 3 种磁盘与主存之间的数据传输机制。其中轮询方式是基于死循环对 I/O 端口进行不断检测。I/O 中断方式是指当数据到达时,磁盘主动向 CPU 发起中断请求,由 CPU 自身负责数据的传输过程。 DMA 传输则在 I/O 中断的基础上引入了 DMA 磁盘控制器,由 DMA 磁盘控制器负责数据的传输,降低了 I/O 中断操作对 CPU 资源的大量消耗。

1. I/O 中断原理

在 DMA 技术出现之前,应用程序与磁盘之间的 I/O 操作都是通过 CPU 的中断完成的。每次用户进程读取磁盘数据时,都需要 CPU 中断,然后发起 I/O 请求等待数据读取和拷贝完成,每次的 I/O 中断都导致 CPU 的上下文切换。

upload successful

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。
  2. CPU 在接收到指令以后对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区。
  3. 数据准备完成以后,磁盘向 CPU 发起 I/O 中断。
  4. CPU 收到 I/O 中断以后将磁盘缓冲区中的数据拷贝到内核缓冲区,然后再从内核缓冲区拷贝到用户缓冲区。
  5. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

2. DMA传输原理

DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。也就是说,基于 DMA 访问方式,系统主内存于硬盘或网卡之间的数据传输可以绕开 CPU 的全程调度。目前大多数的硬件设备,包括磁盘控制器、网卡、显卡以及声卡等都支持 DMA 技术。

upload successful

整个数据传输操作在一个 DMA 控制器的控制下进行的。CPU 除了在数据传输开始和结束时做一点处理外(开始和结束时候要做中断处理),在传输过程中 CPU 可以继续进行其他的工作。这样在大部分时间里,CPU 计算和 I/O 操作都处于并行操作,使整个计算机系统的效率大大提高。

upload successful

有了 DMA 磁盘控制器接管数据读写请求以后,CPU 从繁重的 I/O 操作中解脱,数据读取操作的流程如下:

  1. 用户进程向 CPU 发起 read 系统调用读取数据,由用户态切换为内核态,然后一直阻塞等待数据的返回。
  2. CPU 在接收到指令以后对 DMA 磁盘控制器发起调度指令。
  3. DMA 磁盘控制器对磁盘发起 I/O 请求,将磁盘数据先放入磁盘控制器缓冲区,CPU 全程不参与此过程。
  4. 数据读取完成后,DMA 磁盘控制器会接受到磁盘的通知,将数据从磁盘控制器缓冲区拷贝到内核缓冲区。
  5. DMA 磁盘控制器向 CPU 发出数据读完的信号,由 CPU 负责将数据从内核缓冲区拷贝到用户缓冲区。
  6. 用户进程由内核态切换回用户态,解除阻塞状态,然后等待 CPU 的下一个执行时间钟。

五. 传统I/O方式

为了更好的理解零拷贝解决的问题,我们首先了解一下传统 I/O 方式存在的问题。在 Linux 系统中,传统的访问方式是通过 write() 和 read() 两个系统调用实现的,通过 read() 函数读取文件到到缓存区中,然后通过 write() 方法把缓存中的数据输出到网络端口,伪代码如下:

[++]
1
2
read(file_fd, tmp_buf, len);
write(socket_fd, tmp_buf, len);

下图分别对应传统 I/O 操作的数据读写流程,整个过程涉及 2 次 CPU 拷贝、2 次 DMA 拷贝总共 4 次拷贝,以及 4 次上下文切换,下面简单地阐述一下相关的概念。

upload successful

  1. 上下文切换:当用户程序向内核发起系统调用时,CPU 将用户进程从用户态切换到内核态;当系统调用返回时,CPU 将用户进程从内核态切换回用户态。
  2. CPU拷贝:由 CPU 直接处理数据的传送,数据拷贝时会一直占用 CPU 的资源。
  3. DMA拷贝:由 CPU 向DMA磁盘控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,从而减轻了 CPU 资源的占有率。

1. 传统的读操作

当应用程序执行 read 系统调用读取一块数据的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据;如果数据不存在,则先将数据从磁盘加载数据到内核空间的读缓存(read buffer)中,再从读缓存拷贝到用户进程的页内存中。

[++]
1
read(file_fd, tmp_buf, len);

2. 传统的写操作

当应用程序准备好数据,执行 write 系统调用发送网络数据时,先将数据从用户空间的页缓存拷贝到内核空间的网络缓冲区(socket buffer)中,然后再将写缓存中的数据拷贝到网卡设备完成数据发送。

[++]
1
write(socket_fd, tmp_buf, len);

基于传统的 I/O 写入方式,write() 系统调用会触发 2 次上下文切换,1 次 CPU 拷贝和 1 次 DMA 拷贝,用户程序发送网络数据的流程如下:

  1. 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 将用户缓冲区(user buffer)中的数据拷贝到内核空间(kernel space)的网络缓冲区(socket buffer)。
  3. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  4. 上下文从内核态(kernel space)切换回用户态(user space),write 系统调用执行返回。

六. 零拷贝方式

在 Linux 中零拷贝技术主要有 3 个实现思路:用户态直接 I/O、减少数据拷贝次数以及写时复制技术。

  • 用户态直接 I/O:应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输。这种方式依旧存在用户空间和内核空间的上下文切换,硬件上的数据直接拷贝至了用户空间,不经过内核空间。因此,直接 I/O 不存在内核空间缓冲区和用户空间缓冲区之间的数据拷贝。
  • 减少数据拷贝次数:在数据传输过程中,避免数据在用户空间缓冲区和系统内核空间缓冲区之间的CPU拷贝,以及数据在系统内核空间内的CPU拷贝,这也是当前主流零拷贝技术的实现思路。
  • 写时复制技术:写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么将其拷贝到自己的进程地址空间中,如果只是数据读取操作则不需要进行拷贝操作。

1. 用户态直接I/O

用户态直接 I/O 使得应用进程或运行在用户态(user space)下的库函数直接访问硬件设备,数据直接跨过内核进行传输,内核在数据传输过程除了进行必要的虚拟存储配置工作之外,不参与任何其他工作,这种方式能够直接绕过内核,极大提高了性能。

upload successful
用户态直接 I/O 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。其次,这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的浪费,解决方案是配合异步 I/O 使用。

2. mmap+write

一种零拷贝方式是使用 mmap + write 代替原来的 read + write 方式,减少了 1 次 CPU 拷贝操作。mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址,mmap + write 的伪代码如下:

[++]
1
2
tmp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);

进程中的虚拟内存
进程对内存的读写不是直接使用物理内存地址,而是基于虚拟地址。每个进程运行时,操作系统都会为其创建一个私有的虚拟内存,存放进程运行时代码和数据。操作系统通过内存管理机制,将虚拟内存映射到物理内存。
虚拟内存使得操作系统可以同时支持多个运行进程安全共享物理内存,防止进程之间的不安全读写。

虚拟内存分为两部分:用户空间(User space)和内核空间(kernel space)。用户空间存放用户代码和用户数据;内核空间存放操作系统代码。
前面说过,每个进程有自己私有的虚拟内存,不同进程的虚拟内存中的相同的地址,被映射到物理内存中的不同位置。但是内核空间是个例外,所有进程是共享内核空间的,也就是对不同进程来说,它们内核空间内的内容、地址映射实际上都是相同的。

缺页中断
操作系统为每个进程的虚拟内存和物理内存之间建立了一张映射表,需要注意的是,虚拟内存中的内容只会一部分被装载到物理内存中。

当进程访问的虚拟地址对应的内容不在物理内存时,操作系统会触发一个缺页中断,将物理内存中不用的内容暂时置换到磁盘,将需要的内容读取道物理内存。通过这种管理模式,我们可以在同时运行多个进程的情况下,让每个进程觉得自己在独享整个内存空间。

mmap主要也是依靠缺页中断来获取磁盘文件。

对于内存映射,其实是文件到内存空间的映射,对于用户应用程序来说,和文件建立映射关系的是虚拟地址空间,而不是物理内存或Heap。

当我们建立一个2g大小的映射时,并不是在heap,更不是在物理内存中分配了这么大的空间,仅仅是在虚拟地址空间中划出了这么大一个区域而已,好比是做个记号。

应用访问内存映射区域时,操作系统会把虚拟的地址映射成真正的物理内存地址和底层文件的偏移量。如果应用访问的虚拟地址对应的文件内容尚未被装入内存,操作系统通过缺页中断,将内存中的部分内容交换出去,腾出空间将文件的内容读取到内存。

mmap
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了用户程序对文件的操作而不必再调用read,write等系统调用函数(read,write等操作是对用户空间来说)。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。从而可以看出mmap其实是映射在用户空间的。

upload successful
这里的内存并不是实际的物理内存,而是指进程的虚拟内存地址。

mmap原理

  1. 进程启动映射过程,进程在用户空间调用mmap函数,在虚拟地址空间中为映射创建虚拟映射区域。
  2. 调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
  3. 进程访问分配的虚拟的地址区间中的某个地址,引发缺页异常,实现文件内容到物理内存的拷贝。

应用程序通过虚拟地址查询页表,发现这一段地址并不在物理内存中,所以使用缺页中断把磁盘中的数据读入到物理内存中(也就是读入到页缓存,按虚拟分区是在内核空间中),而且用户程序已经有了虚拟映射地址,可以通过这个映射地址访问到页缓存中的数据。

所以mmap的零拷贝关键在于不再需要把数据从内核内存空间拷贝到用户内存空间。

再说下程序从磁盘中读取然后发送到网络中的过程。

upload successful

基于 mmap + write 系统调用的零拷贝方式,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 程序启动,调用mmap,创建好虚拟映射区域,并且文件物理地址和进程虚拟地址的一一映射关系。
  2. 用户程序读取文件数据时,通过页表查询,发现物理内存上没有该数据,那么就要系统调用,从用户态切换到内核态(第一次上下文切换)
  3. 通过DMA把磁盘中的数据读取到页缓存中(内核缓存区),然后切换到用户态(第二次上下文切换),因为用户空间已经有了虚拟映射地址,所以他是可以找到缓存在页缓存中的数据的,也就不需要再拷贝到用户空间去了。
  4. 接下来用户程序就要调用write方法,把数据写入网卡中。用户态再次切换到内核态(第三次上下文切换),然后使用CPU拷贝,把内核空间中的数据拷贝到Socket缓冲区。
  5. 再利用DMA技术把数据拷贝到网卡进行数据传输。
  6. 最后再切换回用户态(第四次上下文切换)

mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。
mmap 的拷贝虽然减少了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止。

3. sendfile

sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数,它的伪代码如下:

[++]
1
sendfile(socket_fd, file_fd, len);

通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。与 mmap 内存映射方式不同的是, sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。

upload successful
基于 sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
  3. CPU 将读缓冲区(read buffer)中的数据拷贝到的网络缓冲区(socket buffer)。
  4. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  5. 上下文从内核态(kernel space)切换回用户态(user space),sendfile 系统调用执行返回。

相比较于 mmap 内存映射的方式,sendfile 少了 2 次上下文切换,但是仍然有 1 次 CPU 拷贝操作。sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。

4.sendfile + DMA gather copy

Linux 2.4 版本的内核对 sendfile 系统调用进行修改,为 DMA 拷贝引入了 gather 操作。它将内核空间(kernel space)的读缓冲区(read buffer)中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作,sendfile 的伪代码如下:

[++]
1
sendfile(socket_fd, file_fd, len);

在硬件的支持下,sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝,这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。

upload successful
基于 sendfile + DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
  3. CPU 把读缓冲区(read buffer)的文件描述符(file descriptor)和数据长度拷贝到网络缓冲区(socket buffer)。
  4. 基于已拷贝的文件描述符(file descriptor)和数据长度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区(read buffer)拷贝到网卡进行数据传输。
  5. 上下文从内核态(kernel space)切换回用户态(user space),sendfile 系统调用执行返回。

sendfile + DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。

5.splice

sendfile 只适用于将数据从文件拷贝到 socket 套接字上,同时需要硬件的支持,这也限定了它的使用范围。Linux 在 2.6.17 版本引入 splice 系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。splice 的伪代码如下:

[++]
1
splice(fd_in, off_in, fd_out, off_out, len, flags);

splice 系统调用可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作。

upload successful

基于 splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:

  1. 用户进程通过 splice() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
  3. CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)。
  4. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  5. 上下文从内核态(kernel space)切换回用户态(user space),splice 系统调用执行返回。

splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备。

6.缓冲区共享

缓冲区共享方式完全改写了传统的 I/O 操作,因为传统 I/O 接口都是基于数据拷贝进行的,要避免拷贝就得去掉原先的那套接口并重新改写,所以这种方法是比较全面的零拷贝技术,目前比较成熟的一个方案是在 Solaris 上实现的 fbuf(Fast Buffer,快速缓冲区)。

fbuf 的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到用户空间(user space)和内核态(kernel space),内核和用户共享这个缓冲区池,这样就避免了一系列的拷贝操作。

upload successful
缓冲区共享的难度在于管理共享缓冲区池需要应用程序、网络软件以及设备驱动程序之间的紧密合作,而且如何改写 API 目前还处于试验阶段并不成熟。

七. Linux零拷贝对比

无论是传统 I/O 拷贝方式还是引入零拷贝的方式,2 次 DMA Copy 是都少不了的,因为两次 DMA 都是依赖硬件完成的。下面从 CPU 拷贝次数、DMA 拷贝次数以及系统调用几个方面总结一下上述几种 I/O 拷贝方式的差别。

upload successful

八. Java NIO零拷贝实现

在 Java NIO 中的通道(Channel)就相当于操作系统的内核空间(kernel space)的缓冲区,而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer)。

通道(Channel)是全双工的(双向传输),它既可能是读缓冲区(read buffer),也可能是网络缓冲区(socket buffer)。
缓冲区(Buffer)分为堆内存(HeapBuffer)和堆外内存(DirectBuffer),这是通过 malloc() 分配出来的用户态内存。

堆外内存(DirectBuffer)在使用后需要应用程序手动回收,而堆内存(HeapBuffer)的数据在 GC 时可能会被自动回收。因此,在使用 HeapBuffer 读写数据时,为了避免缓冲区数据因为 GC 而丢失,NIO 会先把 HeapBuffer 内部的数据拷贝到一个临时的 DirectBuffer 中的本地内存(native memory),这个拷贝涉及到 sun.misc.Unsafe.copyMemory() 的调用,背后的实现原理与 memcpy() 类似。 最后,将临时生成的 DirectBuffer 内部的数据的内存地址传给 I/O 调用函数,这样就避免了再去访问 Java 对象处理 I/O 读写。

1. MappedByteBuffer

MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。抽象方法 map() 方法在 FileChannel 中的定义如下:

[]
1
2
3
public abstract MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException;

  1. mode:限定内存映射区域(MappedByteBuffer)对内存映像文件的访问模式,包括只可读(READ_ONLY)、可读可写(READ_WRITE)和写时拷贝(PRIVATE)三种模式。
  2. position:文件映射的起始地址,对应内存映射区域(MappedByteBuffer)的首地址。
  3. size:文件映射的字节长度,从 position 往后的字节数,对应内存映射区域(MappedByteBuffer)的大小

MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三个重要的方法:

  • fore():对于处于 READ_WRITE 模式下的缓冲区,把对缓冲区内容的修改强制刷新到本地文件。
  • load():将缓冲区的内容载入物理内存中,并返回这个缓冲区的引用。
  • isLoaded():如果缓冲区的内容在物理内存中,则返回 true,否则返回 false。

下面给出一个利用 MappedByteBuffer 对文件进行读写的使用示例:

[]
1
2
3
4
private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";
private final static String FILE_NAME = "/mmap.txt";
private final static String CHARSET = "UTF-8";

写文件数据:打开文件通道 fileChannel 并提供读权限、写权限和数据清空权限,通过 fileChannel 映射到一个可写的内存缓冲区 mappedByteBuffer,将目标数据写入 mappedByteBuffer,通过 force() 方法把缓冲区更改的内容强制写入本地文件。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void writeToFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_WRITE, 0, bytes.length);
if (mappedByteBuffer != null) {
mappedByteBuffer.put(bytes);
mappedByteBuffer.force();
}
} catch (IOException e) {
e.printStackTrace();
}
}

读文件数据:打开文件通道 fileChannel 并提供只读权限,通过 fileChannel 映射到一个只可读的内存缓冲区 mappedByteBuffer,读取 mappedByteBuffer 中的字节数组即可得到文件数据。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void readFromFileByMappedByteBuffer() {
Path path = Paths.get(getClass().getResource(FILE_NAME).getPath());
int length = CONTENT.getBytes(Charset.forName(CHARSET)).length;
try (FileChannel fileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(READ_ONLY, 0, length);
if (mappedByteBuffer != null) {
byte[] bytes = new byte[length];
mappedByteBuffer.get(bytes);
String content = new String(bytes, StandardCharsets.UTF_8);
assertEquals(content, "Zero copy implemented by MappedByteBuffer");
}
} catch (IOException e) {
e.printStackTrace();
}
}

下面介绍 map() 方法的底层实现原理。map() 方法是 java.nio.channels.FileChannel 的抽象方法,由子类 sun.nio.ch.FileChannelImpl.java 实现,下面是和内存映射相关的核心代码:

[]
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
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
throw new IOException("Map failed", y);
}
}

int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
} else {
return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
}
}

map() 方法通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。

  1. 文件映射需要在 Java 堆中创建一个 MappedByteBuffer 的实例。如果第一次文件映射导致 OOM,则手动触发垃圾回收,休眠 100ms 后再尝试映射,如果失败则抛出异常。
  2. 通过 Util 的 newMappedByteBuffer (可读可写)方法或者 newMappedByteBufferR(仅读) 方法方法反射创建一个 DirectByteBuffer 实例,其中 DirectByteBuffer 是 MappedByteBuffer 的子类。

map() 方法返回的是内存映射区域的起始地址,通过(起始地址 + 偏移量)就可以获取指定内存的数据。这样一定程度上替代了 read() 或 write() 方法,底层直接采用 sun.misc.Unsafe 类的 getByte() 和 putByte() 方法对数据进行读写。

[]
1
private native long map0(int prot, long position, long mapSize) throws IOException;

上面是本地方法(native method)map0 的定义,它通过 JNI(Java Native Interface)调用底层 C 的实现,这个 native 函数(Java_sun_nio_ch_FileChannelImpl_map0)的实现位于 JDK 源码包下的 native/sun/nio/ch/FileChannelImpl.c 这个源文件里面。

[]
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
JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len)
{
void *mapAddress = 0;
jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
jint fd = fdval(env, fdo);
int protections = 0;
int flags = 0;

if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
}

mapAddress = mmap64(
0, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */

if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -1, "Map failed");
}

return ((jlong) (unsigned long) mapAddress);
}


可以看出 map0() 函数最终是通过 mmap64() 这个函数对 Linux 底层内核发出内存映射的调用, mmap64() 函数的原型如下:

[]
1
2
#include <sys/mman.h>
void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t offset);

下面详细介绍一下 mmap64() 函数各个参数的含义以及参数可选值:

  • addr:文件在用户进程空间的内存映射区中的起始地址,是一个建议的参数,通常可设置为 0 或 NULL,此时由内核去决定真实的起始地址。当 flags 为 MAP_FIXED 时,addr 就是一个必选的参数,即需要提供一个存在的地址。
  • len:文件需要进行内存映射的字节长度
    • prot:控制用户进程对内存映射区的访问权限
    • PROT_READ:读权限
    • PROT_WRITE:写权限
    • PROT_EXEC:执行权限
    • PROT_NONE:无权限

flags:控制内存映射区的修改是否被多个进程共享

MAP_PRIVATE:对内存映射区数据的修改不会反映到真正的文件,数据修改发生时采用写时复制机制
MAP_SHARED:对内存映射区的修改会同步到真正的文件,修改对共享此内存映射区的进程是可见的
MAP_FIXED:不建议使用,这种模式下 addr 参数指定的必须的提供一个存在的 addr 参数

fd:文件描述符。每次 map 操作会导致文件的引用计数加 1,每次 unmap 操作或者结束进程会导致引用计数减 1
offset:文件偏移量。进行映射的文件位置,从文件起始地址向后的位移量

下面总结一下 MappedByteBuffer 的特点和不足之处:

  • MappedByteBuffer 使用是堆外的虚拟内存,因此分配(map)的内存大小不受 JVM 的 -Xmx 参数限制,但是也是有大小限制的。
  • 如果当文件超出 Integer.MAX_VALUE 字节限制时,可以通过 position 参数重新 map 文件后面的内容。
  • MappedByteBuffer 在处理大文件时性能的确很高,但也存内存占用、文件关闭不确定等问题,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。
  • MappedByteBuffer 提供了文件映射内存的 mmap() 方法,也提供了释放映射内存的 unmap() 方法。然而 unmap() 是 FileChannelImpl 中的私有方法,无法直接显示调用。因此,用户程序需要通过 Java 反射的调用 sun.misc.Cleaner 类的 clean() 方法手动释放映射占用的内存区域。
[]
1
2
3
4
5
6
7
8
9
10
11
12
public static void clean(final Object buffer) throws Exception {
AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
try {
Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
getCleanerMethod.setAccessible(true);
Cleaner cleaner = (Cleaner) getCleanerMethod.invoke(buffer, new Object[0]);
cleaner.clean();
} catch(Exception e) {
e.printStackTrace();
}
});
}

2. DirectByteBuffer

DirectByteBuffer 的对象引用位于 Java 内存模型的堆里面,JVM 可以对 DirectByteBuffer 的对象进行内存分配和回收管理,一般使用 DirectByteBuffer 的静态方法 allocateDirect() 创建 DirectByteBuffer 实例并分配内存。

[]
1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

DirectByteBuffer 内部的字节缓冲区位在于堆外的(用户态)直接内存,它是通过 Unsafe 的本地方法 allocateMemory() 进行内存分配,底层调用的是操作系统的 malloc() 函数

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

除此之外,初始化 DirectByteBuffer 时还会创建一个 Deallocator 线程,并通过 Cleaner 的 freeMemory() 方法来对直接内存进行回收操作,freeMemory() 底层调用的是操作系统的 free() 函数。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private static class Deallocator implements Runnable {
private static Unsafe unsafe = Unsafe.getUnsafe();

private long address;
private long size;
private int capacity;

private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}

public void run() {
if (address == 0) {
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}

由于使用 DirectByteBuffer 分配的是系统本地的内存,不在 JVM 的管控范围之内,因此直接内存的回收和堆内存的回收不同,直接内存如果使用不当,很容易造成 OutOfMemoryError。
说了这么多,那么 DirectByteBuffer 和零拷贝有什么关系?前面有提到在 MappedByteBuffer 进行内存映射时,它的 map() 方法会通过 Util.newMappedByteBuffer() 来创建一个缓冲区实例,初始化的代码如下:

[]
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
static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd,
Runnable unmapper) {
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
try {
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size), new Long(addr), fd, unmapper });
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new InternalError(e);
}
return dbb;
}

private static void initDBBRConstructor() {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
try {
Class<?> cl = Class.forName("java.nio.DirectByteBufferR");
Constructor<?> ctor = cl.getDeclaredConstructor(
new Class<?>[] { int.class, long.class, FileDescriptor.class,
Runnable.class });
ctor.setAccessible(true);
directByteBufferRConstructor = ctor;
} catch (ClassNotFoundException | NoSuchMethodException |
IllegalArgumentException | ClassCastException x) {
throw new InternalError(x);
}
return null;
}});
}

DirectByteBuffer 是 MappedByteBuffer 的具体实现类。实际上,Util.newMappedByteBuffer() 方法通过反射机制获取 DirectByteBuffer 的构造器,然后创建一个 DirectByteBuffer 的实例,对应的是一个单独用于内存映射的构造方法:

[]
1
2
3
4
5
6
7
8
protected DirectByteBuffer(int cap, long addr, FileDescriptor fd, Runnable unmapper) {
super(-1, 0, cap, cap, fd);
address = addr;
cleaner = Cleaner.create(this, unmapper);
att = null;
}


因此,除了允许分配操作系统的直接内存以外,DirectByteBuffer 本身也具有文件内存映射的功能,这里不做过多说明。我们需要关注的是,DirectByteBuffer 在 MappedByteBuffer 的基础上提供了内存映像文件的随机读取 get() 和写入 write() 的操作。

[]
1
2
3
4
5
6
7
public byte get() {
return ((unsafe.getByte(ix(nextGetIndex()))));
}

public byte get(int i) {
return ((unsafe.getByte(ix(checkIndex(i)))));
}
  • 内存映像文件的随机读操作
    []
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public ByteBuffer put(byte x) {
    unsafe.putByte(ix(nextPutIndex()), ((x)));
    return this;
    }

    public ByteBuffer put(int i, byte x) {
    unsafe.putByte(ix(checkIndex(i)), ((x)));
    return this;
    }


    内存映像文件的随机读写都是借助 ix() 方法实现定位的, ix() 方法通过内存映射空间的内存首地址(address)和给定偏移量 i 计算出指针地址,然后由 unsafe 类的 get() 和 put() 方法和对指针指向的数据进行读取或写入。
    []
    1
    2
    3
    4
    5
    6
    private long ix(int i) {
    return address + ((long)i << 0);
    }



3. FileChannel

FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。

  • transferTo():通过 FileChannel 把文件里面的源数据写入一个 WritableByteChannel 的目的通道。
[]
1
2
public abstract long transferTo(long position, long count, WritableByteChannel target)
throws IOException;
  • transferFrom():把一个源通道 ReadableByteChannel 中的数据读取到当前 FileChannel 的文件里面。
    []
    1
    2
    public abstract long transferTo(long position, long count, WritableByteChannel target)
    throws IOException;

下面给出 FileChannel 利用 transferTo() 和 transferFrom() 方法进行数据传输的使用示例:

[]
1
2
3
4
5
6
private static final String CONTENT = "Zero copy implemented by FileChannel";
private static final String SOURCE_FILE = "/source.txt";
private static final String TARGET_FILE = "/target.txt";
private static final String CHARSET = "UTF-8";


首先在类加载根路径下创建 source.txt 和 target.txt 两个文件,对源文件 source.txt 文件写入初始化数据。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
@Before
public void setup() {
Path source = Paths.get(getClassPath(SOURCE_FILE));
byte[] bytes = CONTENT.getBytes(Charset.forName(CHARSET));
try (FileChannel fromChannel = FileChannel.open(source, StandardOpenOption.READ,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
fromChannel.write(ByteBuffer.wrap(bytes));
} catch (IOException e) {
e.printStackTrace();
}
}


对于 transferTo() 方法而言,目的通道 toChannel 可以是任意的单向字节写通道 WritableByteChannel;而对于 transferFrom() 方法而言,源通道 fromChannel 可以是任意的单向字节读通道 ReadableByteChannel。其中,FileChannel、SocketChannel 和 DatagramChannel 等通道实现了 WritableByteChannel 和 ReadableByteChannel 接口,都是同时支持读写的双向通道。为了方便测试,下面给出基于 FileChannel 完成 channel-to-channel 的数据传输示例。

  • 通过 transferTo() 将 fromChannel 中的数据拷贝到 toChannel
[]
1
2
3
4
5
6
7
8
9
10
11
@Test
public void transferTo() throws Exception {
try (FileChannel fromChannel = new RandomAccessFile(
getClassPath(SOURCE_FILE), "rw").getChannel();
FileChannel toChannel = new RandomAccessFile(
getClassPath(TARGET_FILE), "rw").getChannel()) {
long position = 0L;
long offset = fromChannel.size();
fromChannel.transferTo(position, offset, toChannel);
}
}
  • 通过 transferFrom() 将 fromChannel 中的数据拷贝到 toChannel
    []
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Test
    public void transferFrom() throws Exception {
    try (FileChannel fromChannel = new RandomAccessFile(
    getClassPath(SOURCE_FILE), "rw").getChannel();
    FileChannel toChannel = new RandomAccessFile(
    getClassPath(TARGET_FILE), "rw").getChannel()) {
    long position = 0L;
    long offset = fromChannel.size();
    toChannel.transferFrom(fromChannel, position, offset);
    }
    }

下面介绍 transferTo() 和 transferFrom() 方法的底层实现原理,这两个方法也是 java.nio.channels.FileChannel 的抽象方法,由子类 sun.nio.ch.FileChannelImpl.java 实现。transferTo() 和 transferFrom() 底层都是基于 sendfile 实现数据传输的,其中 FileChannelImpl.java 定义了 3 个常量,用于标示当前操作系统的内核是否支持 sendfile 以及 sendfile 的相关特性。

[]
1
2
3
private static volatile boolean transferSupported = true;
private static volatile boolean pipeSupported = true;
private static volatile boolean fileSupported = true;
  • transferSupported:用于标记当前的系统内核是否支持 sendfile() 调用,默认为 true。
  • pipeSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于管道(pipe)的 sendfile() 调用,默认为 true。
  • fileSupported:用于标记当前的系统内核是否支持文件描述符(fd)基于文件(file)的 sendfile() 调用,默认为 true。

下面以 transferTo() 的源码实现为例。FileChannelImpl 首先执行 transferToDirectly() 方法,以 sendfile 的零拷贝方式尝试数据拷贝。如果系统内核不支持 sendfile,进一步执行 transferToTrustedChannel() 方法,以 mmap 的零拷贝方式进行内存映射,这种情况下目的通道必须是 FileChannelImpl 或者 SelChImpl 类型。如果以上两步都失败了,则执行 transferToArbitraryChannel() 方法,基于传统的 I/O 方式完成读写,具体步骤是初始化一个临时的 DirectBuffer,将源通道 FileChannel 的数据读取到 DirectBuffer,再写入目的通道 WritableByteChannel 里面。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public long transferTo(long position, long count, WritableByteChannel target)
throws IOException {
// 计算文件的大小
long sz = size();
// 校验起始位置
if (position > sz)
return 0;
int icount = (int)Math.min(count, Integer.MAX_VALUE);
// 校验偏移量
if ((sz - position) < icount)
icount = (int)(sz - position);

long n;

if ((n = transferToDirectly(position, icount, target)) >= 0)
return n;

if ((n = transferToTrustedChannel(position, icount, target)) >= 0)
return n;

return transferToArbitraryChannel(position, icount, target);
}

接下来重点分析一下 transferToDirectly() 方法的实现,也就是 transferTo() 通过 sendfile 实现零拷贝的精髓所在。可以看到,transferToDirectlyInternal() 方法先获取到目的通道 WritableByteChannel 的文件描述符 targetFD,获取同步锁然后执行 transferToDirectlyInternal() 方法。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private long transferToDirectly(long position, int icount, WritableByteChannel target)
throws IOException {
// 省略从target获取targetFD的过程
if (nd.transferToDirectlyNeedsPositionLock()) {
synchronized (positionLock) {
long pos = position();
try {
return transferToDirectlyInternal(position, icount,
target, targetFD);
} finally {
position(pos);
}
}
} else {
return transferToDirectlyInternal(position, icount, target, targetFD);
}
}

最终由 transferToDirectlyInternal() 调用本地方法 transferTo0() ,尝试以 sendfile 的方式进行数据传输。如果系统内核完全不支持 sendfile,比如 Windows 操作系统,则返回 UNSUPPORTED 并把 transferSupported 标识为 false。如果系统内核不支持 sendfile 的一些特性,比如说低版本的 Linux 内核不支持 DMA gather copy 操作,则返回 UNSUPPORTED_CASE 并把 pipeSupported 或者 fileSupported 标识为 false。

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private long transferToDirectlyInternal(long position, int icount,
WritableByteChannel target,
FileDescriptor targetFD) throws IOException {
assert !nd.transferToDirectlyNeedsPositionLock() ||
Thread.holdsLock(positionLock);

long n = -1;
int ti = -1;
try {
begin();
ti = threads.add();
if (!isOpen())
return -1;
do {
n = transferTo0(fd, position, icount, targetFD);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
if (n == IOStatus.UNSUPPORTED_CASE) {
if (target instanceof SinkChannelImpl)
pipeSupported = false;
if (target instanceof FileChannelImpl)
fileSupported = false;
return IOStatus.UNSUPPORTED_CASE;
}
if (n == IOStatus.UNSUPPORTED) {
transferSupported = false;
return IOStatus.UNSUPPORTED;
}
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end (n > -1);
}
}

本地方法(native method)transferTo0() 通过 JNI(Java Native Interface)调用底层 C 的函数,这个 native 函数(Java_sun_nio_ch_FileChannelImpl_transferTo0)同样位于 JDK 源码包下的 native/sun/nio/ch/FileChannelImpl.c 源文件里面。JNI 函数 Java_sun_nio_ch_FileChannelImpl_transferTo0() 基于条件编译对不同的系统进行预编译,下面是 JDK 基于 Linux 系统内核对 transferTo() 提供的调用封装

[]
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
#if defined(__linux__) || defined(__solaris__)
#include <sys/sendfile.h>
#elif defined(_AIX)
#include <sys/socket.h>
#elif defined(_ALLBSD_SOURCE)
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/uio.h>

#define lseek64 lseek
#define mmap64 mmap
#endif

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_transferTo0(JNIEnv *env, jobject this,
jobject srcFDO,
jlong position, jlong count,
jobject dstFDO)
{
jint srcFD = fdval(env, srcFDO);
jint dstFD = fdval(env, dstFDO);

#if defined(__linux__)
off64_t offset = (off64_t)position;
jlong n = sendfile64(dstFD, srcFD, &offset, (size_t)count);
return n;
#elif defined(__solaris__)
result = sendfilev64(dstFD, &sfv, 1, &numBytes);
return result;
#elif defined(__APPLE__)
result = sendfile(srcFD, dstFD, position, &numBytes, NULL, 0);
return result;
#endif
}


对 Linux、Solaris 以及 Apple 系统而言,transferTo0() 函数底层会执行 sendfile64 这个系统调用完成零拷贝操作,sendfile64() 函数的原型如下:

[]
1
2
3
#include <sys/sendfile.h>

ssize_t sendfile64(int out_fd, int in_fd, off_t *offset, size_t count);

下面简单介绍一下 sendfile64() 函数各个参数的含义:

out_fd:待写入的文件描述符
in_fd:待读取的文件描述符
offset:指定 in_fd 对应文件流的读取位置,如果为空,则默认从起始位置开始
count:指定在文件描述符 in_fd 和 out_fd 之间传输的字节数

在 Linux 2.6.3 之前,out_fd 必须是一个 socket,而从 Linux 2.6.3 以后,out_fd 可以是任何文件。也就是说,sendfile64() 函数不仅可以进行网络文件传输,还可以对本地文件实现零拷贝操作。

九. 其它的零拷贝实现

Netty 中的零拷贝和上面提到的操作系统层面上的零拷贝不太一样, 我们所说的 Netty 零拷贝完全是基于(Java 层面)用户态的,它的更多的是偏向于数据操作优化这样的概念,具体表现在以下几个方面:

  • Netty 通过 DefaultFileRegion 类对 java.nio.channels.FileChannel 的 tranferTo() 方法进行包装,在文件传输时可以将文件缓冲区的数据直接发送到目的通道(Channel)
  • ByteBuf 可以通过 wrap 操作把字节数组、ByteBuf、ByteBuffer 包装成一个 ByteBuf 对象, 进而避免了拷贝操作
  • ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝
  • Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝

其中第 1 条属于操作系统层面的零拷贝操作,后面 3 条只能算用户层面的数据操作优化。

TCP流协议

TCP是一种流协议,这意着数据是以字节流的形式发送给接收者,没有固定的报文和报文边界的概念。接收端读取tcp数据是无法预知在这一次读操作中会返回多少字节。

假设主机A向主机B发送两条报文M1和M2,调用两次send发送两条独立的报文,但是数据在传输过程中并不会遵循这个方式。在发送端,send操作只是将数据复制到主机A的TCP/IP协议栈,由TCP决定怎么发送和每次发送多少。

这个决定的过程很复杂,如:发送窗口、拥塞窗口、路径上的最大传输单元等。也就是说数据的发送分为很多种情况:

    1. M1和M2分开发送数据。
    1. M1和M2数据一起传输。
    1. M1的先发送一部分,然后剩下的和M2一起发送。
    1. 先发送M1和M2的一部分,然后发送M2剩下的数据。

缓存发送

其实仔细看过TCP协议内容的人就可以发现,TCP协议允许发送端将几次发送的数据包缓存起来合成一个数据包发送到网络上去,因为这样可以获得更高的效率,这一行为通常是在操作系统提供的SOCKET中实现,所以在应用层对此毫无所觉。所以我们在程序中调用SOCKET的send发送了数据后操作系统有可能缓存了起来,等待后续的数据一起发送,而不是立即发送出去。send的文档中对此也有说明。

分包发送

网络传输的概念中有MTU的概念,也即是网络中一个数据包最大的长度。如果要发送超过这个长度的数据包,就需要分包发送。当调用SOCKET的send发送超过MTU的数据包时,操作系统提供的SOCKET实现会自动将这个数据包分割成几个不超过MTU的数据包发送。

粘包

当出现这些上面这些情况的时候,接收端就会发现接收到的数据和发送的数据的次数不一致。这个就是粘包现象。

当我们传输如文件这种数据时,流式的传输非常适合,但是当我们传输指令之类的数据结构时,流式模型就有一个问题:无法知道指令的结束。所以粘包必须问题是必须解决的。

定长结构

因为粘包问题的存在,接收端不能想当然的以为发送端一次发送了多少数据就能一次收到多少数据。如果发送端发送了一个固定长度的数据结构,接收端必须每次都严格判断接收到额数据的长度,当收到的数据长度不足时,需要再次接收数据,直到满足长度,当收到的数据多于固定长度时,需要截断数据,并将多余的数据缓存起来,视为长度不足需要再次接收处理。

不定长结构

定长的数据结构是一种理想的情况,真正的应用中通常使用的都是不定长的数据结构。
对于发送不定长的数据结构,简单的做法就是选一个固定的字符作为数据包结束标志,接收到这个字符就代表一个数据包传输完成了。
但是这只能应用于字符数据,因为二进制数据中很难确定结束字符到底是结束还是原本要传输的数据内容(使用字符来标识数据的边界在传输二进制数据时时可以实现的,只是实现比较复杂和低效。想了解可以参考以太网传输协议)。
目前最通用的做法是在每次发送的数据的固定偏移位置写入数据包的长度。
接收端只要一开始读取固定偏移的数据就可以知道这个数据包的长度,接下来的流程就和固定长度数据结构的处理流程类似。
所以对于处理粘包的关键在于提前获取到数据包的长度,无论这个长度是提前商定好的还是写在在数据包的开头。
因为在每次发送的数据的固定偏移位置写入数据包的长度的方法是最通用的一种方法,所以对这种方法实现中的一些容易出错误的地方在此特别说明。

  • 通常我们使用2~4个字节来存放数据长度,多字节数据的网络传输需要注意字节序,所以要注意接受者和发送者要使用相同的字节序来解析数据长度。
  • 每次新开始接收一段数据时不要急着直接去解析数据长度,先确保目前收到的数据已经足够解析出数据长度,例如数据开头的2个字节存储了数据长度,那么一定确保接收了2个字节以上的数据后才去解析数据长度。
  • 如果没做到这一点的服务器代码,收到了一个字节就去解析数据长度的,结果得到的长度是内存中的随机值,结果必然是崩溃的。

参考文献

TCP新手误区–粘包的处理

概念

我们通常使用http协议用于网页的浏览,但是http有一个最大的缺点就是明文传输,这样在被攻击者截取了web浏览器和网站服务器之间传输的报文,就可以直接看懂信息,并利用信息进行犯罪。为了提高安全性,我们提出了https协议,https协议主要解决了两个问题。

  1. 解决了明文传输的不可靠性。
  2. 进行身份验证,保证数据是从正确的服务器发送过来。确认网站的真实性。

http与https区别

  1. http协议需要到ca申请证书,一般免费证书较少,因而需要一定费用。
  2. http是超文本传输协议,信息是明文传输,https则是具有安全性的ssl/tls加密传输协议。
  3. http和https使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
  4. http的连接很简单,是无状态的;HTTPS协议是由SSL/TLS+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全。

https的通信步骤

在此之前先介绍一些基本的概念:
对称加密:对称加密是指客户端和服务端在传输数据的时候,公用一把锁,这把锁既可以对明文加密,又可以对加密的内容进行解码。
非对称加密:非对称加密是指在进行传输的过程中共有一对公/密匙来起作用,公钥匙是对外公开的由客户端拥有(实际上每个人都可以拥有),而密匙是只有服务端知道,由公匙加密的报文只能用密匙解密。
相比较而言非对称加密安全性更高,因为在使用对称加密的时候需要首先传递共享密匙,这一步有可能会被拦截并被盗取的。而非堆成加密是不存在这样的情况的,公匙是对外公开的,而密匙是服务器私有的,只要服务器没有泄漏密匙,就基本不会出现安全问题。但是同样的非对称加密的算法是指数级别的,而对称加密算法速度更快。
https协议结合了两种算法的优缺点,使用了一种混合的加密算法。具体的算法我们后面介绍。

数字签名
数字签名的目的是为了保证客户端收到的报文是从正确的服务端收到的。通常是求取要发送报文的数字散列H(m),然后用密匙对这个数字散列加密K(H(m)),并加到明文报文后面,构成m+K(H(m)),服务端将新的报文发送到客户端,客户端将前面的明文进行哈希散列构成H(m’),并对后面的K(H(m))进行解密,得到H(m),比较这两个哈希散列是否一直,如果一致就说明数字签名是正确的,数据也是完整的。

数字证书
数字签名虽然已经能够提高准确率,但是黑客仍然可以进行攻击,他们可以拦截服务端发送的报文,并用黑客自己生成的钥匙对加密这个明文报文,并连同公匙一起发送给客户端,客户端拿到公匙和签名发现验证通过就会误以为这是客户端发送的数据。而会用这个黑客的公匙对报文进行加密,并发送给服务端,而服务端并不能将这个数据进行解密,进而无法解决问题。

从这个问题看出来,要想使公匙有用,需要能够正式你具有的公匙实际上就是与你要进行通行的实体。这个时候就可以用ca认证来解决这个问题。我们可以让ca去认证这个服务端的合法信息。一旦ca验证了某个实体的身份,这个CA会生成一个将其身份和实体的公匙绑定起来的证书。这个证书包含这个公匙和公匙所有者全局唯一的身份表示信息。由ca对这个数字证书进行签名。

这样服务端发送的其实是ca签署的证书(由ca私匙加密),然后客户端收到证书后从ca获取公匙,并进行身份验证,最后在利用公匙对明文报文验证其合法性。

(1) 客户使用https的url访问服务器,要求与web服务器建立ssl连接。
(2) Web服务器收到客户端请求后,会将网站的证书信息(证书中包含公钥)传送一份给客户端。
(3) 客户端的浏览器与Web服务器开始协商SSL/TLS连接的安全等级,也就是信息加密的等级。
(4) 客户端的浏览器根据双方同意的安全等级,建立会话密钥,然后利用网站的公钥将会话密钥加密,并传送给网站。
(5) Web服务器利用自己的私钥解密出会话密钥。
(6) Web服务器利用会话密钥加密与客户端之间的通信。

upload successful
upload successful

CA证书的申请及其使用过程

上面客户端使用HTTPS与服务器通信中使用到了CA认证,这里可能大家会问为什么不直接使用非对称加密的形式直接进行,首先这里先介绍下非对称加密。

非对称加密:客户端和服务端均拥有一个公有密匙和一个私有密匙。公有密匙可以对外暴露,而私有密匙只有自己可见。

使用公有密匙加密的消息,只有对应的私有密匙才能解开。反过来,使用私有密匙加密的消息,只有公有密匙才能解开。这样客户端在发送消息前,先用服务器的公匙对消息进行加密,服务器收到后再用自己的私匙进行解密。

upload successful

非对称加密的优点:

  • 非对称加密采用公有密匙和私有密匙的方式,解决了http中消息保密性问题,而且使得私有密匙泄露的风险降低。

  • 因为公匙加密的消息只有对应的私匙才能解开,所以较大程度上保证了消息的来源性以及消息的准确性和完整性。

非对称加密的缺点:

  • 非对称加密时需要使用到接收方的公匙对消息进行加密,但是公匙不是保密的,任何人都可以拿到,中间人也可以。那么中间人可以做两件事,第一件是中间人可以在客户端与服务器交换公匙的时候,将客户端的公匙替换成自己的。这样服务器拿到的公匙将不是客户端的,而是中间人的。服务器也无法判断公匙来源的正确性。第二件是中间人可以不替换公匙,但是他可以截获客户端发来的消息,然后篡改,然后用服务器的公匙加密再发往服务器,服务器将收到错误的消息。

  • 非对称加密的性能相对对称加密来说会慢上几倍甚至几百倍,比较消耗系统资源。正是因为如此,https将两种加密结合了起来。

为了应对上面非对称加密带来的问题,我们就引入了数字证书与数字签名

故CA认证介入我们的HTTPS连接的过程如下:

1、服务器拥有自己的私钥与公钥

2、服务器将公钥交给CA认证机构,请求给予一份数字证书

3、CA认证机构生成数字证书,并颁发给服务器

4、服务器将带有公钥信息的数字证书发给客户端

5、进入客户端生成对称密钥再进行对接的过程……

upload successful

虽然说HTTPS有很大的优势,但其相对来说,还是存在不足之处的:

(1)HTTPS协议握手阶段比较费时,会使页面的加载时间延长近50%,增加10%到20%的耗电;

(2)HTTPS连接缓存不如HTTP高效,会增加数据开销和功耗,甚至已有的安全措施也会因此而受到影响;

(3)SSL证书需要钱,功能越强大的证书费用越高,个人网站、小网站没有必要一般不会用。

(4)SSL证书通常需要绑定IP,不能在同一IP上绑定多个域名,IPv4资源不可能支撑这个消耗。

(5)HTTPS协议的加密范围也比较有限,在黑客攻击、拒绝服务攻击、服务器劫持等方面几乎起不到什么作用。最关键的,SSL证书的信用链体系并不安全,特别是在某些国家可以控制CA根证书的情况下,中间人攻击一样可行。

实践中建议保留http。所以我们在切换的时候可以做http和https的兼容,具体实现方式是,去掉页面链接中的http头部,这样可以自动匹配http头和https头。例如:将http://www.baidu.com改为//www.baidu.com。然后当用户从http的入口进入访问页面时,页面就是http,如果用户是从https的入口进入访问页面,页面即是https的

如何优化HTTPS的速度
1、HSTS重定向技术

HSTS(HTTP Strict Transport Security)技术,启用HSTS后,将保证浏览器始终连接到网站的 HTTPS 加密版本。

1. 用户在浏览器里输入 HTTP 协议进行访问时,浏览器会自动将 HTTP 转换为 HTTPS 进行访问,确保用户访问安全;

2. 省去301跳转的出现,缩短访问时间;

3. 能阻止基于 SSL Strip 的中间人攻击,万一证书有错误,则显示错误,用户不能回避警告,从而能够更加有效安全的保障用户的访问。

2、TLS握手优化

在传输应用数据之前,客户端必须与服务端协商密钥、加密算法等信息,服务端还要把自己的证书发给客户端表明其身份,这些环节构成 TLS 握手过程。

采用 False Start (抢先开始)技术,浏览器在与服务器完成 TLS 握手前,就开始发送请求数据,服务器在收到这些数据后,完成 TLS 握手的同时,开始发送响应数据。

开启 False Start 功能后,数据传输时间将进一步缩短。

3、Session Identifier(会话标识符)复用

如果用户的一个业务请求包含了多条的加密流,客户端与服务器将会反复握手,必定会导致更多的时间损耗。或者某些特殊情况导致了对话突然中断,双方就需要重新握手,增加了用户访问时间。

(1)服务器为每一次的会话都生成并记录一个 ID 号,然后发送给客户端;

(2)如果客户端发起重新连接,则只要向服务器发送该 ID 号;

(3)服务器收到客户端发来的 ID 号,然后查找自己的会话记录,匹配 ID 之后,双方就可以重新使用之前的对称加密秘钥进行数据加密传输,而不必重新生成,减少交互时间。

4、开启OSCP Stapling,提高TLS握手效率

采用OCSP Stapling ,提升 HTTPS 性能。服务端主动获取 OCSP 查询结果并随着证书一起发送给客户端,从而客户端可直接通过 Web Server 验证证书,提高 TLS 握手效率。

服务器模拟浏览器向 CA 发起请求,并将带有 CA 机构签名的 OCSP 响应保存到本地,然后在与客户端握手阶段,将 OCSP 响应下发给浏览器,省去浏览器的在线验证过程。由于浏览器不需要直接向 CA 站点查询证书状态,这个功能对访问速度的提升非常明显。

5、完全前向加密PFS,保护用户数据,预防私钥泄漏

    非对称加密算法 RSA,包含了公钥、私钥,其中私钥是保密不对外公开的,由于此算法既可以用于加密也可以用于签名,所以用途甚广,但是还是会遇到一些问题:

 (1) 假如我是一名黑客,虽然现在我不知道私钥,但是我可以先把客户端与服务器之前的传输数据(已加密)全部保存下来

(2)如果某一天,服务器维护人员不小心把私钥泄露了,或者服务器被我攻破获取到了私钥

(3)那我就可以利用这个私钥,破解掉之前已被我保存的数据,从中获取有用的信息

    所以为了防止上述现象发生,我们必须保护好自己的私钥。

    如果私钥确实被泄漏了,那我们改如何补救呢?那就需要PFS(perfect forward secrecy)完全前向保密功能,此功能用于客户端与服务器交换对称密钥,起到前向保密的作用,也即就算私钥被泄漏,黑客也无法破解先前已加密的数据。维基解释是:长期使用的主密钥泄漏不会导致过去的会话密钥泄漏

    实现此功能需要服务器支持以下算法和签名组合:

(1)ECDHE 密钥交换、RSA 签名;

(2)ECDHE 密钥交换、ECDSA 签名;

这片文章是结合很多篇blog以及TCP/IP详解的滑动窗口部分总结来的,文字基本都是copy,内容不难,本来想自己总结的但是最近事情太多就直接把别人的东西直接拿过来了。

一.窗口定义

upload successful

因此我们先了解一下16位的窗口大小究竟有什么作用。

窗口分为滑动窗口和拥塞窗口。

滑动窗口是接受数据端使用的窗口大小,用来告知发送端接收端的缓存大小,以此可以控制发送端发送数据的大小,从而达到流量控制的目的。

那么对于数据的发送端就是拥塞窗口了,拥塞窗口不代表缓存,拥塞窗口指某一源端数据流在一个RTT内可以最多发送的数据包数

滑动窗口

滑动窗口协议是传输层进行流控的一种措施,接收方通过通告发送方自己的可以接受缓冲区大小(这个字段越大说明网络吞吐量越高),从而控制发送方的发送速度,不过如果接收端的缓冲区一旦面临数据溢出,窗口大小值也会随之被设置一个更小的值通知给发送端,从而控制数据发送量(发送端会根据接收端指示,进行流量控制)。

对ACK的再认识,ack通常被理解为收到数据后给出的一个确认ACK,ACK包含两个非常重要的信息:

  • 一是期望接收到的下一字节的序号n,该n代表接收方已经接收到了前n-1字节数据,此时如果接收方收到第n+1字节数据而不是第n字节数据,接 收方是不会发送序号为n+2的ACK的。举个例子,假如接收端收到1-1024字节,它会发送一个确认号为1025的ACK,但是接下来收到的是 2049-3072,它是不会发送确认号为3072的ACK,而依旧发送1025的ACK。

  • 二是当前的窗口大小m,如此发送方在接收到ACK包含的这两个数据后就可以计算出还可以发送多少字节的数据给对方,假定当前发送方已发送到第x字节,则可以发送的字节数就是y=m-(x-n).这就是滑动窗口控制流量的基本原理.

滑动窗口协议如图所示:

upload successful

在这个图中,我们将字节从1至11进行 标号。接收方通告的窗口称为提出的窗口,它覆盖了从第4字节到第9字节的区域,表明接收方已经确认了包括第3字节在内的数据,且通告窗口大小为6。我们知 道窗口大小是与确认序号相对应的。发送方计算它的可用窗口,该窗口表明多少数据可以立即被发送。当接收方确认数据后,这个滑动窗口不时地向右移动。窗口两 个边沿的相对运动增加或减少了窗口的大小。我们使用三个术语来描述窗口左右边沿的运动:

  • 称窗口左边沿向右边沿靠近为窗口合拢。这种现象发生在数据被发送和确认时。
  • 当窗口右边沿向右移动时将允许发送更多的数据,我们称之为窗口张开。这种现象发生在另一端的接收进程读取已经确认的数据并释放了T C P的接收缓存时。
  • 当右边缘向左移动时,称之为窗口收缩。当然这是TCP所不允许的。

当接收端的缓冲区满了,发送端接收到接收端的窗口大小为0,这个时候停止发送数据,这个时候发送端会过了超时重发的时间,发送一个窗口探测的包,此数据端仅含一个字节以获取最新的窗口大小信息。

拥塞窗口

拥塞控制:防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提:网络能够承受现有的网络负荷。拥塞控制是一个全局性的过程,涉及到所有的主机、路由器,以及与降低网络传输性能有关的所有因素。

流量控制:指点对点通信量的控制,是端到端正的问题。流量控制所要做的就是抑制发送端发送数据的速率,以便使接收端来得及接收。

拥塞控制代价:需要获得网络内部流量分布的信息。在实施拥塞控制之前,还需要在结点之间交换信息和各种命令,以便选择控制的策略和实施控制。这样就产生了额外的开销。拥塞控制还需要将一些资源分配给各个用户单独使用,使得网络资源不能更好地实现共享。

对比

滑动实质上是解决了接收端的缓存的问题,如果从发送端传送的数据超出了接受端所能够接收的最大缓存,那么接收端就会丢包。
而拥塞接口是为了解决整个网络中的过载问题,如果大量的数据在网路上传输,超过了网络的承载的上限那么,也会出现路由器也会丢包现象。

二.几种拥塞控制算法

慢开始,拥塞避免,快重传和快恢复

发送方维持一个拥塞窗口 cwnd ( congestion window )的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。发送方让自己的发送窗口等于拥塞。

发送方控制拥塞窗口的原则是:只要网络没有出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去。但只要网络出现拥塞,拥塞窗口就减小一些,以减少注入到网络中的分组数。

慢开始算法:当主机开始发送数据时,如果立即所大量数据字节注入到网络,那么就有可能引起网络拥塞,因为现在并不清楚网络的负荷情况。因此,较好的方法是 先探测一下,即由小到大逐渐增大发送窗口,也就是说,由小到大逐渐增大拥塞窗口数值。通常在刚刚开始发送报文段时,先把拥塞窗口 cwnd 设置为一个最大报文段MSS的数值。而在每收到一个对新的报文段的确认后,把拥塞窗口增加至多一个MSS的数值。用这样的方法逐步增大发送方的拥塞窗口 cwnd ,可以使分组注入到网络的速率更加合理。

upload successful

每经过一个传输轮次,拥塞窗口 cwnd 就加倍。一个传输轮次所经历的时间其实就是往返时间RTT。不过“传输轮次”更加强调:把拥塞窗口cwnd所允许发送的报文段都连续发送出去,并收到了对已发送的最后一个字节的确认。

另,慢开始的“慢”并不是指cwnd的增长速率慢,而是指在TCP开始发送报文段时先设置cwnd=1,使得发送方在开始时只发送一个报文段(目的是试探一下网络的拥塞情况),然后再逐渐增大cwnd。慢启动实质是解决发送方和接收方之间存在多个路由器和速率较慢的链路时,就有可能出现一些问题。一些中间路由器必须缓存分组,并有可能消耗存储器的空间,慢启动算法是通过观察到新分组进入网络的速率应该与另一端返回确认的速度相同而工作。

为了防止拥塞窗口cwnd增长过大引起网络拥塞,还需要设置一个慢开始门限ssthresh状态变量(如何设置ssthresh)。慢开始门限ssthresh的用法如下:

当 cwnd < ssthresh 时,使用上述的慢开始算法。

当 cwnd > ssthresh 时,停止使用慢开始算法而改用拥塞避免算法。

当 cwnd = ssthresh 时,既可使用慢开始算法,也可使用拥塞控制避免算法。

拥塞避免算法:让拥塞窗口cwnd缓慢地增大,即每经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1,而不是加倍。这样拥塞窗口cwnd按线性规律缓慢增长,比慢开始算法的拥塞窗口增长速率缓慢得多。拥塞避免算法是用于处理丢失分组的方法。
无论在慢开始阶段还是在拥塞避免阶段,只要发送方判断网络出现拥塞(其根据就是没有收到确认),就要把慢开始门限ssthresh设置为出现拥塞时的发送 方窗口值的一半(但不能小于2)。然后把拥塞窗口cwnd重新设置为1,执行慢开始算法。这样做的目的就是要迅速减少主机发送到网络中的分组数,使得发生 拥塞的路由器有足够时间把队列中积压的分组处理完毕。

如下图,用具体数值说明了上述拥塞控制的过程。现在发送窗口的大小和拥塞窗口一样大。
upload successful

  • <1>. 当TCP连接进行初始化时,把拥塞窗口cwnd置为1。前面已说过,为了便于理解,图中的窗口单位不使用字节而使用报文段的个数。慢开始门限的初始值设置为16个报文段,即 cwnd = 16 。

  • <2>. 在执行慢开始算法时,拥塞窗口 cwnd 的初始值为1。以后发送方每收到一个对新报文段的确认ACK,就把拥塞窗口值另1,然后开始下一轮的传输(图中横坐标为传输轮次)。因此拥塞窗口cwnd 随着传输轮次按指数规律增长。当拥塞窗口cwnd增长到慢开始门限值ssthresh时(即当cwnd=16时),就改为执行拥塞控制算法,拥塞窗口按线 性规律增长。

  • <3>. 假定拥塞窗口的数值增长到24时,网络出现超时(这很可能就是网络发生拥塞了)。更新后的ssthresh值变为12(即变为出现超时时的拥塞窗口数值 24的一半),拥塞窗口再重新设置为1,并执行慢开始算法。当cwnd=ssthresh=12时改为执行拥塞避免算法,拥塞窗口按线性规律增长,每经过 一个往返时间增加一个MSS的大小。

强调:“拥塞避免”并非指完全能够避免了拥塞。利用以上的措施要完全避免网络拥塞还是不可能的。“拥塞避免”是说在拥塞避免阶段将拥塞窗口控制为按线性规律增长,使网络比较不容易出现拥塞。

比较

相同:提高网络性能。
不同:
  [1].流量控制:在TCP连接上实现对发送流量的控制,考虑点对点之间对通信量的控制,端到端,即:控制发送端的数据发送速率,使接收端可以来得及接收,保证网络高效稳定运行。
  [2].拥塞控制:处理网络拥塞现象,考虑网络能够承受现有的网络负荷,全局性变量,涉及所有的路由器、主机以及与降低网络传输性能有关的因素。防止过多的数据注入到网络,使网络中的路由器或链路不致过载,确保通信子网可以有效为主机传递分组。

快重传和快恢复

如果发送方设置的超时计时器时限已到但还没有收到确认,那么很可能是网络出现了拥塞,致使报文段在网络中的某处被丢弃。这时,TCP马上把拥塞窗口 cwnd 减小到1,并执行慢开始算法,同时把慢开始门限值ssthresh减半。这是不使用快重传的情况。

快重传算法首先要求接收方每收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时才进行捎带确认。

upload successful
接收方收到了M1和M2后都分别发出了确认。现在假定接收方没有收到M3但接着收到了M4。显然,接收方不能确认M4,因为M4是收到的失序报文段。根据 可靠传输原理,接收方可以什么都不做,也可以在适当时机发送一次对M2的确认。但按照快重传算法的规定,接收方应及时发送对M2的重复确认,这样做可以让 发送方及早知道报文段M3没有到达接收方。发送方接着发送了M5和M6。接收方收到这两个报文后,也还要再次发出对M2的重复确认。这样,发送方共收到了 接收方的四个对M2的确认,其中后三个都是重复确认。快重传算法还规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段M3,而不必 继续等待M3设置的重传计时器到期。由于发送方尽早重传未被确认的报文段,因此采用快重传后可以使整个网络吞吐量提高约20%。

为什么要收到3个以上重复的ack才进行重传
由于我们不知道一个重复的ACK是由一个丢失的报文段引起的,还是由仅仅出现了几个报文段的重排序,因此我们等到少量重复的ack到来。假如这只是一些报文段的重新排序,因此我们等待少量重复的ack到来。假如这只是一些报文段的重新排序,则在重新排序的报文段被处理并产生一个新的ACK之前,只可能生产1~2个重复的ack。如果收到3个或3个以上的重复的ack,就非常啃呢个是一个报文段丢失,因此我们就重传丢失的数据报文段,而无需等待超时定时器溢出。这就是快速重传算法。接下来执行的不是慢启动算法而是快速恢复算法。

与快重传配合使用的还有快恢复算法,其过程有以下两个要点:

  • <1>. 当发送方连续收到三个重复确认,就执行“乘法减小”算法,把慢开始门限ssthresh减半。这是为了预防网络发生拥塞。请注意:接下去不执行慢开始算法。

  • <2>. 由于发送方现在认为网络很可能没有发生拥塞,因此与慢开始不同之处是现在不执行慢开始算法(即拥塞窗口cwnd现在不设置为1),而是把cwnd值设置为 慢开始门限ssthresh减半后的数值,然后开始执行拥塞避免算法(“加法增大”),使拥塞窗口缓慢地线性增大。

下图给出了快重传和快恢复的示意图,并标明了“TCP Reno版本”。

区别:新的 TCP Reno 版本在快重传之后采用快恢复算法而不是采用慢开始算法。
upload successful

说明:新的 TCP Reno 版本在快重传之后采用快恢复算法而不是采用慢启动算法。从接收方对发送方的流量控制的角度考虑,发送方的发送窗口一定不能超过对方给出的接收窗口rwind 。

也有的快重传实现是把开始时的拥塞窗口cwnd值再增大一点,即等于 ssthresh + 3 X MSS 。这样做的理由是:既然发送方收到三个重复的确认,就表明有三个分组已经离开了网络。这三个分组不再消耗网络 的资源而是停留在接收方的缓存中。可见现在网络中并不是堆积了分组而是减少了三个分组。因此可以适当把拥塞窗口扩大了些。

tcp快恢复算法,快恢复过程大致包括以下步骤:

  • 1、cwnd = sshthresh + 3 * MSS (3的意思是确认有3个数据包被收到了)
  • 2、重传Duplicated ACKs指定的数据包
  • 3、如果再收到 duplicated Acks,那么cwnd = cwnd +1
  • 4、如果收到了新的Ack,那么,cwnd = sshthresh。

这里关于为什么第一步cwnd为sshthresh+3MSS,以及为什么第三步没收到一个ack,cwnd+1,最后收到新的ack就恢复为sshthresh。
这里我们用知乎上的回答:
TCP reno版本的快恢复算法最后一步为什么重置窗口?

因为在reno中假定:网络只偶然的丢了一个包。第一句话中+3MSS的原因其实是指你已经收到了3个Dup ack。假定情况如下:你的ssthresh是10000,MSS是1000.那么现在你发送了10个包。始终受到要求第1个包的ACK。然后你再次发生第一个包。还是收到dup ack。这时候Reno让你的cwnd+1,于是你新发了第11个包。循环100次后,你已经发送到了第110个包。
循环100次后,你已经发送到了第110个包。
接收端一下子给你回了一个要求第100个包的ACK。于是你的window一下子就向右移动了100个MSS。
在刚才的inflates过程中,你的cwnd已经达到了10+3+100(N)的一个非常大的数据。而Reno认为,你应该还保持在原有的ssthresh的程度(即10个MSS的程度)
所以要重置ssthresh。

这个方案的好处是:每次DUP ACK,你还能以拥塞避免的速度去发送新的数据;避免中间长时间被浪费。当然问题就是,他主要假定只不见了一个包。于是如果不见了多个包,就会又一次进入fast recovery;于是cwnd和ssthresh再次减半。等于丢一个包就是一次减半.

关于上面这个问题还有一个blog写的比较好我们也可以借鉴一下:
关于快恢复算法中的一些问题

在采用快恢复算法时,慢开始算法只是在TCP连接建立时和网络出现超时时才使用。

采用这样的拥塞控制方法使得TCP的性能有明显的改进。

接收方根据自己的接收能力设定了接收窗口rwnd,并把这个窗口值写入TCP首部中的窗口字段,传送给发送方。因此,接收窗口又称为通知窗口。因此,从接收方对发送方的流量控制的角度考虑,发送方的发送窗口一定不能超过对方给出的接收窗口rwnd 。

发送方窗口的上限值 = Min [ rwnd, cwnd ]

当rwnd < cwnd 时,是接收方的接收能力限制发送方窗口的最大值。

当cwnd < rwnd 时,则是网络的拥塞限制发送方窗口的最大值。

转载连接

TCP的滑动窗口与拥塞窗口

概念

TCP是计算机网络中运输层的一个协议。他有一下几个特点:面向连接、点对点、全双公服务。

  • 面向连接:当一个服务器向另一个服务器发送数据的时候,必须要先通过握手建立连接,才能发送数据。
  • 点对点:这保证数据的传输只有单个的发送方和单个接收方。和UDP的广播和多播是不一样的。
  • 全双工服务:表示建立TCP连接的两个主机既可以发送数据,也可以接收数据。

上面的三个特点保证了TCP的连接是可靠的。

可靠性

具体来讲,TCP/IP详解一书中提到了多个TCP保证可靠性的方式,我将它分为以下两类,一类从数据传输来说,一类是对下层ip协议的可靠性

  • 数据传输:1. tcp在数据传输时,将数据分割成小的数据块,分块发送可以保证在数据传输的时候,如果出现错误需要重穿,只需要将出错的一段重新发送即可,不需要将整个数据包重新上传。

  • 数据传输:2. 定时器机制——当TCP发出一个段时,他会启动一个定时器,等待目的端确认收到这个报文段。如果不能够及时收到确认的话,将重新发送这个报文段。

  • 数据传输:3. 当TCP收到发自TCP连接另一端的数据的时候,它将发送一个确认,这个确认不是立即发送,通常会推迟几分之一秒,这是因为,我们希望能够将数据确认和接下来需要发送的数据一起发送给另一端,这样可以节省发送的次数。

  • 数据传输:4.流量控制,TCP连接的每一端都会有一个固定大小的缓冲空间。这个缓冲空间可以保证发送过来的数据不能及时处理,就会放在缓冲空间中,可以提高吞吐率。即使有缓冲区也有可能会导致缓冲区溢出,所以TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据,这将防止较快主机致使较慢主机的缓冲区溢出。

  • 数据传输:5.TCP将保持它的首部和数据的校验和。

  • 数据传输:6.TCP对传输字节流的内容不做解释。TCP不知道传输的数据字节流是二进制数据还是ASSCII字符。字节流的解释交由引用层来处理。

  • 数据传输:7.当发送端从引用层发送多个字节时,TCP会按照自己的方式将字节转换为小数据块。比如一方的应用程序先传10字节,又传20字节,再传50字节,连接的另一方无法知道发送方每次发了多少字节。接收方可以分4次接收这80个字节,每次接收20字节。

  • ip可靠性保证:1.失序重排:既然TCP报文段作为IP数据包来传输,而IP数据报的到达可能会失序,因此TCP报文段的到达可能失序。如果必要,TCP将对收到的数据进行重排序,将收到的数据以正确的顺序交给应用层。

  • ip可靠性保证:2.重复丢弃:IP数据报会发生重复,TCP的接收端必须丢弃重复的数据。

TCP结构

upload successful

TCP报文主要包括首部和数据两个部分,具体的结构如上图所示,下面我们来具体介绍一下这几个部分。

  • 端口号:每个TCP端都包含两个16位的源端和目的端口号,用于寻找发送端和接收端的进程。这两个值加上IP首部的源端IP地址、目的端IP地址可以唯一确定一个TCP连接。我们通常将IP地址+端口号一起成为socket(端口号)。既然是16位的也就是说端口号最大为2^16-1;

  • 序号:序号用来标示从TCP发送端向TCP接收端发送的数据字节流,他标示在这个报文段中的第一个数据字节的序号。如果将字节流看作在两个应用程序间的单项流动,则TCP用序号对每个字节进行计数,序号是32bit的无符号数,序号达到2^32-1后又从0开始,要注意的是,SYN和FIN标示位是会消耗一个序号。也就是说即使数据是空的,当SYN和FIN的被标志的时候,序号仍然会加1.

upload successful

比如我们发送端要给接收端发送9000字节的数据,我们将这个9000字节的数据进行切分成2500大小的数据报文。那么序号就表示位每一个数据的第一个字节,比如第一个数据的序号就为1,第二个位2500。

  • 确认序号应当是上次成功收到数据字节序号加1.只有ACK标志为1的时候确认序号字段才有效。
    我们还用上面的图进行解释,当接收端收到发送端发来的第一个数据报,并已经确认之后,当接收端向发送端发送数据的时候,接收端希望下一次收到的报文的序号为2500,这样数据才能接上,所以确认序号的值就是2500.

  • 发送的ACK是不占用任何序号的,因为32bit的确认序号和ACK标志一样,总是TCP首部的一部分,因此我们看到,一旦一个连接建立起来,这个字段总是被设置,ACK标志也总是被设置为1.

  • TCP可以表述为一个没有选择确认或者否认的滑动窗口协议,我们说TCP缺少选择确认是因为TCP首部的确认序号表示发送方已经成功接收的字节,但还不包含确认序号所指的字节。当前还无法对数据流中选定的部分进行确认。如下图所示:

upload successful
发送端向接收端发送1000~1999的数据,接收端发送确认序号2000,接着接受端收到3000~3999的数据,但是这不是接收端想要的数据,由于无法选择确认后面的序号,所以只能重新发一次确认序号2000,表明没有收到序号为2000的数据。
没有否认的意思是,如果接收端收到想要的数据,但是校验和不通过,因为不能直接发送否认序号,所以只能发送一个确认序号为2000的新的请求。

  • 首部长度给出首部32位的数目,需要这个值是因为任选字段是可变的,这个字段占4bit,因此TCP最多有60字节的首部,然而,没有任选字段,正常的长度是20字节。因为TCP单位是32位也就是4字节,首部长度占4bit最大可以表示15,15x4=60,所以TCP首部最大60字节。

  • TCP中的标志位有6个,它们中多个可以同时被设置为1.
    URG 紧急指针有效
    ACK 确认序号有效
    PSH 接收方应该尽快将这个报文交给应用层
    RST 重建连接
    SYN 同步序号用来发起一个连接。
    FIN 发端完成发送任务。

  • TCP的流量控制由连接的每一端通过申明窗口大小来提供。窗口大小为字节数,起始与确认序号字段指明的值,这个值是接受端正期望接收的字节。窗口大小是16bit字段,因而窗口大小最大为65535字节。

  • 检验和覆盖了整个TCP报文段。

  • 最常见的可选字段是最长报文大小,又称为MSS。每个连接方通常都在通信的第一个报文段中指明这个选项。它指明本端所能接收的最大长度的报文段。

  • 我们注意到TCP报文段的数据是可选的。当一个连接建立连接和连接终止时,双方交换的报文端只有首部,在处理超时情况下,通常也会发送空数据的报文段。

TCP连接的建立和终止

TCP是一个面向连接的协议。无论哪一个方向另一方发送数据之前,都必须先在双方之间建立一条连接。

TCP连接三次握手

TCP的连接,客户端会向服务端发送一个连接请求,接着服务端会向客户端发送一个请求确认,最后客户端会继续向服务端发送一个请求确认,这就表明了两个主机之间完成了TCP的连接,也叫三次握手。
如下图所示:
upload successful

    1. 请求端发送一个TCP的SYN标志位置1的包,指明客户端打算连接的服务器的端口,以及一个初始的序列号x,保存在包头的序列号字段里面。此时进入SYN_SEND阶段。
    1. 服务端发回包含服务器的初始序号Y的SYN报文段作为应答。同时,将确认的初始序列号ISN加1,放在确认序号中,即X+1.发送完毕后服务端进入SYN_RECV阶段。
    1. 客户端必须将确认序号设置为服务端的ISN加1以对服务器的SYN报文段进行确认。

以上三步,我们称之为三次握手,值得注意的是,三次握手中SYN置为1只出现在前两个连接中,ACK置为1只出现在后两个连接中。选择项MSS只在SYN报文中出现,最终由客户端和服务毒案共同协议,如果两个mss不一样,则选择最小的报文段,如果不接受对方的MSS选择,则MSS就定为默认值536.一般来说,如果没有分段发生,MSS还是越大越好。报文段越大允许每个报文段传送的数据就越多,相对IP和TCP首部就有更高的网络利用率。当TCP发送一个SYN时,或者是因为一个本地应用进程想发起一个连接,或者是因为另一段的主机收到了一个连接请求,他能将MSS值设置为外出接口上的MTU的长度减去固定的IP首部和TCP首部长度。对于一个以太网可以达到1460字节。当MSS的值确定之后,以后的数据交换都不能超过MSS的值。

序列号ISN的确定并不是从0开始的,而是根据按照随时间增加而不断增加的,如果在某一时刻请求端发送请求,他会根据某个函数获取初始的ISN的值,当然不同的操作系统ISN计算方式不同。有些攻击者,可以根据定时的发送请求,来根据两个请求的时间差计算出操作系统使用的是哪一个。

连接超时
很多情况下会导致无法建立连接,一种情况是服务器主机没有处于正常状态。这个时候客户端每隔一定的时间会发送一次请求连接,直到到过一定的阈值。这个时间间隔会越来越大。

二次握手和四次握手
TCP建立连接的时候需要进行三次握手,才能确定连接的双方都能够正常通信,为什么不用两次握手或者四次握手呢。我们知道双方在建立连接的时候,实质上是确定双方的序号以及MSS的大小。双方需要知道自己首部的序号是否已经同步,这样才能在后面发送数据。

两次握手

upload successful
我们想以下,在进行两次握手的时候,发送端想接收端发送SYN的连接请求包,并带了自己的ISN序号,当服务端收到请求,并进行回应。服务端会把自己的SYN序号发给客户端,此时客户端已经知道服务端已经确认了通信,并保证从客户端想服务端发数据是可靠的,但是我们知道TCP是全双工的通道,我们只进行两次握手,服务端是无法知道自己发送的SYN包是否到达了数据库,不知道自己往客户端发送数据的通道是否可靠。如果这个SYN包丢失了,A和B的初始序列号无法达成一致的。

TCP的设计者将SYN这个同步标志SYN设计成占用一个字节的编号(FIN标志也是),既然是一个字节的数据,按照TCP对有数据的TCP segment必须确认的原则,所以这里客户端必须给服务端一个去二人,以确定A已经接收到B的同步信号。

那么三次握手是如何保证可靠的呢,如果客户端发给服务端的ack包丢失了怎么办。客户端会重传这个ACK吗?不会!TCP不会为没有数据的ACK超时重穿。此时服务端会重传自己的SYN同步信号,一直到A的ACK为止。

第一个包,即A发给B的SYN 中途被丢,没有到达B

A会周期性超时重传,直到收到B的确认

第二个包,即B发给A的SYN +ACK 中途被丢,没有到达A

B会周期性超时重传,直到收到A的确认

第三个包,即A发给B的ACK 中途被丢,没有到达B

A发完ACK,单方面认为TCP为 Established状态,而B显然认为TCP为Active状态:

a. 假定此时双方都没有数据发送,B会周期性的超时重传,直到收到A的确认,收到之后的B的TCP连接也为Established状态,双向可以发包。

四次握手
我们再来看一下四次握手,如图

upload successful

看起来很简单,就是将接收端发给客户端的syn包,拆分成了两份。这也很容易就能够看出来,这样的效率并不高。三次握手就可以提高效率。

TCP连接四次挥手

upload successful
TCP连接的建立需要3次握手,但是TCP的终止是需要4次挥手,这是由于TCP的半关闭造成的,因为TCP是一个全双工的连接,每个方向需要分别关闭通道。当一方结束数据传输的时候,就会发送一个FIN包来终止这个方向的连接。当接收端收到一个FIN包的时候,只是说明了来自这个方向的数据传输终止了,但是接收端仍然可以向发送端发送数据。当接收端结束数据传输的时候,他会向客户端发送一个FIN包,用来终止连接。

  • 第一次挥手:数据传输结束后,客户端向服务端发送一个FIN包,并停止发送数据,此时FIN=1,seq=u;
  • 第二次挥手:客户端收到FIN包后,会发送一个确认请求,此时ACK=1,seq=v,ack=u+1。此时服务端处于半关闭状态,客户端收到确认包以后就不会再向服务端发送数据,而服务端仍然会向客户端发送数据。
  • 第三次挥手:若服务器已经没有要向客户端发送的数据,其应用进程就通知服务器释放TCP连接。这个阶段服务器所发出的最后一个报文的首部应为:FIN=1,ACK=1,seq=w,ack=u+1。
  • 第四次挥手:客户端收到连接释放报文段之后,必须发出确认:ACK=1,seq=u+1,ack=w+1。 再经过2MSL(最长报文端寿命)后,本次TCP连接真正结束,通信双方完成了他们的告别。
    在这个过程中,通信双方的状态如下图,其中:ESTAB-LISHED:连接建立状态、FIN-WAIT-1:终止等待1状态、FIN-WAIT-2:终止等待2状态、CLOSE-WAIT:关闭等待状态、LAST-ACK:最后确认状态、TIME-WAIT:时间等待状态、CLOSED:关闭状态

为什么在TIME_WAIT后必须等待2MSL时间呢?

  1. 为了保证客户端(我们记为A端)发送的最后一个ACK报文段能够到达服务器端。这个ACK报文段有可能丢失,因而使处在LASK—ACK端的服务器端(我们记为B端)收不到对已发送的FIN+ACK报文段。B会超时重传这个FIN+ACK报文段,而A就能在2MSL时间内收到这个重传的FIN+ACK报文段。接着A重传一次确认,重新启动2MSL计时器。最后,A和B都正常进入到CLOSED状态。如果A在TIME_WAIT状态不等待一段时间,而是在发送完ACK确认后立即释放连接,那么就无法收到B重传的FIN+ACK报文段,因而也不会再发送一次确认报文段,这样,B就无法正常进入CLOSED状态。

  2. 我们都知道,假如A发送的第一个请求连接报文段丢失而未收到确认,A就会重传一次连接请求,后来B收到了确认,建立了连接。数据传输完毕后,就释放了连接。A共发送了两个连接请求报文段,其中第一个丢失,第二个到达了B。假如现在A发送的第一个连接请求报文段没有丢失,而是在某些网络节点长时间都留了,以至于延误到连接释放后的某个时间才到达B,这本来是已失效的报文段,但B并不知道,就会又建立一次连接。而等待的这2MSL就是为了解决这个问题的,A在发送完最后一个确认报后,在经过时间2MSL,就可以使本链接持续时间内所产生的所有报文段都从网络中消失,这样就可以使下一个新的连接中不会出现这种旧的连接请求报文段。

为什么是四次挥手
我们都知道TCP是全双工,数据传输是双向的,如果客户端完成了数据的传输,发起主动关闭连接,此时服务端可能没有完全结束数据传输,如果只进行三次挥手,那么要求数据结束传输必须是同时的,这就会强制终止服务端的传输,这样数据的传输是不完整的。另一点,如果进行五次以上的挥手操作,一定会造成资源的浪费,使效率低。

半关闭状态
TCP提供了连接的一段在结束后还能够接收来自另一端数据的能力。这就是所谓的半关闭。正如我们早些时候提到的只有很少的应用程序使用它。
为了使用这种特性,编程接口必须为应用程序提供一种方式来说明,我已经完成了数据传送,因此发送一个文件结束给另一端,但我还想接收另一端发来的数据,直到它给我发来文件结束。

TCP连接异常问题

我们之前介绍了三次握手建立连接,四次挥手释放连接。但是如果出现了连接异常现象,TCP是如何处理的呢,这里就会用到TCP标志位RST。

到不存在的端口请求连接
当一个TCP的一个请求连接到达服务器,发现请求的端口未对外开放,这时服务端会回传一个RST报文段,告知客户端端口号不可达。

upload successful

TCP连接异常终止

客户端和服务器的某一方在交互的过程中发生异常(如程序崩溃等),该方系统将向对端发送TCP reset报文,告之对方释放相关的TCP连接,如下图所示:

upload successful

接收端收到TCP报文,但是发现该TCP的报文,并不在其已建立的TCP连接列表内,则其直接向对端发送reset报文

upload successful

在交互的双方中的某一方长期未收到来自对方的确认报文,则其在超出一定的重传次数或时间后,会主动向对端发送reset报文释放该TCP连接,如下图所示:

upload successful

有些应用开发者在设计应用系统时,会利用reset报文快速释放已经完成数据交互的TCP连接,以提高业务交互的效率

upload successful

异常终止有两个好处,丢弃所有待发的数据并直接发送复位的报文段,RST的接收方会区分对方是正常关闭还是异常关闭。

处理半连接状态
如果tcp的一段已经关闭或者异常终止连接而对方却不知道,我们将这样的连接称之为半打开。任何一端的主机异常都有可能会导致发生这种情况。只要不再半打开的连接上传输数据,仍然处于连接的一方就无法知道对方出现异常。
半打开的原因往往是应为连接的一端突然断电,而不是正常的程序关闭出现后再关机,比如说当客户端结束任务,直接拔掉电源。等到重新开机的时候,之前的连接信息全部丢失了,而服务端如果没有发送数据,就不会知道连接已经断开。此时如果服务端向客户端发送一条信息,由于客户端丢失了之前的连接信息,所以它并不知道报文段中的连接,这时TCP的处理方式就是发送一个RST报文来进行复位,告知服务端连接已经终止。

参考文献

TCP 为什么是三次握手,而不是两次或四次?
TCP/IP中MSL详解
简述TCP连接的建立与释放(三次握手、四次挥手)

概要

打家劫舍系列一共有三个题目,算是比较经典的dp的题目,我去年做过一遍今年在刷每日一题的时候又重新回顾一下这三个题目,对我的帮助还是收益满多的,话不多说先看题目。

打家劫舍1

题目

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例1

输入:[1,2,3,1]
输出:4
解释:
偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例2

输入:[2,7,9,3,1]
输出:12
解释:
偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

解法1

这个题目的一个最重要的特点就是相邻的两个区域不能同时选择,所以,我们可以设置一个dp数组用来保存第i个房间被偷的时候能偷到的最大金额,我们就可以写出状态方程
dp[i] = max(dp[i-1]-num[i-1], dp[i-2]) + num[i];
然后利用这个公式,我们选择dp数组中最大的一个数就是我们能偷到的最大的数。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int rob(int[] nums) {
//dp算法
if(nums == null|| nums.length==0) return 0;
if(nums.length == 1) return nums[0];
if(nums.length == 2) return Math.max(nums[0], nums[1]);
int [] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] =nums[1];
int max = Math.max(dp[0],dp[1]);
for(int i=2; i<nums.length;++i){
dp[i] = Math.max(dp[i-1]-nums[i-1],dp[i-2]) + nums[i];
max = Math.max(dp[i],max);
}
return max;
}
}

时间复杂度和空间复杂度都是O(N)

解法2

解法1虽然能够解决问题,但是方法不够简洁,不能够一次性解决问题,还需要对dp数组中寻找一个最大的数值。
我们可以换一种思路,将dp数组表示成当偷到第i个房间的时候,所能够获得的最大金额,这个表示和解法1还是有区别的,解法1是表示第i个房间必须被偷,而这个表示方法第i个房间不一定被偷。
按照这个思路来说,当我们偷第i个房间的时候,就有两种状态,第一种状态是第i-1个房间被偷了,那么根据相邻不可同时偷取的特性,第i个房间就必须跳过,此时有dp[i] = dp[i-1],第二种个状态是第i-1个房间没有被偷,此时第i个房间就可以选择偷也可以选择不偷,此时dp[i] = dp[i-2]+nums[i];结合上面两种状态,我们可以得出:dp[i]=max(dp[i−2]+nums[i],dp[i−1])

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int length = nums.length;
if (length == 1) {
return nums[0];
}
int[] dp = new int[length];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < length; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[length - 1];
}
}

时间复杂度和空间复杂度都是O(N)

解法3

我们可以对方法2进行优化,因为我们看到dp[i]只和dp[i-1],dp[i-2]有关,我们可以利用这个特性,将dp数组转化为3个int整数,这也是滚动pd。代码如下:

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int length = nums.length;
if (length == 1) {
return nums[0];
}
int first = nums[0], second = Math.max(nums[0], nums[1]);
for (int i = 2; i < length; i++) {
int temp = second;
second = Math.max(first + nums[i], second);
first = temp;
}
return second;
}
}


打家劫舍2

题目

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例1

输入:[2,3,2]
输出:3
解释:
你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。

示例2

输入:[1,2,3,1]
输出:4
解释:
你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

解法1

这个题目相比较上一个题目有一个难度的提升,从单链变成了循环,这样就需要保证第一个和最后一个不能同时被偷,这样我们可以遍历两次房间,第一次遍历从头开始遍历,遍历到除了最后一个房间的所有房间。第二次从尾巴开始遍历,遍历到除了第一个房间的所有房间。方式还是和第一题的解法1一样:

代码

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

class Solution {
public int rob(int[] nums) {
//两次dp遍历,从前往后和从后往前
if(nums == null || nums.length ==0) return 0;
if(nums.length ==1) return nums[0];
if(nums.length ==2) return Math.max(nums[0], nums[1]);
if(nums.length ==3) return Math.max(nums[0], nums[1]);
int [] dp1 = new int[nums.length];
int [] dp2 = new int[nums.length];
dp1[0] = nums[0];
dp1[1] = nums[1];
int max = Math.max(dp1[0],dp1[1]);
for(int i=2; i<nums.length-1; ++i){
dp1[i] = Math.max(dp1[i-1]-nums[i-1], dp1[i-2]) + nums[i];
max = Math.max(dp1[i], max);
}
dp2[nums.length-1 ] = nums[nums.length-1];
dp2[nums.length-2 ] = nums[nums.length-2];
max = Math.max(dp2[nums.length-1 ], max);
max = Math.max(dp2[nums.length-2 ], max);
for(int i=nums.length-3; i>=1; i--){
dp2[i] = Math.max(dp2[i+1]-nums[i+1], dp2[i+2]) + nums[i];
max = Math.max(dp2[i], max);
}
return max;
}
}


解法2

方式仍然是两次遍历,但是我们对dp的表达方式转化为和上一题的解法2一样。

代码

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

class Solution {
public int rob(int[] nums) {
if(nums.length == 0) return 0;
if(nums.length == 1) return nums[0];
return Math.max(myRob(Arrays.copyOfRange(nums, 0, nums.length - 1)),
myRob(Arrays.copyOfRange(nums, 1, nums.length)));
}
private int myRob(int[] nums) {
int pre = 0, cur = 0, tmp;
for(int num : nums) {
tmp = cur;
cur = Math.max(pre + num, cur);
pre = tmp;
}
return cur;
}
}


打家劫舍3

题目

在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。

计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。

示例1

输入:[3,2,3,null,3,null,1]
输出:7
解释:
小偷一晚能够盗取的最高金额 = 3 + 3 + 1 = 7.

示例2

输入:[3,4,5,1,3,null,1]
输出:9
解释:
小偷一晚能够盗取的最高金额 = 4 + 5 = 9.

解法1

第三题相对与上一题有时一个提升,是将树和dp算法结合到了一起,通常这样的关于树的dp算法,我们可以用dfs来解决。

对于树中的每个节点我们可以存储他的两个状态,一个状态是这个房间被偷的情况下,它的左右两个节点一定没有被偷,以这个房间为根节点的树所获取的最大金额,那么这个状态的最大金额selected = left.notSelected+ right.notSelected+ root.val;
第二个状态是这个房间没有被偷的情况,那么他的左右两个节点可能被偷了,也可能没有被偷。那么没有被偷的这个状态可以被表示为:notSelected = Math.max(left.selected,ledt.notSelected)+ Math.max(right.selected, right.notSelected);

代码

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

/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public int rob(TreeNode root) {
if(root == null) return 0;
int []rootState = dfs(root);
return Math.max(rootState[0], rootState[1]);
}

public int[] dfs(TreeNode node){
if(node == null) return new int[]{0,0};
int [] l = dfs(node.left);
int [] r = dfs(node.right);
int selected = node.val + l[1] + r[1];
int notSelected =Math.max(l[0],l[1])+ Math.max(r[0], r[1]);
return new int[]{selected,notSelected};
}

}

题目描述

请设计一个算法完成两个超长正整数的加法。

接口说明

/*
请设计一个算法完成两个超长正整数的加法。
输入参数:
String addend:加数
String augend:被加数
返回值:加法结果
*/

public String AddLongInteger(String addend, String augend)
{
/在这里实现功能/

return null;
}

本题有多组输入数据,请使用while(cin>>)等方式读取

输入描述:

  • 输入两个字符串数字

输出描述:

  • 输出相加后的结果,string型

示例1

输入

1
2
99999999999999999999999999999999999999999999999999
1

输出

1
100000000000000000000000000000000000000000000000000

讲解

这个题目第一次做的时候是在去年做网易的笔试题目的时候,当时用的语言还是c++,但是无从下手,不知道该怎么解决这个问题,昨天刷leetcode每日一题的时候,又重新看到了这个题目,所以很系统的了解一下应该如何去处理这样的题目,因为leetcode只需要写算法,不需要整个写输入输出,所以特地去牛客网上找到相同的题目又重新写了一遍。

算法其实不难,但是我们整体用到的知识点就是如何从尾部相加,我们用两个指针i,j分别指向加数和被加数的尾部,然后一个个相加,并用StringBuilder存储结果,最后将StringBuilder逆序并且转化为String类型。下面提供一个我自己写的代码:

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
import java.util.*;


public class Main{
public static void main(String [] args){
Scanner sc = new Scanner(System.in);
while(sc.hasNext()){
String add1 = sc.nextLine();
String add2 = sc.nextLine();
String res = AddLongInteger(add1, add2);
System.out.println(res);
}

}

private static String AddLongInteger(String add1, String add2){
int m = add1.length();
int n = add2.length();
int c = 0;
StringBuilder sb = new StringBuilder();
int i=m-1;
int j = n-1;
while(i>=0 || j>=0 || c ==1){
int a1 = i>=0?add1.charAt(i)-'0':0;
int a2 = j>=0?add2.charAt(j)-'0':0;
int sum = a1+ a2+ c;
sb.append(sum %10);
c = sum/10;
i--;
j--;
}
return sb.reverse().toString();
}
}

一. String概念

String 是java中对字符串的一种表达方式,这是一个示例对象,并不属于常见的8中基本类型,和char[]也有一定的区别。

1.String特性

String有三个特性。

  • 不可变性:从JDK文档中我们可以看到,String是常量的,这就意味着当我们对String重新赋值的时候,需要重写指定内存区域进行赋值,不能对原有的内存地址中的value进行修改。当对现有的字符串进行拼接的时候,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。当我们调用String的replace()方法进行修改的时候,同样的也不能直接修改

  • final 修饰:这意味这String是不可以被继承的,这也增加了String的安全性。实现Serializable接口:表示字符串支持序列化,实现了ComparaBle接口:表示String可以比较大小;

  • 在JVM中维护了一个字符串常量池,用于存放字符串常量,这个对于我们深入理解String是非常重要的,至于字符串常量池的版本变化,我在之前的方法区中有提到过,大家可以去看一下。通过字面量的方式(区别与new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。当然根据字符串常量池的特性,常量池中是不会存放相同的值,当我们创建两个具有相同字面量的字符串时,比如

1
2
String a = "abc";
String b = "abc";

因为字符串a和字符串b被同一个字面量创建,当a被创建的时候,会先检查字符串常量池中是否有“abc”,如果没有则开辟一个空间并把“abc”存放到这个地址上,而当b被创建的时候,检查常量池上已经有“abc”了,所以直接把指针指向该地址。实际上a和b指向的是同一个地址。

2.String内存分配

在Java语言中,有8种基本类型,和一种比较特殊的类型String,这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。
常量池就是类似一个Java系统级别的提供的缓存。8中基本类型的常量池都是系统协调的,String类型的常量池比较特殊。它主要使用的方法有两种:

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。

String操作

String操作有:字符串的创建、拼接、比较等几个比较常用的方法,这些方法的一些用法因为String的特殊性,经常被当作笔试的题目,我们接下来就重点讲解这几个操作。

1.String创建

String的创建有两种方法,一种是使用字面量赋值,比如

1
2
String a = "abc";
String b = "abc";

这种方式创建字符串,会直接在常量池中创建对象,栈对象a和b分别用指针指向该字符串常量池中的常量。

另一种方式是使用常用的new关键字创建,如下所示:

1
String c = new String("abc");

这种方式创建的字符串会先在堆中开辟一个空间,并创建字符串对象c,这个对象的value为“abc”,同时会检查常量池是否含有“abc”字符串,如果没有的话会重新创建一个字符串常量“abc”。

总结来说第一种创建方法指针会直接指向字符串常量池相当于a->”abc”,b->”abc”,第二种方式首先会在堆中创建一个c的String对象,它的value是“abc”。 同时如果这个字符串在常量池中不存在,会在常量池中创建这个String对象“abc”;

我们用下图表示,两者的区别:

upload successful

所以我们可以看到这样的面试题:

1
2
3
4
5
6
7
8
9
public class stringTest {
public static void main(String[] args) {
String a = "abc";
String b = "abc";
String c = new String("abc");
System.out.println(a == b);
System.out.println(a == c);
}
}

返回的结果是

1
2
true
false

2.String拼接

常量池的拼接遵守以下规则:

  • 1.常量和常量的拼接结果在常量池中,原理是编译期优化。
  • 2.常量池中不会存在相同内容的常量。
    1. 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
  • 4.如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放在常量池中,并返回此对象地址。

下面我们根据一些笔试的题目看一下这些规则:
面试题一:

1
2
3
4
5
6
@Test
public void Test1(){
String a = "a" + "b" + "c";
String b = "abc";
System.out.println(a ==b);
}

这里a是由三个字符串拼接的结果,我们根据规则一可以得到拼接结果“abc”会放在常量池中,所以结果阿返回的是true,并且我们根据编译的class文件可以看到,String a = “a” + “b” + “c”;直接会被优化成String a = “abc”;所以最后执行的代码是String a = “abc”;这样我们可以和我们之间的知识对应。

面试题二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void Test2(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "a"+"b";
String s5 = s1+"b";
String s6 = "a"+s2;
String s7 = s1+s2;

System.out.println(s3 == s4); //true
System.out.println(s3 == s5); //false
System.out.println(s3 == s7); //false
System.out.println(s5 == s6); //false
System.out.println(s5 == s7); //false
System.out.println(s6 == s7); //false

}

由上面的规则得到,只要其中有一个是变量,得到的结果就存放在堆中,而不是在常量池中,所以除了当一个返回的是true,其他的返回的都是false。

当我们使用变量进行拼接的时候,我们使用的底层是StringBilder,比如上面的String s7 = s1+s2;就相当于

1
2
3
4
StringBuilder sb = new StringBuilder();
sb.append("a");
sb.append("b");
sb.toString();

StringBuilder的toString操作实际上是一个new String的操作。

当然不是所有的变量拼接操作都是使用StringBuilder操作的,当我们对变量声明为final的时候,变量的拼接就会编程常量的拼接,这样底层就不会用到SringBuilder,而是由编译器优化直接使用常量池进行赋值。

这里同时也变相说明了String的拼接操作会比StringBuilder的拼接操作效率低,因为String在进行拼接操作的过程中会new StringBuilder对象,然后再进行拼接操作,而StringBuilder直接进行拼接,节省了空间和时间从而提高效率。

讲完上面两个String的创建和拼接,我们来看一个综合的笔试面试题目。

1
2
3
4
@Test
public void Test3(){
String a = new String("a")+ new String("b");
}

我们来计算一下这里到底创建了多少个对象,我们先说答案,再说为什么,这里一共生成了6个对象。
首先是会生成一个StringBuilder对象用于拼接,接着对于new String(a)会在堆中以及常量分别生成String对象,以及对于new String(“b”)同样的会生成两个对象,最后调用StringBuilder的toString方法,会生成一个String对象。这里要注意,toString方法并不会在常量池中生成对象,所以常量池中并没有“ab”的字符串对象。

3.intern()

String字符串还有一个比较特殊的API:intern(),当一个字符串s调用这个函数的时候,会从字符串常量池中寻找是否有与s值相等的字符串,如果找到了,就返回常量池中的字符串。否则,将该字符串加入到常量池中,并且返回对该常量池中这个字符串的引用。

比如说:

1
String info = new String("1111").intern();

也就是说,如果在任意字符串上调用String.intern方法,那么返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同,因此,下列表达式的值必定是true

1
{"a"+"b"+"c"}.intern()== “abc”;

通俗来讲,Intern就是确保相同值的字符串在内存中只用一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意这个值会被存放在字符串内部池。

这里我们仍然用一个面试题来看一下intern()方法使用:

1
2
3
4
5
6
7
@Test
public void Test4(){
String a = new String("a") + new String("b");
a.intern();
String b = "ab";
System.out.println(a == b);
}

这题的答案其实根据不同的jdk版本是不一样的,在jdk1.6以前,因为之前说过String a = new String(“a”) + new String(“b”);并没有在常量池中创建“ab”的字符串,且a指向的是堆空间地址,所以返回的是false,但是在jdk7及以后,intern同样会在常量池中寻找“ab”对象,但是因为在堆中已经创建了“ab”的字符串对象,所以当b创建对象的时候,常量池不需要在常量池中重新创建“ab”对象了,可以直接存储堆中的引用,这个引用指向s3引用的对象,也就是说引用地址相同。所以结果最终返回的是true。

当我们将a.intern();和String b = “ab”;调换一下顺序之后,结果又会发生变化,如下:

1
2
3
4
5
6
7
@Test
public void Test4(){
String a = new String("a") + new String("b");
String b = "ab";
a.intern();
System.out.println(a == b);
}

因为String b = “ab”;并不会像a.intern()一样智能会选择直接引用堆中相同的对象,所以结果返回的就是false;

4.String 比较

String的比较主要有两种,一种是==一种是equals。

  • 使用==号:用于比较对象引用的内存地址是否相同。
  • 使用equals方法:在Object类中和==号相同,但在自定义类中,建议覆盖equals方法去实现比较自己内容的细节;由于String类覆盖已经覆盖了equals方法,所以其比较的是字符内容。

三. StringBuffer 和 StringBuilder

StringBuffer和StringBuilder常常用于解决字符串拼接的问题,他们都比String直接凭借效率高很多。

先来分别使用String/StringBuilder/StringBuffer来拼接30000次字符串,对比各自损耗的时间,经过测试发现:

String做字符串拼接的时候,耗时最高,性能极低,原因是String内容是不可变的,每次内容改变都会在内存中创建新的对象。

性能最好的是StringBuilder,其次是StringBuffer,最后是String。StringBuilder和StringBuffer区别并不是很大,也有可能是测试次数还不够吧。感兴趣的小伙伴可以增加拼接次数来看看。代码很简单,就不展示出来了。

所以在开发中拼接字符串时,优先使用StringBuffer/StringBuilder,不到万不得已,不要轻易使用String。

StringBuilder以及StringBuffer的区别

StringBuffer和StringBuilder都表示可变的字符串,两种’的功能方法都是相同的。但唯一的区别:

  • StringBuffer:StringBuffer中的方法都使用了synchronized修饰符,表示同步操作,在多线程并发的时候可以保证线程安全,但在保证线程安全的时候,对其性能有一定影响,会降低其性能。
  • StringBuilder:StringBuilder中的方法都没有使用了synchronized修饰符,线程不安全,正因为如此,其性能较高。

对并发安全没有很高要求的情况下,建议使用StringBuilder,因为其性能很高。像这样的情况会较多些。使用StringBuilder无参数的构造器,在底层创建了一个长度为16的char数组:

upload successful

此时该数组只能存储16个字符,如果超过了16个字符,会自动扩容(创建长度更大的数组,再把之前的数组拷贝到新数组),此时性能极低;如果事先知道大概需要存储多少字符,可以通过构造器来设置字符的初始值:

upload successful

四. 参考资料

尚硅谷2020最新版宋红康JVM教程持续更新中(java虚拟机详解,jvm从入门到精通)

「JAVA」细述合理创建字符串,分析字符串的底层存储,你不该错过

字符串常量池深入解析

文章转自JAVA 内存泄露详解(原因、例子及解决)

Java的一个重要特性就是通过垃圾收集器(GC)自动管理内存的回收,而不需要程序员自己来释放内存。理论上Java中所有不会再被利用的对象所占用的内存,都可以被GC回收,但是Java也存在内存泄露,但它的表现与C++不同。

JAVA中的内存管理

要了解Java中的内存泄露,首先就得知道Java中的内存是如何管理的。

在Java程序中,我们通常使用new为对象分配内存,而这些内存空间都在堆(Heap)上。

下面看一个示例:

1
2
3
4
5
6
7
8
9
public class Simple {
public static void main(String args[]){
Object object1 = new Object();//obj1
Object object2 = new Object();//obj2
object2 = object1;
//...此时,obj2是可以被清理的
}
}

Java使用有向图的方式进行内存管理

在有向图中,我们叫作obj1是可达的,obj2就是不可达的,显然不可达的可以被清理。

内存的释放,也即清理那些不可达的对象,是由GC决定和执行的,所以GC会监控每一个对象的状态,包括申请、引用、被引用和赋值等。释放对象的根本原则就是对象不会再被使用:

  • 给对象赋予了空值null,之后再没有调用过。

  • 另一个是给对象赋予了新值,这样重新分配了内存空间。
    通常,会认为在堆上分配对象的代价比较大,但是GC却优化了这一操作:C++中,在堆上分配一块内存,会查找一块适用的内存加以分配,如果对象销毁,这块内存就可以重用;而Java中,就想一条长的带子,每分配一个新的对象,Java的“堆指针”就向后移动到尚未分配的区域。所以,Java分配内存的效率,可与C++媲美。

    但是这种工作方式有一个问题:如果频繁的申请内存,资源将会耗尽。这时GC就介入了进来,它会回收空间,并使堆中的对象排列更紧凑。这样,就始终会有足够大的内存空间可以分配。

    gc清理时的引用计数方式:当引用连接至新对象时,引用计数+1;当某个引用离开作用域或被设置为null时,引用计数-1,GC发现这个计数为0时,就回收其占用的内存。这个开销会在引用程序的整个生命周期发生,并且不能处理循环引用的情况。所以这种方式只是用来说明GC的工作方式,而不会被任何一种Java虚拟机应用。

    多数GC采用一种自适应的清理方式(加上其他附加的用于提升速度的技术),主要依据是找出任何“活”的对象,然后采用“自适应的、分代的、停止-复制、标记-清理”式的垃圾回收器。具体不介绍太多,这不是本文重点。

JAVA 中的内存泄露

Java中的内存泄露,广义并通俗的说,就是:不再会被使用的对象的内存不能被回收,就是内存泄露。

Java中的内存泄露与C++中的表现有所不同。

在C++中,所有被分配了内存的对象,不再使用后,都必须程序员手动的释放他们。所以,每个类,都会含有一个析构函数,作用就是完成清理工作,如果我们忘记了某些对象的释放,就会造成内存泄露。

但是在Java中,我们不用(也没办法)自己释放内存,无用的对象由GC自动清理,这也极大的简化了我们的编程工作。但,实际有时候一些不再会被使用的对象,在GC看来不能被释放,就会造成内存泄露。

我们知道,对象都是有生命周期的,有的长,有的短,如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。我们举一个简单的例子:

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

Object object;

public void method1(){
object = new Object();
//...其他代码
}
}

这里的object实例,其实我们期望它只作用于method1()方法中,且其他地方不会再用到它,但是,当method1()方法执行完成后,object对象所分配的内存不会马上被认为是可以被释放的对象,只有在Simple类创建的对象被释放后才会被释放,严格的说,这就是一种内存泄露。解决方法就是将object作为method1()方法中的局部变量。当然,如果一定要这么写,可以改为这样:

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

Object object;

public void method1(){
object = new Object();
//...其他代码
object = null;
}
}

这样,之前“new Object()”分配的内存,就可以被GC回收。

到这里,Java的内存泄露应该都比较清楚了。下面再进一步说明:

  • 在堆中的分配的内存,在没有将其释放掉的时候,就将所有能访问这块内存的方式都删掉(如指针重新赋值),这是针对c++等语言的,Java中的GC会帮我们处理这种情况,所以我们无需关心。
  • 在内存对象明明已经不需要的时候,还仍然保留着这块内存和它的访问方式(引用),这是所有语言都有可能会出现的内存泄漏方式。编程时如果不小心,我们很容易发生这种情况,如果不太严重,可能就只是短暂的内存泄露。

一些容易发生内存泄露的例子和解决方法

像上面例子中的情况很容易发生,也是我们最容易忽略并引发内存泄露的情况,解决的原则就是尽量减小对象的作用域(比如android studio中,上面的代码就会发出警告,并给出的建议是将类的成员变量改写为方法内的局部变量)以及手动设置null值。

至于作用域,需要在我们编写代码时多注意;null值的手动设置,我们可以看一下Java容器LinkedList源码(可参考:Java之LinkedList源码解读(JDK 1.8))的删除指定节点的内部方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//删除指定节点并返回被删除的元素值
E unlink(Node<E> x) {
//获取当前值和前后节点
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next; //如果前一个节点为空(如当前节点为首节点),后一个节点成为新的首节点
} else {
prev.next = next;//如果前一个节点不为空,那么他先后指向当前的下一个节点
x.prev = null;
}
if (next == null) {
last = prev; //如果后一个节点为空(如当前节点为尾节点),当前节点前一个成为新的尾节点
} else {
next.prev = prev;//如果后一个节点不为空,后一个节点向前指向当前的前一个节点
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}

除了修改节点间的关联关系,我们还要做的就是赋值为null的操作,不管GC何时会开始清理,我们都应及时的将无用的对象标记为可被清理的对象。

我们知道Java容器ArrayList是数组实现的(可参考:Java之ArrayList源码解读(JDK 1.8)),如果我们要为其写一个pop()(弹出)方法,可能会是这样:

1
2
3
4
5
6
public E pop(){
if(size == 0)
return null;
else
return (E) elementData[--size];
}

写法很简洁,但这里却会造成内存溢出:elementData[size-1]依然持有E类型对象的引用,并且暂时不能被GC回收。我们可以如下修改:

1
2
3
4
5
6
7
8
9
public E pop(){
if(size == 0)
return null;
else{
E e = (E) elementData[--size];
elementData[size] = null;
return e;
}
}

我们写代码并不能一味的追求简洁,首要是保证其正确性。

容器使用时的内存泄露

在很多文章中可能看到一个如下内存泄露例子:

1
2
3
4
5
6
7
8
Vector v = new Vector();

for (int i = 1; i<100; i++)
{
Object o = new Object();
v.add(o);
o = null;
}

可能很多人一开始并不理解,下面我们将上面的代码完整一下就好理解了:

1
2
3
4
5
6
7
8
9
10
11
    void method(){
Vector vector = new Vector();
for (int i = 1; i<100; i++)
{
Object object = new Object();
vector.add(object);
object = null;
}
//...对vector的操作
//...与vector无关的其他操作
}

这里内存泄露指的是在对vector操作完成之后,执行下面与vector无关的代码时,如果发生了GC操作,这一系列的object是没法被回收的,而此处的内存泄露可能是短暂的,因为在整个method()方法执行完成后,那些对象还是可以被回收。这里要解决很简单,手动赋值为null即可:

1
2
3
4
5
6
7
8
9
10
11
12
    void method(){
Vector vector = new Vector();
for (int i = 1; i<100; i++)
{
Object object = new Object();
vector.add(object);
object = null;
}
//...对v的操作
vector = null;
//...与v无关的其他操作
}

上面Vector已经过时了,不过只是使用老的例子来做内存泄露的介绍。我们使用容器时很容易发生内存泄露,就如上面的例子,不过上例中,容器时方法内的局部变量,造成的内存泄漏影响可能不算很大(但我们也应该避免),但是,如果这个容器作为一个类的成员变量,甚至是一个静态(static)的成员变量时,就要更加注意内存泄露了。

下面也是一种使用容器时可能会发生的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    public class CollectionMemory {
public static void main(String s[]){
Set<MyObject> objects = new LinkedHashSet<MyObject>();
objects.add(new MyObject());
objects.add(new MyObject());
objects.add(new MyObject());
System.out.println(objects.size());
while(true){
objects.add(new MyObject());
}
}
}

class MyObject{
//设置默认数组长度为99999更快的发生OutOfMemoryError
List<String> list = new ArrayList<>(99999);
}

运行上面的代码将很快报错:

1
2
3
4
5
3
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.ArrayList.<init>(ArrayList.java:152)
at com.anxpp.memory.MyObject.<init>(CollectionMemory.java:21)
at com.anxpp.memory.CollectionMemory.main(CollectionMemory.java:16)

如果足够了解Java的容器,上面的错误是不可能发生的。这里也推荐一篇本人介绍Java容器的文章:…

容器Set只存放唯一的元素,是通过对象的equals()方法来比较的,但是Java中所有类都直接或间接继承至Object类,Object类的equals()方法比较的是对象的地址,上例中,就会一直添加元素直到内存溢出。

所以,上例严格的说是容器的错误使用导致的内存溢出。

就Set而言,remove()方法也是通过equals()方法来删除匹配的元素的,如果一个对象确实提供了正确的equals()方法,但是切记不要在修改这个对象后使用remove(Object o),这也可能会发生内存泄露。

各种提供了close()方法的对象

比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,以及使用其他框架的时候,除非其显式的调用了其close()方法(或类似方法)将其连接关闭,否则是不会自动被GC回收的。其实原因依然是长生命周期对象持有短生命周期对象的引用。

可能很多人使用过Hibernate,我们操作数据库时,通过SessionFactory获取一个session:

1
Session session=sessionFactory.openSession();
1
session.close();

SessionFactory就是一个长生命周期的对象,而session相对是个短生命周期的对象,但是框架这么设计是合理的:它并不清楚我们要使用session到多久,于是只能提供一个方法让我们自己决定何时不再使用。

因为在close()方法调用之前,可能会抛出异常而导致方法不能被调用,我们通常使用try语言,然后再finally语句中执行close()等清理工作:

1
2
3
4
5
6
    try{
session=sessionFactory.openSession();
//...其他操作
}finally{
session.close();
}

单例模式导致的内存泄露

单例模式,很多时候我们可以把它的生命周期与整个程序的生命周期看做差不多的,所以是一个长生命周期的对象。如果这个对象持有其他对象的引用,也很容易发生内存泄露。

内部类和外部模块的引用

其实原理依然是一样的,只是出现的方式不一样而已。

与清理相关的方法

本节主要谈论gc()和finalize()方法。

gc()

对于程序员来说,GC基本是透明的,不可见的。运行GC的函数是System.gc(),调用后启动垃圾回收器开始清理。

但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低。

JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。

finalize()

finalize()是Object类中的方法。

了解C++的都知道有个析构函数,但是注意,finalize()绝不等于C++中的析构函数。

Java编程思想中是这么解释的:一旦GC准备好释放对象所占用的的存储空间,将先调用其finalize()方法,并在下一次GC回收动作发生时,才会真正回收对象占用的内存,所以一些清理工作,我们可以放到finalize()中。

该方法的一个重要的用途是:当在java中调用非java代码(如c和c++)时,在这些非java代码中可能会用到相应的申请内存的操作(如c的malloc()函数),而在这些非java代码中并没有有效的释放这些内存,就可以使用finalize()方法,并在里面调用本地方法的free()等函数。

所以finalize()并不适合用作普通的清理工作。

不过有时候,该方法也有一定的用处:

如果存在一系列对象,对象中有一个状态为false,如果我们已经处理过这个对象,状态会变为true,为了避免有被遗漏而没有处理的对象,就可以使用finalize()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    class MyObject{

boolean state = false;

public void deal(){
//...一些处理操作
state = true;
}

@Override
protected void finalize(){
if(!state){
System.out.println("ERROR:" + "对象未处理!");
}
}
//...
}

但是从很多方面了解,该方法都是被推荐不要使用的,并被认为是多余的。

总的来说,内存泄露问题,还是编码不认真导致的,我们并不能责怪JVM没有更合理的清理。

今天把宋红康老师的JVM内存结构的方法区看完了,至此所有的内存结构都已经系统的学习一遍了,我也在博客中整理和回顾一下我的学习笔记。ps:本来打算昨天就写blog的,结果在准备今天项目的时候遇到了一些问题,真心发现硬件实践要比算法难很多,所以也给我一个警惕,不仅要关注上层的算法知识,也要多关注底层的硬件,多考虑如何将算法落地。

一. 方法区概念

upload successful
我们之前也提到了JVM的内存结构,分为堆、方法区、本地方法栈、虚拟机栈、程序计数器。如果按照线程是否共享来说,方法区和堆是线程共享的,而本地方法栈、虚拟机栈、程序计数器都是线程私有的。方法区和堆一样,在JVM启动的时候就被创建了,并且它的实际物理内存空间中和堆一样是不连续的。方法区是用于存储已被虚拟机加载的类信息、常量、静态变量、计时编译器编译后的代码等。方法区的大小决定了系统可以保存多少类,如果系统定义了太多的类,导致方法区的溢出,虚拟机同样会抛出内存异常错误:java.lang.OutOfMemoryError:MetaSpace。

方法区在java1.8版本之前都是叫做永久代,从1.8开始永久代被弃用,转而变成了元空间,其一些属性也发生了一些变化,在永久代的时候我们需要设置-XX:PermSize -XX:MaxPermSize来分别设置永久代初始值和最大值,当变成元空间之后,不再使用JVM的内存,转而使用本地内存来存储方法区的信息。其默认大小就是本地内存的大小,当然我们仍然可以设置元空间的初始值和最大值,当达到初始值的时候会触发full GC,当到达最大值并且full gc也满足不了需求的时候会触发OOM。比如说我们一直用动态代理创建对象或者一直往常量池加入数据,就有可能发生方法区内存溢出。当然关于触发了OOM,到底是内存泄漏导致的还是内存溢出导致的,这个还需要我们去探讨一下。我们可以看一下知乎的回答内存泄漏和内存溢出有啥区别?(ps:内存泄漏可能会导致频繁进行full GC)

1. 方法区的内部结构

upload successful
我们之前提到了,方法区内部存储有类信息和运行时常量池、字符串常量池、静态变量。其中静态变量和字符串常量池在java1.7之后放在堆中进行处理,后面我们会进行详细的分析。

upload successful
一个经典的堆内部结构还会有域信息和方法信息,但是我们通常将域信息和方法信息放在类型信息中。

类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 1.这了类型完整的有效名称(全名=包名、类名)
  • 2.这个类型的直接父类的完整有效名(对于interface、或者是java.lang.Object都没有父类)
  • 3.这个类型的修饰符(public abstruct、final的子集)
  • 4.这个类型的直接接口的一个有序序列

方法信息
JVM必须保存所有方法信息,同域信息一样包括声明顺序

  • 1.方法名称
  • 2.方法的返回类型(或void)
  • 3.方法参数的数量和类型(按顺序)
  • 4.方法的修饰符(public、private、protected、static、final、synchroized、native、abstract)
  • 5.方法的字节码(bytecodes)、操作数栈、局部变量表大小(abstract和netive方法除外)
  • 6.异常表(abstract和native除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移位置、被捕获的异常累的常量池检索。

non-final的类变量
静态变量和类变量关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分。类变量被类的所有实例一起分享,即使没有类实例时你也可以访问他。

upload successful

2. 常量池和运行时常量池

常量池在字节码文件中,运行时常量池在方法区中,这两者还是有一定区别的。

常量池
java原文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这个数据会很大以至于不能直接存到字节码里面,换另一种方式,可以存到常量池,这个字节码包含指向常量池的引用,我们在动态链接的时候就会把符号引用变成直接引用。

常量池通常分为字面量和符号引用,字面量比较接近于Java语言层面的常量的概念,比如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面的三类变量:

  • 1.类和接口的全限名称
  • 2.字段的名称和描述符
  • 3.方法的名称和描述符

我先来贴一段代码

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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

import java.io.Serializable;

public class MethodInnerStrucTest implements Comparable<String>, Serializable {
public int num = 10;
public static final int num1 = 23;
public static String str = "测试方法内部结构";

public MethodInnerStrucTest() {
}

public int compareTo(String o) {
return 0;
}

public void test1() {
int count = 20;
System.out.println("count =" + count);
}

public static int test2(int cal) {
int result = 0;

try {
int value = 39;
result = value / cal;
} catch (Exception var3) {
var3.printStackTrace();
}

return result;
}
}


然后在编译之后通过javap进行反编译,我只将反编译结果汇总的常量池贴出来

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

Constant pool:
#1 = Methodref #18.#55 // java/lang/Object."<init>":()V
#2 = Fieldref #17.#56 // MethodInnerStrucTest.num:I
#3 = Fieldref #57.#58 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Class #59 // java/lang/StringBuilder
#5 = Methodref #4.#55 // java/lang/StringBuilder."<init>":()V
#6 = String #60 // count =
#7 = Methodref #4.#61 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #4.#62 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
#9 = Methodref #4.#63 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#10 = Methodref #64.#65 // java/io/PrintStream.println:(Ljava/lang/String;)V
#11 = Class #66 // java/lang/Exception
#12 = Methodref #11.#67 // java/lang/Exception.printStackTrace:()V
#13 = Class #68 // java/lang/String
#14 = Methodref #17.#69 // MethodInnerStrucTest.compareTo:(Ljava/lang/String;)I
#15 = String #70 // 测试方法内部结构
#16 = Fieldref #17.#71 // MethodInnerStrucTest.str:Ljava/lang/String;
#17 = Class #72 // MethodInnerStrucTest
#18 = Class #73 // java/lang/Object
#19 = Class #74 // java/lang/Comparable
#20 = Class #75 // java/io/Serializable
#21 = Utf8 num
#22 = Utf8 I
#23 = Utf8 num1
#24 = Utf8 ConstantValue
#25 = Integer 23
#26 = Utf8 str
#27 = Utf8 Ljava/lang/String;
#28 = Utf8 <init>
#29 = Utf8 ()V
#30 = Utf8 Code
#31 = Utf8 LineNumberTable
#32 = Utf8 LocalVariableTable
#33 = Utf8 this
#34 = Utf8 LMethodInnerStrucTest;
#35 = Utf8 compareTo
#36 = Utf8 (Ljava/lang/String;)I
#37 = Utf8 o
#38 = Utf8 test1
#39 = Utf8 count
#40 = Utf8 test2
#41 = Utf8 (I)I
#42 = Utf8 value
#43 = Utf8 e
#44 = Utf8 Ljava/lang/Exception;
#45 = Utf8 cal
#46 = Utf8 result
#47 = Utf8 StackMapTable
#48 = Class #66 // java/lang/Exception
#49 = Utf8 (Ljava/lang/Object;)I
#50 = Utf8 <clinit>
#51 = Utf8 Signature
#52 = Utf8 Ljava/lang/Object;Ljava/lang/Comparable<Ljava/lang/String;>;Ljava/io/Serializable;
#53 = Utf8 SourceFile
#54 = Utf8 MethodInnerStrucTest.java
#55 = NameAndType #28:#29 // "<init>":()V
#56 = NameAndType #21:#22 // num:I
#57 = Class #76 // java/lang/System
#58 = NameAndType #77:#78 // out:Ljava/io/PrintStream;
#59 = Utf8 java/lang/StringBuilder
#60 = Utf8 count =
#61 = NameAndType #79:#80 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#62 = NameAndType #79:#81 // append:(I)Ljava/lang/StringBuilder;
#63 = NameAndType #82:#83 // toString:()Ljava/lang/String;
#64 = Class #84 // java/io/PrintStream
#65 = NameAndType #85:#86 // println:(Ljava/lang/String;)V
#66 = Utf8 java/lang/Exception
#67 = NameAndType #87:#29 // printStackTrace:()V
#68 = Utf8 java/lang/String
#69 = NameAndType #35:#36 // compareTo:(Ljava/lang/String;)I
#70 = Utf8 测试方法内部结构
#71 = NameAndType #26:#27 // str:Ljava/lang/String;
#72 = Utf8 MethodInnerStrucTest
#73 = Utf8 java/lang/Object
#74 = Utf8 java/lang/Comparable
#75 = Utf8 java/io/Serializable
#76 = Utf8 java/lang/System
#77 = Utf8 out
#78 = Utf8 Ljava/io/PrintStream;
#79 = Utf8 append
#80 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#81 = Utf8 (I)Ljava/lang/StringBuilder;
#82 = Utf8 toString
#83 = Utf8 ()Ljava/lang/String;
#84 = Utf8 java/io/PrintStream
#85 = Utf8 println
#86 = Utf8 (Ljava/lang/String;)V
#87 = Utf8 printStackTrace

我们在常量池中看到

#75 = Utf8 java/io/Serializable
通常表示符号引用
以及
#25 = Integer 23
这样的自面量

这些符号引用通常是需要进行动态链接,在类创建的时候对这些符号引用进行解析,翻译到本地内存中,从而找到真正内存的入口地址。

运行时常量池

运行时常量池是方法区的一部分。常量池表是Class文件的一部分,用于存放编译期生成的种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,都是通过索引访问的。

运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换成了真实地址。

运行时常量池,相对于class文件常量池的另一个重要特征是:具备动态性。
String:intern()
当创建类或者接口窦娥运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutOfMemoryError异常。

二. 方法区的演进细节

在jdk7及以前,习惯上将方法区称之为永久代,但是在1.7之后随着去永久代的落实,永久代的部分内容转移到堆中存储,在java1.8中,正式使用元空间代替了永久代。

本质上方法区和永久代也不是等价的,仅是堆hotspot而言,对如何实现方法区,不做统一要求,例如:BEA JRpckit/IBM J9中就不存在永久代。
现在来看,当年使用永久代。不是好的idea,导致Java程序更容易OOM(超过-XX:MaxPermSize上限)

upload successful

JDK8摒弃了永久代,使用元空间,这两者最大的区别在于元空间不在虚拟机设置的内存中,而是使用本地内存。

hotspot 方法区中的变化

upload successful

这里面有一些问题需要注意,1. 永久代为什么要被元空间替换? 2. 字符串常量池为什么会被转移到堆中 3. 静态变量为什么会被转移到堆中

永久代被元空间替代

随着Java8的到来,Hotspot vm 中再也见不到永久代了,但是这并不意味着类的元数据信息也消失了,这些数据被移到一个与堆不相连的本地内存区域,这个区域叫做元空间。

由于类的元数据分配到本地内存中,元空间的最大可分配空间就是系统可用内存空间。在java虚拟机规范中说的是,为了使得hotspot和jrocket更好的融合,所以去除了方法区。这里解释其实有点含糊,我从我上课中记的笔记和我自己理解的进行一个解释:

  1. 永久代设置空间大小是很难确定的
    元空间默认的最大值为-1也就是整个本地内存,然而永久代的默认最大值是82m,这个是对于一个大工程尤其是使用多个动态代理框架来说是远远不够的,很容易就OOM,而设置永久代最大值需要结合多方面的元素比如JVM加载的class总数,常量池的大小,方法的大小等,如果过小就容易OOM,过大容易导致虚拟机内存紧张。而元空间并不在虚拟机中,而是使用的本地内存,因此,默认情况下,元空间的大小仅受本地内存的限制。

  2. 对永久代调优是困难的
    永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

字符串常量池和静态变量为什么调整

字符串常量池在java1.7的时候被放入了堆中,因为永久代回收效率很低,在full gc的时候才会触发,而full GC是老年代空间不足、永久代空间不足才会触发的,这就导致了字符串常量池回收的效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆中能够及时回收。

首先放入堆中一个重要的好处就是当我们大量加载类或者使用String.intern的时候由于永久代或者元空间不存放字符串常量池,这使得在方法区中触发full GC就不会很频繁,并且应为字符串通常寿命较短在放在堆中垃圾回收的效率也高。

二. 方法区中的垃圾回收

《Java虚拟机规范》对方法区的垃圾回收是十分宽泛的。一般来说这个区域的回收效果比较难以令人满意,尤其是类型的加载,条件相当苛刻。但是这部分区域的回收有时又确实是有必要的。以前Sun公司Bug列表中,曾出现若干个严重的bug都是由于低版本的HotSpot虚拟机对此区域未完全回收而导致的内存泄漏。
方法区中的垃圾回收主要分为两个部分:常量池中废弃的常量和不再使用的类型。

之前说过方法区中常量池主要有字面量和符号引用。HotSpot对常量池的回收策略是十分明确的,只要常量池中的常量没有任何地方引用,就可以被回收。

判定一个常量是否被“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 1.该类所有的实例都已经被回收了,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 2.加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载等,否则通常很难达成。
  • 3.该类对应的Java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述的三个条件的无用类进行回收,这里仅仅说的是被允许,而不是和对象一样,没有引用就一定被回收。关于是够要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息。

在大量使用反射,动态代理,CGlab等字节码框架,动态生成JSP以及OSGI这类频繁自定义类加载器的场景中,通常需要JAVA虚拟机具备类型的卸载能力,以保证不会对方法区造成过大的压力。

总结

最后贴一张JVM的内存模型图,大家自行回忆一下我们学过的知识。

upload successful

参考文献

尚硅谷2020最新版宋红康JVM教程持续更新中(java虚拟机详解,jvm从入门到精通)