oohcode

$\bigodot\bigodot^H \rightarrow CODE$

Redis设计与实现总结——数据结构与对象

基本数据结构

简单的动态字符串

Redis自己构建的一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将SDS用做Redis的默认字符串表示。
SDS的定义在sds.h/sdshdr结构中:

1
2
3
4
5
6
7
8
9
10
11
struct sdshdr {
// 记录buf数组中已使用的字节的数量
// 等于SDS所保存字符串的长度
int len;

// 记录buf数组中未使用字节的数量
int free;

// 字节数组,用于保存字符串
char buf[];
}

SDS结构
C字符串和SDS之间的区别:

C字符串 SDS
获取字符串长度的复杂度为O(N) 获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出 API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配 修改字符串长度N次最多需要执行N次内存重分配
只能保持文本数据 可以保持文本或者二进制数据
可以使用所有<string.h>库中的函数 可以使用一部分<string.h>库中的函数

链表

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。链表在redis链表键,发布与订阅,慢查询,监视器等功能都用到了。
链表结构分为链表和链表节点,每个链表由多个链表的节点组合而成。每个节点都有一个指向前置节点和后置节点的指针,所以Redis的链表实现是一个双端链表。表头节点和表尾节点都指向NULL, 是一个无环链表。保存链表值的类型是void, 可以保持不同类型的值。

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
28
29
30
31
32
// 链表定义adlist/list
typedef struct list {
// 表头节点
listNode *head;

// 表尾节点
listNode *tail;

// 链表所浩瀚的节点数量
unsigned long len;

// 节点复制函数
void *(*dup) (void *ptr);

// 节点释放函数
void (*free) (void *ptr);

// 节点值对比函数
int (*match) (void *ptr, void *key);
} list;

//链表节点定义adlist.h/listNode
typedef struct listNode {
// 表头节点
struct listNode *prev;

// 表尾节点
struct listNode *next;

// 节点值
void *value;
} listNode;

list结构

字典

字典又称为符号表(symbol table), 关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。字典中的每一个键都是独一无二的。字典在Redis中应用相当广泛,比如Redis的数据库就是使用字典作为底层实现的,对数据库的CRUD也是建立在字典的操作上。字典还是哈希键的底层实现之一。
dict结构
字典的结构如上图所示,字典是由多个结构连接而成,首先是字典结构dict.h/dict

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct dict {

// 类型特定函数, 保存了一簇用于操作特定类型键值对的函数,
// Redis会为用途不同的字典设置不同的类型特定函数
dictType *type;

// 私有数据, 保存了需要传给那些类型特定的函数的可选参数
void *privdata;

// 哈希表, 注意这里hash表定义两个,其中一个是实际中使用的,
// 另一个是在扩展或收缩的时候使用的,类似于GC复制算法的原理
dictht ht[2];

// rehash 索引, 记录rehash目前的进度
// 当rehash不在进行时,值为-1
int trehashidx;
} dict;

字典所使用的哈希表dict.h/dictht

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct dictht {

// 哈希表数组
dictEntry **table;

// 哈希表大小
unsigned long size;

// 哈希表大小掩码,用于计算索引值
// 总是等于size-1
unsigned long sizemark;

// 该哈希表已有节点的数量
unsigned long used;
} dictht;

哈希表节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct dictEntry {

// 键
void *key;

// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;

// 指向下个哈希表节点,形成链表
// 使用next指针解决哈希冲突的问题
// 哈希算法为MurmurHash2
struct dictEntry *next;
} dictEntry;

随着操作的不断执行,哈希表保存的键值对会逐渐的增多或减少,为了让哈希负载因子(load factor)维持在一个合理的范围之内,当哈希表保持的键值对对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展或收缩。
为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehashht[1],而是分多次,渐进式地将ht[0]里面的键值对慢慢地rehashht[1]

跳跃表

跳跃表(skiplist)是一种有序数据结构, 它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(longN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。更多介绍参考wiki
Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。跳跃表的另一个应用就是作为集群节点中的内部数据结构。除了这两个地方,其它地方没有用到。
跳跃表有redis.h/zskiplistNoderedis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点信息。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*
* 跳跃表
*/
typedef struct zskiplist {

// 表头节点和表尾节点
struct zskiplistNode *header, *tail;

// 表中节点的数量
unsigned long length;

// 表中层数最大的节点的层数
int level;

} zskiplist;

/*
* 跳跃表节点
*/
typedef struct zskiplistNode {

// 成员对象
robj *obj;

// 分值
double score;

// 后退指针
struct zskiplistNode *backward;

// 层
struct zskiplistLevel {

// 前进指针
struct zskiplistNode *forward;

// 跨度
unsigned int span;

} level[];

} zskiplistNode;

skiplist结构

  • 层: 每个层带有两个属性,前进指针和跨度。前进指针用于访问表尾方向的其节点,而跨度则记录了前进指针所指向节点和当前节点的距离。上图中连线数字上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问就会沿着层的前进指针进行。每次创建一个新跳跃表节点的时候,程序根据幂次定律(power law, 越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的高度。
  • 前进指针: 每个层都有一个指向表尾方向的前进指针(level[i].forward属性), 用于从表头向表尾方向的访问节点。
  • 后退指针: 节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
  • 分值: 各个节点中的1.0,2.0和3.0是节点所保存的分值。在跳跃表中,节点各自所保存的分值从小到大排列。 跳跃表中的节点按照分值进行排序,当分值相同时,节点按照成员对象的大小进行排序。
  • 成员对象: 各个节点中的o1, o2和o3是节点所保存的成员对象。

具体的操作过程参考http://blog.csdn.net/ict2014/article/details/17394259

整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合键的底层实现。
每个intset.h/intset结构表示一个整数集合:

1
2
3
4
5
6
7
8
9
10
tyepdef struct intset {
// 编码方式, 决定contents的类型
uint32_t encoding;

// 集合包含的元素数量
uint32_t length;

// 保持元素的数组
int8_t contents[];
} intset;

intset结构
contents数组是整数集合的底层实现: 整数集合的每个元素都是contents数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。虽然contetns声明为int8_t类型的数组,但实际上contents并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。整数集合不支持降级操作, 一旦对数组进行了升级,编码就会一致保持升级后的状态。

压缩列表

压缩列表(ziplist)是列表建和哈希键的底层实现之一。当一个列表建只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表建的底层实现。
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保持一个字节数组或一个整数值。
ziplist结构
压缩列表各个组成部分的详细说明:

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用
zltail uint32_t 4字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
zllen uint16_t 2字节 记录了压缩列表包含的节点数量,当节点数小于UINT16_MAX时取这个值,大于时需要遍历列表才能得出
entryX 列表节点 不定 压缩列表包含的节点,节点的长度由节点保存的内容决定
zlend uint8_t 1字节 特殊值0xFF(十进制255),用于标记压缩列表的末端

压缩列表的节点构成:

  • previous_entry_length: 以字节为单位,记录了压缩列表中前一个节点的长度。只要我们拥有了一个指向某个节点的起始地址的指针,那么通过这个指针及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终达到压缩列表的表头节点。
  • encoding: 记录了节点的content属性所保存数据的类型及长度。
  • content: 负责保存节点的值,节点值可以使一个字节数组或整数,值的类型和长度由节点的encoding属性决定。

连锁更新问题是指当插入新节点或删除节点后,previous_entry_length属性所记录的长度不能够满足改变后的节点的记录,需要扩容以便记录,最差的情况是后面的每个节点都会改变位置。最差的复杂度为O(N^2)。但是这种情况很少见,一般复杂度为O(N)。

对象

前面介绍了Redis的主要数据结构,但是Redis并没有直接使用这些数据结构实现键值对数据库,而是基于这些数据结构创建了一个对象系统, 这个系统包含字符串对象,列表对象,哈希对象,集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们面前所介绍的数据结构。我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率,而这些对用户是透明的。
Redis的对象系统还实现了基于引用计数技术的内存回收机制(GC), 当程序不再是由某个对象的时候,这个对象所占用的内存就会被自动释放;另外Redis还通过引用计数法实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。
每当我们在Redis数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct redisObject {

// 类型
unsigned type:4;

// 编码
unsigned encoding:4;

// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

// 引用计数
int refcount;

// 指向实际值的指针
void *ptr;

} robj;

使用TYPE命令可以看到对象的类型,对象的类型及type属性的值对应关系如下表:

type类型常量 对象的名称 TYPE命令的输出
REDIS_STRING 字符串对象 “string”
REDIS_LIST 列表对象 “list”
REDIS_HASH 哈希对象 “hash”
REDIS_SET 集合对象 “set”
REDIS_ZSET 有序集合对象 “zset”

每种TYPE对象的底层编码都是由上面说的数据结构组成的,使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码,具体的对应关系如下表:

对象所使用的底层数据结构 编码常量 OBJECT ENCODING命令输出
整数 REDIS_ENCODING_INT “int”
embstr编码的简单动态字符串(SDS) REDIS_ENCODING_EMBSTR “embstr”
简单动态字符串 REDIS_ENCODING_RAW “raw”
字典 REDIS_ENCODING_HT “hashtable”
双端链表 REDIS_ENCODING_LINKEDLIST “linkedlist”
压缩列表 REDIS_ENCODING_ZIPLIST “ziplist”
整数集合 REDIS_ENCODING_INTSET “intset”
跳跃表和字典 REDIS_ENCODING_SKIPLIST “skiplist”

每种类型对象可以使用哪些数据结构,下面做了一个总结:

对象类型 “int” “embstr” “raw”
“string” 如果字符串对象保存的是整数值,并且其可以用long类型来表示 如果字符串对象保持的是一个字符串值,并且其长度小于39字节 如果字符串对象保持的是一个字符串值,并且其长度大于39字节
对象类型 “linkedlist” “ziplist”
“list” 不满足ziplist的条件的情况 同时满足:
1. 所有字符串元素的长度都小于64字节;
2. 元素数量小于512个
对象类型 “hashtable” “ziplist”
“hash” 不满足ziplist的条件的情况 同时满足:
1. 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
2. 哈希对象保存的键值对数量小于512个
对象类型 “intset” “hashtable”
“set” 同时满足:
1. 集合对象保存的所有元素都是整数值;
2. 集合对象保存的元素数量不超过512个
不满足”intset”的条件的情况
对象类型 “ziplist” “skiplist”
“zset” 同时满足:
1. 有序集合保持的元素数量小于128个;
2. 有序集合保持的所有元素成员的长度都小于64字节
不满足”ziplist”的条件的情况

redisObject有一个lru属性,这个属性记录了对象最后一次被命令程序访问的时间,OBJECT IDLETIME命令可以打印出给定键的空转时长(当前时间-lru时间), 另外当开启maxmemory选项,并且服务器用于内存回收的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

垃圾回收进阶算法

了解GC的基本算法后,还需要了解各种改进的GC算法,这些算法是在之前的基础上进行扩展和组合的。主要包括GC标记-压缩算法, 保守式GC, 分代垃圾回收增量式垃圾回收RC Immix算法等。

GC标记-压缩算法

GC标记-压缩算法(Mark Compact GC)是将GC标记-清除算法与GC复制算法相结合的产物。 GC标记-压缩算法由标记阶段和压缩阶段构成。标记阶段和GC标记-清除算法提到的标记阶段一样。接下来需要搜索数次的堆来进行压缩。压缩阶段通过数次搜索堆来重新装填活动对象。

Lisp2算法

标记阶段的代码就不重复了,这里主要看压缩阶段的代码,下面可以看出压缩阶段主要分为三个步骤:

  1. 第一步是set_forwarding_ptr, 主要是按顺序遍历堆内的活动对象,每个活动对象的forwarding指针指向的是以后这个活动对象需要移动到的位置。
  2. 第二步是adjust_ptr, 遍历整个活动对象,复制他们之间的引用关系, 这个步骤只更新指针。
  3. 第三步move_obj, 遍历整个堆,对活动对象进行移动。
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
compaction_phase() {
set_forwarding_ptr()
adjust_ptr()
move_obj()
}

set_forwarding_ptr() {
scan = new_address = $head_start
while(scan < $head_end)
# 对被标记的对象,forwarding指针指向应该移动到的位置
if(scan.mark == TRUE)
scan.forwarding = new_address
new_address += scan.size
# 遍历整个堆
scan += scan.size
}

adjust_ptr() {
# 移动根指针
for(r : $roots)
*r = (*r).forwarding

scan = $head_start
while(scan < $head_end)
# 每个活动对象,原来指向子节点的指针改为指向直接点的forwarding指向的地址
if(scan.mark == TRUE)
for(child : children(scan))
*child = (*child).forwarding
scan += scan.size
}


move_obj() {
scan = $free = $head_start
# 遍历堆
while(scan < $head_end)
if(scan.mark == TRUE)
new_address = scan.forwarding
# 移动当前对象到对象forwarding指针指向的地址
copy_data(new_address, scan, scan.size)
# 移动完活动对象后清空指针和标记,防止再次移动
new_address.forwarding = NULL
new_address.mark = FALSE
# $free最终是压缩后可分配空间的开始
$free += new_address.size
scan += scan.size
}

上面的步骤可以用下面的图形化的例子来描述:
首先假设原始状态如下:
原始状态
先对其进行标记:
标记后
设定forwarding指针:
设定forwarding指针
更新指针:
更新指针
移动对象:
移动对象
上面可以看出,整个过程只是把活动对象往一边移动,活动对象之间的顺序不变。

  • 优点: 这个算法相对其他算法而言,堆利用率高,而且所有活动对象压缩到一端,不存在碎片化,能够充分的利用堆。
  • 缺点: 整个压缩过程需要3遍对堆的搜索,也就是执行该算法所花费的时间与堆大小成正比,吞吐量要劣于其他算法。

Two-Finger算法

Two-Finger算法由两个步骤构成:

  1. 移动对象
  2. 更新指针

我们知道Lisp2算法是把所有对象向右滑动,不改变活动对象的顺序,而Two-Finger算法则是真正的移动对象,把后面的活动对象移动到前面的空间。为了防止对象相互覆盖,必须要将所有对象整理成大小一致, 这个该算法的一个前提条件。另外Lisp2算法需要单独设置forwarding指针,但是Two-Finger算法可以利用对象的域来设定forwarding指针,不要单独占空间。
两个步骤对象的伪代码如下, 要说明的是move_obj函数有两个指针:$free, 从头往后找,找空闲的空间; live,从后往前找,找活动对象。这两个指针就是Two-Finger的名称由来。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
move_obj() {
#从头开始找空闲空间
$free = $heap_start
#从尾开始找活动对象
live = $heap_end - OBJ_SIZE
while(TRUE)
#free, 是活动对像就略过,继续往后找
while($free.mark == TRUE)
$free += OBJ_SIZE
#live, 是活动对象就略过,继续往前找
while(live.mark == FALSE)
live -= OBJ_SIZE
# free 指针 比 live小,证明还没有结束,否则证明查找结束了
if($free < live)
#把live指向的对象复制到free地址
copy_data($free, live, OBJ_SIZE)
#live指向的对象的forwarding指针指向新地址,为下一步更新指针做准备
live.forwarding = $free
#移动过的对象标记位FALSE
live.mark = FALSE
else
break
}

adjust_ptr() {
for(r : $roots)
#*r>=$free的条件是对于被移动过的对象执行指针更新,没有移动过的对象保持原样
if(*r >= $free)
*r = (*r).forwarding

scan = $head_start
#scan < $free 是因为对于大于scan的节点已经失效,只对当前活动对象更新
while(scan < $free)
#更新过的标记一下
scan.mark = FLASE
for(child : children(scan))
#*child >= $free 的条件是对于被移动过的对象执行指针更新,
# 没有移动过的对象保持原样
if(*child >= $free)
*child = (*child).forwarding
scan += OBJ_SIZE
}
  • 优点: 不需要额外的内存存储forwarding指针,内存使用效率比Lisp2高,只搜索两次堆,吞吐量也更好.
  • 缺点: 压缩后对象的顺序发生了很大变化,不利于缓存的使用。而且每个对象大小必须一致,限制比较多。

表格算法

表格算法是综合了Lisp2和Two-Finger两种算法优点的算法。其主要步骤也是有两部分:

  1. 移动对象(群)以及构筑间隙表格(break table)
  2. 更新指针

前面两个每次都是移动一个活动对象,而在表格算法种每次移动的是一个群连续的活动对象,更新指针所有的信息也不再是forwarding指针,而是是有个一个叫间隙表格的方法。间隙表是由两个值组成的,其中每个表格代表的是一个活动对象群的入口,左值代表活动对象群的首地址,右值代表活动对象群所相邻的前面的空间占分块的总大小。
第一步过程可以用伪代码来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
movie_obj(){
#从头开始遍历
scan = $free = $heap_start
size = 0
while(scan < $head_end)
while(scan.mark == FLASE)
# size 记录相邻的非活动对象的大小
size += scan.size
scan += scan.size
# 记录活动对象的首地址
live = scan
while(scan.mark == TRUE)
scan += scan.size
# 上面两个while后,找到了第一个连续的非活动空间和第一个连续的活动空间
# 移动活动对象群,并构筑间隙表格
slide_objs_and_make_bt(scan, $free, live, size)
# 移动后记录下一个空闲空间地址
$free += (scan -live)
}

slide_objs_and_make_bt函数是一个比较复杂的过程,它主要由两部分组成:

  1. 移动对象群
  2. 移动间隙表格

可以用下面的图表示:
首先执行完上面代码到slide_objs_and_make_bt之前:
间隙表格
执行slide_objs_and_make_bt后, 移动了对象群,并且在空出来的空间里记录了间隙表格, 左值100表示对象群首地址B的地址,右值100表示B之前的空白块长度为100
间隙表格
再次执行slide_objs_and_make_bt后,F开头的对象群也进行了移动,并且把两个活动对象群对应的间隙表格都放到了空白块中,第二个间隙表格的550表示F的起始地址,右值300表示第一次执行slide_objs_and_make_bt后,第一个活动对象群的末尾到第二个活动对象群的开始,正好是6块,也就是上图$freelive的size大小是300。执行完最终结果如下:
间隙表格

第二步更新指针的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
adjust_ptr() {
for(r : $roots)
*r = new_address(*r)

scan = $heap_start
# 对活动对象更新指针
while(scan < $free)
scan.mark = FALSE
for(child : children(scan))
*child = new_address(*child)
scan += scan.size
}

# 找到活动对象对应的应该跟新到的指针地址
new_address(obj) {
best_entry = new_bt_entry(0,0)
for(entry : break_table)
if(entry.address <= obj && $best_entry.address < entry.address)
best_entry = entry
return obj - best_entry.size
}

上面的new_address函数比较难理解,就是需要从多个间隙表格中找到活动对象群所对应的,然后利用obj-best_entry.size 就返回节点对应的新地址。

优点: 首先内存利用率和Two-Finger一样,但是由于是保持了原来的顺序,所以可以利用缓存。
缺点: 每次移动都要进行表格的移动和更新,代价比较高。

ImmixGC 算法

暂略……

保守式GC

前面提到过GC是根据对象的指针指向去搜寻其他对象的。另一方面,GC对非指针不进行任何操作。另外可以认为调用栈、寄存器以及全局变量空间都是根。对于上面存在一个问题就是: 如何识别一个变量是否是指针? 这里所说的保守式GC就是指”不能识别指针和非指针的GC”, 而准确式GC指的就是能够正确识别指针和非指针的GC。

保守式GC

之前说的下面这些空间都是根:

  • 寄存器
  • 调用栈
  • 全局变量空间

但是事实上他们都是不明确的根(ambiguous roots)。
保守式GC对检查不明确的根时,所进行的基本项目是:

  • 是不是被正确对齐的值? (32位CPU,为4的倍数;64位CPU为8的倍数; 其他情况被视为非指针)
  • 是不是指着堆内? (分配了GC专用堆,对象就会被分配到堆里,指向对象的指针按道理肯定指向堆内,否则就是非指针)
  • 是不是指着对象的开头?(如果把对象固定大小对齐,例如”BiBOP”法,如果对象的值不是固定大小的倍数,就是非指针)

当不明确的根运行GC时,偶尔会出现非指针和堆里的对象的地址一样的情况,这时就无法识别这个值是非指针,这就是“貌似指针的非指针”(false pointer), 保守式GC这种把”貌似指针的非指针”看成”指向对象的指针”叫做”指针的错误识别”。在采用GC标记-清除算法,这种非指针会被错误的识别为活动对象,不会被回收。这样采取的是一种保守的态度,这样处理也不会出现问题。

  • 优点: 容易编写语言处理程序
  • 缺点: 识别指针和非指针需要付出成本;错误识别指针会压迫堆, 会占用堆空间;能够使用的GC算法有限,不能使用移动对象的GC算法,否则就会重新非指针,照成意想不到的BUG

准确式GC

准确式GC是基于正确识别指针和非指针的“正确的根”(exact roots)来执行GC的。要想创建正确的根,就需要”语言处理程序的支援”, 依赖语言处理程序的实现。常见的方法这里介绍两种:

  • 打标签: 通过打标签的方法把不明确的根里的所有非指针和指针都区别开来。
  • 不把寄存器和栈当做根: 创建一个正确的根来管理,这个正确的根在处理程序里只集合了mutator可能到达的指针,然后以它为基础执行GC。 参考Rubinius语言处理程序的实现。

  • 优点: 相对于保守式GC,能够正确识别指针和非指针,适用的GC方法也更广泛。

  • 缺点: 需要语言处理程序的支援,给实现者带来负担。

间接引用

保守式GC有一个缺点就是”不能使用GC复制算法等移动对象的算法”, 因为如果是非指针的对象发生移动,其值就会发生变化,使用这个对象就会出现问题。解决这个问题的方法就是使用”间接引用”
结合下图来说明:
复制前可以看到根和对象之间有句柄。每个对象都有一个句柄,它们分别持有指向这些对象的指针。并且局部变量和全局变量这些不明确的根里没有指向对象的指针,只装着指向句柄的指针(如图中的1,2,3), 下图中的1,2表示指针,3表示非指针。
间接引用1
复制之后移动了引用目标的对象,只修改了1,2是指针的值,非指针3的值并没有发生改变。
间接引用2

  • 优点: 可以适用于更多的GC算法
  • 缺点: 所有对象都要经由句柄间接引用,回拉低访问对象内数据的速度。

MostlyCopyingGC

又是一个为了能够执行GC复制算法的保守式GC, 这个算法的核心思想就是抛开那些不能移动的对象,将其他”大部分”的对象都进行复制的GC算法,目的是为了保证不能移动的对象一定不会移动,可以移动的对象大部分都移动了,保证不出现BUG。
这个算法执行的前提条件:

  1. 根是不明确的根
  2. 没有不明确的数据结构
  3. 对象大小随意

执行这个算法的要点是把堆分配成一定大小的页(page)组成,执行分配的时候从正在使用的页里分配,如果空间不够则使用空页,如果一个页放不下,则会跨页存储。
执行GC时把所有根直接引用的页升级为To空间,然后再把To页对象的子对象复制到空页。这个过程会保留根直接引用的对象,所以不会复制非指针对象。同时升级的页中也包含了垃圾对象吗,无法清除。

黑名单

保守式GC指针的错误识别所带来害处和这个对象的大小及其子对象的数量有关系,如果一个对象很大,或者子对象很多,却被识别为”还活着”, 那就会在占用很多的堆空间。
这里的黑名单记录的是”不明确的根内的非指针,其指向的是有可能被分配对象的地址”, 这里说的”有可能被分配对象的地址”指的是”堆内未使用的对象的地址”。mutator无法引用至今未使用过的对象。也就是说,如果根里存在有这种地址的指针,那它肯定就是”非指针”,就会被记入黑名单中。在分配对象过程中,如果要分配的地址在黑名单中,这个对象有可能被非指针值所引用。也就是说,及时分配后对象成了垃圾,也很有可能被错误识别为”还活着”。为此,对象分配到这种地址是要满足:

  • 小对象
  • 没有子对象的对象

这样及时错误识别了,对整个堆的影响也不大,把对堆的压迫控制在最低限度。

分代垃圾回收

分代垃圾回收(Generational GC)把对象按“年龄”进行分类,使用不同的GC算法, 提高垃圾回收的效率。年龄的概念就是指对象的生存时间,经历一次GC后活下来的对象年龄就是1,依次类推。 新生成的对象和年龄小于一定值得对象都称为新生代对象, 年龄大于一定值得对象则称为老年代对象, 这就是所谓的分代。新生代对象经历一定GC后会变成老年代对象,这个过程就叫晋升(promotion)

Ungar 的分代垃圾回收

Ungar 的垃圾回收是针对新生代执行GC复制算法,针对老年代执行标记-清除算法。Ungar 将堆结构分为四个部分,分别是生成空间、2个大小相等的幸存空间以及老年代空间,并分别用$new_start$survivor1_start$survivor2_start$old_start这4个变量引用它们的开头。将生成空间和幸存空间合称为新生代空间。
当生成空间满了的时候,新生代GC就会启动,将生成空间的所有活动对象复制,这根GC复制算法是一个道理。目标空间是幸存空间中空闲的一个。

      记 录 集
    +---+---+---+---+
$rs |   |   |   |   |
    +---------------+
    +------------------------+ $new_start
    |              +--------------+   $survivor1_start
    |              |     +-------------+ $survivor2_start
    |              |     |     +-----------+  $old_start
    |              |     |     |                           堆
    v--------------v-----v-----v-----------------------------+
    |              |     |     |                             |
    |              |     |     |                             |
    |              |     |     |                             |
    +--------------+-----+-----+-----------------------------+
     生 成 空 间     幸 存 空 间            老 年 代 空 间
           新 生 代 空 间

分代垃圾回收的优点是只将垃圾回收的重点放在新生代对象上,以此来缩减GC所需的时间。但是老年代有可能引用了新生代对象,所以还需要遍历老年代对象,这样就大大削减了分代垃圾回收的优势,所以为了解决这个问题,又增加了一个记录集。记录集里记录的是对新生代有引用的老年代对象。这样在新生代GC时,只需要再对记录集进行遍历就行了。
为了将老年代对象记录到记录集里,我们利用写入屏障(write barrier)。在mutator更新对象间的指针操作中,写入屏障是不可或缺的。

1
2
3
4
5
6
7
8
9
write_barrier(obj, field, new_obj) {
if(obj >= $old_start #发出引用的对象在老年代里
&& new_obj < $old_start #新生成的对象在新生代里
&& obj.remembered == FALSE) #老年代对象没有被记录
$rs[$rs_index] = obj #老年代对象加入记录集
$rs_index++
obj.remembered = TRUE #表示已经被记录过
*field = new_obj #field是obj的指针,更新指针new_obj成为引用目标的对象
}

分配是在生成空间进行的,执行分配的new_obj()函数伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new_obj(size) {
if($new_free + size >= $survivor1_start)
# 生成空间不够用,执行新生代GC
minor_gc()
if($new_free + size >= $survivor1_start)
# 执行GC后仍然不够用,返回错误
allocation_fail()

obj = $new_free #$new_free 是指向生成空间的分块开头的指针
$new_free += size
obj.age = 0 #年龄默认值
obj.forwarded = FALSE #防止重复复制相同对象的标志,跟GC复制算法和GC标记-压缩算法中的作用一样
obj.remembered = FALSE #是否在记录集里,只用于老年代对象
obj.size = size
return obj
}

新生代GC的伪代码如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
minor_gc() {
$to_survivor_free = $to_survivor_start
#根在新生代的对象进行GC复制
for(r : $roots)
if(*r < $old_start)
*r = copy(*r)

i = 0
#对记录集里的对象的子节点进行GC复制
while(i < $rs_index)
has_new_obj = FALSE
for(child : children($rs[i]))
if(*child < $old_start)
*child = copy(*child)
if(*child < $old_start)
has_new_obj = TRUE
# TRUE表示复制后的对象在新生代,FALSE表示复制后的对象在老年代
# 复制后的对象在老年代,则需要把这个对象从记录集里去掉
if(has_new_obj == FALSE)
$rs[i].remembered = FALSE
$rs_index--
#最后一位与当前节点交换,交换后,最后一位无法在访问到,可以认为是从记录集里去掉了
swap($rs[i], $rs[$rs_index])
else
i++
#交换From空间和To空间
swap($from_survivor_start, $to_survivor_start)
}

# 对象的复制
copy(obj) {
#没有被复制
if(obj.forwarded == FALSE)
#年龄没有达到
if(obj.age < AGE_MAX)
copy_data($to_survivor_free, obj, obj.size)
# 标识已经被复制
obj.forwarded = TRUE
# 被复制到的地址
obj.forwarding = $to_survivor_free
# age++
$to_survivor_free.age++
$to_survivor_free += obj.size
for(child : children(obj))
*child = copy(*child)
else
# 年龄达到,晋升到老年代
promote(obj)
return obj.forwarding
}

# 对象从新生代晋升到老年代
promote(obj) {
#从老年代找空间
new_obj = allocate_in_old(obj)
if(new_obj == NULL)
#空间不够执行老年代的GC,跟GC标记-清除法一样
major_gc()
new_obj = allocate_in_old(obj)
if(new_obj == NULL)
allocation_fail()
obj.forwarding = new_obj
obj.forwarded = TRUE

for(child : children(new_obj))
if(*child < $old_start)
$rs[$rs_index] = new_obj
$rs_index++
new_obj.remembered = TRUE
return
}

分代垃圾回收是建立在”很多对象年纪轻轻就会死”的基础上的,所以满足这种条件时,可以改善GC所花费的时间,提高吞吐量。是但是因为老年代GC很费时,所以没办法缩短mutator的最大暂停时间。并且如果不满足上面的条件时,就没办法利用到分代垃圾回收的优势。

记录各代之间的引用的方法

Ungar 分代垃圾回收的记录集是不可少的,但是这个记录集会浪费很多空间,为了提高内存利用率,可以通过下面两种方法:

  • 卡片标记: 把老年代空间等分成N个卡片,每份假设129字节(1024位),可以用表格表格中位图的一位表示一个卡片,这样能够有效提高内存空间(只需老年代的1/1024)。当标记表格设置很多位时,可能就会在搜索卡片上花费大量时间。
  • 页面标记: 利用OS的页面管理,如果在卡片标记中奖卡片和页面设置为同样大小,我们就能得到OS的帮助。一旦mutator对堆内的某一个页面进行写入操作,OS就会设置跟这个页面对应的位,我们把这个位叫做页面重写标志位(dirty bit)。卡片标记中是搜索标记表格,而页面标记则是搜索这个页面的重写标志位。

多代垃圾回收

分代垃圾回收是把对象分为新生代和老年代两个,也可以分成3个及更多个, 分代越多,对象变成垃圾的机会也就越大,所以这个方法确实能够减少活到最老代的对象。但是每代的空间也就相应的变小了,这样一来各代之间的引用就变多了,各代中垃圾回收花费的时间也就越来越长了。综合来看,少设置一些分代能得到更优秀的吞吐量,据说分为2代或3代是最好的。

列车垃圾回收

Ungar 分代垃圾回收的一个问题是不能够减少最大暂停时间,而列车垃圾回收(Train GC)就是为了控制老年代GC中暂停时间的增长而设计的。列车垃圾回收中将老年代空间按照一定的大小划分,每个划分出来的空间称为车厢,多个车厢有组成列车,多个列车一起组成了老年代空间。1次老年代GC不再是对整个老年代空间进行,而是以1个车厢作为GC对象。
下面这幅图反应的是列车垃圾回收的堆结构:
列车垃圾回收堆结构
具体过程省略……

  • 优点: 缩减了老年代GC照成的mutator的最大暂停时间。还能回收循环的大型垃圾。
  • 缺点: 执行写入屏障的额外负担要比Ungar的分代垃圾回收中执行时所产生的更大,因此吞吐量上要弱一些。

增量式垃圾回收

增量式垃圾回收(Incremental GC)是一种通过逐渐推进垃圾回收来控制mutator最大暂停时间的方法。之前介绍的GC算法,一旦GC开始执行,mutator就没有办法执行了,像这样的GC叫做听执行GC。为了改变这种方式,想出了一种GC和mutator交替运行的方式,这就是增量垃圾回收。

三色标记算法

这个算法将GC中的对象按照各自情况分成三种:

  • 白色: 还未搜索过的对象
  • 灰色: 正在搜索的对象
  • 黑色: 搜索完成的对象

以GC标记-清除算为例,应用到三色标记算法中。默认对象都是白色,GC一旦运行,所有从根能够到达的对象都会被标记,然后放到栈里。放到栈里的对象被标记成灰色,然后栈里的对象依次弹出,搜索其子对象,子对象也被标记成灰色。当其所有的子对象都被标记成灰色时,该对象就被标记成黑色。当GC结束时已经不存在灰色对象了,活动对象全部为黑色,垃圾对象则为白色。
增量式的GC标记-清除算法可以分为以下三个阶段:

  • 根查找阶段
  • 标记阶段
  • 清除阶段

下面是过程的伪代码,所谓标记为灰色并不是真正的标记为灰色,而是标记位TRUE,并放到栈中;置为黑色则只是标记为TRUE; 标记位白色的就是obj.mark=FALSE

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
incremental_gc() {
case $gc_phase
when GC_ROOT_SCAN
root_scan_phase() #根查找阶段
when GC_MARK
incremental_mark_phase() #增量标记阶段
else
incremental_sweep_phase() #增量清除阶段
}

#根查找函数
root_scan_phase() {
for(r : $roots)
mark(*r)
$gc_phase = GC_MARK
}

mark(obj) {
if(obj.mark == FALSE)
obj.mark = TRUE
push(obj, $mark_stack) #灰色对象放到栈里
}

#增量标记
incremental_mark_phase() {
for(i : 1..MARK_MAX) # MARK_MAX每次从栈中弹出对象的次数
if(is_empty($mark_stack) == FALSE)
obj = pop($mark_stack) #从栈中弹出灰色对象, 标记其子对象
for(child : children(obj))
mark(*child)
else
#栈为空,重新从根开始查找
for(r : $roots)
mark(*r)
#从根查找完继续标记
while(is_empty($mark_stack) == FALSE)
obj = pop($mark_stack)
for(child : children(obj))
mark(*child)
#为清除阶段做准备
$gc_phase = GC_SWEEP
$sweeping = $heap_start
return
}

#写入屏障,对于新节点,需要标记为灰色
#如果没有这一步,标记阶段进行到一半有可能不会对新的节点进行搜索
write_barrier(obj, field, newobj) {
if(newobj.mark == FALSE)
newobj.mark = TRUE
push(newobj, $mark_stack)

*field = newobj
}

#清除阶段
incremental_sweep_phase() {
swept_count = 0
while(swept_count < SWEEP_MAX) #每次清除SWEEP_MAX个对象
if($sweeping < $heap_end)
if($sweeping.mark == TRUE)
$sweeping.mark = FALSE
else
#mark=false表示白色,放入到空闲链表中
$sweeping.next = $free_list
$free_list = $sweeping
$free_size += $sweeping.size

$sweeping += $sweeping.size
swept_count++
else
$gc_phase = GC_ROOT_SCAN
return

}

#分配
newobj(size) {
#$free_siz 小于一定量时就执行GC, 而不是等到空间枯竭
if($free_size < HEAP_SIZE * GC_THRESHOLD)
incremental_gc()

chunk = pickup_chunk(size, $free_list)
if(chunk != NULL)
chunk.size = size
$free_size -= size
#chunk如果在清除阶段在要清除的空间,需要涂黑,表示不可回收
if($gc_phrase == GC_SWEEP && $sweeping <= chunk)
chunk.mark = TRUE
return chunk
else
allocation_fail()
}

可以看到上面整个过程,分配和GC是交替进行的,而且GC的三个阶段也是按顺序循环进行的,每次执行incremental_gc()都会进入下一个阶段。

  • 优点: 增量式垃圾回收不是一口气运行GC,而是和mutator交替运行的,因此不会长时间妨碍到mutator的运行。
  • 缺点: 牺牲了吞吐量。吞吐量和最大暂停时间是互相权衡的,一方面做的好另一方面就会变差。

Steele的算法

这个算法中使用的写入屏障要比上面(Dijkstra)的写入屏障条件更严格,它能减少GC中错误的标记的对象。
这个算法的标记函数如下:

1
2
3
4
mark(obj) {
if(obj.mark == FALSE)
push(obj, $mark_stack)
}

可以看出在放入栈时并没有标记obj.mark=TRUE, 也就是说这个算法的灰色对象是指”堆在标记栈里的没有设置标志位的对象”, 黑色对象是”设置了标志位的对象”。
写入屏障的伪代码也不一样:

1
2
3
4
5
6
7
8
9
write_barrier(obj, field, newobj) {
if($gc_phase == GC_MARK &&
obj.mark == TRUE &&
newobj.mark == FALSE)
obj.makr = FALSE
push(obj, $mark_stack)

*field = newobj
}

上面代码主要是判断如果在标记过程中发出引用的对象是黑色对象,且新的引用的目标对象为灰色或白色,那么我们就把发出引用的对象涂成灰色。Steele的写入屏障通过限制标记对象来减少被标记的对象,从而防止了因疏忽而造成垃圾残留的后果。 (详情参见P175)

汤浅的算法

汤浅的算法中标记阶段并没有在搜索根,遵循了”以GC开始时对象间的引用关系为基础执行GC”这项原则。

1
2
3
4
5
6
7
8
9
10
11
incremental_mark_phase() {
for(i : 1..MARK_MAX)
if(is_empty($mark_stack) == FALSE)
obj = pop($mark_stack)
for(child : children(obj))
mark(*child)
else
$gc_phrase = GC_SWEEP
$sweeping = $heap_start
return
}

上面通过写入屏障防止产生从黑色对象指向白色对象的指针,而汤浅的算法中却允许黑色对象指向白色对象的指针。汤浅算法是基于在GC开始时保留活动对象这项原则,就没有必要在生成新指针时标记引用对象的目标了。及时出现了从黑色对象指向白色对象的指针,只要保留了GC开始时的指针,作为引用目标的白色对象早晚会被标记。但是在删除指针时无法保留指针,因此写入屏障要进行一些特殊处理:

1
2
3
4
5
6
7
8
9
write_barrier(obj, field, newobj) {
oldobj = *field
#在标记阶段中如果指针更新前引用的oldobj是白色对象,就将其涂成灰色
if(gc_phase == GC_MARK && oldobj.mark == FALSE)
oldobj.mark = TRUE
push(oldobj, $mark_stack)

*field = newobj
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#分配
newobj(size) {
if($free_size < HEAP_SIZE * GC_THRESHOLD)
incremental_gc()

chunk = pickup_chunk(size, $free_list)
if(chunk != NULL)
chunk.size = size
$free_size -= size
#这里跟之前不一样,分配后会设置obj为黑色
if($gc_phase == GC_MARK)
chunk.mark = TRUE
else if($gc_phase == GC_SWEEP && $sweeping <= chunk)
chunk.mark = TRUE
return chunk

else
allocation_fail()
}

RC Immix算法

垃圾回收基本算法

本章介绍GC的基本算法:GC标记-清除法,引用计数法, GC复制算法。这三种我认为是GC的三个方向的基本思维。其他方法都是围绕这个些基本方法展开的。

GC标记-清除法

基本方法

所谓的标记-清除法,依据其字面意思就是,先做标记,然后在清除。这个过程分为两个阶段,标记阶段就是把所有活动对象坐上标记,清除阶段就是把那些没有做标记的对象,也就是非活动对象回收的阶段。利用伪代码表示就是:

1
2
3
4
mark_sweep() {
mark_phase()
sweep_phase()
}
  • 标记阶段: 这个阶段从出发,利用深度优先遍历(不用广度优先是因为深度优先搜索比广度优先搜索更能压低内存使用量。), 对每个能到达的活动对象都做上标记(用一个位来表示)。这个阶段所花费的时间与”活动对象的总数”成正比。标记阶段伪代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    mark_phase() {
    #遍历根节点, 进行标记
    for(r: $roots)
    mark(*r)
    }
    #标记函数
    mark(obj) {
    if(obj.mark == FALSE)
    obj.mark = TRUE
    #深度优先遍历
    for(child : children(obj))
    mark(*child)
    }
  • 清除阶段: 清除阶段主要工作是通过遍历整个堆,把未被标记的对象(非活动对象)回收再利用。回收对象就是把对象作为分块,连接到被称为”空闲链表”的单向链表。之后进行分配时遍历空闲链表就可以找到分块了。两个相邻的分块如果地址是连续的,就会对其进行合并, 合并操作可以减少碎片的发生。清除阶段的伪代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    sweep_phase() {
    sweeping = $heap_start
    #遍历堆
    while(sweeping < $head_end)
    if(sweeping.mark == TRUE)
    sweeping.mark == FALSE
    else
    #放入空闲链表
    if(sweeping.mark == $free_list + $free_list.size)
    #合并
    $free_list.size += sweeping.size
    else
    sweeping.next = $free_list
    $free_list = sweeping
    sweeping += sweeping.size
    }
  • 分配: 进行mutator申请分块时,搜索空闲链表并找到合适大小的分块,这个过程就叫做分配。找到合适的分块大小有三种策略:

    1. First-fit: 找到最初发现大于等于size的分块就立刻返回。考虑到分配所需的时间,标记清除法选择的就是这种方法。
    2. Best-fit: 遍历空闲链表,找到大于等于size的最小分块返回。
    3. Worst-fit: 找出最大的分块,把分块分割成size大小和剩余分块。
      分配阶段的伪代码:
      1
      2
      3
      4
      5
      6
      7
      new_obj(size) {
      chunk = pickup_chunk(size, $free_list)
      if(chunk != NULL)
      return chunk
      else
      allocation_fail()
      }

优点/缺点

  • 优点:
    1. 实现简单
    2. 与保守式GC算法兼容: 保守式算法就是不知道对象是否是指针,所以移动对象会造成错误(后面会讲到), 而标记清除算法是不会移动对象的,所以是兼容的。
  • 缺点:
    1. 碎片化: 由于非活动对象分布不均匀,容易照成堆内的内存空间碎片化,不利于mutator的执行。
    2. 分配速度: 由于分配时需要遍历空闲链表,查找速度取决于要分配的块和空闲链表的分布。后面要讲到的复制算法和标记-压缩算法由于分块是连续内存分布的,所以速度要快。
    3. 与写时复制技术不兼容: 因为每次GC都要修改活动对象的标记位,导致写操作的发生,从而产生复制。

多个空闲链表

为了提高分配速度,一个改进就是把分块按照大小分为多个空闲链表,这样在分配的时候就可以根据要分配的空间的大小去对应的空闲链表中寻找,大大减少了查找分块的时间。
下面是利用多个空闲链表的new_obj()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new_obj(size){
#index 是一个要分配的字的大小
index = size / (WORD_LENGTH / BYTE_LENGTH)
#空闲链表一共有101个,0-100都是按照字精确分配到对应的$free_list[index]中,
#大于100的字都分配到$free_list[101]中
if(index <= 100)
if($free_list[index] != NULL)
#直接找到对应的空闲链表
chunk = $free_list[index]
$free_list[index] = $free_list[index].next
return chunk
else
#大于100的需要遍历$free_list[101]找到合适大小的块
chunk = pickup_chunk(size, $free_list[101])
if(chunk != NULL)
return chunk

allocation_fail()
}

BiBOP法

针对标记-清除算法的碎片化问题, 可以把堆先分割成大小固定的块,让每个块只能配置同样大小的对象,这就是BiBOP法。如果某个大小字的活动对象很少,其他的字活动对象很多的话,这种情况也不能提高堆的利用率,无法解决碎片化的问题。

位图标记法

上面还说道标记-清除法不能够与写时复制技术兼容是因为修改标记位会引起复制发生,为了解决这个问题,位图标记法采用只收集各个对象的标志位并表格化,不跟对象一起管理。也就是把对象和标记位进行了分离。这样做有两个好处:

  1. 与写时复制技术兼容: 因为GC的时候改变了标记位也不会引起对象的复制, 而位图表格非常小,所以即使被复制也不会有什么大的影响。
  2. 清除操作更高效: 在遍历堆的时候不需要取消标志位,可以最后在位图表格中设置。

延迟清除法

延迟清除法(Lazy Sweep)是缩减因清除操作而导致的mutator最大暂停时间的方法。这个方法的伪代码如下:

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
28
29
30
new_obj(size){
#用延迟清除法找到对应的块
chunk = lazy_sweep(size)
if(chunk != NULL)
return chunk
#没有找到合适的,进行一次标记操作
mark_phase()
#再用延迟清除法找到对应的块
chunk = lazy_sweep(size)
if(chunk != NULL)
return chunk

allocation_fail()
}

lazy_sweep(size){
while($sweeping < $head_end)
if($sweeping.mark == TRUE)
$sweeping.mark == FALSE
#找到和大小合适的块
else if($sweeping.size > size)
chunk = $sweeping
$sweeping += $sweeping + $sweeping.size
return chunk
#没找到继续往下找
$sweeping += $sweeping + $sweeping.size
#遍历完了也没找到,$sweeping置为从头开始
$sweeping = $heap_start
return NULL
}

这里跟之前不同的是$sweeping是一个全局变量,每次执行lazy_sweep的时候都会从当前$sweeping的位置往后查找。如果第一次没有找到,第二次就会从头开始查找,如果第二次也没有查到,那就是没有可以分配的块了。一般情况下第一次查找范围变小了,mutator的执行时间就短了。但是有一个问题是就是当数据分配不均,比如说后面的都是活动对象,前面的都是空的,反而会增加mutator的时间。如何改善这个问题,后面会再说到。

引用计数法

GC的目的是为了释放无法被引用的对象,自然就会想到让每个对象记录下自己被引用的个数,如果个数为0表示无法被引用,那就可以对其进行回收。这种思路就是引用计数法(Reference Counting)。

基本方法

引用计数法最重要的就是引入了一个计数器,用来记录被引用的个数。首先先看一下引用计数法的伪代码实现:

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
28
29
30
31
32
33
34
35
36
37
38
39
#生成新对象
new_obj(size){
#从空闲链表中找到合适的块
obj = pickup_chunk(size, $free_list)

if(obj == NULL)
allocation_fail()
else
#对象有一个计数器,成功生成后计数器值是1
obj.ref_cnt = 1
return obj
}

#更新ptr指针,使其指向新对象obj
update_ptr(ptr, obj){
#被指向的对象计数器+1
inc_ref_cnt(obj)
#原来指向的对象计数器-1
dec_ref_cnt(*ptr)
#指向新对象
*ptr = obj
}

#计数器+1
inc_ref_cnt(obj){
obj.ref_cnt++
}

#计数器-1
dec_ref_cnt(obj){
#obj计数器-1
obj.ref_cnt--
#obj计数器为0,说明对象变成了"垃圾", 需要对其子对象计数器都-1, 因为这个对象不存在了。
if(obj.ref_cnt == 0)
for(child : children(obj))
dec_ref_cnt(*child)
#将obj连接到空闲链表中
reclaim(obj)
}

上面需要注意的一点是执行update_ptr的时候先执行了inc_ref_cnt后执行了dec_ref_cnt, 这是因为当update_ptr的前后两个对象是同一个时,如果先指向了dec_ref_cnt就会把这个对象删除,再执行inc_ref_cnt时就会出错,而顺序反过来就不会存在这个问题了。还有一点是引用计数法和标记清除法不一样的地方:引用计数法会在指针变动时发现是否是垃圾,从而立即回收,而标记清除法则即使发现了也不会立即回收,而是标记完后一起回收。

优点/缺点

  • 优点

    1. 可以即刻进行垃圾回收
    2. 最大暂停时间短: 只在发生引用关系变化时立即回收。
    3. 没有必要沿指针查找: 根据每个变量的引用计数来回收,不需要进行遍历。
  • 缺点

    1. 计数器值的增减处理繁重
    2. 计数器需要占用很多位: 计数器需要记录被引用的个数,这个记录位会占用不少的内存空间。
    3. 实现繁琐复杂
    4. 循环引用无法回收:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      class Person {
      string name
      Person lover
      }

      taro = new Person("太郎") //执行后taro的引用计数为1
      hanako = new Person("花子") //执行后hanako的引用计数为1
      taro.lover = hanako //执行后hanako的引用计数为2
      hanako.lover = taro //执行后taro的引用计数为2
      taro = null //taro指向null, hanako引用计数-1,变为1
      hanako = null //hanako指向null, taro引用计数-1, 变为1
      //全部执行完后taro与hanako的引用计数都为1,不能被回收,但是又无法被引用, 照成了内存泄露的情况

用图来说请其中的过程如下:
循环引用图解

延迟引用计数法

上面说到引用计数法的计数器值得增减处理很繁重,为了改善这个缺点,引入了延迟引用计数法(Deferred Reference Counting)。延迟引用计数法利用ZCT(Zero Count Table)来记录计时器值在dec_ref_cnt()作用下变为0的对象, zct表内的值是指向这些对象的指针。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#update_ptr($ptr, obj)调用不变,只是dec_ref_cnt不会递每次都递归处理子节点的引用计数
dec_ref_cnt(obj){
obj.ref_cnt--
if(obj.ref_cnt == 0)
#$zct满了就执行一次扫描
if(is_full($zct) == TRUE)
scan_zct()
push($zct, obj)
}

new_obj(size){
obj = pickup_chunk(size, $free_list)

if(obj == NULL)
#空间不够执行一次扫描, 释放空间
scan_zct()
obj = pickup_chunk(size, $free_list)
if(obj == NULL)
allocation_fail()

obj.ref_cnt = 1
return obj
}
#扫描zct
scan_zct(){
#对根直接引用的对象都进行增量, 把根引用反映到计数器的值上
for(r : $roots)
(*r).ref_cnt++
#对子对象的计数器进行减量操作,回收
for(obj : $zct)
if(obj.ref_cnt == 0)
remove($zct, obj)
delete(obj)
#恢复根节点直接引用的对象计数器的值
for(r : $roots)
(*r).ref_cnt--
}

#减量操作和回收
delete(obj){
for(child : children(obj))
(*child).ref_cnt--
if((*child).ref_cnt == 0)
delete(*child)

reclaim(obj)
}

书举例说update_ptr($ptr, obj)改写成*$ptr = obj, 我理解这只是举了一个例子说明不需要增减计数器。实际后面的代码中可以看出,还是使用的update_ptr($ptr, obj),否则就没有对dec_ref_cnt(obj)的调用了。变化比较大的是dec_ref_cnt(obj函数,它不再递归调用子节点的计数器减量,而是直接把它放到zct结构中,在必要时调用scan_zct, 这就大大减少了计数器值得增减。

  • 优点: 延迟了根引用的技术,将垃圾一并回收,减轻了因根引用频发发生的变化导致计数器增减所带来的额外负担。
  • 缺点: 失去了引用计数法的一大优点–可即可回收垃圾。另外scan_zct()导致最大暂停时间延长了。

Sticky引用计数法

引用计数法有一个问题就是计数器要设置多大的位宽。如果设置的小了,有可能会出现存不下而溢出的情况;如果设置的大了,又会占用过多的空间。Sticky的思想就是设置一个固定大小的位数,这个位数要比较小,对于溢出的情况下面两种处理方式:

  • 什么都不做
    当计数器出现溢出时,不对其进行任何操作,其值就是能存储的最大值,一般情况下这个值很难达到,如果达到了这个值,证明其非常重要,其成为垃圾的可能性也非常小,对其计数不增也不减,不会存在什么大的问题。
  • 使用GC标记-清除算法进行管理
    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
    28
    29
    30
    31
    mark_sweep_for_counter_overflow(){
    #所有计数器清零
    reset_all_ref_cnt()
    mark_phase()
    sweep_phase()
    }

    #对所有可以达到的节点进行标记,每个节点及其子节点只会进栈一次,所以引用计数的值最多为2, 不会出现溢出的情况
    mark_phase(){
    for(r : $roots)
    #所有根节点放到标记栈中
    push(*r, $mark_stack)

    while(is_empty($mark_stack) == FALSE)
    obj = pop($mark_stack)
    #弹出栈,引用计数+1
    obj.ref_cnt++
    #只有引用计数为1才让其子节点进栈,已经进过的不会再进
    if(obj.ref_cnt == 1)
    for(child : children(obj))
    push(*child, $mark_stack)
    }

    #清除节点遍历堆,所有标记位为0的节点进行回收
    sweep_phase(){
    sweeping = $heap_top
    while(sweeping < $head_end)
    if(sweeping.ref_cnt == 0)
    reclaim(sweeping)
    sweeping += sweeping.size
    }

这么做可以在溢出后依然回收,而且没有对循环引用页适用,但是需要重置计数器。查找对象时没有设置标记位,而只是增量计数器,会出现多次查找活动对象的问题。比起一般的GC标记-清除算法需要更多的时间,吞吐量也会变小。

1位引用计数法

1位引用计数法(1 bit Reference Counting)是Sticky引用计数法的极端例子,计数器只有1位大小。这里的计数器不在表示引用的个数,而是表示有一个引用还是多个引用。

  1. 当计数器值为0,表示对象引用数为1,这种状态称为UNIQUE
  2. 当计数器值为1, 表示引用数为复数, 这种状态称为MULTIPLE

相关伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#指针复制 
#dest_ptr: 目的指针
#src_ptr: 源指针
copy_ptr(dest_ptr, src_ptr){
#由于目的指针原来指向的内容不再指向,需要对目的指针指向删除操作
delete_ptr(dest_ptr)
#执行复制
*dest_ptr = *src_ptr
#目的指针由于和源指针指向了同一个对象,目的指针需要设置为MULTIPLE
set_multiple_tag(dest_ptr)
#源指针如果原来是UNIQUE, 现在多了一个目的指针,需要设置为MULTIPLE
if(tag(src_ptr) == UNIQUE)
set_multiple_tag(src_ptr)
}

#删除目的指针原来的指向对象
delete_ptr(ptr){
#如果原来是UNIQUE,说明对象只有一个指针,删除后需要回收
if(tag(ptr) == UNIQUE)
#回收
reclaim(ptr)
}

其过程可以参考下图:
1bit_rc

  • 优点:
    1. 不容易出现高速缓存缺失, 如上图所示,在更新计数器的时候不需要读取元素的值到内存中(C,D完全没有读), 只需要更新指针的计数器,所以不会出现内存中离得远找出缓存缺失。
    2. 计数器所占空间很小,节省内存。
  • 缺点: 1位引用计数器是在大量计数器都不足2的前提下来做的,当出现大量大于2的计数器时,1位引用计数器方法就无法回收这些对象,给堆带来巨大负担。

部分标记-清除算法

部分标记清除法主要是针对之前的无法回收循环引用的缺点而产生的。之前讲的延迟引用计数法可以处理循环引用的情况,但是效率太低。部分-标记清除算法只针对有可能是循环引用的对象上执行,在一般的对象上还是执行引用计数法。下面结合代码图图示说明一下部分标记-清除算法的过程。

部分标记-清除算法中,对象被涂成四种颜色来管理。每个颜色的含义如下:

  1. 黑(BLACK): 绝对不是垃圾的对象(对象产生时的初始颜色)
  2. 白(WHITE): 绝对是垃圾的对象
  3. 灰(GRAY): 搜索完毕的对象
  4. 阴影(HATCH): 可能是循环垃圾的对象

首先我们假设有一个循环引用对象群,初始状态如下:
初始状态
图中A和D是由根引用。所有对象在初始状态下都为黑色。
对应的初始代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
new_obj(size){
obj = pickup_chunk(size)
if(obj != NULL)
#初始颜色会BLACK
obj.color = BLACK
obj.ref_cnt = 1
return obj
else if(is_empty($hatch_queue) == FALSE)
#当空间不够用时扫描可能是循环引用的对象,然后释放出新的空间, 再次调用new_obj
scan_hatch_queue()
return new_obj(size)
else
allocation_fail()
}

当执行dec_ref_cnt()时, 引用计数为0, 则回收。不为0时都认为是可能存在循环引用的对象, 都标记成HATCH, 并且把这个对象放到$hatch_queue当中。代码如下:

1
2
3
4
5
6
7
8
9
10
dec_ref_cnt(obj){
obj.ref_cnt--
#ref_cnt == 0, 回收对象
if(obj.ref_cnt == 0)
delete(obj)
#ref_cnt != 0 认为是可能存在循环引用的对象
else if(obj.color != HATCH)
obj.color = HATCH
enqueue(obj, $hatch_queue)
}

针对上面的图,如果A的引用被删除了,则执行dec_ref_cnt()之后的状态如下图:

执行dec_ref_cnt

这是对象群在调用new_obj()时已经没有心的内存空间可以使用,所以会触发scan_hatch_queue()函数的调用。对应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13

scan_hatch_queue(){
#可能是循环引用的对象出队列
obj = dequeue($hatch_queue)
#如果颜色为HATCH, 依次调用下面的函数
if(obj.color == HATCH)
paint_gray(obj)
scan_gray(obj)
collect_white(obj)
##如果颜色不为HATCH, 证明不是循环引用对象,继续下一个元素
else if(is_empty($hatch_queue) == FALSE)
scan_hatch_queue()
}

上面需要调用的paint_gray(obj)函数主要作用是深度遍历对象,搜索过的对象标记位GRAY:

1
2
3
4
5
6
7
8
9
paint_gray(){
#对原来是BLACK或HATCH的对象标记为GRAY
if(obj.color == (BLACK | HATCH))
obj.color = GRAY
#深度遍历子节点,引用计数减量, 递归调用paint_gray记性标记
for(child : children(obj))
(*child).ref_cnt--
paint_gray(*child)
}

执行完上面的函数后,对象的状态如下图:
执行dec_ref_cnt
下面scan_gray(obj)的目的是扫描刚才的GRAY节点,把其中的垃圾对象找出来,标记成WHITE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scan_gray(obj){
if(obj.color == GRAY)
if(obj.ref_cnt > 0)
#ref_cnt>0, 不是垃圾,需要标记成BLACK
paint_black(obj)
#ref_cnt == 0, 是垃圾对象,标记成WHITE
else
obj.color = WHITE
for(child : children(obj))
scan_gray(*child)
}

paint_black(obj){
obj.color = BLACK
for(child : chidren(obj))
#由于执行paint_gray的时候ref_cnt--, 这里要恢复ref_cnt
(*child).ref_cnt++
if((*child).color != BLACK)
paint_black(*child)
}

标记后的对象如下:
执行dec_ref_cnt
到上面的步骤后,可以看出已经知道那些颜色为WHITE的对象就是垃圾对象,这些对象需要回收,回收代码入下:

1
2
3
4
5
6
7
collect_white(){
if(obj.color == WHITE)
obj.color = BLACK
for(child : children(obj))
collect_white(*child)
reclaim(obj)
}

回收后的图如下:
执行dec_ref_cnt
上面就是部分标记-清除算法的过程。这个算法的优点就是,只搜索可能是循环垃圾的对象群,就是阴影部分,如何确定这个范围呢?首先产生垃圾循环的条件有两个:

  1. 产生循环引用。
  2. 删除从外部到循环引用的引用。

部分标记-清除算法就利用dec_ref_cnt()函数来判断,如果引用计数减值后不为0, 那这个对象有可能就是循环对象的一份子。
这个算法的缺点就是需要三次查找对象,而每次查找的数量不少,所以付出的成本比较大。

GC复制算法

GC复制算法把原来的内存空间分为两部分(From空间和To空间), 当From空间不够分配时,就会执行GC复制算法,把From空间的活动对象复制到To空间,复制完成后交换From和To空间,GC结束,分配时去心的From空间查找。

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
28
29
copying(){
#$to_start To空间的起始地址
#$free 要copy到的起始地址
$free = $to_start
for(r : $roots)
*r = copy(*r)
#交换From 和 To 空间
swap($from_start, $to_start)
}

#执行From 到 To 的 copy
copy(obj){
# 如果obj.tag != COPIED, 此对象还没有被执行过COPY, 对其执行COPY
if(obj.tag != COPIED)
copy_data($free, obj, obj.size)
#执行完后改变tag值,下次不再对其执行COPY
obj.tag = COPIED
#forwarding是原来对象指向复制后的对象的指针,便于新老节点对应起来,下面递归查询的时候好查找
obj.forwarding = $free
#free是要复制到的起始地址,当复制完一个对象后,需要前进size, 到达新的地址(To空间空闲的起始地址)
$free += obj.size

#对执行过的对象执行深度遍历,全部活动子节点都COPY到TO空间
for(child : children(obj.forwarding))
*child = copy(*child)
#注意,当对根节点的元素执行时,返回的是根节点执行的obj.forwarding,
#所以全部执行完后,根节点结合就是原来的根节点集合的forwarding指针指向的元素
return obj.forwarding
}
1
2
3
4
5
6
7
8
9
10
11
12
13
new_obj(){
#这里FROM和TO等分,如果空间不够,执行GC
if($free + size > $from_start + HEAP_SIZE/2)
copying()
#执行完GC后空间还不够,返回失败
if($free + size > $from_start + HEAM_SIZE/2)
allocation_fail()

obj = $free
obj.size = size
$free += size
return obj
}

GC复制算法过程参考下面的图:
GC复制算法
GC复制算法
GC复制算法
GC复制算法

  • 优点:
  1. 优秀的吞吐量: 只需要搜索活动对象,不需要其他的搜索。
  2. 可实现高速分配: 不需要空闲链表,只移动$free指针,快速分配。
  3. 不会发生碎片化: 因为分配的都是连续的,GC之后也是连续的,对象都放在了堆的一端(叫做压缩)。
  4. 与缓存兼容: 深度优先遍历,关联的节点都被放到了相邻的位置。
  • 缺点:
  1. 堆使用效率低下: GC复制算法通常把堆分为二等分,只有一半可以来安排对象。
  2. 不兼容保守式GC算法: 会发生对象的移动。
  3. 递归调动函数: 递归复制,每次调用都会消耗栈,会有栈溢出的可能。

Cheney的GC复制算法

上面提到GC复制算法用递归复制,会有栈溢出的可能。Cheney的GC复制算法则采用广度优先的方式,用循环代替递归,解决栈溢出的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
copying(){
scan = $free = $to_start
for(r : $roots)
*r = copy(r)
#广度优先遍历需要一个队列保,scan 到 $free 就是这个隐藏的队列
while(scan != $free)
for(child : children(scan))
*child = copy(*child)
scan += scan.size

swap($from_start, $to_start)
}

copy(){
#如果obj.forwarding是指向To空间指针则返回TRUE, 如果不是则返回FALSE
if(is_pointer_to_heap(obj.forwarding, $to_start) == FALSE)
copy_data($free, obj, obj.size)
obj.forwarding = $free
$free += obj.size
return obj.forwarding
}

Cheney复制算法
Cheney复制算法
Cheney复制算法
Cheney复制算法

这个算法的缺点是不能利用局部缓存,因为有关系的节点不是相邻的。

近似深度优先搜索方法

为了解决Cheney算法不能利用局部缓存,这里进行了一个改进,对于每个“页面”内部都是广度优先搜索。下面通过一个例子,看一下Cheney与近似深度优先搜索的方法对比:
图1,原始的引用关系:
近似深度优先搜索方法
图2,假设每三个节点占用一个”页面”的空间,下面就是Cheney方法,广度优先遍历后的ji结果:
近似深度优先搜索方法
可以看出,上图中相互引用的节点之间存储的比较分散,不容里利用局部缓存。
图3是利用近似深度优先搜索方法后的结果,可以看出分布比较集中,可以很好利用局部缓存。
近似深度优先搜索方法

多空间复制算法

上面降到复制算法的一个明显的特征就是堆的利用率低。为了改善这个问题,多空间复制的算法的思想就是把一个堆N等分,只对其中2块空间执行GC复制算法,对剩下的(N-2)块空间执行GC标记-清除算法,也就是把这两种算法组合起来使用。具体细节不再展开。这个方法的优点是可以更有效的利用堆,但是缺点也很明显,就是标记-清除算法的缺点:分配耗费时间,分块碎片化等。

总结

基本算法是进行GC的基本思想,每个算法都有其缺点和优点,没有算法能够完美解决所有问题。所以后面的算法利用这几种基本算法的组合和变形,更好的提高GC的性能。

垃圾回收算法总结

最近研读了《垃圾回收的算法与实现》这本书, 对来垃圾回收(GC)的来龙去脉及理论和实践有了一个概括性,深入性的了解,这里分多篇进行总结。首先本文先对GC的理论来一个总览性的回顾.

什么是垃圾回收

我们知道一台服务器的内存是有限的,而程序的运行需要占用内存空间,一个程序内部可能有些内存空间使用后不再使用,这部分不再使用的内从空间就被视为垃圾。而GC就是要

  1. 找到内存空间里的垃圾
  2. 回收垃圾,让程序员能够再次利用这部分空间

如果没有GC的情况下需要程序员自己手动管理内存,例如C/C++等程序。这个过程将会非常麻烦,如果管理不当就会照成内存泄露引起系统崩溃,引发各种恶性bug和安全问题。有了GC就会省去很大一部分精力,降低了开发的难度。

垃圾回收基本概念

要深入了解垃圾回收的理论知识,下面这些关键件信息比必要掌握:

  • 对象/头/域: 这里对象是由头(heder)和域(field)构成的。头是指保持对象本身信息的部分,主要包括对象的大小对象的种类;域是对象使用者可以访问的部分,域的数据类型主要分为指针和非指针两种。
  • 指针: GC根据对象的指针指向去搜寻其他对象,对于非指针不进行任何操作。
  • mutator: 程序运行过程中关系的改变,主要包括生成对象更新指针等操作。
  • 堆: 用于动态存放对象的内存空间。当mutator申请存放对象时,所需的内从空间就是从这个堆中被分配给mutator的。
  • 活动对象/非活动对象: 内存空间中可以通过mutator引用的对象是”活动对象”, 不能通过程序引用的称为”非活动对象”。非活动对象无法重新被引用,所以就是”垃圾”。
  • 分配: 内存空间中分配(allocatio)对象。当mutator需要新对象时,就会向分配器(allocator)申请一个大小合适的空间。
  • 分块: 未利用对象而事先准备的空间。初始状态堆就是一个大分块,根据mutator的需求而分割成合适的大小。
  • 根: 跟是指向对象的指针的起点,通过mutator可以直接调用的调用栈(call stack),寄存器和全局变量都是根。但是调用栈和寄存器中的值是不是指针,需要再做判断。
  • 评价标准: GC算法的性能评价标准主要有
    1. 吞吐量: 单位时间内的处理能力。
    2. 最大暂停时间: 因执行GC和停止mutator的最长时间。
    3. 堆使用效率
    4. 访问的局部性: 局部性原理,数据离得越近越好处理。

垃圾回收算法总览

首先先上一张垃圾回收算法的总概括图:
垃圾回收算法总览
上面列举和好多算法及对应的细节。其实GC最基本的思想就是三种算法(GC标记-清除法, 引用计数法, GC复制算法), 其他算法都算是这几个算法的延伸和组合。

phpenv安装自定义配置

自定义配置

在使用phpenv安装php是,有时候需要对内置扩展进行自定义控制是否开启,比如我要开启zts模块, 源码安装我么可以用./configure --enable-maintainer-zts来安装,但是phpenv不支持直接这么写,这时候就要phpenv自己的方式来安装了。可以在phpenv安装的路径里找到下面这个文件:~/.phpenv/plugins/php-build/bin/php-build, 这个文件就是phpenv install时运行的脚本,可以找到如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
CONFIGURE_OPTIONS=$(cat "$PHP_BUILD_ROOT/share/php-build/default_configure_options")
...
if [ -n "$PHP_BUILD_CONFIGURE_OPTS" ]; then
CONFIGURE_OPTIONS="$CONFIGURE_OPTIONS $PHP_BUILD_CONFIGURE_OPTS"
fi
...
local append_default_libdir='yes'
for option in $CONFIGURE_OPTIONS; do
case "$option" in
"--with-libdir"*) append_default_libdir='no' ;;
esac
done
if [ "$(uname -p)" = "x86_64" ] && [ "${append_default_libdir}" = 'yes' ]; then
argv="$argv --with-libdir=lib64"
fi
...
./configure $argv > /dev/null
...

可见,默认会读取~/.phpenv/plugins/php-build/share/php-build/default_configure_options里面的配置加到./configure的参数里,当存在变量$PHP_BUILD_CONFIGURE_OPTS时,会把这个变量的值也加到./configure的参数里。
所以就存在两种方式实现上面的安装方法:

  1. ~/.phpenv/plugins/php-build/share/php-build/default_configure_options文件末尾加上--enable-maintainer-zts
  2. 运行PHP_BUILD_CONFIGURE_OPTS=--enable-maintainer-zts phpenv install 5.6.2

InnoDB关键特性

本篇博客是《Mysql技术内幕 InnoDB存储引擎(第二版)》的阅读总结.

工作方式

首先Mysql进程模型是单进程多线程的。所以我们通过ps查找mysqld进程是只有一个。

体系架构

InnoDB存储引擎的架构如下图所以,是由多个内存块组成的内存池,同时又多个后台线程进行工作,文件是存储磁盘上的数据。

后台线程

上面看到一共有四种后台线程,每种线程都在不停地做自己的工作,他们的分工如下:

  • Master Thread: 是最核心的线程,主要负责将缓冲池中的数据异步刷新的磁盘,保证数据的一致性,包括脏页的刷新、合并插入缓冲(INSERT BUFFER),UNDO页的回收等。下面几个线程其实是为了分担主线程的压力而在最新的版本中添加的。
  • IO Thread: InnoDB使用大量的异步IO来处理请求。IO Thread的主要工作就是负责IO请求的回调(call back)处理。异步IO可以分为4个,分别是:write, read, insert buffer 和 log IO thread。
  • Purge Thread: undo log是用来保证事务的,当一个事务正常提交后,这个undo log可能就不再使用了。purge thread就是用来清除这部分log已经分配的undo页的。
  • Page Cleaner Thread: 主要是把脏页的刷新从主线程中拿到单独的线程,减轻主线程的压力,减少用户查询线程的阻塞,提高整体性能。

内存

由于InnoDB是基于磁盘存储的,为了使CPU与磁盘能够快的交互,提升整体性能而采用了缓冲池技术。
读数据简单的说可以用下面的流程图

更新数据的流程则如下:

由缓冲池的作用可以看到,缓冲池越大所容纳的数据就越多,与磁盘的交互就会越少,性能也就越高。所以缓冲池的大小直接影响着数据库的整体性能。
InnoDB在内存中主要有以下几部分组成:

具体来看缓冲池中缓存的数据页类型有:

  • 索引页: 缓存数据表索引
  • 数据页: 缓存数据页,占缓冲池的绝大部分
  • undo页: undo页是保存事务,为回滚做准备的。
  • 插入缓冲(Insert buffer): 上面提到的插入数据时要先插入到缓存池中。
  • 自适应哈希索引(adaptive hash index): 除了B+ Tree索引外,在缓冲池还会维护一个哈希索引,以便在缓冲池中快速找到数据页。
  • InnoDB存储的锁信息(lock info):
  • 数据字典(data dictionary):
    内存中除了缓冲池外外还有:
  • 重做日志缓冲redo log: 为了避免数据丢失的问题,当前数据库系统普遍采用了write ahead log策略,既当事务提交时先写重做日志,再修改写页。当由于发生宕机而导致数据丢失时,可以通过重做日志进行恢复。InnoDB先将重做日志放到这个缓冲区,然后按照一定的频率更新到重做日志文件中。重做日志一般在下列情况下会刷新内容到文件:
    • Master Thread每一秒将重做日志缓冲刷新到重做日志文件
    • 每个事务提交时会将重做日志缓冲刷新到重做日志文件
    • 当重做日志缓冲池剩余空间小于1/2时,重做日志缓冲刷新到重做日志文件
  • 额外内存池: InnoDB存储引擎中,对内存的管理师通过一种称为内存堆的方式进行的,在对一些数据结构本身的内存进行分配时,需要从额外的内存池中进行申请,当该区域的内存不够时,会从缓冲池中进行申请。

缓冲池是一个很大的内存区域,InnoDB是如何对这些内存进行管理的呢。答案就使用LRU list。
LRU(Latest Recent Used, 最近最少使用)算法默认的是最近使用的放到表头,最早使用的放到表尾,依次排列。当有LRU填满时有新的进来就把最早的淘汰掉。InnoDB则是在这个基础上进行了修改:

  1. 最近使用的不放到表头,而是根据配置放到一定比例处,这个地方叫做midpoint, midpoint之前的成为new列表,之后的成为old列表。淘汰的同样是表尾的页。
  2. 为了保证new列表的不经常使用时能够淘汰,设置了一个超时时间:innodb_old_blocks_time,当数据在midpoint(我理解应该是在old列表中,不然这个点的页就一个,变化也比较频繁)的时间超过找个时间时就会被提升到表头,new列表的表尾页则被置换到old列表中。

这么做的原因主要是因为常见的索引或数据的扫描操作会连续读取大量的页,甚至是全表扫描。如果采用原来的LRU算法就会更新全部的缓冲池,其他查询需要的热点数据就会被冲走,导致更多的磁盘读取操作,降低数据库的性能。
LRU是用来管理已经读取的页,当数据库启动时LRU是空列表,既只有表头,没有内容。这时页都放在Free List中。当需要有数据读写时要进行需要获取分页,这时要从Free List中删除分页,然后添加到LRU list中。到一定时间Free List中的分页就会被分配完毕,这时候就正常使用上面的LRU策略。
LRU列表中的页被修改后,称该页为脏页(dirty page),既缓冲池中的数据和磁盘上的数据产生了不一致,这时脏页会被加入到一个Flush 列表中(注意,同时存在两个列表中)。然后根据刷新的机制定时的刷新到磁盘中。

Checkpoint技术

checkpoint其实就是一个刷新缓冲到磁盘的触发机制,当满足一定的条件时就会刷新缓冲到磁盘,这样做可以解决以下几个问题:

  • 缩短数据库的恢复时间: 数据库恢复可以使用redo log,但是如果要恢复的数据很多就会很慢。如果使用checkpoint刷新到磁盘,只需要从checkpoint开始恢复就可以了,所以速度会变快。
  • 缓冲池不够用时,将脏页刷新到磁盘。我们知道缓冲池的大小是由限制的,为了能够高效的使用缓冲池需要把一部分数据刷新到磁盘。
  • 重做日志不可用时,刷新脏页。重做日志并不是无限增大的,而是循环利用的。当有些已经不需要的页存在时可以覆盖写,当可用的页放不下时就会触发checkpoint,刷新到磁盘一部分脏页到磁盘,这样就能覆盖掉一些不再使用的重做日志。

checkpoint根据触发时间,刷新页的策略又可以分为:

  • sharp checkpoint:刷新所有的脏页到磁盘。一般发生在数据库关闭时,为了保证所有的数据能够正常持久化。
  • fuzzy checkpoint:只刷新部分脏页。运行时使用这种可以保证系统的性能。

    Master Thread的工作方式

关键特性

插入缓存

这里所说的插入缓存也是Insert Buffer, 区别是这个插入缓存不是缓冲池中的插入缓存,这里的插入缓存和数据页一样,业务物理页的组成部分。在介绍插入缓存之前先了解聚集索引和非聚集索引,他们之间最重要的区别就是:聚集索引的叶子节点存储的是数据,而且是按照物理顺序存储的;非聚集索引叶子节点是地址(也就是聚集索引键地址),是按照逻辑顺序存储的(以上言论是从网上了解到的,但是本书P194特别指出,聚集索引也不是按照物理地址连续的,而是逻辑上连续的)。
知道这个差别后就知道,当不停的插入数据时,如果是聚集索引的数据,按照物理顺序(这个应该是一般情况下,因为是一般聚集索引是主键,顺序递增的,所以这时候地址就是顺序的)连续插入,代价比较小。而如果是非聚集索引的插入则物理地址是离散的,会导致很大的系统开销,所以对于非聚集索引InnoDB开创性设计了Insert Buffer。使用InnoDB的Insert Buffer需要以下两个条件:

  • 索引是辅助索引(非聚集索引 secondary index);
  • 索引不是唯一(unique)的。

Insert Buffer的使用流程是:

要求索引不是唯一的是因为如果索引是唯一的,那么每次更新都要坚持是不是已经存在,每次还是要访问数据页,这就失去了使用Insert Buffer的优势。
后面还提到了Update buffer以及Merge的过程和Insert Buffer的实现,这里就不再一一说了。

两次写

上面提到的Insert Buffer是提高了数据库的性能,doublewrite则是提高了数据库的可靠性。一个场景是当一个16k的数据页只写了一部分,比如4k,这时候突然断电,就会导致这个页的数据不全。所以就会导致这个页的数据丢失。我们知道重做日志是用来恢复数据的,但是重做日志记录的是对页的物理操作,如果这个页已经发生了损坏在对其进行重做是没有意义的。

上面这段话,其实我并没有看懂,因为对页操作之前是先写重做日志的,当发生宕机时正在写数据页,证明这时候重做日志已经写完了。这时重做日志的记录的完整的,当用这个记录去恢复数据时,不管页是不是损坏,重做日志直接覆盖不就行了么?为什么不行呢?等到后面我更加深入的了解后再来补充。

doublewrite有两部分组成,一部分是内存中的doublewrite buffer, 大小为2MB,另一部分是物理磁盘上共享表空间中连续的128个页,既两个区,大小同样为2MB
。对缓冲池的脏页进行刷新时,比不直接写磁盘,而是会通过memcpy函数将脏页先复制到内存中的doublewrite buffer, 之后通过doublewrite buffer再分两次,每次1MB的写入共享表空间的物理磁盘上,然后马上调用fsync函数,同步磁盘,避免缓冲写带来的问题。完成doublewrite页的写入后,再将doublewrite buffer中的页写入各个表空间文件中。

如果磁盘写入时发生崩溃,可以从共享表空间的doublewrite中找到副本,将其复制到表空间文件,再应用重做日志。

这个地方也有一个疑问,当doublewrite写入的过程中发生了崩溃,这时候数据该怎么办呢?

自适应哈希索引

对于缓冲池中的页,为了能够快速的查找,InnoDB跟情况对其建立了一个hash index。这样对于等值查询就能够利用这个索引更加快速的查找,提高了查找的性能。

异步IO

为了提高磁盘的操作性能,当前的数据库系统都采用异步IO的方式处理磁盘操作。用户可以在发出一个IO请求胡立即再发出另一个IO请求,当全部IO请求发送完毕后,等待所有IO操作完成,这就是AIO。
AIO的另一个优势是可以进行IO Merge操作,也就是将多个IO合并为1个IO, 这样可以提高IOPS的性能。

刷新临近页

Flush Neighbor Page(刷新临近页)是当刷新一个脏页时,InnoDB会检测该页所在区的所有页,如果是脏页,那么一起进行刷新。

apache与nginx对比

apache工作原理

apache httpd通过模块化的设计来适应各种环境,模块化的使用使其变得功能强大而且灵活。最基本的web服务器功能也是通过可选择的多处理模块(MPM),用来绑定到网络端口上,以及调度子程序处理请求。这样做可以带来两个重要的好处:

  • Apache httpd 能更优雅,更高效率的支持不同的平台。尤其是 Apache httpd 的 Windows 版本现在更有效率了,因为 mpm_winnt 能使用原生网络特性取代在 Apache httpd 1.3 中使用的 POSIX 层。它也可以扩展到其它平台 来使用专用的 MPM。
  • Apache httpd 能更好的为有特殊要求的站点定制。例如,要求 更高伸缩性的站点可以选择使用线程的 MPM,即 workerevent; 需要可靠性或者与旧软件兼容的站点可以使用 prefork

下面主要介绍常用的两个MPM工作原理。

perfork

一个单独的控制进程(父进程)负责产生子进程,这些子进程用于监听请求并作出应答。Apache总是试图保持一些备用的 (spare)或是空闲的子进程用于迎接即将到来的请求。这样客户端就无需在得到服务前等候子进程的产生。在Unix系统中,父进程通常以root身份运行以便邦定80端口(注意这里是先绑定再fork的,所以意味着所有的子进程都监听了80端口),而 Apache产生的子进程通常以一个低特权的用户运行。User和Group指令用于配置子进程的低特权用户。运行子进程的用户必须要对他所服务的内容有读取的权限,但是对服务内容之外的其他资源必须拥有尽可能少的权限。
子进程的个数会随着请求量的大小动态调整。调整的策略与perfork的配置息息相关,httpd.conf的配置文件有以下配置:

1
2
3
4
5
6
7
<IfModule prefork.c>
StartServers 5
MinSpareServers 5
MaxSpareServers 10
MaxClients 150
MaxRequestsPerChild 0
</IfModule>

具体的流程这里直接copyApache运行机制剖析这篇博文的介绍。

  1. 控制进程先建立’StartServers’个子进程;
  2. 当空闲进程数小于MinSpareServers时,继续创建子进程,直到满足空闲进程数大于等于MinSpareServers;
  3. 当并发请求高时而空闲进程数小于MinSpareServers时会继续创建子进程,最多可以创建MaxClients个;
  4. 当并发高峰过去时,空闲进程的数量大于MaxSpareServers时会删除多余的子进程,直到剩MaxSpareServers为止;
  5. 当子进程处理的连接数超过MaxRequestsPerChild时,自动关闭,当MaxRequestsPerChild为0时这没有这个限制;

对每个参数的介绍如下:

  • StartServers 指定服务器启动时建立的子进程数量。
  • MinSpareServers 最小的空闲进程数。如果当前空闲进程数少于MinSpareServers时,Apache将以每秒一个的速度产生新的子进程。
  • MaxSpareServers 最大的空闲进程数。如果空闲进程数大于这个值,Apache父进程会自动kill掉一些多余的子进程。
  • MaxRequestsPerChild 每个子进程可处理的请求数。每个子进程处理完MaxRequestsPerChild后将自动销毁。0意味着用户销毁。销毁的好处有以下两个:
    • 可以防止意外的内存泄露
    • 在服务器负载下降的时候会自动减少子进程数
  • MaxClients 设定Apache可以同时处理的请求,是对性能影响最大的参数。如果请求数达到这个限制,那么后来的请求就需要排队,直到某个请求处理完毕。

worker

每个进程能够拥有的线程数量是固定的。服务器会根据负载情况增加或减少进程数量。一个单独的控制进程(父进程)负责子进程的建立。每个子进程能够建立ThreadsPerChild数量的服务线程和一个监听线程,该监听线程监听接入请求并将其传递给服务线程处理和应答。Apache总是试图维持一个备用(spare)或是空闲的服务线程池。这样,客户端无须等待新线程或新进程的建立即可得到处理。在Unix中,为了能够绑定80端口,父进程一般都是以root身份启动,随后,Apache以较低权限的用户建立子进程和线程。User和Group指令用于配置Apache子进程的权限。虽然子进程必须对其提供的内容拥有读权限,但应该尽可能给予他较少的特权。另外,除非使用了suexec ,否则,这些指令配置的权限将被CGI脚本所继承。
相对于prefork,worker是2.0 版中全新的支持多线程和多进程混合模型的MPM。由于使用线程来处理,所以可以处理相对海量的请求,而系统资源的开销要小于基于进程的服务器。但是,worker也使用了多进程,每个进程又生成多个线程,以获得基于进程服务器的稳定性。这种MPM的工作方式将是Apache 2.0的发展趋势。

http.conf中也有关于worker的配置项:

1
2
3
4
5
6
7
8
9
10
<IfModule worker.c>
StartServers 3
MaxClients 2000
ServerLimit 25
MinSpareThreads 50
MaxSpareThreads 200
ThreadLimit 200
ThreadsPerChild 100
MaxRequestsPerChild 0
</IfModule>

由主控制进程生成“StartServers”个子进程,每个子进程中包含固定的ThreadsPerChild线程数,各个线程独立地处理请求。同样,为了不在请求到来时再生成线程,MinSpareThreads和MaxSpareThreads设置了最少和最多的空闲线程数;而MaxClients设置了所有子进程中的线程总数。如果现有子进程中的线程总数不能满足负载,控制进程将派生新的子进程。
参数介绍:
StartServer 服务器启动时建立的子进程数。
ServerLimit 服务器允许配置的进程数上限。这个指令和ThreadLimit结合使用配置了MaxClients最大允许配置的数值。
MinSpareThreads 最小空闲线程数。这个MPM将基于整个服务器监控空闲线程数。如果服务器中的总线程数太少,子进程将产生新的空闲线程。
MaxSpareThreads 最大空闲线程数。这个MPM将基于整个服务器监控空闲线程数。如果服务器总的线程数太多,子进程将kill掉多余的空闲线程。MaxSpareThreads的取值范围是有限制的。
ThreadLimit 每个子进程可配置的线程数上限。这个指令配置了每个子进程可配置的线程数ThreadsPerChild上限。
ThreadsPerChild 每个子进程建立的常驻的执行线程数。子进程在启动时建立这些线程后就不再建立新的线程了。
MaxRequestsPerChild 每个子进程在其生存期间允许执行的最大请求数量。到达这个限制后子进程将会结束,如果为0则永不结束( 注意,对于KeepAlive链接,只有第一个请求会被计数)。这样做的好处是:

  • 能够防止内存泄露无限进行,从而耗尽内存。
  • 給进程一个有限寿命,从而有助于当服务器负载减轻时减少活动进程的数量。

apache”惊群”现象与解决方案

无论是上面那个MPM被选择,都有一问题就是主进程先监听80端口,然后又fork出子进程。所以可以知道,fork出来的每个子进程都在监听80端口,如果这时候有请求过来就会出现所有的空闲进程都回来抢这个fd,也就是这些进程都被唤醒了,但是最终只有一个进程能够拿到这个fd进行处理,其他进程因为拿不到进程而再次进入休眠状态,这就是”惊群”现象。
apache的prefork模型下的处理方式如下如所示:

apache通过在每个accept()函数上 增加互斥锁和条件变量 来解决这个惊群问题。保证每个请求只会被一个线程刚好拿到,不会影响其他线程;
这里详细介绍下:条件变量与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用;互斥锁提供互斥机制,条件变量提供信号机制;
那么apache是如何利用条件变量和互斥锁来解决每次只有一个空闲线程被唤醒,并且处于监听者角色呢?
每次一个新的客户请求过来,正在监听的线程与该请求建立连接,并变为worker工作者线程。让出监听者角色时它同时发送信号到条件变量,并释放锁。这样在空闲(idle)状态的一个线程将被唤醒并获得锁。
也就是说:条件变量保证了其他线程在等待条件变化期间处于睡眠;互斥锁保证一次只有一个线程被唤醒
这个是参考客户/服务器程序设计范式来的,但是有一个明显的问题是prefork是多进程模型不是多线程模型,由于现在还没读过apache源码,姑且认为总体的流程和思想是对的。有机会再深入阅读回来补充。

nginx工作原理

nginx使用的是多进程模型,类似于apache的prefork,不同的是nginx的子进程个数是固定的。nginx的进程模型可以用下图来表示:

可以看到nginx进程模型是由一个mater进程和多个worker进程组成的,master进程主要用来管理worker进程,包含:接收来自外界的信号,向各worker进程发送信号,监控worker进程的运行状态,当worker进程退出后(异常情况下),会自动重新启动新的worker进程。 worker进程则是来处理请求用的。

异步非阻塞

上面提到nginx的worker进程用来处理请求,而worker的个数是有限的,当并发高的时候nginx是如何应对的呢?这里不得不提到一个概念异步非阻塞(参考UNP卷一第三版P160页的介绍)关于这个过程nginx平台初探介绍的很好,直接COPY过来:

为什么nginx可以采用异步非阻塞的方式来处理呢,或者异步非阻塞到底是怎么回事呢?我们先回到原点,看看一个请求的完整过程。首先,请求过来,要建立连接,然后再接收数据,接收数据后,再发送数据。具体到系统底层,就是读写事件,而当读写事件没有准备好时,必然不可操作,如果不用非阻塞的方式来调用,那就得阻塞调用了,事件没有准备好,那就只能等了,等事件准备好了,你再继续吧。阻塞调用会进入内核等待,cpu就会让出去给别人用了,对单线程的worker来说,显然不合适,当网络事件越多时,大家都在等待呢,cpu空闲下来没人用,cpu利用率自然上不去了,更别谈高并发了。好吧,你说加进程数,这跟apache的线程模型有什么区别,注意,别增加无谓的上下文切换。所以,在nginx里面,最忌讳阻塞的系统调用了。不要阻塞,那就非阻塞喽。非阻塞就是,事件没有准备好,马上返回EAGAIN,告诉你,事件还没准备好呢,你慌什么,过会再来吧。好吧,你过一会,再来检查一下事件,直到事件准备好了为止,在这期间,你就可以先去做其它事情,然后再来看看事件好了没。虽然不阻塞了,但你得不时地过来检查一下事件的状态,你可以做更多的事情了,但带来的开销也是不小的。所以,才会有了异步非阻塞的事件处理机制,具体到系统调用就是像select/poll/epoll/kqueue这样的系统调用。它们提供了一种机制,让你可以同时监控多个事件,调用他们是阻塞的,但可以设置超时时间,在超时时间之内,如果有事件准备好了,就返回。这种机制正好解决了我们上面的两个问题,拿epoll为例(在后面的例子中,我们多以epoll为例子,以代表这一类函数),当事件没准备好时,放到epoll里面,事件准备好了,我们就去读写,当读写返回EAGAIN时,我们将它再次加入到epoll里面。这样,只要有事件准备好了,我们就去处理它,只有当所有事件都没准备好时,才在epoll里面等着。这样,我们就可以并发处理大量的并发了,当然,这里的并发请求,是指未处理完的请求,线程只有一个,所以同时能处理的请求当然只有一个了,只是在请求间进行不断地切换而已,切换也是因为异步事件未准备好,而主动让出的。这里的切换是没有任何代价,你可以理解为循环处理多个准备好的事件,事实上就是这样的。与多线程相比,这种事件处理方式是有很大的优势的,不需要创建线程,每个请求占用的内存也很少,没有上下文切换,事件处理非常的轻量级。并发数再多也不会导致无谓的资源浪费(上下文切换)。更多的并发数,只是会占用更多的内存而已。 我之前有对连接数进行过测试,在24G内存的机器上,处理的并发请求数达到过200万。现在的网络服务器基本都采用这种方式,这也是nginx性能高效的主要原因。

所以推荐设置worker的个数为cpu的核数,在这里就很容易理解了,更多的worker数,只会导致进程来竞争cpu资源了,从而带来不必要的上下文切换。而且,nginx为了更好的利用多核特性,提供了cpu亲缘性的绑定选项,我们可以将某一个进程绑定在某一个核上,这样就不会因为进程的切换带来cache的失效。

nginx”惊群”现象与解决方案

worker进程之间是平等的,每个进程,处理请求的机会也是一样的。当我们提供80端口的http服务时,一个连接请求过来,每个进程都有可能处理这个连接,怎么做到的呢?首先,每个worker进程都是从master进程fork过来,在master进程里面,先建立好需要listen的socket(listenfd)之后,然后再fork出多个worker进程。所有worker进程的listenfd会在新连接到来时变得可读,为保证只有一个进程处理该连接,所有worker进程在注册listenfd读事件前抢accept_mutex,抢到互斥锁的那个进程注册listenfd读事件,在读事件里调用accept接受该连接。当一个worker进程在accept这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,这样一个完整的请求就是这样的了。我们可以看到,一个请求,完全由worker进程来处理,而且只在一个worker进程中处理。

因为这里主要是对比apache与nginx的原理的不同,所以更深入的探讨nginx这里先不做介绍更深入的探讨nginx这里先不做介绍,以后有机会学习nginx源码的时候再写。

参考文献

多处理模块(MPM)
Apache运行机制剖析
nginx平台初探
客户/服务器程序设计范式
“惊群”,看看nginx是怎么解决它的
web服务器nginx和apache的对比分析

PHP数组的key溢出问题

作为PHP最重要的数据类型HashTable其key值是有一定的范围的,如果设置的key值过大就会出现溢出的问题,下面根据其内部结构及实现原理详细探讨一下key值溢出问题。

下面先给出一个key溢出的例子:

1
2
3
4
5
6
7
8
<?php
$arr[1] = '1';
$arr[18446744073708551617333333333333] = '18446744073708551617333333333333';
$arr[] = 'test';
$arr[4294967296] = 'test';
$arr[9223372036854775807] = 'test';
$arr[9223372036854775808] = 'test';
var_dump($arr);

上面代码的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
array(6) {
[1]=>
string(1) "1"
[-999799117276250112]=>
string(32) "18446744073708551617333333333333"
[2]=>
string(4) "test"
[4294967296]=>
string(4) "test"
[9223372036854775807]=>
string(4) "test"
[-9223372036854775808]=>
string(4) "test"
}

我们可以看到当key值比较小是没有问题,当key值很大时输出的值溢出了,临界点是9223372036854775807这个数字。
下面分析一下原因 。首先我们先分析一下HashTable的结构(本文分析的是php-5.5.15版本的源码),可以通过源码看一下:

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
28
29
30
/* file: Zend/zend_hash.h */
typedef struct bucket {
ulong h; /* Used for numeric indexing */ /*对char *key进行hash后的值,或者是用户指定的数字索引值,可能会溢出*/
uint nKeyLength; /*hash关键字的长度,如果数组索引为数字,此值为0*/
void *pData; /*指向value,一般是用户数据的副本,如果是指针数据,则指向pDataPtr*/
void *pDataPtr; /*如果是指针数据,此值会指向真正的value,同时上面pData会指向此值*/
struct bucket *pListNext; /*整个hash表的下一个元素*/
struct bucket *pListLast; /*整个hash表该元素的上一个元素*/
struct bucket *pNext; /*存放在同一个hash Bucket的下一个元素*/
struct bucket *pLast; /*同一个hash bucket的上一个元素*/
const char *arKey; /*保存当前值所对于的key字符串,这个字段只能定义在最后,实现变长结构体*/
} Bucket;

typedef struct _hashtable {
uint nTableSize; /*hash Bucket的大小,最小为8,最以2*x增长*/
uint nTableMask; /*nTableSize-1, 索引取值的优化*/
uint nNumOfElements; /*hash Bucket中当前存在的元素个数, count()函数会直接返回此值*/
ulong nNextFreeElement; /*下一个数字索引的位置*/
Bucket *pInternalPointer; /* Used for element traversal ,当前遍历的指针,foreach比for快的原因之一,这个指针指向当前激活的元素*/
Bucket *pListHead; /*存储数组头元素指针*/
Bucket *pListTail; /*存储数组尾元素指针*/
Bucket **arBuckets; /*存储hash数组*/
dtor_func_t pDestructor; /*在删除元素时执行的回调函数,用于资源的释放*/
zend_bool persistent; /*指出了Bucket内存分配的方式。如果persistent为TRUE,则使用操作系统本身的内存分配函数为Bucket分配内存,否则使用PHP的内存分配函数*/
unsigned char nApplyCount; /*标记当前hash Bucket被递归访问的次数(防止多次递归)*/
zend_bool bApplyProtection; /*标记当前hash桶允许不允许多次访问。不允许时,最多只能递归3次*/
# if ZEND_DEBUG
int inconsistent;
# endif
} HashTable;

假设我们已经对源码有了一定的了解了,我们可以知道bucket.h就是我们存储的key值,bucket.h的生成方法是根据time33算法获取的,对应到代码实现如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
//对于字符串类型的key
ZEND_API int _zend_hash_add_or_update(HashTable *ht, const char *arKey, uint nKeyLength, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC)
{
ulong h;
uint nIndex;
Bucket *p;
# ifdef ZEND_SIGNALS
TSRMLS_FETCH();
# endif

IS_CONSISTENT(ht);

ZEND_ASSERT(nKeyLength != 0);

CHECK_INIT(ht);

//算出来hash key后需要根据hashTable的长度,把nIndex限制在这个长度内(通过nTableMask)
h = zend_inline_hash_func(arKey, nKeyLength);
nIndex = h & ht->nTableMask;

p = ht->arBuckets[nIndex];
...
}
//对于数字类型的key
ZEND_API int _zend_hash_index_update_or_next_insert(HashTable *ht, ulong h, void *pData, uint nDataSize, void **pDest, int flag ZEND_FILE_LINE_DC)
{
uint nIndex;
Bucket *p;
# ifdef ZEND_SIGNALS
TSRMLS_FETCH();
# endif

IS_CONSISTENT(ht);
CHECK_INIT(ht);

/* 如果是新增元素(如$arr[] = 'hello'), 则使用nNextFreeElement值作为hash值,否则直接使用传入的key h 最为hash值 */
if (flag & HASH_NEXT_INSERT) {
h = ht->nNextFreeElement;
}
nIndex = h & ht->nTableMask;

p = ht->arBuckets[nIndex];
...
}
//字符串的hash函数
static inline ulong zend_inline_hash_func(const char *arKey, uint nKeyLength)
{
register ulong hash = 5381; //这个常量是哪儿来的?

/* variant with the hash unrolled eight times */
for (; nKeyLength >= 8; nKeyLength -= 8) {
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
hash = ((hash << 5) + hash) + *arKey++;
}
switch (nKeyLength) {
case 7: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 6: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 5: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 4: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 3: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 2: hash = ((hash << 5) + hash) + *arKey++; /* fallthrough... */
case 1: hash = ((hash << 5) + hash) + *arKey++; break;
case 0: break;
EMPTY_SWITCH_DEFAULT_CASE()
}
return hash;
}

上面函数主要是插入或更新hashTable的函数,当插入的key是数字时,这个数字就是hastTable的索引值,其key值不经过hash算法,只经过nIndex = h & ht->nTableMask;来确保存储的值范围属于hastTable的范围内,所以可以看出索引值key ,与其对应的时nIndex这个值,正在存储的槽位就是nIndex这个地方。

这个key类型是ulong,也就是unsigned long类型。由于我们的机器是64位的,所以unsigned long类型的取值范围应该是0~1844674407370955161。PHP有两个预定义的变量PHP_INT_MAXPHP_INT_SIZE对于64位的机器他们的值分别是9223372036854775807和8,这恰好是hasttable所能表示key的最大值,到这里也许你会有一个疑问:为什么PHP_INT_MAX的值比key的范围不一致?
要回答这个问题首先要知道,hastTable的key输出可以是负值,这是怎么做到的呢?其实一个hashTable的hash值一定是一个正整数才行,但是输出的数和hash值只是一个对应关系,不需要都为正整数, 虽然我们定义的参数为unsigned long,其实我们却可以传一个负数,比如$arr[-1] = 'test',这时候也是和传递一个正数的处理过程是一样的。这时候h的值其实是-1的补码。再回到上面的问题,为什么PHP_INT_MAX的值比key范围不一致。当我们负值 PHP_INT_MAX时,其值是9223372036854775807,当赋值再比这个大时,输出的却是负数。这其实跟我们使用var_dump这个函数有关系, 下面代码是使用var_dump输出数组时所使用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static int php_array_element_dump(zval **zv TSRMLS_DC, int num_args, va_list args, zend_hash_key *hash_key) /* {{{ */
{
int level;

level = va_arg(args, int);

if (hash_key->nKeyLength == 0) { /* numeric key */
php_printf("%*c[%ld]=>\n", level + 1, ' ', hash_key->h);
} else { /* string key */
php_printf("%*c[\"", level + 1, ' ');
PHPWRITE(hash_key->arKey, hash_key->nKeyLength - 1);
php_printf("\"]=>\n");
}
php_var_dump(zv, level + 2 TSRMLS_CC);
return 0;
}

可以看到,当key为数字时输出的格式时%ld,值是hash_key->h,这就是问题所在了,存储的是一个unsigned long,输出的却是long,当值比long大时,自然输出的就是负数了。

总结: PHP的hastTable是通过链表法实现的,按说是不会存在溢出的问题,但是其索引值表示的范围有限,当超出索引值时就会造成溢出,这个溢出只存在当索引值为数字时,输入的数字为正,输出却为负值的原因是函数参数与输出的类型不一致导致的。

mean primer

MEAN入门

MEAN简介

什么是MEAN?

根据官方文档, MEAN就是MongoDB + Express + AngularJS + Node.js的组合。那么组成MEAN的各个部分又分别是什么?

  • MongoDB: 是一个基于分布式文件存储的NoSQL数据库。具体介绍和使用方法请参考官方文档
  • Express: 是一个简洁、灵活的Node.js Web应用开发框架。其实和PHP的MVC框架作用是一样的。详细介绍参见官方文档
  • AngularJS: 前端的MVC框架,更接近于 MVVM(Model-View-ViewModel)。具体介绍参考官方文档
  • Node.js: javascript的一个解析器,提供js在服务器端的运行环境。官方网站

为什么是这个组合?

大家已经熟悉了LAMP/LNMP的开发模式,这些开发模式已经能够满足了现在web开发的绝大部分需求。而新型的MEAN开发模式则是另外一个尝试,其目的是为了解决现在开发中的一些问题,是开发更加高效。总结起来主要有以下几个优点:

  • Web服务器包含在了应用程序中,可以自动安装,部署过程得到了极大简化。
  • 从传统数据库到NoSQL再到以文档为导向的持久存储MongoDB,使用户花费在编写SQL上的时间减少,有更多的时间编写js中的映射/化简功能。还能节省大量的转换逻辑(因为MongoDB存储的时JSON对象,js可以直接用)
  • 得益于AngularJS,从传统的服务器端页面变为客户端单页面应用程序越来越方便。

以上内容参考来源:http://www.ibm.com/developerworks/cn/web/wa-mean1/

另外,任何开发模式都不是万能的,也就是没有银弹,这种开发模式可以給大家带来很多新的思想,开拓思路,对大家以后应对不同应用场景的需求是提供更多的参考。

MEAN安装

MEAN只是一个组合,可以自己单独安装配置各个模块,也有现成的集成方案,如meanjsmean.io(关于他们之间的区别可以参考stackoverflow上面的讨论)。这里我们选择的是meanjs作为开发框架。

meanjs的安装可以参考官方文档。这里需要提前介绍一下安装时用到的一些工具及安装遇到的问题和解决方案。

常用工具

  • npm: 是Node Package Manage的简称,Node.js的包管理工具,它的主要功能就是管理node包,包括:安装、卸载、更新、查看、搜索、发布等。这个类似于centos系统上的yum工具. 可以通过package.json对npm进行配置。可以访问官网查看相关文档,也可以编写自己的npm包提交上去。(安利一下我写的一个很简单的包:https://www.npmjs.com/package/hexo-tag-plantuml)
  • bower: 也是包管理工具,由twitter推出.他和npm的区别是npm针对服务端的工具进行管理,bower则是主要管理前端页面的js依赖关系。通过bower.json和.bowerrc进行配置.
  • grunt: 构建javascript的工具,可以自动的完成代码规范的检查,文件合并,文件压缩,单元测试等流程(参考这边文档grunt从入门到自定义项目模板).详细信息参考官网

安装流程

这部分上面提到的meanjs官网有详细的步骤,简单概况一下就是:

  1. 安装Node.js&npm, MongoDB, Bower, Grunt等
  2. 下载源码: git clone https://github.com/meanjs/mean.git meanjs
  3. 进入meanjs目录,执行npm install ; bower install,执行bower使用会出现下面的提示:

    1
    2
    3
    4
    5
    6
    7
    Since bower is a user command, there is no need to execute it with superuser permissions.
    If you're having permission errors when using bower without sudo, please spend a few minutes learning more about how your system should work and make any necessary repairs.

    http://www.joyent.com/blog/installing-node-and-npm
    https://gist.github.com/isaacs/579814

    You can however run a command with sudo using --allow-root option

    需要通过bower install --allow-root命令来执行安装。

只需要这些,一个完成的网站就建成了。meanjs自带了一个博客登陆体系和博客浏览发布的功能。
在根目录下运行grunt命令就可以启动服务器了,默认的端口是3000,我们可以通过ip:3000的方式来访问这个网站。

meanjs结构简介

首先进入根目录可以看到如下的文件内容:

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
[@tc_214_162 meanjs]# tree -aL 1
.
├── app #后端MVC的内容目录
├── bower.json #bower配置包管理的文件
├── .bowerrc #配置安装路径
├── config #相关配置目录
├── .csslintrc
├── Dockerfile
├── .editorconfig
├── fig.yml
├── .git
├── .gitignore
├── gruntfile.js #grunt相关配置
├── .jshintrc
├── karma.conf.js
├── LICENSE.md
├── node_modules #node模块的安装目录
├── package.json #npm包管理配置
├── Procfile
├── public #前端内容
├── README.md
├── scripts #独立脚本目录
├── server.js #服务运行的入口文件
├── .slugignore
└── .travis.yml

后端MVC的主要结构如下:

1
2
3
4
5
6
app/
├── controllers #C层
├── models #M层
├── routes #路由规则
├── tests
└── views #V层

前端主要结构如下:

1
2
3
4
5
6
7
public/
├── application.js #应用入口
├── config.js #应用配置
├── humans.txt
├── lib #angular相关库文件
├── modules #angular不同模块
└── robots.txt

angular的模块结构如下:

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
public/modules/
├── articles
│   ├── articles.client.module.js
│   ├── config
│   ├── controllers #angular的C
│   ├── services #angular的服务层
│   ├── tests
│   └── views #V层
├── core
│   ├── config
│   ├── controllers
│   ├── core.client.module.js
│   ├── css
│   ├── img
│   ├── services
│   ├── tests
│   └── views
└── users
├── config
├── controllers
├── css
├── img
├── services
├── tests
├── users.client.module.js
└── views

要相对上面的结构有清晰的了解,必须对熟悉各个模块的用法,还要了解一个页面从访问到展现的流程是怎么样。 下面通过一个页面的访问流程来对整个架构的工作流程有一个大概的认识。

打开一个页面的流程

为了便于我们假设你已经注册登录并创建了几篇文章,下面我们就依据对文章列表页的打开流程进行介绍。

  1. 首先通过menu进入文章列表页:http://localhost:3000/#!/articles我们可以看到文章的列表。通过观察这个url可以看出,其实#是一个锚点,后台的部分只是hash参数,前面的才是真正的url,也就是我们其实访问的是根目录,通过访问日志也可以看出来:

    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
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    GET / 200 26.246 ms - -
    GET /modules/core/css/core.css 200 31.710 ms - 354
    GET /modules/users/css/users.css 200 26.138 ms - 211
    GET /lib/bootstrap/dist/css/bootstrap-theme.css 200 41.393 ms - -
    GET /lib/angular-resource/angular-resource.js 200 15.583 ms - -
    GET /lib/bootstrap/dist/css/bootstrap.css 200 81.453 ms - -
    GET /lib/angular-animate/angular-animate.js 200 36.241 ms - -
    GET /lib/angular-ui-utils/ui-utils.js 200 22.724 ms - -
    GET /lib/angular-bootstrap/ui-bootstrap-tpls.js 200 28.636 ms - -
    GET /lib/angular-ui-router/release/angular-ui-router.js 200 44.805 ms - -
    GET /config.js 200 24.392 ms - 791
    GET /application.js 200 37.393 ms - 1016
    GET /modules/articles/articles.client.module.js 200 30.888 ms - 133
    GET /modules/core/core.client.module.js 200 24.737 ms - 129
    GET /modules/users/users.client.module.js 200 18.616 ms - 129
    GET /modules/articles/config/articles.client.config.js 200 13.114 ms - 389
    GET /lib/angular/angular.js 200 161.376 ms - -
    GET /modules/articles/config/articles.client.routes.js 200 36.936 ms - 700
    GET /modules/articles/services/articles.client.service.js 200 25.093 ms - 295
    GET /modules/core/config/core.client.routes.js 200 19.327 ms - 384
    GET /modules/core/controllers/header.client.controller.js 200 13.791 ms - 495
    GET /modules/articles/controllers/articles.client.controller.js 200 34.063 ms - -
    GET /modules/core/controllers/home.client.controller.js 200 43.584 ms - 224
    GET /modules/users/config/users.client.config.js 200 31.473 ms - 708
    GET /modules/core/services/menus.client.service.js 200 39.634 ms - -
    GET /modules/users/config/users.client.routes.js 200 27.816 ms - -
    GET /modules/users/controllers/authentication.client.controller.js 200 22.157 ms - -
    GET /modules/users/controllers/password.client.controller.js 200 17.350 ms - -
    GET /modules/users/controllers/settings.client.controller.js 200 21.926 ms - -
    GET /modules/users/services/authentication.client.service.js 200 15.335 ms - 202
    GET /modules/users/services/users.client.service.js 200 9.742 ms - 244
    GET /lib/bootstrap/dist/css/bootstrap-theme.css.map 200 8.378 ms - 47721
    GET /lib/bootstrap/dist/css/bootstrap.css.map 200 49.468 ms - 390518
    GET /modules/articles/views/list-articles.client.view.html 200 8.756 ms - 819
    GET /modules/core/views/header.client.view.html 200 17.955 ms - -
    GET /articles 200 24.296 ms - 407
    GET /modules/core/img/brand/favicon.ico 200 12.350 ms - 32038
  2. 请求到达之后首先会根据app/routes目录下面的路由规则进行匹配,app/routes/core.server.routes.js匹配到了这个路由,其内容入下:

    1
    2
    3
    4
    5
    module.exports = function(app) {
    // Root routing
    var core = require('../../app/controllers/core.server.controller');
    app.route('/').get(core.index);
    };

可以看出这个请求匹配到后交给了core.index进行处理,app/controllers/core.server.controller.js内容如下:

1
2
3
4
5
6
exports.index = function(req, res) {
res.render('index', {
user: req.user || null,
request: req
});
};

index函数并接收到请求后对'index'模板进行了渲染,模板文件app/views/index.server.view.html内容如下:

1
2
3
4
{% extends 'layout.server.view.html' %}
{% block content %}
<section data-ui-view></section>
{% endblock %}

这个模板继承了layout模板,并且重写了content的block内容。
我们再看一下被继承的模板app/views/layout.server.view.html其主要内容入如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<header data-ng-include="'/modules/core/views/header.client.view.html'" class="navbar navbar-fixed-top navbar-inverse"></header>
<section class="content">
<section class="container">
{% block content %}{% endblock %}
</section>
</section>

<!--Embedding The User Object-->
<script type="text/javascript">
var user = {{ user | json | safe }};
</script>

<!--Application JavaScript Files-->
{% for jsFile in jsFiles %}
<script type="text/javascript" src="{{jsFile}}"></script>
{% endfor %}

{% if process.env.NODE_ENV === 'development' %}
<!--Livereload script rendered -->
<script type="text/javascript" src="http://{{request.hostname}}:35729/livereload.js"></script>
{% endif %}

上面的主要功是加载页面所需的js文件,这些文件的配置都在config里。根据上面的访问日志可以看出主要public下的js文件都被加载了。
到这里服务端的工作已经完成一半了。

  1. 前端js加载后,angular就开始发挥作用了。angular也是一套MVC框架。在进入流程之前我们再次观察一下URL。我们打开主页时会发现url变成了http://localhost:3000/#!/。为什么url会自动加上这部分内容,又为什么需要加这部分内容呢?
    根据官方文档$locaiton Hashbang and HTML5 Modes部分的介绍,这应该是和浏览器对history支持的兼容性有关系。具体介绍可以看文档。 angualr的路由匹配规则其实是从#!之后开始的。再回到本页,public/modules/articles/config/articles.client.routes.js匹配到了当前规则代码如下:
    1
    2
    3
    4
    state('listArticles', {
    url: '/articles',
    templateUrl: 'modules/articles/views/list-articles.client.view.html'
    }).

可以看出当匹配时,angualr会自动加载templateUrl到页面片上来,在观察一下被加载的页面片public/modules/articles/views/list-articles.client.view.html内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<section data-ng-controller="ArticlesController" data-ng-init="find()">
<div class="page-header">
<h1>Articles</h1>
</div>
<div class="list-group">
<a data-ng-repeat="article in articles" data-ng-href="#!/articles/{{article._id}}" class="list-group-item">
<small class="list-group-item-text">
Posted on
<span data-ng-bind="article.created | date:'mediumDate'"></span>
by
<span data-ng-bind="article.user.displayName"></span>
</small>
<h4 class="list-group-item-heading" data-ng-bind="article.title"></h4>
<p class="list-group-item-text" data-ng-bind="article.content"></p>
</a>
</div>
<div class="alert alert-warning text-center" data-ng-if="articles.$resolved && !articles.length">
No articles yet, why don't you <a href="/#!/articles/create">create one</a>?
</div>
</section>

页面加载后执行find()函数,这个函数在控制层文件public/modules/articles/controllers/articles.client.controller.js里可以看到:

1
2
3
$scope.find = function() {
$scope.articles = Articles.query();
};

这个函数调用了Articels.query()方法,这个方法是Angualr的一个注册的service(参考文档Services), 位于文件public/modules/articles/services/articles.client.service.js中,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
//Articles service used for communicating with the articles REST endpoints
angular.module('articles').factory('Articles', ['$resource',
function($resource) {
return $resource('articles/:articleId', {
articleId: '@_id'
}, {
update: {
method: 'PUT'
}
});
}
]);

看到并没有定义query方法,是因为这方法是$resource默认的(参考$resource),代码中articleId是参数,本次查询并没有传递参数,所以实际访问的url是/articles,这个是一个RESTful接口,返回的结果赋值給$scope.articles,就可以在前端正常展示文章列表了。

  1. 第3步的最后提到了访问/articles接口,这个接口的作用就是从数据库取数据然后在返回到前端。当访问接口时,服务器接到请求,文件app/routes/articles.server.routes.js匹配到路由规则:
    1
    2
    3
    app.route('/articles')
    .get(articles.list)
    .post(users.requiresLogin, articles.create);

由于是get方法,所以需求转给了articles.list方法进行处理,app/controllers/articles.server.controller.jslist方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
exports.list = function(req, res) {
Article.find().sort('-created').populate('user', 'displayName').exec(function(err, articles) {
if (err) {
return res.status(400).send({
message: errorHandler.getErrorMessage(err)
});
} else {
res.json(articles);
}
});
};

其中Articles这个对象是这个文件前面定义的:

1
Article = mongoose.model('Article')

其中mongoose是MongoDB的一个js封装库,这个module是在app/models/article.server.model.js下定义并注册的,代码如下:

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
28
29
var mongoose = require('mongoose'),
Schema = mongoose.Schema;

/**
* Article Schema
*/
var ArticleSchema = new Schema({
created: {
type: Date,
default: Date.now
},
title: {
type: String,
default: '',
trim: true,
required: 'Title cannot be blank'
},
content: {
type: String,
default: '',
trim: true
},
user: {
type: Schema.ObjectId,
ref: 'User'
}
});

mongoose.model('Article', ArticleSchema);

到这里,整个页面从访问到处理到返回数据和渲染页面的流程就完毕了。

HTTP缓存机制

HTTP缓存是web开发中经常碰到的问题,但是之前虽然看过,那时候web开发刚开始做,概念有点儿模糊不清,所以重读了《HTTP权威指南》的缓存部分。这里对自己的理解做一下记录。

为什么要缓存

为什么要做缓存?如果你看到了这篇文章,其实心中肯定有了一个作缓存的需求和目标。缓存的目的无非就是提高用户体验,节省资源两方面。

  • 提高用户体验:
    从提高用户体验来讲,就是用户能够看到反馈的时间越短越好,速度越快越好。那么如何才能提高速度呢?当然是数据和用户越近越好,最好就在本地;还有就是网速越快越好,最好连接网络就能访问。我们都知道本地应用是最快的,因为所有的都在你的电脑里了,不需要再费劲去网上下载了。缓存的目的就是为了尽量完成这个工作而设计的。
  • 节省资源:
    这个怎么说呢,如果你要访问一个网站,第一次你肯定没有这些数据,但是如果访问后所有的数据都保存在了本地,或者离你很近的地方,就省得在网络的海洋漫游过来了,这当然就节省了不少流量,当然你的计费方式是按带宽来的,多少流量无所谓,可是对于提供网站服务的供应商来说这些都是白花花的银子啊,能剩就剩,节约每一个铜板嘛。

    缓存的机制

    这里只介绍基于HTTP协议的缓存。也许做web开发的都听过expires,cache-control,If-Modified-Since等缓存控制的HTTP头,这些眼花缭乱的头真让让人头晕啊,到底他们之间是什么关系,写了那么多,但是缓存是不是生效了?怎么生效的啊?带着这些疑问我也仔细阅读了一下书里的内容。其实一幅图可以说明很多问题:

通过这个流程图我们可以看出有三个方向:

  1. 未找到缓存(黑色线)
    当没有找到缓存时,说明我们本地并没有这些数据,这种情况一般发生在我们首次访问网站,或者以前访问过,但是清除过缓存后。这时浏览器没有这些数据,浏览器就会先访问服务器,然后把服务器上的内容取回来,内容取回来以后,就要根据情况来决定是否要保留到缓存中了。这个地方与cache-control字段的内容有关系。
  2. 缓存未过期(蓝色线)
    缓存未过期,指的是本地缓存没有过期,不需要访问服务器了,直接就可以拿本地的缓存作为响应在本地使用了。这样节省了不少网络成本,提高了用户体验过。这个内容跟expires字段和cache-control有密切的联系。
  3. 缓存已过期(红色线)
    缓存过期,是根据expirescache-control来判断的,当满足过期的条件时,会向服务器发送请求,发送的请求一般都会进行一个验证,目的是虽然缓存文档过期了,但是文档内容不一定会有什么改变,所以服务器返回的也许是一个新的文档,这时候的HTTP状态码是200,或者返回的只是一个最新的时间戳和304状态码。

下面就针对这些情况和其对应的字段及字段的优先级进行性说明:
第一优先级cache-control(expires)。下面的表是从高到低的顺序优先级递减。

字段 备注
Cache-Control no-store 不存储缓存数据,禁止对相应进行复制
Cache-Control no-cache 可以存储在本地缓存区中,但是必须进行新鲜度再验证满足之后才能使用
Pragma no-cache 用在HTTP/1.0协议中,与Cache-control一样
Cache-Control must-revalidate 表示必须进行新鲜度的再验证之后才能使用,与no-cache的区别是,这个是在过期之后才会进行强制校验,一般用在没有使用cache-control等字段明确规定的缓存时,这时会自动使用缓存策略,如果不想自动缓存,则使用这个字段值(实际测试跟no-cache没什么区别)
Cache-Control max-age 相对存活时间,相对与Last-Modified的时间,如果当前时间与Last时间只差小于这个值,则不用访问服务器,直接使用缓存,否者要进行新鲜度校验
Expires date 旧版本的使用方式,date是具体的过期时间,当没有cacche-control时使用

上面这些主要是在本地进行的,主要作用就是决定用本地缓存还是用远程服务器的资源。下面有要介绍的是新鲜度校验阶段要用到的HTTP控制字段。

字段 备注
If-Modified-Science date 初始值与Last-Modified的值一样,当请求服务器时判断文件的Last-Modified,如果比现在的时间晚,证明做过修改,需要冲重新请求文档,返回的Last-Modified为最新的时间,下次次请求时,If-Modified-Science的值会更新到与Last-Modified一致,然后在发送请求.如果时间与现在的一样,证明那个没有更新,直接返回304状态码
Last-Modified date 表示的就是文档在服务器上的最后更新时间
If-None-Match 版本号 前面的If-Modified-Science有一个缺点就是虽然文件的更新时间变了,但是内容并没有改变,也会重新发送文档,为了减少网络传输,这里就需要If-None-Match来判断了。主要是判断版本号与当前etag不一致时,更新文档,当Etag一致时只需更新文件更新时间就可以了
Etag 版本号 标识当前文档内容

优先级: Etag > Last-Modified 也就是说如果有Etag,就用If-None-Match来验证,否者才能用If-Modified-Science验证.

基本上常用的HTTP缓存功能就是这些。下面介绍一下在Nginx服务器先如何配置及前端页面如何访问和查看。

怎么使用

由于现在Nginx用的比较广泛,我用的也是Nginx,所以这里只介绍nginx的配置,其他服务器请google之。
首先是expirescache-control的配置,参看Nginx官方文档