Java性能调优14——线程与同步的性能(中)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

线程同步

在理想的世界中,或者是在书本上的例子中,很容易避开对线程同步的需求。而在现实世界中,就未必那么容易了。

同步与 Java 并发设施

在本节中,当用到“同步”(synchronization)这个术语时,它指的是这样的代码:这段代码在一个代码块内,它们对一组变量的访问看上去是串行的,每次只有一个线程能访问内存。具体而言,既包括用 synchronized 关键字保护的代码块,也包括用 java.util.concurrent.lock.Lock 实例保护的代码,再就是 java.util.concurrent 和 java.util.concurrent.atomic 包内的代码。

严格来讲,atomic 下的类并没有使用同步,至少从 CPU 编程术语来看是这样的。它们利用了“比较与交换”(Compare and Swap,CAS)CPU 指令,而同步需要互斥访问某个资源。在同步访问同一资源时,利用了 CAS 指令的线程不会阻塞,而对于需要同步锁的线程而言,如果另一个线程占据了该资源,则这个线程会阻塞。

这两种方式之间存在性能的权衡(本节后面会讨论)。然而,即使 CAS 指令是无锁、非阻塞的,它们仍然会表现出阻塞方式所具有的大部分行为:在开发者看来,最终结果看上去还是线程只能串行地访问被保护内存。

同步的代价

同步代码对性能有两个方面的影响。其一,应用在同步块上所花的时间会影响该应用的可伸缩性。其二,获取同步锁需要一些 CPU 周期,所以也会影响性能。

1. 同步与可伸缩性

先看重要的,当某个应用被分割到多个线程上运行时,加速比(Speedup)可以用如下等式定义(即 Amdahl 定律):

P 是程序并行运行部分所花的时间,N 是所用到的线程数(假定每个线程总有 CPU 可用)。所以,如果 20% 的代码是串行执行的(这意味着 P 是 80%),有 8 个 CPU 可用,则可以预计存在并发的情况下加速比为 3.33。

从这个等式可以看出一个关键事实,即随着 P 值的降低(也就是说,有更多代码是串行执行的),引入多个线程所带来的性能优势也会随之下降。限制串行块中的代码量之所以如此重要,原因就在于此。在这个例子中,有 8 个 CPU 可用,我们可能会希望速度提升 8 倍。但是在只有 20% 的代码串行执行时,引入多个线程的好处就少了一半多(只增加了 3.33 倍)。

2. 锁定对象的开销

除了对可伸缩性的影响,同步操作本身还有两个基本的开销。

首先是获取同步锁的成本。如果某个锁没有被争用(即两个线程没有同时尝试访问这个锁),那这方面的开销会相当小。synchronized 关键字和 CAS 指令之间有轻微的差别。非竞争的 synchronized 锁被称为非膨胀(uninflated)锁,获取非膨胀锁的开销在几百纳秒的数量级。非竞争的 CAS 代码损失会更小。(第 12 章有例子对比了其差别,可以参考。)

在存在竞争的条件下,开销会更高。当第 2 个线程尝试访问某个同步锁时,可以预见这个锁会变成膨胀的(inflated)。这个成本是固定的,不管是 2 个还是 20 个线程要访问同一个锁,执行的代码量是一样的。(20 个线程都必须执行加锁代码,当然,成本会随线程数增加,但每个线程所花的时间是固定的,这是重点。)

对于使用 CAS 指令的代码,当存在竞争时,开销是无法预测的。CAS 原语基于一种乐观的策略:线程设置某个值,执行一些代码,然后确保初始值没有被修改。如果值被修改了,那么基于 CAS 的代码必须再次执行这些代码。在最坏的情况下,如果有两个线程,

它们都在修改 CAS 所保护的值,那么相互就会看到另一个线程同时也在修改这个值,就有可能会陷入无限循环。不过在实践中,两个线程不会进入这样的无限循环,但是随着竞争 CAS 所保护值的线程数的增加,重试次数也会增加。(如果此处的操作是只读的,那基于 CAS 的保护不会受竞争访问的影响。比如,不管有多少线程,它们都可以同时在同一个对象上调用 AtomicLong.get() 方法,而不用因竞争付出任何代价。这是使用基于 CAS 的设施的另一个重要优势。)

同步的第 2 个开销是 Java 特有的,依赖于 Java 内存模型(Java Memory Model)。和 C++ 和 C 等语言不同,Java 对同步相关的内存语义有严格的保证,而且该保证适用于基于 CAS 的保护、传统的同步以及 volatile 关键字。

例子中使用的 volatile

Java 内存模型对本书中的两个例子有微妙的影响。第 2 章探讨了编写一个微基准测试存在的问题;最终的解决方案需要一个 volatile 变量来保存每次循环迭代的结果:

public class MicroBenchmark {
    private volatile double answer;
    public static void main(String[] args) {
        long then = System.currentTimeMills();
        for (int i = 0; i < nLoops; i++) {
            answer = compute(randomValue[i]);
        }
        long now = System.currentTimeMills();
        System.out.println("Elapsed time:" + (now - then));
    }
}

编译器会优化代码,它可以展开循环,得到如下伪代码:

for (int i = 0; i < nLoops; i += 4) {
    answer = compute(randomValue[i]);
    answer = compute(randomValue[i + 1]);
    answer = compute(randomValue[i + 2]);
    answer = compute(randomValue[i + 3]);
}

如果 JVM 把 answer 的值保存在某个寄存器中,它就可以注意到寄存器被写了多次,但是没有读取操作(因为其他线程不能读这个寄存器),因此,除了最终结果,可以优化掉所有的循环计算。将 answer 用 volatile 定义,确保 JVM 必须保存每次循环的计算结果。JVM 不能优化掉这些计算,因为它无从知道是否会有其他线程过来,从主内存(main memory)中读取这个值。

类似地,前面的双重检查锁定的例子中也需要使用一个 volatile 变量:

private volatile ConcurrentHashMap instanceChm;
...
public void doOperation() {
    ConcurrentHashMap chm = instanceChm;
    if (chm == null) {
        synchronized(this) {
            chm = instanceChm;
            if (chm == null) {
                chm = new ConcurrentHashMap();
                …… 填充这个Map
                instanceChm = chm;
            }
        }
    }
    ……use the chm……
}

在这个例子中,volatile 关键字实现了两个目的。其一,可以注意到,HashMap 是先用一个局部变量初始化的,而且只有完全初始化的最终值才会被赋值给 instanceChm 变量。如果填充 HashMap 的代码直接使用 instanceChm 这个实例变量,则第 2 个线程有可能会看到一个部分初始化的 Map。其二,它可以确保当 Map 完全初始化后,其他线程可以立即看到值被写入了 instanceChm 变量。

同步的目的是保护对内存中值(也就是变量)的访问。变量可能会临时保存在寄存器中,这要比直接在主内存中访问更高效。寄存器值对其他线程是不可见的;当前线程修改了寄存器中的某个值,必须在某个时机把寄存器中的值刷新到主内存中,以便其他线程可以看到这个值。而寄存器值必须刷新的时机,就是由线程同步控制的。

实际的语言会非常复杂,最简单的理解是,当一个线程离开某个同步块时,必须将任何修改过的值刷新到主内存中。这意味着进入该同步块的其他线程将能看到最新修改的值。类似地,基于 CAS 的保护确保操作期间修改的变量被刷新到主内存中,标记为 volatile 的变量,无论什么时候被修改了,总会在主内存中更新。

应该学习避免使用 Java 中性能不太高的构造,即使这看上去像“过早地优化”自己的代码(事实并非如此)。下面循环中有个有趣的案例,而且是一个现实中的例子:

Vector v;
for (int i = 0; i < v.size(); i++) {
    process(v.get(i));
}

在生产中,我们发现这个循环消耗的时间惊人。比较合乎逻辑的假设是,process() 方法是罪魁祸首。但事实并非如此,问题也不在于 size() 和 get() 方法调用本身(调用已经被编译器内联了)。Vector 类的 size() 和 get() 方法是同步的,所有这些调用所需要的寄存器刷新是很大的性能问题。

这段代码之所以不理想,还有其他一些原因。特别是,在某个线程调用 size() 和调用 get() 的中间时间内,Vector 对象的状态有可能会发生变化。如果在此之间,另一个线程移除了这个对象的最后一个元素,则 get() 方法将抛出 ArrayIndexOutOfBoundsException。除了代码中的语义问题,细粒度的同步也是较差的选择。

一种方案是将大量连续的、细粒度的同步调用包含在一个同步块内:

synchronized(v) {
    for (int i = 0; i < v.size(); i++) {
        process(v.get(i));