问题 大型对象上的JSON.parse()使用的内存比它应该的多


我生成一个~200'000元素的对象数组(在里面使用对象文字符号) map 而不是 new Constructor()),我正在保存一个 JSON.stringify'它的版本到磁盘,它需要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的Activity Monitor,节点内存使用量约为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.parse整个数组需要一大堆内存,而在元素方面解析它会更加节省内存。

为什么内存使用量存在如此巨大的差异?这是一个问题 JSON.parse 阻止在V8中有效隐藏类生成?如果没有切片和切块字符串,我怎样才能获得良好的内存性能?我必须使用流式JSON解析吗?

为了便于实验,我将JSON文件置于问题中 要旨,请随意克隆它。


11503
2018-06-01 02:08


起源

进程消耗的内存毫无意义。从字面上看,你不能在此基础上推断你的代码内存消耗效率。 - zerkms
@zerkms感谢你指出这一点。我应该注意到,一旦我尝试第一种方法,我的系统(4 GB物理RAM)实际上感觉更加懒散:即使在终端输入时我也能说出来。 - Ahmed Fasih
呵呵。如果我开始 node --expose-gc,运行第一个代码片段(使用1 GB内存),然后运行 global.gc(); 大约五十次,节点内存使用率慢慢下降到100~MB。意义 - 哇。 - Ahmed Fasih
gist.githubusercontent.com/fasiha/909090f86ab5d9e12985/raw/... 显示“错误:blob太大” - guest271314
@ guest271314抱歉,Github不会显示原始文件,因为它们太大了,但你可以通过 git clone https://gist.github.com/909090f86ab5d9e12985.git。或者,如果您只想查看一些JSON文件,Github将显示几千行 gist.github.com/fasiha/909090f86ab5d9e12985/revisions - Ahmed Fasih


答案:


几点需要注意:

  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 功能 有两个论点:JSON文本和可选的“reviver”函数。

[].map() 功能 通行证  参数 它调用的函数:项值,数组索引和整个数组。

所以,如果你通过 JSON.parse 直接地,它被调用JSON文本作为第一个参数(如预期的那样),但它也被传递了一个  用于“复活”功能。 JSON.parse() 忽略第二个参数因为它不是函数引用,所以你在这里就可以了。但你可以想象一下你可能遇到麻烦的其他情况 - 所以当你传递一个你没有写入的任意函数时,对它进行三重检查总是一个好主意。 [].map()


8
2018-06-01 03:25



<del>是否有'字段分隔的JSON'的名称?我之前实际创建了这样的文件,使用制表符,但总觉得很阴暗,部分是因为混合了JSON和TSV,但更严重的是因为我从来不知道该怎么称呼它或使用什么文件扩展名。我不想把它称为JSON,这只会引起无休止的混淆。</ del> en.wikipedia.org/wiki/Line_Delimited_JSON 看起来像是一件事。 - Ahmed Fasih
这是一个很好的观点,你不会将文件称为整个JSON,即使它的每一行都是JSON文本。我会选择你喜欢的任何扩展,或者让我建议: .data:-) - Michael Geary
以行分隔的JSON 是一件事,谁知道! .ldjson 要么 .ldj 显然是文件扩展名,或 .jsonl。 - Ahmed Fasih
啊哈!完成这个圈子后,我添加了这个页面作为维基百科文章的引用...... - Michael Geary
Eeeeek,我不知道 Array.map 将多个参数传递给给定的“第一类函数”,最好总是将这些参数归结为 map。 - Ahmed Fasih


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

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

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


1
2017-09-09 13:37