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));
    }
}

如果 process() 方法执行时间很长,并不能很好地解决问题,因为这个 Vector 对象没法并行处理。另一个备选方案,复制并分割 Vector 对象可能是必要的,这样就可以借助副本来并行处理其中的元素,不过其他线程仍然可能会修改原始的 Vector 对象。

寄存器刷新的影响也和程序运行所在的处理器种类有关;有大量供线程使用的寄存器的处理器与较简单的处理器相比,将需要更多刷新。实际上,这段代码在许多环境上执行了很长时间,而没有出现问题。只是在尝试每个线程有大量寄存器可用、基于 SPARC 的大型机时,才出现了问题。

这是不是就意味着,在较为小型的环境中,不太可能会遇到寄存器刷新相关的问题?或许是。但是正如多核 CPU 在简单笔记本上都已经司空见惯,配备了更多缓存和寄存器的更复杂的 CPU 也会越来越常见,像这样的隐藏问题将会暴露出来。

同步代价小结

  1. 线程同步有两个性能方面的代价:限制了应用的可伸缩性,以及获取锁是有开销的。

  2. 同步的内存语义、基于 CAS 的设施和 volatile 关键字对性能可能会有很大的影响,特别是在有很多寄存器的大型机上。

避免同步

如果同步可以完全避免,那加锁的损失就不会影响应用的性能。有两种一般性的方式可以应对。

其一是在每个线程中使用不同的对象,这样访问对象时就不存在竞争了。为保证线程安全,很多 Java 对象是同步的,但是它们未必需要共享。JDK的Random 类就是这样。

另一方面,很多 Java 对象创建的成本很高,或者是会占用大量内存。以 NumberFormat 类为例:这个类的实例不是线程安全的,因为需要构建一个 java.util.Locale 实例,以满足国际化需求,这使得构建新对象的成本非常高。一个程序用一个共享的全局 NumberFormat 实例也行得通,但是对这个共享对象的访问需要同步。

相反,更好的模式是使用 ThreadLocal 对象(但是注意用完了清理):

public class Thermometer {
    private static ThreadLocal<NumberFormat> nfLocal = new ThreadLocal<>() {
        public NumberFormat initialValue() {
            NumberFormat nf = NumberFormat.getInstance();
            nf.setMinumumIntegerDigits(2);
            return nf;
        }
    }
    public String toString() {
        NumberFormat nf = nfLocal.get();
        nf.format(...);
    }
}

通过使用一个线程局部变量,总的对象数得到了限制(使对 GC 的影响最小化),而且每个对象都不会受制于线程竞争。

其二是使用基于 CAS 的替代方案。在某种意义上,这不像是避免同步,更像是解决不同的问题。但是在这个背景之下,它也可以减少同步带来的性能损失,会得到同样的效果。

基于 CAS 的保护和传统的同步之间,其差别看上去正适合用微基准测试来测量:编写比较基于 CAS 的操作和传统同步方法的代码应该并不繁琐。比如,JDK 支持很方便地使用基于 CAS 保护的计数器:AtomicLong 及类似的类。微基准测试可以将使用了基于 CAS 包含的代码与传统的同步做对比。例如如下代码:

AtomicLong al = new AtomicLong(0);
public long doOperation() {
    return al.getAndIncrement();
}

对比以下代码:

    private volatile long al = 0;
    public synchronized doOperation() {
        return al++;
    }

结果表明,使用微基准测试是行不通的。如果只有一个线程(也就不存在竞争的可能性),使用上述代码所做的微基准测试可以合理地估算两种方案在无竞争环境下的代价(。但是对于存在竞争的环境,没法提供任何信息(而且如果代码不会存在竞争,那么一开始就不需要考虑线程安全了)。

仍然使用前面的两个代码片段,构造一个使用两个线程的微基准测试,会发现在共享资源上存在极大的竞争。这也并非现实中的情况:在实际的应用中,两个线程总是同步访问共享资源的情形不大可能出现。加入更多线程只是引入了更多并不现实的竞争情况。

竞争与 volatile 变量

开发者有时会考虑使用 volatile 变量来减少同步,进而减少其应用中的竞争。结果是,对 volatile 变量的同步写会非常缓慢。

本章前面使用 ForkJoinPool 的例子包含着一个专门设计的循环,即通过向一个 volatile 变量写无意义的值来消耗大量 CPU 周期:

for (int j = 0; j < d.length - i; j++) {
    for (int k = 0; k < 100; k++) {
        dummy = j * k + i; // dummy是一个volatile变量,因此发生了多次写操作
        d[i] = dummy;
    }
}

dummy 被定义为这段代码所在类的一个实例变量,尽管例子中有 4 个线程同步执行,但是它们操作的是这个类的不同实例。因此,并不存在围绕 dummy 变量的竞争,例子中的测试在 16 秒内就完成了。

然而,如果将 dummy 修改为 static 变量,事情就会发生变化。现在有多个线程同时访问这个 volatile 变量,同样的测试需要 209 秒。

如第2章所讨论的,微基准测试容易大大高估所测问题中同步瓶颈的影响。希望这里的讨论也能说明这一点。如果将本节中的代码用在真实的应用中,可以更现实地对比两种方案的得失。

在通常情况下,在比较基于 CAS 的设施和传统的同步时,可以使用如下指导原则。

如果访问的是不存在竞争的资源,那么基于 CAS 的保护要稍快于传统的同步(虽然完全不使用保护会更快)。

如果访问的资源存在轻度或适度的竞争,那么基于 CAS 的保护要快于传统的同步(而且往往是快得多)。

随着所访问资源的竞争越来越剧烈,在某一时刻,传统的同步就会成为更高效的选择。在实践中,这只会出现在运行着大量线程的非常大型的机器上。

当被保护的值有多个读取,但不会被写入时,基于 CAS 的保护不会受竞争的影响。

Java 8 和存在竞争时的原子类

java.util.concurrent.atomic 包中的类使用了基于 CAS 的原语,而非传统的同步。因此,与编写一个同步方法来增加某个 long 变量相比,至少在对 CAS 原语的竞争非常剧烈之前,那些类(比如 AtomicLong 类)的性能往往要更好。

Java 8 引入了很多类来解决这个问题:原子的加法器(Adder)和累加器(Accumulator,比如 LongAdder 类)。与传统的原子类相比,这些类的可伸缩性更好。当多个线程更新某个 LongAdder 实例时,这个类可以独立保存每个线程所做的更新。这意味着,这些线程不需要等待其他线程完成其操作;相反,操作值本质上会被保存到一个数组中,每个线程都可以快速返回。之后,当某个线程尝试获取当前值时,操作值会被加起来或是累加起来。

在竞争很少或者没有竞争的情况下,值会随着程序的运行而累加,加法器的行为和传统的原子类一样。在竞争非常剧烈的情况下,更新会更快,不过实例会使用更多内存来保存值的数组。这种情况下,获取某个值也会稍微慢些,因为必须处理数组中所有挂起的值。不过,即使在竞争非常剧烈的情况下,与传统的原子类相比,这些新类也通常会表现得更好。

最后,还是要对代码所运行的真实生产环境做大量的测试,这是什么都代替不了的:只有 这时,我们才能明确地说,到底某个特定方法的哪个实现会更好。不过即使是这样的真实 情况,得出的判断也仅适用于当时那些条件。

避免同步小结

  1. 避免对同步对象的竞争,是缓解同步对性能影响的有效方式之一。

  2. 线程局部变量不会受竞争之苦;对于保存实际不需要在多个线程间共享的同步对象,它们非常理想。

  3. 对于确实需要共享的对象,基于 CAS 的工具也是避免传统的同步的方式之一。

伪共享

在同步可能的影响方面,有一点很少被讨论到,就是伪共享(false sharing)。在多线程程序中,这个问题过去相当隐蔽,但是随着多核机器成为标配,很多同步性能问题更明显地浮出水面了。伪共享就是一个越来越重要的问题。

伪共享之所以会出现,跟 CPU 处理其高速缓存的方式有关。考虑一个简单类中的数据:

public class DataHolder {
    public volatile long l1;
    public volatile long l2;
    public volatile long l3;
    public volatile long l4;
}

这里的每个 long 值都保存在毗邻的内存位置中;比如,l1 可能保存在 0xF20 这个内存位置。那么 l2 会保存在 0xF28,l3 在 0xF2C,以此类推。当程序要操作 l2 时,会有一大块内存被加载到当前所用的某个 CPU 核上,比如说,从 0xF00 到 0xF80 的 128 字节。如果有第 2 个线程要操作 l3,则会加载同样一段内存到另一个核的缓存行(cache line)中。

大多数情况下,像这样加载邻接的值是有意义的:如果程序访问了对象中的某个特定实例变量,则很有可能会访问邻接的实例变量。如果这些实例变量被加载到当前核的高速缓存中,内存访问就非常快,这是很大的性能优势。

这种模式的缺点是,当程序更新本地缓存中的某个值时,当前的核必须通知其他所有核:这个内存被修改了。其他核必须作废其缓存行,并重新从内存中加载。

我们来看看,如果有多个线程大量使用 DataHolder 类会发生什么:

public class ContendedTest extends Thread {
    private static class DataHolder {
        private volatile long l1 = 0;
        private volatile long l2 = 0;
        private volatile long l3 = 0;
        private volatile long l4 = 0;
    }
    private static DataHolder dh = new DataHolder();
    private static long nLoops;
    public ContendedTest(Runnable r) {
        super(r);
    }

    public static void main(String[] args) throws Exception {
        nLoops = Long.parseLong(args[0]);
        ContendedTest[] tests = new ContendedTest[4];
        tests[0] = new ContendedTest(() -> {
                for (long i = 0; i < nLoops; i++) {
                        dh.l1 += i;
                }
        });
        tests[1] = new ContendedTest(() -> {
                for (long i = 0; i < nLoops; i++) {
                        dh.l2 += i;
                }
        });
        //……tests[2]和tests[3]类似……
        long then = System.currentTimeMillis();
        for (ContendedTest ct : tests) {
            ct.start();
        }
        for (ContendedTest ct : tests) {
            ct.join();
        }
        long now = System.currentTimeMillis();
        System.out.println("Duration: " + (now - then) + " ms");
    }
}

结果并非如此:当一个特定的线程在其循环中写 volatile 值时,其他每个线程的缓存行都会被作废,内存值必须重新加载。结果如下表所示,性能随着线程数增多而变差了。

线程数消耗时间(秒)
17.1
252.1
391.0
4128.3

严格来讲,伪共享未必会涉及同步(或 volatile)变量:不论何时,CPU 缓存中有任何数据被写入了,其他保存了同样范围数据的缓存都必须作废。然而,切记 Java 内存模型要求数据只是在同步原语(包括 CAS 和 volatile 构造)结束时必须写入主内存。所以这种情况是最常见的。在这个例子中,如果 long 变量不是 volatile 的,那么编译器会将这些值放到寄存器中,不管有多少个线程,测试将在大约 7.1 秒内执行完毕。

很明显,这是个极端的例子,但是它提出了一个问题:如何检测并纠正伪共享?遗憾的是,并没有清晰、完整的答案。在第 3 章所讨论的标准工具集中,没有哪个能解决伪共享,因为这需要与处理器架构相关的非常专门的知识。

如果幸运的话,目标处理器的厂商会提供用于诊断伪共享的工具。比如,Intel 就有一个叫作 VTune 的程序,可以通过检查缓存未命中事件来检测伪共享。特定的原生分析器(profiler)可能会提供给定代码行的每指令周期数(Cycles Per Instruction,CPI)的相关信息;如果某个循环内的一条简单指令的 CPI 非常高,可能预示着代码正在等待将目标内存的信息重新加载到 CPU 缓存中。

另外,检测伪共享还需要一些直觉和实验。如果正常的分析表明,某个特定循环耗时惊人,则需要检查这个循环,看看是否有可能循环内有多个线程正在访问非共享变量。(即便很多人认为性能调优更像是艺术而非科学,Intel VTune 手册也写道:“避免伪共享的主要手段就是代码检查”。)

要阻止伪共享,需要对代码做些修改。理想的情况是所涉及的变量不会频繁写入。在前面的例子中,计算可以使用局部变量进行,只有最终结果才写回到 DataHolder 变量。如果随后的写入次数比较少,就不太可能出现对缓存行的竞争,即使所有 4 个线程在循环结束时同时更新其结果,也不会影响性能。

第 2 个可能的方案是填充(padding)相关变量,以免其被加载到相同的缓存行中。如果目标 CPU 有个 128 字节的缓存,那么像下面这样填充可能会有效果(也可能没有):

public class DataHolder {
    public volatile long l1;
    pubilc long[] dummy1 = new long[128 / 8];
    public volatile long l2;
    pubilc long[] dummy2 = new long[128 / 8];
    public volatile long l3;
    pubilc long[] dummy3 = new long[128 / 8];
    public volatile long l4;
}

像这样使用数组或许行不通,因为 JVM 可能会重新安排那些实例变量的布局,以便所有的数组紧挨在一起,于是所有的 long 变量就仍然会紧挨着了。使用基本类型的值来填充该结构,行之有效的可能性更大,但是考虑到所需的变量数目,并不现实。

使用填充来防止伪共享还有其他问题。填充的大小很难预测,因为不同 CPU 的缓存大小也不同。而且填充很明显会增大问题中的实例,这对垃圾收集器影响很大(当然也取决于所需的实例数)。不过,如果没有算法上的改进方案,填充数据有时会具有明显的优势。

@Contended 注解

Java 8 有个新特性,即能够减少指定字段(JEP 142)上的竞争。其实现方式是使用一个新的注解(@sun.misc.Contended)来标记应该由 JVM 自动填充的变量。

这个注解所属的包非常重要:尽管这是一个 JDK 增强提案(JDK Enhancement Proposal,JEP)特性,但它主要是供 JVM 自身使用。这个注解是否会进入未来的版本(即使会,其行为是否会保持不变),并没有保证。

如果不能通过其他任何手段解决伪共享,可以考虑使用该注解。一个好处是,因为 JVM 了解其运行所在 CPU 的架构,所以可以自动算出需要填充的大小;然而当 AMD(或是无论哪家厂商)推出配备了新的缓存行大小的新处理器时,较老的 JVM 只能猜测需要填充的大小是多少。和所有的填充解决方案一样,使用这个注解会大大增加目标实例的大小,所以要谨慎使用。

默认情况下,除了 JDK 内部的类,JVM 会忽略该注解。要支持应用代码使用该注解,应该使用 -XX:-RestrictContended 标志,它默认为 true(意味着该注解仅限于 JDK 类使用)。另一方面,要关掉 JDK 中的自动填充,应该设置 -XX:-EnableContended 标志,它也默认为 true。这将减小 Thread 和 ConcurrentHashMap 类的大小。

伪共享小结

  1. 对于会频繁地修改 volatile 变量或退出同步块的代码,伪共享对性能影响很大。

  2. 伪共享很难检测。如果某个循环看上去非常耗时,可以检查该代码,看看是否与伪共享出现时的模式相匹配。

  3. 最好通过将数据移到局部变量中、稍后再保存来避免伪共享。作为一种替代方案,有时可以使用填充将冲突的变量移到不同的缓存行中。

更新时间:2020-04-06 18:15:22

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优14线程与同步的性能中
最后更新:2020-04-06 18:15:22

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×