注意:本站所指的虚拟机均为进程虚拟机,而非系统虚拟机

内存管理

  假如我们是一个仓库管理员,我们管理着一个庞大的仓库,这个仓库用来存放各种尺寸的形状规则的货物,同时为了简便起见,我们假定货物之间不可以堆积(即货物只能放在地上)。这个仓库就是我们编程中所说的堆内存,货物就是内存中的数据。

  每当我们要放入新的货物时,我们都需要在仓库中找到一个可以放下新货物的位置,然后才能把它放进去。这个放货物的过程就是申请内存。

  存储货物的目的不仅是存储,更多是为了使用被存储的货物,每当我们需要使用某一个货物的时候,我们便需要打开它。这个过程就是读取内存。

  但是仓库这么大,想要从如此庞大的仓库中找到想要的货物无疑是非常困难的,所以我们需要有一个表来标明哪个货物存放在哪个位置。这个位置就是内存地址,表中存储的就是指针,表本身在代码中找不到准确的对应。

  同时,并不是所有货物都是永久有用的,我们需要清除不再被需要的货物。这个清除货物的过程就是销毁内存。

  必须注意的是,假如我们清除了某一个货物,我们必须在表中也清除掉相应的记录,否则我们就有可能找空位置,或者找到这个位置之后发现这个地方存储的是别的货物。这些错误的表记录我们称之为“野指针”,野指针在编程过程中是绝对要避免的。

  但是我们发现了一个问题,我们移除一个货物后并不会移动其它货物,这在短期内不是什么问题,但是在长时间使用仓库后,难免会有很多小空无法被利用,如下图:

内存碎片示例

其中,白色区域为未使用空间,黑色区域为已使用空间

  假设剩余白色区域的总面积为 100m2,那么我们有办法放入一个面积为 100m2的货物进去吗?答案显然时不可以的,因为没有任何一个独立完整的白色区域的面积有 100m2

  这个问题导致在有些时候,明明我们的剩余空间足够放下新的货物,但是实际上我们却完全放不进去。这些夹杂在几个货物中间的细小空间我们称之为“内存碎片”。这里我们并不分析操作系统是如何减少内存碎片的产生的,提出这个问题是为了后文叙述虚拟机的GC是如何处理内存碎片的。

这里我们是用一个二维的例子描述了内存管理,在实际应用中,内存是一维的。

什么是GC

   在进行虚拟机系列的编程语言编写代码时我们应该能注意到一个很明显的区别,我们不需要手动回收内存。

  在使用非虚拟机系的语言(比如:C/C++)时,只要我们使用new(或malloc)分配了一段内存空间,那么我们必须使用delete(或free)来释放空间,不然就会导致内存泄漏。

  但是在使用虚拟机系的语言(比如:JavaPython)时,我们使用new(或没有关键字)分配了一段空间,我们完全不需要去delete它,甚至大多数虚拟机中完全不可能在代码层面上告知虚拟机要回收哪段内存。

  这个差别存在的原因就是有无GC(垃圾回收器),正是得益于GC的存在,虚拟机系的语言的开发基本摆脱了手动管理内存的烦恼。

  虚拟机一般会将内存分为更多的分类,来方便管理内存,本篇博文不涉及复杂的内存管理技术,只是简单指明内存管理的作用以及运作原理,所以我们将内存简单的分为两类:堆和栈。

  GC只在堆上工作,因为在栈上运行GC没有任何好处,栈内存会跟随方法的执行与退出一同创建或销毁,在栈上运行GC只会加大GC的压力。

GC回收方式

因为现有的虚拟机都是支持面向对象的,而且以面向对象为基础叙述问题也更方便,我们假设读者已经了解面向对象的基本内容

  垃圾回收算法有三大类(当然也有GC不属于其中的任何一类,是多种算法的结合体,这些GC不在本文的讨论范围内):

  1. 标记-清除
  2. 标记-复制
  3. 标记-整理

  不难发现,不论是哪一种算法,都存在着“标记”这一过程。我们先来说说“标记”是在干什么。

标记

  所谓标记,就是在GC开始前,先标记出哪些对象是有用的、哪些是无用的。这一过程说起来非常简单,但是在实际应用中却也有着很高的复杂度。

  标记同样有着非常多的算法,我们这里只按照最粗略的标准将这些算法分为两类:

  1. 引用计数算法
  2. 可达性分析算法

引用计数

  引用计数的原理非常简单,给每一个对象都添加一个计数器,如果有新的指针指向了这个对象,那么就让计数器+1,反之;如果有已有的指针解除了对这个对象的引用,那么就让计数器-1。最终,每次GC时计数器为0的对象便是需要回收的对象。

  但是,这样的算法真的严谨吗?答案是否定的,我们假设一种情况:

  我们家里放有很多物品:电视机、电视机遥控器、电脑……很显然,电视机需要遥控器,遥控器也依赖电视机,所以电视机和遥控器的计数器一定至少为1。但是现在我们不需要这台电视机了,我们按照上面的算法能把电视机扔掉吗?不能,因为我们发现还有其它物品“使用”着电视机,但是我们不知道是什么。实际上,正在“使用”电视机的只有一个电视遥控器,而这个遥控器也是我们要一起扔掉的。

  我们再用代码重复一遍这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Main {

/*
很明显,在test()执行完毕后arg0、arg1、arg2都应当被回收
但是如果按照引用计数算法的话,这三个对象都无法被回收
*/

public static void main(String[] args) {
test();
//do something...
}

static void test(){
//两个对象间的循环引用
Sample arg0 = new Sample();
Sample arg1 = new Sample();
arg0.that = arg1;
arg1.that = arg0;
//一个对象本身的循环引用
Sample arg2 = new Sample();
arg3.that = arg3;
}

}

class Sample {
Sample that;
}

这个样例用C++写多少有点不合适,可以的话还是看Java的比较好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Sample {
Sample* that;
}

void test() {
Sample arg0 = new Sample();
Sample arg1 = new Sample();
arg0->that = arg1;
arg1->that = arg0;
Sample arg2 = new Sample();
arg2->that = arg2;
}

//很明显,在test()执行完毕后arg0、arg1、arg2都应当被回收
//但是如果按照引用计数算法的话,这三个对象都无法被回收
int main() {
test();
//do something...
return 0;
}

  发现了吗?如果存在两个对象互相引用,但是没有任何办法可以访问到这两个对象中的任意一个,在这种情况下,这两个对象都是需要被清除的。按照引用计数算法管理内存就会出现扔不掉这个问题,这个问题被称为循环引用问题。

  Python的虚拟机采用以引用计数为主、可达性分析为辅的方法,所以在代码层面避免出现循环引用可以有效地提高虚拟机的GC效率。

可达性分析

  为了避免上面遇到的循环引用问题,开发者们开发出了另一种算法——可达性分析算法。

  可达性分析算法和引用计数算法在原理上完全不同,可达性分析算法的基本原理是从可直接访问的根节点开始查找,寻找所有可被访问到的对象,并将其标记为存活。这样在遍历完成后,没有被标记为存活的就是需要被清理的。

  所谓的“根”,就是可以在代码层面上直接访问到的对象,而不需要通过其它对象来访问这一对象。在Java中,最常见的根节点就是栈中引用以及全局静态引用。

可达性分析算法可以说是五花八门、各显神通,想要了解更多内容我推荐阅读《深入理解Java虚拟机:JVM高级特性与最佳实践》一书

清除

  现在,我们继续来说第一种算法——标记-清除算法。

  我们继续延用上面的图片,这次,我们用蓝色标记出需要被回收的对象:

标记-清除=标记

  标记-清除算法的思路很简单,就是直接删除掉要回收的内存,回收后内存就变成了这样子:

标记-清除=清除

  不难发现,这样子的算法还是无法规避内存碎片。

复制

  接下来,我们来说第二种算法——标记-复制算法。

  为了更简洁地表现出算法的复制过程,我们这次使用一个更简单的图像,其中蓝色标记的是需要被回收的对象:

标记-复制=标记

  清理后内存就变成了下面这个样子:

标记-复制=复制

  我们用文字描述以下回收过程: 在标记出完毕后,将所有存活的对象都复制到先前的保留内存中,然后将原活动内存全部清除。这样做的优势就是避免了内存碎片的产生;劣势不难看出,这样操作非常浪费内存,同时复制过程也很浪费性能。

  不过事实证明,在后来一段时间内,标记-复制算法成功地成为了业界主流。不过并完全是上面所说的标记-复制,而是优化过后的标记-复制算法。这里不再详细描述,同样的,想要了解更多内容,推荐阅读《深入了解Java虚拟机:JVM高级特性与最佳实践》。

整理

  最后,我们来说第三种算法——标记-整理算法。

  我们依然使用蓝色标明要回收的对象:

标记-整理=标记

  首先,我们把所有存活的对象都向内存的一边移动:

标记-整理=移动

  最后,我们把边界外的所有对象都回收掉:

标记-整理=清除

  这种回收算法同样避免了内存碎片的产生,但是其同样有着消耗时间的诟病,但是相对于标记-复制算法,它没有浪费那么多内存。

时停

  在进行GC时很难避免出现Stop the world的现象,这个现象的名字看起来非常的帅气,但是实际上却是人们避之不及的玩意。

  那么什么是Stop the world呢?就是在进行GC的时候要先暂停程序的运行,等待垃圾回收完成(部分GC是可以和程序并行运行的)后再继续执行程序。说白了就是在进行GC的时候程序会卡住,直到GC结束。

  为什么会出现这个问题呢?首先我们要明白,GC的线程是独立的,它不与程序所在的线程在同一线程中。然后需要时停的第一个原因就是在进行“标记”时如果堆中的数据还在不停的变动,就很容易出现扫描结果错误的情况。其次,有很多GC在进行内存回收的过程中需要移动对象,如果此时和程序线程一同工作,就可能导致对象已经移动走了,但是程序依然按照还没来得及更新的地址去访问这个对象。

  这么看来,时停好像无法避免。事实上,确时没有办法避免,只能想办法减少时停的时间。比较普遍的方法就是让内存回收过程和程序一同进行,很多GC都为此做出了非常多的努力。这里我们也不再多说,想要了解更多推荐阅读《深入了解Java虚拟机:JVM高级特性与最佳实践》。

代码优化

  根据上面的描述,我们不难发现,GC非常讨厌朝生夕死的对象,尤其是体积庞大的对象。所以在编程中我们应当尽量避免无用对象的创建,尤其要杜绝临时超大型对象的创建(实在没办法了除外)。

  当然,这并不是说我们要尽量不创建对象。在现代虚拟机中,都为内存管理做了非常多的优化,比如说:分代、栈上分配等等。

  另外,在虚拟机中,都对堆上分配做了充足的优化,所以虚拟机中的newC++new不知道快了多少倍。也正因如此,大家也可以放心的用new创建小对象,在很多时候,代码的可读性是比性能更加重要的。

  最后再补充一点,很多虚拟机都是允许代码层面上主动触发GC的,不过这么做意义不大,因为虚拟机本身就会在需要GC的时候自动触发GC,频繁的手动触发GC只会增大GC的压力。

结语

  用虚拟机系得语言进行代码开发,了解虚拟机得基本执行原理是必须的,不然在遇到与虚拟机原理有关的错误时就会无从下手。并且了解虚拟机的执行原理后也更容易写出对虚拟机更加友好的代码。