Skip to content

Instantly share code, notes, and snippets.

@o2njxa05jsa
Last active January 24, 2019 07:50
Show Gist options
  • Save o2njxa05jsa/9eb5366c25331204c1f96d7227d16893 to your computer and use it in GitHub Desktop.
Save o2njxa05jsa/9eb5366c25331204c1f96d7227d16893 to your computer and use it in GitHub Desktop.

堆和栈和所谓"xx在栈上xx在堆上"

"基本类型分配在栈上,复杂类型(对象)分配在堆上,栈上保存指针"之类的说法相当的流行。然而在阐述的过程中这些文章往往缺乏必要的论据,对于"xx分配在xx"往往是直接给出结论,其正确性令人怀疑。

出处

诸如[这篇文章]的描述

基本类型 (Undefined、Null、Boolean、Number和String)
基本类型在内存中占据空间小、大小固定 ,他们的值保存在栈(stack)空间,是按值来访问
引用类型 (对象、数组、函数)
引用类型占据空间大、大小不固定, 栈内存中存放地址指向堆(heap)内存中的对象。是按引用访问的

是本文要讨论的谬误的一个典型实例。然而笔者并没有查到最早是谁传出来的。鉴于这种说法听起来很java,在这里暂且当做有人从java套用过来的。

错误之处

首先一个常识是,一个语言的规范(例如[ecma-262])通常是不会指定数据如何储存的。一个语言规范应当关心的是生存期这种较为通用的抽象,而关于生存期的规定具体如何实现则属于实现细节。在保证语义的情况下,一个变量究竟存在栈里还是堆里,还是根本没出过寄存器甚至直接被优化掉了,都是可以被允许的。在这一点上,"xx在栈上xx在堆上"的观点就已经完全站不住脚了。
其次,字符串绝对不会保存在栈上,即使javascript真的定义了堆和栈,以及某些类型的变量必须存在什么地方。不然弄一个1g大的字符串分分钟爆栈。。
再次,大家都知道闭包可以延长一个变量的生存期。那被分配在栈上的变量的生存期的延长到底是怎么实现的呢?阻止栈的回溯?临时起意这个变量移到堆上?听着就不靠谱。。

为什么会有这样的错误观念

首先要弄清楚一点,这种观点是否来自java?
虽然笔者不怎么会java并且不太喜欢java,但是这好像真的怪不到java头上,虽然有相当多的东西都该怪java。java没有这种规定,并且[这篇文档]提到hotspot通过某种分析,在确定一个对象有的生存期的情况下可以将其放在栈上来提高性能。总之,这次不怪java。。吗?[这个文章]看起来怎么这么眼熟呢?[这个据说一篇就够的文章]好像也挺类似。果然还是java用户的锅。相对的[stackoverflow的回答]就负责任且靠谱的多。所以说学好英语真的相当重要。

正确的答案是?

it depends!

既然标准没有规定,那当然是怎么快怎么来。实际上主流实现会相当智能地根据实际情况选择一个较优的做法。[这个][这个]都谈到了这点。这种情况下想要预测变量最终被放在哪是相当困难的。大家都知道v8会直接生成机器码并进行优化,而所谓优化基本上就是只要符合可观测的行为一致,想怎么搞怎么搞(比如[as-if rule])。举例来说,

function foo(a){
    for(let i=0;i<1000000000;++i){
        a+=i;
    }
    return a;
}

上述代码照理是要消耗海量内存的,具体原因笔者懒得再从头写一遍,可以参考[这里],总之就是按理说上面的代码会直接爆炸,实际并没有,原因是上面的代码并没有哪里保存i的引用,只是对i进行了求值,所以省略了复杂的创建新的环境的步骤,接近c++的

int foo(int a){
    for(int i=0;i<1000000000;++i){
        a+=i;
    }
    return a;
}

所表达的简单的语义(当然c艹没准直接优化成return a+499999999500000000了)。

结论

不需要关心什么东西放在哪。反正即使关心了也做不了什么。而且比起这些琐事,还有更多更值得关心的东西,比如可读性,比如抽象是否合理,是否易于修改或者维护等等。即使真的要从代码的角度优化性能,多知道些通用的常识(比如看看那本 深入理解计算机系统 )可能反而更有用。当然,有问题尽量上stackoverflow而不是各种抄来抄去的论坛也不失为一个好习惯。  
   
   
   
   
   
 

但是并没有结束

基本类型 (Undefined、Null、Boolean、Number和String)
基本类型在内存中占据空间小、大小固定 ,他们的值保存在栈(stack)空间,是按值来访问
引用类型 (对象、数组、函数)
引用类型占据空间大、大小不固定, 栈内存中存放地址指向堆(heap)内存中的对象。是按引用访问的

还有一些问题没能说清楚
虽然这个按值访问和按引用访问最初可能也是来自于java用户,但这不是重点。重点是javascript中基本类型也都是按引用访问的。只有函数传参的时候基本类型会被求值并且赋值给形参。在这之中string类型又比较特殊,因为string是不可变的,对string的修改会导致重新创建一个字符串,所以虽然string实际上肯定是传引用或者是传个header(没道理没事把字符串拷贝来拷贝去),但是实际效果和传值看起来一样。 看一段大家都熟的不能再熟的代码

var arr=[];
for(var i=0;i<10;++i){
    arr.push(function(){
        console.log(i);
    });
}
for(var j=0;j<10;++j){
    arr[j]();
}

上面这段代码会打印10个10而非0到9。其原因是每个匿名函数都捕获到了同一个i,当然是按引用捕获的。javascript没有,并且笔者个人认为十分缺乏一个按值捕获的语义。实际上对于上面的情况,用户(指javascript的)想要的是这样的一个feature,可以定义一个函数,在它被pusharr的时候对i进行求值,然后在函数体内用i来访问该次求值时i的值而非通过引用访问i。下面这种看起来很奇葩的方式反而是最直接的,虽然效率应该挺捉鸡

var arr=[];
for(var i=0;i<10;++i){
    arr.push(Function(`
        console.log(`+i+`);
    `);
}

在这点上c++就做的不错,c++的lambda表达式可以指明按引用还是按值捕获一个"变量",当然按引用捕获的情况下要承担没写好导致原地爆炸的风险。

let在for语句的特殊语义在某种程度上缓解了这个问题,当然是以非常丑陋的形式。考虑到实现起来很麻烦以及let凑活着也能用,这种按值捕获的feature可能永远也不会有。

然而这一切的始作俑者(无辜风评被害的)java完全没有这个问题。java的基本类型是值语义的,java也有lambda表达式并且也能捕获,但是java对local variable的捕获仅限于final和虽然不是final实际上没变过也能当成final的变量,从根源上抹杀了造成这种混乱的可能性。想要按引用捕获?自己new一个大小为1的数组去,反正数组都是引用语义,就显得很统一很和谐。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment