【3.8】操作系统内存管理、Redis数据结构、哈希表

内存满了,会发生什么?

当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。

缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。

如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。

  • 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
  • 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。

如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制

OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

  • 哪些内存可以被回收?

    • 主要有两类内存可以被回收,而且它们的回收方式也不同。

      • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存

      • 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

    • 文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:

      • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;

      • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

  • 回收内存带来的性能影响

    • 回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。
  • 针对回收内存导致的性能影响,常见的解决方式。

    • 设置 /proc/sys/vm/swappiness,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页;
    • 设置 /proc/sys/vm/min_free_kbytes,调整 kswapd 内核线程异步回收内存的时机;
    • 设置 /proc/sys/vm/zone_reclaim_mode,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题;
  • 调整文件页和匿名页的回收倾向

    • Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向。

    • swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。

  • 尽早触发kswapd内核线程异步回收内存

    • 内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:

      • 页高阈值(pages_high);

        • 说明内存有一定压力,但还可以满足应用程序申请内存的请求;在这个区间说明内存充足;

        • 页低阈值(pages_low);

          • 如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。
      • 页最小阈值(pages_min);

        • 如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。
    • kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域,如果剩余内存低于了 pages_min 会触发直接内存回收,高于了 pages_high 又不会唤醒 kswapd。

      • 增大了 min_free_kbytes 配置(设置的是页最小阈值)后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。
        • 所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。
  • NUMA架构下的内存回收策略

    • SMP 指的是一种多个 CPU 处理器共享资源的电脑硬件架构,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。

      随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。

    • 为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access)。

      NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。每个 Node 有自己独立的资源,包括内存、IO 等,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。

    • 虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为 0,即在回收本地内存之前,在其他 Node 寻找空闲内存;

  • 如何保护一个进程不被OOM杀掉呢?

    • 在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,Linux 内核里有一个 oom_badness() 函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。进程得分的结果受下面这两个方面影响:

      • 第一,进程已经使用的物理内存页面数。

      • 第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过 /proc/[pid]/oom_score_adj 来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。

在4GB物理内存的机器上,申请8G内存会怎么样?

  • 32位系统和64位系统所占用的内核空间不同

    • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
    • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
  • 32位系统的场景

    • 因为 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,在申请虚拟内存阶段就会失败
  • 64位系统的场景

    • 64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8GB 内存是没问题的,因为进程申请内存是申请虚拟内存,只要不读写这个虚拟内存,操作系统就不会分配物理内存。

    • 但是申请虚拟内存的过程中,还是用到了物理内存(比如内核保存虚拟内存的数据结构),如果主机只有2GB的物理内存的话,大概率会触发OOM。(没有开始Swap机制的情况下)

      • 内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。
    • 程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:

      • 如果没有开启 Swap 机制,程序就会直接 OOM;

      • 如果有开启 Swap 机制,程序可以正常运行。

  • Swap机制

    当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:

    • 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;

    • 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;

  • 使用 Swap 机制优点是,应用程序实际可以使用的内存空间将远远超过系统的物理内存。当然,频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。

  • Linux中的Swap机制会在内存不足和内存闲置的场景下触发:

    • 内存不足:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。
    • 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位 时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。
  • Swap换入换出的是什么类型的内存?

    • 内核缓存的文件数据,因为都有对应的磁盘文件,所以在回收文件数据的时候, 直接写回到对应的文件就可以了。

    • 但是像进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。

    • 匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

如何避免预读失效和缓存污染问题?

传统的 LRU 算法存在这两个问题:

  • 「预读失效」导致缓存命中率下降

  • 「缓存污染」导致缓存命中率下降

Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。

MySQL 和 Linux 操作系统是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。

  • Linux的缓存

    Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache。Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据即可。

    因此,Page Cache 起到了加速访问数据的作用。

  • MySQL的缓存

    MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),Buffer Pool 属于内存空间里的数据。

  • 传统的 LRU 算法的实现思路是这样的:

    • 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。

    • 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。

  • 预读失效带来的问题

    应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用预读机制(ReadaHead) 机制完成了 16KB 数据的读取。预读机制带来的好处就是减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量。如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效

    在传统的LRU算法中,如果预读页一直不会被访问到,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率

  • 避免预读失效的影响

    要避免预读失效带来影响,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长

    • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
      • 有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。
    • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域
      • 划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。
  • 缓存污染带来的问题

    如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

    当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了

    • 比如,当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。
  • 避免缓存污染造成的影响

    只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉

    • Linux :在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。

    • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断

      • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;

      • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

    通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

操作系统结构

3.1 Linux内核 vs Windows内核

  • 什么是内核?

    计算机使用内核作为应用连接硬件设备的桥梁,应用程序只需关心与内核交互,不用关心硬件的细节。

  • 内核的能力:

    • 管理进程、线程,决定哪个进程、线程使用 CPU,也就是进程调度的能力

    • 管理内存,决定内存的分配和回收,也就是内存管理的能力

    • 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力

    • 提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口

  • 内核具有很高的权限,可以控制 cpu、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,把内存分成了两个区域:

    • 内核空间,这个内存空间只有内核程序可以访问;
    • 用户空间,这个内存空间专门给应用程序使用;
  • 用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此,当程序使用用户空间时,我们常说该程序在用户态执行,而当程序使内核空间时,程序则在内核态执行。

  • 内核程序执行在内核态,用户程序执行在用户态。当应用程序使用系统调用时,会产生一个中断。发生中断后, CPU 会中断当前在执行的用户程序,转而跳转到中断处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把 CPU 执行权限交回给用户程序,回到用户态继续工作。

  • Linux的设计

    Linux 内核设计的理念主要有这几个点:

    • MultiTask,多任务

      多任务意味着可以有多个任务同时执行,这里的「同时」可以是并发或并行:

      • 对于单核 CPU 时,可以让每个任务执行一小段时间,时间到就切换另外一个任务,从宏观角度看,一段时间内执行了多个任务,这被称为并发
      • 对于多核 CPU 时,多个任务可以同时被不同核心的 CPU 同时执行,这被称为并行
    • SMP,对称多处理

      SMP 的意思是对称多处理,代表着每个 CPU 的地位是相等的,对资源的使用权限也是相同的,多个 CPU 共享同一个内存,每个 CPU 都可以访问完整的内存和硬件资源。

      这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应用程序或内核程序,而是每个程序都可以被分配到任意一个 CPU 上被执行。

    • ELF,可执行文件链接格式

      ELF 的意思是可执行文件链接格式,它是 Linux 操作系统中可执行文件的存储格式。执行 ELF 文件的时候,会通过「装载器」把 ELF 文件装载到内存里,CPU 读取内存中的指令和数据,于是程序就被执行起来了。

    • Monolithic Kernel,宏内核

      Monolithic Kernel 的意思是宏内核,Linux 内核架构就是宏内核,意味着 Linux 的内核是一个完整的可执行程序,且拥有最高的权限。

      宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。

      不过,Linux 也实现了动态加载内核模块的功能,例如大部分设备驱动是以可加载模块的形式存在的,与内核其他模块解藕,让驱动开发和驱动加载更为方便、灵活。

  • Windows设计

    • 当今 Windows 7、Windows 10、11 使用的内核叫 Windows NT,NT 全称叫 New Technology。

    • Windows 和 Linux 一样,同样支持 MultiTask 和 SMP,但不同的是,Window 的内核设计是混合型内核,内核中有一个 MicroKernel 模块,这个就是最小版本的内核,而整个内核实现是一个完整的程序,含有非常多模块。

      • 混合型内核:内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核。
    • Windows 的可执行文件格式叫 PE,称为可移植执行文件,扩展名通常是.exe.dll.sys等。

  • 454. 四数相加 II - 力扣(LeetCode)

    两两为一组,先放入a + b的值,后来在Map中寻找0 - (c + d)的值。

    class Solution {
        public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {
            int count = 0;
            int n = nums1.length;
            Map <Integer , Integer> map = new HashMap<>();
            for(int i = 0 ; i < n ; i++){
                int a = nums1[i];
                for(int j = 0 ; j < n ; j ++){
                    int b = nums2[j];
                    int sum0 = a + b;
                    map.put(sum0 , map.getOrDefault(sum0 , 0) + 1);
                }
            }
            for(int i  = 0 ; i< n ; i++){
                int c = nums3[i];
                for(int j = 0 ; j < n ; j ++){
                    int d = nums4[j];
                    int sum1 = c + d;
                    if(map.containsKey(0 - sum1)){
                        count += map.get(0 - sum1);
                    }
                }
            }
            return count;
        }
    }
    
  • 383. 赎金信 - 力扣(LeetCode)

    class Solution {
        public boolean canConstruct(String ransomNote, String magazine) {
            int l = ransomNote.length();
            int map [] = new int [26];
            for(int i = 0 ; i < l ; i++){
                char c = ransomNote.charAt(i);
                map[c - 'a']++;
            }
            for(int i = 0 ; i < magazine.length() ; i++){
                char m = magazine.charAt(i);
                map[m - 'a'] --;
            }
            for(int i : map){
                if(i > 0){
                    return false;
                }
            }
            return true;
        }
    }
    
  • 15. 三数之和 - 力扣(LeetCode)

    • 使用双指针解题:

      拿这个nums数组来举例,首先将数组排序,然后有一层for循环,i从下标0的地方开始,同时定一个下标left 定义在i+1的位置上,定义下标right 在数组结尾的位置上。

      依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]。

      接下来如何移动left 和right呢, 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动,这样才能让三数之和小一些。

      如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。

    • 去重a

      我们要做的是 不能有重复的三元组,但三元组内的元素是可以重复的!

      所以这里是有两个重复的维度。

      那么应该这么写:

      if (i > 0 && nums[i] == nums[i - 1]) {
          continue;
      }
      
      //错误写法:这样就把出现重复元素的情况跳过了。
      if(nums[i] == nums[i + 1]){
        continue;
      }
      

      这么写就是当前使用 nums[i],我们判断前一位是不是一样的元素,在看 {-1, -1 ,2} 这组数据**,当遍历到 第一个 -1 的时候,只要前一位没有-1,那么 {-1, -1 ,2} 这组数据一样可以收录到 结果集里。**

    • 去重b和c

       // 去重逻辑应该放在找到一个三元组之后,对b 和 c去重
          while (right > left && nums[right] == nums[right - 1]) right--;
          while (right > left && nums[left] == nums[left + 1]) left++;
      
    class Solution {
        public List<List<Integer>> threeSum(int[] nums) {
            List<List<Integer>> ans = new ArrayList<>();
            Arrays.sort(nums);
            for(int i = 0; i < nums.length ; i ++){
                if(nums[0] > 0) return ans;
                int left = i + 1;
                int right = nums.length - 1;
                if(i > 0 && nums[i] == nums[i - 1]){
                    continue;
                }
                
                while(left < right){
                    if(nums[i] + nums[left] + nums[right] > 0){
                        right -- ;
                    }else if(nums[i] + nums[left] + nums[right] < 0){
                        left ++;
                    }else{
                        ans.add(Arrays.asList(nums[i] , nums[left] , nums[right]));
    
                        while (right > left && nums[right] == nums[right - 1]) right--;
                        while (right > left && nums[left] == nums[left + 1]) left++;
    
                        left ++;
                        right--;
                    }
                }
            }
            return ans;
        }
    }
    
  • 18. 四数之和 - 力扣(LeetCode)

    思路与三数之和一致。

    class Solution {
        public List<List<Integer>> fourSum(int[] nums, int target) {
            List<List<Integer>> ans = new ArrayList<>();
            Arrays.sort(nums);
            for(int i = 0 ; i < nums.length ; i++){
                if(nums[i] > target && nums[i] > 0) return ans;
    
                //去重
                if(i > 0 && nums[i] == nums[i - 1]){
                    continue;
                }
    
                for(int j = i + 1 ; j < nums.length ; j ++){
                   // if(nums[i] + nums[j] > target && nums[i] + nums[j] > 0) return ans;
    
    
                    //去重
                    if(j > i + 1 && nums[j] == nums[j - 1]){
                        continue;
                    }
                    int left = j + 1;
                    int right = nums.length - 1;
                    while(left < right){
                        long sum = nums[i] + nums[j] + nums[left] + nums[right];
                        if(sum > target){
                            right--;
                        }else if(sum < target){
                            left ++;
                        }else{
                            ans.add(Arrays.asList(nums[i] , nums[j] , nums[left] , nums[right]));
    
                            while(left < right && nums[left] == nums[left + 1]){
                                left++;
                            }
                            while(left < right && nums[right] == nums[right - 1]){
                                right --;
                            }
                            left++;
                            right--;
                        }
                    }
                }
            }
            return ans;
        }
    }
    

数据结构

Redis的键值对中的key就是字符串对象,而value可以是字符串对象,也可以是集合数据类型的对象。

Redis使用一个哈希表来保存所有的键值对,哈希表的最大好处就是可以在O(1)的时间复杂度来快速查找键值对。哈希表就是一个数组,数组中的元素叫做哈希桶。

哈希桶存放指向键值对数据的指针,通过指针找到键值对的数据,键值对数据保存了void * key和void * value指针,分别指向key和value。

void * key 和void * value指向的是Redis对象,Redis中每个对象都由redisObject结构表示。

SDS

Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。

  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

对比C语言,主要有以下优势:

  • O(1)复杂度获取字符串长度

    • C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。

    • 而 Redis 的 SDS 结构因为加入了 len 成员变量,那么获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)

  • 二进制安全

    • 因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。

      通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。

  • 不会发生缓冲区溢出

    • Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc - len 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。

    • 而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。

  • 节省内存空间

    flags成员变量表示SDS类型,SDS 设计了不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。

    除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

链表

Redis在listNode结构体的基础上又封装了list这个数据结构,这样操作起来会更方便。

img

Redis 的链表实现优点如下:

  • listNode 链表节点的结构里带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表
  • list 结构因为提供了表头指针 head 和表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1)
  • list 结构因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1)
  • listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值

链表的缺陷也是有的:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。

不过,压缩列表存在性能问题,所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。

然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。

压缩列表

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

但是,压缩列表的缺陷也是有的:

  • 不能保存过多的元素,否则查询效率就会降低;
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。

因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。

  • 压缩列表在表头有三个字段:

    • zlbytes,记录整个压缩列表占用对内存字节数;

    • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;

    • zllen,记录压缩列表包含的节点数量;

    • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

    在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素

  • 压缩列表节点包含三部分内容:

    • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;

    • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。

    • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

    当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的

  • 压缩列表除了查找复杂度高的问题,还有一个问题。

    • 压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降
  • 压缩列表的缺陷

    • 虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。可能会导致每个元素的空间都要重新分配。

    • 因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。

哈希表

哈希表是一种保存键值对(key-value)的数据结构。哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value等等。

Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。

  • 链式哈希

    实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。

  • rehash

    • 在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

      • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;

      • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;

      • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

    • 这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求

  • 渐进式rehash

    为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。

    渐进式 rehash 步骤如下:

    • 给「哈希表 2」 分配空间;

    • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上

    • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

    这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。

    在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。

    另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。

  • rehash触发条件

    负载因子 = 哈希表已保存的节点数量 / 哈希表大小

    • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
    • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作

整数集合

整数集合是Set对象的底层实现之一。当一个Set对象只包含整数值元素,并且元素数量不大,就会使用整数集这个数据结构作为底层实现。

  • 整数集合本质上是一块连续内存空间,它的结构定义如下:
typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

​ 可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。不同类型的 contents 数组,意味着数组的大小也会不同。

  • 整数集合的升级操作

    整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。

    整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。

  • 整数集合升级有什么好处?

    如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组,只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。

    因此,整数集合升级的好处是节省内存资源

  • 整数集合支持降级操作吗?

    不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。

跳表

  • Zset对象

    Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

    zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。

    Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。

    Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。

    Zset 对象在使用跳表作为数据结构的时候,是使用由「哈希表+跳表」组成的 struct zset,但是struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。

  • 跳表结构设计

    链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。

    那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下:

    typedef struct zskiplistNode {
        //Zset 对象的元素值
        sds ele;
        //元素权重值
        double score;
        //后向指针
        struct zskiplistNode *backward;
      
        //节点的level数组,保存每层上的前向指针和跨度
        struct zskiplistLevel {
            struct zskiplistNode *forward;
            unsigned long span;
        } level[];
    } zskiplistNode;
    

    Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

    跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组

    level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。跨度实际上是为了计算这个节点在跳表中的排位

  • 跳表节点查询过程

    查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

    • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
    • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。

    如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。

  • 跳表节点层数设置

    跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)

    如何将比例控制在2:1呢?Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。

    具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数

    这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

    虽然我前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点

  • 为什么用跳表而不用平衡树?

    • 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
    • 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
    • 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sivan_Xin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值