Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

对于内存释放的例子,感觉不是很好理解 #7

Closed
aogg opened this issue Feb 28, 2017 · 22 comments
Closed

对于内存释放的例子,感觉不是很好理解 #7

aogg opened this issue Feb 28, 2017 · 22 comments
Labels

Comments

@aogg
Copy link

aogg commented Feb 28, 2017

对于内存释放的例子,感觉不是很好理解(每次都是一个例子)。不知道下面理解是否正确?
运行时添加 -expose-gc的node命令行参数。

https://github.com/ElemeFE/node-interview/blob/master/sections/js-basic.md#内存释放

// 错误
var theThing = null;
var replaceThing = function () {
    let 泄漏变量 = theThing;
    let unused = function () {
        if (泄漏变量)
            console.log("hi")
    };
    // 不断修改引用
    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log('a')
        }
    };

    global.gc();
    // 每次输出的值会越来越大
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);

正确修改1

// 正确
var theThing = null;
var replaceThing = function () {
    let unused = function () {
        if (theThing)
            console.log("hi")
    };
    // 不断修改引用
    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log('a')
        }
    };

    global.gc();
    // 每次输出的值会保持不变
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);

正确修改2

// 正确
var theThing = null;
var replaceThing = function () {
    let 泄漏变量 = theThing;
    let unused = function () {
        if (泄漏变量)
            console.log("hi")
    };
    // 不断修改引用
    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log('a')
        }
    };

    泄漏变量 = null;
    // unused = null; // 不行匿名函数依然存在

    global.gc();
    // 每次输出的值会保持不变
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);
@hyj1991
Copy link

hyj1991 commented Mar 1, 2017

这个其实你从这方面理解:在V8当前版本中,闭包对象是当前作用域中的所有内部函数作用域共享的,并且这个当前作用域的闭包对象中除了包含一条指向上一层作用域闭包对象的引用外,其余的存储的变量引用一定是当前作用域中的所有内部函数作用域中使用到的变量

这样就可以解释泄漏的原因了:在 replaceThing 定义的函数作用域中,由于 unused 表达式定义的函数使用到了 泄漏变量,因此 泄漏变量replaceThing 函数作用域的闭包对象持有了,从而导致 theThing 对象中的 someMethod 函数隐式地持有了 泄漏变量 的引用。

这样就造成了 theThing->someMethod->泄漏变量->上一次theThing->... 的循环引用,因此产生了内存泄漏,而且 thThing. longStr 又是一个大字符串,所以每次访问后内存上升明显。

其实这里要解决也比较简单:一种就是你的第二种修改方式,手动在每次调用完成后把 泄漏变量 = null;,另一种是干掉 unused 函数和 泄漏变量 间的闭包引用,这样 someMethodreplaceThing 定义的函数作用域生成的闭包对象中就不会有 泄漏变量了,也就没有内存泄漏了。

@aogg
Copy link
Author

aogg commented Mar 1, 2017

@hyj1991 学到了,没留意到theThing.someMethod函数

根据你上面说的第二种取消必包引用,补充如下:(不知理解对不)

var theThing = '';
var replaceThing = function () {
    let 泄漏变量 = !!theThing;
    let unused = function () {
        if (泄漏变量)
            console.log("hi")
    };

    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log('a')
        }
    };

    global.gc();
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);

但是这样theThing.someMethod方法依然占用两个变量(但不会内存上升),是不是应该每次都要在最后调用

    泄漏变量 = null;
    unused = null;

1、这样看来是不是应该减少匿名函数使用,或有什么更好的方法?
2、如果是面向对象,都用类会不会就好点,将定义外部变量和定义类分开(减少副作用)?

然后不断的学习,又发现一个解决方案:(利用let的块级作用域)

var theThing = '';
var replaceThing = function () {
    {
        let 泄漏变量 = theThing; // 如果是var则依然会内存上升
        let unused = function () {
            if (泄漏变量)
                console.log("hi")
        };
    }

    // 不断修改引用
    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log('a')
        }
    };

    global.gc();
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);

@Lellansin
Copy link
Contributor

Lellansin commented Mar 1, 2017

Hi, 还可以:

let unused = function (内部变量) {
    if (内部变量)
        console.log("hi")
};

这样 immutable 一下貌似也可以了

@hyj1991
Copy link

hyj1991 commented Mar 2, 2017

@aogg ,你的两种修改方式其实和提问的本意有一些区别了。
首先你的第一种方式,其实相当于断掉了 泄漏变量theThing 的引用,所以不会泄露。我当时的意思其实是这样处理:

let unused = function (泄漏变量) {
    if (泄漏变量)
        console.log("hi")
};

这样 unused 表达式和外部的 泄漏变量 的闭包引用切断了,那么同一个作用域下的 someMethod 方法持有的指向 replaceThing 函数作用域的闭包对象中就不会有 泄露变量 了,从而一次interval中的引用链 theThing->someMethod 就结束了,内存不会泄露。

至于你写的第二种处理方式,是因为let的块级作用域,导致整个 replaceThing 中的作用域结构完全变掉了,即:

{
        let 泄漏变量 = theThing; // 如果是var则依然会内存上升
        let unused = function () {
            if (泄漏变量)
                console.log("hi")
        };
    }

这个一整个部分和 someMethod 的函数作用域共享同一个 replaceThing 函数作用域生成的闭包对象了,这里显然 unusedsomeMethod 不会再共享同一个闭包对象了,所以也不会泄露。

@aogg
Copy link
Author

aogg commented Mar 3, 2017

@hyj1991 对,应该通过传参来减少函数对外部变量的依赖。好像js对这种处理方式用的会比较多,再补充个完整的:

var theThing = '';
var replaceThing = function () {
    let 泄漏变量 = theThing;
    let unusedSync = function (泄漏变量) { // 同步执行
        if (泄漏变量)
            console.log("hii")
    };
    unusedSync(泄漏变量);
    
    
    let unused = 泄漏变量 => function () { // 异步执行,就多一层
        if (泄漏变量)
            console.log("hi")
    };
    setTimeout(unused(泄漏变量), 0);

    theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
            console.log('a')
        }
    };


    global.gc();
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);

@gjc9620
Copy link

gjc9620 commented Mar 3, 2017

@hyj1991 hyj1991 也许可以这样? 但是为什么这样可以?

// 错误
var theThing = null;
var replaceThing = function () {
  let 泄漏变量 = theThing;
  let unused = function () {
    if (泄漏变量)
      console.log("hi")
  };
  theThing = theThing ||  {}
  theThing.longStr = new Array(1000000).join('*');
  theThing.someMethod = function () {
    // console.log('a')
  };
  // 不断修改引用
  // theThing = {
  //   // longStr: new Array(1000000).join('*')
  //   // ,
  //   someMethod: function () {
  //     // console.log('a')
  //   }
  // };
  
  global.gc();
  // 每次输出的值会越来越大
  console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);

@hyj1991
Copy link

hyj1991 commented Mar 6, 2017

@gjc9620 你这个问题我觉得可以分两个方面讨论下:

  • 看到的回收一部分是由于:直接被持有无法释放的是 new Array().join("*") 后的字符串,所以 new Array() 得到的临时数组对象是可以被释放的。

  • 回收的另一核心部分:这里就算把 new Array().join("*") 改为 new Array() 后,即直接对每次生成的数组对象直接引用,但是按照你的修改方式后依旧能回收的原因在于你写的 thething = theing || {} ,实际上例子中的泄漏产生的原因是 thething 每次指向一个新生成的对象,并且这个对象中包含一个函数 someMethod 又指向上次循环前的 thething,导致的内存泄漏。而你修改后,只有第一次会生成新对象,后面的 thething 其实一直指向的是自己,那么就相当于每次循环只做了 thething 对象里面属性的重新赋值而已,自然重新赋值后之前的 new Array() 对象或者 new Array().join() 字符串就能被回收了,也就不会产生内存泄漏~

有误之处还请指正

@gjc9620
Copy link

gjc9620 commented Mar 6, 2017

@hyj1991
感谢你的回答

  • 实际上例子中的泄漏产生的原因是 thething 每次指向一个新生成的对象,并且这个对象中包含一个函数 someMethod 又指向上次循环前的 thething,导致的内存泄漏
    someMethod 又指向上次循环前的 thething 怎么理解? 每次都是新的对象好像和前几次的循环没有关系?请指教

  • 从而导致 theThing 对象中的 someMethod 函数隐式地持有了 泄漏变量 的引用。
    为什么someMethod会隐式持有呢?他只是很简单的函数并没有用到任何闭包的变量呀

@gjc9620
Copy link

gjc9620 commented Mar 6, 2017

@hyj1991

  let unused = function () {
    if (泄漏变量)
      console.log("hi")
  };

话说回来这个unused并没有使用 不会被GC清除吗?这个问题似乎很复杂和GC如何执行有关

@hyj1991
Copy link

hyj1991 commented Mar 6, 2017

@gjc9620
第一个问题,本来 theThing 每次tick时指向新对象,那么 theThing 指向的老对象因为没人持有它的引用了应该会被回收,但是这里恰恰因为 thething->新对象->someMethod->泄漏变量->老thething引用->老对象 的链式引用关系 导致老对象永远被持有所以无法释放掉。至于 someMethod 为什么会引用到 泄漏变量 就是你的第二个问题了。

第二个问题:你可以仔细看看我的第一个回答:实际上同一个作用域生成的闭包对象是被该作用域中所有下一级作用域共同持有的,正是因为 unused 使用到了 泄漏变量,所以导致 replaceThing这一级的函数作用域中的闭包对象包含了 泄漏变量 ,而 theThing 持有了 someMethodsomeMethod 定义的函数作用域由持有上述的闭包对象,所以虽然在 someMethod 中 根本没有使用到 泄漏变量,也会隐式地持有。

@hyj1991
Copy link

hyj1991 commented Mar 6, 2017

@gjc9620
至于你说的和GC机制有关,怎么回收肯定是GC机制,但是这里 泄漏变量 无法释放其实也是因为有对象一直在引用它,所以GC在这个问题上没有什么特殊处理的部分

@aogg
Copy link
Author

aogg commented Mar 6, 2017

@hyj1991 其实 隐式地持有变量显式地持有变量 会导致什么或者说有什么区别?

这个内存泄漏问题是因为同时存在两个函数与一个不断变化引用的新对象变量,其中两个函数一个显式持有一个隐式持有(满足这三个条件就会导致内存泄漏)。
而上面所有解决方案都是为了减少这个三个条件中的一个。假设新变量的逻辑不改,那就只有从两个函数入手,可为什么去掉unused函数的 显式持有变量 就可以解决内存泄漏这个问题呢?它还不是在 隐式地持有变量 吗?
难道当没有显式地持有变量时变量就会被回收吗?这种文档哪里有?

@gjc9620
Copy link

gjc9620 commented Mar 6, 2017

@hyj1991
请问隐式地持有变量 有相关引用或者代码吗 想了解一下相关

@hyj1991
Copy link

hyj1991 commented Mar 6, 2017

隐式还是显式只是一个说法罢了,实际上就是js闭包对象除了保持一条指向上一级作用域闭包对象的引用外,还会包含所有下面作一级域使用到的本作用域定义的变量,看这段吧:

In V8, once there is any closure in the context, the context will be attached to every function, even for those who don’t reference the context at all

@gjc9620
Copy link

gjc9620 commented Mar 6, 2017

@hyj1991 谢谢 请问文章出处?

@Lellansin
Copy link
Contributor

Feel free to reopen.

@zwmmm
Copy link

zwmmm commented Jul 5, 2019

var replaceThing = function () {
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 1000);

image
这是为什么?

@Lellansin
Copy link
Contributor

var replaceThing = function () {
    console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 1000);

image
这是为什么?

观察的频率太低了,你把 1000 改成 10 试试。

@zwmmm
Copy link

zwmmm commented Jul 8, 2019

@Lellansin 和频率有什么关系?难道内存会自己慢慢增长?

@hsiaosiyuan0
Copy link

hsiaosiyuan0 commented Aug 6, 2019

如果 v8 中的实现细节真的如 @hyj1991 大佬说的那样,那我觉得把这个当做是 v8 的缺陷来说比较好。

首先闭包的基本概念是:

  1. 变量可以被它作用域下的闭包所捕获:
function f() {
  let a = 1
  return function () { console.log(a) }
}

上面代码中 return 语句在创建函数对象的时候,会将 a 捕获到与之关联的闭包中,不过此时闭包尚未将 a 拷贝到内部,当 a 离开自己的作用域时,即函数调用结束时,会将 a 拷贝到闭包中。

  1. 变量在被作用域下的多个闭包所同时捕获的时候,以共享的形式存在于各个闭包中,也就是被捕获变量有能够被共享的特性:
function f() {
  let a = 1
  return [
    function () { a++ },
    function () { console.log(a) }
  ]
}
const [f1, f2] = f();
f1();
f2();

上面代码中 return 语句创建了两个函数,当然也创建了与两个函数各自相关联的闭包。a 将会以一个被这两个闭包所共享的形式存在。

说回 JS 中引入闭包的目的,就是为了让作为一等公民的函数、也能继续沿用词法作用域。在此之后衍生出来的比如模拟私有变量之类,都只能算作是用途。

  1. 闭包的生命周期,与那个与之关联的函数是一样的

这是因为闭包被创建了之后,除了与之关联的函数,再没有路径可以访问到它。所以只有函数对象被标记为垃圾对象,与之关联的闭包才会变为垃圾对象。当然并不是函数对象成了垃圾之后,闭包中的内容就都成了垃圾,考虑被捕获的变量具有共享的特性,只有当共享它的所有闭包都成了垃圾时,那个被捕获的共享对象才会成为垃圾。

回到例子中的代码:

var theThing = null
var replaceThing = function () {
  var originalThing = theThing
  var unused = function () {  // closure1
    if (originalThing)
      console.log("hi")
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () { // closure2
      console.log('a')
    }
  };
};
setInterval(replaceThing, 1000)

按照我上面的描述,这里应该有两个闭包,分别是 closure1closure2closure1 会捕获栈上的 originalThingclosure2 并没有捕获栈上的内容。

如果是分开的两个闭包,那么很明显并不会导致内存泄露,因为 closure1 会因为 unused 的释放而被释放。

而 v8 的实现中,将 closure1closure2 合二为一,这样就导致原本 closure2 根本没有想捕获的变量被意外的捕获了。

从技术来说,即使合二为一了,也应该能够做到 unused 释放时,把 originalThing 也给释放了,因为是词法作用域,所以引擎在解析阶段就能够知道 someMethod 完全不会用到 originalThing。但是目前 v8 的实现还做不到这点,所以在不发生内存泄露的情况下,「合N为1」是相安无事的,因为对于 someMethod 而言反正它不想也不能用到 originalThing。可一旦发生了存在泄露的操作,调试起来还是得消磨不少时间,而且这个泄露有引擎实现的一半的功劳在里面。

为了再验证一下其他语言或者引擎对闭包的实现,我分别尝试了 CLua 和 quickjs

同样闭包的概念在 lua 中也有,CLua 的实现不会导致内存泄露:

function createArray(size)
  local a = {}
  for i=1,size do
    a[i] = 0
  end
  return a
end


local theThing = nil
local replaceThing = function ()
  local originalThing = theThing
  local unused = function ()
    if originalThing then
      print('hi');
    end
  end
  theThing = {
    longStuff = createArray(1 << 20);
    someMethod = function ()
      print('a')
    end
  }
end

while(true) do
  replaceThing()
end

在最新的 quickjs 中执行等价代码,也不会有内存泄露:

var theThing = null;
var replaceThing = function () {
  var a = theThing;
  var unused = function () {
    if (a)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1 << 20).join('*'),
    someMethod: function () {
      console.log('a');
    }
  };
};

while(true) {
  replaceThing();
}

有错误的话还请指正。

@guohaoyun
Copy link

如果 v8 中的实现细节真的如 @hyj1991 大佬说的那样,那我觉得把这个当做是 v8 的缺陷来说比较好。

我用Node8版本测试有内存泄露问题,但是Node12没有。可能确实是当时版本的一个缺陷,有人能找到相关更新修复说明吗。

@shanzhangf123
Copy link

shanzhangf123 commented Sep 21, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

9 participants