Java性能权威指南-总结18
- 线程与同步的性能
- 线程同步
- 避免同步
- 伪共享
线程与同步的性能
线程同步
避免同步
如果同步可以完全避免,那加锁的损失就不会影响应用的性能。 有两种一般性的方式可以应对。其一是在每个线程中使用不同的对象,这样访问对象时就不存在竞争了。为保证线程安全,很多Java对象是同步的,但是它们未必需要共享。Random
类就是这样。
另一方面,很多Java对象创建的成本很高,或者是会占用大量内存。以NumberFornat
类为例:这个类的实例不是线程安全的,因为需要构建一个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++;
}
结果表明,使用微基准测试是行不通的。如果只有一个线程(也就不存在竞争的可能性),使用上述代码所做的微基准测试可以合理地估算两种方案在无竞争环境下的代价。但是对于存在竞争的环境,没法提供任何信息(而且如果代码不会存在竞争,那么一开始就不需要考虑线程安全了)。
仍然使用前面的两个代码片段,构造一个使用两个线程的微基准测试,会发现在共享资源上存在极大的竞争。这也并非现实中的情况:在实际的应用中,两个线程总是同步访问共享资源的情形不大可能出现。加入更多线程只是引入了更多并不现实的竞争情况。
在通常情况下,在比较基于CAS的设施和传统的同步时,可以使用如下指导原则:
- 如果访问的是不存在竞争的资源,那么基于CAS的保护要稍快于传统的同步(虽然完全不使用保护会更快)。
- 如果访问的资源存在轻度或适度的竞争,那么基于CAS的保护要快于传统的同步(而且往往是快得多)。
- 随着所访问资源的竞争越来越剧烈,在某一时刻,传统的同步就会成为更高效的选择。在实践中,这只会出现在运行着大量线程的非常大型的机器上。
- 当被保护的值有多个读取,但不会被写入时,基于CAS的保护不会受竞争的影响。
最后,还是要对代码所运行的真实生产环境做大量的测试,这是什么都代替不了的:只有这时,才能明确地说,到底某个特定方法的哪个实现会更好。不过即使是这样的真实情况,得出的判断也仅适用于当时那些条件。
快速小结
- 避免对同步对象的竞争,是缓解同步对性能影响的有效方式之一。
- 线程局部变量不会受竞争之苦;对于保存实际不需要在多个线程间共享的同步对象,它们非常理想。
- 对于确实需要共享的对象,基于CAS的工具也是避免传统的同步的方式之一。
伪共享
在同步可能的影响方面,有一点很少被讨论到,就是伪共享(false sharing)。在多线程程序中,这个问题过去相当隐蔽,但是随着多核机器成为标配,很多同步性能问题更明显地浮出水面了。伪共享就是一个越来越重要的问题。
伪共享之所以会出现,跟CPU处理其高速缓存的方式有关。考虑一个简单类中的数据:
public class DataHolder {
public volatile long L1;
public volatile long l2;
public volatile long l3;
public volatile long l4;
}
这里的每个long值都保存在毗邻的内存位置中;比如,11可能保存在0xF20这个内存位置。那么L2会保存在0xF28,L3在0xF2C,以此类推。当程序要操作12时,会有一大块内存被加载到当前所用的某个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 = θ; i < nLoops; i++) {
dh.l1 += i;
}
});
tests[1]= new ContendedTest(() -> {
for (long i = θ;i < nLoops; i++) {
dh.l2 += i;
}
});
//……tests[2]和tests[3]类似……
long then = Systen.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
值时,其他每个线程的缓存行都会被作废,内存值必须重新加载。结果如下表所示,性能随着线程数增多而变差了。
严格来讲,伪共享未必会涉及同步(或volatile
)变量:不论何时,CPU缓存中有任何数据被写入了,其他保存了同样范围数据的缓存都必须作废。然而,切记Java内存模型要求数据只是在同步原语(包括CAS
和volatile
构造)结束时必须写入主内存。所以这种情况是最常见的。在这个例子中,如果long
变量不是volatile
的,那么编译器会将这些值放到寄存器中,不管有多少个线程,测试将在大约7.1秒内执行完毕。
很明显,这是个极端的例子,但是它提出了一个问题:如何检测并纠正伪共享?遗憾的是,并没有清晰、完整的答案。如果幸运的话,目标处理器的厂商会提供用于诊断伪共享的工具。比如,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[] dunmy1 = 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的缓存大小也不同。而且填充很明显会增大问题中的实例,这对垃圾收集器影响很大(当然也取决于所需的实例数)。不过,如果没有算法上的改进方案,填充数据有时会具有明显的优势。
快速小结
- 对于会频繁地修改
volatile
变量或退出同步块的代码,伪共享对性能影响很大。 - 伪共享很难检测。如果某个循环看上去非常耗时,可以检查该代码,看看是否与伪共享出现时的模式相匹配。
- 最好通过将数据移到局部变量中、稍后再保存来避免伪共享。作为一种替代方案,有时可以使用填充将冲突的变量移到不同的缓存行中。