简述
编程语言层出不穷,但是大体上可以分为两类:
有运行时的,没有运行时的。
没有运行时的,C/C++是典型代表,这两年很火的rust也是。
有运行时的,java和js则是代表,另有python,c#,go等。
有运行时的语言,又区分两种,一种是编译型(所有没有运行时的语言,都是编译型语言),一种是解释型。
编译型语言的另一个tag是,静态语言。比如java,c#。
解释型语言的另一个tag是动态语言。比如JS和python。
静态语言和动态语言的区别在语法上类似于JS和TS的区别,但是TS在你写错变量类型的时候,虽然会报错,但是实际上依然是可以编译出正确的JS的。而真正的静态语言,是直接中断编译的。
操作系统和运行时
在冯诺依曼的计算机结构下,一个计算机最重要的成分,是CPU和存储。
这意味着,要想在计算机上跑程序,你就要去控制内存。
早期的汇编语言,就是直接操作内存和寄存器的。比如
add %eax, %ebx
ret
这行表示的意思是,将寄存器eax的值,与ebx的值相加,并且将结果写入eax。
汇编语言,可以说是面向寄存器编程。
汇编语言的操作其实非常直接,有效,但是也非常容易出错,可读性差,写出复杂的逻辑更是困难。
于是诞生了编译器。
编译器的作用,就是将另一种可读性更好的代码,编译成汇编,或者直接编译成机器码。比如C和C++所用的编译器,GCC。
GCC封装了一系列CPU指令和汇编指令,这样程序员就避免了直接操作寄存器。
在代码运行前,要先使用GCC编译成可执行文件,然后在运行可执行文件。这样的好处是,执行的直接是汇编语言或者机器码,这对CPU是友好的,所以执行起来非常快。而且,当你开始编译的时候,可以出去抽根烟或者喝杯茶,合法的摸鱼(因为编译一次非常久)
但是这样依然有两个问题,第一,开发人员虽然不用直接操作寄存器了,但是依然要手动处理内存的分配。第二是,代码被编译成机器码了,再进行故障定位的时候,是无法还原现场的。这就导致开发的效率低,且极易因为内存的问题出错。
操作系统和运行时
我们写好了程序,那又是如何交给CPU运行的呢,这就是操作系统的作用了。史上最早的操作系统,只是一个批处理工具,只是负责把程序员打孔的纸带批量的交给cpu去执行。
但是这样的问题就是,在古早的年代,计算资源是非常稀缺的,有人用的时候,剩下的人就只能放空等待。
这时,横空出世了unix操作系统,它实现了分时和多任务,一下子解放了计算资源,并最终成为现代操作系统的祖奶奶。
到现在,一个操作系统包含了很多个部分,大概的分有:
- 内存管理
- 进程和线程以及调度
- 文件系统
- 设备管理和驱动开发
- 虚拟化
其中,内存管理和线程调度是最核心的,因为其他几项都依赖这两个。
操作系统其实是运行在计算中一个特殊的程序,而运行时则是运行在操作系统中的另一个特殊的程序。
常见的运行时:
- JAVA 虚拟机JVM,包含了编译时和运行时
- C#的运行时CLR
- Python的虚拟机CPython,包含了编译时和运行时
- JS的解释器V8,包含了编译时和运行时
运行时做了什么事情呢?主要是三个
- 帮忙管理内存,解放生产力
- 帮忙管理线程(有一部分运行时没有这一块,比如V8,V8自己有进程管理,但是没有暴露API给程序员用)
- 帮忙做代码优化,提升执行速度(因为解释型代码的性能主要卡在解释的过程中)
那么运行时和操作系统是什么关系呢?
** 分配内存的API和线程调度的API,是由操作系统内核提供的。我们在代码中写的线程,最终被转换成浏览器的内核线程,被内核调度 **
操作系统是如何管理内存的
-
物理内存和虚拟内存
计算机中的物理内存有三个,CPU缓存,计算机内存和计算机存储。
操作系统管理的,主要是CPU缓存和计算机内存。
操作系统将物理内存,按照固定的大小进行编码,每一个内存单元都有一个编号,这个编号就是内存地址,也就是编程里常说的指针。
CPU能访问多少个地址,就表示CPU的寻址空间有多大。一个32位的操作系统,能表示的值是2的32次方,因为它的最大寻址空间就只有4个G。如果给一个32位的操作系统,装上一个8G的内存,实际上并不能带来性能的提升。
但是直接操作物理内存也带来了很多不便,比如内存碎片,进程之间的内存不安全等等问题。
操作系统另外设计了虚拟内存,虚拟内存其实就是一个映射关系表。通过虚拟内存,操作系统可以给每一个进程都配上4G的内存,并且因为进程无法直接操作内存了,进程之间的数据也就安全了。
-
分页与缺页
为了对内存进行精细化管理,操作系统又设计了分段和分页。现代操作系统一般是分页,比如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就会被释放掉。第三行代码在编译时就会报错。
首先,这些运行时拿到了操作系统给它分配的虚拟内存,然后将内存分为区,常见的有堆区,栈区,方法区等。
常见的内存回收算法
-
标记清除
每一次内存不足时,触发GC。
GC首先访问根对象(不同的算法对根的定义不一样,比如JVM的根的定义是
a. 栈(栈帧中的本地变量表)中引用的对象
b. 方法区中的静态成员(static关键字声明的变量)
c. 全局变量
d. 本地方法栈中的引用的对象
然后递归所有的根对象,将跟对象引用的其他对象标记为活动。
标记完成之后,再进行递归遍历,将所有未标记的对象内存释放。
算法比较简单,缺点是比较耗时,而且无法解决内存碎片的问题。
-
引用计数
这是一种非常具有自我管理意识的算法。
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;
当引用失效时,计数器值减1,引用数量为0的时候,则说明对象没有被任何引用指向,可以认定是”垃圾”对象。自动挂载到空闲对象链表,等待回收。
但是这种算法会有一个问题,就是循环引用。
Obj a = new Obj(); Obj b = new Obj(); a.t = b; b.t = a ; a = null ; b = null;这种场景下,a和b应该被释放的,但是他们的计数都是1,不会被清理,造成了内存泄露。
-
复制算法
简单粗暴的算法。
将内存区分分为两块,一块是Clean,一块是dirty。当dirty区域满时,将dirty中的全部活动对象,拷贝到clean区域。然后将两个区域再置换一下,clean区域变成了dirty区域,旧的dirty区域变成了clean区域。
优点很明显,它不会产生内存碎片,因此分配速度很快。缺点同样明显,只能使用一半内存,而且因为要复制活动对象,所以也要使用递归。
-
标记压缩算法
标记阶段与标记清除算法一样,而压缩阶段则与复制算法一样。
这样就中和了两者的优点,并且弥补了双方的一些缺陷。
-
分代算法
将内存分为4个空间,分别是生成空间,2个大小相等的幸存空间以及老年代空间。
生成空间和幸存空间合称新生代空间。
当生成空间满时,触发新生代的GC。
GC将生成空间的活动对象复制到幸存空间,这个同复制算法。
幸存空间有两个,这个空间扮演复制算法中的clean和dirty区域,互相之间进行倒腾。
当一个对象经过了几次GC,依然在幸存空间中,则晋升到老年代,释放幸存空间的内存。
缺点明显,复制的次数增多了,分配的吞吐量就变低了。
好处就是,不会产生内存碎片