C#(.Net6.0)使用Newtonsoft.Json解析大JSON文件如何降低内存占用
超大JSON行文件解析内存优化方案
问题背景
- 需求:将多行独立JSON存储的大文件解析为二维数组结构,现有功能已开发完成,但运行时内存占用达到源文件大小的10倍
- 测试场景:
sample.json共包含100000行数据,每行JSON字段不固定,文件大小为500MB时,现有代码运行内存占用约5GB - 技术栈:Newtonsoft.Json、.NET 6.0
- 文件格式:为JSON Lines格式,每行是一个独立的JSON对象,样例内容如下:
{Field1:0,Field2:1,Field2:3} {Field1:0,Field5:1,Field6:3} {Field1:0,Field7:1,Field9:3} {Field1:0,Field13:1,Field50:3,Field57:3} ...
现有实现代码
JSON读取逻辑
static void Read() { List<Dictionary<string, string>> rows = new List<Dictionary<string, string>>(); string path = @"D:\small.json"; using (FileStream fsRead = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (BufferedStream bsRead = new BufferedStream(fsRead)) using (StreamReader srRead = new StreamReader(bsRead)) { string? line; while ((line = srRead.ReadLine()) != null) { JObject jsonObject = JObject.Parse(line); MakeRowData(jsonObject, out var row); rows.Add(row); } } }
行数据构造逻辑
private static void MakeRowData(JObject jsonData, out Dictionary<string, string> row) { Dictionary<string, string> output = new Dictionary<string, string>(); foreach (var item in jsonData) { int childSize = 0; if (item.Value != null) { childSize = item.Value.Children().Count(); /// 有子节点则递归展开 if (childSize > 0) { ExploreChild(item.Value, ref output); } /// 无子节点直接写入 else { string str = item.Value.ToString(); output[item.Key] = str ?? ""; } } } row = output; } private static void ExploreChild(JToken jToken, ref Dictionary<string, string> row) { foreach (var item in jToken) { int childSize = item.Children().Count(); /// 有子节点则递归展开 if (childSize > 0) { ExploreChild(item, ref row); } /// 无子节点直接写入 else { string path = jToken.Path.Replace('[', '(').Replace(']', ')'); string str = jToken.First.ToString(); row[path] = str?? ""; } } }
内存占用过高的核心原因
- 全量加载:所有解析完成的行数据全部存入
List<Dictionary<string, string>>全程驻留内存,是最大的开销来源 - 解析冗余:
JObject.Parse会为每行JSON构造完整的JToken对象树,所有值都转为引用类型存储,中间临时对象多,内存开销远大于原始文本 - 字符串重复:每行的相同字段名都会生成新的字符串实例,没有复用;递归过程中反复生成Path字符串、执行Replace操作,产生大量冗余字符串
- 逻辑冗余:反复调用
Children().Count()遍历子节点判断嵌套层级,产生不必要的遍历开销;Dictionary未指定初始容量,扩容时会产生双倍临时内存拷贝 - 逻辑bug:
ExploreChild方法中遍历子节点时,取值和取路径用的是父级jToken而非当前遍历的item,嵌套场景下会出现值重复、取值错误的问题,也会产生无效赋值开销
可落地优化方案
- 优先改为流式处理:如果业务场景允许,不要把所有行全量存在内存中,每解析完一行就直接执行后续业务逻辑(比如写入数据库、导出文件、投递下游),处理完立即释放当前行的内存,这一步可以降低80%以上的内存占用。如果确实需要全量数据驻留内存,先扫描全文件收集所有出现过的字段名做统一映射,每行用
string[]按列索引存储,替换Dictionary结构,内存占用可以降低50%以上。 - 替换JObject解析为逐Token读取:不要用
JObject.Parse生成完整对象树,改用JsonTextReader逐Token读取每行JSON内容,在读取过程中直接拼接键路径、提取值,跳过中间JObject/JToken对象的生成,这一步可以减少60%左右的解析临时内存。核心实现示例:
Dictionary<string, string> output = new Dictionary<string, string>(16); // 指定初始容量避免扩容 StringBuilder pathBuilder = new StringBuilder(); using (var jsonReader = new JsonTextReader(new StringReader(line))) { while (jsonReader.Read()) { switch (jsonReader.TokenType) { case JsonToken.PropertyName: // 维护当前属性路径,嵌套场景直接拼接,不需要事后Replace pathBuilder.Clear(); pathBuilder.Append(jsonReader.Value.ToString()); break; case JsonToken.String: case JsonToken.Integer: case JsonToken.Float: case JsonToken.Boolean: case JsonToken.Date: case JsonToken.Null: // 直接取值写入,不生成JToken对象 string key = pathBuilder.ToString(); // 复用字符串实例,避免重复生成相同键名 key = string.IsInterned(key) ?? string.Intern(key); output[key] = jsonReader.Value?.ToString() ?? ""; break; // 嵌套对象、数组的路径拼接可在这里维护栈结构实现 } } }
- 减少字符串冗余分配:所有字段名用
string.Intern做驻留复用,避免10万行数据生成几十万份重复的键名字符串;路径拼接用StringBuilder动态维护,不要每次递归全量生成Path字符串再执行Replace操作。 - 可选替换序列化库:.NET 6环境下
System.Text.Json的只读逐Token解析内存占用比Newtonsoft.Json低30%-50%,如果没有强依赖Newtonsoft的特殊特性,可以直接替换进一步降低内存。 - 及时释放临时资源:如果必须全量加载数据,可以在每解析完1000行时手动触发一次GC回收临时内存,避免大量临时对象在GC堆二代驻留抬高内存占用。
内容的提问来源于stack exchange,提问作者YS R
相关产品推荐
相关产品推荐

