写点什么

《数据结构》之栈和堆结构及 JVM 简析

作者:EquatorCoco
  • 2023-06-28
    福建
  • 本文字数:5333 字

    阅读完需:约 17 分钟

导言:


在数据结构中,我们第一了解到了栈或堆栈,它的结构特点是什么呢?先进后出,它的特点有什么用呢?我们在哪里可以使用到栈结构,栈结构那么简单,使用这么久了为什么不用其它结构替代?


一.程序在内存中的分布


作为一个程序猿,我们应该会常常跟代码打交道,那么我们所编写的程序或代码,是怎么跑起来的,操作系统怎么调用的,怎么划分的我们可以使用一个简单的图来了解一下:



在图片中,把我们的内存一共分为了两个部分,一个是操作系统的内核区,另外一个就是用户区

对于操作系统: 操作系统(英语:Operating System,缩写:OS)是管理计算机硬件与软件资源的系统软件,同时也是计算机系统的内核与基石。操作系统需要处理如管理与配置内存、决定系统资源供需的优先次序、控制输入与输出设备、操作网络与管理文件系统等基本事务。操作系统也提供一个让用户与系统交互的操作界面。


操作系统内核要负责:程序调用,驱动调用,内存管理和分配,有兴趣可以学学操作系统,我们这里主要是看看用户内存区。


用户内存区主要是用户对计算机发出的指令,计算机要做出相应的操作,用户区也是我们最大化操作计算机的地方,启动一个 APP,打开网站,鼠标单击,Linux 中的一次 dir 指令等等,都是我们人,用户在进行操作和管理。


我们写的程序或代码也是运行在用户空间上的程序在用户空间上加载出来六个部分:


  • 代码段

  • 只读数据段

  • 初始化数据段

  • 未初始化数据段

  • heap 堆

  • stack 栈


代码段:就是我们写的源生的一些语言指令,在操作系统也可以叫做临界区,他在运行是不可改变的,又是纯的


只读数据段:就是我们在程序中写的一些常量,比如 c 或 Java 中使用 const 修饰的变量,它们是全局唯一且不可变的,包括语言本省有一些自定义的宏都是不可变的


初始化和未初始化的数据段:都是一些申请的全局变量,有些是未初始化的,有些是初始化的,在程序运行的时候就会被调用,都是数据段范围


heap 堆:这就是我们的重点了,它的作用是动态申请空间的程序存放的地方,比如 Java 中我们 new 了一个对象,对象都存放在堆中的,还有 c 语言中 malloc 函数,它们所申请的东西都是存放在堆中的


stack 栈:这是程序运行时被调用最频繁的了,它的结构很适合存储不长时间存在的变量,以及断点记录,每当一个函数执行完,它就会释放此次函数所产生的空间


补充:


我们仔细去看看图片就会发现,堆空间是向上扩张的,栈空间是向下扩张的,除了这两个空间,其它的空间都是静态资源,即在编译时分配的空间在执行的时候所需的空间还是那么大


栈空间是紧贴内核区的,因为栈在程序运行时本身就需要大量的操作,即使在内存中,把数据送到 cpu 或内核都是要开销的,所以为了使计算机可以快起来,栈作为操作多的结构就被选择紧贴内核


堆中存储的数据结构普遍都很大,包括对象这些操作集合,它们两者都向中间靠,会不会相遇呢?其实中间的数据区空间是很大的,绝大多数情况不会


栈中存储的都是值类型,指针也是值类型

二.JVM 中的内存模型

内存模型分析



 在 JVM 中,


栈空间,负责的就是存放着函数调用产生的局部变量,每当一个函数被调用它所产生的内存开销都会在栈中,随着函数的结束会被销毁,但是我们知道,在函数执行的时候会有对象的创建,对象大多数情况都是存在堆空间的,


那么函数怎么执行对象呢?其实它会在栈中存放一个指针,用于指向某个对象,在栈中存在的是一个链接地址


堆空间是共有的,也就是一个线程创建出来的对象,另外一个对象可以继续调用,对空间的执行结构就比栈更加复杂,它伴随着内存清理,堆空间的对象一旦不使用了就会被 jvm 的 GC 机制给清理掉,它和 C 语言的 free()释放空间一样,都是对堆空间进行操作,由于栈空间会被系统接管,所以很少有对栈空间的操作


本地方法栈,其实和 Java 语言没什么关系,它是用于其它语言和 Java 兼容的,我们导入了其它语言的方法,就会被存在这里,包括 C++等,jvm 就是 c++写出来的,它交由 jvm 执行而专门设置的一个栈


程序计数器,就和它的名字一样,它是用来记录程序执行到那个位置的,它的所占空间需求很小,它会对栈空间的函数开始点和结束点进行记录


方法区,它是具体的实现,也是我们自定义的一些静态方法存储的地方,与之对应还有一个元空间的东西,它和方法区的区别就是,方法区注重编写接口,而元空间注重实现这些接口,在 jdk1.8 以前元空间是在堆空间的一块区域,1.8 以后就把它从堆空间中独立出来了


栈空间(线程私有)



 我们可以看到一次函数执行中,从括号开始,就会为传进来的参数创建空间,如果函数内部有单独声明的变量,在这时候也会去申请空间


函数的计算阶段,没有额外的内存开销,如果对已有的变量进行赋值,也是在开始创建的空间上完成覆盖,这里 a = 11 进行赋值,其实这个 a 去覆盖了刚开始传进来空间开辟的 a =10,所以没有额外的空间开销

当遇到调用函数的结束大括号 } 时,就会结束函数,相应的在栈空间内就会删除相应的在函数运行时开辟的空间,所以一次调用完成以后,就像最左边一样,栈内的空间就仅有主函数了


我们可以看看函数执行的结果:



 我们在 fun 函数不是把值已经改变成 11 了嘛,为什么主函数打印最后执行还是输出自己定义的 a = 10 呢?

这就是栈空间的特点,基于这个特点,我们可以很容易就实现了局部变量和全局变量,我们来细细的品一下这段代码内存的分布:



 从这个实例中我们可换一种思维来理解局部变量,或者说全局变量和局部变量本意上都是一样的,在栈中也具有同等地位,不同的是局部变量的特点是朝生夕死的,而全局变量在栈中活到了最后


这也是为什么静态变量,常量一般都是全局作用域,它们在程序开始的时候就已经被压到栈内了,栈的特点是先进后出,也就是说要把常量和静态资源拿出来需要把绝大部分的变量方法和参数都要拿出来,符合这种情况的就只有程序快结束的时候,主函数也到了大括号 } 


每执行完成一个函数就删除它所开辟的空间,对内存利用的效率也是很高的


堆空间(程序公共访问)


不同于栈空间的线程私有,所在堆中的对象都可以被任何栈中指针访问,栈结构总归是有序的,但是堆空间是无序存储的,不像栈空间有先进后出这一特点


对象在堆空间中如果内部属性为基本变量,就会是自己就是一个实体,但是如果内部属性涵盖了其它对象,它会以一个指针的形式去指向那个对象,如果对象存在,否则再去自己造一个对象指向它,很显然是为了减少对象频繁构建的大量开销



 我们可以看到图中,id=1 是直接赋值到自己的 person 对象内部的,但是 String 类型的却是用指针指向,因为 String 也是一个对象,不是基本类型,对象都会以指针的形式去指向


且堆空间是公用的,也就是我们 person 对象执行完成以后,其它对象需要用到 String 对象的时候,会直接链向这两个 String,而不是自己去创建


可能这些链接来自于不同的私有栈,但是都是可以的,对于堆的清理,我们就要知道 jvm 的 GC 机制了,它是专门负责清理堆中不会再使用的对象的,会使用到的对象还是一样的会继续存放再堆空间中


栈和堆与对象的关系


既然对象和变量是分别存在堆和栈中的,我们在函数执行的时候主要还是对栈操作,那么当栈中需要使用到对象的时候怎么办呢?



 如图,因为栈中只存储基本类型,所以使用栈要使用对象的时候,它会用一个指针去指向堆中的对象,一般对象的大小是不确定的,每个对象有它自己的大小,所以栈中存储指针是基本类型它和 int 大小是一样的,对栈的管理也更进一步


值得注意的是,当栈中开辟的函数空间被清理了以后,堆中的空间不会立马被清理,可能其它的栈正在调用,或者还没有触发 jvm 的 GC 机制


这样的设定又变相的解决了堆中对象的共享问题


省流:栈空间中存储的都是值类型,指针也是值类型,而堆中存储的都是对象


三.JVM 的 GC 机制(堆特别篇)


我们从 jvm 的内存模型中分析了,堆的中产生的对象在一个栈使用完了以后不会立即清理,而是要等到 GC 机制来清理,我们就来看看 GC 机制是怎么清理的


  • 首先我们得知道什么样的情况可以清理


  1. 正在使用的不能清理

  2. 间接被其它对象调用的不能清理

  3. 本地方法栈和方法区(元空间)的对象不能清理


除了这些情况其它都是可以清理的,这样 GC 机制就会启动去清理这些不在此范围内的对象

清理方案(三种)

1.标记清理


标记清理指的是 GC 对堆中的对象先进行扫描,那些对象没有用了可以清理的就打上标记,然后扫描完了统一清理有标记的



 这种方式执行起来简单,实现逻辑清楚,但是有个很明显的缺点,在删除完成后,会有内碎片,相信学过操作系统的堆内碎片一定很熟悉,尤其是程序的对象不一,会导致内碎片越来越多


2.标记整理


标记整理是基于标记清理实现的,它在清理完成以后会把对象的位置向前移动,使得后面可以空出来大片区域



 标记整理的方式,的确非常符合我们对堆空间的清理,但是它的开销很大,每次清理都伴随着大量的移动


3.分区复制


 分区指的是把堆空间分为两个部分,在进行清理标记的时候,把没有标记的分到另外一个半区,表示对象还在使用,然后把此半区的全部清理掉,以供它们交替工作



 分区复制的缺点也很明显,它需要两倍的内存,开销也是非常大的


JVM 中的 GC 机制


那么在 jvm 中,它的对象清理机制是如何实现的呢?肯定比上面三种更合理



 jvm 中的堆空间大致被分为了两个比较大的部分,一个是老年代,即 old,一个是年轻代,即 young


年轻代中装的是 E 区和 S 区,E 区即 Eden,想必大家都很熟悉,就是伊甸园的意思,是亚当和夏娃偷食禁果产生新生命的地方,在 jvm 中也是一样的每个新 new 出来的对象都是出生在 E 区的,S 区对应的就是 Survivor,即幸存的意思


老年代中装的是在 S 区存活 6 次都没被清理掉的对象,因为开发者认为 6 次都没被清理掉,说明这个对象可能会存活更久或者存活到到最后


young 区工作过程



 我们知道了 E 区是装载新对象的地方,当 Eden 区快要装满时,触发 GC 的时候,就会有一次扫描标记,然后将没有标记的复制到 S 区中区,表示存活的对象,然后清理全部的 E 区和另外的一个 S 区,我们有两个 S 区其实是交替工作的,这次复制到 S0 区,下次必复制到 S1 区,


E 区要满的时候就会触发 youngGC 因为是在 young 区触发 的也很好理解,每一次复制都会有一个年龄标志,当一个对象达到 6 岁的时候就会被复制到 old 区中区,表示它会存活更长或者存活到最后


我们可以看到 S 区比 E 区要小,其实在 JVM 的设定中 S0:S1:E 是 1:1:8,也就是 E 区要比 S 区大很多,这是因为对象也满足朝生夕死的特点,每次触发 GC 的时候大部分的对象都会被标记清理


补充:


还有一个 FullGC,指的是 old 区要满的时候触发的,old 区要是满了,会造成程序异常终止,JVM 会专门来处理 FullGC,同时会顺带的触发 youngGC,所以每次 FullGC 必 youngGC,但是一般不会 FullGC,因为 old 区很大,FullGC 的清理方式是标记整理,也就是那三种清理方法的前两种,而 youngGC 是复制也就是第三种清理方法,youngGC 是专门为对象的朝生夕死而设计的


JVM 中的垃圾收集器(了解)


由于我们的堆是有两部分构成的,老年代和年轻代,所以垃圾收集器也是分别有两种:



解释: 


Serial 是单线程的移动复制用于 young 区,与之对应的就是 Serial  Old 是标记整理用于 old 区,会发生 STM

PawNew 是多线程的移动复制和 Serial 其它都一样,而 CMS 则是服务器段 old 区使用最多的垃圾回收器他不会造成 STM


parallel Scavenge 则是更注重吞吐量,在客户端使用最多的就是它,单次执行很满,但整体的效率很高,与之对应就是 old 区的 Parallel  Old


 注:STM 指的是 stop-the-world,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。


由于 young 区的空间小,所以 STW 时间短,但是 old 区要是发生了 GC,STW 停顿时间很长,这也是服务器端为什么要使用 CMS 的原因,它不会发生 STM 现象,因为它采用的是标记清理,但是缺点我们也知道,会有内碎片


young 区的算法都是移动复制,就不用说了,并且会发生短暂的 STM,我们可以着重看看 CMS,这个服务器端常用的垃圾收集器


CMS:


  1. 初次标记,GCRoot 对象,会发生 STM

  2. 并发标记,所有 old 区的对象

  3. 从新标记,修正第二步(有可能在标记的时候又产生了废弃对象)会发生 STM

  4. 并发清理,标记清理


此外,G1 垃圾收集器是从 jdk1.9 开始沿用的,它对堆的划分就不像这几种那么规律了,它提出了一个堆空间的新型划分概念,有兴趣的朋友可以去了解一下


四.拓展


Java 逃逸分析机制:


逃逸分析在 jdk6 以后是默认开启的


逃逸分析指的是它会分析对象如果是在当前函数使用完了不存,因而改为在栈上申请空间,而栈运行完就清理,所以不需要等 GC,大大缓解了 GC 的压力


如果不是当前函数范围则不会在栈上,而是在堆上申请


对象的大小计算:



 非数组对象,头部大小是固定的 12 字节,数组对象则多了 4 字节为 16 字节


内容会根据具体的基本类型和其它对象大小而定


如果我此对象中有两个 int 变量,那么对象大小就是 12+8=20,但结果会是 24,因为 Java 为了使对象在空间上对齐,会对不满足 8 倍数的大小对象强制扩大,24 是 8 的倍数所以可以,


这里就会有个小 tips,那你会发现一个对象中两个 int 型和三个 int 型的对象大小是一样的


如果此对象的一个属性是对象的的话,会取决于被引用对象的基本类型和它包含的其它对象


分析:


地址:是此对象在堆中的实际位置


标记:记录了 hash 值,是否有锁,年龄(是否进入 old 区使用的)


数组长度:记录了数组的下标,这也是为什么数组对象长度为一个 int 的大小,它们是等字节的


文章转载自: ~java小白~

原文链接:https://www.cnblogs.com/5ran2yl/p/17454634.html

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
《数据结构》之栈和堆结构及JVM简析_数据结构_EquatorCoco_InfoQ写作社区