JavaScript 的即时编译(JIT)预热与冷启动性能的数学建模与优化

各位同仁,下午好。今天我们齐聚一堂,探讨一个对JavaScript应用性能至关重要,却又常被开发者忽视的深层机制:JavaScript即时编译(JIT)的预热(Warm-up)与冷启动(Cold Start)性能,并尝试对其进行数学建模与优化。作为一名编程专家,我深知理解这些底层原理,能帮助我们写出更高效、更可预测的代码。

JavaScript执行的演进与JIT的崛起

JavaScript,这门最初设计用于浏览器端脚本的语言,如今已无处不在,从前端到后端(Node.js),从桌面到移动。随着其应用场景的扩展,对性能的需求也与日俱增。

在早期,JavaScript引擎主要是纯解释器。这意味着代码逐行读取、解释并执行。这种方式启动速度快,但执行效率低下,尤其是在处理大量计算或循环时。

为了突破性能瓶颈,即时编译(Just-In-Time Compilation, JIT)技术应运而生。JIT引擎不再简单地解释代码,而是在运行时将JavaScript代码编译成机器码。这使得JavaScript的执行速度能够大幅提升,甚至接近于一些传统编译型语言。现代JavaScript引擎,如V8(Chrome和Node.js)、SpiderMonkey(Firefox)和JavaScriptCore(Safari),都采用了高度优化的JIT编译架构。

JIT的核心思想是“运行时优化”。它不仅仅是编译,更是在程序运行过程中,根据实际的执行情况(例如数据类型、代码路径的频率等)进行动态的、激进的优化。这个过程并非一蹴而就,而是分阶段进行的,这正是我们今天讨论的“预热”与“冷启动”现象的根源。

JIT编译的内部机制与分层架构

为了理解冷启动和预热,我们首先需要深入JIT编译器的内部机制。现代JIT引擎通常采用多层(tiered)编译架构,以平衡启动速度和峰值性能。V8引擎是一个极佳的例子,它通常包含多个编译器,每个编译器有不同的优化目标和策略。

以V8为例,其典型的编译流程大致如下:

  1. 解析(Parsing): JavaScript源代码首先被解析成抽象语法树(AST)。
  2. 基线编译器(Baseline Compiler): 早期V8使用的是Ignition解释器,现在又有了Sparkplug。当代码首次执行时,它会先被解释器执行,或者由一个非常快速的基线编译器(如Sparkplug)编译成机器码。这个阶段的编译速度极快,但优化程度很低。其主要目的是快速启动,让代码尽快跑起来。
    • Sparkplug: 这是一个新的JIT层,它介于Ignition解释器和更高级的优化编译器(如Maglev和Turbofan)之间。Sparkplug的目标是生成比解释器快得多但编译时间却非常短的机器码。它在代码执行次数达到一定阈值后触发。
  3. 性能分析(Profiling): 在代码执行过程中,引擎会持续收集运行时数据,例如:
    • 函数调用频率: 哪些函数被频繁调用?
    • 循环迭代次数: 哪些循环是“热”循环?
    • 变量类型信息: 特定变量在不同执行路径中通常是什么类型?
    • 对象属性访问模式: 对象属性的结构是否稳定?
      这些分析数据对于后续的激进优化至关重要。
  4. 优化编译器(Optimizing Compiler): 当引擎通过性能分析发现某个函数或代码块变得“非常热”(即被频繁执行)时,它会将这段代码发送给更强大的优化编译器(如V8中的Maglev或Turbofan)。
    • Maglev: V8的又一个新层级,它介于Sparkplug和Turbofan之间。Maglev的优化程度比Sparkplug高,生成更高效的机器码,但编译时间比Turbofan短。它适用于那些足够热但又不值得Turbofan进行深度优化的代码。
    • Turbofan: 这是V8中最强大的优化编译器,它能执行非常复杂的、激进的优化,例如:
      • 内联(Inlining): 将小型函数的代码直接插入到调用点,消除函数调用的开销。
      • 类型特化(Type Specialization): 根据运行时收集到的类型信息,生成针对特定类型的优化代码。例如,如果一个变量总是数字,编译器可以生成直接操作数字的机器指令,而无需进行类型检查。
      • 死代码消除(Dead Code Elimination): 移除永远不会执行的代码。
      • 循环优化(Loop Optimizations): 例如循环不变式外提、强度削减等。
      • 隐藏类(Hidden Classes/Shapes): 这是V8特有的一种优化,用于快速访问对象属性。当对象属性的结构稳定时,JIT可以为该结构创建一个“隐藏类”,从而避免在每次属性访问时进行昂贵的字典查找。
        优化编译器生成的机器码执行效率极高,但编译时间也相对较长。
  5. 去优化(Deoptimization): 优化编译器是基于运行时假设进行优化的。例如,它可能假设一个变量总是数字。如果某个时刻这个变量接收了一个字符串,那么之前的假设就被打破了。此时,引擎会“去优化”这段代码,将其回退到基线编译的代码或解释器执行,并重新开始收集性能分析数据。去优化是一个昂贵的操作,会显著影响性能。

这个多层编译架构可以用下表概括:

阶段 编译器/解释器 目标 优化程度 编译速度 执行速度 触发条件
冷启动 Ignition (解释器) 快速启动 极快 极慢 代码首次执行
温启动/预热 Sparkplug (基线JIT) 比解释器快,编译时间短 很快 代码执行次数达到较低阈值
预热 Maglev (中级JIT) 比Sparkplug快,编译时间中等 中等 中等 中等 代码执行次数达到中等阈值
稳态 Turbofan (优化JIT) 峰值性能,激进优化 极快 代码执行次数达到较高阈值,且稳定
异常处理 去优化 修正错误假设 回退 回退到慢速 运行时假设被打破

冷启动与预热:性能曲线的奥秘

理解了JIT的分层架构,我们就能清晰地定义冷启动和预热。

冷启动(Cold Start)

当一段JavaScript代码首次被执行时,或者在长时间不活动后再次被执行时,它会经历一个“冷启动”阶段。在这个阶段,JIT引擎没有任何关于这段代码的性能分析数据。代码通常会:

  1. 首先由解释器(如Ignition)执行。
  2. 很快地,如果足够热,会被基线编译器(如Sparkplug)编译成机器码。

冷启动的特点是启动速度快,但执行效率相对较低。这是因为引擎牺牲了深度优化来换取快速响应。如果一个应用程序的生命周期很短,或者其核心逻辑只执行一两次,那么冷启动性能将是其总性能的主要决定因素。例如,一个一次性运行的Node.js脚本,或者一个页面加载时只执行一次的初始化函数。

预热(Warm Up)

“预热”是指JIT引擎通过持续的性能分析和分层编译,逐步将代码优化到最高效率的过程。当代码被反复执行时,引擎会:

  1. 收集更多的运行时数据。
  2. 将“热”代码路径提交给更高级的优化编译器(如Maglev和Turbofan)。
  3. 生成高度优化的机器码。

预热的特点是执行效率随时间逐步提升。当代码充分预热并达到“稳态”(Steady State)时,它的执行速度将达到峰值。对于长时间运行的应用程序,如Web服务器、实时游戏或复杂的Web应用,预热性能至关重要。应用程序的大部分运行时间都将处于预热后的稳态,因此,确保核心逻辑能够快速进入稳态并保持优化状态,是性能优化的关键。

数学建模:量化JIT性能

为了更严谨地分析和优化JIT性能,我们可以尝试构建一个简化的数学模型来量化冷启动和预热过程。

我们考虑一个函数 $f(x)$,它在一个应用程序的生命周期内被调用 $M$ 次。假设每次调用都执行相同的逻辑,但由于JIT的动态特性,其执行效率会随着调用次数而变化。

定义以下变量:

  • $T_{total}$: 函数在整个生命周期内的总执行时间。
  • $T_{interpret}(k)$: 函数第 $k$ 次调用时在解释器中执行的时间。
  • $T_{compile_baseline}(k)$: 函数第 $k$ 次调用时触发基线编译所需的时间(通常发生在早期调用)。
  • $T_{execute_baseline}(k)$: 函数第 $k$ 次调用时执行基线编译代码的时间。
  • $T_{compile_optimize}(k)$: 函数第 $k$ 次调用时触发优化编译所需的时间(通常发生在较晚调用)。
  • $T_{execute_optimize}(k)$: 函数第 $k$ 次调用时执行优化编译代码的时间。
  • $T_{profiling}(k)$: 函数第 $k$ 次调用时进行性能分析的开销。
  • $T_{deopt}(k)$: 函数第 $k$ 次调用时发生去优化的开销。

为了简化模型,我们假设一个理想的场景,其中不存在去优化,且编译开销可以分摊到多次执行中。

冷启动阶段(前 $N_C$ 次调用)
在这个阶段,函数主要由解释器或基线编译器执行。
第 $k$ 次调用的执行时间 $t_k$ 可以表示为:
$tk approx T{interpret}(k) + T{compile_baseline}(k) + T{execute_baseline}(k) + T_{profiling}(k)$

由于基线编译通常只发生一次,我们可以将 $T{compile_baseline}$ 视为一个常数 $C{baseline}$,并将其分摊到前几次调用中。
假设解释器执行一次操作的成本是 $C_I$,基线编译执行一次操作的成本是 $C_B$。
函数 $f(x)$ 的操作数量为 $O_f$。
那么,在冷启动阶段,每次调用的执行时间 $t_k$ 约等于:
$t_k approx O_f cdot CI + C{baseline}$ (如果由解释器主导)
$t_k approx O_f cdot CB + C{baseline}$ (如果由基线编译主导)

预热阶段(从第 $N_C+1$ 次调用到第 $N_W$ 次调用)
在这个阶段,JIT引擎开始发现函数 $f(x)$ 是“热”的,并触发优化编译。
第 $k$ 次调用的执行时间 $t_k$ 可以表示为:
$tk approx T{compile_optimize}(k) + T{execute_optimize}(k) + T{profiling}(k)$

优化编译也是一次性开销,我们设为 $C_{optimize}$。
优化编译执行一次操作的成本是 $C_O$ ($C_O < C_B < C_I$)。
在预热过程中,随着优化编译器逐渐接管,执行时间 $t_k$ 会从 $O_f cdot C_B$ 逐渐下降到 $O_f cdot C_O$。
这个过程可以用一个指数衰减模型来近似:
$t_k = O_f cdot C_O + (O_f cdot C_B – O_f cdot C_O) cdot e^{-(k-NC)/tau} + C{optimize}$
其中 $tau$ 是预热时间常数,它表示性能提升的速度。$C_{optimize}$ 表示优化编译本身的开销。这个开销可能在某个特定调用触发,或者分摊到多个调用中。

稳态阶段(从第 $N_W+1$ 次调用到 $M$ 次调用)
函数已经被完全优化,并以最高效率执行。
$t_k approx O_f cdot C_O$

总执行时间 $T_{total}$
$T{total} = sum{k=1}^{M} t_k$

这个模型揭示了性能曲线的典型形态:

  1. 初始高延迟:前几次调用(冷启动)由于解释器或基线编译,执行时间较长。
  2. 逐步下降:随着调用次数增加,JIT进行性能分析和优化编译,执行时间逐渐减少。
  3. 稳态低延迟:达到一定调用次数后,代码被完全优化,执行时间稳定在最低水平。

编译成本与执行收益的权衡

JIT引擎的决策是基于成本效益分析的。
编译成本(JIT Cost):
$Cost{JIT} = C{baseline} + C{optimize} + sum{k=1}^{M} T{profiling}(k) + sum{k=1}^{M} T{deopt}(k)$
其中 $C
{baseline}$ 和 $C_{optimize}$ 分别是基线和优化编译的固定开销。

执行收益(Execution Benefit):
$Benefit{JIT} = sum{k=1}^{M} (T{slow,k} – T{fast,k})$
其中 $T{slow,k}$ 是第 $k$ 次调用在未优化(解释器或基线)状态下的执行时间,$T{fast,k}$ 是在优化状态下的执行时间。
JIT引擎的目标是在总执行时间上最小化 $T{total} = (sum T{slow,k}) – Benefit{JIT} + Cost{JIT}$。
实际上,JIT引擎会尝试将 $Benefit{JIT}$ 远大于 $Cost{JIT}$ 的代码路径进行优化。如果一个函数只执行一两次,那么优化它的成本可能高于收益,JIT就不会对其进行深度优化。

影响JIT性能的因素

理解了JIT的机制和模型后,我们可以探讨哪些编程实践和代码结构会影响其性能。

1. 类型一致性(Monomorphism)

这是对JIT性能影响最大的因素之一。JIT优化编译器高度依赖于类型预测。如果一个变量或函数的参数在运行时总是具有相同的类型,JIT可以生成高度特化的代码。这被称为“单态性”(Monomorphism)。

相反,如果类型经常变化(例如,一个参数有时是数字,有时是字符串),JIT将无法进行激进的类型特化,甚至可能导致去优化。这被称为“多态性”(Polymorphism)或“巨态性”(Megamorphism)。

示例代码:

// 示例1: 单态函数 (JIT友好)
function addMonomorphic(a, b) {
    return a + b;
}

// 持续使用相同类型调用
for (let i = 0; i < 100000; i++) {
    addMonomorphic(i, i * 2); // 始终是 number + number
}

// 示例2: 多态函数 (JIT不友好)
function addPolymorphic(a, b) {
    return a + b;
}

// 混合类型调用
for (let i = 0; i < 100000; i++) {
    if (i % 2 === 0) {
        addPolymorphic(i, i * 2); // number + number
    } else {
        addPolymorphic('hello', 'world'); // string + string
    }
}

// 示例3: 巨态函数 (JIT非常不友好)
function processValue(value) {
    // 假设value可以是多种不相关的类型
    if (typeof value === 'number') {
        return value * 2;
    } else if (typeof value === 'string') {
        return value.toUpperCase();
    } else if (Array.isArray(value)) {
        return value.length;
    } else {
        return null;
    }
}

for (let i = 0; i < 100000; i++) {
    if (i % 3 === 0) {
        processValue(i);
    } else if (i % 3 === 1) {
        processValue('test' + i);
    } else {
        processValue([1, 2, i]);
    }
}

在上面的例子中,addMonomorphic 函数会很快被JIT优化,因为它总是处理数字。而 addPolymorphic 由于类型混用,JIT可能需要插入类型检查,或者干脆放弃深度优化。processValue 更是典型的巨态函数,JIT很难对其进行高效优化。

2. 对象属性的结构稳定性(Hidden Classes/Shapes)

V8引擎使用“隐藏类”(Hidden Classes)来优化对象属性的访问。当一个对象被创建时,V8会为其生成一个隐藏类,描述其属性的布局。如果后续为该对象添加或删除属性,V8会创建新的隐藏类,导致旧的优化失效。

示例代码:

// 示例4: 稳定对象结构 (JIT友好)
function createPointMonomorphic(x, y) {
    const point = { x: x, y: y };
    return point;
}

for (let i = 0; i < 100000; i++) {
    const p = createPointMonomorphic(i, i * 2);
    // 始终访问相同的属性
    const sum = p.x + p.y;
}

// 示例5: 不稳定对象结构 (JIT不友好,可能导致去优化)
function createPointPolymorphic(x, y) {
    const point = { x: x };
    if (y !== undefined) {
        point.y = y; // 动态添加属性,改变隐藏类
    }
    return point;
}

for (let i = 0; i < 100000; i++) {
    let p;
    if (i % 2 === 0) {
        p = createPointPolymorphic(i); // 只有x属性
    } else {
        p = createPointPolymorphic(i, i * 2); // 有x和y属性
    }
    // 即使访问的属性是相同的,但对象的隐藏类可能不同
    // 导致JIT无法进行单态优化
    const xVal = p.x;
}

为了JIT优化,最好在对象创建时就定义所有属性,并保持属性顺序一致。

3. 热点代码(Hot Code)与冷代码(Cold Code)

JIT引擎会优先优化那些被频繁执行的“热点”代码路径。对于很少执行的“冷”代码,JIT通常不会花费时间去优化它们,或者只进行基线编译。

这意味着:

  • 循环体内部的代码:通常是热点,值得优化。
  • 频繁调用的函数:热点。
  • 错误处理、不常见的分支:通常是冷代码,不值得优化。

示例代码:

function processData(data) {
    // 热点路径
    for (let i = 0; i < data.length; i++) {
        // 核心计算逻辑,会被高度优化
        data[i] = data[i] * 2 + 1;
    }

    // 冷代码路径 (假设data通常不会为空)
    if (data.length === 0) {
        console.warn("Empty data received."); // 这部分代码不太可能被优化
    }
}

const largeArray = Array.from({ length: 100000 }, (_, i) => i);
for (let i = 0; i < 1000; i++) {
    processData([...largeArray]); // 反复调用热点路径
}

4. 函数大小与内联(Inlining)

小型、专注的函数更容易被JIT引擎内联到调用点。内联可以消除函数调用的开销,并允许JIT在更大的代码块上执行跨函数优化。如果函数体过大,JIT可能决定不内联,从而影响性能。

5. eval()witharguments

这些JavaScript特性会干扰JIT的静态分析能力,因为它使得代码的结构和变量的作用域在运行时难以预测。因此,包含这些特性的代码块通常不会被JIT优化,或者会导致去优化。现代JavaScript开发中,应尽量避免使用 eval()with。对于 arguments 对象,在ES6之后,建议使用剩余参数(rest parameters)替代。

6. 异常处理(Try-Catch)

try-catch 块会引入额外的控制流和栈管理开销,有时会阻碍JIT进行某些优化。在性能敏感的代码路径中,如果可以避免,应尽量减少 try-catch 的使用。当然,代码的健壮性总是第一位的。

7. 数组类型

JIT对密集数组(所有元素都连续存储且类型一致)的优化效果最好。如果数组中包含稀疏元素或混合类型,JIT可能需要回退到更通用的实现,从而降低性能。

// 示例6: 密集数组 (JIT友好)
const denseArray = new Array(1000).fill(0).map((_, i) => i);
for (let i = 0; i < denseArray.length; i++) {
    denseArray[i] *= 2;
}

// 示例7: 稀疏数组或混合类型数组 (JIT不友好)
const sparseArray = [];
sparseArray[0] = 1;
sparseArray[100] = 'hello'; // 稀疏且类型混合

优化策略与最佳实践

基于以上对JIT机制和影响因素的理解,我们可以总结出以下优化策略和最佳实践:

1. 保持类型一致性(Monomorphism)

  • 函数参数和返回值:尽量确保函数在所有调用中接收和返回相同类型的参数。如果一个函数需要处理多种类型,可以考虑重载或分派到不同的单态函数。
  • 对象属性:在对象创建时初始化所有属性,并避免在对象生命周期中动态添加或删除属性。保持属性顺序一致。
  • 数组元素:尽量使数组元素类型一致。如果需要存储不同类型的数据,可以考虑使用对象数组或者 Map/Set
// 优化前:多态
function calculateArea(shape) {
    if (shape.type === 'circle') {
        return Math.PI * shape.radius * shape.radius;
    } else if (shape.type === 'rectangle') {
        return shape.width * shape.height;
    }
}

// 优化后:分派到单态函数
function calculateCircleArea(radius) {
    return Math.PI * radius * radius;
}
function calculateRectangleArea(width, height) {
    return width * height;
}

// 在外部根据类型调用
let s1 = { type: 'circle', radius: 5 };
let s2 = { type: 'rectangle', width: 4, height: 6 };

// JIT可以优化这些单态调用
calculateCircleArea(s1.radius);
calculateRectangleArea(s2.width, s2.height);

2. 识别并隔离热点代码

  • 将核心的、计算密集型的逻辑封装在独立的、小型的函数中。
  • 确保这些函数能够被频繁调用,以便JIT将其识别为热点并进行优化。
  • 避免在热点路径中引入可能导致去优化的代码(如 try-catch、类型变化等)。

3. 避免去优化

去优化是性能杀手。一旦代码被优化,任何违反JIT假设的行为都会导致去优化,并回退到慢速路径,这会带来显著的性能开销。

  • 类型稳定性:如前所述,保持类型一致是避免去优化的关键。
  • 避免结构变化:不要在热点函数内部动态修改对象结构。
  • 谨慎使用不确定性操作:避免在性能关键路径中使用 eval()with

4. 函数内联友好

  • 编写小而精的函数,提高它们被JIT内联的可能性。
  • 复杂的逻辑可以分解为多个小型辅助函数。

5. 合理使用循环

  • 确保循环变量和循环体内部的类型稳定。
  • 避免在循环内部创建新的隐藏类对象。
  • 考虑循环不变式外提:将循环内部不随迭代次数变化的计算移到循环外部。
// 优化前:循环内重复计算
function processItems(items, factor) {
    for (let i = 0; i < items.length; i++) {
        items[i] = items[i] * factor * factor; // factor * factor 是循环不变式
    }
}

// 优化后:循环不变式外提
function processItemsOptimized(items, factor) {
    const precomputedFactor = factor * factor; // 只计算一次
    for (let i = 0; i < items.length; i++) {
        items[i] = items[i] * precomputedFactor;
    }
}

6. 使用性能测量工具

  • performance.now(): 在浏览器和Node.js中提供高精度的时间戳,用于测量代码块的执行时间。
  • console.time() / console.timeEnd(): 方便地测量代码块的耗时。
  • Node.js perf_hooks: 提供更底层的性能测量API。
  • 浏览器开发者工具(Performance Tab): 这是分析JIT行为、识别热点和去优化的最强大工具。它可以可视化调用栈、CPU使用率,并显示JIT编译器活动。
  • Node.js --profv8-profiler: 用于在Node.js环境中生成V8引擎的性能剖析文件。

使用 performance.now() 测量冷启动与预热:

function runTest(iterations) {
    let sum = 0;
    for (let i = 0; i < iterations; i++) {
        sum += i * i;
    }
    return sum;
}

function measurePerformance(func, initialCalls, warmUpCalls, testCalls) {
    console.log(`--- Measuring ${func.name} ---`);

    // 冷启动阶段
    let coldStartTimes = [];
    for (let i = 0; i < initialCalls; i++) {
        const start = performance.now();
        func(1000); // 运行少量操作,模拟冷启动
        coldStartTimes.push(performance.now() - start);
    }
    console.log(`冷启动平均耗时 (${initialCalls}次): ${
        (coldStartTimes.reduce((a, b) => a + b, 0) / initialCalls).toFixed(3)
    } ms`);

    // 预热阶段
    let warmUpTimes = [];
    for (let i = 0; i < warmUpCalls; i++) {
        const start = performance.now();
        func(1000); // 继续运行,让JIT预热
        warmUpTimes.push(performance.now() - start);
    }
    console.log(`预热阶段平均耗时 (${warmUpCalls}次): ${
        (warmUpTimes.reduce((a, b) => a + b, 0) / warmUpCalls).toFixed(3)
    } ms`);

    // 稳态测试阶段
    let steadyStateTimes = [];
    for (let i = 0; i < testCalls; i++) {
        const start = performance.now();
        func(1000); // 在预热后测试
        steadyStateTimes.push(performance.now() - start);
    }
    console.log(`稳态平均耗时 (${testCalls}次): ${
        (steadyStateTimes.reduce((a, b) => a + b, 0) / testCalls).toFixed(3)
    } ms`);
    console.log('---------------------------');
}

// 运行测试
measurePerformance(runTest, 10, 1000, 10000);

运行上述代码,你通常会观察到冷启动的平均耗时最高,预热阶段的耗时逐渐下降,最终稳态阶段的耗时最低。这直观地展示了JIT的预热过程。

7. 避免不必要的抽象

虽然抽象是良好软件设计的基石,但在性能敏感的代码路径中,过度的抽象(如使用大量高阶函数、复杂的代理对象等)可能会阻碍JIT的优化。JIT擅长优化简单的、可预测的代码流。在性能成为瓶颈时,适当地“去抽象化”或“扁平化”代码逻辑可能是有益的。

8. 了解并利用新的JIT层级

V8引擎不断发展,引入了Sparkplug、Maglev等新的JIT层级。这些新的编译器旨在进一步缩短预热时间,并提供更广泛的优化范围。开发者虽然不能直接控制这些,但了解它们的存在可以帮助我们理解为什么某些代码在特定引擎版本上表现更好或更差。

结语

JavaScript的即时编译技术是现代Web和Node.js应用高性能的基石。理解JIT引擎的冷启动、预热机制以及其背后的分层编译原理,对于编写高性能、可预测的JavaScript代码至关重要。通过遵循类型一致性、保持对象结构稳定、隔离热点代码以及避免去优化等最佳实践,我们可以有效地指导JIT引擎,使其充分发挥优化潜力。性能优化永远是一个权衡和持续迭代的过程,数学建模为我们提供了量化分析的框架,而实际的性能测量工具则是不可或缺的实践指南。随着JavaScript引擎的不断演进,对这些底层机制的深入理解将始终是编程专家们的核心竞争力。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注