为什么这段代码加锁后运行更快?

13

一些背景信息:我创建了一个人为的示例,向我的团队演示如何使用VisualVM。特别是,有一个方法有一个不必要的synchronized关键字,我们看到线程池中的线程被阻塞,而它们不需要被阻塞。但是删除该关键字会产生下面描述的令人惊讶的效果,下面的代码是我能够减少原始示例以重现问题的最简单案例,并且使用ReentrantLock也会创建相同的效果。

请考虑以下代码(完整可运行代码示例位于https://gist.github.com/revbingo/4c035aa29d3c7b50ed8b - 您需要将Commons Math 3.4.1添加到类路径中)。它创建100个任务,并将它们提交给5个线程的线程池。在任务中,创建两个大小为500x500的随机值矩阵,然后将它们相乘。

public class Main {
private static ExecutorService exec = Executors.newFixedThreadPool(5);

private final static int MATRIX_SIZE = 500;
private static UncorrelatedRandomVectorGenerator generator = 
            new UncorrelatedRandomVectorGenerator(MATRIX_SIZE, new StableRandomGenerator(new JDKRandomGenerator(), 0.1d, 1.0d));

private static ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) throws Exception {

    for(int i=0; i < 100; i++) {

        exec.execute(new Runnable() {
            @Override
            public void run() {
                double[][] matrixArrayA = new double[MATRIX_SIZE][MATRIX_SIZE];
                double[][] matrixArrayB = new double[MATRIX_SIZE][MATRIX_SIZE];
                for(int j = 0; j< MATRIX_SIZE; j++) {
                    matrixArrayA[j] = generator.nextVector();
                    matrixArrayB[j] = generator.nextVector();
                }

                RealMatrix matrixA = MatrixUtils.createRealMatrix(matrixArrayA);
                RealMatrix matrixB = MatrixUtils.createRealMatrix(matrixArrayB);

                lock.lock();
                matrixA.multiply(matrixB);
                lock.unlock();
            }
        });
    }
}
}
ReentrantLock 实际上是不必要的,因为在线程之间没有需要同步的共享状态。当锁定时,我们预计观察到线程池中的线程阻塞。去除锁定后,我们预期观察不到阻塞,所有线程都能够完全并行运行。
去除锁定的意外结果是,在我的机器上(四核心i7),代码完成时间持续变长,约为15-25%。对代码进行分析显示,没有任何线程阻塞或等待的迹象,总CPU使用率只约为50%,相对均匀地分布在各个核心上。
第二个意外情况是,这也取决于所使用的 generator 的类型。如果我使用 GaussianRandomGenerator 或 UniformRandomGenerator 而不是 StableRandomGenerator,则会观察到预期的结果——通过删除 lock() 使代码运行速度更快(约为10%)。
如果线程没有阻塞,CPU 处于合理水平,并且没有涉及 IO,那么这如何解释呢?我唯一的线索是 StableRandomGenerator 确实调用了大量三角函数,因此显然比高斯或均匀生成器更加 CPU 密集,但为什么我没有看到 CPU 达到最大值呢?
编辑:另一个重要的点(感谢 Joop)-使 generator 局部到 Runnable(即每个线程一个)会显示正常的预期行为,其中添加锁定会使代码变慢约 50%。因此,奇怪行为的关键条件是 a) 使用 StableRandomGenerator,以及 b) 让该 generator 在线程之间共享。但据我所知,该 generator 是线程安全的。
编辑2:虽然这个问题表面上非常类似于链接的重复问题,而且答案是合理的,并且几乎肯定是一个因素,但我还没有被说服它并不像那么简单。让我质疑它的东西:
1) 问题仅在同步 multiply() 操作时显示出来,该操作没有调用 Random。我的第一反应是同步在某种程度上使线程错开了,因此“意外地”改善了 Random#next() 的性能。然而,同步对对 generator.nextVector() 的调用(在理论上具有相同的效果,以“正确”的方式)不会复制该问题-同步会使代码变慢,如您所预期的那样。
2) 只有 StableRandomGenerator 才观察到该问题,即使其他 NormalizedRandomGenerator 的实现也使用 JDKRandomGenerator(如指出的只是 java.util.Random 的包装)。事实上,我用直接调用 Random#nextDouble 来填充矩阵代替了 RandomVectorGenerator 的使用,行为再次恢复到预期结果-同步代码的任何部分都会导致总吞吐量下降。
总之,该问题只能通过以下方式观察到:
a) 使用 StableRandomGenerator-任何 NormalizedRandomGenerator 的子类或直接使用 JDKRandomGenerator 或 java.util.Random 都不显示相同的行为。
b) 同步对 RealMatrix#multiply 的调用。当同步随机生成器的调用时,不会观察到相同的行为。

如果你有一台四核i7电脑,应该使用8个线程而不是5个,这样可以充分利用CPU。此外,Java版本和JRE供应商也很重要。 - Thomas Jungblut
是的,8个线程确实可以使CPU达到最大值。我的困惑在于,如果少于8个线程,则没有一个CPU被占满,因此理论上我应该有一些“余地”来加速计算,不是吗?另外:JVM:Java HotSpot(TM) 64-Bit Server VM(23.3-b01,混合模式)Java:版本1.7.0_07,供应商Oracle Corporation。 - RevBingo
generator 不是静态变量而是局部变量时会发生什么? - Joop Eggen
1
我不知道生成器的类别,但锁定可以使控制流更加顺序化。最简单的解释是发生了太多的上下文切换。抱歉,我不知道。 - Joop Eggen
无法在装有OpenJDK Runtime Environment(IcedTea 2.5.3)(7u71-2.5.3-2)的Atom D525上重现,两种方式都需要97秒。这可能是CPU特定的影响。 - that other guy
显示剩余3条评论
2个回答

4
与此问题相同。
实际上,你正在测量具有共享状态的PRNG中的争用。 JDKRandomGenerator基于java.util.Randomseed在所有工作线程之间共享。线程竞争在比较和设置循环中更新seed
那么为什么lock会提高性能呢?实际上,它通过串行化工作来帮助减少java.util.Random中的争用:当一个线程执行矩阵乘法时,另一个线程正在用随机数填充矩阵。没有lock时,线程同时执行相同的工作。

所以,经过很多困惑,我会把这个看作是“差不多就行了”。我仍然有一些不太明白的地方,但比较和设置竞争有一定的道理,在 JVM 中并不是立即显而易见的,因为强制发生了一些更低级别(即 CPU 级别)的同步。让我更加信服的是,我发现当它不必进行乘法运算时,代码实际上要慢50%!如果我的所有代码都能随着添加更多代码而变得更快就好了! - RevBingo

2
在使用随机数生成器时,需要记住很多事情。简而言之,由于生成器在给出随机数之前必须收集足够的熵,所以你的怪癖是因为共享生成器,每次调用都需要熵来“填补”,因此这是你的阻塞点。现在,有些生成器与其他生成器不同,它们收集熵的方式也不同,因此一些生成器受到的影响更大或者没有从头开始建立起来。当你在实例中创建生成器时,每个实例都会自行生成熵,因此速度更快。
让我指向SecureRandom,特别是类JavaDoc,其中写道:“注意:根据实现方式,generateSeed和nextBytes方法可能会在收集熵时阻塞,例如,如果它们需要在各种类Unix操作系统上从/dev/random读取。”这就是你看到的,以及为什么速度很慢。使用单个生成器,它一直阻塞。是的,它是线程安全的,但它在获取熵时会阻塞(请注意,您的线程在等待阻塞方法返回从生成随机数构建熵等方面存在争用)。当你加入自己的锁时,你给了它收集熵和以“礼貌”的方式完成其工作的时间。它可能是线程安全的,但这并不意味着当遭受攻击时它是好的或者高效的;-)
此外,对于使用java.util.Random的任何内容,来自Random
“java.util.Random的实例是线程安全的。但是,在跨线程同时使用相同的java.util.Random实例时,可能会遇到争用和因此造成性能下降。在多线程设计中,考虑改用ThreadLocalRandom。”

1
顺便说一下,熵的收集也是为什么你的核心没有被最大化利用的原因。 - DanO
1
来自java.util.Random:java.util.Random实例是线程安全的。然而,在跨线程并发使用相同的java.util.Random实例时,可能会遇到争用和相应的性能下降。在多线程设计中,考虑改用ThreadLocalRandom。 - DanO
顺便说一下,当链接并且仅使用种子而没有其他外部输入时,就是您的熵。第一轮有初始熵(可能包括种子或外部输入),但其他调用依赖于生成的种子提供“熵”。所以,正如我所说,当使用链接算法(这就是我们正在讨论的内容)时,我们必须等待熵(在这种情况下为种子)“稳定”(改变)。 - DanO
是的,关于种子的观点是正确的,谢谢你们两个,但是这里有一点我不明白——lock锁定的是multiply,而不是向量生成(使用Random)。 - RevBingo
我的第一反应是同步multiply()函数只会“交错”线程。然而,通过同步向量生成循环本身来实现相同的效果会降低吞吐量,这是可以预料的。 - RevBingo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接