【问题标题】:JSON.parse() on a large array of objects is using way more memory than it should大量对象上的 JSON.parse() 使用的内存超出了应有的范围
【发布时间】:2015-08-14 09:28:41
【问题描述】:

我生成了一个约 200'000 个元素的对象数组(使用 map 中的对象文字表示法而不是 new Constructor()),并且我将它的 JSON.stringify'd 版本保存到磁盘中占用 31 MB,包括换行符和每个缩进一个空格 (JSON.stringify(arr, null, 1))。

然后,在一个新的节点进程中,我将整个文件读入一个 UTF-8 字符串并传递给JSON.parse

var fs = require('fs');
var arr1 = JSON.parse(fs.readFileSync('JMdict-all.json', {encoding : 'utf8'}));

根据 Mavericks 的活动监视器,节点内存使用量约为 1.05 GB!在我古老的 4 GB RAM 机器上,即使在终端上打字也感觉比较迟钝。

但是,如果在一个新的节点进程中,我将文件的内容加载到一个字符串中,在元素边界处将其切碎,然后 JSON.parse 每个元素单独进行,表面上得到相同的对象数组:

var fs = require('fs');
var arr2 = fs.readFileSync('JMdict-all.json', {encoding : 'utf8'}).trim().slice(1,-3).split('\n },').map(function(s) {return JSON.parse(s+'}');});

节点只使用了大约 200 MB 的内存,并且没有明显的系统延迟。这种模式在节点的多次重启中仍然存在:JSON.parseing 整个数组占用大量内存,而按元素解析它的内存效率更高。

为什么在内存使用方面存在如此巨大的差异?这是JSON.parse 阻止V8 中有效隐藏类生成的问题吗?如何在不切片和切割字符串的情况下获得良好的内存性能?我必须使用流式 JSON 解析吗?????

为了方便实验,我已将有问题的 JSON 文件放在 Gist 中,请随意克隆它。

【问题讨论】:

  • 进程消耗的内存没有意义。从字面上看,您无法基于此推断您的代码内存消耗效率。
  • @zerkms 感谢您指出这一点。我应该注意到,当我尝试第一种方法时,我的系统(4 GB 物理 RAM)实际上感觉比较迟钝:即使在终端输入时我也能分辨出来。
  • 嗯。如果我启动node --expose-gc,运行第一个代码sn -p(用完1 GB内存),然后运行global.gc();大约五十次,节点内存使用量慢慢下降到100~ MB。影响——哇。
  • @guest271314 抱歉,Github 不会向您显示原始文件,因为它们太大了,但您可以通过 git clone https://gist.github.com/909090f86ab5d9e12985.git 获取 repo。或者如果你只是想看一点 JSON 文件,Github 会显示几千行 gist.github.com/fasiha/909090f86ab5d9e12985/revisions

标签: javascript arrays json node.js parsing


【解决方案1】:

注意几点:

  1. 您发现,无论出于何种原因,对数组的每个元素进行单独的 JSON.parse() 调用要比对一个大的 JSON.parse() 调用效率更高。
  2. 您生成的数据格式在您的控制之下。除非我理解错了,否则整个数据文件不一定是有效的 JSON,只要您可以解析即可。
  3. 听起来您的第二种更有效的方法的唯一问题是拆分原始生成的 JSON 的脆弱性。

这提出了一个简单的解决方案:不是生成一个巨大的 JSON 数组,而是为数组的每个元素生成一个单独的 JSON 字符串 - JSON 字符串中没有换行符,即只使用 JSON.stringify(item) 而没有 space 参数。然后将这些 JSON 字符串与换行符(或您知道永远不会出现在数据中的任何字符)连接起来并写入该数据文件。

当您读取此数据时,在换行符上拆分传入数据,然后在每一行上分别执行JSON.parse()。换句话说,这一步就像您的第二个解决方案,但使用简单的字符串拆分,而不必摆弄字符数和花括号。

您的代码可能看起来像这样(实际上只是您发布的内容的简化版本):

var fs = require('fs');
var arr2 = fs.readFileSync(
    'JMdict-all.json',
    { encoding: 'utf8' }
).trim().split('\n').map( function( line ) {
    return JSON.parse( line );
});

正如您在编辑中指出的那样,您可以将此代码简化为:

var fs = require('fs');
var arr2 = fs.readFileSync(
    'JMdict-all.json',
    { encoding: 'utf8' }
).trim().split('\n').map( JSON.parse );

但我会小心这一点。它在这种特殊情况下确实有效,但在更一般的情况下存在潜在危险。

JSON.parse 函数 takes two arguments:JSON 文本和可选的“reviver”函数。

[].map() 函数 passes three arguments 到它调用的函数:项目值、数组索引和整个数组。

因此,如果您直接传递 JSON.parse,它会以 JSON 文本作为第一个参数(如预期的那样)被调用,但它也会被传递一个 number 用于“reviver”函数。 JSON.parse() 忽略第二个参数,因为它不是函数引用,所以你在这里没问题。但是您可能可以想象其他可能遇到麻烦的情况 - 因此,当您将一个未写入 [].map() 的任意函数传递给它时,对其进行三次检查总是一个好主意。

【讨论】:

  • “字段分隔的 JSON”有名称吗?实际上,我以前使用选项卡创建过这样的文件,但总是感觉很阴暗,部分原因是 JSON 和 TSV 的混合,但更严重的是因为我不知道该调用什么或使用什么文件扩展名。我不想称它为 JSON,那只会造成无穷无尽的混乱。 en.wikipedia.org/wiki/Line_Delimited_JSON 看起来像是一件事。
  • 这很好,即使文件的每一行都是 JSON 文本,您也不会将文件称为整个 JSON。我会选择您喜欢的任何扩展名,或者让我建议:.data :-)
  • Line-delimited JSON 是一回事,谁知道呢! .ldjson.ldj 显然是文件扩展名,或 .jsonl
  • 啊哈!完成圆圈后,我将此页面添加为该维基百科文章的引用...
  • Eeeeek,我不知道Array.map 会将多个参数传递给给它的“一流函数”? 最好总是将这些参数归类为map
【解决方案2】:

我认为评论暗示了这个问题的答案,但我会稍微扩展一下。正在使用的 1 GB 内存可能包括大量实际“死”的数据分配(因为它已变得无法访问,因此程序不再真正使用)但尚未被垃圾收集器。

当使用的编程语言/技术是典型的现代语言/技术(例如 Java/JVM、c#/.NET、JavaScript)时,几乎任何处理大型数据集的算法都可能以这种方式产生大量的碎屑.最终 GC 将其删除。

有趣的是,可以使用一些技术来显着减少某些算法产生的临时内存分配量(通过将指针指向字符串的中间),但我认为这些技术很难或不可能在 JavaScript 中使用.

【讨论】:

    猜你喜欢
    • 2020-08-09
    • 1970-01-01
    • 1970-01-01
    • 2016-04-05
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-04-14
    • 1970-01-01
    相关资源
    最近更新 更多