JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结

时间:2021-05-21 05:53:00

俗话说,自己写的代码,6个月后也是别人的代码……复习!复习!复习!涉及到的知识点总结如下:

  • 堆栈是栈
  • JVM栈和本地方法栈划分
  • Java中的堆,栈和c/c++中的堆,栈
  • 数据结构层面的堆,栈
  • os层面的堆,栈
  • JVM的堆,栈和os如何对应
  • 为啥方法的调用需要栈

  属于月经问题了,正好碰上有人问我这类比较基础的知识,无奈我自觉回答不是有效果,现在深入浅出的总结下:

前一篇文章总结了:JVM 的内存主要分为3个分区

  • 堆区(Heap)-- 只存对象(数组)本身(引用类型的数据),不存基本类型和对象的引用。JVM只有一个堆区,这个“堆”是动态内存分配意义上的堆——用于管理动态生命周期的内存区域。JVM的堆被同一个JVM实例中的所有Java线程共享,它通常由某种自动内存管理机制所管理,这种机制通常叫做“垃圾回收”(garbage collection,GC)。JVM规范并不强制要求JVM实现采用哪种GC算法。
  • 栈区(Stack)-- 栈中只保存基础数据类型的对象和对象引用。每个线程一个栈区,每个栈区中的数据都是私有的,其他栈不能访问。栈内有帧(方法调用会生成栈帧)分三个部分:基本类型变量区,执行环境上下文,操作指令区。
  • 方法区 -- 又叫静态区,跟堆一样,被所有线程共享。方法区包含所有的class和static变量。方法区包含的都是在整个程序中永远唯一的元素。如:class,satic。

  堆栈是啥?是堆还是栈?

  之前初学c++的时候被人误导过,说堆栈是堆……其实这个是翻译的误读,堆栈,其实应该翻译成栈更合适,和堆区分开来,因为英文的stack就是堆栈的意思, 位于RAM(Random Access Memory,随机访问存储区),速度仅次于寄存器。存放基本变量和引用,存在栈中的数据可以共享。但是,栈中的数据大小和生存周期必须确定,这是栈的缺点

  堆栈不是堆,是栈。堆是存放了所有的java对象(逃逸分析除外)。


  本地方法栈和JVM栈是如何划分的?
  JVM规范写到:每个Java线程拥有自己的独立的JVM栈,也就是Java方法的调用栈。同时JVM规范为了允许native代码可以调用Java代码,以及允许Java代码调用native方法,还规定每个Java线程拥有自己的独立的native方法栈。都是JVM规范所规定的概念上的东西,并不是说具体的JVM实现真的要给每个Java线程开两个独立的栈。以Oracle JDK / OpenJDK的HotSpot VM为例,它使用所谓的“mixed stack”——在同一个调用栈里存放Java方法的栈帧与native方法的栈帧,所以每个Java线程其实只有一个调用栈,融合了JVM规范的JVM栈与native方法栈这俩概念。如之前文章1的结构图:
 
JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结
  

  数据结构层面的堆和栈
  数据结构里面。

stack,中文翻译为堆栈,其实指的是栈,这里讲的是数据结构的栈,不是内存分配里面的堆和栈。栈是先进后出的数据的结构,好比你碟子一个一个堆起来,最后放的那个是堆在最上面的。

栈数据结构比较简单。heap翻译为堆,是

一种有序的树。


 
  JVM的堆,栈和c、c++的堆、栈一样么?
  回答这个问题之前,先得回答程序运行时的内存分配策略,编译原理的理论认为:程序运行的内存分配有三个策略:
 
  • 静态存储分配:在编译时就能确定每个数据目标在运行时刻的存储空间需求,因而在编译时就可以给他们分配固定的内存空间。这种分配策略要求程序代码中不允许有可变数据结构(如可变数组),也不允许有嵌套或者递归的结构出现。因为它们都会导致编译程序无法计算准确的存储空间需求。
  • 栈式存储分配:也叫动态存储分配,和静态存储分配相反,栈就是暂时!在栈式存储方案中,存储的都是局部变量,临时变量,比如基本数据类型,对象引用……从内存的分配角度来看,因为存储的是基本的数据类型,编译器事先已经知道了类型的大小,故直接可以进行有效的内存分配,比如int,计算机是知道其范围的,所以直接由系统分配在栈中,无需程序自己去申请xxx内存!而引用类型,比如自己定义一个类,很明显这个类是不知道大小的,应该有程序自己来申请内存空间,所以由堆来分配!栈分配模式规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小,才能够为其分配内存。和我们在数据结构所熟知的栈一样,栈式存储分配按照先进后出的原则进行分配。
  • 堆式存储分配:则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。

  因此我们断定:堆主要用来存放对象,栈主要是用来执行程序的。而这种不同又主要是由于堆和栈的特点决定的: 例如C/C++……所有的方法调用都是通过栈来进行的,所有的局部变量,形式参数都是从栈中分配内存空间,就好像工厂中的传送(conveyor belt)带一样,Stack Top Pointer 会自动指引你放东西的位置,你所要做的只是把东西放下来就行。退出函数的时候,修改栈顶指针就可以把栈中的内容销毁。这样的模式速度最快,当然要用来运行程序了。

  现在言归正传,之前的文章1已经总结了——JVM是基于堆栈的虚拟机。每一个JVM实例都为每个新创建的线程分配一个栈,而多个线程共享唯一一个堆区,也就是说,对于一个Java程序来说,它的运行就是通过对栈的操作来完成的。栈以帧为单位保存线程的状态。JVM对栈只进行两种操作:以帧为单位的压栈和出栈操作。当某个线程正在执行某个方法时,我们就称此线程为当前方法,而当前方法使用的帧称为当前帧。当线程要调用一个Java方法时,JVM就会先在线程的Java栈里新压入一个帧。这个帧自然成为了当前帧。在此方法执行期间,这个帧将用来保存方法的形参,局部变量,中间计算过程和其他数据……这个帧在这里和编译原理中的活动纪录的概念是差不多的。

  好了,罗嗦了半天,从这个栈式分配机制来看,栈可以这样理解:栈(Stack)是os在建立某个进程或者线程(在支持多线程的操作系统中是线程)时,为这个(进程)线程建立的存储区域,该区域具有先进后出的特性。栈中的新加数据项放在其他数据的顶部,移除时你也只能移除最顶部的数据(不能越位获取)。类似这个纸:

JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结

  再说堆,每一个JVM的实例有且只有一个堆,这个唯一的堆被全局的线程共享!程序在运行中所创建的所有类实例或数组都放堆中,并由应用所有的线程共享。堆中的数据项位置没有固定的顺序,你可以以任何顺序插入和删除,因为他们没有“顶部”数据这一概念。如图:

JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结

跟C/C++不同,Java中分配堆内存是自动化管理的(Java虚拟机的自动垃圾回收器来管理,缺点是,由于要在运行时动态分配内存,存取速度较慢)Java中所有对象的存储空间都是在堆中分配,但对象引用是在栈中分配,而堆中分配的内存才是实际的这个对象本身,栈中分配的内存只是一个指向这个对象的指针(引用)变量而已(变量的取值等于数组或对象在堆内存中的首地址)。而c++的堆内存管理,需要程序员手动管理的,new,delete运算符……


  内存管理中的栈分配方法有什么特点?优缺点又是什么?

  首先想到就是该快内存FILO的特性,还有经过前面这么罗嗦的哔哔,又得出一个结论:栈中的数据可以共享

  int a = 3;
  int b = 3;

  编译器先处理int a = 3; 会在栈中创建一个变量为a的引用,然后查找栈中是否有3这个值,如果没找到,就将3存放进来,然后将a指向3。接着处理int b = 3; 在创建完b的引用变量后,因为在栈中已经有3这个值,便将b直接指向3。这样,就出现了a与b同时均指向3的情况。这时,如果再令a=4; 那么编译器会重新搜索栈中是否有4值,如果没有,则将4存放进来,并令a指向4; 如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。要注意这种数据的共享与两个对象的引用同时指向一个对象的这种共享是不同的,因为这种情况a的修改并不会影响到b, 它是由编译器完成的,它有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量。

  优点:速度快,不用管理内存,缺点是太小,方法调用过度,容易内存溢出,还有栈就是暂时,数据有生命周期,属于临时存储。


  站在实际的计算机物理内存的角度上看,栈和堆在哪儿?   

  1. 在通常情况下由操作系统(OS)和语言的运行时(runtime)控制吗?

  2. 它们的作用范围是什么?

  3. 它们的大小由什么决定?

  4. 哪个更快?

  5. JVM的栈如何对应os?

  回答这个问题之前,必须先知道内存管理的机制根据不同的编译器和处理器架构的不同而不同!为了帮助理解,先总结几个原理:

  什么是局部性原理?

  os的教科书这样写到局部性原理:CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

  我是这样理解的:计算机的存储系统从小到大,分为寄存器,一级缓存,二级缓存,三级缓存,内存,磁盘……而寄存器是CPU存放计算数据的地方,CPU要工作了,需要数据或者地址,先从一级缓存里面找,找不到就从二级缓存里面找,二级找不到就去三级找……假如找到磁盘才有了目标数据,那么该数据就会先放入内存,再存入三级缓存、二级缓存、一级缓存,最后存入寄存器,才能被CPU使用。可以说,一级缓存是寄存器的缓存,二级缓存是一级缓存的缓存,三级缓存是二级缓存的缓存……下面一层是上面一层的缓存。而局部性原理,通俗的说就是因为CPU的运转速度非常非常快!是高速存储的!而磁盘和内存之间的存取速度很慢(I/O瓶颈绕不开……),如果CPU需要的数据更多的在磁盘,内存……这样会花非常多的等待时间,故我们就设置了高速缓存!当CPU频繁的用了某块数据,计算机会遇见性的把它及其附近地址上的数据都存入高速缓存内,因为预判这些数据再次被用到的可能性很大,计算机就把它们存到越接近寄存器的层次,也就是cpu所访问的数据,都趋于集中在一个较小的连续区域中,这也才是缓存的真正意义。那么,现在的问题就变为回答:

  计算机怎样才能判断一个数据接下来可能被用到?

  • 时间局部性:如果一个数据正在被访问,那么在近期它很可能还会被再次访问。这当然是正确的,用过的数据当然可能再次被用到。
  • 空间局部性:在最近的将来将用到的信息很可能与现在正在使用的信息在空间地址上是临近的,正在使用的这个数据地址旁边的数据,当然也是很可能被用到的。比如数组什么的……

  哦了。前面几个问题已经得出这样的结论:栈和堆都是用来从底层操作系统中获取内存的。在多线程环境下每一个线程都可以有他自己完全的独立的栈,但是他们共享堆。并行存取被堆控制而不是栈。

  • 堆:包含一个链表来维护已用和空闲的内存块

JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结  

在堆上新分配(用 new 或者 malloc)内存是从空闲的内存块中找到一些满足要求的合适块。这个操作会更新堆中的块链表。这些元信息也存储在堆上,经常在每个块的头部一个很小区域。堆增加新块通常从低地址向高地址扩展,也就是说堆是向上增长的!因此可以认为堆随着内存分配而不断的增加大小。如果申请的内存大小很小的话,通常从底层操作系统中得到比申请大小要多的内存。申请和释放许多小的块可能会产生如下状态:在已用块之间存在很多小的浪费的空闲块……进而导致申请大块内存失败,虽然空闲块的总和足够,但是空闲的小块是零散的,不能满足申请的大小,这叫做“内存碎片”。当旁边有空闲块的已用块被释放时,新的空闲块可能会与相邻的空闲块合并为一个大的空闲块,这样可以有效的减少“碎片”的产生。

  堆的管理依赖于运行时环境,C 使用 malloc ,free,C++ 使用 new 和delete,但是很多语言有垃圾回收机制,比如Java的GC。

  • 栈:栈经常与 sp 寄存器一起工作,最初 sp 指向栈顶(栈的高地址)。栈是向下增长的!

JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结

CPU 用 push 指令来将数据压栈,用 pop 指令来弹栈。当用 push 压栈时,sp 值减少(向低地址扩展)。当用 pop 弹栈时,sp 值增大。存储和获取数据都是 CPU 寄存器的值

    • 当函数被调用时,CPU使用特定的指令把当前的 IP 压栈,接下来将调用函数的地址赋给 IP,让cpu去调用函数。当函数返回时,旧的 IP 被弹栈,CPU 继续去函数调用之前的代码。
    • 当进入函数时,sp 向下扩展,扩展到确保为函数的局部变量留足够大小的空间。如果函数中有一个 32-bit 的局部变量会在栈中留够四字节的空间。当函数返回时,sp 通过返回原来的位置来释放空间。
      • 如果函数有参数的话,在函数调用之前,会将参数压栈。函数中的代码通过 sp 的当前位置来定位参数并访问它们。
      • 函数嵌套调用,每一次新调用的函数都会分配函数参数,返回值地址、局部变量空间、嵌套调用的活动记录都要被压入栈中。函数返回时,按照正确方式的撤销。

  栈要受到内存块的限制,不断的函数嵌套……为局部变量分配太多的空间,可能会导致栈溢出。当栈中的内存区域都已经被使用完之后继续向下写(低地址),会触发一个 CPU 异常。这个异常接下会通过语言的运行时转成各种类型的栈溢出异常。总的来说,栈以更低层次的特性与处理器架构紧密的结合到一起,当堆不够时可以扩展空间。但是,扩展栈通常来说是不可能的,因为在栈溢出的时候,执行线程就被操作系统关闭了,这已经太晚了。

  现在可以回答这几个问题:

  在通常情况下由操作系统(OS)和语言的运行时(runtime)控制吗?

  如前所述,堆和栈是一个统称,可以有很多的实现方式。计算机程序通常有一个栈叫做调用栈,用来存储当前函数调用相关的信息(比如:主调函数的地址,局部变量),因为函数调用之后需要返回给主调函数。栈通过扩展和收缩来承载信息。实际上,程序不是由运行时来控制的,它由编程语言、操作系统甚至是系统架构来决定。堆是在任何内存中动态和随机分配的(内存的)统称;也就是无序的。内存通常由操作系统分配,通过应用程序调用 API 接口去实现分配。在管理动态分配内存上会有一些额外的开销,不过这由操作系统来处理。

  它们的作用范围是什么?

  调用栈是一个低层次的概念,就程序而言,它和“作用范围”没什么关系。就高级语言而言,语言有它自己的范围规则。一旦函数返回,函数中的局部变量会直接释放。在堆中,也很难去定义。作用范围是由操作系统限定的,但是编程语言可能增加它自己的一些规则,去限定堆在应用程序中的范围。体系架构和操作系统是使用虚拟地址的,然后由处理器翻译到实际的物理地址中,还有页面错误等等。它们记录那个页面属于那个应用程序。不过你不用关心这些,因为你仅仅在编程语言中分配和释放内存,和一些错误检查(出现分配失败和释放失败的原因)。

  它们的大小由什么决定?

  依赖于语言,编译器,操作系统和架构。栈通常提前分配好了,因为栈必须是连续的内存块。语言的编译器或者操作系统决定它的大小。不要在栈上存储大块数据,这样可以保证有足够的空间不会溢出,除非出现了无限递归的情况或者其它。堆是任何可以动态分配的内存的统称。它的大小是变动的。在现代处理器中和操作系统的工作方式是高度抽象的,因此你在正常情况下不需要担心它实际的大小,除非你必须要使用你还没有分配的内存或者已经释放了的内存。

  哪个更快一些?

  栈更快因为所有的空闲内存都是连续的,因此不需要对空闲内存块通过列表来维护。只是一个简单的指向当前栈顶的指针。编译器通常用一个专门的、快速的寄存器SP来实现。更重要的一点是,随后的栈上操作会遵循局部性原理。

  JVM的栈如何对应os?

  以linux 中一个进程的虚拟内存分布为例:

JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结

图中0号地址在最下边,越往上内存地址越大。以32位操作系统为例,一个进程可拥有的虚拟内存地址范围为0-2^32。分为两部分,一部分留给kernel使用(kernel virtual memory),剩下的是进程本身使用, 即图中的process virtual memory。普通Java 程序使用的就是process virtual memory。上图中最顶端的一部分内存叫做user stack. 这就是栈stack,32位的栈顶指针寄存器是esp,中间有 runtime heap。就是堆,注意他们和数据结构里的stack 和 heap 不是一回事。前面总结了,stack 是向下生长的,heap是向上生长的。当程序进行函数调用时,每个函数都在stack上有一个 call frame(帧)。
  

  小结,总结了那么多,现在最后一个问题:为啥方法的调用需要栈
  其实确切的说:并不是方法的调用需要用栈来实现,而是它设计成用栈实现!我们知道,各个方法的活动记录(即局部或者自动变量)被分配在栈上, 这样做不但存储了这些变量,而且可以用来嵌套方法的追踪。因为我们经过观察可以知道,方法的调用过程是这样的:
  1,计算参数,传参
  2,保存方法的返回地址
  3,控制转移至callee
  4,保存必要的caller现场
  以上一些步骤之间的顺序是可变的,但理论上并没有哪个步骤是必须用栈来实现的。理论上如果有很多寄存器,我们完全可以抛弃栈,然而实际上我们并没有,所以从现实的角度来说,栈是一个适合的实现方法,简单说就是方法调用的局部数据的存活时间满足“先进后出(FILO)”的顺序,之所以用栈来记录是因为栈的基本操作正好就是支持这种顺序的访问。而堆是无法实现的。

 文章总结参考资料:《Java编程思想》、《现代操作系统》,《深入理解计算机系统》、《现代编译原理》,《深入理解Java虚拟机》、《JVM规范 7》、知乎、*……

欢迎关注

dashuai的博客是终身学习践行者,大厂程序员,且专注于工作经验、学习笔记的分享和日常吐槽,包括但不限于互联网行业,附带分享一些PDF电子书,资料,帮忙内推,欢迎拍砖!

JVM学习(2)——技术文章里常说的堆,栈,堆栈到底是什么,从os的角度总结