内存,运行时和编程语言

482 阅读10分钟

简述

编程语言层出不穷,但是大体上可以分为两类:

有运行时的,没有运行时的。

没有运行时的,C/C++是典型代表,这两年很火的rust也是。

有运行时的,java和js则是代表,另有python,c#,go等。

有运行时的语言,又区分两种,一种是编译型(所有没有运行时的语言,都是编译型语言),一种是解释型。

编译型语言的另一个tag是,静态语言。比如java,c#。

解释型语言的另一个tag是动态语言。比如JS和python。

静态语言和动态语言的区别在语法上类似于JS和TS的区别,但是TS在你写错变量类型的时候,虽然会报错,但是实际上依然是可以编译出正确的JS的。而真正的静态语言,是直接中断编译的。

操作系统和运行时

在冯诺依曼的计算机结构下,一个计算机最重要的成分,是CPU和存储。

image.jpeg

这意味着,要想在计算机上跑程序,你就要去控制内存。

早期的汇编语言,就是直接操作内存和寄存器的。比如

add %eax, %ebx 
ret

这行表示的意思是,将寄存器eax的值,与ebx的值相加,并且将结果写入eax。

汇编语言,可以说是面向寄存器编程。

汇编语言的操作其实非常直接,有效,但是也非常容易出错,可读性差,写出复杂的逻辑更是困难。

于是诞生了编译器。

编译器的作用,就是将另一种可读性更好的代码,编译成汇编,或者直接编译成机器码。比如C和C++所用的编译器,GCC。

GCC封装了一系列CPU指令和汇编指令,这样程序员就避免了直接操作寄存器。

在代码运行前,要先使用GCC编译成可执行文件,然后在运行可执行文件。这样的好处是,执行的直接是汇编语言或者机器码,这对CPU是友好的,所以执行起来非常快。而且,当你开始编译的时候,可以出去抽根烟或者喝杯茶,合法的摸鱼(因为编译一次非常久)

但是这样依然有两个问题,第一,开发人员虽然不用直接操作寄存器了,但是依然要手动处理内存的分配。第二是,代码被编译成机器码了,再进行故障定位的时候,是无法还原现场的。这就导致开发的效率低,且极易因为内存的问题出错。

操作系统和运行时

我们写好了程序,那又是如何交给CPU运行的呢,这就是操作系统的作用了。史上最早的操作系统,只是一个批处理工具,只是负责把程序员打孔的纸带批量的交给cpu去执行。

但是这样的问题就是,在古早的年代,计算资源是非常稀缺的,有人用的时候,剩下的人就只能放空等待。

这时,横空出世了unix操作系统,它实现了分时和多任务,一下子解放了计算资源,并最终成为现代操作系统的祖奶奶。

到现在,一个操作系统包含了很多个部分,大概的分有:

  1. 内存管理
  2. 进程和线程以及调度
  3. 文件系统
  4. 设备管理和驱动开发
  5. 虚拟化

其中,内存管理和线程调度是最核心的,因为其他几项都依赖这两个。

操作系统其实是运行在计算中一个特殊的程序,而运行时则是运行在操作系统中的另一个特殊的程序。

常见的运行时:

  1. JAVA 虚拟机JVM,包含了编译时和运行时
  2. C#的运行时CLR
  3. Python的虚拟机CPython,包含了编译时和运行时
  4. JS的解释器V8,包含了编译时和运行时

运行时做了什么事情呢?主要是三个

  1. 帮忙管理内存,解放生产力
  2. 帮忙管理线程(有一部分运行时没有这一块,比如V8,V8自己有进程管理,但是没有暴露API给程序员用)
  3. 帮忙做代码优化,提升执行速度(因为解释型代码的性能主要卡在解释的过程中)

那么运行时和操作系统是什么关系呢?

** 分配内存的API和线程调度的API,是由操作系统内核提供的。我们在代码中写的线程,最终被转换成浏览器的内核线程,被内核调度 **

操作系统是如何管理内存的

  1. 物理内存和虚拟内存

    计算机中的物理内存有三个,CPU缓存,计算机内存和计算机存储。

    操作系统管理的,主要是CPU缓存和计算机内存。

    操作系统将物理内存,按照固定的大小进行编码,每一个内存单元都有一个编号,这个编号就是内存地址,也就是编程里常说的指针。

    CPU能访问多少个地址,就表示CPU的寻址空间有多大。一个32位的操作系统,能表示的值是2的32次方,因为它的最大寻址空间就只有4个G。如果给一个32位的操作系统,装上一个8G的内存,实际上并不能带来性能的提升。

    但是直接操作物理内存也带来了很多不便,比如内存碎片,进程之间的内存不安全等等问题。

    操作系统另外设计了虚拟内存,虚拟内存其实就是一个映射关系表。通过虚拟内存,操作系统可以给每一个进程都配上4G的内存,并且因为进程无法直接操作内存了,进程之间的数据也就安全了。

  2. 分页与缺页

    为了对内存进行精细化管理,操作系统又设计了分段和分页。现代操作系统一般是分页,比如linux一页的大小是4K。通过页码+偏移量来对虚拟内存进行访问,这样既提升了管理的精细度,也避免因为太精细而降低了访问速度。

通过虚拟内存,解决了内存分配的问题。但是无法解决回收的问题。因为分配内存是程序员决定的,这个内存什么时候被放弃,也只能由程序员决定,操作系统是不知道的。

而正是这个问题,分流了现代编程语言。

运行时接管了内存之后,面临两个问题。

第一是如何分配。运行时在执行代码时,遇到new之类的操作符,需要向操作系统申请一定大小的内存。这个大小,是通过类型来确定的。因此,静态语言需要在变量声明时声明具体的类型,这正是告诉运行时,我需要多大的内存。

数据类型一般分为基础类型和复杂类型。基础类型就是常见的char,int,float等。复杂类型就是struct,class,obj之类的。基础类型的内存是确定的,所以计算往往集中在复杂类型上。

在GCC中,有的类型是4个字节,有的是8个字节,有的是1个字节。在对struct的内存大小进行计算时,低于4字节的类型会被当成4字节处理,这样能够保证内存是对齐的。

那么,运行时又是如何进行回收的呢?

这里先讲一个特立:Rust.

Rust是一个没有运行时的语言,但是他不用程序员来处理内存。他在处理内存释放的时候,采用了一个新的方案:所有权。

const a:Obj = new Obj(); 
const b = a; 
const c = a;

每一个值,在同一个作用域内只能由一个变量持有它。像上面的代码,a 先持有了1,然后将a赋值给b,那么1的持有人就变成了b。这时,a就会被释放掉。第三行代码在编译时就会报错。

首先,这些运行时拿到了操作系统给它分配的虚拟内存,然后将内存分为区,常见的有堆区,栈区,方法区等。

常见的内存回收算法

  1. 标记清除

    每一次内存不足时,触发GC。

    GC首先访问根对象(不同的算法对根的定义不一样,比如JVM的根的定义是

    a. 栈(栈帧中的本地变量表)中引用的对象

    b. 方法区中的静态成员(static关键字声明的变量)

    c. 全局变量

    d. 本地方法栈中的引用的对象

    然后递归所有的根对象,将跟对象引用的其他对象标记为活动。

    标记完成之后,再进行递归遍历,将所有未标记的对象内存释放。

    算法比较简单,缺点是比较耗时,而且无法解决内存碎片的问题。

  2. 引用计数

    这是一种非常具有自我管理意识的算法。

    给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;

    当引用失效时,计数器值减1,引用数量为0的时候,则说明对象没有被任何引用指向,可以认定是”垃圾”对象。自动挂载到空闲对象链表,等待回收。

    但是这种算法会有一个问题,就是循环引用。

    Obj a = new Obj(); 
    Obj b = new Obj();
    a.t = b;
    b.t = a ;
    a = null ;
    b = null;
    

    这种场景下,a和b应该被释放的,但是他们的计数都是1,不会被清理,造成了内存泄露。

  1. 复制算法

    简单粗暴的算法。

    将内存区分分为两块,一块是Clean,一块是dirty。当dirty区域满时,将dirty中的全部活动对象,拷贝到clean区域。然后将两个区域再置换一下,clean区域变成了dirty区域,旧的dirty区域变成了clean区域。

    优点很明显,它不会产生内存碎片,因此分配速度很快。缺点同样明显,只能使用一半内存,而且因为要复制活动对象,所以也要使用递归。

  2. 标记压缩算法

    标记阶段与标记清除算法一样,而压缩阶段则与复制算法一样。

    这样就中和了两者的优点,并且弥补了双方的一些缺陷。

  3. 分代算法

    将内存分为4个空间,分别是生成空间,2个大小相等的幸存空间以及老年代空间。

    生成空间和幸存空间合称新生代空间。

    当生成空间满时,触发新生代的GC。

    GC将生成空间的活动对象复制到幸存空间,这个同复制算法。

    幸存空间有两个,这个空间扮演复制算法中的clean和dirty区域,互相之间进行倒腾。

    当一个对象经过了几次GC,依然在幸存空间中,则晋升到老年代,释放幸存空间的内存。

    缺点明显,复制的次数增多了,分配的吞吐量就变低了。

    好处就是,不会产生内存碎片

V8的垃圾回收... 未完待续