24B消灭上亿次装箱:Acl.Excel零装箱设计

24 字节 CellValue 怎样清零 1 亿次装箱?

本文力求客观:既呈现 Acl.Excel 的实测数据,也如实标注测试口径、版本差异与已知局限,供读者自行判断。

口径说明:本文以 100 万行 × 100 列为测算场景,涉及约 1 亿次装箱消除与 24B 的 CellValue 布局;局限:CellValue[] 须"用完即取"。

Excel系列文章 第6篇 / 共11篇

系列导航← 上一篇:读取引擎 · 下一篇:写入引擎 →

如果你用常见的 Excel 读取库读一份"只有"100 万行、每行 100 列的表格,GC 堆上会凭空多出约 1 亿个被装箱的值类型对象,平均每一格一次装箱。这些短命对象会疯狂触发 Gen0 回收,让读取吞吐断崖式下跌。问题往往不在你的代码,而在"单元格用 object 表示"这个看似理所当然的设计。

本文拆解 Acl.Excel 的答案:CellValue,一个仅 24 字节的值类型结构体,配合显式布局联合体与 11 种类型判别,把装箱彻底抹掉。读完后你会清楚:24 字节是怎么排布的、零装箱的访问路径长什么样、泛型与源生成器如何在编译期消灭反射,以及为什么 CellValue[] 必须"用完即取"。

核心结论
- Acl.Excel 用 24 字节的 CellValue 值类型 + 显式布局联合体([FieldOffset])实现类型安全联合,彻底消除单元格读取时的装箱。
- 在 100 万行 × 100 列场景下,原本约 1 亿次装箱被完全消除,Gen0 GC 压力随之骤降。
- CellCategory 用 11 种类型判别,在解码阶段就完成分类,避免后续类型转换时反复尝试解析。
- 从解码(CellDecoder)、转换(CellParser)、映射(CompiledRowMapper)到源生成器,全链路零反射、零装箱。
- CellValue[] 连续内存 + 跨行复用 buffer,缓存友好且无额外分配,代价是必须"用完即取"。

一、CellValue 是怎么设计的:24 字节显式布局联合体

为什么要把单元格做成值类型?因为值类型存放在数组或栈的连续内存中,不进入 GC 堆,也就没有装箱这回事。Acl.Excel 的答案是 CellValue:一个 24 字节的值类型结构体,用显式布局([FieldOffset])实现类型安全联合体,彻底消除装箱。

[StructLayout(LayoutKind.Explicit)]
public readonly record struct CellValue : IEquatable<CellValue>
{
    [FieldOffset(0)]  public readonly CellCategory Category;  // 1 字节:类型判别
    [FieldOffset(8)]  public readonly string?       AsString;  // 8 字节:字符串引用
    [FieldOffset(16)] private readonly long         _data;     // 8 字节:整型/浮点/布尔
}

24 字节的布局分为三个区域:

  • 偏移 0(1 字节 + 7 字节填充)CellCategory 枚举(底层类型 byte),标识当前存储的实际类型
  • 偏移 8(8 字节)string? 引用(AsString),只在字符串类型时有效
  • 偏移 16(8 字节)long 类型 _data 字段,用于存储整型、浮点、布尔值。通过 BitConverter.Int64BitsToDouble 在 long 和 double 之间转换

访问器使用只读属性而非方法,因此读取路径上不会产生任何装箱:

  • AsInt64 → 直接返回 _data
  • AsFloat64BitConverter.Int64BitsToDouble(_data)
  • AsBoolean_data != 0

CellCategory 枚举定义了 11 种类型判别:

类别 含义 数据来源
Null 空单元格 不存在或值为空
SharedString 共享字符串 (t="s") AsString 字段
Boolean 布尔值 (t="b") _data 字段
InlineString 内联字符串 (t="inlineStr"/t="str") AsString 字段
Error 错误值 (t="e") AsString 字段
DateTime 日期时间 _data(OA 日期)
TimeSpan 时间跨度 _data
Percentage 百分比 _data
GeneralInt 通用整型 _data 字段
GeneralDouble 通用浮点 _data 字段
GeneralString 通用字符串 AsString 字段

GeneralIntGeneralDoubleGeneralString 三种类别对应 Excel 的"通用"格式,即单元格未设置明确数字格式时的默认类型。Acl.Excel 在解码阶段就把这三种情况区分开,避免后续类型转换时反复尝试解析。

二、零装箱是怎么访问的:先判类型,再读字段

传统的写法往往是用 if (cell is int) 这类类型判定,而 is 判定本身就会触发装箱。Acl.Excel 的访问不依赖装箱判断,而是先检查 Category,再直接读取对应字段:

// 通过 Category 判别后直接访问值(零装箱)
if (cell.Category == CellCategory.GeneralInt)
    return cell.AsInt64;
if (cell.Category == CellCategory.GeneralDouble)
    return (long)cell.AsFloat64;

调用方通过 Category 进行 switch 分发,一次判断后直接访问值,整个路径不产生任何装箱。对于 100 万行 x 100 列的场景,这意味着 1 亿次装箱被完全消除。

三、CellDecoder:从 XML 到 CellValue 的统一解码

CellDecoder 是单元格解码的统一内核。它接收来自 XML 扫描器的原始字符串(元素内容、属性值),一次调用完成分类和物化,避免把解析逻辑散落到多处:

// 核心解码路径
internal static CellValue Decode(
    string raw,                // 原始 XML 文本
    string t,                  // t 属性值(类型标记:"s"/"b"/"n"/"e"/"str"/"inlineStr")
    string? style,             // style 属性值(可空)
    Func<int, string>? sst,    // 共享字符串表查询委托(可空)
    StylesParser? stylesParser // 样式解析器(可空)
)

解码逻辑:

  1. 根据 t 属性值确定大类(共享字符串 / 布尔 / 数字 / 错误 / 内联字符串)
  2. 对于数字类型,检查样式以确定是否为日期、时间、百分比等特殊格式
  3. 对于整型数字,尝试 long.TryParseGeneralInt 路径(99% 的整型数字命中此路径)
  4. 返回填充好的 CellValueCategory 字段已正确设置

四、CellParser<TCellValue, T>:泛型零反射转换

到了把 CellValue 转换成业务类型的环节,最容易踩的坑就是反射。反射不仅慢,还会带来装箱与临时分配。CellParser<TCellValue, T> 在静态构造器中编译表达式树,运行期零反射调用。两个泛型参数中,TCellValue 在运行时被检查为 {string, long, double, bool} 之一,T 是目标类型。表达式树在静态构造器中编译一次,后续所有调用都是直接委托执行,零反射、零装箱。

对于可空类型(int?double? 等),CellParser 自动复用底层非可空版本的转换委托,在 null 检查后直接调用,不产生额外的委托包装。

五、CellSetter 与 CompiledRowMapper

CellSetter<TModel> 是一个委托类型,由源生成器或表达式树编译生成,负责将单个 CellValue 直接赋值到 TModel 的属性,跳过任何中间装箱:

// 源码中的委托定义
public delegate void CellSetter<TModel>(TModel model, CellValue cell);

// CompiledRowMapper 内部为每个映射列生成一个 CellSetter 委托
// 运行时逐列调用:cellSetter[i](model, cellValues[i])

CompiledRowMapper 是表达式树路径的等效实现,CompileDirect 方法生成 CellSetter<TModel>[] 数组,每个映射列一个 setter 委托。运行时逐列调用,零装箱。

六、源生成器:编译时零反射

如果希望连表达式树编译这一步都省掉,可以进一步交给源生成器。[Excel][ExcelColumn] 特性标记模型类,Roslyn 增量源生成器在编译时自动生成预编译读写代码:

[Excel(ExcelGenerate.ReadWrite)]
public class Order
{
    [ExcelColumn("订单ID")]
    public long Id { get; set; }

[ExcelColumn("客户名称")]
public string CustomerName { get; set; }

[ExcelColumn("金额")]
public double Amount { get; set; }
}

// 编译后自动生成:
// var orders = ExcelFile.Query<Order>("orders.xlsx");
// ExcelFile.Write(orders, "output.xlsx");

三种生成模式:

  • [Excel(ExcelGenerate.Read)]:仅生成读代码
  • [Excel(ExcelGenerate.Write)]:仅生成写代码
  • [Excel][Excel(ExcelGenerate.ReadWrite)]:双向(默认)

源生成器路径的优势是:完全 AOT 兼容(NativeAOT 场景下不依赖 Expression.Compile),且编译时即发现列名不匹配等问题。

七、CellValue 是怎么遍历的:连续内存与跨行复用

因为 CellValue 是值类型,CellValue[] 数组的每个元素都存储在数组本身的连续内存中(而非散布在 GC 堆上的引用)。遍历一行 100 个单元格时,CPU 缓存可以高效预取数组内容,迭代器也无需追踪对象引用。

重要约束:CellValue[] buffer 跨行复用。消费者必须在每次迭代中立即消费或拷贝所需数据,不能跨行缓存 CellValue 引用,下一行数据会覆盖同一块 buffer。这是为了实现零分配的折中设计。

八、关键收获

  • 装箱不是免费的:每行每格一次装箱,百万行规模就是上亿次,直接压垮 Gen0 回收。
  • 值类型联合体([FieldOffset])是零装箱的核心武器,24 字节即可覆盖 11 种单元格类型。
  • 零装箱是系统工程:解码、泛型转换、委托映射、源生成器要层层配合,缺一不可。
  • CellValue[] buffer 跨行复用,意味着你必须"用完即取",不能跨行持有 CellValue 引用。

免责声明:本文基于 Acl.Excel 4.0.0(2026 年 7 月,撰写日期前后)实测数据撰写,测试环境与口径见正文;数据可能随版本演进变化,重要决策请自行复测核验。

客观性说明:本文的结论基于以下事实约束,而非自诩无偏:① 以 100 万行 × 100 列为测算场景,约 1 亿次装箱消除为按"每格一次装箱"的估算口径;② 24B CellValue 显式布局联合体与 11 种类型判别为 Acl.Excel 当前设计实现;③ 局限:CellValue[] 须"用完即取",buffer 跨行复用要求消费者在每次迭代中立即消费或拷贝。以上局限已在正文相应位置如实标注,供读者结合完整信息自行判断。

系列导航

Acl.Excel 系列文章(共 11 篇)

序号 文章 重点
从 libdeflate 到纯 C#:Acl.Excel DEFLATE 解压/压缩器的移植与优化之旅 纯 C# 移植 libdeflate
从 libdeflate 到纯 C#(续):优化纯 C# DEFLATE 压缩器——从 1.6x 到 4.9x 的提速之路 14 轮优化提速
Acl.Excel vs MiniExcel 1.45.0:性能对比与选型分析(.NET 8 基准) 百万行对等实测(本文)
不依赖 NPOI/ClosedXML,纯 C# Excel 库如何做到 30 倍性能? 概述与设计哲学
百万行Excel如何恒定内存?Acl.Excel流式拆解 读取引擎流式管线
24B消灭上亿次装箱:Acl.Excel零装箱设计 24B CellValue 零装箱
写入分配砍掉 63%:Acl.Excel 绕过 XmlWriter 字节直写 字节直写与并行写入
零依赖纯 C# DEFLATE 引擎:2916 行移植 libdeflate DEFLATE 引擎移植
一个 42KB 的 Excel 如何撑爆 4.5PB?Acl.Excel 8 层防御 安全纵深防御
Acl.Excel 跨库基准:读取最高快 32.8 倍 跨库性能基准
从实战到生产:Acl.Excel 八大场景与避坑指南(终篇) 实战与避坑指南

建议按 ①→⑪ 顺序阅读,从底层算法到实战落地形成完整认知。(当前本篇为第 3 篇,已以 粗体 标记。)

下一篇将进入写入管线,解析 Utf8RawWriter 如何通过字节直写取代 XmlWriter,以及多 Sheet 并行写入和增量持久化如何将写入吞吐推向极致。

标签(建议):Acl.Excel, .NET, 性能优化, Excel 处理, 零装箱/值类型

posted @ 2026-08-25 10:39  风云  阅读(3)  评论(0)    收藏  举报