heap and stack 堆和栈在内存管理和数据结构中的差异

  1. 数据结构中的栈(Stack)和堆(heap)和内存分配中的堆和栈没有直接的关联,只是因为名称相同而容易混淆。
  2. 内存中的堆栈和数据结构堆栈不是一个概念,可以说内存中的堆栈是真实存在的物理区,数据结构中的堆栈是抽象的数据存储结构。

内存管理中的堆栈

  1. Stacks in computing architectures are regions of memory where data is added or removed in a last-in-first-out manne
  2. Heap is a common name for the pool of memory from which dynamically allocated memory is allocated

内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据,动态数据区又分为栈区和堆区。我们可以称其为内存四区。

一个可执行程序在存储(没有调入内存)时分为代码段、数据区和未初始化数据区三部分,可执行程序(调入内存后)在运行时又多出两个区域:栈区和堆区。

内存四区:代码区、全局区、栈区、堆区

程序执行前,生成代码区、全局区;程序执行后,生成栈区、堆区。

  1. 代码区:存放CPU的机器指令(即代码的二进制形式)。具有两个特性:共享性和只读性
  2. 全局区:全局区的数据在程序结束后才释放。存放了全局变量、静态变量、字符串常量、全局常量
  3. 栈区:由编译器自动分配和释放。存放了函数的参数值、局部变量(函数体内的变量)
  4. 堆区:由程序员分配和释放,若程序员未释放,程序结束时(也就是把exe关掉以后)由操作系统自动回收。
  • 栈区的数据由编译器管理,调用完之后就自动释放,压栈,出栈。先进后出的原则,栈的内存地址生长方向与堆相反,由高到底(从内存高地址向低地址存储),栈中存储的数据的生命周期随着函数的执行完成而结束。栈区是有大小的,一般是1M左右,所以别定义太大的数组。
  • 堆区中的生长方向由低到高(从内存低地址向高地址存储),并不一定按照申请顺序分配地址空间的高低。

为什么会出现两种形式?

堆栈从本质上讲是提供程序运行所需要的内存空间

因为程序不仅仅是由数据构成,还包括处理数据的逻辑方法。而这些方法的编码和执行顺序是非常符合后出先进的设计逻辑。
因此栈的存在更多是为程序的方法服务,所以我们经常说的调用栈就是指可以查看程序调用顺序的内存空间,在栈顶部的就是最后执行的方法。
方法中的临时变量如下图所示,也会存在栈中。理论上这些变量全部在堆(Heap)中生成也是可行的。
Stack由高地址向低地址索取空间,Heap则相反从低向高。但是也有很别处是Stack自上而下,Heap相反。所以是不是写错了?到底哪种才是正确的分配方案。
其实根据系统架构的不同,这两种方式都是存在的。只是顺序的颠倒?️,没有其它不同。

为什么会放在一起?

上古时期,内存有限的情况下,Heap和Stack能共享一段连续空间,根据自身情况,在保证安全的情况下,向对方霸占一些领地。从而更好的利用内存。 在现今的操作系统和硬件上,就很少出现这么紧俏的情况,而且Stack和Heap也不会在连续的物理内存中。Heap和Stack连续的情况更多是出现在以虚拟内存中,来表示程序的结构。

栈为什么不大?

  • ulimit -s查看,可能只有几M的大小。相较8GB的内存,为何栈大小只有区区几MB。
  • 其中的一种说法是,栈的调用,也就是程序执行的速度,不仅仅依赖于CPU,也同样依赖内存的读取速度。
  • 所以栈的存储会依赖于大小有限的高速缓存,如果栈过大,则高速缓存会出现大量内存空间的交换,反而降低了执行的速度。
  • 而珍贵有限的栈空间,就应该尽量避免存储容量大或者声明周期长的数据。
  • 所以堆(Heap)就有它存在的场景了,内存空间大,数据存在时间长 (依赖于不同的内存释放策略),通过地址引用,也相对松散。

程序通常牵涉三个可能的内存管理器的操作

  1. 让内存管理器分配一个某个大小的内存块
  2. 让内存管理器释放一个之前分配的内存块
  3. 让内存管理器进行垃圾收集操作,寻找不再使用的内存块并予以释放

C++ 通常会做上面的操作 1 和 2。Java 会做上面的操作 1 和 3。而 Python 会做上面的操作 1、2、3。这是语言的特性和实现方式决定的。

堆和栈的主要区别

  1. 管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
  2. 空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的。当然,我们可以修改
  3. 碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出
  4. 生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
  5. 分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。
  6. 分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
  堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;
  由于没有专门的系统支持,效率很低;
  由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。
  所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。
  所以,我们推荐大家尽量用栈,而不是用堆。

为什么栈地址从高到低生长,堆从低到高?

这个问题与虚拟地址空间的分配规则有关,每一个可执行C程序,从低地址到高地址依次是:text,data,bss,堆,栈,环境参数变量
其中堆和栈之间有很大的地址空间空闲着,在需要分配空间的时候,堆向上涨,栈往下涨。

如此设计可以使得堆和栈能够充分利用空闲的地址空间。如果栈向上涨的话,我们就必须得指定栈和堆的一个严格分界线,但这个分界线怎么确定呢?平均分?但是有的程序使用的堆空间比较多,而有的程序使用的栈空间比较多。所以就可能出现这种情况:一个程序因为栈溢出而崩溃的时候,其实它还有大量闲置的堆空间呢,但是我们却无法使用这些闲置的堆空间。所以呢,最好的办法就是让堆和栈一个向上涨,一个向下涨,这样它们就可以最大程度地共用这块剩余的地址空间,达到利用率的最大化

数据结构中的堆栈

  • 栈:是一种连续存储的数据结构,特点是存储的数据先进后出。
  • 堆:堆是一种非线性结构,可以把堆看作一个数组,也可以被看作一个完全二叉树;看成是一棵完全二叉树结构,特点是父节点的值大于(小于)两个子节点的值(分别称为大顶堆和小顶堆)。它常用于管理算法执行过程中的信息,应用场景包括堆排序,优先队列等。
    1. 大顶堆:每个结点的值都大于或等于其左右孩子结点的值;对应公式:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
    2. 小顶堆:每个结点的值都小于或等于其左右孩子结点的值;对应公式:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

计算机中为什么要用二进制?

计算机使用二进制是由它的内部物理结构所决定的,由于计算机内部都是由一个个门电路所构成,当计算机工作的时候,电路通电工作,于是每个输出端就有了电压。
电压的高低通过模数转换即转换成了二进制:高电平是由1表示,低电平由0表示。
也就是说将模拟电路转换成为数字电路。这里的高电平与低电平可以人为确定,一般地,2.5伏以下即为低电平,3.2伏以上为高电平。
数据在计算机中以器件的物理状态表示,采用二进制数字系统,计算机处理所有的字符或符号也要用二进制编码来表示。

进程 线程 协程 进程通信机制

基本定义

1.进程是资源分配的单位;
2.线程是CPU调度的单位;
3.协程是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)

进程线程区别

1) 地址空间:线程是进程内的一个执行单元,进程内至少有一个线程,它们共享进程的地址空间,而进程有自己独立的地址空间
2) 资源拥有:进程是资源分配和拥有的单位,同一个进程内的线程共享进程的资源
3) 线程是处理器调度的基本单位,但进程不是
4) 二者均可并发执行
5) 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制

线程协程的关系

1) 一个线程可以多个协程,一个进程也可以单独拥有多个协程。
2) 线程进程都是同步机制,而协程则是异步。
3) 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。
4)线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
5)协程并不是取代线程, 而且抽象于线程之上, 线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行, 线程是协程的资源, 但协程不会直接使用线程, 协程直接利用的是执行器(Interceptor), 执行器可以关联任意线程或线程池, 可以使当前线程, UI线程, 或新建线程。
6)线程是协程的资源。协程通过Interceptor来间接使用线程这个资源。

进程间的通信方式

每个进程都有自己的用户空间,而内核空间是每个进程共享的。因此进程之间想要进行通信,就需要通过内核来实现

进程之前的通信方式主要有六种: 管道、消息队列、贡献内存、信号量、信号、socket

  1. 信号:(signal)是一种处理异步事件的方式。信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程外,还可以发送信号给进程本身。
  2. 信号量:(Semaphore)进程间通信处理同步互斥的机制。是在多线程环境下使用的一种设施,它负责协调各个线程,以保证它们能够正确、合理的使用公共资源。
  3. 简单地说,信号就是一种异步通信,通知进程某种事件的发生;信号量是进程/线程同步与互斥的一种机制,保证进程/线程间之间的有序执行或对公共资源的有序访问
    信号:是由用户、系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。
    信号量:信号量是一个特殊的变量,它的本质是计数器,信号量里面记录了临界资源的数目,有多少数目,信号量的值就为多少,进程对其访问都是原子操作(pv操作,p:占用资源,v:释放资源)。它的作用就是,调协进程对共享资源的访问,让一个临界区同一时间只有一个进程在访问它。

所以它们两的区别也就显而易见了,信号是通知进程产生了某个事件,信号量是用来同步进程的(用来调协进程对共享资源的访问的)

管道:

管道是最简单,效率最差的一种通信方式。

管道本质上就是内核中的一个缓存,当进程创建一个管道后,Linux会返回两个文件描述符,一个是写入端的描述符,一个是输出端的描述符,可以通过这两个描述符往管道写入或者读取数据。

如果想要实现两个进程通过管道来通信,则需要让创建管道的进程fork子进程,这样子进程们就拥有了父进程的文件描述符,这样子进程之间也就有了对同一管道的操作。

缺点:

半双工通信,一条管道只能一个进程写,一个进程读。
一个进程写完后,另一个进程才能读,反之同理。

消息队列:

管道的通信方式效率是低下的,不适合进程间频繁的交换数据。这个问题,消息队列的通信方式就可以解决。A进程往消息队列写入数据后就可以正常返回,B进程需要时再去读取就可以了,效率比较高。

而且,数据会被分为一个一个的数据单元,称为消息体,消息发送方和接收方约定好消息体的数据类型,不像管道是无格式的字节流类型,这样的好处是可以边发送边接收,而不需要等待完整的数据。

但是也有缺点,每个消息体有一个最大长度的限制,并且队列所包含消息体的总长度也是有上限的,这是其中一个不足之处。

另一个缺点是消息队列通信过程中存在用户态和内核态之间的数据拷贝问题。进程往消息队列写入数据时,会发送用户态拷贝数据到内核态的过程,同理读取数据时会发生从内核态到用户态拷贝数据的过程。

共享内存:

共享内存解决了消息队列存在的内核态和用户态之间数据拷贝的问题。

现代操作系统对于内存管理采用的是虚拟内存技术,也就是说每个进程都有自己的虚拟内存空间,虚拟内存映射到真实的物理内存。共享内存的机制就是,不同的进程拿出一块虚拟内存空间,映射到相同的物理内存空间。这样一个进程写入的东西,另一个进程马上就能够看到,不需要进行拷贝。

(这里的物理内存貌似不是内核空间的内存?)

信号量:

当使用共享内存的通信方式,如果有多个进程同时往共享内存写入数据,有可能先写的进程的内容被其他进程覆盖了。

因此需要一种保护机制,信号量本质上是一个整型的计数器,用于实现进程间的互斥和同步。

信号量代表着资源的数量,操作信号量的方式有两种:

P操作:这个操作会将信号量减一,相减后信号量如果小于0,则表示资源已经被占用了,进程需要阻塞等待;如果大于等于0,则说明还有资源可用,进程可以正常执行。
V操作:这个操作会将信号量加一,相加后信号量如果小于等于0,则表明当前有进程阻塞,于是会将该进程唤醒;如果大于0,则表示当前没有阻塞的进程。

(1)信号量实现互斥:

信号量初始化为1

进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。

(2)信号量实现同步:

由于多线程下各线程的执行顺序是无法预料的,有些时候我们希望多个线程之间能够密切合作,这时候就需要考虑线程的同步问题。

信号量初始化为0

如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。

信号:

在Linux中,为了响应各种事件,提供了几十种信号,可以通过kill -l命令查看。

如果是运行在shell终端的进程,可以通过键盘组合键来给进程发送信号,例如使用Ctrl+C产生SIGINT信号,表示终止进程。

如果是运行在后台的进程,可以通过命令来给进程发送信号,例如使用kill -9 PID产生SIGKILL信号,表示立即结束进程。

Socket:

前面提到的管道,消息队列,共享内存,信号量和信号都是在同一台主机上进行进程间通信,如果想要跨网络和不同主机上的进程进行通信,则需要用到socket。

实际上,Socket不仅可以跨网络和不同主机进行进程间通信,还可以在同一主机进行进程间通信。

Socket是操作系统提供给程序员操作网络的接口,根据底层不同的实现方式,通信方式也不同。