2024年1月

更多技术交流、求职机会,欢迎关注字节跳动数据平台微信公众号,回复【1】进入官方交流群

随着LLM技术应用及落地,数据库需要提高向量分析以及AI支持能力,向量数据库及向量检索等能力“异军突起”,迎来业界持续不断关注。简单来说,向量检索技术以及向量数据库能为 LLM 提供外置的记忆单元,通过提供与问题及历史答案相关联的内容,协助 LLM 返回更准确的答案。

不仅仅是LLM,向量检索也早已在OLAP引擎中应用,用来提升非结构化数据的分析和检索能力。ByteHouse是火山引擎推出的云原生数据仓库,近期推出高性能向量检索能力,本篇将结合ByteHouse团队对向量数据库行业和技术的前沿观察,详细解读OLAP引擎如何建设高性能的向量检索能力,并最终通过开源软件VectorDBBench测试工具,在 cohere 1M 标准测试数据集上,recall 98 的情况下,QPS性能已可以超过专用向量数据库(如milvus)。

向量检索现状分析

向量检索定义

对于诸如图片、视频、音频等非结构化数据,传统数据库方式无法进行处理。目前,通用的技术是把非结构化数据通过一系列 embedding 模型将它变成向量化表示,然后将它们存储到数据库或者特定格式里。在搜索过程中,通过相同的一个模型把查询项转化成对应的向量,并进行一个近似度的匹配就可以实现对非结构化数据的查询。

在技术原理层面,向量检索主要是做一个 K Nearest Neighbors (K最近邻,简称 KNN) 计算,目标是在N个D维的向量的库中找最相似的k个结果。

在数据量较大场景,KNN 计算通常代价比较大,很难在较短时间内返回结果,此外,在很多场景,用户并不需要绝对精确的相似结果。因此,在真正在使用向量检索时,通常会使用相似最近邻搜索,即 ANN 的方式来替代 KNN,从 k 个绝对最近似结果变成 K 个近似最优结果,以牺牲一定准确度的前提,得到更短的响应时间。

picture.image

picture.image

LLM与向量检索

由于大模型的训练数据有限,在针对一些最近的消息或者特定领域信息的查询来说,通常结果不准确。为了提升检索的准确性,一种比较常见的处理方式是将想搜索的信息的相关文档进行文本处理,并通过 embedding 模型将向量写入到向量数据库里后,把问题通过相同的 embedding 模型转化为向量进行近似度搜索,得到问题的相似知识作为 prompt,连同问题一起提交给大模型处理,最终得到更准确的答案。

picture.image

向量检索的四种算法(索引)

向量检索算法基于其存储结构大致可分为四种。

  • 第一种是 Table-based,典型算法如 LSH。
  • 第二种是 Tree-based,是把向量根据相似度去构造成一个树的结构。
  • 第三种是 Cluster-based,也称为 IVF(Inverted File),把向量先进行聚类处理,检索时首先计算出最近的 k 个聚类中心,再在这些聚类中心中计算出最近的 k 个向量。这种索引的优点是构建速度快,因为构建时只需要多一个 training 的过程。相比于其他常用索引(主要是 Graph-based 索引),只需要额外存储倒排表和聚类中心结构,所以内存额外占用比较少。但也存在相应的缺点,由于每次查询要把聚类中心里面所有的向量都遍历一遍,所以它的查询速度受维度信息影响较大且高精度查询计算量比较大,计算开销大。这类索引通常还会结合一些量化算法来使用,包括 SQ、PQ等。
  • 第四种是Graph-based, 把向量按照相似度构建成一个图结构,检索变成一个图遍历的过程。常用算法是HNSW。它基于关系查询,并以构建索引时以及构建向量之间的关系为核心,而主要技术则是highway和多层优化方式。这种算法的优点是查询速度快、并发性能好;而缺点则表现为构建速度慢、内存占用高。

目前实际场景中,使用较多的方法主要是后面的两种,即 Cluster-based 和 Graph-based。

picture.image

picture.image

picture.image

picture.image

如何构建向量数据库

首先,一个向量数据库需要具备向量类型数据和向量索引的存储与管理相关功能,包括增删改查等数据维护功能,另外,对于向量检索性能通常要求比较高。其次,向量检索通常需要与属性过滤等操作结合计算。最后,向量检索通常会与其他属性结合查询,比如以图搜图等场景,最终需要的,是相似的图片路径或文件。

构建向量数据库时,一种思路是以向量为中心,从底向上构建一个专用的向量数据库,这样的特点是,可以针对向量检索做特定的优化,能够保证较高的性能,缺点为缺乏复杂的数据管理和查询能力,通常需要结合其他数据库来使用。

另一种设计思路是基于现有的数据库和数据引擎增加向量检索相关扩展功能。优势是可以做到 all in one 的数据管理和查询支持,缺点为受现有架构的限制,很难做到较高的检索性能。

picture.image

向量数据库的当前进展

向量数据库目前还处于一个快速发展的阶段,目前看有两个趋势,一个是以专用向量数据库为基础,不断添加更多复杂的数据类型支持以及更多的数据管理机制,比如存算分离、一致性支持、实时导入等。此外,查询上也在不断添加前后置过滤等复杂查询策略的支持。

第二种构建思路是数据库加向量检索扩展,继续去支持更多的向量检索算法,并且不断按照向量检索的需求,添加特殊的过滤策略、简化对应的执行计划。

以上两种构建思路都在向一个统一的目标去汇合,即带有高性能向量检索,与完备数据管理和查询支持的数据库形态。这也是 ByteHouse 在设计向量检索相关功能时,主要考虑的一个目标。

picture.image

picture.image

ByteHouse的高性能向量检索方案

ByteHouse是火山引擎研发的云原生数据仓库产品,在开源ClickHouse引擎之上做了技术架构重构,实现了云原生环境的部署和运维管理、存储计算分离、多租户管理等功能。在可扩展性、稳定性、可运维性、性能以及资源利用率方面都有巨大的提升。

此外,ByteHouse还支持了向量检索、全文检索、地理空间数据检索等功能。

ByteHouse 作为一款高性能向量数据库的底座的优势在于,其具备比较完备的 SQL 语法支持,高性能的计算引擎,以及比较完备的数据管理机制和丰富的数据表引擎,能够支持不同场景。

为了达到更高的向量检索性能,ByteHouse 基于向量为中心的设计思路,构建了一条高效的向量检索的执行路径,同时,引入了多种常用的向量检索算法,以满足不同场景的向量检索需求。

picture.image

picture.image

picture.image

设计方案

主要设计思路

  • 在 Query 执行过程中,针对向量检索相关查询,从语法解析到执行算子进行了短路改造,同时,引入特殊的执行算子,减少计算冗余与 IO 开销。
  • 添加了专用的 Vector Index 管理模块,包含 向量检索库、向量检索执行器、缓存管理、元数据管理等组件。
  • 存储层添加 Vector Index 相关读写支持,每个 data part 维护一个 Vector Index 持久化文件。

picture.image

基本使用方式

实际使用时,在建表时可以加一个 Index 的定义,包含索引名称、向量列、以及索引类型信息。

数据导入支持多种方式,比如基于 Kafka 的实时导入,insert file,python SDK 等。

基本查询是一个定式:select 需要的列信息,增加一个 order by + limit 的指令。查询支持与标量信息结合的混合查询,以及针对 distance 的 range 查询。

picture.image

picture.image

picture.image

picture.image

picture.image

遇到的挑战

在添加高性能向量检索功能过程中,ByteHouse 主要克服以下三大难点:

读放大问题

根本原因:ByteHouse 中,当前最小的读取单元是一个 mark,即便通过 Vector Index 查询得到结果是有行号信息的,但是在真正读取的时候仍需要转成对应的 mark id 传给下层存储层读取。

优化:

1.把向量检索的计算进行了前置处理。

最初的设计中,向量检索计算时需要每个 data part 首先做 Vector Search 加上其他列信息的读取,然后再去做后面的 order by + limit 得到最终的结果。这种做法相当于每个data part 要取 top k,它的读取的行数是 part 数量乘以 mark_size 乘以 top k。这里做的优化是将 Vector Search 计算前置,上推到 data part 的读取之前,首先执行所有 data part 的 Vector Search,获取全局的 topK 个结果,再分配到各个 data part 去做 read。这样可以实现 IO 从百万减到千的级别的降低,实际使用中整体性能实现了两倍以上的提升。

2.存储层的过滤。

把 row level 的查询结果往下推到存储层读 mark 的位置进行一些过滤,减少了反序列化的开销。

3.在 filter by range 场景进行优化。

基于主键查找如按天查找或者按 label 查找等场景,只对首尾 mark 进行了一个读取和过滤,降低过滤语句的执行开销。

picture.image

picture.image

picture.image

picture.image

构建资源消耗大

根本原因:相比于其他比较简单的索引,如 MinMax 等,向量索引构建时间更长,并且消耗资源更多。

优化:

  1. 在 Build Threads 和 Background Merge Tasks 做了并发限制。
  2. 构建过程中内存使用优化,把一些完全在内存里面进行的计算做成了 Disk-based,增加了 memory buffer 的机制。此优化主要是对 IVF 类型的索引进行的。

picture.image

冷读问题

原因:使用索引需要 Index 结构载入内存,载入到内存后才能进行一些检索加速。

优化:

加入Cache Preload 支持。在服务启动和数据写入以及后台数据 merge 的场景可以自动地把新的 index load 到内存。另外,自动的 GC 会把 Cache 中过期数据自动回收。

picture.image

最终效果

ByteHouse 团队基于业界最新的 VectorDBBench 测试工具进行测试,在 cohere 1M 标准测试数据集上,recall 98 的情况下,可以达到与专用向量数据库同等水平的性能。在 recall 95 以上的情况下,QPS 可以达到 2600 以上,p99 时延在 15ms 左右,具备业界领先优势。

性能评测

  • QPS:即评测在不断扩大并发度的前提下,它的QPS最终能达到多少。在同时用HNSW索引情况下,ByteHouse可以达到甚至超过 Milvus
  • Recall:在精确度同等都是98的recall下,QPS才有意义
  • Load duration:即评测数据从外部添加到系统的时间,包括数据写入和 vector index built 的时间。整个过程包括数据写入和整体时间 ByteHouse 都比 Milvus 好一些。
  • Serial Latency P99:串行执行 1万条查询,P99 latency。这个 case 下 ByteHouse 要比 Milvus 性能差一些。主要原因是 ByteHouse IO 和 query 解析上仍有一些额外的开销,有很多需要优化的地方,对于小的查询还没有达到一个比较理想的状态。

picture.image

不同索引评测

评测对象:IVFPQFS+Refine(SQ8)和HNSW。

IVFPQFS+Refine(SQ8) 优点:

  • 在 recall 要求不高的前提下,内存占用资源比较少。(是HNSW的三分之一)
  • 可以降低 refine 的精度,进一步减少内存占用
  • 内存资源受限,写入场景频繁

痛点:

很难在高精度的场景下替换HNSW

picture.image

未来计划

  • 构建资源需求更少、性能更好的索引结构,比如 on-disk indices 的索引接入以及更好的压缩算法策略。
  • 怎样更好地将向量检索和其他查询结果操作进行融合。比如:复杂过滤语句、基于UDF的Embedding计算融合以及全文检索支持
  • 性能优化。如何与优化器结合,以及点查场景优化
  • 易用性与生态。目前正在接入 langchain 等 LLM 框架,后续会进一步思考怎么样在大模型场景以及其他向量检索场景中做到更好的易用性。

picture.image

点击跳转
ByteHouse
了解更多

image

简介

监视指定目录的更改,并将有关更改的信息打印到控制台,该功能的实现不仅可以在内核层,在应用层同样可以。程序中使用 ReadDirectoryChangesW 函数来监视目录中的更改,并使用 FILE_NOTIFY_INFORMATION 结构来获取有关更改的信息。

ReadDirectoryChangesW 是Windows提供一个函数,它属于Windows API的一部分,主要用于监视文件系统中目录的修改、新增、删除等变化,并通过回调函数向应用程序提供通知。该API很实用,目前市面上已知的所有运行在用户态同步应用,都绕不开这个接口。但正确使用该API相对来说比较复杂,该接口能真正考验一个Windows开发人员对线程、异步IO、可提醒IO、IO完成端口等知识的掌握情况。

其函数原型为:

BOOL WINAPI ReadDirectoryChangesW(
  _In_        HANDLE                          hDirectory,
  _Out_       LPVOID                          lpBuffer,
  _In_        DWORD                           nBufferLength,
  _In_        BOOL                            bWatchSubtree,
  _In_        DWORD                           dwNotifyFilter,
  _Out_opt_   LPDWORD                         lpBytesReturned,
  _Inout_opt_ LPOVERLAPPED                    lpOverlapped,
  _In_opt_    LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
  • hDirectory:要监视的目录的句柄。
  • lpBuffer:接收变更通知的缓冲区。
  • nBufferLength:缓冲区的大小。
  • bWatchSubtree:如果为 TRUE,则监视目录树中的所有目录。如果为 FALSE,则仅监视指定的目录。
  • dwNotifyFilter:指定要监视的变更类型,可以是文件夹或文件的新增、删除、修改等。
  • lpBytesReturned:返回实际读取到的字节数。
  • lpOverlapped:用于异步操作的 OVERLAPPED 结构。
  • lpCompletionRoutine:指定一个回调函数,在异步操作完成时调用。

由于该函数提供了丰富的调用方式,包括同步和异步方式。异步方式可以采用以下三种方式获取完成通知:

  • 在OVERLAPPED结构中的hEvent成员中设置一个事件句柄,使用GetOverlappedResult 获取完成结果。
  • 使用可提醒IO, 在参数lpComletionRoutine指定一个回调函数。当ReadDirectoryChangesW异步请求完成时,驱动会将指定的回调函数(lpComletionRoutine)投递到调用线程的APC队列中。对可提醒IO,OVERLAPPED结构中的hEvent 字段操作系统并不使用,我们可以自己使用该值。
  • 使用IO完成端口,通过GetQueuedComletionStatus获取完成结果。

同步方式比较简单,但不具可伸缩性,在实际应用中并不多。不同的异步方式也影响到线程模型的选择,所以如何正确使用该函数其实并不容易。

使用可提醒 IO

可提醒IO是异步IO的一种,为了支持可提醒IO, Windows为线程都增加了一个基础设施——APC(异步过程调用),即每个线程都有一个APC队列。当线程处理于可提醒状态时,系统会检测该线程的APC队列是否为空,如果不会空,系统会依次取出队列中的APC进程调用。

采用可提醒IO时,需要设置一个完成回调函数ReadDirectoryChangesW。当发起异步IO请求后,调用线程不会被阻塞,系统会将该异步请求交给驱动程序,驱动程序将该请求加入到请求队列中,当异步请求完成时,驱动程序会将完成回调函数加入到发起线程的APC队列中,当发起线程处于可提醒状态时,该完成回调函数就会被执行。

Windows提供了6个API,可以将线程置为可提醒状态,分别是:

SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx、SignalObjectAndWait、GetQueuedCompletionStatusEx、MsgWaitForMultipleObjectsEx。

利用线程的APC队列,可以创建一个工作线程,该线程采用可提醒IO方式循环等待APC调用,当我们在工作线程中发起一个ReadDirectoryChangesW请求时,线程被挂起,当一个请求完成时,会将完成回调函数加入到线程的APC队列中,系统检测到APC队列不为空,线程会被唤醒,并取出APC队列中的一项进行调用,当APC队列为空中,线程会被再次挂起,直到APC队列中出现一项新的项。

读者可能会觉得上面的流程很复杂,其实实现很简单,复杂的东西都由系统帮我们做了,我们使用SleepEx使工作线程变为可提醒状态,工作线程代码如下:

    while (!m_bTerminate || HasOutstandingRequests())
    {
        ::SleepEx(INFINITE, true);
    }

有了工作线程帮我们处理完成回调函数的调用,我们还需要在该工作线程中发起一个ReadDirectoryChangesW请求,在请求时需要指定一个完成回调函数(最后一个参数)。对于倒数第二个参数OVERLAPPED,对可提醒IO来讲,系统并不关心hEvent,所以可以将该参数设计为业务相关的数据进行传递,在实现时设置为了一个请求对象的指针(具体参考代码实现),ReadDirectoryChangesW 请求代码如下:

BOOL success = ::ReadDirectoryChangesW(
        GetDirectoryHandle(),               // handle to directory
        GetBuffer(),                        // read results buffer
        GetBufferSize(),                    // length of buffer
        IsWatchSubTree(),                   // monitoring option
        GetNotifyFilter(),                  // filter conditions
        NULL,                               // bytes returned
        this,                               // overlapped buffer
        &FileIoCompletionRoutine);          // completion routine

完成回调函数需要我们自己实现,原型为:

VOID CALLBACK FileIOCompletionRoutine(
  _In_    DWORD        dwErrorCode,
  _In_    DWORD        dwNumberOfBytesTransfered,
  _Inout_ LPOVERLAPPED lpOverlapped
);

读者可能会疑问,怎么让ReadDirectoryChangesW请求在工作线程中执行呢?Windows为我们提供了以下API,可以将一个APC投递到一个指定线程的APC队列中:

DWORD QueueUserAPC(
  PAPCFUNC  pfnAPC,
  HANDLE    hThread,
  ULONG_PTR dwData
);

有了上面这个利器,我们可以很方便的在线程间通信,为了简化代码复杂度,采用无锁设计,我将添加文件夹、读取文件夹变更请求、移除文件夹、结束请求都投递到该工作线程中执行,并约定一些类成员变量只能在该线程中访问。

需要注意的是,由于我们需要不断监控文件夹的磁盘变更情况,所以在FileIOCompletionRoutine中处理完文件夹的变更数据后,需要再次发起一次ReadDirectoryChangesW请求,这样就形成了一条变更链,实现文件夹实时磁盘监控。

使用IO完成端口

IO完成端口,是Windows为打造一个出色服务器环境,提高应用程序性能而提出的解决方案。关于IO完成端口的背景知识并不是本文的重点,不熟悉的读者请自行补充。

ReadDirectoryChangesW 支持采用IO完成端口方式读取文件夹磁盘变更,为了简单起见,在不考虑线程模型的情况下,其流程大概如下:

1. 创建一个IO完成端口;
2. 打开一个文件夹;
3. 将打开的文件夹句柄关联到一个IO完成端口上;
4. 发起一次ReadDirectoryChangesW请求;
5. 调用GetQueuedCompletionStatus获取完成通知;
6. 处理完成通知;
7. 关闭文件夹句柄;
8. 关闭IO完成端口;

在第5步中,调用GetQueuedCompletionStatus会阻塞调用线程,在实际应用中,我们经常会在一个工作线程中调用GetQueuedCompletionStatus。为了实时监控文件夹的磁盘变更,我同样会创建一个工作线程,且该线程只用于处理IO完成端口的完成通知,代码如下:

    while (1)
    {
        ULONG_PTR pCompKey = NULL;
        DWORD dwNumberOfBytes = 0;
        OVERLAPPED* pOverlapped = NULL;
        BOOL bRet = m_iocp.GetStatus(&pCompKey, &dwNumberOfBytes, &pOverlapped);
        DWORD dwLastError = ::GetLastError();
        if (bRet)
        {
            ProcessIocpSuccess(pCompKey, dwNumberOfBytes, pOverlapped);
        }
        else
        {
            if (!ProcessIocpError(dwLastError, pOverlapped))
            {
                break;
            }
        }
    }

工作线程就绪后,在做完2,3步之后,仍然需要发起一个ReadDirectoryChangesW请求,对于IO完成端口,虽然请求并不是一定要在工作线程中执行,但我们仍然需要这样做,理由是除了简化我们的编程模型之外,也能使线程更容易得体地退出(稍后会说)。

跟可提醒IO不同的是,发起一个ReadDirectoryChangesW 请求时,IO完成端口会使用OVERLAPPED中的hEvent,所以我们不能将其设为一个请求对象的指针,而应该设为NULL, 但为了在上下文中传递请求对象指针,使用了点技巧,即将请求对象继承自OVERLAPPED,再将请求对象的指针传入即可(具体参考代码);另外并不需要再指定完成回调函数,如下:

BOOL success = ::ReadDirectoryChangesW(
        GetDirectoryHandle(),               // handle to directory
        GetBuffer(),                        // read results buffer
        GetBufferSize(),                    // length of buffer
        IsWatchSubTree(),                   // monitoring option
        GetNotifyFilter(),                  // filter conditions
        NULL,                               // bytes returned
        this,                               // overlapped buffer
        NULL);                              // completion routine

同样,我们怎样让ReadDirectoryChangesW请求在工作线程中执行呢,幸运的是Windows提供了API:

BOOL WINAPI PostQueuedCompletionStatus(
  _In_     HANDLE       CompletionPort,
  _In_     DWORD        dwNumberOfBytesTransferred,
  _In_     ULONG_PTR    dwCompletionKey,
  _In_opt_ LPOVERLAPPED lpOverlapped
);

以上API可以在任何线程中调用,将一个和完成键dwCompletionKey关联的数据投递到任何一个调用GetQueuedCompletionStatus的线程,当然这里只是我们的工作线程。这使得其它线程可以很容易和工作线程通信。

同样为了简化代码复杂度,采用无锁设计,仍然将添加文件夹、读取文件夹变更请求、移除文件夹、结束请求都投递到该工作线程中执行,并约定一些类成员变量只能在该线程中访问。

如何退出工作线程

取消一个ReadDirectoryChangesW请求,可以使用CancelIo或CancelIoEx,这两个API的区别是,CancelIo只能取消调用线程关联的IO设备;而CancelIoEx可以取消指定线程关联的IO设备;但CancelIoEx只能在Vista及之后的系统中使用,为了让代码能正常工作于XP及以后的系统,我使用了CancelIo,这也是为什么我在使用IO完成端口的时候也要将请求放到工作线程中去执行的原因。

    1. 可提醒IO退出
      如上所说,CancelIo需要在工作线程中去执行,我们先将m_bTerminate设为true, 再调用QueueUserAPC将一个退出请求投递到工作线程中,然后在工作线程中调用CancelIO,之后,系统会将完成回调函数加入到工作线程的APC队列中,并且将dwErrorCode设为ERROR_OPERATION_ABORTED,当收到该错误时,我们释放请求对象占用的系统资源,当所有请求对象都释放时,工作线程中的while循环结束,线程正常退出。
    1. IO完成端口退出
      和可提醒IO退出方式不同的是,GetQueuedCompletionStatus的错误处理稍微复杂一点,是采用GetLastError获得,同样在收到错误码为ERROR_OPERATION_ABORTED时,释放请求对象占用的系统资源,当所有请求对象都释放时,工作线程中的while循环结束,线程正常退出。

代码结构

为了同时支持可提醒IO和IO完成端口异步请求的方式调用ReadDirectoryChangesW, 代码做了一些抽象,采用C/S模型。将ReadDirectoryChangesW调用封装到了CReadDirectoryRequest类中,根据不同的异步模型派生出CCompletionRoutineRequest和CIoCompletionPortRequest类;

同样工作线程封装到了CReadDirectoryServer类中,根据不同的异步模型,派生出CCompletionRoutineServer和CIoCompletionPortServer类;

CReadDirectoryChanges类管理CReadDirectoryServer对象的生命周期,并维护一个线程安全的队列用于缓存文件夹的变更数据,同时对客户端暴露基本服务接口。框架结构如下图所示:

image

完整代码项目

以下代码中使用CreateThread函数创建一个线程,并将MonitorFileThreadProc运行起来,此函数使用带有FILE_LIST_directory标志的CreateFile打开指定的目录,该标志允许该函数监视目录。并使用ReadDirectoryChangesW函数读取目录中的更改,传递一个缓冲区来存储更改,并指定要监视的更改类型。

使用WideCharToMultiByte函数将宽字符文件名转换为多字节文件名,并将文件名与目录路径连接以获得文件的完整路径。然后,该功能将有关更改的信息打印到控制台。

#include <stdio.h>
#include <Windows.h>
#include <tlhelp32.h>
 
DWORD WINAPI MonitorFileThreadProc(LPVOID lParam)
{
  char *pszDirectory = (char *)lParam;
  BOOL bRet = FALSE;
  BYTE Buffer[1024] = { 0 };
 
  FILE_NOTIFY_INFORMATION *pBuffer = (FILE_NOTIFY_INFORMATION *)Buffer;
  DWORD dwByteReturn = 0;
  HANDLE hFile = CreateFile(pszDirectory, FILE_LIST_DIRECTORY, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
    NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL);
  if (INVALID_HANDLE_VALUE == hFile)
    return 1;
 
  while (TRUE)
  {
    ZeroMemory(Buffer, sizeof(Buffer));
    // 设置监控目录回调函数
    bRet = ReadDirectoryChangesW(hFile,&Buffer,sizeof(Buffer),TRUE,
      FILE_NOTIFY_CHANGE_FILE_NAME |      // 修改文件名
      FILE_NOTIFY_CHANGE_ATTRIBUTES |     // 修改文件属性
      FILE_NOTIFY_CHANGE_LAST_WRITE,      // 最后一次写入
      &dwByteReturn, NULL, NULL);
    if (TRUE == bRet)
    {
      char szFileName[MAX_PATH] = { 0 };
 
      // 将宽字符转换成窄字符,宽字节字符串转多字节字符串
      WideCharToMultiByte(CP_ACP,0,pBuffer->FileName,(pBuffer->FileNameLength / 2),
        szFileName,MAX_PATH,NULL,NULL);
 
      // 将路径与文件连接成完整文件路径
      char FullFilePath[1024] = { 0 };
      strncpy(FullFilePath, pszDirectory, strlen(pszDirectory));
      strcat(FullFilePath, szFileName);
 
      switch (pBuffer->Action)
      {
        case FILE_ACTION_ADDED:
        {
          printf("添加: %s \n", FullFilePath); break;
        }
        case FILE_ACTION_REMOVED:
        {
          printf("删除: %s \n", FullFilePath); break;
        }
        case FILE_ACTION_MODIFIED:
        {
          printf("修改: %s \n", FullFilePath); break;
        }
        case FILE_ACTION_RENAMED_OLD_NAME:
        {
          printf("重命名: %s", szFileName);
          if (0 != pBuffer->NextEntryOffset)
          {
            FILE_NOTIFY_INFORMATION *tmpBuffer = (FILE_NOTIFY_INFORMATION *)
              ((DWORD)pBuffer + pBuffer->NextEntryOffset);
            switch (tmpBuffer->Action)
              {
                case FILE_ACTION_RENAMED_NEW_NAME:
                {
                  ZeroMemory(szFileName, MAX_PATH);
                  WideCharToMultiByte(CP_ACP,0,tmpBuffer->FileName,
                    (tmpBuffer->FileNameLength / 2),
                    szFileName,MAX_PATH,NULL,NULL);
                  printf(" -> %s \n", szFileName);
                  break;
                }
              }
          }
          break;
        }
        case FILE_ACTION_RENAMED_NEW_NAME:
        {
          printf("重命名(new): %s \n", FullFilePath); break;
        }
      }
    }
  }
  CloseHandle(hFile);
  return 0;
}
 
int main(int argc, char * argv[])
{
  char *pszDirectory = "C:\\";
 
  HANDLE hThread = CreateThread(NULL, 0, MonitorFileThreadProc, pszDirectory, 0, NULL);
  WaitForSingleObject(hThread, INFINITE);
  CloseHandle(hThread);
  system("start https://www.chwm.vip/?ReadDirectoryChangesW");
  return 0;
}

效果演示 :

image

docker-logo

这篇文章主要介绍了 Docker 如何利用 Linux 的 Control Groups(cgroups)实现容器的资源隔离和管理。

最后通过简单 Demo 演示了如何使用 Go 和 cgroups 交互。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


1.Docker 是如何使用 Cgroups 的

我们知道 Docker 是通过 Cgroups 实现容器资源限制和监控的,那么具体是怎么用的呢?

演示

包含以下步骤:

  • 1)创建容器,指定内存限制
  • 2)查看 cgroup 情况
  • 3)停止容器
  • 4)再次查看 cgroup 情况

先启动一个容器:

[root@iZ2zefmrr626i66omb40ryZ memory]# docker run -itd -m 128m nginx
da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416

这里通过
-m
参数设置了内存限制为 128M。

该命令执行后 docker 会在 memory cgroup 上(也就是
/sys/fs/cgroup/memory
路径下)创建一个叫 docker 的子 cgroup,具体如下:

$ ls -l /sys/fs/cgroup/memory/docker/
-rw-r--r-- 1 root root 0 Jan  6 19:53 cgroup.clone_children
--w--w--w- 1 root root 0 Jan  6 19:53 cgroup.event_control
-rw-r--r-- 1 root root 0 Jan  6 19:53 cgroup.procs
# 可以发现这一长串ID和创建容器时打印的是一致的
drwxr-xr-x 2 root root 0 Jan  6 19:56 da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416
# 省略其他文件

内部除了 cgroup 相关的文件外,还有很多目录,
使用容器 ID 作为目录名,其中每个目录即对应一个容器

其中,
da82f9e...
这个目录名称和容器 ID 一致,说明 docker 是为每个容器创建了一个子 cgroup 来单独限制。

查看一下里面的具体配置:

[root@iZ2zefmrr626i66omb40ryZ docker]# cd da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416/
[root@iZ2zefmrr626i66omb40ryZ da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416]# cat memory.limit_in_bytes
134217728

可以发现,memory.limit_in_bytes 中配置的值为 134217728,转换一下
134217728/1024/1024=128M
, 刚好就是我们指定的 128M。

然后我们停止该容器

docker stop da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416

再次查看 cgroup 情况

ls -l /sys/fs/cgroup/memory/docker/|grep da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416

发现目录已经被删除,说明容器对应的子 cgroup 也同步被回收。

把停止的容器 start 一下看看

docker start da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416

再次查看 cgroup 情况

[root@docker ~]# ls -l /sys/fs/cgroup/memory/docker/|grep da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416
drwxr-xr-x 2 root root 0 Jan  6 19:58 da82f9ebd384730dda7f831b4331c9e55893c100c83c0c9b0ce112436aa93416

可以看到,同名目录又被创建出来了。

至此,演示完成。

结论:Docker 容器启动时创建容器 ID 同名子 group 以实现资源控制,容器停止时删除该子 cgroup。

Demo 中只演示了内存限制,其他资源也是类似的

小结

所以 docker 使用 cgroup 其实很简单,

  • 1)为每个容器创建一个子 cgroup
  • 2)根据 docker run 时提供的参数调整 cgroup 中的配置
  • 3)容器被删除时同步删除对应子 cgroup

2.Cgroups 相关操作命令

这里记录一下 cgroups 的一些常用操作命令。

hierarchy

创建

由于 Linux Cgroups 是基于内核中的 cgroup virtual filesystem 的,所以创建 hierarchy 其实就是将其挂载到指定目录。

语法为:
mount -t cgroup -o subsystems name /cgroup/name

  • 其中 subsystems 表示需要挂载的 cgroups 子系统
  • /cgroup/name 表示挂载点(一般为具体目录)

这条命令同在内核中创建了一个 hierarchy 以及一个默认的 root cgroup。

例如:

$ mkdir cg1
$ mount -t cgroup -o cpuset cg1 ./cg1

比如以上命令就是挂载一个 cg1 的 hierarchy 到 ./cg1 目录,如果指定的 hierarchy 不存在则会新建。

hierarchy 创建的时候就会就会自动创建一个 cgroup 以作为 cgroup 树中的 root 节点。

删除

删除 hierarchy 则是卸载。

语法为:
umount /cgroup/name

  • /cgroup/name 表示挂载点(一般为具体目录)

例如:

$ umount ./cg1

以上命令就是卸载 ./cg1 这个目录上挂载的 hierarchy,也就是前面挂载的 cg。

hierarchy 卸载后,相关的 cgroup 都会被删除。

不过 cg1 目录需要手动删除。

默认文件含义

hierarchy 挂载后会生成一些文件,具体如下:

为了避免干扰,未关联任何 subsystem

$ mkdir cg1
$ mount -t cgroup -o none,name=cg1 cg1 ./cg1
$ tree cg1
cg1
├── cgroup.clone_children
├── cgroup.procs
├── cgroup.sane_behavior
├── notify_on_release
├── release_agent
└── tasks

具体含义如下:

  • cgroup.clone_children
    :这个文件只对 cpuset subsystem 有影响,当该文件的内容为 1 时,新创建的 cgroup 将会继承父 cgroup
    的配置,即从父 cgroup 里面拷贝配置文件来初始化新
    cgroup,可以参考
    cgroup.clone_children
  • cgroup.procs
    :当前 cgroup 中的所有
    进程
    ID,系统不保证 ID 是顺序排列的,且 ID 有可能重复
  • cgroup.sane_behavior
    :这个文件只会存在于 root cgroup 下面,用于控制某些特性的开启和关闭。
    • 由于 cgroup 一直再发展,很多子系统有很多不同的特性,因此内核用
      CGRP_ROOT_SANE_BEHAVIOR
      来控制
  • notify_on_release
    :该文件的内容为 1 时,当 cgroup 退出时(不再包含任何进程和子 cgroup),将调用 release_agent 里面配置的命令。
    • 新 cgroup 被创建时将默认继承父 cgroup 的这项配置。
  • release_agent
    :里面包含了 cgroup 退出时将会执行的命令,系统调用该命令时会将相应 cgroup 的相对路径当作参数传进去。
    • 注意:这个文件只会存在于 root cgroup 下面,其他 cgroup 里面不会有这个文件。
    • 相当于配置一个回调用于清理资源。
  • tasks
    :当前 cgroup 中的所有
    线程
    ID,系统不保证 ID 是顺序排列的

cgroup.procs 和 tasks 的区别见 cgroup 操作章节。

release_agent 演示

当一个 cgroup 里没有进程也没有子 cgroup 时,release_agent 将被调用来执行 cgroup 的清理工作。

具体操作流程:

  • 首先需要配置 notify_on_release 以开启该功能。
  • 然后将脚本内容写入到 release_agent 中去。
  • 最后 cgroup 退出时(不再包含任何进程和子 cgroup)就会执行 release_agent 中的命令。
#创建新的cgroup用于演示
dev@ubuntu:~/cgroup/demo$ sudo mkdir test
#先enable release_agent
dev@ubuntu:~/cgroup/demo$ sudo sh -c 'echo 1 > ./test/notify_on_release'

#然后创建一个脚本/home/dev/cgroup/release_demo.sh,
#一般情况下都会利用这个脚本执行一些cgroup的清理工作,但我们这里为了演示简单,仅仅只写了一条日志到指定文件
dev@ubuntu:~/cgroup/demo$ cat > /home/dev/cgroup/release_demo.sh << EOF
#!/bin/bash
echo \$0:\$1 >> /home/dev/release_demo.log
EOF

#添加可执行权限
dev@ubuntu:~/cgroup/demo$ chmod +x ../release_demo.sh

#将该脚本设置进文件release_agent
dev@ubuntu:~/cgroup/demo$ sudo sh -c 'echo /home/dev/cgroup/release_demo.sh > ./release_agent'
dev@ubuntu:~/cgroup/demo$ cat release_agent
/home/dev/cgroup/release_demo.sh

#往test里面添加一个进程,然后再移除,这样就会触发release_demo.sh
dev@ubuntu:~/cgroup/demo$ echo $$
27597
dev@ubuntu:~/cgroup/demo$ sudo sh -c 'echo 27597 > ./test/cgroup.procs'
dev@ubuntu:~/cgroup/demo$ sudo sh -c 'echo 27597 > ./cgroup.procs'

#从日志可以看出,release_agent被触发了,/test是cgroup的相对路径
dev@ubuntu:~/cgroup/demo$ cat /home/dev/release_demo.log
/home/dev/cgroup/release_demo.sh:/test

cgroup

创建

创建 cgroup 很简单,在父 cgroup 或者 hierarchy 目录下新建一个目录就可以了。

具体层级关系就和目录层级关系一样。

# 创建子cgroup cgroup-cpu
$ mkdir cgroup-cpu
$ cd cgroup-cpu
# 创建cgroup-cpu的子cgroup
$ mkdir cgroup-cpu-1

删除

删除也很简单,删除对应
目录
即可。

注意:是删除目录 rmdir,而不是递归删除目录下的所有文件。

如果有多层 cgroup 则需要先删除子 cgroup,否则会报错:

$ rmdir cgroup-cpu
# 如果cgroup中有进程正在本限制,也会出现这个错误,需要先停掉对应进程,或者把进程移动到另外的 cgroup 中(比如父cgroup)
rmdir: failed to remove 'cgroup-cpu': Device or resource busy

先删除子 cgroup 就可以了:

$ rmdir cg1
$ cd ../
$ rmdir cgroup-cpu

也可以借助 libcgroup 工具来创建或删除。

使用 libcgroup 工具前,请先安装 libcgroup 和 libcgroup-tools 数据包

redhat 系统安装:

$ yum install libcgroup
$ yum install libcgroup-tools

ubuntu 系统安装:

$ apt-get install cgroup-bin
# 如果提示cgroup-bin找不到,可以用 cgroup-tools 替换
$ apt-get install cgroup-tools

具体语法:

# controllers就是subsystem
# path可以用相对路径或者绝对路径
$ cgdelete controllers:path

例如:

$ cgcreate cpu:./mycgroup
$ cgdelete cpu:./mycgroup

添加进程

创建新的 cgroup 后,就可以往里面添加进程了。注意下面几点:

  • 在一颗 cgroup 树里面,
    一个进程必须要属于一个 cgroup

    • 所以不能凭空从一个 cgroup 里面删除一个进程,只能将一个进程从一个 cgroup 移到另一个 cgroup
  • 新创建的子进程将会自动加入父进程所在的 cgroup。
    • 这也就是 tasks 和 cgroup.proc 的区别。
  • 从一个 cgroup 移动一个进程到另一个 cgroup 时,只要有目的 cgroup 的写入权限就可以了,系统不会检查源 cgroup 里的权限。
  • 用户只能操作属于自己的进程,不能操作其他用户的进程,root 账号除外。
#--------------------------第一个shell窗口----------------------
#创建一个新的cgroup
dev@ubuntu:~/cgroup/demo$ sudo mkdir test
dev@ubuntu:~/cgroup/demo$ cd test

#将当前bash加入到上面新创建的cgroup中
dev@ubuntu:~/cgroup/demo/test$ echo $$
1421
dev@ubuntu:~/cgroup/demo/test$ sudo sh -c 'echo 1421 > cgroup.procs'
#注意:一次只能往这个文件中写一个进程ID,如果需要写多个的话,需要多次调用这个命令

#--------------------------第二个shell窗口----------------------
#重新打开一个shell窗口,避免第一个shell里面运行的命令影响输出结果
#这时可以看到cgroup.procs里面包含了上面的第一个shell进程
dev@ubuntu:~/cgroup/demo/test$ cat cgroup.procs
1421

#--------------------------第一个shell窗口----------------------
#回到第一个窗口,随便运行一个命令,比如 top
dev@ubuntu:~/cgroup/demo/test$ top
#这里省略输出内容

#--------------------------第二个shell窗口----------------------
#这时再在第二个窗口查看,发现top进程自动加入了它的父进程(1421)所在的cgroup
dev@ubuntu:~/cgroup/demo/test$ cat cgroup.procs
1421
16515
dev@ubuntu:~/cgroup/demo/test$ ps -ef|grep top
dev      16515  1421  0 04:02 pts/0    00:00:00 top
dev@ubuntu:~/cgroup/demo/test$

#在一颗cgroup树里面,一个进程必须要属于一个cgroup,
#所以我们不能凭空从一个cgroup里面删除一个进程,只能将一个进程从一个cgroup移到另一个cgroup,
#这里我们将1421移动到root cgroup
dev@ubuntu:~/cgroup/demo/test$ sudo sh -c 'echo 1421 > ../cgroup.procs'
dev@ubuntu:~/cgroup/demo/test$ cat cgroup.procs
16515
#移动1421到另一个cgroup之后,它的子进程不会随着移动

#--------------------------第一个shell窗口----------------------
##回到第一个shell窗口,进行清理工作
#先用ctrl+c退出top命令
dev@ubuntu:~/cgroup/demo/test$ cd ..
#然后删除创建的cgroup
dev@ubuntu:~/cgroup/demo$ sudo rmdir test

cgroup.procs vs tasks

#创建两个新的cgroup用于演示
dev@ubuntu:~/cgroup/demo$ sudo mkdir c1 c2

#为了便于操作,先给root账号设置一个密码,然后切换到root账号
dev@ubuntu:~/cgroup/demo$ sudo passwd root
dev@ubuntu:~/cgroup/demo$ su root
root@ubuntu:/home/dev/cgroup/demo#

#系统中找一个有多个线程的进程
root@ubuntu:/home/dev/cgroup/demo# ps -efL|grep /lib/systemd/systemd-timesyncd
systemd+   610     1   610  0    2 01:52 ?        00:00:00 /lib/systemd/systemd-timesyncd
systemd+   610     1   616  0    2 01:52 ?        00:00:00 /lib/systemd/systemd-timesyncd
#进程610有两个线程,分别是610和616

#将616加入c1/cgroup.procs
root@ubuntu:/home/dev/cgroup/demo# echo 616 > c1/cgroup.procs
#由于cgroup.procs存放的是进程ID,所以这里看到的是616所属的进程ID(610)
root@ubuntu:/home/dev/cgroup/demo# cat c1/cgroup.procs
610
#从tasks中的内容可以看出,虽然只往cgroup.procs中加了线程616,
#但系统已经将这个线程所属的进程的所有线程都加入到了tasks中,
#说明现在整个进程的所有线程已经处于c1中了
root@ubuntu:/home/dev/cgroup/demo# cat c1/tasks
610
616

#将616加入c2/tasks中
root@ubuntu:/home/dev/cgroup/demo# echo 616 > c2/tasks

#这时我们看到虽然在c1/cgroup.procs和c2/cgroup.procs里面都有610,
#但c1/tasks和c2/tasks中包含了不同的线程,说明这个进程的两个线程分别属于不同的cgroup
root@ubuntu:/home/dev/cgroup/demo# cat c1/cgroup.procs
610
root@ubuntu:/home/dev/cgroup/demo# cat c1/tasks
610
root@ubuntu:/home/dev/cgroup/demo# cat c2/cgroup.procs
610
root@ubuntu:/home/dev/cgroup/demo# cat c2/tasks
616
#通过tasks,我们可以实现线程级别的管理,但通常情况下不会这么用,
#并且在cgroup V2以后,将不再支持该功能,只能以进程为单位来配置cgroup

#清理
root@ubuntu:/home/dev/cgroup/demo# echo 610 > ./cgroup.procs
root@ubuntu:/home/dev/cgroup/demo# rmdir c1
root@ubuntu:/home/dev/cgroup/demo# rmdir c2
root@ubuntu:/home/dev/cgroup/demo# exit
exit

结论:将线程 ID 加到 cgroup1 的 cgroup.procs 时,会把线程对应进程 ID 加入 cgroup.procs 且还会把当前进程下的全部线程 ID 加入到
tasks 中。

这里看起来,进程和线程好像效果是一样的。

区别来了,如果此时把某个线程 ID 移动到另外的 cgroup2 的 tasks 中,会自动把 线程 ID 对应的进程 ID 加入到 cgroup2 的
cgroup.procs 中,且只把对应线程加入 tasks 中。

此时 cgroup1 和 cgroup2 的 cgroup.procs 都包含了同一个进程 ID,但是二者的 tasks 中却包含了不同的线程 ID。

这样就实现了
线程粒度的控制
。但通常情况下不会这么用,并且在 cgroup V2 以后,将不再支持该功能,只能以进程为单位来配置
cgroup。

3.如何使用 Go 和 Cgroups 交互

其实挺简单的,就是用 Go 翻译了一遍上面的命令。

后续则是按照这个流程实现自己的 docker。

具体代码如下:

// cGroups cGroups初体验
func cGroups() {
// /proc/self/exe是一个符号链接,代表当前程序的绝对路径
if os.Args[0] == "/proc/self/exe" {
// 第一个参数就是当前执行的文件名,所以只有fork出的容器进程才会进入该分支
fmt.Printf("容器进程内部 PID %d\n", syscall.Getpid())
// 需要先在宿主机上安装 stress 比如 apt-get install stress
cmd := exec.Command("sh", "-c", `stress --vm-bytes 200m --vm-keep -m 1`)
cmd.SysProcAttr = &syscall.SysProcAttr{}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println(err)
os.Exit(1)
}
} else {
// 主进程会走这个分支
cmd := exec.Command("/proc/self/exe")
cmd.SysProcAttr = &syscall.SysProcAttr{Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWNS | syscall.CLONE_NEWPID}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
fmt.Println(err)
os.Exit(1)
}
// 得到 fork 出来的进程在外部namespace 的 pid
fmt.Println("fork 进程 PID:", cmd.Process.Pid)
// 在默认的 memory cgroup 下创建子目录,即创建一个子 cgroup
err := os.Mkdir(filepath.Join(cgroupMemoryHierarchyMount, "testmemorylimit"), 0755)
if err != nil {
fmt.Println(err)
}
// 	将容器加入到这个 cgroup 中,即将进程PID加入到cgroup下的 cgroup.procs 文件中
err = ioutil.WriteFile(filepath.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "cgroup.procs"),
[]byte(strconv.Itoa(cmd.Process.Pid)), 0644)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// 	限制进程的内存使用,往 memory.limit_in_bytes 文件中写入数据
err = ioutil.WriteFile(filepath.Join(cgroupMemoryHierarchyMount, "testmemorylimit", "memory.limit_in_bytes"),
[]byte("100m"), 0644)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
cmd.Process.Wait()
}
}

首先是一个 if 判断,区分主进程和子进程,分别执行不同逻辑。

  • 主进程:fork 出子进程,并创建 cgroup,然后将子进程加入该 cgrouop
  • 子进程:执行 stress 命令,以消耗内存,便于查看 memory cgroup 的效果

运行并测试:

lixd  ~/projects/docker/mydocker main $ go build main.go
lixd  ~/projects/docker/mydocker main $ sudo ./main
fork 进程 PID: 21827
当前进程 pid 1
stress: info: [7] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd

根据输出可以知道,我们 fork 出的进程,pid 为 21827。

通过
pstree -pl
查看进程关系:

$pstree -pl
init(1)─┬─init(8)───init(9)───fsnotifier-wsl(10)
        ├─init(12)───init(13)─┬─exe(20618)─┬─sh(20623)───stress(20624)───stress(20625)
        │                     │            ├─{exe}(20619)
        │                     │            ├─{exe}(20620)
        │                     │            ├─{exe}(20621)
        │                     │            └─{exe}(20622)
└─zsh(14)───sudo(21821)───main(21822)─┬─exe(21827)─┬─sh(21832)───stress(21833)───stress(21834)

可以看到 21827 进程 最终启动了一个 21834 的 stress 进程。

top
查看以下内存占用:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
21834 root      20   0  208664 101564    272 D  35.2   1.3   0:14.38 stress

可以看到 RES 101564,也就是刚好 100M,说明我们的 cgroup 是有效果的。

4. 小结

本文主要介绍了

  • 1)Docker 是如何使用 cgroups 的;
  • 2) hierarchy 和 cgroup 相关的操作,如创建删除等;
  • 3)最后则是简单演示了如何使用 Go 和 cgroups 进行交互。

至此,cgroups 的相关内容就告一段落了,加上本文一共包括 3 篇文章:
初探 Linux Cgroups:资源控制的奇妙世界
深入剖析 Linux Cgroups 子系统:资源精细管理

包括以下内容:

  • 1)cgroups 怎么实现资源控制的
  • 2)相关 subsystem 演示
  • 3)docker 怎么使用 cgroups 的
  • 4)go 怎么操作 cgroups

后续可以使用 go 实现 docker 的时候,资源控制就会使用 go 和 cgroups 交互来实现。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


5.参考

cgroups(7) — Linux manual page

Linux Cgroup 系列(02):创建并管理 cgroup

cgroup 源码分析 6——cgroup 中默认控制文件的内核实现分析

文章原文:
https://gaoyubo.cn/blogs/6997cf1f.html

一、运行时数据区

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域 有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是 依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域

image-20231029140510184
image-20231029233012225

1.1程序计数器

线程私有

是一个非常小的内存区域,用于存储当前线程正在执行的字节码指令的地址。每个线程在JVM中都有一个独立的程序计数器。当JVM执行一条字节码指令时,程序计数器会更新为下一条指令的地址。

简而言之,程序计数器存储的是当前正在执行的字节码指令的地址。一旦这条指令执行完毕,程序计数器会立即更新为下一条指令的地址。这样,JVM就可以知道接下来应该执行哪条指令。

需要注意的是,对于那些会导致控制流跳转的指令(如条件跳转、循环等),程序计数器会根据指令的具体行为更新为相应的目标地址,而不是简单地递增到下一个地址。

  • 执行 Java 方法和 native 方法时的区别:
    • 执行 Java 方法时:记录虚拟机正在执行的
      字节码指令地址
    • 执行 native 方法时:空(Undefined);
  • 是 5 个区域中
    唯一不会出现 OOM 的区域

1.2虚拟机栈

image-20231029233100723

线程私有

每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧用于存储
局部变量表

操作数栈

动态连接

方法出口
等信息。

  • 服务于 Java 方法;
  • 可能抛出的异常:
    • OutOfMemoryError
      (在虚拟机栈可以动态扩展的情况下,扩展时无法申请到足够的内存);
    • StackOverflowError
      (线程请求的栈深度 > 虚拟机所允许的深度);
  • 虚拟机参数设置:
    -Xss
    .

局部变量表
存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用。

1.3本地方法栈

线程私有

  • 服务于 native 方法;
  • 可能抛出的异常:与 Java 虚拟机栈一样。

1.4堆

image-20231029233145284

线程共享

“几乎”
所有的对象实例都在这里分配内存

由于即时编 译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙 的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

  • 唯一的目的:存放对象实例;
  • 垃圾收集器管理的主要区域;
  • 可以处于物理上不连续的内存空间中;
  • 可能抛出的异常:
    • OutOfMemoryError
      (堆中没有内存可以分配给新创建的实例,并且堆也无法再继续扩展了)。
  • 虚拟机参数设置:
    • 最大值:
      -Xmx
    • 最小值:
      -Xms
    • 两个参数设置成相同的值可避免堆自动扩展。

1.5方法区

常量池

线程共享

  • 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
    • 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等。
  • 垃圾收集行为在此区域很少发生;
    • 不过也不能不清理,对于经常动态生成大量 Class 的应用,如 Spring 等,需要特别注意类的回收状况。
  • 可能抛出的异常:
    • OutOfMemoryError
      (方法区无法满足内存分配需求时)。
  • JDK8之前:方法区称呼为
    永久代
  • JDK8以后:废弃了
    永久代
    的概念,改用与
    JRockit

    J9
    一样在本地内存中实现的
    元空间

方法区的类型信息、静态变量<------>class文件的相对应的表

方法区的运行时常量池<---------->class的常量池表

运行时常量

运行时常量池也是方法区的一部分;

运行时常量池相对于Class文件常量池的另外一个重要特征是具备
动态性
,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,
运行期间也可以将新的常量放入池中
,这种特性被开发人员利用得比较多的便是
String
类的
intern()
方法。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译器生成的各种字面量(就是代码中定义的 static final 常量)和符号引用,这部分信息就存储在运行时常量池中。

Class文件不会保存各个方法和字段的最终内存布局信息,而是在将类加载到JVM后进行
动态链接
的,需要将字段、方法的符号引用经过运行期转换才能正常使用;

1.6 直接内存

  • 直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
  • 直接内存是在Java堆外的、直接向系统申请的内存空间
  • 来源于
    NIO
    ,通过存在堆中的
    DirectByteBuffer
    操作Native内存
  • 通常,访问直接内存的速度会优于Java堆,即读写性能高。因此处于性能考虑,读写频繁的场合可能会考虑使用直接内存。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

image-20231029143742474
image-20231029143859646

由于直接内存在Java堆外,因此它的大小不会直接受限于
-Xmx
指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存

缺点

  • 分配回收成本较高
  • 不受JVM内存回收管理

直接内存大小可以通过
MaxDirectMemorySize
设置;如果不指定,默认与堆的最大值
-Xmx
参数值一致

参考:
JVM系列(九)直接内存(Direct Memory) - 掘金 (juejin.cn)

二、HotSpot虚拟机对象

2.1对象的创建

当Java虚拟机遇到一条字节码new指令时。

  1. 首先将去检查这个指令的参数是否能在
    常量池
    中定位到一个类的
    符号引用
  2. 并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
  3. 在堆中将为新生对象分配内存
    内存分配策略
  4. 内存空间(但不包括对象头)都初始化为零值
  5. 对象头设置:是哪个类的实例、如何才能找到类的元数据信息、哈希码、GC分代年龄
  6. 从虚拟机的视角来看,一个新的对象已经产生了。
  7. 从Java程序的视角看来,Class文件中的
    <init>()
    方法还没有执行,执行构造方法。

这其中有两个问题,

  1. 如何为对象内存划分空间
  2. 如何保证创建内存时,划分内存
    线程安全

划分可用的内存

  • 指针碰撞(内存分配规整)
    • 用过的内存放一边,没用过的内存放一边,中间用一个指针分隔;
    • 分配内存的过程就是将指针向没用过的内存那边移动所需的长度;

image-20231029232645062

  • 空闲列表(内存分配不规整)
    • 维护一个列表,记录哪些内存块是可用的;
    • 分配内存时,从列表上选取一块足够大的空间分给对象,并更新列表上的记录;

划分内存的指针的同步问题

  • 对分配内存空间的动作进行同步处理(CAS);
  • 把内存分配动作按照线程划分在不同的空间之中进行;
    • 每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB);
    • 哪个线程要分配内存就在哪个线程的 TLAB 上分配,TLAB 用完需要分配新的 TLAB 时,才需要同步锁定;
    • 通过
      -XX:+/-UseTLAB
      参数设定是否使用 TLAB。

2.2对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:
对象头(Header)

实例数据(Instance Data)

对齐填充(Padding)

image-20231029150149170
image-20231029150309158

  • 对象头:
    • 第一部分(Mark Word):哈希码(HashCode)、GC分代年龄、偏向状态、锁状态标志、偏向线程ID、偏向时间戳等信息。
    • 第二部分:类型指针,指向它的类元数据的指针,虚拟机通过这个指针来判断这个对象是哪个类的实例(HotSpot 采用的是直接指针的方式访问对象的);
    • 如果是个数组对象,对象头中还有一块用于记录数组长度的数据。
  • 实例数据:
    • 默认分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers),相同宽度的字段会被分配在一起,除了 oops,其他的长度由长到短;
    • 默认分配顺序下,父类字段会被分配在子类字段前面。
  • 填充数据:
    • HotSpot VM
      要求对象的起始地址必须是8字节的整数倍,所以不够要补齐。

2.3对象的访问定位

Java 程序需要通过虚拟机栈上的 reference 数据来操作堆上的具体对象,reference 数据是一个指向对象的引用,不过如何通过这个引用定位到具体的对象,目前主要有以下两种访问方式:句柄访问和直接指针访问。

句柄访问

句柄访问会在 Java 堆中划分一块内存作为句柄池,每一个
句柄存放
着到对象实例数据和
对象类型数据的指针。

优势:对象移动的时候(这在垃圾回收时十分常见)只需改变句柄池中对象实例数据的指针,不需要修改reference本身。

image-20231029155643690

直接指针访问

直接指针访问方式在 Java 堆对象的实例数据中
存放了一个指向对象类型数据的指针
,在
HotSpot
中,这个指针会被存
放在对象头
中。

优势:减少了一次指针定位对象实例数据的开销,速度更快。

image-20231029155746893

三、OOM 异常

3.1Java 堆溢出

  • 出现标志:
    java.lang.OutOfMemoryError: Java heap space
  • 解决方法:
    • 先通过内存映像分析工具分析 Dump 出来的堆转储快照,确认内存中的对象是否是必要的,即分清楚是出现了内存泄漏还是内存溢出;
    • 如果是内存泄漏,通过工具查看泄漏对象到 GC Root 的引用链,定位出泄漏的位置;
    • 如果不存在泄漏,检查虚拟机堆参数(
      -Xmx

      -Xms
      )是否可以调大,检查代码中是否有哪些对象的生命周期过长,尝试减少程序运行期的内存消耗。
  • 虚拟机参数:
    • -XX:HeapDumpOnOutOfMemoryError
      :让虚拟机在出现内存泄漏异常时 Dump 出当前的内存堆转储快照用于事后分析。

3.2Java 虚拟机栈和本地方法栈溢出

  • 单线程下,栈帧过大、虚拟机容量过小都不会导致
    OutOfMemoryError
    ,只会导致
    StackOverflowError
    (栈会比内存先爆掉),一般多线程才会出现
    OutOfMemoryError
    ,因为线程本身要占用内存;
  • 如果是多线程导致的
    OutOfMemoryError
    ,在不能减少线程数或更换 64 位虚拟机的情况,只能通过减少最大堆和减少栈容量来换取更多的线程;
    • 这个调节思路和 Java 堆出现 OOM 正好相反,Java 堆出现 OOM 要调大堆内存的设置值,而栈出现 OOM 反而要调小。

3.3方法区和运行时常量池溢出

  • 测试思路:产生大量的类去填满方法区,直到溢出;
  • 在经常动态生成大量 Class 的应用中,如
    Spring 框架
    (使用
    CGLib
    字节码技术),方法区溢出是一种常见的内存溢出,要特别注意类的回收状况。

3.4直接内存溢出

  • 出现特征:Heap Dump 文件中看不见明显异常,程序中直接或间接用了 NIO;
  • 虚拟机参数:
    -XX:MaxDirectMemorySize
    ,如果不指定,则和
    -Xmx
    一样。

四、垃圾收集

垃圾收集(Garbage Collection,GC)
,它的任务是解决以下 3 件问题:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

其中第一个问题很好回答,在 Java 中,
GC
主要发生在 Java 堆和方法区中,对于后两个问题,将在之后的内容中进行讨论,并介绍
HotSpot
的 7 个垃圾收集器。

4.1判断对象的生死

什么时候回收对象?当然是这个对象再也不会被用到的时候回收。

所以要想解决 “什么时候回收?” 这个问题,我们要先能判断一个对象什么时候什么时候真正的 “死” 掉了,判断对象是否可用主要有以下两种方法。

4.1.1判断对象是否可用的算法

引用计数算法

  • 算法描述:
    • 给对象添加一个引用计数器;
    • 每有一个地方引用它,计数器加 1;
    • 引用失效时,计数器减 1;
    • 计数器值为 0 的对象不再可用。
  • 缺点:
    • 很难解决循环引用的问题。即
      objA.instance = objB; objB.instance = objA;
      ,objA 和 objB 都不会再被访问后,它们仍然相互引用着对方,所以它们的引用计数器不为 0,将永远不能被判为不可用。

可达性分析算法(主流)

image-20231029233255061

  • 算法描述:
    • 从 "GC Root" 对象作为起点开始向下搜索,走过的路径称为引用链(Reference Chain);
    • 从 "GC Root" 开始,不可达的对象被判为不可用。
  • Java 中可作为 “GC Root” 的对象:
    • 栈中(本地变量表中的reference)
      • 虚拟机栈中,栈帧中的本地变量表引用的对象;
      • 本地方法栈中,JNI 引用的对象(native方法);
    • 方法区中
      • 类的静态属性引用的对象;
      • 常量引用的对象;

即便如此,一个对象也不是一旦被判为不可达,就立即死去的,宣告一个的死亡需要经过两次标记过程。

并发情况的可达性分析

在可达性分析中,第一阶段 ”根节点枚举“ 是必须 STW 的,那么为什么因此必须在一个能
保障一致性的快照
上才能进行对象图的遍历,而不是同步用户线程进行呢?

引入三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示
    对象尚未被垃圾收集器访问过
    。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示
    对象已经被垃圾收集器访问过
    ,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示
    对象已经被垃圾收集器访问过
    ,但这个对象上
    至少存在一个引用还没有被扫描
    过。

image-20231029215916416

关于可达性分析的扫描过程,可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,此时如果用户线程改变了对象的引用关系,会发生两种情况:

  • 一种是把原本消亡的对象错误标记为存活, 这下次收集清理掉就好。
  • 另一种是把原本存活的对象错误标记为已消亡,那么可能会导致程序崩溃。

image-20231029220102915

如上图所示,
b -> c 的引用被切断,但同时用户线程建立了一个新的从 a -> c 的引用
,由于已经遍历到了 b,不可能再回去遍历 a(黑色对象不会被重新扫描),再遍历 c,所以这个 c 实际是存活的对象,但由于没有被垃圾收集器扫描到,被错误地标记成了白色,就
会导致c被标记为需要回收的对象

总结下对象消失问题的两个条件:

  1. 插入了一条或多条从黑色对象到白色对象的新引用
  2. 删除了全部从灰色对象到该白色对象的直接或间接引用

当且仅当
以上两个条件同时满足时,才会产生 “对象消失” 的问题,即原本应该是黑色的对象被误标为白色

两种解决方案
  1. 增量更新
    (Incremental Update):增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时(就是上图中的 a -> c 引用关系),就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象(a)为根,重新扫描一次。这可以简化理解为,
    黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
  2. 原始快照
    (Snapshot At The Beginning,SATB):原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时(上图中的 b -> c 引用关系),就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象(b)为根,重新扫描一次。这也可以简化理解为,
    无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

对引用关系记录的插入还是删除,虚拟机的记录操作都是通过
写屏障
现的。在
HotSpot虚拟机
中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新 来做并发标记的,G1、Shenandoah则是用原始快照来实现。

4.1.2四种引用类型

JDK 1.2 后,Java 中才有了后 3 种引用的实现。

  • 强引用:

    Object obj = new Object()
    这种,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用:
    用来引用还存在但非必须的对象。对于软引用对象,在 OOM 前,虚拟机会把这些对象列入回收范围中进行第二次回收,如果这次回收后,内存还是不够用,就 OOM。实现类:
    SoftReference
  • 弱引用:
    被弱引用引用的对象只能生存到下一次垃圾收集前,一旦发生垃圾收集,被弱引用所引用的对象就会被清掉。实现类:
    WeakReference
  • 虚引用:
    幽灵引用,对对象没有半毛钱影响,甚至不能用来取得一个对象的实例。它唯一的用途就是:当被一个虚引用引用的对象被回收时,系统会收到这个对象被回收了的通知。实现类:
    PhantomReference

4.1.3死亡的两次标记过程

  • 当发现对象不可达后,该对象被第一次标记,并进行是否有必要执行
    finalize()
    方法的判断;
    • 不需要执行:对象没有覆盖
      finalize()
      方法,或者
      finalize()
      方法已被执行过(
      finalize()
      只被执行一次);
    • 需要执行:将该对象放置在一个队列中,稍后由一个虚拟机自动创建的低优先级线程执行。
  • finalize()
    方法是对象逃脱死亡的最后一次机会,不过虚拟机不保证等待
    finalize()
    方法执行结束,也就是说,虚拟机只触发
    finalize()
    方法的执行,如果这个方法要执行超久,那么虚拟机并不等待它执行结束,所以最好不要用这个方法。
  • finalize()
    方法能做的,try-finally 都能做,所以忘了这个方法吧

4.1.4方法区的回收

永久代的 GC 主要回收:
废弃常量

无用的类

  • 废弃常量:例如一个字符串 "abc",当没有任何引用指向 "abc" 时,它就是废弃常量了。
  • 无用的类:同时满足以下 3 个条件的类。
    • 该类的所有实例已被回收,Java 堆中不存在该类的任何实例;
    • 加载该类的 Classloader 已被回收;
    • 该类的 Class 对象没有被任何地方引用,即无法在任何地方通过反射访问该类的方法。

4.2垃圾收集算法

image-20231029204343997

4.2.1标记 - 清除算法

  • 算法描述:


    • 先标记出所有需要回收的对象(图中深色区域);
    • 标记完后,统一回收所有被标记对象(留下狗啃似的可用内存区域……)。
  • 不足:


    • 效率问题:标记和清理两个过程的效率都不高。
    • 空间碎片问题:标记清除后会产生大量不连续的内存碎片,导致以后为较大的对象分配内存时找不到足够的连续内存,会提前触发另一次 GC。

    image-20231029204413509

4.2.2标记 - 复制算法

  • 算法描述:


    • 将可用内存分为大小相等的两块,每次只使用其中一块;
    • 当一块内存用完时,将这块内存上还存活的对象复制到另一块内存上去,将这一块内存全部清理掉。
  • 不足:
    可用内存缩小为原来的一半,适合GC过后只有少量对象存活的新生代。

  • 节省内存的方法:


    • 新生代中的对象 98% 都是朝生夕死的,所以不需要按照 1:1 的比例对内存进行划分;
    • 把内存划分为:
      • 1 块比较大的 Eden 区;
      • 2 块较小的 Survivor 区;
    • 每次使用 Eden 区和 1 块 Survivor 区;
    • 回收时,将以上 2 部分区域中的存活对象复制到另一块 Survivor 区中,然后将以上两部分区域清空;
    • JVM 参数设置:
      -XX:SurvivorRatio=8
      表示
      Eden 区大小 / 1 块 Survivor 区大小 = 8

    image-20231029204510517

4.2.3标记 - 整理算法

  • 算法描述:
    • 标记方法与 “标记 - 清除算法” 一样;
    • 标记完后,将所有存活对象向一端移动,然后直接清理掉边界以外的内存。
  • 不足:
    存在效率问题,适合老年代。

image-20231029204836071

进化:分代收集算法

  • 新生代:
    GC 过后只有少量对象存活 ——
    复制算法
  • 老年代:
    GC 过后对象存活率高 ——
    标记 - 整理算法

4.3HotSpot 中 GC 算法的实现

image-20231029204934730

通过之前的分析,GC 算法的实现流程简单的来说分为以下两步:

  1. 找到死掉的对象;
  2. 把它清了。

想要找到死掉的对象,我们就要进行可达性分析,也就是从 GC Root 找到引用链的这个操作,需要获取所有对象引用。

那么,首先要找到哪些是 GC Roots。

有两种查找 GC Roots 的方法:

  • 遍历方法区和栈区查找(保守式 GC)。
  • 通过
    OopMap
    数据结构来记录
    GC Roots
    的位置(准确式 GC)。

很明显,保守式 GC 的成本太高。准确式 GC 的优点就是能够让虚拟机快速定位到 GC Roots。

但是当内存中的对象间的引用关系发生变化时,就需要改变
OopMap
中的相应内容。可是能导致引用关系发生变化的指令非常之多,如果我们执行完一条指令就改下
OopMap
,这 GC 成本实在太高了。于此,安全点和安全区域就很重要了。

4.3.1安全点

因此,
HotSpot
采用了一种在
“安全点”
更新
OopMap
的方法,安全点的选取既不能让 GC 等待的时间过长,也不能过于频繁增加运行负担,也就是说,我们既要让程序运行一段时间,又不能让这个时间太长。

JVM 中每条指令执行的是很快的,所以一个超级长的指令流也可能很快就执行完了,所以
真正会出现 “长时间执行” 的一般是指令的复用,例如:方法调用、循环跳转、异常跳转等
,虚拟机一般会将这些地方设置为安全点更新
OopMap
并判断是否需要进行
GC
操作。

此外,在进行枚举根节点的这个操作时,为了保证准确性,我们需要在一段时间内 “冻结” 整个应用,即
Stop The World
,因为如果在我们分析可达性的过程中,对象的引用关系还在变来变去,那是不可能得到正确的分析结果的。即便是在号称几乎不会发生停顿的
CMS 垃圾收集器
中,枚举根节点时也是必须要停顿的。这里就涉及到了一个问题:

如何让所有线程跑到最近的安全点再停顿下来进行 GC 操作呢?

主要有以下两种方式:

  • 抢先式中断:
    • 先中断所有线程;
    • 发现有线程没中断在
      安全点
      ,恢复它,让它跑到安全点。
  • 主动式中断:
    (主要使用)
    • 设置一个中断标记;
    • 每个线程到达
      安全点
      时,检查这个中断标记,选择是否中断自己。

4.3.2安全区域

除此安全点之外,还有一个叫做
安全区域
的东西。

安全区域是指在一段代码片段之中,引用关系不会发生变化,因此在这个区域中的任意位置开始 GC 都是安全的。

一个一直在执行的线程可以自己 “走” 到安全点去,可是一个处于
Sleep
或者
Blocked
状态的线程是没办法自己到达安全点中断自己的,我们总不能让 GC 操作一直等着这些个 ”不执行“ 的线程重新被分配资源吧。对于这种情况,我们要依靠安全区域来解决。

当线程执行到安全区域时,它会把自己标识为
Safe Region
,这样 JVM 发起
GC
时是不会理会这个线程的。当这个线程要离开安全区域时,它会检查系统是否在
GC
中,如果不在,它就继续执行,如果在,它就等
GC
结束再继续执行。

4.4 记忆集、卡表、写屏障

记忆集

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为
记忆集(Remembered Set)
的数据结构,用以
避免
把整个老年代加进
GC Roots
扫描范围。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
收集器只需要通过记忆集判断出
某一块非收集区域是否存在
有指向了
收集区域的指针
就可以了。

采用种称为
卡表(Card Table)
的方式去实现记忆集。

卡表

卡表最简单的形式只是一个字节数组。

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个 内存块被称作
卡页(Card Page)

一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可 以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如 果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块

image-20231029213018820

一个卡页的内存中通常包含不止一个对象,
只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty)
,没有则标识为0。

在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它 们加入
GC Roots
中一并扫描。

写屏障

如何维护卡表元素?

如何维护卡表《=======》如何在
对象赋值的那一刻
去更新卡表

  • 解释执行
    的字节码,好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间
  • 编译执行
    的场景中经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。


HotSpot虚拟机
里是通过
写屏障(Write Barrier)
技术维护卡表状态的。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的
AOP切面
,在引用对象赋值时会产生一个
环形(Around)通知
供程序执行额外的动作。

在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier)
在赋值 后的则叫作写后屏障(Post-Write Barrier)

void oop_field_store(oop* field, oop new_value) {
	// 引用字段赋值操作
	*field = new_value;
	// 写后屏障,在这里完成卡表状态更新
	post_write_barrier(field, new_value);
}

伪共享问题

除了写屏障的开销外,卡表在高并发场景下还面临着
伪共享(False Sharing)
问题。

伪共享是处 理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以
缓存行(Cache Line)
为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓 存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对 象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

为了
避免伪共享问题
,一种简单的解决方案是不采用无条件的写屏障,而是
先检查卡表标记
,只有当该卡表元 素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:

if (CARD_TABLE [this address >> 9] != 0)
{
	CARD_TABLE [this address >> 9] = 0;
}

在JDK 7之后,HotSpot虚拟机增加了一个新的参数
-XX:+UseCondCardMark
,用来决定是否开启卡表更新的条件判断

4.5垃圾收集器

垃圾收集器就是内存回收操作的具体实现。有的属于新生代收集器,有的属于老年代收集器,所以一般是搭配使用的。

查看垃圾收集器种类指令:
java -XX:+PrintCommandLineFlags -version

image-20231029210826750

Serial / ParNew 搭配 Serial Old 收集器

image-20231029210903044

Serial 收集器是虚拟机在 Client 模式下的默认新生代收集器,它的优势是简单高效,在单 CPU 模式下很牛。

ParNew 收集器就是 Serial 收集器的多线程版本,虽然除此之外没什么创新之处,但它却是许多运行在 Server 模式下的虚拟机中的首选新生代收集器,因为除了 Serial 收集器外,只有它能和 CMS 收集器搭配使用。

Parallel 搭配 Parallel Scavenge 收集器

它们的关注点与其他收集器不同,其他收集器关注于尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目的是达到一个可控的吞吐量。

吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )

因此,Parallel Scavenge 收集器不管是新生代还是老年代都是多个线程同时进行垃圾收集,十分适合于应用在注重吞吐量以及 CPU 资源敏感的场合。

可调节的虚拟机参数:

  • -XX:MaxGCPauseMillis
    :最大 GC 停顿的秒数;
  • -XX:GCTimeRatio
    :吞吐量大小,一个 0 ~ 100 的数,
    最大 GC 时间占总时间的比率 = 1 / (GCTimeRatio + 1)
  • -XX:+UseAdaptiveSizePolicy
    :一个开关参数,打开后就无需手工指定
    -Xmn

    -XX:SurvivorRatio
    等参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,自行调整。

CMS 收集器

回收老年代

image-20231029211206666664
image-20231029211216197

参数设置:

  • -XX:+UseCMSCompactAtFullCollection
    :在 CMS 要进行 Full GC 时进行内存碎片整理(默认开启)
  • -XX:CMSFullGCsBeforeCompaction
    :在多少次 Full GC 后进行一次空间整理(默认是 0,即每一次 Full GC 后都进行一次空间整理)

关于 CMS 使用 标记 - 清除 算法的一点思考:

之前对于 CMS 为什么要采用 标记 - 清除 算法十分的不理解,既然已经有了看起来更高级的 标记 - 整理 算法,那 CMS 为什么不用呢?

  1. 标记 - 整理 会将所有存活对象向一端移动,需要一个指针来维护这个分隔存活对象和无用空间的点,而CMS 是并发清理的,虽然我们启动了多个线程进行垃圾回收,不过如果使用 标记 - 整理 算法,为了保证线程安全,在整理时要对那个分隔指针加锁,保证同一时刻只有一个线程能修改它,
    加锁的这一过程相当于将并行的清理过程变成了串行的,也就失去了并行清理的意义了。
  2. CMS关注的是最短停顿时间,标记 - 清除算法的Stop The World最小。

所以,CMS 采用了 标记 - 清除 算法。

Garbage First(G1)收集器

image-20231029212015528
image-20231029212024404

在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代
(Minor GC)
,要么就是整个老年代
(Major GC)
,再要么就是整个Java堆
(Full GC)

而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而
是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

Region布局

G1不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域
Region
,每一个
Region
都可以 根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。

  • 大对象存储区域:Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。
  • 参数设置:每个Region的大小可以通过参数
    -XX:G1HeapRegionSize
    设 定,取值范围为1MB~32MB,且应为2的N次幂。
  • 超大对象存储:对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的
    Humongous Region
    之中,G1的大多数行为都把
    Humongous Region
    作为老年代 的一部分来进行看待
垃圾处理思路

具体的处理思路是让G1收集器去跟踪各个Region里面的垃 圾堆积的“价值”大小,价值即
回收所获得的空间大小以及回收所需时间的经验值
,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数
-XX:MaxGCPauseMillis
指定,默 认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

难以解决的问题
  1. Region里面存在的跨Region引用对象如何解决?

使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记 忆集的应用其实要复杂很多。

G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂

  1. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行

G1 收集器则是通过
原始快照(SATB)算法
来实现的

此外,G1为每一个Region设 计了两个名为
TAMS(Top at Mark Start)
的指针,把Region中的一部分空间划分出来用于并发回收过 程中的新对象分配,
并发回收时新分配的对象地址都必须要在这两个指针位置以上

G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

对比CMS收集器

  • 内存占用:
    • G1的卡表实现更为复杂,每个Region都需要有一份卡表,无论其在新生代还是老年代中的角色。这可能导致G1的记忆集和其他内存消耗占用整个堆容量的20%甚至更多的内存空间。
    • 相比之下,CMS的卡表相对简单,只有一份,并且只需处理老年代到新生代的引用。这减少了卡表维护的开销。
  • 执行负载:
    • 由于G1和CMS使用写屏障,它们在程序运行时的负载会有所不同。
    • CMS使用写后屏障来更新维护卡表。
    • G1除了使用写后屏障进行卡表维护外,为了实现原始快照搜索算法,还需要使用写前屏障来跟踪并发时的指针变化。原始快照搜索算法减少了并发标记和重新标记阶段的消耗,避免了CMS在最终标记阶段停顿时间过长的缺点,但在用户程序运行过程中会带来额外的负担。
  • 写屏障实现:
    • 由于G1对写屏障的复杂操作消耗更多的运算资源,CMS的写屏障实现是直接的同步操作。
    • G1将写屏障实现为类似于消息队列的结构,异步处理队列中的写前屏障和写后屏障任务。
  • 适用场景:
    • 在小内存应用上,CMS的性能可能仍然优于G1。
    • 在大内存应用上,G1通常能够发挥其优势,特别是在Java堆容量在6GB至8GB之间。

总体而言,对于选择垃圾收集器,需要考虑具体的应用场景和需求,并通过实际测试来得出最合适的结论。随着HotSpot对G1的不断优化,G1在不同场景中的表现可能会持续改善。

目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也 会让对比结果继续向G1倾斜

里程碑

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),而不追求一次把整个Java堆全部清理干净。

这样,应用在分配,同时收集器在收集,
只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。

这种新的收集器设计思路 从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

GC 日志解读

image-20231029212257717

低延迟收集器

Shenandoah和ZGC是两种在Java平台上开发的具有低停顿时间目标的垃圾收集器。它们的目标是减少长时间停顿(Full GC)的发生,以提高Java应用程序的响应性和性能。以下是关于Shenandoah和ZGC的一些关键特点:

Shenandoah Garbage Collector:

  1. 低停顿时间
    :Shenandoah的主要目标是实现非常低的停顿时间。它通过并发标记、并发标记-清除和并发整理等技术来降低垃圾收集期间的暂停时间。
  2. 适用范围
    :Shenandoah适用于需要高度响应性的应用程序,例如在线事务处理系统,其中低延迟是至关重要的。
  3. 全局并发
    :Shenandoah使用全局并发的方式,意味着它在整个堆内存上工作,而不仅仅是一部分。这使得它可以在更大的堆内存上表现出色。
  4. Java版本
    :Shenandoah在Java 12中首次引入,是OpenJDK项目的一部分。

Z Garbage Collector (ZGC):

  1. 低停顿时间
    :ZGC的目标也是降低暂停时间。它通过并发标记、压缩和垃圾回收来实现这一目标。
  2. 适用范围
    :ZGC适用于需要低延迟和响应性的应用程序,特别是大内存应用,例如大数据处理。
  3. 全局并发
    :类似于Shenandoah,ZGC也使用全局并发,这使得它可以在大型堆上工作。
  4. Java版本
    :ZGC在Java 11中首次引入,也是OpenJDK项目的一部分。

这两个垃圾收集器的共同目标是减少垃圾收集期间的停顿时间,使Java应用程序更具响应性。选择哪个收集器取决于应用程序的具体需求和硬件环境。在Java 11及之后的版本中,开发者可以根据性能要求选择Shenandoah或ZGC,以提高应用程序的性能和用户体验。

Epsilon垃圾收集器

Epsilon垃圾收集器是一种特殊的垃圾收集器,它在Java中引入了一种不进行垃圾收集的策略。Epsilon垃圾收集器实际上是一种"无操作"的垃圾收集器,它不会执行任何垃圾回收操作,而是允许堆内存不断增长,直到达到操作系统的限制。

Epsilon垃圾收集器的设计目标是用于某些特殊用途,例如:

  1. 性能测试和基准测试
    :Epsilon垃圾收集器可以用于执行性能测试和基准测试,其中不希望垃圾收集引入额外的性能变化。
  2. 短暂的、生命周期较短的应用程序
    :对于某些应用程序,例如一次性命令行工具或短暂运行的应用程序,Epsilon垃圾收集器可以作为一种轻量级的选择,避免了垃圾收集器的启动和停顿。
  3. 堆外内存管理
    :Epsilon垃圾收集器还可以用于管理堆外内存,这是一种不受垃圾收集器管理的内存,适用于一些特殊用途。

Epsilon垃圾收集器并
不适用于大多数常规Java应用程序
,因为它不会回收堆内存中的垃圾,这可能导致内存泄漏。它适用于那些确切了解自己的应用程序行为并且明确知道不需要垃圾收集的情况。

Epsilon垃圾收集器是Java 11中引入的,可以通过命令行参数
-XX:+UseEpsilonGC
启用。但大多数Java应用程序仍然使用其他垃圾收集器,例如G1、ZGC或Shenandoah,以满足它们的垃圾收集需求。

收集器的权衡

出发点

应用程序的主要关注点是什么?

如果是数据分析、科学计算类的任务,目标是能尽快算出结果, 那吞吐量就是主要关注点;

如果是
SLA
应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的。

SLA(Service Level Agreement,服务级别协议)应用通常是指在服务提供者和服务使用者之间制定和遵守的一种协议,其中规定了服务的质量、性能、可用性等方面的标准和承诺。SLA应用通常与服务提供者和客户之间的服务交付和接受有关,特别是在云计算、网络服务、托管服务和其他IT服务领域。

以下是一些SLA应用的示例:

  1. 云服务提供商
    :云服务提供商通常与客户签订SLA,以规定云计算服务的性能、可用性、数据备份、安全性等方面的承诺。如果云服务提供商未能满足SLA中的承诺,可能需要提供赔偿或补偿。
  2. 网络服务
    :网络服务提供商通常与企业客户签订SLA,以规定网络连接的可用性、带宽、延迟等方面的服务质量。SLA可用于确保网络服务符合业务需求。
  3. 托管服务
    :托管服务提供商通常与客户签订SLA,以规定托管服务的性能、可用性、安全性和数据备份等方面的标准。这有助于确保托管的应用程序和数据的可靠性。
  4. 电子商务
    :在线商店和电子商务平台可能与物流服务提供商签订SLA,以确保订单交付的时间和质量达到一定标准。
  5. 移动应用程序和游戏
    :开发者和移动应用程序平台或游戏服务提供商之间可以签订SLA,以规定应用程序或游戏的性能、稳定性和可用性。

运行应用的基础设施如何?

譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是 ARM/Aarch64;

处理器的数量多少,分配内存的大小;选择的操作系统是
Linux

Solaris
还是
Windows

使用JDK的发行商是什么?版本号是多少?


ZingJDK/Zulu

OracleJDK

Open-JDK

OpenJ9
或是其他公司的发行版?
该JDK对应了《Java虚拟机规范》的哪个版本?

选择

一般来说,收集器的选择就从以上这几点出发来考虑。举个例子,假设某个直接面向用户提供服 务的B/S系统准备选择垃圾收集器,一般来说延迟时间是这类应用的主要关注点,那么:

C4

如果有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以使用传说中的C4收集器了。

C4(Continuous Concurrent Compacting Collector)是一种用于Java虚拟机(JVM)的垃圾收集器,它专注于降低垃圾收集引起的停顿时间。C4收集器的目标是在减少停顿时间的同时提供高吞吐量和良好的性能。它是以低停顿时间为特色的垃圾收集器。

以下是C4垃圾收集器的一些关键特点:

  1. 低停顿时间
    :C4收集器的设计目标是实现极低的停顿时间。它通过并发标记、并发标记-清除和并发整理等技术,使垃圾收集的大部分工作在应用程序运行时进行,从而降低了停顿时间。
  2. 适用范围
    :C4收集器适用于需要快速响应和低延迟的应用程序,如在线事务处理系统、Web应用程序和其他对停顿时间要求较高的应用。
  3. 全局并发
    :C4采用全局并发的方式,允许垃圾收集器与应用程序线程并发工作,而不是在停顿期间独占堆内存。
  4. 分代收集
    :C4收集器通常使用分代收集策略,将堆内存划分为不同的代。这使得它可以更有效地管理内存,降低了垃圾收集的频率。
  5. 自适应调整
    :C4收集器具有自适应调整的能力,可以根据应用程序和硬件环境的变化自动调整其行为。

需要注意的是,C4垃圾收集器通常不是Oracle JDK的默认垃圾收集器,而是一种商业JVM的特性,如Azul Zing。C4垃圾收集器在一些商业JVM中提供,而不是在开源JVM中普遍使用。选择使用C4垃圾收集器通常需要根据具体的商业JVM产品进行配置和许可。

ZGC

如果没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。

Z Garbage Collector(ZGC)是一种用于Java虚拟机(JVM)的垃圾收集器,旨在降低大型Java应用程序的停顿时间。ZGC是由Oracle开发的,并于Java 11中首次引入。以下是ZGC垃圾收集器的一些关键特点和优势:

  1. 低停顿时间
    :ZGC的主要设计目标之一是降低停顿时间。它采用了一种并发的方式来执行垃圾收集,以减少应用程序的停顿时间。通常,垃圾收集过程中的停顿时间在几毫秒到几十毫秒之间,这对需要快速响应的应用程序非常有利。
  2. 大堆支持
    :ZGC适用于非常大的堆内存,可以处理几十GB甚至上百GB的堆内存。这使其适合大型数据处理应用和内存密集型应用。
  3. 并发处理
    :ZGC的标记、清理和整理阶段是并发进行的,这意味着垃圾收集过程与应用程序线程并行执行。这有助于减少停顿时间。
  4. 可预测性
    :ZGC致力于提供可预测的停顿时间,这对于需要满足服务级别协议(SLA)的应用程序非常重要。
  5. 无需特殊硬件
    :ZGC不需要特殊的硬件支持,可以在标准的x86架构上运行。
  6. 多平台支持
    :ZGC支持多种平台,包括Linux、Windows和macOS。

需要注意的是,ZGC并不适用于所有应用程序。它在大型内存需求和低停顿时间要求的情况下表现最佳。对于小型应用程序,传统的垃圾收集器(如G1或CMS)可能足够了。在选择ZGC时,还需要考虑Java版本的兼容性,因为它是从Java 11开始引入的。

总的来说,ZGC是一种在大型内存应用程序中降低停顿时间的有效垃圾收集器,特别适用于需要可预测性和低延迟的应用程序。

CMS/G1

如果接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一 下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1。

五、内存分配策略

image-20231029231733213

新生代和老年代的 GC 操作

  • 新生代 GC 操作:
    Minor GC
    • 发生的非常频繁,速度较块。
  • 老年代 GC 操作:
    Full GC / Major GC
    • 经常伴随着至少一次的
      Minor GC
    • 速度一般比
      Minor GC
      慢上 10 倍以上。

5.1优先在 Eden 区分配

image-20231029233351944

  1. new的对象先放在伊甸园区
    ,此区有大小限制

  2. 伊甸园区满时
    ,程序又需要创建对象,此时JVM的垃圾回收器(YGC/Minor GC)对伊甸园区进行
    垃圾回收
    ,将伊甸园区中不被对象所引用的对象进行销毁,再加载新的对象放到此区中。
  3. 然后将伊甸园中
    剩余的对象
    (存活的)
    移动到幸存者0区

  4. 再次垃圾回收
    时,还是先销毁对象并将存活对象移动到幸存者1区,然后
    将处在幸存者0区的也移动到幸存者1区
    (这些对
    象的年龄++
    )。
  5. 接下来重复,每次放入幸存者区时
    ,放入空的那个
    (to区)
  6. 当再次垃圾回收时, 且当幸存者区中的对象的
    年龄有到达15的
    (可以更改
    -XX:MaxTenuringThreshold=?
    ),则将此对象
    移动到老年区
  7. 老年区相对悠闲,当老年区内存不足时,触发
    Major GC
    ,进行老年区的清理。
  8. 若老年区
    执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常
  • 虚拟机参数:
    • -Xmx
      :Java 堆的最大值;
    • -Xms
      :Java 堆的最小值;
    • -Xmn
      :新生代大小;
    • -XX:SurvivorRatio=8
      :Eden 区 / Survivor 区 = 8 : 1

5.2大对象直接进入老年代

  • 大对象定义:
    需要大量连续内存空间的 Java 对象。例如那种很长的字符串或者数组。
  • 设置对象直接进入老年代大小限制:
    • -XX:PretenureSizeThreshold
      :单位是字节;
      • 只对
        Serial

        ParNew
        两款收集器有效。
    • 目的:
      因为新生代采用的是复制算法收集垃圾,大对象直接进入老年代可以避免在 Eden 区和 Survivor 区发生大量的内存复制。

5.3长期存活的对象将进入老年代

  • 固定对象年龄判定:
    虚拟机给每个对象定义一个年龄计数器,对象每在 Survivor 中熬过一次 Minor GC,年龄 +1,达到
    -XX:MaxTenuringThreshold
    设定值后,会被晋升到老年代,
    -XX:MaxTenuringThreshold
    默认为 15;
  • 动态对象年龄判定:
    Survivor 中有相同年龄的对象的空间总和大于 Survivor 空间的一半,那么,年龄大于或等于该年龄的对象直接晋升到老年代。

5.4空间分配担保

我们知道,新生代采用的是复制算法清理内存,每一次 Minor GC,虚拟机会将 Eden 区和其中一块 Survivor 区的存活对象复制到另一块 Survivor 区,但
当出现大量对象在一次 Minor GC 后仍然存活的情况时,Survivor 区可能容纳不下这么多对象,此时,就需要老年代进行分配担保,即将 Survivor 无法容纳的对象直接进入老年代。

这么做有一个前提,就是老年代得装得下这么多对象。可是在一次 GC 操作前,虚拟机并不知道到底会有多少对象存活,所以空间分配担保有这样一个判断流程:

  • 发生 Minor GC 前,虚拟机先检查老年代的最大可用连续空间是否大于新生代所有对象的总空间;
    • 如果大于,Minor GC 一定是安全的;
    • 如果小于,虚拟机会查看
      HandlePromotionFailure
      参数,看看是否允许担保失败;
      • 允许失败:尝试着进行一次
        Minor GC
      • 不允许失败:进行一次
        Full GC
  • 不过 JDK 6 Update 24 后,
    HandlePromotionFailure
    参数就没有用了,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。

5.5etaspace 元空间与 PermGen 永久代

Java 8 彻底将永久代 (PermGen) 移除出了
HotSpot JVM
,将其原有的数据迁移至 Java Heap 或 Metaspace。

移除 PermGen 的原因:

  • PermGen
    内存经常会溢出,引发恼人的
    java.lang.OutOfMemoryError: PermGen
    ,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的
    OOM
  • 移除
    PermGen
    可以促进
    HotSpot JVM

    JRockit VM
    的融合,因为
    JRockit
    没有永久代。

移除 PermGen 后,方法区和字符串常量的位置:

  • 方法区:移至
    Metaspace
  • 字符串常量:移至
    Java Heap

Metaspace 的位置:
本地堆内存(native heap)。

Metaspace 的优点:
永久代 OOM 问题将不复存在,因为默认的类的元数据分配只受本地内存大小的限制,也就是说本地内存剩余多少,理论上
Metaspace
就可以有多大;

JVM参数:

  • -XX:MetaspaceSize
    :分配给类元数据空间(以字节计)的初始大小,为估计值。
    MetaspaceSize
    的值设置的过大会延长垃圾回收时间。垃圾回收过后,引起下一次垃圾回收的类元数据空间的大小可能会变大。
  • -XX:MaxMetaspaceSize
    :分配给类元数据空间的最大值,超过此值就会触发
    Full GC
    ,取决于系统内存的大小。JVM会动态地改变此值。
  • -XX:MinMetaspaceFreeRatio
    :一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最小比例,不够就会导致垃圾回收。
  • -XX:MaxMetaspaceFreeRatio
    :一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最大比例,不够就会导致垃圾回收。

分享是最有效的学习方式。

故事

又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。

不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。

小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台的老六直接找我要个支付接口,我就给他了的,以前的代码,我都没有动过的......”。

于是小猫一边疑惑一边翻看着以前的代码,越看脸色越差......

小猫做的是一个标准的积分兑换商城,以前和客户合作的时候,客户直接用的是小猫单位自己定制的h5页面。这次合作了一家公司有点特殊,由于公司想要定制化自己个性化的H5,加上本身A公司自己有开发能力,所以经过讨论就以接口的方式直接将相关接口给出去,A客户H5开发完成之后自己来对接。

慢慢地,原因也水落石出,之前好好的业务一直没有问题是因为商城的本身H5页面做了防重复提交,由于量小,并且一般对接方式用的都是纯H5,所以都没有什么问题,然后这次是直接将接口给出去了,完了接口居然没有加幂等......

小猫躺枪,数据订正当然是少不了了,事故报告当然也少不了了。

正所谓前人挖坑,后人遭殃,前人锅后人背。

聊聊幂等

接口幂等梗概

这个案例其实就是一个典型的接口幂等案例。那么老猫就和大家从以下几个方面好好剖析一下接口幂等吧。
概要

什么是接口幂等

比较专业的术语:其任意多次执行所产生的影响均与第一次执行的影响相同。
大白话:多次调用的情况下,接口最终得到的结果是一致的。

那么为什么需要幂等呢?

  1. 用户进行提交动作的时候,由于网络波动等原因导致后端同步响应不及时,这样用户就会一直点点点,这样机会发生重复提交的情况。

  2. 分布式系统之间调用的情况下,例如RPC调用,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。

  3. 分布式系统经常会用到消息中间件,当由于网络原因,mq没有收到ack的情况下,就会导致消息的重复投递,从而就会导致重复提交行为。

  4. 还有就是恶意攻击了,有些业务接口做的比较粗糙,黑客找到漏洞之后会发起重复提交,这样就会导致业务出现问题。打个比方,老猫曾经干过,邻居小孩报名了一个画画比赛,估计是机构培训发起的,功能做的也差,需要靠投票赢得某些礼品,然后老猫抓到接口信息之后就模拟投票进行重复刷了投票。

那么哪些接口需要做幂等呢?

首先我们说是不是所有的接口都需要幂等?是不是加了幂等就好呢?显然不是。
因为接口幂等的实现某种意义上是要消耗系统性能的,我们没有必要针对所有业务接口都加上幂等。

这个其实并不能做一个完全的定义说哪个就不用幂等,因为很多时候其实还是得结合业务逻辑一起看。但是其中也是有规律可循的。

既然我们说幂等就是多次调用,接口最终得到结果一致,那么很显然,查询接口肯定是不要加幂等的,另外一些简单删除数据的接口,无论是逻辑删除还是物理删除,看场景的情况下其实也不用加幂等。

但是大部分涉及到多表更新行为的接口,咱们最好还是得加上幂等。

接口幂等实战方案

前端防抖处理

前端防抖主要可以有两种方案,一种是技术层面的,一种是产品层面的:

  1. 技术层面:例如提交控制在100ms内,同一个用户最多只能做一次订单提交的操作。
  2. 产品层面:当然用户点击提交之后,按钮直接置灰。

基于数据库唯一索引

  1. 利用数据库唯一索引。我们具体来看一下流程,咱们就用小猫遇到的例子。如下:
    唯一索引

过程描述:

  • 建立一张去重表,其中某个字段需要建立唯一索引,例如小猫这个场景中,咱们就可以将订单提交流水单号作为唯一索引存储到我们的数据库中,就模型上而言,可以将其定义为支付请求流水表。

  • 客户端携带相关流水信息到后端,如果发现编号重复,那么此时就会插入失败,报主键冲突的错误,此时我们针对该错误做一下业务报错的二次封装给到客户另一个友好的提示即可。

数据库乐观锁实现

什么是乐观锁,它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。
说得直白一点乐观锁就是一个马大哈。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

例如提交订单的进行支付扣款的时候,本来可能更新账户金额扣款的动作是这样的:

update Account set balance = balance-#{payAmount} where accountCode = #{accountCode}

加上版本号之后,咱们的代码就是这样的。

update Account set balance = balance-#{payAmount},version=version +1 where accountCode = #{accountCode} and version = #{currVersion}

这种情况下其实就要求客户端每次在请求支付下单的时候都需要上层客户端指定好当前的版本信息。
不过这种幂等的处理方式,老猫用的比较少。

数据库悲观锁实现

悲观锁的话具有强烈的独占和排他特性。大白话谁都不信的主。所以我们就用select ... for update这样的语法进行行锁,当然老猫觉得单纯的select ... for update只能解决同一时刻大并发的幂等,所以要保证单号重试这样非并发的幂等请求还是得去校验当前数据的状态才行。就拿当前的小猫遇到的场景来说,流程如下:

悲观锁

begin;  # 1.开始事务
select * from order where order_code='666' for update # 查询订单,判断状态,锁住这条记录
if(status !=处理中){
   //非处理中状态,直接返回;
   return ;
}
## 处理业务逻辑
update order set status='完成' where order_code='666' # 更新完成
update stock set num = num - 1 where spu='xxx' # 库存更新
commit; # 5.提交事务

这里老猫一再想要强调的是在校验的时候还是得带上本身的业务状态去做校验,select ... for update并非万能幂等。

后端生成token

这个方案的本质其实是引入了令牌桶的机制,当提交订单的时候,前端优先会调用后端接口获取一个token,token是由后端发放的。当然token的生成方式有很多种,例如定时刷新令牌桶,或者定时生成令牌并放到令牌池中,当然目的只有一个就是保住token的唯一性即可。

生成token之后将token放到redis中,当然需要给token设置一个失效时间,超时的token也会被删除。

当后端接收到订单提交的请求的时候,会先判断token在缓存中是否存在,第一次请求的时候,token一定存在,也会正常返回结果,但是第二次携带同一个token的时候被拒绝了。

流程如下:

token机制

有个注意点大家可以思考一下:
如果用户用程序恶意刷单,同一个token发起了多次请求怎么办?
想要实现这个功能,就需要借助分布式锁以及Lua脚本了,分布式锁可以保证同一个token不能有多个请求同时过来访问,lua脚本保证从redis中获取令牌->比对令牌->生成单号->删除令牌这一系列行为的原子性。

分布式锁+状态机(订单状态)

现在很多的业务服务都是分布式系统,所以就拿分布式锁来说,关于分布式锁,老猫在此不做赘述,之前老猫写过redis的分布式锁和实现,还有zk锁和实现,具体可见链接:

  1. 锁的演化
  2. 手撕redis分布式锁
  3. 手撸ZK锁

当然和上述的数据库悲观锁类似,咱们的分布式锁也只能保证同一个订单在同一时间的处理。其次也是要去校订单的状态,防止其重复支付的,也就是说,只要支付的订单进入后端,都要将原先的订单修改为支付中,防止后续支付中断之后的重复支付。

在上述小猫的流程中还没有涉及到现金补充,如果涉及到现金补充的话,例如对接了微信或者支付宝的情况,还需要根据最终的支付回调结果来最终将订单状态进行流转成支付完成或者是支付失败。

总结

在我们日常的开发中,一些重要的接口还是需要大家谨慎对待,即使是前任开发留下的接口,没有任何改动,当有人咨询的时候,其实就要好好去了解一下里面的实现,看看方案有没有问题,看看技术实现有没有问题,这应该也是每一个程序员的基本素养。

另外的,在一些重要的接口上,尤其是资金相关的接口上,幂等真的是相当的重要。小伙伴们,你们觉得呢?如果大家还有好的解决方案,或者有其他思考或者意见也欢迎大家的留言。