node-v8堆内存分析

node v8 内存一二三

基础知识

  • Node.js 进程的内存管理,都是有V8自动处理的,包括内存分配和释放。
  • V8内部,会为程序中的所有变量构建一个图,来表示变量间的关联关系,当变量从根节点无法触达时,就意味着这个变量不会再被使用了,即可回收。
  • 回收是一个过程性的,从快速GC到最后的Full GC,是需要一段时间的。另外,Full GC是有触发阈值的,所以可能会出现内存长期占用在一个高值,也可以算是一种内存泄漏。还有一种就是引用不释放,导致无法进入GC环节,并且一直产生新的占用,这一般会发生在Javascript业务层面。
  • 定位内存泄漏,找有引用但不被使用

V8 内存构成(以下研究基于node 8.x 版本依赖的v8版本)

一个 V8 进程的内存通常由以下几个块构成:

Heap内存分配枚举

// NOTE: SpaceIterator depends on AllocationSpace enumeration values being
// consecutive.
// Keep this enum in sync with the ObjectSpace enum in v8.h
enum AllocationSpace {
  NEW_SPACE,   // Semispaces collected with copying collector.
  OLD_SPACE,   // May contain pointers to new space.
  CODE_SPACE,  // No pointers to new space, marked executable.
  MAP_SPACE,   // Only and all map objects.
  LO_SPACE,    // Promoted large objects.

  FIRST_SPACE = NEW_SPACE,
  LAST_SPACE = LO_SPACE,
  FIRST_PAGED_SPACE = OLD_SPACE,
  LAST_PAGED_SPACE = MAP_SPACE
};

node-v8 暴露模块

  • 新生代内存区(new_space
  • 大多数的对象都会被分配在这里,区域很小但是垃圾回收比较频繁,由两个半区域(semispace)构成
  • 老生代内存区(old_space
  • 属于老生代,存放从新生代晋升而来的对象,可能包含对new space的引用,GC频率低,按照GC 1.4GB至少要50ms以上,非增量更是1s以上。
  • 大对象区(large_object_space
  • 这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象区
  • 代码区(code_space
  • 代码对象,会被分配在这里,无引用。唯一拥有执行权限的内存
  • map 区(map_space
  • 存放 CellMap,每个区域都是存放相同大小的元素,结构简单

Heap内存计算

size_t Heap::Capacity() {
  if (!HasBeenSetUp()) return 0;

  return new_space_->Capacity() + OldGenerationCapacity();
}

size_t Heap::OldGenerationCapacity() {
  if (!HasBeenSetUp()) return 0;

  return old_space_->Capacity() + code_space_->Capacity() +
         map_space_->Capacity() + lo_space_->SizeOfObjects();
}
  • 从上面代码可以看出v8整个heap内存容器大小即新生代+老生代+代码区+map区+大对象区
Heap::heap()
    :...
    // semispace_size_ should be a power of 2 and old_generation_size_ should
    // be a multiple of Page::kPageSize.
    max_semi_space_size_(8 * (kPointerSize / 4) * MB),
    initial_semispace_size_(MB),
    max_old_generation_size_(700ul * (kPointerSize / 4) * MB),
    initial_max_old_generation_size_(max_old_generation_size_),
    initial_old_generation_size_(max_old_generation_size_ /
                                  kInitalOldGenerationLimitFactor),
    old_generation_size_configured_(false), //是否配置老生代内存上限
    ...
  // heap.h 头文件申明
  static const int kInitalOldGenerationLimitFactor = 2;
  • 上面代码中,看到几个关键信息:
  • max_semi_space_size_kPointerSize是指的当前系统的当前编译器的sizeof(void*)大小,即指针大小,64bits系统通常为 832bits 通常为 4,笔者电脑测试为16MB
  • initial_semispace_size_semi_sapce_size初始为1MB
  • max_old_generation_size_ 按照上面计算方法得出1400MB
  • initial_max_old_generation_size_max_old_generation_size_大小,如果后续有启动参数配置则为配置大小如 node --max-old-space-size=2000 xxx.js,表示将最大老生代内存设置为2000MB
  • initial_old_generation_size_ 同理可得到为最大老生代内存的一半
// Returns the maximum amount of memory reserved for the heap.
  size_t MaxReserved() {
    return 2 * max_semi_space_size_ + max_old_generation_size_;
  }
  • 该函数获得当前系统当前编译器中最大的heap内存,通过计算为1432MB,笔记通过API实时调用能看到也是1432MB,可通过v8提供的getHeapStatistics()函数拿到heap_size_limit就是我们heap内存最大上限。

内存分布



内存模块中通常分为已申请区、使用区、可使用区等。new_space则分为激活区、未激活区。

以下通过三个API获取的当前系统及程序内存环境数据,笔者已换算成MB

  • process.memoryUsage()
  • v8.getHeapStatistics()
  • v8.getHeapSpaceStatistics()
{
  "code": 100000,
  "data": {
    "memoryUsage": {
      "rss": 217,
      "heapTotal": 181,
      "heapUsed": 152,
      "external": 11
    },
    "HeapStatistics": {
      "total_heap_size": 181,
      "total_heap_size_executable": 10,
      "total_physical_size": 179,
      "total_available_size": 1260,
      "used_heap_size": 152,
      "heap_size_limit": 1432,
      "malloced_memory": 1,
      "peak_malloced_memory": 11,
      "does_zap_garbage": 0
    },
    "HeapSpaceStatistics": [
      {
        "space_name": "new_space",
        "space_size": 32,
        "space_used_size": 8,
        "space_available_size": 9,
        "physical_space_size": 32
      },
      {
        "space_name": "old_space",
        "space_size": 88,
        "space_used_size": 86,
        "space_available_size": 2,
        "physical_space_size": 88
      },
      {
        "space_name": "code_space",
        "space_size": 9,
        "space_used_size": 8,
        "space_available_size": 1,
        "physical_space_size": 9
      },
      {
        "space_name": "map_space",
        "space_size": 3,
        "space_used_size": 3,
        "space_available_size": 1,
        "physical_space_size": 3
      },
      {
        "space_name": "large_object_space",
        "space_size": 50,
        "space_used_size": 49,
        "space_available_size": 1250,
        "physical_space_size": 50
      }
    ]
  },
  "msg": "metrics"
}

v8内存生命周期

假设当前有一个变量house,从创建到销毁过程大致过程如下。

  1. 这个对象被分配到了 new_space
  2. 随着程序的运行,new_space 塞满了,GC 开始清理 new_space 里的对象,house 因为还处于活跃状态,所以没被清理出去GC
  3. 清理了两遍 new_space,发现 house 依然还活跃着,就把 house 移动到了 old_space
  4. 随着程序的运行,old_space 也塞满了,GC 开始清理 old_space,这时候发现 house 已经没有被引用了,就把 house 给清理出去了,如果一直引用,则不会被清理

第二步里,清理 new_space 的过程叫做 Scavenge(不是胡说八道,证据在下面),即空间换时间,我们把new_space分为激活和未激活两个半(semi)区域,则过程如下:

//heap.h

// Returns the timer used for a given GC type.
// - GCScavenger: young generation GC
// - GCCompactor: full GC
// - GCFinalzeMC: finalization of incremental full GC
// - GCFinalizeMCReduceMemory: finalization of incremental full GC with
// memory reduction
HistogramTimer* Heap::GCTypeTimer(GarbageCollector collector) {
  if (IsYoungGenerationCollector(collector)) {
    return isolate_->counters()->gc_scavenger();
  } else {
    if (!incremental_marking()->IsStopped()) {
      if (ShouldReduceMemory()) {
        return isolate_->counters()->gc_finalize_reduce_memory();
      } else {
        return isolate_->counters()->gc_finalize();
      }
    } else {
      return isolate_->counters()->gc_compactor();
    }
  }
}
// 是否是新生代收集器
static inline bool IsYoungGenerationCollector(GarbageCollector collector) {
  return collector == SCAVENGER || collector == MINOR_MARK_COMPACTOR;
}

static inline GarbageCollector YoungGenerationCollector() {
  return (FLAG_minor_mc) ? MINOR_MARK_COMPACTOR : SCAVENGER;
}
//gc-tracer.cc
const char* GCTracer::Event::TypeName(bool short_name) const {
  switch (type) {
    case SCAVENGER:
      return (short_name) ? "s" : "Scavenge";
    case MARK_COMPACTOR:
    case INCREMENTAL_MARK_COMPACTOR:
      return (short_name) ? "ms" : "Mark-sweep";
    case MINOR_MARK_COMPACTOR:
      return (short_name) ? "mmc" : "Minor Mark-Compact";
    case START:
      return (short_name) ? "st" : "Start";
  }
  return "Unknown Event Type";
}



scavenge-cheney算法

  1. 当活跃区满了或主动GC,from会有两个操作,且都是在经过标记后,一个清除经过标记后的非存活对象,另一个复制经过标记后存活对象到to
  2. 交换fromto
  3. 交换中如果有存活对象经过清道夫标记后标记数>1,或当前to区域占比超过25%,则直接进入old_space

mark-sweep标记清扫

第四步里,清理 old_space 的过程叫做 Mark-sweep,也就是标记和清扫,这块占用内存很大,所以没有使用 Scavenge,这个回收过程包含了若干次(2-7次)标记过程和清理过程:

// full GC
void Heap::CollectAllAvailableGarbage(GarbageCollectionReason gc_reason) {
  // Since we are ignoring the return value, the exact choice of space does
  // not matter, so long as we do not specify NEW_SPACE, which would not
  // cause a full GC.
  // Major GC would invoke weak handle callbacks on weakly reachable
  // handles, but won't collect weakly reachable objects until next
  // major GC.  Therefore if we collect aggressively and weak handle callback
  // has been invoked, we rerun major GC to release objects which become
  // garbage.
  // Note: as weak callbacks can execute arbitrary code, we cannot
  // hope that eventually there will be no weak callbacks invocations.
  // Therefore stop recollecting after several attempts.
  if (gc_reason == GarbageCollectionReason::kLastResort) {
    InvokeOutOfMemoryCallback();
  }
  RuntimeCallTimerScope runtime_timer(
      isolate(), &RuntimeCallStats::GC_Custom_AllAvailableGarbage);
  if (isolate()->concurrent_recompilation_enabled()) {
    // The optimizing compiler may be unnecessarily holding on to memory.
    DisallowHeapAllocation no_recursive_gc;
    isolate()->optimizing_compile_dispatcher()->Flush(
        OptimizingCompileDispatcher::BlockingBehavior::kDontBlock);
  }
  isolate()->ClearSerializerData();
  set_current_gc_flags(kMakeHeapIterableMask | kReduceMemoryFootprintMask);
  isolate_->compilation_cache()->Clear();
  const int kMaxNumberOfAttempts = 7;
  const int kMinNumberOfAttempts = 2;
  for (int attempt = 0; attempt < kMaxNumberOfAttempts; attempt++) {
    if (!CollectGarbage(OLD_SPACE, gc_reason,
                        v8::kGCCallbackFlagCollectAllAvailableGarbage) &&
        attempt + 1 >= kMinNumberOfAttempts) {
      break;
    }
  }

  set_current_gc_flags(kNoGCFlags);
  new_space_->Shrink();
  UncommitFromSpace();
}

把当前 内存数据抽象为森林,如下


清理后


  1. 标记从根可达的对象为白色
  2. 遍历白色对象的邻接对象,直到所有可到对象都标记为白色
  3. 循环标记若干次(2-7)
  4. 清理掉非白色的对象。

简单来说,Mark-sweep 就是把从根节点无法获取到的对象清理掉,与scavenge-cheney相比,scavenge-cheney只会复制存活对象,而新内存中本身就小,且存活对象不多,所以高效。mark-sweep则只会清除没被标记的对象,而老生代死对象少,这也就是mark-sweep针对老生代区域高效GC的原因。

mark-compact 标记整理和压缩

由于经过mark-sweep算法GC后,会出现不连续的空间,导致空间碎片,当下次需要移动大对象或对象晋升,但没有足够的空间使用,将会再次导致GC,但往往这个时候GC没必要的,因为很可能是刚刚GC过,所以怎么合理利用空间碎片就成了性能关键。于是mark-compact算法出现了。

  1. 将存活对象移动到old_space的一端
  2. 将另一端直接清空

整理之前(黑色为死对象)


整理之后


我们对比下三种GC算法



incremental-marking

由于GC期间,需要执行stop-the-world来保证应用程序逻辑和GC看到的一致性,所以v8中引入了incremental-marking增量标记策略,清理一会儿,执行一会儿应用程序。

void Heap::ReportExternalMemoryPressure() {
  const GCCallbackFlags kGCCallbackFlagsForExternalMemory =
      static_cast<GCCallbackFlags>(
          kGCCallbackFlagSynchronousPhantomCallbackProcessing |
          kGCCallbackFlagCollectAllExternalMemory);
  if (external_memory_ >
      (external_memory_at_last_mark_compact_ + external_memory_hard_limit())) {
    CollectAllGarbage(
        kReduceMemoryFootprintMask | kFinalizeIncrementalMarkingMask,
        GarbageCollectionReason::kExternalMemoryPressure,
        static_cast<GCCallbackFlags>(kGCCallbackFlagCollectAllAvailableGarbage |
                                     kGCCallbackFlagsForExternalMemory));
    return;
  }
  if (incremental_marking()->IsStopped()) {
    if (incremental_marking()->CanBeActivated()) {
      StartIncrementalMarking(i::Heap::kNoGCFlags,
                              GarbageCollectionReason::kExternalMemoryPressure,
                              kGCCallbackFlagsForExternalMemory);
    } else {
      CollectAllGarbage(i::Heap::kNoGCFlags,
                        GarbageCollectionReason::kExternalMemoryPressure,
                        kGCCallbackFlagsForExternalMemory);
    }
  } else {
    // Incremental marking is turned on an has already been started.
    const double kMinStepSize = 5;
    const double kMaxStepSize = 10;
    const double ms_step =
        Min(kMaxStepSize,
            Max(kMinStepSize, static_cast<double>(external_memory_) /
                                  external_memory_limit_ * kMinStepSize));
    const double deadline = MonotonicallyIncreasingTimeInMs() + ms_step;
    // Extend the gc callback flags with external memory flags.
    current_gc_callback_flags_ = static_cast<GCCallbackFlags>(
        current_gc_callback_flags_ | kGCCallbackFlagsForExternalMemory);
    incremental_marking()->AdvanceIncrementalMarking(
        deadline, IncrementalMarking::GC_VIA_STACK_GUARD,
        IncrementalMarking::FORCE_COMPLETION, StepOrigin::kV8);
  }
}

理论于实践的意义

注:本文Chrome调试部分基于版本(Version 74.0.3729.169 (Official Build) (64-bit))

首先认识下Chrome DevTools内存模块(当前静态内存分布,时刻)

  • 准备好 Chrome,然后执行下面的代码
class BeikeClass {
  constructor(){} //没有构造函数效果一样
}
class BeikeFangClass {
  constructor(){
    this.fang = new BeikeClass()
  }
}

let array = new Array(100000).fill('').map(item => new BeikeFangClass())
  • 打开Chrome devtools,进入memory



介绍下几个tab:

  • Constructor为构造函数
  • Distance为对象到根层级
  • Shallow Size为对象自己内存大小(不包含内部引用)
  • Retained Size为对象内存总大小且包含内部引用对象大小

上面经过过滤后会看到申明的两个类 BeikeClassBeikeFangClass,可以看到 BeikeFangClass(6)下一层级有 BeikeClass(7),而且 2400000+3200000=5600000 也符合上面对 Shallow SizeRetained Size的解释(瞎解释?,官网走你

注:Retained Size 是性能调优阶段重要指标(主动GC

怎么样才能看到内存在涨呢?看个动态的Performance之前叫Timeline



看,这里的波动图就能看到内存在涨。怎么操作?执行下面的代码

var x = [];

function grow() {
  for (var i = 0; i < 10000; i++) {
    document.body.appendChild(document.createElement('div'));
  }
  x.push(new Array(1000000).join('x'));
}

document.getElementById('grow').addEventListener('click', grow);

然后点击开始记录当前时刻开始一段时间内的内存使用情况,下面仔细看下这20s左右内存使用情况:

  • 首先主动GC一次,能看到内存有所下降,也就是图中标注的第一次(Major GC,其中可能包含一到多次Minor GC),Major GC通常是针对老生代、Minor GC通常针对新生代,那也就意味着Major通常比Minor慢,因为老生代内存比新生代内存大很多,算法也不相同。
  • 中间红色框标记的为向文档中插入10000个DOM,可以看到js heap有增长
  • 后续每次插入10000DOM,都能看到明显的js heap增长,同时还有nodes增长
  • 在随着DOM原来越多,系统会自动触发DOM GC,尝试回收无用DOM,以及Minor GC


  • 最后一次主动GC后内存也明显下降了
  • 我们还可以点击下方的 Call tree 来查看整个过程的调用树,我们能看到除Major GCMinor GCDOM GC之外的其他相关系统调用栈及具体信息


  • 通过Event Log查看根据时间系统调用栈


怎么分析内存爆了?谁爆了?

那我们模拟一个泄漏的例子,模拟内存增长:

  • 打开 memory,然后执行下面的代码,每隔一段时间录制一段 HEAP SNAPSHOTS,然后做两两对比
class BeikeClass {
  constructor(){
    this.fang = new ArrayBuffer(1000000)  
  }
}

const _heap = {}
setInterval(() => {
  _heap[Date.now()] = new BeikeClass()
}, 100)


  • 对于上面的图来说,我们的首先选中其中一个SNAPSHOTS比如SNAPSHOTS 7,然后修改Summary->Comparison,右侧选中SNAPSHOTS 7作对比,我们看到右侧红色框中new中比较多,也是我们需要关注的。
  • 重复上面的过程,选择对比SNAPSHOTS 8SNAPSHOTS 7,同样能得到一个对比


  • 对比两张图,可以看到两次对比中BeikeClassArrayBufferstring等几项都明显增长,我们点击三角展开就能定位变量最终引用比如这里的ArrayBuffer,点开后fang in BeikeiClass-1560244861634-_heap,及最终引用链(官网叫支配项)
  • 查看对象引用关系



最终我们也就能定位到是BeikeClassArrayBufferstring几个可能是‘凶手’,从而破案。

node环境怎么操作?

node环境下结合node-heapdump和自己监听内存使用情况或使用node-memwatch

  • node-memwatch监听当前程序(V8实例)的GC事件,然后会触发leakstats事件,leak是在内存泄漏时候会触发,stats是在GC后触发,上报数据

如果遇到node-memwatch编译报错

no matching constructor for initialization of 'String::Utf8Value'
candidate constructor not viable: requires 1 argument, but 2 were provided

修复方法两个:node升级到9以上或者修改源码,上面笔者说本文基于8.x,所以这里只能改源码了

String::Utf8Value utfString(isolate, str->ToString());

删除utfString第一个参数即可(别问为什么,问就是看源码)

笔者这里有一段代码示例,(我们在启动的时候可以加上 --trace_gc 参数来观察运行过程中的详细GC信息)

class BeikeFang {
  constructor(){
    this.stamp = Date.now()
  }
}

class BeikeINF {
  constructor(){
    this.list = new BeikeFang()
  }
}
// 每个10ms向GC_VARS数组push一个{class: new BeikeINF()}
global.GC_VARS = []
const timer = setInterval(() => {
  var GC_VAR = {class: new BeikeINF()}
  global.GC_VARS.push(GC_VAR)
}, 10);
setTimeout(() => {
  clearInterval(timer)
  console.log('clear')
}, 100000);

clear之前然后每隔一段时间生成heapsnapshot

let filename = './' + Date.now() + '.heapsnapshot'
heapdump.writeSnapshot(filename, function(a, b){
  console.log('succ filename', b)
  filename = b
})

下面生成了6个快照



导入Chrome->memory分析


上图中能明显看到内存是增长趋势,按照之前描述的方法进行分析对比就能知道是那块变量出现问题

我们可以使用memwatch提供的diff方法进行对应两个时间点的snapshotdiff

const hd = new memwatch.HeapDiff();
let diff = null
function sleep() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      diff = hd.end()
      resolve(diff)
    }, 5000);
  })
}
diff = await sleep()
ctx.ajax(diff, {
  error: false,
  message: 'heap diff'
})

下面是利用HeapDiff类生成的diff结果

{
  before: {
    nodes: 447998,
    size_bytes: 52876240,
    size: "50.43 mb"
  },
  after: {
    nodes: 447670,
    size_bytes: 52568136,
    size: "50.13 mb"
  },
  change: {
    size_bytes: -308104,
    size: "-300.88 kb",
    freed_nodes: 1975,
    allocated_nodes: 1543,
    details: [{
      what: "Arguments",
      size_bytes: -64,
      size: "-64 bytes",
      +: 0,
      -: 2
    },
    {
      what: "Array",
      size_bytes: -41432,
      size: "-40.46 kb",
      +: 102,
      -: 631
    },
    {
      what: "BeikeFang",
      size_bytes: 14112,
      size: "13.78 kb",
      +: 441,
      -: 0
    },
    {
      what: "BeikeINF",
      size_bytes: 14112,
      size: "13.78 kb",
      +: 441,
      -: 0
    },
    {
      what: "Buffer",
      size_bytes: -80,
      size: "-80 bytes",
      +: 0,
      -: 1
    },
    {
      what: "BufferList",
      size_bytes: 48,
      size: "48 bytes",
      +: 1,
      -: 0
    },
    {
      what: "Closure",
      size_bytes: 720,
      size: "720 bytes",
      +: 16,
      -: 3
    },
    {
      what: "Code",
      size_bytes: -282656,
      size: "-276.03 kb",
      +: 15,
      -: 392
    },
    {
      what: "FSReqWrap",
      size_bytes: -32,
      size: "-32 bytes",
      +: 0,
      -: 1
    },
    {
      what: "Native",
      size_bytes: 344,
      size: "344 bytes",
      +: 6,
      -: 1
    },
    {
      what: "Number",
      size_bytes: -16,
      size: "-16 bytes",
      +: 0,
      -: 1
    },
    {
      what: "Object",
      size_bytes: 14288,
      size: "13.95 kb",
      +: 445,
      -: 2
    },
    {
      what: "Promise",
      size_bytes: 384,
      size: "384 bytes",
      +: 4,
      -: 0
    },
    {
      what: "PromiseWrap",
      size_bytes: 192,
      size: "192 bytes",
      +: 4,
      -: 0
    },
    {
      what: "ReadableState",
      size_bytes: 192,
      size: "192 bytes",
      +: 1,
      -: 0
    },
    {
      what: "Socket",
      size_bytes: 248,
      size: "248 bytes",
      +: 1,
      -: 0
    },
    {
      what: "String",
      size_bytes: -8856,
      size: "-8.65 kb",
      +: 1,
      -: 212
    },
    {
      what: "TCP",
      size_bytes: 32,
      size: "32 bytes",
      +: 1,
      -: 0
    },
    {
      what: "TickObject",
      size_bytes: -128,
      size: "-128 bytes",
      +: 0,
      -: 2
    },
    {
      what: "Timeout",
      size_bytes: 176,
      size: "176 bytes",
      +: 1,
      -: 0
    },
    {
      what: "Timer",
      size_bytes: 32,
      size: "32 bytes",
      +: 1,
      -: 0
    },
    {
      what: "TimersList",
      size_bytes: 72,
      size: "72 bytes",
      +: 1,
      -: 0
    },
    {
      what: "WritableState",
      size_bytes: 224,
      size: "224 bytes",
      +: 1,
      -: 0
    },
    {
      what: "system / Context",
      size_bytes: 56,
      size: "56 bytes",
      +: 3,
      -: 2
    }]
  }
}

从diff的结果看,明显看到

{
  what: "BeikeFang",
  size_bytes: 14112,
  size: "13.78 kb",
  +: 441,
  -: 0
},
{
  what: "BeikeINF",
  size_bytes: 14112,
  size: "13.78 kb",
  +: 441,
  -: 0
},

那我们还可以进行进一步的diff,继续采用这种方式进行选择diff,最终确认内存爆掉的凶手

node中查看GC数据

上面一节中说到了通过添加--trace_gc来查看运行过程中详细的GC数据



  • 我们重点关注这里的红框部分,左边的是通过memwatch.stats函数监听得到的数据(后续会讲),右边的是通过启动参数得到的GC算法。能看到前面说的Mark-sweepscavenge两个算法,针对老生代和新生代。

内存使用

  • 作用域
const foo = function(){     let inner = {}   }

上面的代码foo函数没执行一次,都会生成一个foo的函数作用域,同时foo的局部变量也在函数作用域中,执行结束函数作用域也随之销毁,局部变量亦然。局部变量存活周期很短,会首先分配到新生代的From区域,函数执行结束后,也就被GC掉了。 - 作用域链

const foo1 = function(){     
  const foo2 = function(){       
    let inner_var = 1       
      return (function(){         
        return inner_var       
      }())     
   }     
  foo2()   
}   
foo1()

上面的代码foo1在执行的时候,首先生成foo1Function Scope,然后进入foo2的Function Scope,到里面的闭包中,return inner_var时,闭包的Function Scope里没有inner_var,然后找foo2的Function Scope,找到了其中的局部变量,然后返回。这里的如果foo2里作用域没有inner_var则再向上找,直到global Scope

  • 主动释放变量

根据前面讲到的GC原则,我们在编码的时候要注意主动释放不用的内存变量。全局上的变量是整个APP生命周期可访问,所以这部分的变量会很快放到老生代,所以如果有未使用的或用过后不再使用的变量,及时释放。对于局部变量而言,v8本身的GC就够用了,除非手抖搞成了全局的。释放变量可通过delete或重新赋值。

查看内存数据

// 显示内存
const showMem = function() {
  const mem = process.memoryUsage();
  const format = function(bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
  };
  console.log('Process: heapTotal ' + format(mem.heapTotal) + ' heapUsed ' + format(mem.heapUsed) + ' rss ' + format(mem.rss));
  console.log('---------------------------------------------------------------------------');
};
// 不停分配内存但不释放
const useMem = function() {
  const size = 20 * 1024 * 1024;
  const arr = new Array(size);
  for (let i = 0; i < size; i++) {
    arr[i] = 0;
  }
  return arr;
};
const total = [];
for (let j = 0; j < 15; j++) {
  showMem();
  total.push(useMem());
}
showMem();



第一个红框内:我们看到上面的分配内存只执行了9次,v8内存就爆了,heapTotal 1437.03 MB heapUsed 1352.93 MB rss 1377.70 MB第九次中,申请的heap内存总共1437.03MB,已使用1352.93MB,常驻内存1377.70MB,在第十次分配中就爆了。 第二个红框内:我们看到相关的GC数据,在第九次后尝试GC老生代内存失败。 第三个红框内:v8内存爆掉,进程down,给出了相关的js stack trace,我们可以明确看到useMem就是凶手。

当然这个时候再访问系统也就GG了。

堆外存

在上面我们用Array发现其分配内存是在v8的heap中,Buffer则不会通过v8来分配,是Node自己处理分配的,我们把useMem换成Buffer再看一遍

const useMem = function () {
  const size = 200 * 1024 * 1024;
  const arr = Buffer.alloc(size);
  for (let i = 0; i < size; i++) {
    arr[i] = 0;
  }
  return arr;
};



这里我们执行了20次,执行后有多次的full_gc,还有多次的inc_gc,分配很频繁,前面也说到了,v8的GC原则中,在很频繁触发GC的时候会采用inc_gc也就是增量的,这样保证程序能及时响应我们请求。而且我们看到总堆内存和使用堆内存变化并不大,最大的就是常驻内存变化一直增加,这也就说明Buffer在node环境中不是通过v8的分规则进行分配的,我们在适当的时候也可以用这种方法突破v8的限制,当然我们也可以使用前面说的通过--max-old-space-size参数启动的时候指定。

我们可以看下对应的leak事件中的数据,其中包含事件开始时间和结束时间(NODE_UNIXTIME_V8类型),在五次GC过程中内存增长了(字节),这里只有内存(疑似)泄漏,没有详细的原因说明

{
  start: 2019-06-28T06:06:53.000Z, // 这里目前最新node-memwatch版本把时间注掉了,可以自己打开,然后发一个自己源下的包
  end: 2019-06-28T06:07:05.000Z,
  growth: 584568,
  reason: 'heap growth over 5 consecutive GCs (12s) - 167.25 mb/hr'
}

我们可以按照上面讲的Comparison方式来对比我们泄漏前和泄漏后的堆内存变化,看那些增长明显

不过需要注意:我们在leak事件里不能主动结束HeapDiff()end(),在leak会提前结束,所以我们还是在leak里手动生成heapsnapshot比较靠谱。

编辑于 2019-06-30

文章被以下专栏收录