一尘不染

在几乎不改变执行顺序的情况下,如何分配变量会导致严重的性能下降?

java

在使用多线程时,我会发现一些与AtomicLong(以及使用它的类,例如java.util.Random)有关的意外但严重的性能问题,目前我对此没有任何解释。但是,我创建了一个简单的示例,该示例基本上由两个类组成:一个类“
Container”,该类保留对volatile变量的引用;一个类“ DemoThread”,其在线程执行期间对“
Container”的实例进行操作。请注意,对“ Container”和volatile
long的引用是私有的,并且永远不会在线程之间共享(我知道这里不需要使用volatile,仅用于演示目的)-因此,“
DemoThread”的多个实例应该完美运行在多处理器计算机上并行运行,但是由于某些原因,

private static class Container  {

    private volatile long value;

    public long getValue() {
        return value;
    }

    public final void set(long newValue) {
        value = newValue;
    }
}

private static class DemoThread extends Thread {

    private Container variable;

    public void prepare() {
        this.variable = new Container();
    }

    public void run() {
        for(int j = 0; j < 10000000; j++) {
            variable.set(variable.getValue() + System.nanoTime());
        }
    }
}

在测试期间,我反复创建4个DemoThread,然后将其启动并加入。每个循环中唯一的区别是调用“
prepare()”的时间(显然这是线程运行所必需的,否则将导致NullPointerException):

DemoThread[] threads = new DemoThread[numberOfThreads];
    for(int j = 0; j < 100; j++) {
        boolean prepareAfterConstructor = j % 2 == 0;
        for(int i = 0; i < threads.length; i++) {
            threads[i] = new DemoThread();
            if(prepareAfterConstructor) threads[i].prepare();
        }

        for(int i = 0; i < threads.length; i++) {
            if(!prepareAfterConstructor) threads[i].prepare();
            threads[i].start();
        }
        joinThreads(threads);
    }

由于某种原因,如果在启动线程之前立即执行prepare(),则完成时间将是原来的两倍,即使没有“
volatile”关键字,性能差异也很明显,至少在两台计算机和OS上是我测试了代码。这是一个简短的摘要:


Mac OS摘要:

Java版本:1.6.0_24
Java类版本:50.0
VM供应商:Sun Microsystems Inc.
VM版本:19.1-b02-334
VM名称:Java HotSpot(TM)64位服务器VM
OS名称:Mac OS X
OS架构:x86_64
OS版本:10.6.5
处理器/核心:8

带有volatile关键字:
最终结果:
31979毫秒。实例化后调用prepare()的时间。
96482毫秒 在执行之前调用prepare()的时间。

没有volatile关键字:
最终结果:
26009 ms。实例化后调用prepare()的时间。
35196毫秒 在执行之前调用prepare()的时间。


Windows摘要:

Java版本:1.6.0_24
Java类版本:50.0
VM供应商:Sun Microsystems Inc.
VM版本:19.1-b02
VM名称:Java HotSpot(TM)64位服务器VM
OS名称:Windows 7
OS Arch:amd64
OS版本:6.1
处理器/核心:4

带有volatile关键字:
最终结果:
18120 ms。实例化后调用prepare()的时间。
36089毫秒 在执行之前调用prepare()的时间。

没有volatile关键字:
最终结果:
10115毫秒。实例化后调用prepare()的时间。
10039毫秒 在执行之前调用prepare()的时间。


Linux摘要:

Java版本:1.6.0_20
Java类版本:50.0
VM供应商:Sun Microsystems Inc.
VM版本:19.0-b09
VM名称:OpenJDK 64位服务器VM
OS名称:Linux
OS Arch:amd64
OS版本:2.6.32-28-通用
处理器/内核:4

带有volatile关键字:
最终结果:
45848毫秒。实例化后调用prepare()的时间。
110754毫秒 在执行之前调用prepare()的时间。

没有volatile关键字:
最终结果:
37862 ms。实例化后调用prepare()的时间。
39357毫秒 在执行之前调用prepare()的时间。


Mac OS详细信息(易失):

测试1、4个线程,
在653毫秒后完成创建循环Thread-2中的设置变量。
线程3在653毫秒后完成。
线程4在653毫秒后完成。
线程5在653毫秒后完成。
总时间:654毫秒。

测试2、4个线程,
在1588毫秒后完成启动循环Thread-7中的设置变量。
线程6在1589毫秒后完成。
线程8在1593毫秒后完成。
线程9在1593毫秒后完成。
总时间:1594毫秒。

测试3-4个线程,
在648毫秒后完成创建循环Thread-10中的设置变量。
线程12在648毫秒后完成。
线程13在648毫秒后完成。
线程11在648毫秒后完成。
总时间:648毫秒。

测试4、4个线程,
在1353毫秒后完成启动循环Thread-17中的设置变量。
线程16在1957 ms之后完成。
线程14在2170毫秒后完成。
线程15在2169毫秒后完成。
总时间:2172毫秒。

(依此类推,有时“慢速”循环中的一个或两个线程会按预期完成,但大多数情况下不会。)

给出的示例从理论上看是没有用的,这里不需要’volatile’-但是,如果您使用’java.util.Random’-Instance而不是’Container’-Class并调用,例如,nextInt()多次,将产生相同的效果:如果在Thread的构造函数中创建对象,则将快速执行该线程,但如果在run()方法中创建该对象,则将缓慢执行该线程。我相信Mac
OS上的Java Random Slowdowns中
描述的性能问题一年多以前与这种效果有关,但我不知道为什么会这样-
除此之外,我确定不应该那样做,因为这将意味着创建一个新的除非您知道对象图中不会涉及任何易失变量,否则线程运行方法中的对象都是对象。剖析无济于事,因为这种情况下问题消失了(与Mac
OS上的Java Random Slowdowns
相同的观察,),并且在单核PC上也没有发生-
所以我猜是有点像线程同步问题…但是,奇怪的是,实际上没有什么要同步的,因为所有变量都是线程局部的。

真的很期待任何提示-万一您想确认或伪造问题,请参阅下面的测试用例。

谢谢,

史蒂芬

public class UnexpectedPerformanceIssue {

private static class Container  {

    // Remove the volatile keyword, and the problem disappears (on windows)
    // or gets smaller (on mac os)
    private volatile long value;

    public long getValue() {
        return value;
    }

    public final void set(long newValue) {
        value = newValue;
    }
}

private static class DemoThread extends Thread {

    private Container variable;

    public void prepare() {
        this.variable = new Container();
    }

    @Override
    public void run() {
        long start = System.nanoTime();
        for(int j = 0; j < 10000000; j++) {
            variable.set(variable.getValue() + System.nanoTime());
        }
        long end = System.nanoTime();
        System.out.println(this.getName() + " completed after "
                +  ((end - start)/1000000) + " ms.");
    }
}

public static void main(String[] args) {
    System.out.println("Java Version: " + System.getProperty("java.version"));
    System.out.println("Java Class Version: " + System.getProperty("java.class.version"));

    System.out.println("VM Vendor: " + System.getProperty("java.vm.specification.vendor"));
    System.out.println("VM Version: " + System.getProperty("java.vm.version"));
    System.out.println("VM Name: " + System.getProperty("java.vm.name"));

    System.out.println("OS Name: " + System.getProperty("os.name"));
    System.out.println("OS Arch: " + System.getProperty("os.arch"));
    System.out.println("OS Version: " + System.getProperty("os.version"));
    System.out.println("Processors/Cores: " + Runtime.getRuntime().availableProcessors());

    System.out.println();
    int numberOfThreads = 4;

    System.out.println("\nReference Test (single thread):");
    DemoThread t = new DemoThread();
    t.prepare();
    t.run();

    DemoThread[] threads = new DemoThread[numberOfThreads];
    long createTime = 0, startTime = 0;
    for(int j = 0; j < 100; j++) {
        boolean prepareAfterConstructor = j % 2 == 0;
        long overallStart = System.nanoTime();
        if(prepareAfterConstructor) {
            System.out.println("\nTest " + (j+1) + ", " + numberOfThreads + " threads, setting variable in creation loop");             
        } else {
            System.out.println("\nTest " + (j+1) + ", " + numberOfThreads + " threads, setting variable in start loop");
        }

        for(int i = 0; i < threads.length; i++) {
            threads[i] = new DemoThread();
            // Either call DemoThread.prepare() here (in odd loops)...
            if(prepareAfterConstructor) threads[i].prepare();
        }

        for(int i = 0; i < threads.length; i++) {
            // or here (in even loops). Should make no difference, but does!
            if(!prepareAfterConstructor) threads[i].prepare();
            threads[i].start();
        }
        joinThreads(threads);
        long overallEnd = System.nanoTime();
        long overallTime = (overallEnd - overallStart);
        if(prepareAfterConstructor) {
            createTime += overallTime;
        } else {
            startTime += overallTime;
        }
        System.out.println("Overall time: " + (overallTime)/1000000 + " ms.");
    }
    System.out.println("Final results:");
    System.out.println(createTime/1000000 + " ms. when prepare() was called after instantiation.");
    System.out.println(startTime/1000000 + " ms. when prepare() was called before execution.");
}

private static void joinThreads(Thread[] threads) {
    for(int i = 0; i < threads.length; i++) {
        try {
            threads[i].join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

}


阅读 219

收藏
2020-12-03

共1个答案

一尘不染

这可能是因为两种挥发性变量ab过于接近对方,他们属于在同一高速缓存行; 尽管CPU A仅读取/写入变量a,而CPU
B仅读取/写入变量b,但它们仍通过同一高速缓存行彼此耦合。这些问题称为 虚假共享

在您的示例中,我们有两种分配方案:

new Thread                               new Thread
new Container               vs           new Thread
new Thread                               ....
new Container                            new Container
....                                     new Container

在第一种方案中,两个volatile变量几乎不可能彼此接近。在第二种方案中,几乎可以肯定是这种情况。

CPU缓存不能使用单个单词;相反,它们处理高速缓存行。高速缓存行是连续的内存块,例如64个相邻字节。通常,这很好-
如果CPU访问了某个单元,则很有可能也会访问相邻的单元。除您的示例外,该假设不仅无效,而且有害。

假设ab属于同一缓存行L。CPU A更新时a,它会通知其他L脏的CPU 。由于B
L也正在缓存,因为它正在工作b,所以B必须删除其cached L。因此,下次B需要读取时b,必须重新加载L,这是昂贵的。

如果B必须访问主内存以进行重新加载,这将是非常昂贵的,通常速度要慢100倍。

幸运的是,AB可以直接对新值,而无需通过主内存去沟通。但是,这需要花费额外的时间。

为了验证这一理论,您可以在中填充额外的128个字节Container,以使两个的volatile变量Container不会落在同一高速缓存行中。那么您应该观察到这两个方案执行大约需要相同的时间。

经验教训:通常,CPU假定相邻变量是相关的。如果我们想要自变量,则最好将它们彼此远离。

2020-12-03