Blog

编程语言的一等公民

什么是编程语言的“一等公民”呢? #

关于这个名词,业界和教科书都没有给出精准的定义。我们这里可以引用一下 wiki 发明人、C2 站点作者沃德·坎宁安 (Ward Cunningham)对“一等公民”的解释:

如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。

Viewpoint #

From #

21|函数:请叫我“一等公民”

具名返回值

具名返回值 #

当函数的返回值个数较多时,每次显式使用 return 语句时都会接一长串返回值,这时,我们用具名返回值可以让函数实现的可读性更好一些,比如下面 Go 标准库 time 包中的 parseNanoseconds 函数就是这样:

// $GOROOT/src/time/format.go
func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string, err error) {
    if !commaOrPeriod(value[0]) {
        err = errBad
        return
    }
    if ns, err = atoi(value[1:nbytes]); err != nil {
        return
    }
    if ns < 0 || 1e9 <= ns {
        rangeErrString = "fractional second"
        return
    }
    scaleDigits := 10 - nbytes
    for i := 0; i < scaleDigits; i++ {
        ns *= 10
    }
    return
}

Viewpoint #

From #

21|函数:请叫我“一等公民”

Point-Out与Point-In

Point-Out与Point-In #

像 G1 这种分区回收算法,有些 Region 可能被选入 CSet( 回收集合CollectionSet),有些则不会。所以,我们需要知道当一个 Region 需要被回收时,有哪些其他的 Region 引用了自己。相应地,为了加快定位速度,分区回收算法为每个 Region 都引入了记录集(Remembered Set,RSet),每个 Region 都有自己的专属 RSet。

和 Card table 不同的是,RSet 记录谁引用了我,这种记录集被人们称为 point-in 型的,而 Card table 则记录我引用了谁,这种记录集被称为 point-out 型。

图的左侧为Card Table,展示了一个维护跨区引用的通用记录集,它是全局唯一的,记录了每个Region对其他Region的引用,为Point Out型 。

图右侧展示了 Region1 的专属记录集,记录的是哪些Region引用了Region1。

Viewpoint #

From #

22 | G1 GC:分区回收算法说的是什么?

回收集合CollectionSet

回收集合CollectionSet #

G1 的垃圾回收模式有两种:分别是 young GC 和 mixed GC。

  1. young GC:只回收年轻代的 Region。
  2. mixed GC:回收全部的年轻代 Region,并回收部分老年代的 Region。

无论是 young GC 还是 mixed GC,都会回收全部的年轻代,mixed 回收的老年代 Region 是需要进行决策的(Humongous 在回收时也是当做老年代的 Region 处理的)。那么决定老年代 Region 是否被回收的因素具体有哪些呢?

我们把 mixed GC 中选取的老年代对象 Region 的集合称之为回收集合(Collection Set,CSet)。CSet 的选取要素有以下两点:

  1. 该 Region 的垃圾占比。垃圾占比越高的 Region,被放入 CSet 的优先级就越高,这就是垃圾优先策略(Garbage First),也是 G1 GC 名称的由来。
  2. 建议的暂停时间。建议的暂停时间由 -XX:MaxGCPauseMillis 指定,G1 会根据这个值来选择合适数量的老年代 Region。

MaxGCPauseMillis 默认是 200ms,一般不需要进行调整,如果需要停顿时间更短可以对它进行设置,不过需要注意的是,MaxGCPauseMillis 设置的越小,选取的老年代 Region 就会越少,如果 GC 压力居高不下,就会触发 G1 的 Full GC。

触发 G1 的 Full GC 代价是很高的。最早的实现是一个单线程的 Mark-Compact GC,停顿时间非常长,虽然后来也改进成多线程,但还是需要尽量避免触发 G1 的 Full GC。如果一个应用会频繁触发 G1 GC 的 Full GC,那么说明这个应用的 GC 参数配置是不合理的,理想情况下 G1 是没有 Full GC 的。

...

SATB开始时快照

SATB开始时快照 #

解决活跃对象漏标的问题的两种常见的方法,分别是“往前走”和“往后退一步”。还有第三种解法,这是由日本学者汤浅太一提出的,具体的算法如下图所示: 在这张图中,我们可以看到,当对象 B 对 C 的引用关系消失以后,再将 C 标记为灰色,即便将来 A 对 C 的引用消失了,也会在当前 GC 周期内被视为活跃对象。也就是说,C 有可能变成浮动垃圾。我们把这种在删除引用的时候进行维护的屏障叫做 deletion barrier。

G1 中采用的就是这种做法。这种做法的特点是,在 GC 标记开始的一瞬间,活跃的对象无论在标记期间发生怎样的变化,都会被认为是活跃的对象。

我们知道,当一个对象的全部引用被删除时,才会被当做垃圾。而如果使用我们前面讲到的 deletion barrier,在并发标记阶段,即便对象的全部引用被删除,也会被当做活跃对象来处理。就好像在 GC 开始的瞬间,内存管理器为所有活跃对象做了一个快照一样,所以人们给了这种技术一个很形象的名字:开始时快照(Snapshot At The Beginning,SATB)。

你要注意的是,有些文章对 SATB 的解释是:在 GC 开始时将堆做一个内存快照,存放到磁盘上。这种说法就是望文生义了。因为快照这个词在计算机领域通常是指压缩,索引等技术,所以就有人把这里的快照理解成了对堆对象的一种压缩。由此,我们就知道这种错误的说法是怎么来的了。

当 B 对象对 C 对象的引用消失时,C 对象将会被标记为灰色。这个动作的效率是比较低的,如果都放在写屏障中做,会极大地影响程序性能。因为写屏障的逻辑是由业务线程执行的。

为了解决这个问题,GC 开发者将“C 对象标记为灰色”这件事情往后推迟了。业务线程只需要把 C 对象记录到一个本地队列中就可以了。每个业务线程都有一个这样的线程本地队列,它的名字是 SATB 队列。

当业务线程发现对象 C 的引用被删除之后,直接将 C 放到 SATB 队列中,并不去做标记,真正做标记的工作交给 GC 线程去做,这样就减少了写屏障的开销。

如上图所示,每个线程有自己的本地 SATB 队列,当本地队列满了之后,就把它交给 SATB 队列集合,然后再领取一个空队列当做线程的本地 SATB 队列。GC 线程则会将 SATB 队列集合中的对象标记为灰色,至于什么时候标记,并不需要业务线程关心。

...

G1分区算法的堆结构

G1分区算法的堆结构 #

我们来了解一下分区回收算法的堆空间是如何划分的。下图是 G1 GC 的堆结构: G1 也是一个分代的垃圾回收算法,不过,和之前介绍的 CMS、Scavenge 算法不同的是:G1 的老年代和年轻代不再是一块连续的空间,整个堆被划分成若干个大小相同的 Region,也就是区。Region 的类型有 Eden、Survivor、Old、 Humongous 四种,而且每个 Region 都可以单独进行管理。

Humongous 是用来存放大对象的,如果一个对象的大小大于一个 Region 的 50% (默认值),那么我们就认为这个对象是一个大对象。为了防止大对象的频繁拷贝,我们可以将大对象直接放到 Humongous 中。

而 Eden、Survivor、Old 三种区域和我们前面课程中介绍的 Eden 分区、 Survivor 分区以及老年代的作用是类似的。也就是说,对象会在 Eden Regions 中分配,当进行年轻代 GC 时,会将活跃对象拷贝到 Survivor Regions;当对象年龄超过晋升阈值时,就把活跃对象复制进 Old Regions。

Viewpoint #

From #

22 | G1 GC:分区回收算法说的是什么?

内存屏障与InvalidQueue

内存屏障与InvalidQueue #

你先来看看下面这个代码:

// CPU0
void foo() {
    a = 1;
    b = 1;
}
// CPU1
void bar() {
    while (b == 0) continue;
    assert(a == 1);
}

假如,CPU0 和 CPU1 的缓存中都有变量 a 的副本,也就是说变量 a 所对应的缓存行在 CPU0 和 CPU1 中都是 Share 状态。CPU1 中没有变量 b 的副本,b 所对应缓存在 CPU0 中是 Exclusive 状态。

当 CPU0 在将变量 a 写入缓存的时候,会产生 Invalid 消息,这个消息到达 CPU1 以后,CPU1 不再立即处理它了,而是将这个消息放入 invalid queue,并且立即向 CPU0 回复了 invalid acknowledgement 消息。

CPU0 在得到这个确认消息以后,就可以独占该缓存了,直接将这块缓存变为 Modified 状态,然后把 a 写入。在 a 写入以后,foo 函数中的内存屏障就可以顺利通过了,接下来就可以写入变量 b 的新值。由于 b 是 Exclusive 的,它的更新比较简单,你可以自己思考一下。

...

内存屏障与StoreBuffer

内存屏障与StoreBuffer #

CPU Store Buffer 的存在是为提升写性能,放弃了缓存的顺序一致性,我们把这种现象称为弱缓存一致性。在正常的程序中,多个 CPU 一起操作同一个变量的情况是比较少的,所以 store buffer 可以大大提升程序的运行性能。但在需要核间同步的情况下,我们还是需要这种一致性的,这就需要软件工程师自己在合适的地方添加内存屏障了。

store buffer并不能保证变量写入缓存和主存的顺序,你先来看看下面这个代码:

// CPU0
void foo() {
    a = 1;
    b = 1;
}
// CPU1
void bar() {
    while (b == 0) continue;
    assert(a == 1);
}

在这个代码块中,CPU0 执行 foo 函数,CPU1 执行 bar 函数。但在对变量 a 和 b 进行赋值的时候,有两种情况会导致它们的赋值顺序被打乱。

  1. CPU 的乱序执行。CPU 为了提升运行效率和提高缓存命中率,采用了乱序执行。
  2. store buffer 在写入时,有可能 b 所对应的缓存行会先于 a 所对应的缓存行进入独占状态,也就是说 b 会先写入缓存。

这种情况完全是有可能的。如果 a 是 Share 状态,b 是 Exclusive 状态,那么尽管 CPU0 在执行时没有乱序,这两个变量由 store buffer 写入缓存时也是不能保证顺序的。

那这个时候,我们假设 CPU1 开始执行时,a 和 b 所对应的缓存行都是 Invalid 状态。当 CPU1 开始执行第 9 行的时候,由于 b 所对应的缓存区域是 Invalid 状态,它就会向总线发出 BusRd 请求,那么 CPU1 就会先把 b 的最新值读到本地,完成变量 b 的值的更新,从而跳出while的循环,继续执行assert 语句。

...

D型触发器

D型触发器 #

下面这个电路在 RS触发器基础之上,在 R 和 S 开关之后,加入了两个与门,同时给这两个与门加入了一个时钟信号 CLK 作为电路输入。

这样,当时钟信号 CLK 在低电平的时候,与门的输入里有一个 0,两个实际的 R 和 S 后的与门的输出必然是 0。也就是说,无论我们怎么按 R 和 S 的开关,根据 R-S 触发器的真值表,对应的 Q 的输出都不会发生变化。

只有当时钟信号 CLK 在高电平的时候,与门的一个输入是 1,输出结果完全取决于 R 和 S 的开关。我们可以在这个时候,通过开关 R 和 S,来决定对应 Q 的输出。 通过一个时钟信号,我们可以在特定的时间对输出的 Q 进行写入操作

如果这个时候,我们让 R 和 S 的开关,也用一个反相器连起来,也就是通过同一个开关控制 R 和 S。只要 CLK 信号是 1,R 和 S 就可以设置输出 Q。而当 CLK 信号是 0 的时候,无论 R 和 S 怎么设置,输出信号 Q 是不变的。这样,这个电路就成了我们最常用的 D 型触发器。用来控制 R 和 S 这两个开关的信号呢,我们视作一个输入的数据信号 D,也就是 Data,这就是 D 型触发器的由来。

...

RS触发器

RS触发器 #

下面这个 RS 触发器电路由两个或非门电路组成。在图里面,把它标成了 A 和 B。 或非门的真值表 NOR 0 1 0 1 0 1 0 0

  1. 在这个电路一开始,输入开关都是关闭的,所以或非门(NOR)A 的输入是 0 和 0。对应到我列的这个真值表,输出就是 1。而或非门 B 的输入是 0 和 A 的输出 1,对应输出就是 0。B 的输出 0 反馈到 A,和之前的输入没有变化,A 的输出仍然是 1。而整个电路的输出 Q,也就是 0。
  2. 当我们把 A 前面的开关 R 合上的时候,A 的输入变成了 1 和 0,输出就变成了 0,对应 B 的输入变成 0 和 0,输出就变成了 1。B 的输出 1 反馈给到了 A,A 的输入变成了 1 和 1,输出仍然是 0。所以把 A 的开关合上之后,电路仍然是稳定的,不会像晶振那样振荡,但是整个电路的输出 Q 变成了 1。
  3. 这个时候,如果我们再把 A 前面的开关 R 打开,A 的输入变成和 1 和 0,输出还是 0,对应的 B 的输入没有变化,输出也还是 1。B 的输出 1 反馈给到了 A,A 的输入变成了 1 和 0,输出仍然是 0。这个时候,电路仍然稳定。开关 R 和 S 的状态和上面的第一步是一样的,但是最终的输出 Q 仍然是 1,和第 1 步里 Q 状态是相反的。我们的输入和刚才第二步的开关状态不一样,但是输出结果仍然保留在了第 2 步时的输出没有发生变化。
  4. 这个时候,只有我们再去关闭下面的开关 S,才可以看到,这个时候,B 有一个输入必然是 1,所以 B 的输出必然是 0,也就是电路的最终输出 Q 必然是 0。

这样一个电路,我们称之为触发器(Flip-Flop)。接通开关 R,输出变为 1,即使断开开关,输出还是 1 不变。接通开关 S,输出变为 0,即使断开开关,输出也还是 0。也就是,当两个开关都断开的时候,最终的输出结果,取决于之前动作的输出结果,这个也就是我们说的记忆功能。

...