开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Node 中的 AsyncLocalStorage 的前世今

发表于 2023-05-16

一、背景

你好!我是逻千,非常高兴你能看到这篇文章,在开始前我将大致介绍两点,关于适合谁看和能了解到什么

适合谁看

类型人群 推荐指数
完全不了解Node,但有兴趣看看 适合,有点门槛,会尽量照顾到
写过Node Server,但没有用过了解过这个API 很适合,交流学习的机会
用过了解过此API,但不知道他的前世今生 非常适合,你看标题啊
非常了解此API甚至提过相关PR 非常适合!

Takeaway

如果你有耐心看完本篇文章,你将了解到什么:

  1. 什么是 AsyncLocalStorage ?一般什么时候使用它?如何使用它?
  2. 没有 AsyncLocalStorage 这个 API 之前的时代是怎么解决异步存储的?大概的原理是什么?
  3. 了解广义上的 Async Local Storage 是如何一步一步发展过来的?(即合订本)
  4. AsyncLocalStorage 与最新的阿里巴巴主导的 TC39 提案 AsyncContext 之间是什么关系?
  5. 其他语言中类似的方法是怎么用的?
  6. Node 是如何实现的 AsyncHook?

二、开门见山:什么是 AsyncLocalStorage

一个案例引入

当一个 Request 通过层层关卡打到我们的 Server,一般会产生多个系统的日志,包括但不限于:

  1. 访问日志
  2. 异常日志
  3. SQL日志
  4. 第三方服务日志等

而当发生了线上问题的时候,需要进行溯源排查。

一般的做法是在请求之处,生成一个 unique traceId,此 id 在所有日志中携带就可以追踪该请求的所有链路,这就是所谓的全链路日志追踪。

好的,那么在 Node Server 中如何让一个请求的上下游调用都带上这个 traceId 呢,我们下面给几个方案。

(先不管这个 id 是 server 自己生成的还是 client 带上来的)

方案1:全局变量

简单,先拍脑袋想一个方法,全局变量globalTraceId!

因为closure闭包的特性,会帮我们close住全局变量的引用。

所以可以在任何异步调用中,读取这个变量所指的内存里的值,然后传给日志服务即可:(以 raw Node.js 为例)

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
js复制代码// Raw Node.js HTTP server
const http = require('http');
let globalTraceId // 全局变量

// 0. 处理请求的方法
function handleRequest(req, res) {
// 生成唯一 traceId,每次请求进入,复写 globalTraceId 的值
globalTraceId = generateTraceId()

// 检查用户cookie是否有效
cookieValidator().then((res) => {
// 校验成功,返回给用户他需要的内容
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('Congrats! Your damn cookie is the best one!');
res.end();
}).catch((err) => {
//  把 traceId 连同 error 上报给异常监控系统
reportError(err, globalTraceId)

// 写状态码500和错误信息等
// ...
});
}

// 1. 创建 server
const server = http.createServer((req, res) => {
handleRequest(req, res)
});

// 2. 让 server 和 port:3000 建立 socket 链接,持续接收端口信息
server.listen(3000, () => {
console.log('Server listening on port 3000');
});

但是在 Node.js 是单线程(主线程是单线程),globalTraceId这样全局变量,在第一个请求异步校验 cookie 的过程中,因为 main stack 已空,所以从backlog里面调入第二个请求进入主线程。

而globalTraceId会被第二个请求复写,导致第一个请求在错误上报的时候不能拿到正确的 id

方案2:直接透传参数

那上面全局变量的方法确实不对,那你会想,那我把生成的 traceId 作为参数一步步透传,是不是也可以达到每个请求同一个 traceId 的效果,好像好多库和框架就是这么做的。

是的,我们来看下直接透传参数是怎么做的。

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
js复制代码const http = require('http');

function handleRequest(req, res) {
const traceId = req.headers['x-trace-id'] || generateTraceId();
// 把 traceId 写入 req 这个 object,将参数一路带下去
req.traceId = traceId;

// 同上
cookieValidator().then((result) => {
// 校验成功,返回给用户他需要的内容
// ...
}).catch((err) => {
//  上报 traceId
reportError(err, req.traceId)

// 写状态码500和错误信息等
// ...
});
}

function cookieValidator() {
return new Promise((resolve, reject) => {
setTimeout(() => {
// do someting
// ...
}, 1000);
});
}

// 此后省略监听等操作
// ...

能够看出来,把 traceId 通过 req 这个 object 一路传下去。能传下去的原因是 node 异步调用的时候,会创建一个新的 context(上下文),把当前调用栈、local variable、referenced global variable 存下来,一直到请求返回再在存下来的 context 中继续执行。

所以所谓的直接透传参数,就是通过 local variable 被存到了 async function call context 里面而完成了traceId在一次请求里面一路传递。

常见的 Node 库如何处理 traceId 的?

细心的你已经发现,我们最常用 express 或者 koa 这样的库的时候,不就是这样干的嘛。那我们来举几个常用的库的例子

Express.js

Express 是最早流行的Node server库,到现在依然很流行,实现了路由、中间件等各种功能。

下面看下 Express 是如何传递 TraceId 的

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
js复制代码// Via express
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { reportError } = require('./error-logging');

const app = express();

// 中间件
app.use((req, res, next) => {
const traceId = uuidv4(); // generate a new UUID for the trace ID
req.traceId = traceId; // attach the trace ID to the request object

next();
});

// 设置路由
app.get('/', async (req, res, next) => {
const traceId = req.traceId;

try {
// call an asynchronous function and pass along the trace ID
const result = await someAsyncFunction(traceId);

// do something with the result
res.send(result);
} catch (error) {
// log the error and trace ID to the error logging system
reportError(error, { traceId });
next(error);
}
});

// 监听端口
// ...

Koa.js

Koa 也是社区非常流行的库

Koa 早期使用 yield语法,后期支持了await语法。我们熟悉的 egg.js是基于 Koa 封装的Node Server框架。现在淘宝的midwayjs最早也是基于egg.js做的。

好的,辈分关系梳理完了,我们来看下在 Koa 中是如何透传参数的。

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
js复制代码const Koa = require('koa');
const { v4: uuidv4 } = require('uuid');
const { reportError } = require('./error-logging');

const app = new Koa();

// 中间件A
app.use(async (ctx, next) => {
const traceId = uuidv4(); // generate a new UUID for the trace ID
ctx.state.traceId = traceId; // store the trace ID in the Koa context object

try {
await next();
} catch (error) {
// log the error and trace ID to the error logging system
reportError(error, { traceId });
throw error;
}
});

// 中间件B,通过 ctx 透传 traceId
app.use(async (ctx) => {
const traceId = ctx.state.traceId;

// call an asynchronous function and pass along the trace ID
const result = await someAsyncFunction(traceId);

// do something with the result
ctx.body = result;
});

// 监听端口
// ...

从上面的代码几乎和 express 一样,也是通过把 tracId 存到一路透传的 ctx 变量里面实现参数的透传。

Nest.js

Nest (NestJS) 是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的开发框架。它利用 JavaScript 的渐进增强的能力,使用并完全支持 TypeScript (仍然允许开发者使用纯 JavaScript 进行开发),并结合了 OOP (面向对象编程)、FP (函数式编程)和 FRP (函数响应式编程)。

总结来说 Nest 的特点是,完美支持ts、拥抱装饰器和注解,同时通过依赖注入(DI)和模块化思想等,使代码结构工整,易于阅读。更多介绍可以看官网。

在官方文档上,Nest 是推荐如何使用 Async Local Storage 的,可以见这篇文章,代码如下

docs.nestjs.com/recipes/asy…

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
js复制代码// 使用 nestjs-cls这个库
// npm i nestjs-cls


// 模块初始化的时候,申明 Cls Module
@Module({
imports: [
// Register the ClsModule,
ClsModule.forRoot({
middleware: {
// automatically mount the
// ClsMiddleware for all routes
mount: true,
// and use the setup method to
// provide default store values.
setup: (cls, req) => {
// 通过CLS存储 traceId
cls.set('traceId', req.headers['x-trace-id'] || generateTraceId());
},
},
}),
],
providers: [CatService],
controllers: [CatController],
})
export class AppModule {}


// 在 Service 中注册 Cls,并且直接调用
@Injectable()
export class CatService {
constructor(
// We can inject the provided ClsService instance,
private readonly cls: ClsService,
private readonly catRepository: CatRepository,
) {}

getCatForUser() {
// and use the "get" method to retrieve any stored value.
const userId = this.cls.get('traceId'); // 获得 traceId
return this.catRepository.getForUser(userId);
}
}

上面的代码我们可以看到,Nest 和上面的库肉眼上的不同,是采用了依赖注入的方式进行注册,同时大量使用装饰器的方法。

如果对依赖注入有兴趣可以看这篇文章,完成了IOC的一个简单的实现。

zhuanlan.zhihu.com/p/311184005

OK,那么 nestjs-cls这个库做了什么?我们来看这个包的描述

The nestjs-cls package provides several DX improvements over using plain AsyncLocalStorage (CLS is an abbreviation of the term continuation-local storage).

DX 是 Developer Experience 即开发者体验。所以这个库是用于提升开发者体验的基于原生AsyncLocalStorage的包,所以,下面终于介绍到了我们今天的主角AsyncLocalStorage!

方案3:今天的角,AsyncLocalStorage

AsyncLocalStorage 是 Nodejs 的 API(大约在2019年推出,2020年进入稳定版本)

简单来说,就是 Node.js 觉得大家的方法都不够优雅,哥直接在C++的层面给你们做掉这个事,然后提供个API给大伙用,怎么样?嗯大概就是这样。

前面已经说了,这是官方的API,自然有官方的文档来描述

官方文档:

This class creates stores that stay coherent through asynchronous operations.

While you can create your own implementation on top of the node:async_hooks module, AsyncLocalStorage should be preferred as it is a performant and memory safe implementation that involves significant optimizations that are non-obvious to implement.

The following example uses AsyncLocalStorage to build a simple logger that assigns IDs to incoming HTTP requests and includes them in messages logged within each request.

文档地址:nodejs.org/api/async_c…

中文解释简单来说就是:AsyncLocalStorage是基于node:async_hooks实现的,并且(比之其他方法)是性能好、内存安全的方法去存储用于log的信息

我们来看个例子 AsyncLocalStorage是怎么使用的

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
javascript复制代码// How to use AsyncLocalStorage in Node.js
import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

function logWithId(msg) {
const traceId = asyncLocalStorage.getStore();
console.log(`${traceId}:`, msg);
}

let traceId = 0;
http.createServer((req, res) => {
// 关键的API调用
asyncLocalStorage.run(traceId++, () => {
logWithId('start');
// Imagine any chain of async operations here
setImmediate(() => {
logWithId('finish');
res.end();
});
});
}).listen(8080);

http.get('http://localhost:8080');
http.get('http://localhost:8080');
// Prints:
// 0: start
// 1: start
// 0: finish
// 1: finish

下面是这段代码的解释:

上面展示了2个请求被打到port:8080,异步调用被放在了asyncLocalStorage.run这个API里面。setImmediate异步调用的时候,通过logWithId取出该请求在进入时被赋予的id。可以看到,即使第二个请求的id(第一个请求进来的时候idSeq=0,第二个请求进来时idSeq=1)已经被log出来了,但是 line 8 的 同一个 asyncLocalStorage却能getStore出对应每个请求的不同id。

所以此方法,比通过函数内部变量的方式存储id优雅得多(另外,通过asyncLocalStorage这样隐式传递参数有点像函数式编程中的 State Monad 帮助开发者隐式管理参数传递)

小结

讲到这,我们Takeaway中的1已经被解决了

  • 什么是 AsyncLocalStorage ?什么时候使用它?如何使用它?

如果认真看完,相信你对它已经有了个感性的认识。那么接下来,我们来看标题的后一句话,AsyncLocalStorage的前世今生

三、历史合订:在 Node.js 14 之前的 Async Local Storage

忘记历史就意味着背叛(狗头),所以我们来看看历史的合订本

既然大家都有参数异步传递的需求,所以等19年AsyncLocalStorage被推出之前一定有社区方案被大家使用,所以我们来看下在 Node.js 14发布之前,社区是如何处理的。

这里我按照时间线来梳理此事,那要从2013年的春天说起

2013年:CLS横空出世(1.1k Star)

2013年4月30号,node-continuation-local-storage 这个仓库提交了第一个 commit,而这个仓库,社区更多地称它为CLS(Continuation-Local Storage,之后简称为CLS)。我截取了这个10岁的仓库README中的第一段给大家

Continuation-local storage works like thread-local storage in threaded programming, but is based on chains of Node-style callbacks instead of threads. The standard Node convention of functions calling functions is very similar to something called “continuation-passing style” in functional programming, and the name comes from the way this module allows you to set and get values that are scoped to the lifetime of these chains of function calls.

这段话可以提取出几个信息:

  1. CLS 像多线程编程中的独立线程的 storage(TLS: thread local storage)一样工作,只是原理是基于 callback function 而不是线程
  2. 取名中有 Continuation 代表 C,是因为类似于函数编程中的 “continuation-passing style” 概念,旨在链式函数调用过程中维护一个持久的数据
  3. 你set和get的值,是在这些异步的function的整个生命周期的调用链内的

如何使用

下面是 github 上该仓库的示例

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
js复制代码const express = require('express');
const cls = require('continuation-local-storage'); // require('cls-hooked') 也行,后面会提到
const app = express();

// Create a new namespace for the traceId
const traceNamespace = cls.createNamespace('trace');

// Middleware to set the traceId for each request
app.use((req, res, next) => {
traceNamespace.run(() => {
// Generate a new traceId if one doesn't exist
traceNamespace.set('traceId', generateTraceId());
next();
});
});

// Route to get the traceId for the current request
app.get('/traceId', async (req, res) => {
try {
const cookie = await cookieValidator()
// 校验是否成功等
// ...
} catch(e) {
//  上报 traceId
const traceId = traceNamespace.get('traceId');
reportError(err, traceId)
}
res.send(`Trace ID: ${traceId}`);
});

每次执行 namespace.run(callback) 都会生成一个上下文。语法上,通过 run 方法,包住一个回调函数,在这个回调内可以访问到我们的 Continuation-Local Storage。这个xxx.run(callbakc, ...)的语法之后我们会多次看到。

实现原理

CLS 通过 process.addAsyncListener 这个 API 监听异步事件。在创建异步事件的时候将当前上下文传入,执行异步回调时,传入上下文,异步事件执行结束销毁上下文。而process.addAsyncListener是 Node v0.11 版本的 API,目前仓库引用的是 polyfill 的方法。

从下面截图中可以看下,这是段 polyfill 的代码是2013年9月2号被提交的,是相当古早的事情了。

1
2
scss复制代码// load polyfill if native support is unavailable
if (!process.addAsyncListener) require('async-listener');

通过 async call 的事件,可以写出一个方法来存储我们在每个异步调用中的需要存储的变量。所以,这里还是用的一个局部变量来存储当前异步调用的上下文;同时在全局变量里面,维护了一个类似于栈的结构,通过此数据结构完成了nest的功能,即嵌套调用,run里面在嵌入一个run。不得不说,栈的结构非常适合做调用场景,因为 main call stack 就是一个栈 : )

我们来看下具体的代码实现:github.com/othiym23/no…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lua复制代码// createNamespace 就是调用内部的 create 方法
function create(name) {
assert.ok(name, "namespace must be given a name!");

var namespace = new Namespace(name); // 新建 space
namespace.id = process.addAsyncListener({
create : function () { return namespace.active; },
before : function (context, storage) { if (storage) namespace.enter(storage); },
after : function (context, storage) { if (storage) namespace.exit(storage); },
error : function (storage) { if (storage) namespace.exit(storage); }
});

process.namespaces[name] = namespace;
return namespace;
}

在create这个方法中,我们会新建一个 Namespace 来管理所有的方法,此 name 会在原生API上监听各种事件,同时触发我们的 store 变化。其中namespace.enter(storage)表示将此时的 ctx 入栈,在async call before的时候调用,即完成异步时间后、开始执行回调函数之前。而在async call after时,则是调用出栈方法 namespace.exit(storage)。

这个过程中,传入的参数 storage,就是我们在 store 中存入的 traceId

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
js复制代码// cls的实现

// 这是 store 全局变量的 class
function Namespace(name) {
this.name = name;
// changed in 2.7: no default context
this.active = null;
this._set = [];
this.id = null;
}

// run方法
Namespace.prototype.run = function (fn) {
var context = this.createContext();
this.enter(context);
try {
fn(context);
return context;
}
catch (exception) {
if (exception) {
exception[ERROR_SYMBOL] = context;
}
throw exception;
}
finally {
this.exit(context);
}
};

// 当前的 active 入栈,把新的 ctx 当做 this.active
Namespace.prototype.enter = function (context) {
assert.ok(context, "context must be provided for entering");

this._set.push(this.active);
this.active = context;
};

上面的 this._set就是刚才说的被维护的栈的结构。每一次 run 的调用,会创建一个 context 作为 this.active,同时把当前的老的 context(this.active)给 push 进入 this._set 这个栈,等待后续被pop后调用。

后来介绍的cls-hooked逻辑和他差不多,但是实现更容易理解,把他把每个异步调用的上下文存到了一个全局变量new map(),然后通过全局唯一的为异步调用生成的asyncId 作为 key 来区分。不过为了嵌套能力,栈的结构依旧保留。

虽然这个仓库已经在”历史的垃圾堆”里了,但是里面 API 的设计和数据存储结构的还是值得一看,因为之后的实现也沿用的类似的设计。

那我们接下来,看下一个API的发布

2017年:async_hooks

async_hooks不是一个三方库,而是一个Node build-in的module,供用户调用。

The async_hooks API was released in Node.js 8.x in 2017

如何使用

通过 hook 可以往 async call 的各个阶段注册方法,类似于我们熟悉的React生命周期。同时,每次异步初始化,都会生成一个独一无二的asyncId,所以基于此可以实现我们的异步监听

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
javascript复制代码const asyncHooks = require('async-hooks')
const asyncHook = asyncHooks.createHook({
init: (asyncId, type, triggerAsyncId, resource) => {},
before: asyncId => {},
after: asyncId => {},
destroy: asyncId => {},
promiseResolve: asyncId => {},
})
asyncHook.enable();



// init() is called during object construction. The resource may not have
// completed construction when this callback runs. Therefore, all fields of the
// resource referenced by "asyncId" may not have been populated.
function init(asyncId, type, triggerAsyncId, resource) { }

// before() is called just before the resource's callback is called. It can be
// called 0-N times for handles (such as TCPWrap), and will be called exactly 1
// time for requests (such as FSReqCallback).
function before(asyncId) { }

// after() is called just after the resource's callback has finished.
function after(asyncId) { }

// destroy() is called when the resource is destroyed.
function destroy(asyncId) { }

实现原理

具体的讨论,可以看最后的延展章节,这里就不过多介绍了

2017年:cls-hooked

在2017年,async_hooks发布后,Jeff Lewis 这位老兄马不停蹄地将老仓库fork出来,重新用最新的 async_hooks 重写了 CLS。由于重写后 API 没有任何变化,就不再列举使用方法了。

下面我们来看看他的 README

This is a fork of CLS using AsyncWrap OR async_hooks instead of async-listener.

When running Nodejs version < 8, this module uses AsyncWrap which is an unsupported Nodejs API, so please consider the risk before using it.

When running Nodejs version >= 8.2.1, this module uses the newer async_hooks API which is considered Experimental by Nodejs.

从他的 README 可以看到,Node版本小于8的,使用了 AsyncWrap,而Node版本大于8.2.1的则用async_hooks重写了。

值得注意的是,他用 Experimental来描述此API,自然而然我们到 Nodejs 的官网可以看到,不再被推荐使用。原因是可用性、安全性、以及性能表现都有问题。

当你想使用 Async Context Tracking 的能力,取而代之的应该是 AsyncLocalStorage。

其实因为是 ALS 做了适当的优化并且语法更简洁

Stability: 1 - Experimental. Please migrate away from this API, if you can. We do not recommend using the createHook, AsyncHook, and executionAsyncResource APIs as they have usability issues, safety risks, and performance implications. Async context tracking use cases are better served by the stable AsyncLocalStorage API.

Node文档中,API的稳定性(就是告诉你敢不敢用这个 API)有分级

即Stability index被分为了4档,分别是:

  • Stability: 0 - Deprecated
  • Stability: 1 - Experimental
  • Stability: 2 - Stable
  • Stability: 3 - Legacy

我们的 async_hooks 被归到了 Experimental,在未来的任何版本中都可能出现非向后兼容的变化或删除。不建议在生产环境中使用该功能。所以,任何线上环境都只使用 Statbility:2 - Stable 就好。

2019年:AsyncLocalStorage(ALS)千呼万唤始出来

AsyncLocalStorage was first introduced in Node.js version 12.0.0, released on April 23, 2019.

因为AsyncLocalStorage (ALS) 的需求强烈,所以在经过一系列的实验后,ALS终于在 Node v13.10.0 完整支持了,随后 API 迁移到了长期支持版本 Node v12.17.0

因为之前已经介绍过API的使用,也夸了不少了,下面我从其他角度来阐述下

性能问题

AsyncLocalStorage 直接基于 async_hooks 进行封装。而 async_hooks 对于性能有一定的影响,在高并发的场景,大家对此 API 保持了谨慎的态度。

图片来源:github.com/bmeurer/asy…

image.png

同时,Node 的 issue 里面也有大量对此的讨论,比如这个《AsyncLocalStorage kills 97% of performance in an async environment #34493》

本来Node的性能就是他的短板(或者说这事因为他的特质所导致的),现在用上ALS后性能又变差了不少,自然会让人在生产环境对他敬而远之,所以怎么解决呢?

后续更新

这个时候老大哥 V8 出手了,我借给你一个 V8 的 API 用吧,可以直接监听我的 Promise lifecycle

这就是 v8::Context PromiseHook API。这个 Hook 被加入 V8 后,很快被 Stephen Belanger 加入到了 async_hooks

这是引入 PromiseHook 的 PR 地址:PR: async_hooks: use new v8::Context PromiseHook API #36394

image.png

然后,从21年5月这个评论里面就能看出,github.com/nodejs/node…,在V8的加持下,在 Node v16.2.0 的版本里,ALS的性能”大幅”提升

image.png

小结

我们完成了2和3,同时拓展了些类似的场景和用法

  • 没有 AsyncLocalStorage 这个 API 之前的时代是怎么解决异步存储的?大概的原理是什么?
  • 了解广义上的 Async Local Storage 是如何一步一步发展过来的?(即合订本)

通过此章,我们其实可以按照时间轴画一张图出来

image.png

好的,这幅图已经阐述了大致的发展的历史

而最后一个之前从未提及的东西,也引出了这篇文章被写出的原因AsyncContext。为什么这么说,最总结的时候说吧

四、异枝同根:ALS 与最新 TC39 提案 AsyncContext 的关系

由阿里巴巴 TC39 代表主导的 Async Context 提案 刚在 2023年 2 月初的 TC39 会议中成为了 TC39 Stage 1 提案。提案的目标是定义在 JavaScript 的异步任务中传递数据的方案。

既然看到这里,大家也能很快明白新的 TC39 提案AsyncContext是在做什么。

对比两者的API,可以看到AsyncContext结合了AsyncLocalStorage和AsyncResource,并用了更通用的名字context来指代后两种方法的结合。

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
ts复制代码// ======= TC39提案: AsyncContext =======
class AsyncContext<T> {
// 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。
// 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。
static wrap<R>(fn: (...args: any[]) => R): (...args: any[]) => R;

// 立刻执行 fn,并在 fn 执行期间将 value 设置为当前
// AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被
// 快照(相当于 wrap)。
run<R>(value: T, fn: () => R): R;

// 获取当前 AsyncContext 实例的值。
get(): T;
}


// ======= Node API: AsyncLocalStorage =======
class AsyncLocalStorage<T> {
constructor();

// 立刻执行 callback,并在 callback 执行期间设置异步局部变量值。
run<R>(store: T, callback: (...args: any[]) => R, ...args: any[]): R;

// 获取异步局部变量当前值
getStore(): T;
}

class AsyncResource {
// 快照当前的执行上下文异步局部变量全局状态。
constructor();

// 立刻执行 fn,并在 fn 执行期间将快照恢复为当前执行上下文异步局部变量全局状态。
runInAsyncScope<R>(fn: (...args: any[]) => R, thisArg, ...args: any[]): R;
}

我在这里回答下这章的标题,他们的关系是什么?

答案是

  • AsyncLocalStorage是Node的API;不是标准,只是一个 runtime 的 API
  • AsyncContext是EMACScript标准(如果通过);通过后将成为规范,具体实现由各种 runtime 配合 JS Engine 来支持

看来这比雷锋和雷锋塔的关系更近些。

另外,为了让 ECMA 标准能同时能兼容Node的API(因为标准和目前的API不一样,到时候又得改),所以吞吞老师的提案让 AsyncContext 的语法和 AsyncLocalStorage 非常接近

五、它山之石:其他语言中管理多线程上下文的方法

大家可以了解下同行都是怎么做的,大家看看门道,我也看个热闹

Java

使用 ThreadLocal 来处理线程内的变量

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
java复制代码public class TraceIdFilter implements Filter {

private static final String TRACE_ID_HEADER = "X-Trace-Id";

private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String traceId = httpRequest.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
traceId = generateTraceId();
}
TRACE_ID.set(traceId);
try {
chain.doFilter(request, response);
} finally {
TRACE_ID.remove();
}
}

public static String getTraceId() {
return TRACE_ID.get();
}

private String generateTraceId() {
return UUID.randomUUID().toString();
}

// Other methods for initializing and destroying the filter...
}

C++

使用 thread_local 来处理本地线程变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cpp复制代码#include <iostream>
#include <thread>

thread_local int my_thread_local;

void my_thread_function() {
my_thread_local = std::hash<std::thread::id>()(std::this_thread::get_id());
std::cout << "My thread-local value is " << my_thread_local << std::endl;
}

int main() {
std::thread t1(my_thread_function);
std::thread t2(my_thread_function);
t1.join();
t2.join();
return 0;
}

Python

同上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
py复制代码import threading

my_thread_local = threading.local()

def my_thread_function():
my_thread_local.value = threading.get_ident()
print(f"My thread-local value is {my_thread_local.value}")

t1 = threading.Thread(target=my_thread_function)
t2 = threading.Thread(target=my_thread_function)
t1.start()
t2.start()
t1.join()
t2.join()

六、多走一步:AsyncLocalStorage 是如何实现的

下面两章,我们将多走俩步,介绍和讨论下这些 API 是如何实现的。如有错误,希望指正。

因为具体的一些细节会和 Node 的版本有强相关

所以特别声明:下面的文档、代码都以 Node v16.x LTS (Long-term Support) 中的文档和代码为例。

I Wonder Where the API Comes From

要想知道这个API是如何实现的。第一步,读文档

nodejs.org/docs/latest…

很遗憾,文档并没有过多介绍这个API的实现,但是却透露了一个重要信息,source code 来自lib/async_hook.js

image.png

image.png
所以顺理成章,我们进入这个文件进行探索

探索之前,我先大概介绍下 nodejs 项目的目录结构

配置文件和构建文件咱先不看,咱们看最主要的三个文件夹deps、lib、src

名字 说明 主要使用语言
deps 包含 Node.js 使用的第三方依赖项,包括 V8 引擎和 libuv 库等。简单理解就是 node_modules C++/C
lib 包含 Node.js 核心库文件,包括用于处理 I/O 操作、网络和其他核心功能的模块。简单理解就是核心库的JS的封装,作为API提供给Nodejs使用。比如咱们熟悉的 require(http) 就是引用的 lib/http.js Javascript
src 包含 Node.js 的 C++ 源代码,包括核心组件如事件循环、模块加载机制和 HTTP 服务器。C++核心模块 C++

让我们一步步来梳理 AsyncLocalStorage API’s calling chain

Javascript Zone

OK到这一步,我们大概知道了的 AsyncLocalStorageAPI 来自哪里,接着我们打开 async_hooks.js文件。

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
js复制代码// location: lib/async_hooks.js


// 1. 真正的储存位置
const storageList = [];
const storageHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
const currentResource = executionAsyncResource();
// Value of currentResource is always a non null object
for (let i = 0; i < storageList.length; ++i) {
storageList[i]._propagate(resource, currentResource);
}
}
});

function createHook(fns) {
return new AsyncHook(fns);
}

// 2. ALS Class 的实现
class AsyncLocalStorage {
constructor() {
this.kResourceStore = Symbol('kResourceStore');
this.enabled = false;
}

_enable() {
if (!this.enabled) {
this.enabled = true;
ArrayPrototypePush(storageList, this);
storageHook.enable();
}
}

run(store, callback, ...args) {
// Avoid creation of an AsyncResource if store is already active
if (ObjectIs(store, this.getStore())) {
return ReflectApply(callback, null, args);
}

this._enable();

// 新老 resource 交接班
const resource = executionAsyncResource(); // 新的resource
const oldStore = resource[this.kResourceStore]; // 老的resource

resource[this.kResourceStore] = store; // 新的resource,traceId存放的地方

try {
return ReflectApply(callback, null, args);
} finally {
resource[this.kResourceStore] = oldStore; // 等callback执行结束,将老的oldStore归还
}
}


getStore() {
if (this.enabled) {
const resource = executionAsyncResource();
return resource[this.kResourceStore];
}
}
}

为了便于阅读,上面的代码删去了不必要的部分。

当我们运行 AsyncLocalStorage.run(callback)的时候,会执行2个动作:

参照下面的API调用代码来看

  • this._enable(),激活 hook 监听
  • 通过 executionAsyncResource(),获得当前异步资源resource(AsyncResource,每次异步调用,V8都会创建一个对应的AsyncResource)
  • 然后把我们传入的 store 当做resource里kResourceStore对应的值(store就是traceId,kResourceStore就是一个Symbol而已)
  • 然后才执行我们的callback代码ReflectApply(callback, null, args)。其中ReflectApply直接理解为JS中的Function.Apply()。
  • 之后这个 run 方法里面,任何通过executionAsyncResource()得到的值都是我们👆🏻上面的 traceId
  • 最后,我们通过getStore()拿到这个traceId,完美!
1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

let traceId = 0;
asyncLocalStorage.run(traceId++, () => {
console.log(asyncLocalStorage.getStore())
setImmediate(() => {
console.log(asyncLocalStorage.getStore())
})
});

asyncLocalStorage.run('test', () => {})

总的来说基于此,我们ALS.run()里面的callback同步请求都可以顺利拿到对应的store,但是异步的请求每次会新建 AsyncResource。所以拿不到上面的 store

此时我们来看storageHook变量,他创建了一个 Hook 来监听 init 事件,在每个异步事件初始化的时候,把当前的异步资源(AsyncResource)的值传给我们初始化的异步调用,我们命名它为异步A。所以在不久的将来,异步A执行的时候,我们通过asyncLocalStorage.getStore()可以拿到正确的值

结论,ALS也是基于AsyncHook和神秘的executionAsyncResource实现的。但是他只使用了 init Hook,而且封装的很好,所以性能和可用性都更好。

所以不管怎么看 AsyncHook都更可疑。所以下章我们来分析它是如何实现的,并且可以监听劫持任何一种异步操作的生命周期。

另外,这个run方法 里面又是一个类似栈的结构,只不过实现的形式是通过类似于递归调用实现的。

通过这个方法完成了嵌套nest能力

其实从这段代码的 commit log 中也能证实我们的猜想

这个截图里面还有个小彩蛋 Reviewed-By: Zijian Liu

其实,这种递归还有点像 Leetcode 经典的回溯算法题 51. N-Queens,它就是对 tree 的DFS遍历。DFS遍历用递归写是上面的写法,而用迭代写就是用Stack了

七、再走一步:AsyncHook 是如何在 Node Core 层实现的

Intro and Guess

在上一个章节中,我们已经发现 AsyncHook 和 executionAsyncResource 是比较可疑的。所以我们先来看 AsyncHook

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
js复制代码// location: lib/async_hooks.js

class AsyncHook {
constructor({
init,
before,
after,
destroy,
promiseResolve
}) {
this[init_symbol] = init;
this[before_symbol] = before;
this[after_symbol] = after;
this[destroy_symbol] = destroy;
this[promise_resolve_symbol] = promiseResolve;
}

enable() {
// The set of callbacks for a hook should be the same regardless of whether
// enable()/disable() are run during their execution. The following
// references are reassigned to the tmp arrays if a hook is currently being
// processed.
const {
0: hooks_array,
1: hook_fields
} = getHookArrays();

// Each hook is only allowed to be added once.
if (ArrayPrototypeIncludes(hooks_array, this))
return this;

const prev_kTotals = hook_fields[kTotals];

// createHook() has already enforced that the callbacks are all functions,
// so here simply increment the count of whether each callbacks exists or
// not.
hook_fields[kTotals] = hook_fields[kInit] += +!!this[init_symbol];
hook_fields[kTotals] += hook_fields[kBefore] += +!!this[before_symbol];
hook_fields[kTotals] += hook_fields[kAfter] += +!!this[after_symbol];
hook_fields[kTotals] += hook_fields[kDestroy] += +!!this[destroy_symbol];
hook_fields[kTotals] +=
hook_fields[kPromiseResolve] += +!!this[promise_resolve_symbol];
ArrayPrototypePush(hooks_array, this);

if (prev_kTotals === 0 && hook_fields[kTotals] > 0) {
enableHooks();
}

updatePromiseHookMode();

return this;
}

}

还好,构造函数不算可疑,和预想的一样,把每个阶段的 hook 的 callback 存起来。然后再通过 enable 方法激活他们,那 line 44 的 enableHooks() 来自哪里?来自lib/internal/async_hooks.js(是的,自此我们知道原来每一个lib文件夹的API还调用了lib/internal这层内部的实现,简单理解就是又抽象了一层出来。)

看下代码

1
2
3
4
5
6
7
8
9
10
js复制代码// location: lib/internal/async_hooks.js

const async_wrap = internalBinding('async_wrap');
const { setCallbackTrampoline } = async_wrap;

function enableHooks() {
async_hook_fields[kCheck] += 1;

setCallbackTrampoline(callbackTrampoline);
}

在里面调用了 setCallbackTrampoline,这个方法来自 async_wrap。

其实,看代码可以知道,我们刚刚调用的神秘的 executionAsyncResource 里面调用的关键变量,几乎都来自async_wrap,通过 internalBinding 获取到的。

既然这里用的是internalBinding(String),入参是个string,再加上这个方法的名字,我们自然可以猜测 internalBinding 里面还有许多string可以被调用,而且可枚举完(但是为啥没有统一管理为const、enum、或者symbol,这个可能需要聪明的你去解答了)

随便搜下,无数个方法都通过 internalBinding获取到,猜测验证结束

image.png

但是在这一步,我们遇到一些小麻烦,因为你会发现internalBinding并没有通过 require()的方式引用进代码,command+左键也只能到它的d.ts定义里面。感觉似乎陷入了死胡同或者什么黑魔法之中

1
2
3
4
5
6
7
8
ts复制代码// d.ts file
declare function InternalBinding(binding: 'blob'): {
createBlob(sources: Array<Uint8Array | InternalBlobBinding.BlobHandle>, length: number): InternalBlobBinding.BlobHandle;
FixedSizeBlobCopyJob: typeof InternalBlobBinding.FixedSizeBlobCopyJob;
getDataObject(id: string): [handle: InternalBlobBinding.BlobHandle | undefined, length: number, type: string] | undefined;
storeDataObject(id: string, handle: InternalBlobBinding.BlobHandle, size: number, type: string): void;
revokeDataObject(id: string): void;
};

github.com/nodejs/help…

简单一搜,就能搜到问题的回答,柳暗花明。(其实全局搜索也能搜到这个方法)

让我们把目光来到lib/internal/bootstrap/loader.js。

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
js复制代码// location: lib/internal/bootstrap/loader.js

// This file creates the internal module & binding loaders used by built-in
// modules. In contrast, user land modules are loaded using
// lib/internal/modules/cjs/loader.js (CommonJS Modules) or
// lib/internal/modules/esm/* (ES Modules).

// C++ binding loaders:
// - internalBinding(): the private internal C++ binding loader, inaccessible
// from user land unless through `require('internal/test/binding')`.
// These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL()
// and have their nm_flags set to NM_F_INTERNAL.

// This file is compiled as if it's wrapped in a function with arguments
// passed by node::RunBootstrapping()
/* global process, getLinkedBinding, getInternalBinding, primordials */


// Set up internalBinding() in the closure.
/**
* @type {InternalBinding}
*/
let internalBinding;
{
const bindingObj = ObjectCreate(null);
// eslint-disable-next-line no-global-assign
internalBinding = function internalBinding(module) {
let mod = bindingObj[module];
if (typeof mod !== 'object') {
mod = bindingObj[module] = getInternalBinding(module);
ArrayPrototypePush(moduleLoadList, `Internal Binding ${module}`);
}
return mod;
};
}

这是简化后的备注,简单理解就是这个文件被加载为了 loader,既然是 loader 自然要 load 文件,load 什么呢?

用于 load built-in modules(加载内部模块)。把C++的模块load到js里面进行调用

大家可以发现,In line 30,getInternalBinding 也是‘凭空’出现的。自然我们去搜下他

image.png

看来,getInternalBinding() 是真在js文件里面找不到了,因为它的定义不来自js世界

Alright,恭喜你,到达C++的地盘。

其实,我们是使用 internalBinding() 将 async_wrap 从async_wrap.cc加载到了 async_wrap.js

那 internalBinding 是被定义在了 js 文件里面,又为啥可以被全局访问到,而且没使用 require。这个在备注里面也有解释

// This file is compiled as if it's wrapped in a function with arguments

// passed by node::RunBootstrapping()

这个文件被编译了,就像函数的参数一样被传入node::RunBootstrapping()调用。而这个方法,就是Node的C++ built-in module的启动函数

C++ World

我们回到主线,看看async_wrap在做什么,是怎么实现的

总之,async_wrap被getInternalBinding给get到了loader.js里面,那有get就一定有个对应的set或者说注册。是的,我们把目光放到src文件夹的这里src/async_wrap.cc

1
2
3
4
5
6
7
cpp复制代码// location: src/async_wrap.cc

// 该文件结尾处
// 在node_binding.h里面定义了宏macro
// #define NODE_MODULE_CONTEXT_AWARE_INTERNAL(modname, regfunc)
NODE_MODULE_CONTEXT_AWARE_INTERNAL(async_wrap, node::AsyncWrap::Initialize);
NODE_MODULE_EXTERNAL_REFERENCE(async_wrap, node::AsyncWrap::RegisterExternalReferences);

在该文件的结尾处,我们通过NODE_MODULE_CONTEXT_AWARE_INTERNAL这个宏(macro),注册了async_wrap。

自此我们在代码层面回答了 where setasync_wrapis called。

其实我们的内部模块(Internal Module)是通过NODE_MODULE_CONTEXT_AWARE_INTERNAL来暴露给js以作调用的,过程中使用的 loader 就是上文提到的getInternalBinding

至于NODE_MODULE_CONTEXT_AWARE_INTERNAL是怎么实现的,欢迎大家自己深挖。

NODE_MODULE_CONTEXT_AWARE_INTERNAL-> NODE_MODULE_CONTEXT_AWARE_CPP -> …

自此小结,我们知道了async_wrap是在哪里被注册的,以及async_wrap的行为在哪里被定义

接下来我们看async_wrap.cc内部在做什么

async_wrap.cc

这个文件有700行左右的代码,我就不全部贴出来了。

不过在看具体代码前,我们先猜下,他在干嘛。下面是我的猜测,从名字来看,他叫做 async wrap,wrap就是把我们的async call给包住了,包住代表什么?代表async call想做什么事,都得先过一层我wrap再说。

熟悉吗?这就是我们所谓的监听(listen)、劫持(hijack)、包裹(wrap)、监控(monitor),大致都是一个意思。wrap其实还有点像AOP(面向切面编程)、洋葱模型、代理proxy等东西,都是主观上给人一种层(layer)的感觉。

话说回来,在一开始我们就知道,libuv是用C写的库,负责异步I/O等工作,那我们的就猜测,你既然wrap劫持async call,那具体是劫持什么呢,多半就是和libuv的东西有关了。所以下一步,我们找文件里面和libuv和劫持相关的代码。

不过很遗憾,并没有在async_wrap.cc代码内部找到uv.h头文件(代表libuv)的引用,至少libuv没有被直接使用在这个文件里。但是大的方向不会错,那我们来看libuv官网文档docs.libuv.org/en/v1.x/api…

这里里面有大量句柄(handle),用于处理I/O事件的对象,它负责管理底层I/O资源的分配和释放,以及处理I/O事件的通知和回调

注意下面只是猜想!不是对的!

猜想1:

应该就是调用的 libuv 里这个API了,用作提交异步请求,并且拿到异步的回调。

两个库内部的代码直接互相调用,并不符合规范,他们都被包装到一个内部对外的API进行交互

所以我们 async_wrap <---> libuv 这种关系可以抽象为下面的图👇🏻

image.png

猜想2:

AsyncWrap作为基类,提供各种基础API和JS层交互。衍生的子类和 libuv 通过 uv_handle_t 进行交互,由 libuv 通知子类去执行对应的 async hook

我们可以在下一章看看猜测是否正确

最后,libuv里面的uv不是每日访问量UV(Unique Visitor)或者DAU(Daily Active User),而是 Lib of Unicorn Velociraptor(独角迅猛龙)。你问我为啥是独角迅猛龙,因为。。。。看他的logo吧

image.png

How Is AsyncHook.Init() Invoked by Node Core

为了回答上面那个猜想,我想我可以直接介绍下 AsyncHook 的 init 方法是如何被 Node Core 调用的

既然我们注册了方法,把 init 存在了某个地方,那么在一个 async call 的初始化的时候,它会被触发。所以我们有了下面2个步骤:

Step 1, where is callback stored in?

上一章说了,每一个 hook cb 被存在了 AsyncHook Class 对应的 this[xxx_symbol] = xxx 里面,在被 enable 的时候,通过 ArrayPrototypePush(hooks_array, this) 被 push 到了 hooks_array。

这个 hooks_array 来自 lib/internal/async_hooks.js,叫做 active_hooks,看下定义,一个简单的 object

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
js复制代码// location: lib/internal/async_hooks.js

// Properties in active_hooks are used to keep track of the set of hooks being
// executed in case another hook is enabled/disabled. The new set of hooks is
// then restored once the active set of hooks is finished executing.
const active_hooks = {
// Array of all AsyncHooks that will be iterated whenever an async event
// fires. Using var instead of (preferably const) in order to assign
// active_hooks.tmp_array if a hook is enabled/disabled during hook
// execution.
array: [],
// Use a counter to track nested calls of async hook callbacks and make sure
// the active_hooks.array isn't altered mid execution.
call_depth: 0,
// Use to temporarily store and updated active_hooks.array if the user
// enables or disables a hook while hooks are being processed. If a hook is
// enabled() or disabled() during hook execution then the current set of
// active hooks is duplicated and set equal to active_hooks.tmp_array. Any
// subsequent changes are on the duplicated array. When all hooks have
// completed executing active_hooks.tmp_array is assigned to
// active_hooks.array.
tmp_array: null,
// Keep track of the field counts held in active_hooks.tmp_array. Because the
// async_hook_fields can't be reassigned, store each uint32 in an array that
// is written back to async_hook_fields when active_hooks.array is restored.
tmp_fields: null
};


module.exports = {
executionAsyncId,
triggerAsyncId,
// Private API
getHookArrays,
symbols: {
async_id_symbol, trigger_async_id_symbol,
init_symbol, before_symbol, after_symbol, destroy_symbol,
promise_resolve_symbol, owner_symbol
},
// ..
executionAsyncResource,
// Internal Embedder API
// ...
nativeHooks: {
init: emitInitNative, // <====== 看这里
before: emitBeforeNative,
after: emitAfterNative,
destroy: emitDestroyNative,
promise_resolve: emitPromiseResolveNative
},
};

同时,这个 lib/internal/async_hooks.js 文件export的方法中有个名字比较可疑,emitInitNative。Native, Native,名字里面有 Native,实在太不对劲了。我们来看下实现

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
js复制代码// location: lib/internal/async_hooks.js

// Emit From Native //

// Used by C++ to call all init() callbacks. Because some state can be setup
// from C++ there's no need to perform all the same operations as in
// emitInitScript.
function emitInitNative(asyncId, type, triggerAsyncId, resource) {
active_hooks.call_depth += 1;
resource = lookupPublicResource(resource);
// Use a single try/catch for all hooks to avoid setting up one per iteration.
try {
// Using var here instead of let because "for (var ...)" is faster than let.
// Refs: https://github.com/nodejs/node/pull/30380#issuecomment-552948364
// eslint-disable-next-line no-var
for (var i = 0; i < active_hooks.array.length; i++) {
if (typeof active_hooks.array[i][init_symbol] === 'function') {
active_hooks.array[i][init_symbol](
asyncId, type, triggerAsyncId,
resource
);
}
}
} catch (e) {
fatalError(e);
} finally {
active_hooks.call_depth -= 1;
}

// Hooks can only be restored if there have been no recursive hook calls.
// Also the active hooks do not need to be restored if enable()/disable()
// weren't called during hook execution, in which case active_hooks.tmp_array
// will be null.
if (active_hooks.call_depth === 0 && active_hooks.tmp_array !== null) {
restoreActiveHooks();
}
}

通过 comment 证明了,emitInitNative 确实这个方法,最终会被 native 调用(C++)。可以看到,我们一路存下来的 active_hooks 会在 line 18 里面,在js层被调用。

同理,我们上面的 before, after 等也是一样的。

至此,我们回答了标题,callback是存在哪里并被执行的

先别急着走到下一步,我们还差一件事,就是如何把这个 js 的方法暴露给我们的 native 层?在这里

1
2
3
4
js复制代码// location: lib/internal/bootstrap.js

const { nativeHooks } = require('internal/async_hooks');
internalBinding('async_wrap').setupHooks(nativeHooks);

神秘的,从C++层来的 async_wrap 负责把我们的 nativeHooks 注册到c++。OK,现在我们只需要记住 setupHooks is responsible for registering nativeHooks 即可

Step 2, which code line is responsible for calling init callback?

先说结论,每一个 async call 都会由一个 C++ 的类叫做 AsyncWrap 来包装,地址在 src/async_wrap.cc

同时,上面提到,我们是通过下面这个方法把 async_wrap 暴露出去的,所以我来看Initialize

NODE_MODULE_CONTEXT_AWARE_INTERNAL(async_wrap, node::AsyncWrap::Initialize)

Initialize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cc复制代码// location: src/async_wrap.cc

void AsyncWrap::Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
Environment* env = Environment::GetCurrent(context);
Isolate* isolate = env->isolate();
HandleScope scope(isolate);

env->SetMethod(target, "setupHooks", SetupHooks);
env->SetMethod(target, "setCallbackTrampoline", SetCallbackTrampoline);
env->SetMethod(target, "pushAsyncContext", PushAsyncContext);
env->SetMethod(target, "popAsyncContext", PopAsyncContext);
env->SetMethod(target, "executionAsyncResource", ExecutionAsyncResource);
env->SetMethod(target, "clearAsyncIdStack", ClearAsyncIdStack);
env->SetMethod(target, "queueDestroyAsyncId", QueueDestroyAsyncId);
env->SetMethod(target, "setPromiseHooks", SetPromiseHooks);
env->SetMethod(target, "registerDestroyHook", RegisterDestroyHook);

PropertyAttribute ReadOnlyDontDelete =
static_cast<PropertyAttribute>(ReadOnly | DontDelete);
// ...
}

先解释几个基本概念和数据类型:

  • Isolate: line 8 中被用到,被定义在 v8.h。Isolate是V8引擎的一个独立实例。它是一个独立的JavaScript运行时,运行在一个单独的线程中,拥有自己的内存堆、垃圾回收器和执行上下文。可以在一个进程中创建多个Isolate,每个Isolate提供一个单独的运行时环境,可以独立地运行JavaScript代码。
  • Context: line 5 中被用到,被定义在v8.h。Context表示Isolate中的一个执行上下文。它是一个JavaScript对象,包含当前执行上下文的状态,包括变量、函数和其他数据。Context在Isolate中创建,并与特定的执行线程相关联。可以在单个Isolate中创建多个Context,每个Context可以独立地执行JavaScript代码。我们熟知的 vm.createContext()也是创建了一个新的 Context 实例。
  • Local: lin 5 中被用到,被定义在v8.h。在 V8 引擎(Node.js 的 JavaScript 引擎)中,用于表示 JavaScript 对象的本地句柄(Handle)
    • 看下原文描述:An object reference managed by the v8 garbage collector. All objects returned from v8 have to be tracked by the garbage collector so that it knows that the objects are still alive。
      • 可以理解为类似于指针,但是指向的内存地址会随着GC(garbage collection)而变化,确保总是指向我们需要的值,同时管理引用的对象是否可以被清理。
      • Local 句柄是一种轻量级的对象引用,它在 V8 的内存管理系统中的生命周期是有限的。当 V8 的垃圾回收器进行内存回收时,Local 句柄所引用的对象可能会被清理。Local就代表一个V8 Context 的本地句柄。除了本地句柄Local,还有MaybeLocal,Eternal等类型。
      • line 9 中的 HandleScope 也是用于管理句柄生命周期的
  • Environment: line 7 中被用到,被定义在src/env.h。在 Node.js 的 C++ 层面,Environment 类是一个核心组件,负责管理和维护 Node.js 应用程序的上下文环境和资源。它提供了一个桥梁,让 Node.js 的 JavaScript 层与底层的 C++ 实现进行交互。Environment 类封装了许多与 JavaScript 运行时相关的资源。以下是 Environment 类的一些主要职责:
    • 管理 V8 Isolate 实例:Isolate 是 V8 引擎中表示独立的 JavaScript 运行时环境的对象。一个 Environment 实例与一个特定的 Isolate 实例关联,它们共同构成了 Node.js 应用程序的运行时环境。
      • 内存管理:Environment 类负责管理与内存相关的资源,如对象句柄、缓冲区等。通过创建 V8 HandleScope 和 EscapableHandleScope 实例,Environment 能确保 V8 能正确地管理 JavaScript 对象的生命周期。
      • 与 JavaScript 层的互操作:Environment 类提供了一系列方法,使 JavaScript 层与底层的 C++ 实现进行交互。这些方法包括设置 JavaScript 对象的属性和方法、执行回调函数等。

OK,基于此我们再来看代码。

在 line 7,我们通过 Environment::GetCurrent(context) 获取到当前上下文的 Environment* 指针,接着在line 11,通过这个指针所指的方法 SetMethod,讲我们的 SetupHooks 绑定到上一节提到过的 internalBinding('async_wrap').setupHooks(nativeHooks);

那 SetupHooks 是怎么实现的

SetupHooks

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
cc复制代码// location: src/async_wrap.cc

static void SetupHooks(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);

CHECK(args[0]->IsObject());

// All of init, before, after, destroy, and promise_resolve are supplied by
// async_hooks internally, so this should only ever be called once. At which
// time all the functions should be set. Detect this by checking if
// init !IsEmpty().
CHECK(env->async_hooks_init_function().IsEmpty());

Local<Object> fn_obj = args[0].As<Object>();

#define SET_HOOK_FN(name) \
do { \
Local<Value> v = \
fn_obj->Get(env->context(), \
FIXED_ONE_BYTE_STRING(env->isolate(), #name)) \
.ToLocalChecked(); \
CHECK(v->IsFunction()); \
env->set_async_hooks_##name##_function(v.As<Function>()); \
} while (0)

SET_HOOK_FN(init);
SET_HOOK_FN(before);
SET_HOOK_FN(after);
SET_HOOK_FN(destroy);
SET_HOOK_FN(promise_resolve);
#undef SET_HOOK_FN
}

这里第一步,是获取 Environment* 指针,接着确保 args[0] 是一个 Objext,同时 async_hooks_init_function 是 empty,确保只会被初始化1次。

接着定义了 SET_HOOK_FN 这个宏(marco),通过这个宏,将 init 方法绑定到触发函数

env -> set_async_hooks_##name##_function()

##name## 就是我们的init、before、after、destroy变量,‘#’ 和 ‘##’ 语法用于 C/C++ 中的 macro 命令。最后的 #undef 使用是去掉宏的定义,目的是为了防止此宏在其他地方调用。

所以最终,这个方法,在 AsyncWrap::EmitAsyncInit 中调用

EmitAsyncInit

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
cc复制代码// location: src/async_wrap.cc

void AsyncWrap::EmitAsyncInit(Environment* env,
Local<Object> object,
Local<String> type,
double async_id,
double trigger_async_id) {
CHECK(!object.IsEmpty());
CHECK(!type.IsEmpty());
AsyncHooks* async_hooks = env->async_hooks();

// Nothing to execute, so can continue normally.
if (async_hooks->fields()[AsyncHooks::kInit] == 0) {
return;
}

HandleScope scope(env->isolate());
Local<Function> init_fn = env->async_hooks_init_function();

Local<Value> argv[] = {
Number::New(env->isolate(), async_id),
type,
Number::New(env->isolate(), trigger_async_id),
object,
};

TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
USE(init_fn->Call(env->context(), object, arraysize(argv), argv));
}

// location: v8.h

/**
* A JavaScript function object (ECMA-262, 15.3).
*/
class V8_EXPORT Function : public Object {}

set the fn: env -> set_async_hooks_##name##_function()

get the corresponding fn: env -> async_hooks_init_function()

在 line 18,我们获得了之前注册的 init,这是一个 Local 句柄,Local 就是一个指向 js 的 function 的句柄,最后,我们通过 line 28 的 init_fn -> Call() 可以来触发 js 函数

至此,说完了一个 Async Init Hook 是如何被完整调用的。

下面我们来回顾下整体的关系,算是是一个小结

Sum Up: High-Level Overview Flowchart

image.png

  • API:很好理解,暴露了这3个重要的API
  • Node Core - Native JS Module:
+ 上面的3个API来自async\_hooks.js中的3个类:AsyncLocalStorage/AsyncResource/AsyncHook
+ AsyncHook负责注册4个阶段的Callback function
+ 在这里通过 internalBinding('async\_wrap') 获得C++层的 AsyncWrap
  • Node Core - C++ Binding:
+ 在 async\_wrap.cc 中定义了关键的基类 `AsyncWrap`,它继承自 `BaseObject`
+ 通过 NODE\_MODULE\_CONTEXT\_AWARE\_INTERNAL 方法暴露给 JS 层
+ AsyncWrap只是一个基类。UPDWrap、TCPWrap、FSEventWrap等直接或间接继承者它,为各种 Wrap 提供负责触发Hook回调的方法。
    - 比如 TCPWrap -> ConnectionWrap -> LibuvStreamWrap -> HandleWrap -> AsyncWrap
    - libuv的方法在具体的 Wrap 里面调用。
        * 举个例子,当一个 TCP 网络请求发出时,会执行 new TCPWrap,通过 uv\_tcp\_connect() 发起链接(方法来自libuv);
        * 链接成功后,会通过一个句柄(uv\_tcp\_t),对 libuv 保持访问。整个过程中句柄类型会被转变 uv\_tcp\_t -> uv\_stream\_t
        * 当请求返回的时候, TCPHandle 对象会触发 uv\_\_stream\_io() 方法去执行 uv\_\_read(),最终通知 TCPWrap 或者其父类执行回调
+ src/api 文件夹中给三方addons提供了一些API,其中AsyncResource是基于AsyncWrap的封装,AsyncWrap触发before和after的异步事件是通过 AsyncWrap::MakeCallback 方法,该方法调用 CallbackScope 内部的 InternalMakeCallback
  • Deps:
+ Libuv: 对I/O异步、网络异步回调负责
+ V8: 对Promise 和 async/await 语法负责
+ 最终通过 AsyncWrap 通知到 JS 层的 AsyncHook

Add-On

这里是一些收集资料过程中发现的相关信息和彩蛋,分享给大家

Performance Improvement

image.png

PR:async_hooks: use resource stack for AsyncLocalStorage run #39890

关键字:

  • using stack instead of AsyncResouce instance
  • eliminate extra lifecycle event

上文提到过,执行AsyncLocalStorage.run的时候有个 commit log,这次的 PR 目的是为了提升性能

PromiseHook

image.png

PR: async_hooks: fast-path for PromiseHooks in JS #32891

又是这个哥们。

也有一个彩蛋。Reviewed-By: Chengzhong Wu

(但是为啥明明被关闭的PR?代码 commit 却出现了在了 Node v16.18?因为Node发版和代码,不使用Github的PR,有一套自己的流程)

image.png

How is loader created

这里放下 lib/internal/bootstrap/loader.js文件的完整备注,有兴趣的同学可以看下,里面的分情况讨论了require语法里拿到的各种对象是怎么被加载进去的。

其实把文档的注释读完,整个 loader 这一层就会清楚很多。所以非常喜欢 Node 的丰富的注释。

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
js复制代码// location: lib/internal/bootstrap/loader.js

// This file creates the internal module & binding loaders used by built-in
// modules. In contrast, user land modules are loaded using
// lib/internal/modules/cjs/loader.js (CommonJS Modules) or
// lib/internal/modules/esm/* (ES Modules).
//
// This file is compiled and run by node.cc before bootstrap/node.js
// was called, therefore the loaders are bootstrapped before we start to
// actually bootstrap Node.js. It creates the following objects:
//
// C++ binding loaders:
// - process.binding(): the legacy C++ binding loader, accessible from user land
// because it is an object attached to the global process object.
// These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE()
// and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees
// about the stability of these bindings, but still have to take care of
// compatibility issues caused by them from time to time.
// - process._linkedBinding(): intended to be used by embedders to add
// additional C++ bindings in their applications. These C++ bindings
// can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag
// NM_F_LINKED.
// - internalBinding(): the private internal C++ binding loader, inaccessible
// from user land unless through `require('internal/test/binding')`.
// These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL()
// and have their nm_flags set to NM_F_INTERNAL.
//
// Internal JavaScript module loader:
// - NativeModule: a minimal module system used to load the JavaScript core
// modules found in lib/**/*.js and deps/**/*.js. All core modules are
// compiled into the node binary via node_javascript.cc generated by js2c.py,
// so they can be loaded faster without the cost of I/O. This class makes the
// lib/internal/*, deps/internal/* modules and internalBinding() available by
// default to core modules, and lets the core modules require itself via
// require('internal/bootstrap/loaders') even when this file is not written in
// CommonJS style.
//
// Other objects:
// - process.moduleLoadList: an array recording the bindings and the modules
// loaded in the process and the order in which they are loaded.

'use strict';

// This file is compiled as if it's wrapped in a function with arguments
// passed by node::RunBootstrapping()
/* global process, getLinkedBinding, getInternalBinding, primordials */

The Workflow of Node.js Startup

图片来源:leezhenghui.github.io/node.js/201…

image.png

我们的上面提到过的 loader 就是在 LoadEnv 阶段被执行的。

而我们之前讨论的 AsyncHook 的实现,就在上面的 [node native module] - [node-core(c/c++)] 两层之间

有兴趣的同学可以看看原文章。

非常详细得介绍了 Node,名字也很有意思 《Demystify node.js - Modularization》

image.png

八、最后的最后:打个总结

写这篇文章的想法非常突然。大概是2月初,吞吞老师在群里分享了关于 AsyncContext 提案进入 Stage1 的消息,就瞄了一眼发现看不懂,于是在大群里求教。

后来,了解到这个概念和Node中的一些API类似,之后就开始慢慢了解这个东西的背景,心里想着都整理了些内容了,不如写篇文章总结下吧。但是光介绍 API 咋用由有点浅,官网文档也有,于是所幸梳理下这个概念的发展过程。后来感觉,发展过程都看了,不如实现原理也一把梭吧,况且要写就质量写高点嘛,正好借这个机会再了解下Node,于是,便有了这篇文章。

不过,整个过程还是比较艰难的,边猜边学边写。难点有二,难点之一,是文章内容对我有一定难度;从一个点切入进入(一个API),发现还需要一条线(Node的机制)甚至一个面的知识(其他语言、甚至相关领域的知识)。一开始有点overwhelming,发现需要同步学的知识太多了。后来可算找到了方法,就是我只要紧盯着这个点,不要盲目展开 Todo List,就慢慢向外扩展即可。难点之二,是行文如何展开,因为就我自己读技术书籍的感觉,很少描写为什么,需要自己去想或者本身就有知识储备才能得到答案,我还是希望能提供更多的背景来一步一步介绍这个概念。想法很美好,不过事实上自己上手之后发现,还是很难理出一条思路,你需要像演员带入角色一样来带入读者,很考验功力,特别是最后3章,把握不住了。

幸好,过程中有贵人相助,感谢过程中各位老师的指教 @子肃 @步白 @革新。感谢两位老师的审稿@吞吞 @晓田;以及感谢让我写这篇文章的”始作俑者” @吞吞,没有那条恭喜提案的消息我也不会去看这方面的内容,自然也不会有这篇文章。还有些没有点名的朋友,再次也一一感谢了。多助得道、寡助失道,寻求帮助对我来说意义很大,感谢大家。

最后,希望大家在有收获的地方不吝点赞,发现错误的地方不吝指正,谢谢!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

手把手带你入门 Threejs Shader 系列(一)

发表于 2023-05-15

插播一条在朋友圈、群里早发过但文章里还没发的“最新”动态:五一前后古柳总算换了能看到 AR 的手机,基于一年前的 demo 用 Three.js + Shader 等做了个 AR 简单效果,欢迎大家用手机 Chrome 访问 desertsx.github.io/webxr 体验下并分享录屏效果,每次配色都会不同,在自己身边显示出增强现实效果还是很酷的。当然有些手机可能不支持,出现 not supported 就是不行,注意得用手机 Chrome 访问。具体效果可参见原文里的视频号内容:手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳

说多了都是泪

离上篇文章发布又快过去一个月,这篇文章古柳写起来也是很难受,感觉很多东西讲起来很繁琐,自己讲不清楚,一直不擅长讲解原理和基础的细节,有时候真想直接放一些文章和视频,让大家自己去看,不做解释,但感觉那样对于这个系列的教程而言就有所缺失,所以终究还是边痛苦地尝试解释,边艰难地着一步步完成内容,虽然最终内容可能仍不完美,但好歹先完成再说。也希望大家点赞、转发、打赏、评论等多多支持。另外放了一些链接可供大家参考学习,希望能对大家有所帮助。

前置要求

在正式带大家走进 Three.js Shader 世界之前,需要大家对 Three.js 有最基本的了解,能看懂下面的代码,知道如何在 3D 里放一个纯色的平面物体即可。

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
html复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js Shader</title>
<style>
body {
margin: 0;
}
</style>
</head>

<body>
<script type="module">
import * as THREE from 'https://unpkg.com/three@0.152.2/build/three.module.js';

// Scene
const scene = new THREE.Scene();

// Camera
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 2;
// camera.lookAt(new THREE.Vector3());

// Mesh = Geometry + Material
const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.MeshBasicMaterial({
color: 0x0ca678,
// wireframe: true
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// Renderer
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor("#e6fcf5", 1);
document.body.appendChild(renderer.domElement);

// Animation
function animate() {
requestAnimationFrame(animate);
// mesh.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>

如果你之前没接触过 Three.js,建议先看看 Three.js 官方文档的这篇教程「创建一个场景(Creating a scene)」,或者看看古柳之前安利过的b站up主进华简洁清晰的视频——「three.js教程-从入门到入门」。

如果你喜欢看书学习,刚好「Learn Three.js/Three.js开发指南」英文第四版前几个月上市,zlibrary 上已经有电子书,大家可自行下载,也可以在「牛衣古柳」公众号后台回复 learn three.js 获取 PDF 版本。

点线面体

目前我们在场景里放了一个宽高 1x1 的平面,当开启线框模式 wireframe: true,会发现默认的平面由4个顶点、2个三角形组成。

1
2
3
4
5
6
7
js复制代码const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.MeshBasicMaterial({
color: 0x0ca678,
wireframe: true
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

顶点连成线、线组成三角形、三角形组成几何体,立方体、球体等亦是如此。

顶点属性数据

console.log(geometry) 打印几何体,可以看到其中 attributes 上携带的每个顶点的数据,里面包含顶点坐标 position、纹理坐标 uv、法线 normal。

这里 count 是顶点数,itemSize 是每个属性的维度数,比如 position 和 normal 都是3维的、uv 是2维的,具体说来就是 position 的 array 里是3个一组表示某个顶点的坐标数据,uv、normal 同理。配图里古柳按照每个顶点一条数据的格式进行排列,方便大家理解。

因为平面宽高均为1,默认几何体在3维坐标系的原点(0, 0, 0)处居中且面朝z轴正方向显示,所以顶点坐标依次如图所示,挺好理解的。

uv 纹理坐标

而 uv 纹理坐标很多人可能第一次接触不太了解,其实只需要记住这张图,u 从左到右增加、v 从下到上增加,范围从左下角 (0, 0) 到右上角 (1, 1) 即可,哪怕是长方形平面、球体、复杂3D模型等物体,其顶点上的纹理坐标都是这个范围。借助 uv 就能把纹理图片贴到3D物体上,这也是 uv 的一大用处,后续会演示,很简单。

别看只有几个顶点才有 uv 值,其实三角形内每个片元/像素后续都会自动插值得到数值,这背后借助了三角形重心坐标/barycentric coordinates这一良好特性,感兴趣的朋友可自行了解,这里只需知道平面 plane 内每个位置都会有 uv 值,比如中心位置是 (0.5, 0.5)。

而顶点上的属性,后续在 vertex shader 顶点着色器里就能直接获取到并使用。我们也可以往顶点上挂所需的数据,即自定义属性,然后在着色器里使用,这些后续都会介绍。

Shader 登场

讲完顶点上的属性数据,尤其是后续会用到的 uv 值,接下来该 Shader 着色器登场,看看具体能用 uv 做些什么(得到下一篇文章才来得及讲了)。

在「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳」一文,古柳就简单演示过 Three.js 里要写的 Shader 代码长什么样,这里展开介绍下细节。

首先我们不再需要使用 Three.js 内置的那些材质,而是用 ShaderMaterial 替换 MeshBasicMaterial,并且通过设置 vertexShader 顶点着色器和 fragmentShader 片元着色器,来实现自定义每个顶点、每个片元/像素如何显示。

  • 链接:ShaderMaterial - Three.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码const vertex = `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragment = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;

// const material = new THREE.MeshBasicMaterial({ color: 0x0ca678 });
const material = new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment
});

vertex 和 fragment 里是用类似C语言的 GLSL 语言——即 OpenGL Shading Language——写的 shader 程序,由 GPU 分别对每个顶点、每个片元独立执行,并且每个顶点或片元都不知道其他顶点或片元的数据。这句话看似不起眼,但古柳觉得却是大家刚接触 shader 时觉得很抽象不好理解的原因之一,也是古柳刚开始入门时觉得非常重要的一个切入点,后续讲到具体例子时会再提,这里大家还一头雾水也没事。

shader 程序可以单独写在诸如 vertex.glsl、fragment.glsl 的文件里再导入使用;也可以和示例一样直接在 JavaScript 里用字符串格式表示。这里为了讲解方便采取后者方式。

在顶点着色器里需要设置 gl_Position 顶点位置,在片元着色器里需要设置 gl_FragColor 片元/像素颜色,两者都在没有返回值的 void main() {} 主函数里设置,并且 main 函数会被自动执行。在后续很多例子里我们都只需要设置这俩内置变量的值,只有等介绍粒子系统时才会在顶点着色器里另外设置 gl_PointSize 粒子大小。

Vertex Shader

一般的教程会先讲片元着色器,先不去改变顶点、改变三维物体形状,毕竟先把另一个放一边讲起来更方便,而设置颜色相对好讲些。但即使如此,要确保 Three.js Shader 里代码完整性,最简单的顶点着色器也要设置这一串东西,以确保三维空间的物体呈现在二维屏幕上。

1
2
3
4
5
6
js复制代码const vertex = `
void main() {
// gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

这里虽然就一行代码,但背后原理需要一整篇文章进行介绍,此处罗列了一些文章仅供参考,由于涉及不少图形学知识,如果大家目前还不理解,也不重要,只需知道必须这样固定设置才能显示,看完下面介绍初步了解后跳到 GLSL 基础讲解与片元着色器学习颜色设置也是可以的。

  • 链接:从零开始学图形学:MVP Transformation
  • 链接:图形学:MVP变换概述
  • 链接:计算机图形学 5:齐次坐标与 MVP 矩阵变换

虽然解释起来很为难,但古柳还是简单讲下,这里的 position 就是上文提到的几何体 attributes 里各顶点的坐标,它是三维向量类型 vec3,需要先变成 vec4 四维向量类型——即(x, y, z, w)4个分量的格式——因为有了第4个值 w=1.0 才能进行矩阵操作,实现旋转、平移、缩放,这一步通过乘以 modelMatrix 模型矩阵将原本模型以自身本地坐标定位的方式变成世界坐标里适当的位置和大小,从而实现想要的场景布局;场景有了,相机位置、视角的不同看到的画面也会不同,这一步通过乘以 viewMatrix 视图矩阵实现物体基于相机的位置,前两者可以简写成 modelViewMartix,最后再通过乘以 projectionMatrix 投影矩阵变换到剪裁空间,最终变成二维屏幕上渲染出来的效果。

之前在 b 站看犹他大学的计算机图形学课程,感觉这部分课件很直观。一开始椅子、桌子等三维物体都是基于自身中心来放置,然后通过平移、缩放、旋转等模型变换构建出合适的场景世界里的布局,然后通过设置的相机看到特定的画面,最后再渲染到画面里。

  • 链接:2020年 秋 犹他大学 计算机图形学课程(中英字幕)

需要注意的是,这里的变量 modelViewMatrix、projectionMatrix 和属性 position 都是 ShaderMaterial 里内置的可以直接拿来用,已经帮我们声明好了,顶点上的 uv、normal 也可以在顶点着色器里直接使用;假如换成 RawShaderMaterial 就需要自己声明后才能用,当然目前古柳没怎么看到过直接用 RawShaderMaterial 的。其他内置变量和属性可以参考这篇文档。

  • 链接:WebGLProgram - Three.js

GLSL 语言基础

在开始讲 fragment shader 里如何给片元/像素赋颜色值之前,有必要先讲下 GLSL 语言基础。

如果你看本文时也跟着敲了代码,并且“自作聪明”地省略了 GLSL 每行语句最后的分号;,或者把 float 浮点数0.0、1.0偷懒写成了整型 0、1,那么你就会发现画面里的物体无法显示、控制台有报错,因为 GLSL 里这两点是很严格的,这也是大家不论刚入门 Shader、还是后续编写 Shader 过程中一不小心就非常容易出错的地方,务必牢记在心。

不过古柳刚测试了下,这几处数字现在写成整数也不会出错了,记得以前是会报错的,而且大家看网上其他教程大多都是浮点数的写法,所以虽然可能向量类型 vec 里支持整数了,但后续教程里古柳可能还是会延续浮点数的写法,这点需要大家注意下。

1
2
3
4
5
6
7
8
9
10
11
js复制代码const vertex = `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragment = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;

GLSL 里变量数据类型有浮点数 float、整型 int、布尔型 bool,但以古柳目前接触过的代码里,浮点数的使用远远多于后两者。

1
2
3
C复制代码float alpha = 0.5;
int num = 10;
bool flag = true;

另外还有 vec 向量类型系列,其中包含二维向量 vec2、三维向量 vec3、四维向量 vec4,可以分别看成由 (x,y)、(x,y,z)或(r,g,b)、(x,y,z,w)或(r,g,b,a) 等分量组成,并且可以像下面代码里一样直接访问或修改对应分量数值;当分量的值都一样时,可以只写一个值;向量之间或向量与浮点数之间的加减乘除四则运算,都是基于每个分量单独计算的。

另外 vec3 可以由 vec2+float 创建、vec4 可以由 vec3+float、vec2+vec2 等不同方式创建,比较灵活。

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
C复制代码vec2 a = vec2(1.0, 0.0);
// a.x=1.0 a.y=0.0
a.x = 2.0;
a.y = 0.5;

vec2 a = vec2(1.0);
// a.x=1.0 a.y=1.0

// 向量之间或向量与浮点数之间的加减乘除四则运算是基于每个分量单独计算
vec2 a = vec2(1.0) + vec2(0.1, 0.2);
// a.x=1.1 a.y=1.2
vec2 a = vec2(1.0) * 2.0;
// a.x=2.0 a.y=2.0

vec3 b = vec3(1.0, 2.0, 0.0);
// b.x=1.0 b.y=2.0 b.z=0.0
// b.r=1.0 b.g=2.0 b.b=0.0
b.z = 3.0;

vec4 c = vec4(1.0, 1.0, 1.0, 1.0);
// c.x=c.y=c.z=c.w=1.0
// c.r=c.g=c.b=c.a=1.0
c.r = 0.9
c.g = 0.0;
c.b = 0.0;

vec3 d = vec3(vec2(0.5), 1.0);
vec4 e = vec4(vec3(1.0), 1.0);
vec4 e = vec4(vec2(0.3), vec2(0.1));

vec3(1.0) 既可以看成是 x=y=z=1.0 处的坐标点,也可以看成是从原点指向该点的向量,还可以看成是 r=g=b=1.0 也就是白色颜色,注意在 GLSL 里 rgb 范围都是 0.0-1.0,而非一般的 0-255。黑色为(0.0,0.0,0.0)、白色为(1.0,1.0,1.0)、红色为(1.0,0.0,0.0)……如果设置的颜色值小于0.0会被截取到0.0,大于1.0会被截取到1.0。

另外 GLSL 里的函数需要以返回值的类型开头,没有返回值就如同 main 主函数一样以 void 开头,函数里有参数的需要写明数据类型;参数个数、参数类型、返回值类型等不同的同名函数,可以同时存在以实现类似功能,这就是函数重载,比如这里虚构的函数,支持传入不同格式的 rgb 颜色数值以得到 hsl 颜色格式。

1
2
3
4
5
6
7
scss复制代码vec3 rgb2hsl(float r, float g, float b){
return vec3(1.0, 1.0, 1.0);
}

vec3 rgb2hsl(vec3 color){
return vec3(1.0, 1.0, 1.0);
}

Fragment Shader

有了前面这些 GLSL 基础,回过头看 fragment shader 里的代码,想来大家应该没什么疑问了吧。

设置 gl_FragColor 为 vec4(1.0, 0.0, 0.0, 1.0) 就是设置红色,这段代码会通过 GPU 对所有 plane 上的像素执行,于是呈现出来的就是红色的平面。

1
2
3
4
5
js复制代码const fragment = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;

下篇内容更精彩

原本想继续以 uv 为切入点,讲讲将其直接用于 fragment shader 里作为颜色,并结合 GLSL 的一些内置函数以生成重复的条纹、重复的圆圈等效果,但一些必要介绍一写起来篇幅就冗长起来了,还没来及讲的内容只能留到下一篇继续,这里先给大家看看早就生成的一些文章配图尝尝鲜(当然朋友圈和群里早就放出去了,也欢迎新朋友来围观与交流)。

另外,本文只讲了些古柳觉得目前大家需要知道的 GLSL 基础,有意不写成大而全的形式,只涉及当前够用的知识,后面等讲到其他例子时再一步步带出来更多内容。当然如文章开头所言,讲解地可能仍不清楚,如果你想了解更多 GLSL 基础内容,可以看看这篇文章,古柳觉得还不错。

  • 链接:Three.js 进阶之旅:Shader着色器入门

照例

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Flutter 310 之 Flutter Web 路线已

发表于 2023-05-12

随着 Flutter 3.10 发布,Flutter Web 也引来了它最具有「里程碑」意义的更新,这里的「里程碑」不是说这次 Flutter Web 有多么重大的更新,而是 Flutter 官方对于 Web 终于有了明确的定位和方向。

提升

首先我们简单聊提升,这不是本篇的重点,只是顺带。

本次提升主要在于两个大点:Element 嵌入支持和 fragment shaders 支持 。

首先是 Element 嵌入,Flutter 3.10 开始,现在可以将 Flutter Web嵌入到网页的任何 HTML 元素中,并带有 flutter.js 引擎和 hostElement 初始化参数。

简单来说就是不需要 iframe 了,如下代码所示,只需要通过 initializeEngine 的 hostElement 参数就可以指定嵌入的元素,灵活度支持得到了提高。

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
xml复制代码<html>
 <head>
   <!-- ... -->
   <script src="flutter.js" defer></script>
 </head>
 <body>

   <!-- Ensure your flutter target is present on the page... -->
   <div id="flutter_host">Loading...</div>

   <script>
     window.addEventListener("load", function (ev) {
       _flutter.loader.loadEntrypoint({
         onEntrypointLoaded: async function(engineInitializer) {
           let appRunner = await engineInitializer.initializeEngine({
             // Pass a reference to "div#flutter_host" into the Flutter engine.
             hostElement: document.querySelector("#flutter_host")
          });
           await appRunner.runApp();
        }
      });
    });
   </script>
 </body>
</html>

PS :如果你的项目是在 Flutter 2.10 或更早版本中创建的,要先从目录中删除 /web 文件 ,然后通过 flutter create . --platforms=web 重新创建模版。

fragment shaders 部分一般情况下大家可能并不会用到,shaders 就是以 .frag 扩展名出现的 GLSL 文件,在 Flutter 里是在 pubspec.yaml 文件下的 shaders 中声明,现在它支持 Web 了:

1
2
3
makefile复制代码flutter:
shaders:
  - shaders/myshader.frag

一般运行时会把 frag 文件加载到 FragmentProgram 对象中,通过 program 可以获取到对应的 shader,然后通过 Paint.shader 进行使用绘制, 当然 Flutter 里 shaders 文件是存在限制的,比如不支持 UBO 和 SSBO 等。

当然,这里不是讲解 shaders ,而是宣告一下,Flutter Web 支持 shaders 了。

未来

其实未来才是本篇的重点,我们知道 Flutter 在 Web 领域的支持上一直在「妥协」,Flutter Web 在整个 Flutter 体系下一直处于比较特殊的位置,因为它一直存在两种渲染方式:html 和 canvaskit。

简单说 html 就是转化为 JS + Html Element 渲染,而 canvaskit 是采用 Skia + WebAssembly 的方式,而 html 的模式让 Web 在 Flutter 中显得「格格不入」,路径依赖和维护成本也一直是 Flutter Web 的头痛问题。

面对这个困境,官方在年初的 Flutter Forword 大会上提出重新规划 Flutter Web 的未来,而随着 Flutter 3.10 的发布,官方终于对于 Web 的未来有了明确的定位:

“Flutter 是第一个围绕 CanvasKit 和 WebAssembly 等新兴 Web 技术进行架构设计的框架。”

Flutter 团队表示,Flutter Web 的定位不是设计为通用 Web 的框架,类似的 Web 框架现在有很多,比如 Angular 和 React 等在这个领域表现就很出色,而 Flutter 应该是围绕 CanvasKit 和 WebAssembly 等新技术进行架构设计的框架。

所以 Flutter Web 未来的路线会是更多 CanvasKit ,也就是 WebAssembly + Skia ,同时在这个领域 Dart 也在持续深耕:从 Dart 3 开始,对于 Web 的支持将逐步演进为 WebAssembly 的 Dart native 的定位。

什么是 WebAssembly 的 dart native ?一直以来 Flutter 对于 WebAssembly 的支持都是:使用 Wasm 来处理CanvasKit 的 runtime,而 Dart 代码会被编译为 JS,而这对于 Dart 团队来时,其实是一个「妥协」的过渡期。

而随着官方与 WebAssembly 生态系统中的多个团队的深入合作,Dart 已经开始支持直接编译为原生的 wasm 代码,一个叫 WasmGC 的垃圾收集实现被引入标准,该扩展实现目前在基于 Chromium 的浏览器和 Firefox 浏览器中在趋向稳定。

目前在基准测试中,执行速度提高了 3 倍

要将 Dart 和 Flutter 编译成 Wasm,你需要一个支持 WasmGC 的浏览器,目前 Chromium V8 和 Firefox 团队的浏览器都在进行支持,比如 Chromium 下:

通过结构和数组类型为 WebAssembly 增加了对高级语言的有效支持,以 Wasm 为 target 的语言编译器能够与主机 VM 中的垃圾收集器集成。在 Chrome 中启用该功能意味着启用类型化函数引用,它会将函数引用存储在上述结构和数组中。

现在在 Flutter master 分支下就可以提前尝试 wasm 的支持,运行 flutter build web --help 如果出现下图所示场, 说明支持 wasm 编译。

之后执行 flutter build web --wasm 就可以编译一个带有 native dart wasm 的 web 包,命令执行后,会将产物输出到 build/web_wasm 目录下。

之后你可以使用 pub 上的 dhttpd 包在 build/web_wasm目录下执行本地服务,然后在浏览器预览效果。

1
2
3
shell复制代码> cd build/web_wasm
> dhttpd
Server started on port 8080

目前需要版本 112 或更高版本的 Chromium 才能支持,同时需要启动对应的 Chrome 标识位:

  • enable-experimental-webassembly-stack-switching
  • enable-webassembly-garbage-collection

当然,目前阶段还存在一些限制,例如:

Dart Wasm 编译器利用了 JavaScript-Promise Integration (JSPI) 特性,Firefox 不支持 JSPI 提议,所以一旦 Dart 从 JSPI 迁移出来,Firefox 应启用适当的标志位才能运行。

另外还需要 JS-interop 支持,因为为了支持 Wasm,Dart 改变了它针对浏览器和 JavaScript 的 API 支持方式, 这种转变是为了防止把 dart:html 或 package:js 编译为 Wasm 的 Dart 代码,大多数特定于平台的包如 url_launcher 会使用这些库。

最后,目前 DevTools 还不支持 flutter run 去运行和调试 Wasm。

最后

很高兴能看到 Flutter 团队最终去定了 Web 的未来路线,这让 Web 的未来更加明朗,当然,正如前面所说的,Flutter 是第一个围绕 CanvasKit 和 WebAssembly 等新兴 Web 技术进行架构设计的框架。

所以 Flutter Web不是为了设计为通用 Web 的框架去 Angular 和 React 等竞争,它是让你在使用 Flutter 的时候,可以将能力很好地释放到 Web 领域,而 CanvasKit 带来的一致性更符合 Flutter Web 的定位,当然,解决加载时长问题会是任重道远的需求。

最后不得不提 WebGPU, WebGPU 作为新一代的 WebGL,可以提供在浏览器绘制 3D 的全新实现,它属于 GPU硬件(显卡)向 Web(浏览器)开放的低级 API,包括图形和计算两方面相关接口。

WebGPU 来自 W3C 制定的标准,与 WebGL 不同,WebGPU 不是基于 OpenGL ,它是一个新的全新标准,发起者是苹果,目前由 W3C GPU 与来自苹果、Mozilla、微软和谷歌一起制定开发,不同于 WebGL (OpenGL ES Web 版本),WebGPU 是基于 Vulkan、Metal 和 Direct3D 12 等,能提供更好的性能和多线程支持。

WebGPU 已经被正式集成到 Chrome 113 中,首个版本可在会支持 Vulkan 的 ChromeOS 设备、 Direct3D 12 的 Windows 设备和 macOS 的 Chrome 113 浏览器,除此之外 Linux、Android 也将在 2023 年内开始陆续发布,同步目前也初步登陆了 Firefox 和 Safari 。

提及 WebGPU 的原因在于:WebGPU + WebAssembly 是否在未来可以让 Web 也支持 Impeller 的可能?。

详细可见:cohost.org/mcc/post/14… 和 www.infoq.cn/article/qwa…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Google I/O 2023 - Flutter 310

发表于 2023-05-11

核心部分原文链接:medium.com/flutter/wha…

虽然本次 I/O 的核心 keynote 主要是 AI ,但是按照惯例依然发布了新的 Flutter 稳定版,不过并非大家猜测的 4.0,而是 3.10 ,Flutter 的版本号依然那么的出人意料。

Flutter 3.10 主要包括有对 Web、mobile、graphics、安全性等方面的相关改进,核心其实就是:

  • iOS 默认使用了 Impeller
  • 一堆新的 Material 3 控件袭来
  • iOS 性能优化,Android 顺带可有可无的更新
  • Web 可以无 iframe 嵌套到其他应用

Framework

Material 3

看起来谷歌对于 Material 3 的设计规范很上心,根据最新的 Material Design spec 规范 Flutter 也跟进了相关的修改,其中包括有新组件和组件主题和新的视觉效果等。

目前依然是由开发者可以在 MaterialApp 主题配置下,通过 useMaterial3 标志位选择是否使用 Material 3,不过从下一个稳定版本开始,useMaterial3 默认会被调整为 true 。

对于 Material 3 ,可以通过 flutter.github.io/samples/mat… 上的相关 Demo 预览。

ColorScheme.fromImageProvider

所有 M3 组件配置主题的默认颜色 ColorScheme,默认配色方案使用紫色 shades,这有区别于之前默认的蓝色。

除了可以从单一 “seed” 颜色来定制配置方案之后,通过 fromImageProvider 图像也可以创建自定义配色方案。

NavigationBar

本次还增加了一个 M3 版本的 BottomNavigationBar 控件效果,虽然 M3 使用不同的颜色、highlighting 和 elevation,但它的工作方式其实还是和以前一样。

如果需要调整 NavigationBars 的默认外观,可以使用使用 NavigationBarTheme 来覆盖修改,虽然目前你不需要将现有 App 迁移到 NavigationBars ,但是官方建议还是尽可能在新项目里使用 NavigationBars 作为导航控件。

NavigationDrawer

M3 针对 Drawer 同样提供了新的 NavigationDrawer ,它通过 NavigationDestinations 显示单选列表,也可以在该列表中包含其他控件。

同步M3下 Drawer 也更新了颜色和高度,同时对布局进行了一些小的更改。

NavigationDrawer 需要时可以滚动,如果要覆盖 NavigationDrawer 的默认外观,同样可以使用 NavigationDrawerTheme 来覆盖。

SearchBar 和 SearchAnchor

这是 Flutter 为搜索查询和提供预测效果新增的控件。

当用户在输入搜索查询时,会在 “search view” 中计算匹得到一个配响应列表,用户选择一个结果或调整匹配结果。

如果要覆盖 SearchBarTheme 的默认外观,同样可以使用 SearchAnchorTheme 来覆盖。

Secondary Tab Bar

M3 下 Flutter 现在默认提供创建第二层选项卡式内容的支持,针对二级 Tab 可以使用 TabBar.secondary。

DatePicker 和 TimePicker 更新

M3下 DatePicker 更新了控件的日历、文本字段的颜色、布局和形状等,对应 API 没有变动,但会个新增了 DatePickerTheme 用于调整控件样式。

TimePicker 和DatePicker 一样,更新了控件的常规版本和紧凑版本的颜色、布局和形状。

BottomSheet 更新

M3 下 BottomSheet 除了颜色和形状更新之外,还添加了一个可选的拖动手柄,当设置 showDragHandle为 true 时生效。

ListTile 更新

M3下 ListTile 更新了定位和间距,包括 content padding、leading 和 trailing 控件的对齐、minimum leading width, 和 vertical spacing 等,但是 API 保持不变。

TextField 更新

M3 更新了所有 TextField 对原生手势支持。

用鼠标双击或三次点击 TextField 和在触摸设备上双击或三次点击效果相同,默认情况下 TextField 和CupertinoTextField 都可以使用该功能。

TextField double click/tap 手势

  • Double click + drag:扩展字块中的选择。
  • Double tap + drag:扩展字块中的选择。

TextField triple click/tap 手势

Triple click

  • 在多行 TextField(Android/Fuchsia/iOS/macOS/Windows) 中选择点击位置的段落块。
  • 在多行 TextField (Linux) 内部时,在 click 位置选择一个行块。
  • 选择单行中的所有文本 TextField。

Triple tap

  • 在 multi-line TextField 中选择点击位置的段落块 。
  • 选择单行 TextField 中的所有文本

Triple click+拖动

  • 扩展段落块中的选择 (Android/Fuchsia/iOS/macOS/Windows)。
  • 扩展行块中的选择 (Linux)。

简单来说,就是手势和鼠标在双击和三击下,会触发不同的选择效果,并且 Linux 在三击效果下会有点差异

Flutter 支持 SLSA 级别 1

Flutter Framework 现在使用软件工件供应链级别 ( SLSA ) 级别 1 进行编译,这里面支持了许多安全功能的实现,包括:

  • 脚本化构建过程:Flutter 的构建脚本现在允许在受信任的构建平台上自动构建,建立在受保护的架构上有助于防止工件篡改,从而提高供应链安全性。
  • 带有审计日志的多方批准:Flutter 发布工作流程仅在多个工程师批准后执行,所有执行都会创建可审计的日志记录,这些更改确保没有人可以在源代码和工件生成之间引入更改。
  • 出处:Beta 和稳定版本现在使用 provenance 构建,这意味着具有预期内容的可信来源构建了框架发布工件,每个版本都会发布链接以查看和验证 SDK 存档 的出处。

这项工作还在朝着 SLSA L2 和 L3 合规性迈进,这两个级别侧重于在构建过程中和构建之后提供 artifacts 保护。

Web

改进了加载时间

3.10 减小了图标字体的文件大小,它会从 Material 和 Cupertino 中删除了未使用的字形,从而提供了更快加载。

CanvasKit 变小

基于 Chromium 的浏览器可以使用更小的自定义 CanvasKit 渠道,托管在 Google gstatic.com 上的 CanvasKit 可以进一步提高性能。

Element 嵌入

现在可以 从页面中的特定 Element 来加载 Flutter Web ,不需要 iframe,在这个版本之前 fluter web 是需要填充整个页面主体或显示在 iframe 标记内,简单说就是把 flutter web 嵌套到其他 Web 下更方便了。

具体 Demo 可见:github.com/flutter/sam…

着色器支持

Web 应用可以使用 Flutter 的 fragment shader :

1
2
3
yaml复制代码flutter:
shaders:
- shaders/myshader.frag

Engine

Impeller

在 3.7 稳定版中 iOS 提供了 Impeller 预览支持,从那时起 Impeller 就收到并解决了用户的大量反馈。

在 3.10 版本中,我们对 Impeller 进行了 250 多次提交,现在我们将 Impeller 设置为 iOS 上的默认渲染器。

默认情况下,所有使用 Flutter 3.10 为 iOS 构建的应用都使用 Impeller,这样 iOS 应用预计将会有更少的卡顿和更一致的性能。

自 3.7 版本以来,iOS 上的 Impeller 改进了内存占用,可以使用较少的渲染通道和中间渲染目标。

在较新的 iPhone 上,启用有损纹理压缩可在不影响保真度的情况下减少内存占用,这些进步也显着提高了 iPad 的性能。

比如 Wonderous 应用中的 “pull quote” 页面,这些改进是的当前页面下的内存占用量减少了近一半。

内存使用量的减少也适度降低了 GPU 和 CPU 负载,Wondrous 应用可能不会记录这些负载下降,它的框架之前已经优化的不错,但这一变化应该会延长续航能力。

Impeller 还释放了团队可以更快地交付流行功能请求的能力,例如在 iOS 上支持更广泛的 P3 色域。

社区贡献加速了我们的进步,特别是 GitHub 用 户ColdPaleLight 和 luckysmg ,他们编写了多个与 Impeller 相关的补丁,提高了保真度和性能。

虽然 Impeller 满足大多数 Flutter 应用的渲染需求,但你可以选择关闭 Impeller。如果选择退出,请考虑在 GitHub 上提交问题以告诉我们原因。

1
2
3
4
> vbnet复制代码  <key>FLTEnableImpeller</key>
> <false/>
>
>

用户可能会注意到 Skia 和 Impeller 在渲染时存在细微差别,这些差异可能是错误,所以请务必在 Github 上提出问题,在未来的版本中,我们将删除适用于 iOS 的旧版 Skia 渲染器以减小 Flutter 的大小。

另外,Impeller 的 Vulkan 后端然在支持当中,Android 上的 Impeller 仍在积极开发中,但尚未准备好进行预览。

要了解 Impeller 进展,请查看 github.com/orgs/flutte…

Performance

3.10 版本涵盖了除 Impeller 之外还有更多性能改进和修复。

消除卡顿

这里要感谢 luckysmg, 他们发现可以缩短从 Metal 驱动获取下一个可绘制层的时间,而方式就是需要将 FlutterViews 背景颜色设置为非零值。

此更改消除了最近 iOS 120Hz 显示器上的低帧率问题,在某些情况下它会使帧速率增加三倍,这帮助我们解决了六个 GitHub issue。

这一变化具有意义重大,以至于我们向后移植了一个修补程序到 3.7 版本中。

在 3.7 稳定版中,我们将本地图像的加载从平台线程转移到 Dart 线程,以避免延迟来自平台线程的 vsync 事件。但是用户注意到 Dart 线程上的这项额外工作也导致了一些卡顿。

在 3.10 中,我们将本地图像的打开和解码从 Dart 线程移至后台线程,这个更改消除了具有大量本地图像的屏幕上潜在的长时间停顿,同时避免了延迟 vsync 事件,在我们的本地测试和自动化基准测试中,这个更改将多个同步图像的加载时间缩短了一半。

我们继续在 Flutter 新的内部 DisplayList 结构之上构建优化,在 3.10 中,我们添加了 R-Tree based culling 机制。

这种机制在我们的渲染器中更早地移除了绘制操作的处理。例如 优化加速了输出在屏幕外失败的自定义painter。

我们的 microbenchmarks 显示 DisplayList 处理时间最多减少了 50%,具有裁剪自定义绘画的 App 可能会看到不同效果的改进,改进的程度取决于隐藏绘制操作的复杂性和数量。

减少 iOS 启动延迟

之前应用中标识符查找的低效策略增加了应用启动延迟,这个启动延迟的增长与应用的大小成正比。

而在 3.10 中,我们修复了 bundle identifier lookup,这将大型应用的启动延迟减少了 100 毫秒或大约 30–50%。

缩小尺寸

Flutter 使用 SkParagraph 作为文本、布局和渲染的默认库,之前我们包括了一个标志以支持回退到遗留 libtxt和 minikin 。

由于我们对 SkParagraph 有充分的信心,我们在 3.10 中删除了 libtxt 和 minikin 以及它们的标志,这将 Flutter 的压缩大小减少了 30KB。

看起来信心十足了。

稳定性

在 3.0 版本中,我们在渲染管道后期启用了一项 Android 功能,该功能使用高级 GPU 驱动,当只有一个“dirty” 区域发生变化时,这些驱动功能会重新绘制较少的屏幕内容。

我们之前已经将它添加到早期的优化中以达到类似的效果,尽管我们的基准测试结果不错,但还是出现了两个问题:

  • 首先,改进最多的基准可能不代表实际用例。
  • 其次,事实证明很难找到支持此 GPU 驱动功能的设备和 Android 版本集

鉴于有限的进步和支持,我们在 Android 上禁用了部分重绘功能。

而使用 Skia 后端时,该功能在 iOS 上依然保持启用状态,我们希望在未来的版本中可以通过 Impeller 启用它。

API 改进

APNG解码器

Flutter 3.10 解决了一个我们最受关注的问题,它增加了 APNG 解码图像的能力,现在可以使用 Flutter 现有的图片加载 API 来加载 APNG 图片。

图片加载 API 改进

3.10 添加了一个新方法 instantiateImageCodecWithSize,该方法满足以下三个条件的用例支持:

  • 加载时宽高比未知
  • 边界框约束
  • 原始纵横比约束

Mobile

iOS

无线调试

现在可以在无线的情况下运行和热重新加载的 Flutter iOS 应用。

在 Xcode 中成功无线配对 iOS 设备后,就可以使用 flutter run 将应用部署到该设备,如果遇到问题,请在 Window > Devices 和 Simulators > Devices下验证网络图标是否出现在设备旁边。

要了解更多信息,可以查阅 docs.flutter.dev/get-started…

宽色域图像支持

iOS 上的 Flutter 应用现在可以支持宽色域图像的精确渲染,要使用宽色域支持,应用必须使用 Impeller 并在 Info.plist 文件添加 FLTEnableWideGamut 标志。

拼写检查支持

SpellCheckConfiguration() 控件现在默认支持 Apple 在 iOS 上的拼写检查服务,可以使用 spellCheckConfiguration 中的参数对其进行设置 CupertinoTextField 。

自适应复选框和单选

3.10 将 CupertinoCheckBox 和 CupertinoRadio 添加到库中 Cupertino ,他们创建符合 Apple 样式的复选框和单选按钮组件。

Material 复选框和单选控件添加了 .adaptive 构造函数,在 iOS 和 macOS 上,这些构造函数使用相应的 Cupertino 控件,在其他平台上使用 Material 控件。

优化 Cupertino 动画、过渡和颜色

Flutter 3.10 改进了一些动画、过渡和颜色以匹配 SwiftUI,这些改进包括:

  • 更新 CupertinoPageRoute
  • 添加标题放大动画 CupertinoSliverNavigationBar
  • 添加几种新的 iOS 系统颜色 CupertinoColors

PlatformView 性能

当 PlatformViews 出现在屏幕上时,Flutter会限制 iOS 上的刷新率以减少卡顿,当应用显示动画或可滚动时,用户可能会在应用出现 PlatformViews 时注意到这一点。

macOS 和 iOS 可以在插件中使用共享代码

Flutter 现在支持插件 pubspec.yaml 文件中的 sharedDarwinSource ,这个 key 表示 Flutter 应该共享 iOS 和 macOS 代码。

1
2
3
4
5
6
7
8
yaml复制代码ios: 
pluginClass: PathProviderPlugin
dartPluginClass: PathProviderFoundation
sharedDarwinSource: true
macos:
pluginClass: PathProviderPlugin
dartPluginClass: PathProviderFoundation
sharedDarwinSource: true

应用扩展的新资源

我们为 Flutter 开发人员添加了使用 iOS 应用扩展文档,这些扩展包括实时活动、主屏幕控件和共享扩展。

为了简化创建主屏幕控件和共享数据,我们向 path_provider 和 homescreen_widget 插件添加了新方法。

具体可见:docs.flutter.dev/development…

跨平台设计的新资源

该文档现在包括针对特定 UI 组件的跨平台设计注意事项,要了解有关这些 UI 组件的更多信息,请查看Flutter UX GitHub 存储库中的讨论: github.com/flutter/uxr…

具体可见:docs.flutter.dev/resources/p…

Android

Android CameraX 支持

Camera X 是一个 Jetpack 库,可简化向 Android 应用添加丰富的相机功能。

该功能适用于多种 Android 相机硬件,在 3.10 中,我们为 Flutter Camera 插件添加了对 CameraX 的初步支持,此支持涵盖以下用例:

  • 图像捕捉
  • 视频录制
  • 显示实时相机预览
1
2
3
yaml复制代码Dependencies: 
camera: ^0.10.4 # 最新相机版本
camera_android_camerax: ^0.5.0

开发者工具

我们继续改进了 DevTools,这是一套用于 Dart 和 Flutter 的性能和调试工具,一些亮点包括:

  • DevTools UI 使用 Material 3,这让外观现代化又增强了可访问性。
  • DevTools 控制台支持在调试模式下评估正在运行的应用,在 3.10 之前,只能在暂停应用时执行此操作。
  • 嵌入式 Perfetto 跟踪查看器取代了以前的时间线跟踪查看器。

Perfetto 可以处理更大的数据集,并且比传统的跟踪查看器表现得更好,例如:

  • 允许固定感兴趣的线程
  • 单击并拖动以从多个帧中选择多个时间轴事件
  • 使用 SQL 查询从时间轴事件中提取特定数据

弃用和重大更改

弃用的 API

3.10 中的重大更改包括在 v3.7 发布后过期的弃用 API。

要查看所有受影响的 API 以及其他上下文和迁移指南,请查看之前版本的弃用指南。

Dart Fix 可以修复其中的许多问题,包括在 IDE 中快速修复和使用dart fix命令批量应用。

Android Studio Flamingo 升级

将 Android Studio 升级到 Flamingo 后,你可能会在尝试 flutter run 或 flutter build Flutter Android 应用时看到错误。

发生此错误是因为 Android Studio Flamingo 将其捆绑的 Java SDK 从 11 更新到 17,使用 Java 17 时,之前的 7.3 Gradle 版本无法运行。

我们更新来了 flutter analyze --suggestions 以验证是否由于 Java SDK 和 Gradle 版本之间的不兼容而发生此错误。

要了解修复此错误的不同方法,请查看我们的迁移指南:docs.flutter.dev/go/android-…

Window singleton 弃用

该版本弃用了 Window singleton,依赖它的应用和库需要开始迁移。

当你的应用在未来版本的 Flutter 中做支持时,这会可以为你的应用提前做好多窗口准备支持。

PS:还可以关注下本次 I/O 基于 Flutter 发布的新小游戏:I/O FLIP 小游戏

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】44期 神器啊,从未想过 VS

发表于 2023-05-10

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇,走过路过的小伙伴可以点击关注下这个目前是掘金关注数最多的专栏。

欢迎点此扫码加我微信 ruochuan02 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式,具体的看这里
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

任务发布时间

2023年5月4日 - 2023年5月21日(两周)。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。

语雀本期任务说明链接

语雀有树形菜单,更方便查看,所以也放下语雀的这一期链接

学习任务

  1. 如何开发一个 VSCode 插件
  2. 学会开发开源项目的工作流是怎样的
  3. 学会调试 VSCode 插件
  4. 学会 open in github button vscode 插件的原理是什么
  5. 学会 open in github vscode 插件原理
  • 参考我的文章
  • 神器啊,从未想过 VSCode 还能这样直接打开仓库URL,原理揭秘~

参考文章

  • 神器啊,从未想过 VSCode 还能这样直接打开仓库URL,原理揭秘~
  • 看文章,看源码,交流讨论,写笔记发布在掘金。再在掘金这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Google IO 2023 第一弹, I/O FLIP 小

发表于 2023-05-10

Google IO 2023 马上就要开始了,而和去年一样,今年同样发布了预热小游戏 I/O FLIP , 该款小游戏是使用 AI 设计的纸牌游戏,采用了 Flutter 和 Firebase 开发构建,整体体验上像是 Demo 版本的炉石传说。

Google 本次针对 I/O FLIP 提供 AI 支持,主要是希望展示 AI 技术的可能性,底层主要使用 DreamBooth 和 Muse 预先生成了大量自定义角色图像,并使用 PaLM API 生成了它们的描述。

游戏的 UI 和后端使用 Flutter 和 Dart 构建,利用 Firebase 托管和共享数据,Cloud Run 用于帮助扩展。

游戏整体流程也比较简单,进入游戏后:

  • 选择一个角色类别和一个力量体系来随机生成 12 张卡片
  • 从包中选择三张卡片你觉得最合适的卡片开始游戏
  • 加入一场比赛并赢得三局两胜(田忌赛马),赢了可以进入下一场
  • 卡片对战是以攻击力为主,并会有属性克制效果,属性克制时会降低对方 10 点攻击力
  • 连续赢得多场会有连胜纪录,从而有机会进入排行榜

Flutter 和 Dart 支持

I/O FLIP 的逻辑、UI 和音频支持使用的是 Flutter Casual Games Toolkit 工具包,并且利用 go_router 实现页面路由。

另外由于 FLIP 是一个 web 游戏,所以它需要适配不同屏幕的大小和调整,所以它需要支持 adaptive-responsive。

FLIP 中的大部分逻辑是基于游戏卡片进行设计,每张卡片都包含四个 Google 吉祥物之一的形象:Dash、Sparky、Dino 和 Android 以及它们对应的描述。

两者的设定主要都来自用户在游戏开始时选择的职业和力量,卡片还随机分配了一种元素力量(空气、水、火、金属、地球)和一个 10-100 之间的数字,数字表示卡片的强度,元素力量可以在比赛中相互影响,如下图所示:

如果卡片的元素受到克制,那场上它将受到 10 分的削弱。

每场比赛都是三局两胜制,类似田忌赛马,玩家赢了可以继续用他们选择的手牌继续比赛,而输的一方可以分享他们的手牌或选择新手牌再次开始。

在这里,即将发布的= Flutter 和 Dart 全新功能帮助我们快速实现上述支持:例如 :

  • Dart 3 的 record 可以帮助我们基于卡片元素来渲染 frame
  • fragment shaders 的官方支持,可以帮助我们创建一些卡片上的特殊 3D 效果。

Muse 和 PaLM API 上的 Dreambooth

I/O FLIP 中的每张卡片都是使用了 AI 生成的图像和描述。

图像是使用 Google Research 开创的两种技术预先生成的:

  • Muse,一种来自 Imagen 模型系列的文本到图像 AI 模型
  • DreamBooth,一种运行在 Muse 之上的技术,允许个性化文本 to 图像的模型,可以使用自己的一小组图像进行训练,生成特定主题的新图像

卡片描述在 MakerSuite 中制作原型,并使用访问 Google 大型语言模型的 PaLM API 进行预先生成。

根据玩家在游戏开始时选择的力量,可能会得到一张卡片描述,为图像提供背景信息,包括角色的特殊力量,例如:

“Dash the Wizard lives in a castle with his pet dragon. He loves to cast spells and make people laugh.”

Flutter 用于使用 GameCard 控件,根据名称、描述、图像和能力组合卡片,创建卡片后,对应的元素的边框就会被赋予,如果你运气够好,设计中还会有特殊的着色器效果。

Firebase: 游戏托管、共享和实时游戏

Cloud Storage for Firebase 存储生成玩家牌组的所有图像、描述、元素和数字。

Firestore 跟踪“最高连胜”的排行榜,并使用 firedart 添加新的获胜者。

在 Flutter 应用程序直接访问 Firestore 的所有情况下,我们都使用App Check来确保只允许我们自己编写的代码,并且我们使用 Firebase 安全规则来确保代码只能访问数据并进行更改授权给。

Dart Frog

Dart Frog 会将游戏结果(例如每一轮的获胜者)保留在后端,并且在 Flutter 前端和 Firestore 后端之间共享此代码,这不仅有助于防止作弊,还可以让我们用同一种语言来编写后端和前端代码。

通过将 I/O FLIP 的 Dart Frog 服务器部署到 Cloud Run,该游戏还可以利用autoscaling 等功能,使其能够同时处理许多玩家。

最后,Dart Frog 还支持在社区里下载或分享卡片,在一轮游戏结束时,玩家可以选择下载或分享到 Twitter 或 Facebook。

当用户点击分享按钮时,Dart Frog 会生成一个预填充的帖子,其中包含要分享的文本和一个指向带有相应手牌或卡片的网页的链接,以及一个供访问者玩游戏的按键。

个人总结

可以看到,本次 Google IO 的核心还是 AI ,整个游戏的设计都是基于 AI 实现,可以看到 AI 在设计和内容领域的能力支持上越来越成熟。

同时本次游戏的整体体验也比去年更加优秀,主要是 Flutter 在游戏领域的支持也得到进一步提升,同时也印证了去年的说法, Flutter 团队在游戏支持上的进一步投入,让 Flutter Forward 上的承诺在一步一步慢慢成为现实。

最后,该项目也是开源的,开源地址: github.com/flutter/io_…

PS:本篇大部分内容来自: developers.googleblog.com/2023/05/how… ,个人做了一些补充调整。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 13JVM 线程内存设计

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:

  • 全网最硬核 TLAB 解析
  • 全网最硬核 Java 随机数解析
  • 全网最硬核 Java 新内存模型解析

本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house

本篇全篇目录(以及涉及的 JVM 参数):

  1. 从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)
    1. Native Memory Tracking 的开启
    2. Native Memory Tracking 的使用(涉及 JVM 参数:NativeMemoryTracking)
    3. Native Memory Tracking 的 summary 信息每部分含义
    4. Native Memory Tracking 的 summary 信息的持续监控
    5. 为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed
  2. JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)
    1. Linux 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    8. 堆大小的动态伸缩(涉及 JVM 参数:MinHeapFreeRatio,MaxHeapFreeRatio,MinHeapDeltaBytes)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)
    9. 适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
    10. JVM 参数 AlwaysPreTouch 的作用
    11. JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
    12. JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
  4. JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
    1. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 线程内存设计(重点研究 Java 线程)

Java 19 中 Loom 终于 Preview 了,虚拟线程(VirtualThread)是我期待已久的特性,但是这里我们说的线程内存,并不是这种 虚拟线程,还是老的线程。其实新的虚拟线程,在线程内存结构上并没有啥变化,只是存储位置的变化,实际的负载线程(CarrierThread)还是老的线程。

同时,JVM 线程占用的内存分为两个部分:分别是线程栈占用内存,以及线程本身数据结构占用的内存。

5.1. JVM 中有哪几种线程,对应线程栈相关的参数是什么

JVM 中有如下几类线程:

  • VM 线程:全局唯一的线程,负责执行 VM Operations,例如 JVM 的初始化,其中的操作大部分需要在安全点执行,即 Stop the world 的时候执行。所有的操作请参考:https://github.com/openjdk/jdk/blob/jdk-21+17/src/hotspot/share/runtime/vmOperation.hpp
  • GC 线程:负责做 GC 操作的线程
  • Java 线程:包括 Java 应用线程(java.lang.Thread),以及 CodeCacheSweeper 线程, JVMTI 的 Agent 与 Service 线程其实也是 JAva 线程。
  • 编译器线程: JIT 编译器的线程,有 C1 和 C2 线程(xi稿滚去shi)
  • 定时任务时钟线程:全局唯一的线程,即 Watcher 线程,负责计时并执行定时任务,目前 JVM 中包括的定时任务可以通过查看继承 PeriodicTask 的类看到,其中两个比较重要的任务是:
    • StatSamplerTask:定时更新采集的 JVM Performance Data(PerfData)数据, 包括 GC、类加载、运行采集等等数据,这个任务多久执行一次是通过 -XX:PerfDataSamplingInterval 参数控制的,默认为 50 毫秒(参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/share/runtime/globals.hpp)。这些数据一般通过 jstat 读取,或者通过 JMX 读取。
    • VMOperationTimeoutTask:由于 VM 线程是单线程,执行 VM Operations,单个任务执行不能太久,否则会阻塞其他 VM Operations。所以每次执行 VM Operations 的时候,这个定时任务都会检查当前执行了多久,如果超过 -XX:AbortVMOnVMOperationTimeoutDelay 就会报警。AbortVMOnVMOperationTimeoutDelay 默认是 1000ms(参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/share/runtime/globals.hpp)。
  • 异步日志线程:全局唯一的线程, Java 17 引入的异步 JVM 日志特性,防止因为 JVM 日志输出阻塞影响全局安全点事件导致全局暂停过长,或者 JVM 日志输出导致线程阻塞,负责异步写日志,通过 -Xlog:async 启用 JVM 异步日志,通过 -XX:AsyncLogBufferSize= 指定异步日志缓冲大小,这个大小默认是 2097152 即 2MB
  • JFR 采样线程:全局唯一的线程,负责采集 JFR 中的两种采样事件,一个是 jdk.ExecutionSample,另一个是 jdk.NativeMethodSample,都是采样当前正在 RUNNING 的线程,如果线程在执行 Java 代码,就属于 jdk.ExecutionSample,如果执行 native 方法,就属于 jdk.NativeMethodSample。

相关的参数有:

  • ThreadStackSize:每个 Java 线程的栈大小,这个参数通过 -Xss 也可以指定,各种平台的默认值为:
    • linux 平台,x86 CPU,默认为 1024 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
    • linux 平台,aarch CPU,默认为 2048 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
    • windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
  • VMThreadStackSize:VM 线程,GC 线程,定时任务时钟线程,异步日志线程,JFR 采样线程的栈大小,各种平台的默认值为:
    • linux 平台,x86 CPU,默认为 1024 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
    • linux 平台,aarch CPU,默认为 2048 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
    • windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
  • CompilerThreadStackSize:编译器线程的栈大小,各种平台的默认值为:
    • linux 平台,x86 CPU,默认为 1024 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_x86/globals_linux_x86.hpp
    • linux 平台,aarch CPU,默认为 2048 KB,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/linux_aarch64/globals_linux_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_x86/globals_windows_x86.hpp
    • windows 平台,aarch CPU,默认为 0,即使用操作系统默认值(64 位虚拟机为 1024KB),参考:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/os_cpu/windows_aarch64/globals_windows_aarch64.hpp
  • StackYellowPages:后面会提到并分析的黄色区域的页大小
    • linux 平台,x86 CPU,默认为 2 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 2 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 3 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 2 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • StackRedPages:后面会提到并分析的红色区域的页大小
    • linux 平台,x86 CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • StackShadowPages:后面会提到并分析的影子区域的页大小
    • linux 平台,x86 CPU,默认为 20 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 20 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 8 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 20 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • StackReservedPages:后面会提到并分析的保留区域的页大小
    • linux 平台,x86 CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • linux 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
    • windows 平台,x86 CPU,默认为 0 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/globals_x86.hpp
    • windows 平台,aarch CPU,默认为 1 页,参考:https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/aarch64/globals_aarch64.hpp
  • RestrictReservedStack:默认为 true,与保留区域相关,保留区域会保护临界区代码(例如 ReentrantLock)在抛出 StackOverflow 之前先把临界区代码执行完再结束,防止临界区代码执行到一半就抛出 StackOverflow 导致状态不一致导致这个锁之后再也用不了了。标记临界区代码的注解是 @jdk.internal.vm.annotation.ReservedStackAccess。在这个配置为 true 的时候,这个注解默认只能 jdk 内部代码使用,如果你有类似于 ReentrantLock 这种带有临界区的代码也想保护起来,可以设置 -XX:-RestrictReservedStack,关闭对于 @jdk.internal.vm.annotation.ReservedStackAccess 的限制,这样你就可以在自己的代码中使用这个注解了。

我们接下来重点分析 Java 线程栈。

5.2. Java 线程栈内存的结构

熟悉编译器的人应该知道激活记录(Activation Record)这个概念,它是一种数据结构,其中包含支持一次函数调用所需的所有信息。它包含该函数的所有局部变量,以及指向另一个激活记录的引用(或指针),其实你可以简单理解为,每多一次方法调用就多一个激活记录。而线程栈帧(Stack Frame),就是激活记录的实际实现。每在代码中多一次方法调用就多一个栈帧,但是这个说法并不严谨,比如,JIT 可能会内联一些方法,可能会跳过某些方法的调用等等。Java 线程的栈帧有哪几种呢,其实根据 Java 线程执行的方法有 Java 方法以及原生方法(Native)就能推测出有两种:

  • Java 虚拟机栈帧(Java Virtual Machine Stack Frame):用于保存 Java 方法的执行状态,包括局部变量表、操作数栈、方法出口等信息。
  • Native 方法栈帧(Native Method Stack Frame):用于保存 Native 方法的执行状态,包括局部变量表、操作数栈、方法出口等信息。

在最早的时候,Linux 还没有线程的概念,Java 自己做了一种叫做 Green Thread 的东西即用户态线程(与现在的虚拟线程设计差异很大,不是一个概念了),但是调度有诸多问题,所以在 Linux 有线程之后,Java 也舍弃了 Green Thread。Java 线程其实底层就是通过操作系统线程实现,是一一对应的关系。不过现在,虚拟线程也快要 release 了,但是这个并不是今天的重点。并且,在最早的时候,Java 线程栈与 Native 线程栈也是分开的,虽然可能都是一个线程执行的。后来,发现这样做对于 JIT 优化,以及线程栈大小限制,以及实现高效的 StackOverflow 检查都不利,所以就把 Java 线程栈与 Native 线程栈合并了,这样就只有一个线程栈了。

JVM 中对于线程栈可以使用的空间是限制死的。对于 Java 线程来说,这个限制是由 -Xss 或者 -XX:ThreadStackSize 来控制的,-Xss 或者 -XX:ThreadStackSize 基本等价, 一般来说,-Xss 或者 -XX:ThreadStackSize 是用来设置每个线程的栈大小的,但是更严谨的说法是,它是设置每个线程栈最大使用的内存大小,并且实际可用的大小由于保护页的存在还要小于这个值,并且设置这个值不能小于保护页需要的大小,否则没有意义。根据前面对于 JVM 其他区域的分析我们可以推测出,对于每个线程,都会先 Reserve 出 -Xss 或者 -XX:ThreadStackSize 大小的内存,之后随着线程占用内存升高而不断 Commit 内存。

同时我们还知道,对于一段 Java 代码,分为编译器执行,C1 执行,C2 执行三种情况,因此,一个 Java 线程的栈内存结构可能如下图所示:

这个图片我们展示了一个比较极端的情况,线程先解释执行方法 1,之后调用并解释执行方法 2,然后调用一个可能比较热点的方法 3,方法 3 已经被 C1 优化编译,这里执行的是编译后的代码,之后调用可能更热点的方法 4,方法 4 已经被 C2 优化编译,这里执行的是编译后的代码。最后方法 4 还需要调用一个 native 方法 5。

5.3. Java 线程如何抛出的 StackOverflowError

JVM 线程内存还有一些特殊的内存区域,结构如下:

  • 保护区域(Guard Zone),保护区的内存没有映射物理内存,访问的话会像前面第三章提到的 NullPointerException 优化方式类似,即抛出 SIGSEGV 被 JVM 捕获,再抛出 StackOverflowError。保护区包括以下三种:
    • 黄色区域(Yellow Zone):大小由前面提到的 -XX:StackYellowPages 参数决定。如果栈扩展到了黄色区域,则发生 SIGSEGV,并且信号处理程序抛出 StackOverflowError 并继续执行当前线程。同时,这时候黄色页面会被映射分配内存,以提供一些额外的栈空间给异常抛出的代码使用,抛出异常结束后,黄色页面会重新去掉映射,变成保护区。
    • 红色区域(Red Zone):大小由前面提到的 -XX:StackRedPages 参数决定。正常的代码只会可能到黄色区域,只有 JVM 出一些 bug 的时候会到红色区域,这个相当于最后一层保证。保留这个区域是为了出这种 bug 的时候,能有空间可以将错误信息写入 hs_err_pid.log 文件用于定位。
    • 保留区域(Reserved Zone):大小由前面提到的 -XX:StackReservedPages 参数决定。在 Java 9 引入(JEP 270: Reserved Stack Areas for Critical Sections)(洗稿狗的区域是细狗区),主要是为了解决 JDK 内部的临界区代码(例如ReentrantLock)导致 StackOverflowError 的时候保证内部数据结构不会处于不一致的状态导致锁无法释放或者被获取。如果没有这个区域,在 ReentrantLock.lock() 方法内部调用某个内部方法的时候可能会进入黄色区域,导致 StackOverflowError,这时候可能 ReentrantLock 内部的一些数据可能已经修改,抛出异常导致这些数据无法回滚让锁处于当初设计的时候没有设计的不一致状态。为了避免这个情况,引入保留区域。在执行临界区方法的时候(被 @jdk.internal.vm.annotation.ReservedStackAccess 注解修饰的方法),如果进入保留区域,那么保留区域会被映射内存,用于执行完临界区方法,执行完临界区方法之后,再抛出 StackOverflowError,并解除保留区域的映射。另外,前面我们提到过,@jdk.internal.vm.annotation.ReservedStackAccess 这个注解默认只能 jdk 内部代码使用,如果你有类似于 ReentrantLock 这种带有临界区的代码也想保护起来,可以设置 -XX:-RestrictReservedStack,关闭对于 @jdk.internal.vm.annotation.ReservedStackAccess 的限制,这样你就可以在自己的代码中使用这个注解了。
  • 影子区域(Shadow Zone):这个区域的大小由前面提到的 -XX:StackShadowPages 参数决定。影子区域只是抽象概念,跟在当前栈占用的顶部栈帧后面,随着顶部栈帧变化而变化。这个区域用于保证 Native 调用不会导致 StackOverflowError。在后面的分析我们会看到,每次调用方法前需要估算方法栈帧的占用大小,但是对于 Native 调用我们无法估算,所以我们就假设 Native 大小最大不会超过影子区域大小,在发生 Native 调用前,会查看当前栈帧位置加上影子区域大小是否会达到保留区域,如果达到了保留区域,那么会抛出 StackOverflowError,如果没有达到保留区域,那么会继续执行。这里我们可以看出,JVM 假设 Native 调用占用空间不会超过影子区域大小,JDK 中自带的 native 调用也确实是这样。如果你自己实现了 Native 方法并且会占用大量栈内存,那么你需要调整 StackShadowPages。

我们看下源码中如何体现的这些区域,参考源码:https://github.com/openjdk/jdk/blob/jdk-21%2B18/src/hotspot/share/runtime/stackOverflow.hpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cpp复制代码size_t StackOverflow::_stack_red_zone_size = 0;
size_t StackOverflow::_stack_yellow_zone_size = 0;
size_t StackOverflow::_stack_reserved_zone_size = 0;
size_t StackOverflow::_stack_shadow_zone_size = 0;

void StackOverflow::initialize_stack_zone_sizes() {
//读取虚拟机页大小,第二章我们分析过
size_t page_size = os::vm_page_size();
//目前各个平台最小页大小基本都是 4K
size_t unit = 4*K;
//使用 StackRedPages 乘以 4K 然后对虚拟机页大小进行对齐作为红色区域大小
assert(_stack_red_zone_size == 0, "This should be called only once.");
_stack_red_zone_size = align_up(StackRedPages * unit, page_size);
//使用 StackYellowPages 乘以 4K 然后对虚拟机页大小进行对齐作为黄色区域大小
assert(_stack_yellow_zone_size == 0, "This should be called only once.");
_stack_yellow_zone_size = align_up(StackYellowPages * unit, page_size);
//使用 StackReservedPages 乘以 4K 然后对虚拟机页大小进行对齐作为保留区域大小
assert(_stack_reserved_zone_size == 0, "This should be called only once.");
_stack_reserved_zone_size = align_up(StackReservedPages * unit, page_size);
//使用 StackShadowPages 乘以 4K 然后对虚拟机页大小进行对齐作为保留区域大小
assert(_stack_shadow_zone_size == 0, "This should be called only once.");
_stack_shadow_zone_size = align_up(StackShadowPages * unit, page_size);
}

5.3.1. 解释执行与编译执行时候的判断(x86为例)

我们继续针对 Java 线程进行讨论。在前面我们已经知道,Java 线程栈的大小是有限制的,如果线程栈使用的内存超过了限制,那么就会抛出 StackOverflowError。但是,JVM 如何知道什么时候该抛出呢?

首先,对于解释执行,一般没有任何优化,就是在调用方法前检查。不同的环境下的实现会有些差别,我们以 x86 cpu 为例:

https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp

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
cpp复制代码void TemplateInterpreterGenerator::generate_stack_overflow_check(void) {
//计算栈帧的一些元数据存储的消耗
const int entry_size = frame::interpreter_frame_monitor_size() * wordSize;
const int overhead_size =
-(frame::interpreter_frame_initial_sp_offset * wordSize) + entry_size;
//读取虚拟机页大小,第二章我们分析过
const int page_size = os::vm_page_size();
//比较当前要调用的方法的元素个数,判断与除去元数据以外一页能容纳的元素个数谁大谁小
Label after_frame_check;
__ cmpl(rdx, (page_size - overhead_size) / Interpreter::stackElementSize);
__ jcc(Assembler::belowEqual, after_frame_check);
//大于的才会进行后续的判断,因为小于一页的话,绝对可以被黄色区域限制住,因为黄色区域要与页大小对齐,因此至少一页
//小于一页的栈帧不会导致跳过黄色区域,只有大于的须有后续仔细判断

Label after_frame_check_pop;
//读取线程的 stack_overflow_limit_offset
//_stack_overflow_limit = stack_end() + MAX2(stack_guard_zone_size(), stack_shadow_zone_size());
//即栈尾 加上 保护区域 或者 阴影区域 的最大值,即有效栈尾地址
//其实就是当前线程栈容量顶部减去 保护区域 或者 阴影区域 的最大值的地址,即当前线程栈只能增长到这个地址
const Address stack_limit(thread, JavaThread::stack_overflow_limit_offset());
//将前面计算的栈帧元素个数大小保存在 rax
__ mov(rax, rdx);
//将栈帧的元素个数转换为字节大小,然后加上栈帧的元数据消耗
__ shlptr(rax, Interpreter::logStackElementSize);
__ addptr(rax, overhead_size);

//加上前面计算的有效栈尾地址
__ addptr(rax, stack_limit);

//与当前栈顶地址比较,如果当前栈顶地址大于 rax 当前值,证明没有溢出
__ cmpptr(rsp, rax);
__ jcc(Assembler::above, after_frame_check_pop);

//否则抛出 StackOverflowError 异常
__ jump(ExternalAddress(StubRoutines::throw_StackOverflowError_entry()));
__ bind(after_frame_check_pop);
__ bind(after_frame_check);
}

代码的步骤大概是(plagiarism和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏! ):

  1. 首先判断要分配的栈帧大小,是否大于一页。
  2. 如果小于等于一页,不用检查,直接结束。因为如果小于一页,那么栈帧的元素个数一定小于一页,栈增长不会导致跳过保护区域,如果达到保护区域就会触发 SIGSEGV 抛出 StackOverflowError。因为每个保护区域如前面源代码所示,都是对虚拟机页大小进行对齐的,因此至少一页。
  3. 如果大于一页,则需要检查。检查当前已经使用的空间,加上栈帧占用的空间,加上保护区域与阴影区域的最大值,占用空间是否大于栈空间限制。如果大于,则抛出 StackOverflowError 异常。为什么是保护区域与阴影区域的最大值?阴影区域其实是我们假设的最大帧大小,最后至少要有这么多空间才一定不会导致溢出栈顶污染其他内存(当然,如之前所述,如果你自己实现一个 Native 调用并且栈帧很大,则需要修改阴影区域大小)。如果本身保护区域就比阴影区域大,那么就用保护区域的大小,就也能保证这一点。

可以看出,编译执行,虽然做了一定的优化,但是还是很复杂,就算大部分栈帧应该都小于一页,但是刚开始的判断指令还是有不小的消耗。我们看看 JIT 编译后的代码,还是以 x86 cpu 为例:

https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/share/asm/assembler.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cpp复制代码void AbstractAssembler::generate_stack_overflow_check(int frame_size_in_bytes) {
//读取虚拟机页大小,第二章我们分析过
const int page_size = os::vm_page_size();
//读取影子区大小
int bang_end = (int)StackOverflow::stack_shadow_zone_size();

//如果栈帧大小大于一页,那么需要将 bang_end 加上栈帧大小,之后检查每一页是否处于保护区域
const int bang_end_safe = bang_end;
if (frame_size_in_bytes > page_size) {
bang_end += frame_size_in_bytes;
}

//检查每一页是否处于保护区域
int bang_offset = bang_end_safe;
while (bang_offset <= bang_end) {
bang_stack_with_offset(bang_offset);
bang_offset += page_size;
}
}

https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/cpu/x86/macroAssembler_x86.cpp

1
2
3
4
5
cpp复制代码//检查是否处于保留区域,其实就是将 rsp - offset 的地址上的值写入 rax 上,
//如果 rsp - offset 保护区域,那么就会触发 SIGSEGV
void bang_stack_with_offset(int offset) {
movl(Address(rsp, (-offset)), rax);
}

编译后执行的指令就简单多了:

  1. 如果栈帧大小小于一页:只需要考虑 Native 调用是否会导致 StackOverflow 即可。检查当前占用位置加上影子区域大小,之后判断是否会进入保护区域即可,不用考虑当前方法栈帧占用大小,因为肯定小于一页。验证是否进入保护区域也和之前讨论过的 NullPointeException 的处理是类似的,就是将 rsp - offset 的地址上的值写入 rax 上,如果 rsp - offset 处于保护区域,那么就会触发 SIGSEGV。
  2. 如果栈帧大小大于一页:那么需要将当前占用位置,加上栈帧大小,加上影子区域大小,之后从当前栈帧按页检查,是否处于保护区域。因为大于一页的话,直接验证最后的位置可能会溢出到其他东西占用的内存(比如其他线程占用的内存)。

5.3.2. 一个 Java 线程 Xss 最小能指定多大

这个和平台是相关的,我们以 linux x86 为例子,假设没有大页分配,一页就是 4K,一个线程至少要保留如下的空间:

  • 保护区域:
    • 黄色区域:默认 2 页
    • 红色区域:默认 1 页
    • 保留区域:默认 1 页
  • 影子区域:默认 20 页

这些加在一起是 24 页,也就是 96K。

同时,在 JVM 代码中也限制了,除了这些空间,每种线程的最小大小:

https://github.com/openjdk/jdk/blob/jdk-21%2B19/src/hotspot/os_cpu/linux_x86/os_linux_x86.cpp

1
2
3
cpp复制代码size_t os::_compiler_thread_min_stack_allowed = 48 * K;
size_t os::_java_thread_min_stack_allowed = 40 * K;
size_t os::_vm_internal_thread_min_stack_allowed = 64 * K;

所以,对于 Java 线程,至少需要 40 + 96 = 136K 的空间。我们试一下:

1
2
3
4
vbnet复制代码bash-4.2$ java -Xss1k
The Java thread stack size specified is too small. Specify at least 136k
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 12元空间各种监控手段

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:

  • 全网最硬核 TLAB 解析
  • 全网最硬核 Java 随机数解析
  • 全网最硬核 Java 新内存模型解析

本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house

本篇全篇目录(以及涉及的 JVM 参数):

  1. 从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)
    1. Native Memory Tracking 的开启
    2. Native Memory Tracking 的使用(涉及 JVM 参数:NativeMemoryTracking)
    3. Native Memory Tracking 的 summary 信息每部分含义
    4. Native Memory Tracking 的 summary 信息的持续监控
    5. 为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed
  2. JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)
    1. Linux 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    8. 堆大小的动态伸缩(涉及 JVM 参数:MinHeapFreeRatio,MaxHeapFreeRatio,MinHeapDeltaBytes)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)
    9. 适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
    10. JVM 参数 AlwaysPreTouch 的作用
    11. JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
    12. JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
  4. JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
    1. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 元空间设计

4.6. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解

4.6.1. jcmd <pid> VM.metaspace 元空间说明

通过 jcmd <pid> VM.metaspace 命令可以查看对应 JVM 进程的元空间当前的详细使用情况,返回内容是:

1.元空间从 MetaChunk 角度的使用统计信息

1
2
3
4
yaml复制代码Total Usage - 1383 loaders, 33006 classes (1361 shared):
Non-Class: 7964 chunks, 150.83 MB capacity, 150.77 MB (>99%) committed, 150.21 MB (>99%) used, 562.77 KB ( <1%) free, 6.65 KB ( <1%) waste , deallocated: 869 blocks with 249.52 KB
Class: 2546 chunks, 21.00 MB capacity, 20.93 MB (>99%) committed, 20.21 MB ( 96%) used, 741.42 KB ( 3%) free, 216 bytes ( <1%) waste , deallocated: 1057 blocks with 264.88 KB
Both: 10510 chunks, 171.83 MB capacity, 171.70 MB (>99%) committed, 170.42 MB (>99%) used, 1.27 MB ( <1%) free, 6.86 KB ( <1%) waste , deallocated: 1926 blocks with 514.41 KB

意思是:

  1. 一共 1383 个类加载器,加载了 33006 个类(其中 1361 个是共享类)。
  2. capacity 是指 MetaChunk 的总容量大小(Reserved 内存);committed 是指这些 MetaChunk 中 committed 的内存大小,也就是实际占用系统物理内存是这么大(虽然可能会有点细微差异,参考本篇文章的第二章);used 是指这些 MetaChunk 实际使用的大小,肯定比 committed 的要小;free 是指剩余的大小;committed = used + free + waste;deallocated 是指回收到 FreeBlocks 的大小,属于 free 的一部分,另一部分就是 MetaChunk 中 committed 但是还没使用的部分;waste 是指浪费的大小(前面我们提到了什么造成的浪费,主要是搜索 FreeBlocks 的空间使用的时候,可能正好剩下 1 字节,就不放回了继续使用了)洗稿的狗也遇到不少
  3. 数据元空间使用情况:一共使用了 7964 个 MetaChunk,这些 MetaChunk 相关总容量大小是 150.83 MB,目前 commit 了 150.77 MB,使用了 150.21 MB,剩余 562.77 KB 可以使用,6.65 KB 的空间被浪费了。FreeBlocks 目前回收了 869 块内存,一共 249.52 KB。
  4. 类元空间使用情况:一共使用了 2546 个 MetaChunk,总容量大小是 21.00 MB,目前 commit 了 20.93 MB,使用了 20.21 MB,剩余 741.42 KB 可以使用,216 bytes 的空间被浪费了。FreeBlocks 目前回收了 1057 块内存,一共 264.88 KB。
  5. 总的元空间使用情况(类元空间 + 数据元空间的):一共使用了 10510 个 MetaChunk,总容量大小是 171.83 MB,目前 commit 了 171.70 MB,使用了 170.42 MB,剩余 1.27 MB 可以使用,6.86 KB 的空间被浪费了。FreeBlocks 目前回收了 1926 块内存,一共 514.41 KB。

前面的是从 MetaChunk 的角度去查看,另一个角度是从 VirtualSpaceList 去查看,接下来的信息就是:

1
2
3
4
kotlin复制代码Virtual space:
Non-class space: 152.00 MB reserved, 150.81 MB (>99%) committed, 19 nodes.
Class space: 1.00 GB reserved, 20.94 MB ( 2%) committed, 1 nodes.
Both: 1.15 GB reserved, 171.75 MB ( 15%) committed.

意思是:

  1. 数据元空间的 VirtualSpaceList:总共 Reserve 了 152.00 MB,目前 Commit 了 150.81 MB,一共有 19 个 VirtualSpaceNode。这个与 MetaChunk 的统计信息是有差异的,VirtualSpaceList 的统计信息更体现元空间实际占用的,从 MetaChunk 角度统计的时候,将每个 MetaChunk 统计信息相加,会有精度损失。
  2. 类元空间的 VirtualSpaceList:总共 Reserve 了 1.00 GB,目前 Commit 了 20.94 MB,一共有 1 个 VirtualSpaceNode。
  3. 总的元空间的 VirtualSpaceList:总共 Reserve 了 1.15 GB,目前 Commit 了 171.75 MB。不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。

接下来是每个 ChunkManager 的 FreeChunkListVector 的统计信息:

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
yaml复制代码Chunk freelists:
Non-Class:

4m: (none)
2m: (none)
1m: 2, capacity=2.00 MB, committed=0 bytes ( 0%)
512k: (none)
256k: (none)
128k: 2, capacity=256.00 KB, committed=0 bytes ( 0%)
64k: (none)
32k: 2, capacity=64.00 KB, committed=0 bytes ( 0%)
16k: (none)
8k: 2, capacity=16.00 KB, committed=0 bytes ( 0%)
4k: 2, capacity=8.00 KB, committed=0 bytes ( 0%)
2k: (none)
1k: 2, capacity=2.00 KB, committed=0 bytes ( 0%)
Total word size: 2.34 MB, committed: 0 bytes ( 0%)

Class:

4m: (none)
2m: 1, capacity=2.00 MB, committed=0 bytes ( 0%)
1m: 1, capacity=1.00 MB, committed=0 bytes ( 0%)
512k: (none)
256k: (none)
128k: (none)
64k: (none)
32k: (none)
16k: (none)
8k: (none)
4k: 1, capacity=4.00 KB, committed=0 bytes ( 0%)
2k: (none)
1k: (none)
Total word size: 3.00 MB, committed: 0 bytes ( 0%)

Both:

4m: (none)
2m: 1, capacity=2.00 MB, committed=0 bytes ( 0%)
1m: 3, capacity=3.00 MB, committed=0 bytes ( 0%)
512k: (none)
256k: (none)
128k: 2, capacity=256.00 KB, committed=0 bytes ( 0%)
64k: (none)
32k: 2, capacity=64.00 KB, committed=0 bytes ( 0%)
16k: (none)
8k: 2, capacity=16.00 KB, committed=0 bytes ( 0%)
4k: 3, capacity=12.00 KB, committed=0 bytes ( 0%)
2k: (none)
1k: 2, capacity=2.00 KB, committed=0 bytes ( 0%)
Total word size: 5.34 MB, committed: 0 bytes ( 0%)

以上的信息可能用图片更直接一些:

接下来是关于回收利用的从 MetaChunk 的角度去查看一些统计信息:

1
2
3
4
5
6
7
8
erlang复制代码Waste (unused committed space):(percentages refer to total committed size 171.75 MB):
Waste in chunks in use: 6.86 KB ( <1%)
Free in chunks in use: 1.27 MB ( <1%)
In free chunks: 0 bytes ( 0%)
Deallocated from chunks in use: 514.41 KB ( <1%) (1926 blocks)
-total-: 1.78 MB ( 1%)

chunk header pool: 10520 items, 748.30 KB.

包含的信息是:

  1. 当前被使用的 MetaChunk(即存在于每个类加载器对应的 MetaspaceArena 中的 MetaChunk)中有 6.86 KB 的空间被浪费了。当前被使用的 MetaChunk(即存在于每个类加载器对应的 MetaspaceArena 中的 MetaChunk)中剩余 1.27 MB 可以使用。在 FreeChunkListVector 中没有浪费的空间,其实从前面的 FreeChunkListVector 的详细信息就能看出来。
  2. FreeBlocks 目前回收了 1926 块内存,一共 514.41 KB。FreeBlocks 里面有 1926 个 FreeBlock,一共 514.41 KB。
  3. ChunkHeaderPool 目前有 10520 个 ChunkHeader,一共占用 748.30 KB。

然后是一些统计信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
yaml复制代码Internal statistics:

num_allocs_failed_limit: 24.
num_arena_births: 2768.
num_arena_deaths: 2.
num_vsnodes_births: 20.
num_vsnodes_deaths: 0.
num_space_committed: 2746.
num_space_uncommitted: 0.
num_chunks_returned_to_freelist: 28.
num_chunks_taken_from_freelist: 10515.
num_chunk_merges: 9.
num_chunk_splits: 6610.
num_chunks_enlarged: 4139.
num_purges: 2.
num_inconsistent_stats: 0.

包含的信息是:

  1. num_allocs_failed_limit:元空间普通分批内存失败的次数(前文分析过详细流程),后面也有对应的 JFR 事件会分析。
  2. num_arena_births:MetaspaceArena 的创建次数。
  3. num_arena_deaths:MetaspaceArena 的销毁次数。发生于对应的类加载器被回收之后。
  4. num_vsnodes_births:VirtualSpaceNode 的创建次数。(根据前面的 VirtualSpaceList 的统计信息可以知道是 19 + 1 = 20)
  5. num_vsnodes_deaths:VirtualSpaceNode 的销毁次数。
  6. num_space_committed:Commit 内存的次数。
  7. num_space_uncommitted:Uncommit 内存的次数。
  8. num_chunks_returned_to_freelist:MetaChunk 被回收到 FreeChunkListVector 的次数。
  9. num_chunks_taken_from_freelist:从 FreeChunkListVector 中获取 MetaChunk 进行分配的次数。
  10. num_chunk_merges:MetaChunk 合并的次数。
  11. num_chunk_splits:MetaChunk 拆分的次数。
  12. num_chunks_enlarged:MetaChunk 扩容的次数。
  13. num_purges:MetaspaceArena 的清理次数。一般等于销毁次数。
  14. num_inconsistent_stats:不一致的统计次数。这个一般不用关心,主要是为了调试用的。

最后是一些参数信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
yaml复制代码Settings:
MaxMetaspaceSize: unlimited
CompressedClassSpaceSize: 1.00 GB
Initial GC threshold: 40.00 MB
Current GC threshold: 210.12 MB
CDS: on
MetaspaceReclaimPolicy: balanced
- commit_granule_bytes: 65536.
- commit_granule_words: 8192.
- virtual_space_node_default_size: 1048576.
- enlarge_chunks_in_place: 1.
- new_chunks_are_fully_committed: 0.
- uncommit_free_chunks: 1.
- use_allocation_guard: 0.
- handle_deallocations: 1.
  1. MaxMetaspaceSize:元空间最大值。默认是无限制的。这里我们也没限制。
  2. CompressedClassSpaceSize:压缩类空间大小。默认是 1 GB。这里我们也没指定,所以是默认的。
  3. Initial GC threshold:初始的元空间 GC 阈值。默认是 40 MB。这里我们也没指定,所以是默认的。
  4. Current GC threshold:当前的元空间 GC 阈值。前面我们分析过这个阈值改变的机制。
  5. CDS:是否开启了 CDS。默认开启。这个我们不用太关心,主要和 CDS 特性相关(JEP 310: Application Class-Data Sharing 和 JEP 350: Dynamic CDS Archives),在以后的文章会详细分析。
  6. 元空间 MetaspaceReclaimPolicy 为 balanced
  7. commit 粒度(commit_granule_bytes)为 65536 字节,转化单位为字之后,是 8192 字(一 word 为 8 字节)。虚拟内存空间节点内存大小(virtual_space_node_default_size)为 1048576 字,转化单位为字之后,是 64 MB。当前 MetaChunk 不足以分配的时候,是否尝试扩容当前 MetaChunk(enlarge_chunks_in_place)为是,新分配的 MetaChunk 是否一次性全部 commit(new_chunks_are_fully_committed)为否,是否在 MetaChunk 释放的时候 uncommit(uncommit_free_chunks)为是。以上配置都在前文分析过。最后两个配置都是 debug 配置,正式版里面都是无法修改的,我们也不用太关心这两个配置的效果,并且 handle_deallocations 已经在 Java 18 中移除了(https://github.com/openjdk/jdk/commit/157e1d5073e221dab084422389f68eea53974f4c)

4.6.2. 元空间相关 JVM 日志

我们通过启动参数 -Xlog:metaspace*=debug::utctime,level,tags,查看元空间相关 JVM 日志。

首先,初始化 JVM 元空间的时候,会输出元空间基本参数:

1
2
3
4
5
6
7
8
9
ini复制代码[2023-04-11T09:07:31.994+0000][info][metaspace] Initialized with strategy: balanced reclaim.
[2023-04-11T09:07:31.994+0000][info][metaspace] - commit_granule_bytes: 65536.
[2023-04-11T09:07:31.994+0000][info][metaspace] - commit_granule_words: 8192.
[2023-04-11T09:07:31.994+0000][info][metaspace] - virtual_space_node_default_size: 1048576.
[2023-04-11T09:07:31.994+0000][info][metaspace] - enlarge_chunks_in_place: 1.
[2023-04-11T09:07:31.994+0000][info][metaspace] - new_chunks_are_fully_committed: 0.
[2023-04-11T09:07:31.994+0000][info][metaspace] - uncommit_free_chunks: 1.
[2023-04-11T09:07:31.994+0000][info][metaspace] - use_allocation_guard: 0.
[2023-04-11T09:07:31.994+0000][info][metaspace] - handle_deallocations: 1.

以上这几行日志的意思是:元空间 MetaspaceReclaimPolicy 为 balanced,commit 粒度(commit_granule_bytes)为 65536 字节,转化单位为字之后,是 8192 字(一 word 为 8 字节)。虚拟内存空间节点内存大小(virtual_space_node_default_size)为 1048576 字,转化单位为字之后,是 64 MB。当前 MetaChunk 不足以分配的时候,是否尝试扩容当前 MetaChunk(enlarge_chunks_in_place)为是,新分配的 MetaChunk 是否一次性全部 commit(new_chunks_are_fully_committed)为否,是否在 MetaChunk 释放的时候 uncommit(uncommit_free_chunks)为是。以上配置都在前文分析过。最后两个配置都是 debug 配置,正式版里面都是无法修改的,我们也不用太关心这两个配置的效果,并且 handle_deallocations 已经在 Java 18 中移除了(https://github.com/openjdk/jdk/commit/157e1d5073e221dab084422389f68eea53974f4c)

接下来,初始化元空间的内存空间:

1
2
3
4
5
6
7
less复制代码[2023-04-11T09:07:32.411+0000][info ][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bde000-0x0000000800bde000), size 12443648, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.[2023-04-11T09:07:32.411+0000][info ][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[2023-04-11T09:07:32.411+0000][info ][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
[2023-04-11T09:07:32.417+0000][debug][metaspace ] Arena @0x0000ffff807a1cc0 (non-class sm): : born.
[2023-04-11T09:07:32.417+0000][debug][metaspace ] Arena @0x0000ffff807a1dd0 (class sm): : born.
[2023-04-11T09:07:32.417+0000][debug][metaspace ] CLMS @0x0000ffff807a1c80 : born (nonclass arena: 0x0000ffff807a1cc0, class arena: 0x0000ffff807a1dd0.
[2023-04-11T09:07:32.411+0000][debug][metaspace ] VsListNode @0x0000ffff80784ab0 base 0x0000000800c00000 : born (word_size 134217728).
[2023-04-11T09:07:32.417+0000][debug][metaspace ] VsListNode @0x0000ffff807a27b0 base 0x0000ffff52800000 : born (word_size 1048576).

这几行日志的意思是:

  1. CDS 元数据映射到内存的地址范围是 [0x0000000800000000-0x0000000800bde000-0x0000000800bde000),大小为 12443648 字节,共享基地址为 0x0000000800000000,ArchiveRelocationMode 为关闭。这些信息我们不用太关心,主要和 CDS 特性相关(JEP 310: Application Class-Data Sharing 和 JEP 350: Dynamic CDS Archives),在以后的文章会详细分析。
  2. 我们这里是默认配置,所以压缩类空间是开启的,初始化压缩类空间,映射到内存的地址范围是 [0x0000000800c00000-0x0000000840c00000),Reserved 内存大小为 1073741824 字节(1GB),默认压缩类空间最大大小就是 1GB。加载到压缩类空间的类的基地址为 0x0000000800000000(),偏移量为 0,范围为 0x100000000,这个前面也简单分析过。
  3. Bootstrap ClassLoader 创建了两个 MetaspaceArena,分别是前文分析的类元空间的 MetaspaceArena 和数据元空间的 MetaspaceArena,放入对应的 ClassLoadMetaSpace 中。不要偷取他人的劳动成果,也不要浪费自己的时间和精力,让我们一起做一个有良知的写作者。
  4. 初始化类元空间的还有数据元空间的 VirtualSpaceList,并分别创建并放入各自的第一个 VirtualSpaceNode

接下来开始加载类,从元空间申请内存进行分配:

1
2
3
4
5
ini复制代码[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): requested chunk: pref_level: lv12, max_level: lv12, min committed size: 0.
[2023-04-11T09:07:32.411+0000][debug][metaspace] VsListNode @0x0000ffff80784ab0 base 0x0000000800c00000 : new root chunk @0x0000ffff807867f0, f, base 0x0000000800c00000, level lv00.
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): allocated new root chunk.
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): splitting chunk @0x0000ffff807867f0, f, base 0x0000000800c00000, level lv00 to lv12.
[2023-04-11T09:07:32.411+0000][debug][metaspace] ChkMgr @0x0000ffff807863d0 (class-space): handing out chunk @0x0000ffff807867f0, u, base 0x0000000800c00000, level lv12.

这几行日志的意思分别是:

  1. 加载类需要从元空间申请内存,这是第一次申请,所以各个数据结构都是空的,所以需要申请新的 MetaChunk,优先考虑的与最大的 ChunkLevel 都是 12,对应 1KB。本次申请发生在 ChunkManager @0x0000ffff807863d0
  2. 申请新的 RootMetaChunk,基址 0x0000000800c00000
  3. 将新的 RootMetaChunk 按照之前的算法拆分到 ChunkLevel 为 12,结果是 MetaChunk @0x0000ffff807867f0,将拆出来的其他 MetaChunk 放入 ChunkManager @0x0000ffff807863d0 的 FreeListVector 中

4.6.3. 元空间 JFR 事件详解

4.6.3.1. jdk.MetaspaceSummary 元空间定时统计事件

元空间定时统计事件 jdk.MetaspaceSummary,包括以下属性:

  • 事件开始时间:其实就是事件发生时间
  • GC Identifier:全局 GC 的 id 标识
  • When:事件发生的时机,包括 Before GC 和 After GC 两种,分别是 GC 前和 GC 后的统计数据,可以根据 GC Identifier 对比 GC 前后的数据,看看 GC 之后元空间的使用情况.plagiarism和洗稿是恶意抄袭他人劳动成果的行为,是对劳动价值的漠视和践踏!
  • GC Threshold:GC 阈值,即前面提的 _capacity_until_GC
  • Class:Reserved:类元空间 Reserved 的内存空间大小
  • Class:Committed:类元空间 Committed 的内存空间大小
  • Class:Used:类元空间实际保存数据使用的内存空间大小(前面的机制分析中我们会看到,Committed 的空间会比实际使用的大,主要因为类加载器回收,以及可能 MetaChunk 分配的时候 commit 所有内存)
  • Data:Reserved:数据元空间 Reserved 的内存空间大小
  • Data:Committed:数据元空间 Committed 的内存空间大小
  • Data:Used:数据元空间实际保存数据使用的内存空间大小
  • Total:Reserved:整个元空间 Reserved 的内存空间大小(其实就是类元空间 + 数据元空间)
  • Total:Committed:整个元空间 Committed 的内存空间大小(其实就是类元空间 + 数据元空间)
  • Total:Used:整个元空间实际保存数据使用的内存空间大小(其实就是类元空间 + 数据元空间)

4.6.3.2. jdk.MetaspaceAllocationFailure 元空间分配失败事件

前面提到过,如果普通分配失败,那么会触发 jdk.MetaspaceAllocationFailure 这个 JFR 事件,大家可以监控这个事件,去调整元空间大小减少由于元空间不足触发的 GC,这个事件包括以下属性:

  • 事件开始时间:其实就是事件发生时间
  • 类加载器:触发 OOM 的类加载器
  • Hidden Class Loader:是否是隐藏类加载器
  • Metadata Type:元数据类型,分为属于类元空间的以及属于数据元空间的两种类型,分别是:Class 和 Metadata
  • Metaspace Object Type:元空间对象类型,包括 Class、ConstantPool、Symbol、Method、Klass、Module、Package、Other
  • Size:本次分配的大小

这个事件也会采集堆栈信息,用来定位分配失败的源头是哪些类的加载导致的。

4.6.3.3. jdk.MetaspaceOOM 元空间 OOM 事件

前面提到过,当元空间 OOM 的时候,就会产生这个事件,这个事件包括以下属性(和 jdk.MetaspaceAllocationFailure 事件一样):

  • 事件开始时间:其实就是事件发生时间
  • 类加载器:触发 OOM 的类加载器
  • Hidden Class Loader:是否是隐藏类加载器
  • Metadata Type:元数据类型,分为属于类元空间的以及属于数据元空间的两种类型,分别是:Class 和 Metadata
  • Metaspace Object Type:元空间对象类型,包括 Class、ConstantPool、Symbol、Method、Klass、Module、Package、Other
  • Size:本次分配的大小

与 jdk.MetaspaceAllocationFailure 事件一样,也会采集堆栈信息,用来定位 OOM 的原因。

4.6.3.4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件

前面我们说过,元空间的 GC 阈值(_capacity_until_GC)是动态调整的,这个事件就是用来记录元空间 GC 阈值变化的。这个事件包括以下属性:

  • 事件开始时间:其实就是事件发生时间
  • New Value:新的 GC 阈值
  • Old Value:旧的 GC 阈值
  • Updater:哪个机制触发的 GC 阈值修改,我们之前讨论过 _capacity_until_GC 有两个场景会修改:
    • 分配过程中,达到 GC 阈值,触发 GC,但是处于 GCLocker 处于锁定禁止 GC,就尝试增大 _capacity_until_GC 进行分配。对应的 Updater 是 expand_and_allocate
    • 每次 GC 之后,触发重新计算 _capacity_until_GC,如果有更新,就会生成这个事件,对应的 Updater 是 compute_new_size

4.6.3.5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件

这个事件在 Java 16 引入 JEP 387: Elastic Metaspace 弹性元空间的设计之后,里面的统计数据就都是 0 了,还没有实现,参考:https://bugs.openjdk.org/browse/JDK-8251342,所以我们先不用关心。参考源码:https://github.com/openjdk/jdk/blob/jdk-21%2B17/src/hotspot/share/memory/metaspaceUtils.hpp

1
2
3
4
scss复制代码// (See JDK-8251342). Implement or Consolidate.
static MetaspaceChunkFreeListSummary chunk_free_list_summary(Metaspace::MetadataType mdtype) {
return MetaspaceChunkFreeListSummary(0,0,0,0,0,0,0,0);
}

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 11元空间分配与回收流程

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:

  • 全网最硬核 TLAB 解析
  • 全网最硬核 Java 随机数解析
  • 全网最硬核 Java 新内存模型解析

本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house

本篇全篇目录(以及涉及的 JVM 参数):

  1. 从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)
    1. Native Memory Tracking 的开启
    2. Native Memory Tracking 的使用(涉及 JVM 参数:NativeMemoryTracking)
    3. Native Memory Tracking 的 summary 信息每部分含义
    4. Native Memory Tracking 的 summary 信息的持续监控
    5. 为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed
  2. JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)
    1. Linux 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    8. 堆大小的动态伸缩(涉及 JVM 参数:MinHeapFreeRatio,MaxHeapFreeRatio,MinHeapDeltaBytes)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)
    9. 适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
    10. JVM 参数 AlwaysPreTouch 的作用
    11. JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
    12. JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
  4. JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
    1. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 元空间设计

4.5. 元空间大小限制与动态伸缩

前文我们没有提到,如何限制元空间的大小,其实就是限制 commit 的内存大小。元空间的限制不只是受限于我们的参数配置,并且前面我们提到了,元空间的内存回收也比较特殊,元空间的内存基本都是每个类加载器的 ClassLoaderData 申请并管理的,在类加载器被 GC 回收后,ClassLoaderData 管理的这些元空间也会被回收掉。所以,GC 是可能触发一部分元空间被回收了。所以元空间在设计的时候,还有一个动态限制 _capacity_until_GC,即触发 GC 的元空间占用大小。当要分配的空间导致元空间整体占用超过这个限制的时候,尝试触发 GC。这个动态限制也会在每次 GC 的时候动态扩大或者缩小。动态扩大以及缩小

我们先回顾下之前提过的参数配置:

  • MetaspaceSize:初始元空间大小,也是最小元空间大小。后面元空间大小伸缩的时候,不会小于这个大小。默认是 21M。
  • MaxMetaspaceSize:最大元空间大小,默认是无符号 int 最大值。
  • MinMetaspaceExpansion:每次元空间大小伸缩的时候,至少改变的大小。默认是 256K。
  • MaxMetaspaceExpansion:每次元空间大小伸缩的时候,最多改变的大小。默认是 4M。
  • MaxMetaspaceFreeRatio:最大元空间空闲比例,默认是 70,即 70%。
  • MinMetaspaceFreeRatio:最小元空间空闲比例,默认是 40,即 40%。

4.5.1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC

CommitLimiter 是一个全局单例,用来限制元空间可以 commit 的内存大小。每次分配元空间 commit 内存的时候,都会调用 CommitLimiter::possible_expansion_words 方法,这个方法会检查:

  1. 当前元空间已经 commit 的内存大小加上要分配的大小是否超过了 MaxMetaspaceSize
  2. 当前元空间已经 commit 的内存大小加上要分配的大小是否超过了 _capacity_until_GC,超过了就尝试触发 GC

尝试 GC 的核心逻辑是:

  1. 重新尝试分配
  2. 如果还是分配失败,检查 GCLocker 是否锁定禁止 GC,如果是的话,首先尝试提高 _capacity_until_GC 进行分配,分配成功直接返回,否则需要阻塞等待 GCLocker 释放
  3. 如果没有锁定,尝试触发 GC,之后回到第 1 步 (这里有个小参数 QueuedAllocationWarningCount,如果尝试触发 GC 的次数超过这个次数,就会打印一条警告日志,当然 QueuedAllocationWarningCount 默认是 0,不会打印,并且触发多次 GC 也无法满足的概率比较低)

4.5.2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC

在 JVM 初始化的时候,_capacity_until_GC 先会设置为 MaxMetaspaceSize,因为 JVM 初始化的时候会加载很多类,并且这时候要避免触发 GC。在初始化之后,将 _capacity_until_GC 设置为当前元空间占用大小与 MetaspaceSize 中比较大的那个值。同时,还会初始化一个 _shrink_factor,这个 _shrink_factor 主要是如果需要缩小元空间大小,每次缩小的比例。洗稿的狗也遇到不少

之后,在每次 GC 回收之后,需要重新计算新的 _capacity_until_GC:

image

  1. 读取 crrent_shrink_factor = _shrink_factor,统计当前元空间使用的空间 used_after_gc。
  2. 首先看是否需要扩容:
    1. 先使用 MinMetaspaceFreeRatio 最小元空间空闲比例计算 minimum_free_percentage 和 maximum_used_percentage,看是否需要扩容。
    2. 计算当前元空间至少要多大 minimum_desired_capacity:使用当前元空间使用的空间 used_after_gc 除以 maximum_used_percentage,并且保证它不小于初始元空间大小 MetaspaceSize,不大于最大元空间大小 MaxMetaspaceSize。
    3. 如果当前的 _capacity_until_GC 小于计算的当前元空间至少要多大 minimum_desired_capacity,那么就查要扩容的空间是否大于等于配置 MinMetaspaceExpansion,以及小于等于 MaxMetaspaceExpansion,只有满足才会真正扩容。
    4. 扩容其实就是增加 _capacity_until_GC
  3. 然后看是否需要缩容:
    1. 使用 MaxMetaspaceFreeRatio 最大元空间空闲比例计算 minimum_free_percentage 和 maximum_used_percentage,看是否需要缩容。
    2. 计算当前元空间至少要多大 maximum_desired_capacity:使用当前元空间使用的空间 used_after_gc 除以 maximum_used_percentage,并且保证它不小于初始元空间大小 MetaspaceSize,不大于最大元空间大小 MaxMetaspaceSize。
    3. 如果当前的 _capacity_until_GC 大于计算的当前元空间至少要多大 maximum_desired_capacity,计算 shrink_bytes = _capacity_until_GC 减去 maximum_desired_capacity。
    4. _shrink_factor 初始为 0,之后为 10%,之后每次翻 4 倍,直到 100%。扩容的大小为 shrink_bytes 乘以这个百分比
    5. 如果缩容大于等于配置 MinMetaspaceExpansion,以及小于等于 MaxMetaspaceExpansion,并且缩容后不会小于初始元空间大小 MetaspaceSize,就会缩容。
    6. 缩容其实就是减少 _capacity_until_GC

我们还可以看出,如果我们设置 MinMetaspaceFreeRatio 为 0,那么就不会扩容,如果设置 MaxMetaspaceFreeRatio 为 100,那么就不会缩容。_capacity_until_GC 就不会因为 GC 更改。

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

全网最硬核 JVM 内存解析 - 10元空间分配与回收流程

发表于 2023-04-25

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~
另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的“亲切”的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。
今天又是干货满满的一天,这是全网最硬核 JVM 解析系列第四篇,往期精彩:

  • 全网最硬核 TLAB 解析
  • 全网最硬核 Java 随机数解析
  • 全网最硬核 Java 新内存模型解析

本篇是关于 JVM 内存的详细分析。网上有很多关于 JVM 内存结构的分析以及图片,但是由于不是一手的资料亦或是人云亦云导致有很错误,造成了很多误解;并且,这里可能最容易混淆的是一边是 JVM Specification 的定义,一边是 Hotspot JVM 的实际实现,有时候人们一些部分说的是 JVM Specification,一部分说的是 Hotspot 实现,给人一种割裂感与误解。本篇主要从 Hotspot 实现出发,以 Linux x86 环境为主,紧密贴合 JVM 源码并且辅以各种 JVM 工具验证帮助大家理解 JVM 内存的结构。但是,本篇仅限于对于这些内存的用途,使用限制,相关参数的分析,有些地方可能比较深入,有些地方可能需要结合本身用这块内存涉及的 JVM 模块去说,会放在另一系列文章详细描述。最后,洗稿抄袭狗不得 house

本篇全篇目录(以及涉及的 JVM 参数):

  1. 从 Native Memory Tracking 说起(全网最硬核 JVM 内存解析 - 1.从 Native Memory Tracking 说起开始)
    1. Native Memory Tracking 的开启
    2. Native Memory Tracking 的使用(涉及 JVM 参数:NativeMemoryTracking)
    3. Native Memory Tracking 的 summary 信息每部分含义
    4. Native Memory Tracking 的 summary 信息的持续监控
    5. 为何 Native Memory Tracking 中申请的内存分为 reserved 和 committed
  2. JVM 内存申请与使用流程(全网最硬核 JVM 内存解析 - 2.JVM 内存申请与使用流程开始)
    1. Linux 下内存管理模型简述
    2. JVM commit 的内存与实际占用内存的差异
      1. JVM commit 的内存与实际占用内存的差异
    3. 大页分配 UseLargePages(全网最硬核 JVM 内存解析 - 3.大页分配 UseLargePages开始)
      1. Linux 大页分配方式 - Huge Translation Lookaside Buffer Page (hugetlbfs)
      2. Linux 大页分配方式 - Transparent Huge Pages (THP)
      3. JVM 大页分配相关参数与机制(涉及 JVM 参数:UseLargePages,UseHugeTLBFS,UseSHM,UseTransparentHugePages,LargePageSizeInBytes)
  3. Java 堆内存相关设计(全网最硬核 JVM 内存解析 - 4.Java 堆内存大小的确认开始)
    1. 通用初始化与扩展流程
    2. 直接指定三个指标的方式(涉及 JVM 参数:MaxHeapSize,MinHeapSize,InitialHeapSize,Xmx,Xms)
    3. 不手动指定三个指标的情况下,这三个指标(MinHeapSize,MaxHeapSize,InitialHeapSize)是如何计算的
    4. 压缩对象指针相关机制(涉及 JVM 参数:UseCompressedOops)(全网最硬核 JVM 内存解析 - 5.压缩对象指针相关机制开始)
      1. 压缩对象指针存在的意义(涉及 JVM 参数:ObjectAlignmentInBytes)
      2. 压缩对象指针与压缩类指针的关系演进(涉及 JVM 参数:UseCompressedOops,UseCompressedClassPointers)
      3. 压缩对象指针的不同模式与寻址优化机制(涉及 JVM 参数:ObjectAlignmentInBytes,HeapBaseMinAddress)
    5. 为何预留第 0 页,压缩对象指针 null 判断擦除的实现(涉及 JVM 参数:HeapBaseMinAddress)
    6. 结合压缩对象指针与前面提到的堆内存限制的初始化的关系(涉及 JVM 参数:HeapBaseMinAddress,ObjectAlignmentInBytes,MinHeapSize,MaxHeapSize,InitialHeapSize)
    7. 使用 jol + jhsdb + JVM 日志查看压缩对象指针与 Java 堆验证我们前面的结论
      1. 验证 32-bit 压缩指针模式
      2. 验证 Zero based 压缩指针模式
      3. 验证 Non-zero disjoint 压缩指针模式
      4. 验证 Non-zero based 压缩指针模式
    8. 堆大小的动态伸缩(涉及 JVM 参数:MinHeapFreeRatio,MaxHeapFreeRatio,MinHeapDeltaBytes)(全网最硬核 JVM 内存解析 - 6.其他 Java 堆内存相关的特殊机制开始)
    9. 适用于长期运行并且尽量将所有可用内存被堆使用的 JVM 参数 AggressiveHeap
    10. JVM 参数 AlwaysPreTouch 的作用
    11. JVM 参数 UseContainerSupport - JVM 如何感知到容器内存限制
    12. JVM 参数 SoftMaxHeapSize - 用于平滑迁移更耗内存的 GC 使用
  4. JVM 元空间设计(全网最硬核 JVM 内存解析 - 7.元空间存储的元数据开始)
    1. 什么是元数据,为什么需要元数据
    2. 什么时候用到元空间,元空间保存什么
    3. 2.1. 什么时候用到元空间,以及释放时机
    4. 2.2. 元空间保存什么
    5. 元空间的核心概念与设计(全网最硬核 JVM 内存解析 - 8.元空间的核心概念与设计开始)
      1. 元空间的整体配置以及相关参数(涉及 JVM 参数:MetaspaceSize,MaxMetaspaceSize,MinMetaspaceExpansion,MaxMetaspaceExpansion,MaxMetaspaceFreeRatio,MinMetaspaceFreeRatio,UseCompressedClassPointers,CompressedClassSpaceSize,CompressedClassSpaceBaseAddress,MetaspaceReclaimPolicy)
      2. 元空间上下文 MetaspaceContext
      3. 虚拟内存空间节点列表 VirtualSpaceList
      4. 虚拟内存空间节点 VirtualSpaceNode 与 CompressedClassSpaceSize
      5. MetaChunk
        1. ChunkHeaderPool 池化 MetaChunk 对象
        2. ChunkManager 管理空闲的 MetaChunk
      6. 类加载的入口 SystemDictionary 与保留所有 ClassLoaderData 的 ClassLoaderDataGraph
      7. 每个类加载器私有的 ClassLoaderData 以及 ClassLoaderMetaspace
      8. 管理正在使用的 MetaChunk 的 MetaspaceArena
      9. 元空间内存分配流程(全网最硬核 JVM 内存解析 - 9.元空间内存分配流程开始)
        1. 类加载器到 MetaSpaceArena 的流程
        2. 从 MetaChunkArena 普通分配 - 整体流程
        3. 从 MetaChunkArena 普通分配 - FreeBlocks 回收老的 current chunk 与用于后续分配的流程
        4. 从 MetaChunkArena 普通分配 - 尝试从 FreeBlocks 分配
        5. 从 MetaChunkArena 普通分配 - 尝试扩容 current chunk
        6. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk
        7. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 从 VirtualSpaceList 申请新的 RootMetaChunk
        8. 从 MetaChunkArena 普通分配 - 从 ChunkManager 分配新的 MetaChunk - 将 RootMetaChunk 切割成为需要的 MetaChunk
        9. MetaChunk 回收 - 不同情况下, MetaChunk 如何放入 FreeChunkListVector
      10. ClassLoaderData 回收
    6. 元空间分配与回收流程举例(全网最硬核 JVM 内存解析 - 10.元空间分配与回收流程举例开始)
      1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间
      2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间
      3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间
      4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间
      5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间
      6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间
      7. 然后类加载器 1 被 GC 回收掉
      8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间
    7. 元空间大小限制与动态伸缩(全网最硬核 JVM 内存解析 - 11.元空间分配与回收流程举例开始)
      1. CommitLimiter 的限制元空间可以 commit 的内存大小以及限制元空间占用达到多少就开始尝试 GC
      2. 每次 GC 之后,也会尝试重新计算 _capacity_until_GC
    8. jcmd VM.metaspace 元空间说明、元空间相关 JVM 日志以及元空间 JFR 事件详解(全网最硬核 JVM 内存解析 - 12.元空间各种监控手段开始)
      1. jcmd <pid> VM.metaspace 元空间说明
      2. 元空间相关 JVM 日志
      3. 元空间 JFR 事件详解
        1. jdk.MetaspaceSummary 元空间定时统计事件
        2. jdk.MetaspaceAllocationFailure 元空间分配失败事件
        3. jdk.MetaspaceOOM 元空间 OOM 事件
        4. jdk.MetaspaceGCThreshold 元空间 GC 阈值变化事件
        5. jdk.MetaspaceChunkFreeListSummary 元空间 Chunk FreeList 统计事件
  5. JVM 线程内存设计(重点研究 Java 线程)(全网最硬核 JVM 内存解析 - 13.JVM 线程内存设计开始)
    1. JVM 中有哪几种线程,对应线程栈相关的参数是什么(涉及 JVM 参数:ThreadStackSize,VMThreadStackSize,CompilerThreadStackSize,StackYellowPages,StackRedPages,StackShadowPages,StackReservedPages,RestrictReservedStack)
    2. Java 线程栈内存的结构
    3. Java 线程如何抛出的 StackOverflowError
      1. 解释执行与编译执行时候的判断(x86为例)
      2. 一个 Java 线程 Xss 最小能指定多大
  1. JVM 元空间设计

4.4. 元空间分配与回收流程举例

我们前面介绍了元空间的组成元素,但是没有将他们完整的串联起来,我们这里举一个简单的例子,将之前的所有元素串联起来。

通过前面的分析之后,我们知道元空间的主要抽象包括:

  • 全局唯一的类元空间 MetaspaceContext,它包括:
    • 一个 VirtualSpaceList,类元空间的 VirtualSpaceList 只有一个 VirtualSpaceNode
    • 一个 ChunkManager
  • 全局唯一的数据元空间 MetaspaceContext,它包括:
    • 一个 VirtualSpaceList,数据元空间的 VirtualSpaceList 才是一个真正的 VirtualSpaceNode 的链表
    • 一个 ChunkManager
  • 每个类加载器都有一个独有的 ClassLoaderData,它包含自己独有的 ClassLoaderMetaspace,ClassLoaderMetaspace 包含:
    • 一个类元空间 MetaspaceArena
    • 一个数据元空间 MetaspaceArena

假设我们全局只有一个类加载器,即类加载器 1,并且 UseCompressedClassPointers 为 true,那么我们可以假设当前元空间的初始结构为:

image

接下来我们来看看详细的例子

4.4.1. 首先类加载器 1 需要分配 1023 字节大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.搜索 FreeBlocks 查看是否有可用空间,但是这是第一次分配,肯定没有。

4.尝试从 _current_chunk 分配,但是由于是第一次分配,_current_chunk 是 NULL。

image

5.将要分配的内存(1023 字节)按照 8 字节对齐,即 1024 字节。大于等于它的最小 ChunkLevel 为 12,即 max_level = 12。假设这个类加载器是 Bootstrap ClassLoader,其实是啥无所谓,我们主要是想找一个对应的 ArenaGrowthPolicy,根据这个 ArenaGrowthPolicy,第一个要申请的 MeataChunk 大小是 256KB,对应的 ChunkLevel 为 4,preferred_level 是 max_level 与这个之间相比小的那个,即 4。我们从类元空间的 ChunkManager 申请这么大的 MetaChunk,对应的 ChunkLevel 是 4

6.首先搜索 ChunkManager 的 FreeChunkListVector,看看是否有合适的。但是这是第一次分配,肯定没有。

7.尝试从类元空间的 VirtualSpaceList 申请 RootMetaChunk 用于分配。

image

8.从类元空间的 VirtualSpaceList 的唯一一个 VirtualSpaceNode 分配 RootMetaChunk,对半切分到 ChunkLevel 为 4 的 MetaChunk,返回 leader 的 ChunkLevel 为 4 的 MetaChunk 作为 _current_chunk 用于分配。分割出来剩下的 ChunkLevel 为 1, ChunkLevel 为 2, ChunkLevel 为 3, ChunkLevel 为 4 的各一个放入 FreeChunkListVector 中

9.commit 要分配的内存大小,如果 AlwaysPreTouch 是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。

10.从 _current_chunk 分配内存,分配成功。

4.4.2. 然后类加载器 1 还需要分配 1023 字节大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.搜索 FreeBlocks 查看是否有可用空间,目前还是没有。

4.尝试从 _current_chunk 分配,将要分配的内存(1023 字节)按照 8 字节对齐,即 1024 字节,_current_chunk 空间足够。

5.commit 要分配的内存大小,如果 AlwaysPreTouch 是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。

6.从 _current_chunk 分配内存,分配成功。

4.4.3. 然后类加载器 1 需要分配 264 KB 大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.搜索 FreeBlocks 查看是否有可用空间,目前还是没有。

4.尝试从 _current_chunk 分配,将要分配的内存(264KB)按照 8 字节对齐,即 264KB,_current_chunk 空间不足,但是如果扩容一倍就足够,所以尝试扩大 _current_chunk。

image

5.查看他的兄弟 MetaChunk 是否是空闲的,当然是,从 FreeChunkListVector 移除这个 MetaChunk,将这个兄弟 MetaChunk 与 _current_chunk。_current_chunk 的大小变为原来 2 倍,_current_chunk 的 ChunkLevel 减 1 之后为 3。

6.commit 要分配的内存大小,如果 AlwaysPreTouch 是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。

7.从 _current_chunk 分配内存,分配成功。

4.4.4. 然后类加载器 1 需要分配 2 MB 大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.搜索 FreeBlocks 查看是否有可用空间,目前还是没有。

4.尝试从 _current_chunk 分配,将要分配的内存(2MB)按照 8 字节对齐,即 2MB,_current_chunk 空间不足,扩容一倍也不够,所以就不尝试扩大 _current_chunk 了。

image

5.要分配的大小是 2MB,大于等于它的最小 ChunkLevel 为 1,即 max_level = 1。根据 ArenaGrowthPolicy,下一个要申请的 MeataChunk 大小是 256KB,对应的 ChunkLevel 为 4,preferred_level 是 max_level 与这个之间相比小的那个,即 1。从 FreeChunkListVector 寻找,发现有合适的,将其作为 current_chunk 进行分配。

6.commit 要分配的内存大小,如果 AlwaysPreTouch 是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。

7.之前的 current_chunk 的剩余空间大于 2 bytes,需要回收到 FreeBlocks 中。由于大于 33 bytes,需要放入 BlockTree。

8.从 _current_chunk 分配内存,分配成功。

4.4.5. 然后类加载器 1 需要分配 128KB 大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.将要分配的内存(128KB)按照 8 字节对齐,即 128KB。搜索 FreeBlocks 查看是否有可用空间,目前 FreeBlocks 有合适的可以分配。

4.commit 要分配的内存大小,如果 AlwaysPreTouch 是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。

5.从 FreeBlocks 的 BlockTree 的节点分配内存,分配成功。为啥要打击抄袭,稿主被抄袭太多所以断更很久。

4.4.6. 新来一个类加载器 2,需要分配 1023 Bytes 大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.搜索 FreeBlocks 查看是否有可用空间,但是这是第一次分配,肯定没有。

4.尝试从 _current_chunk 分配,但是由于是第一次分配,_current_chunk 是 NULL。

image

5.将要分配的内存(1023 字节)按照 8 字节对齐,即 1024 字节。大于等于它的最小 ChunkLevel 为 12,即 max_level = 12。假设这个类加载器是 Bootstrap ClassLoader,其实是啥无所谓,我们主要是想找一个对应的 ArenaGrowthPolicy。根据 ArenaGrowthPolicy,下一个要申请的 MeataChunk 大小是 256KB,对应的 ChunkLevel 为 4,preferred_level 是 max_level 与这个之间相比小的那个,即 4。

6.首先搜索 ChunkManager 的 FreeChunkListVector,看看是否有合适的。搜索到之前放入的 ChunkLevel 为 3 的。将其取出作为 _current_chunk。

7.commit 要分配的内存大小,如果 AlwaysPreTouch 是开启的,那么就会像之前我们分析 Java 堆内存那样进行 pre touch。

8.从 _current_chunk 分配内存,分配成功。

4.4.7. 然后类加载器 1 被 GC 回收掉

image

1.将类加载器 1 消耗的所有空间放回 FreeBlocks 中。前面分配了 1024 bytes, 1024 bytes, 264KB, 2MB 还有 128KB,这次放回 BlockTree,BlockTree 之前本身还有剩余一个 118KB。整体如图所示。

2.这样一来,原来 MetaspaceArena 中 MetaChunkList 管理的 MetaChunk 的内存全都空闲了。

image

  1. 将 MetaChunkList 管理的 MetaChunk 放回全局的 ChunkManager 的 FreeChunkListVector 中。并且放回的都是有 commit 过内存的,会放在每个 ChunkLevel 对应的 MetaChunk 链表的开头。

4.4.8. 然后类加载器 2 需要分配 1 MB 大小的内存,属于类空间

image

1~2.首先,类加载器 1 从它私有的 ClassLoaderData 去分配空间,由于要分配的是类元空间的,所以会从私有的类元空间的 MetaspaceArena 去分配空间。

3.搜索 FreeBlocks 查看是否有可用空间,目前还是没有。为啥要打击抄袭,稿主被抄袭太多所以断更很久。

4.尝试从 _current_chunk 分配,空间不足。并且 _current_chunk 不是 leader,所以就不尝试扩容了。

image

5.将要分配的内存(1MB)按照 8 字节对齐,即 1MB。要分配的大小是 1MB,大于等于它的最小 ChunkLevel 为 2,即 max_level = 2。根据 ArenaGrowthPolicy,下一个要申请的 MeataChunk 大小是 256KB,对应的 ChunkLevel 为 4,preferred_level 是 max_level 与这个之间相比小的那个,即 2。从 FreeChunkListVector 寻找,发现有合适的,将其作为 current_chunk 进行分配。这个其实就是之前从类加载器 1 回收的。

6.因为是之前回收的,里面的内存都是 committed 了,所以这里就不用 commit 了。

7.之前的 current_chunk 的剩余空间大于 2 bytes,需要回收到 FreeBlocks 中。由于大于 33 bytes,需要放入 BlockTree。

8.从 _current_chunk 分配内存,分配成功。

微信搜索“干货满满张哈希”关注公众号,加作者微信,每日一刷,轻松提升技术,斩获各种offer
我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

  • 知乎:www.zhihu.com/people/zhxh…
  • B 站:space.bilibili.com/31359187

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…767778…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%