Java 虚拟机上的数组分配和访问以及内存争用

请遵守线程子类的以下定义(为方便起见,整个可运行的 Java 源文件包含在问题末尾):

final class Worker extends Thread {
    Foo[] array = new Foo[1024];
    int sz;

    public Worker(int _sz) {
        sz = _sz;
    }

    public void run() {
        //Foo[] arr = new Foo[1024];
        Foo[] arr = array;
        loop(arr);
    }

    public void loop(Foo[] arr) {
        int i = 0;
        int pos = 512;
        Foo v = new Foo();
        while (i < sz) {
            if (i % 2 == 0) {
                arr[pos] = v;
                pos += 1;
            } else {
                pos -= 1;
                v = arr[pos];
            }
            i++;
        }
    }
}

说明: 程序启动此类线程,并将每个线程的 设置为 ,其中 和 在运行程序时通过命令行设置。每个线程对象都有一个字段,该字段使用新的 -element 数组进行初始化。理由是,我们希望在不同数量的线程之间分配等量的工作量 - 我们希望程序能够扩展。-Dparsz-Dsize / -Dpar-Dsize-Dpararray1024

然后启动每个线程,并测量所有线程完成所需的时间。我们进行多次测量以抵消任何与JIT相关的影响,如下所示。每个线程执行一个循环。在循环中,线程在偶数迭代中读取数组中位置处的元素,并在奇数迭代中写入相同的元素。否则,仅修改局部变量。512512

完整程序如下。

分析

测试使用 - 在此程序运行期间没有发生垃圾回收。-verbose:gc

运行命令:

java -Xmx512m -Xms512m -server -Dsize=500000000 -Dpar=1 org.scalapool.bench.MultiStackJavaExperiment 7

案例 1:线程的运行时间,按该顺序排列(7 次重复):1,2,4,8

>>> All running times: [2149, 2227, 1974, 1948, 1803, 2283, 1878]
>>> All running times: [1140, 1124, 2022, 1141, 2028, 2004, 2136]
>>> All running times: [867, 1022, 1457, 1342, 1436, 966, 1531]
>>> All running times: [915, 864, 1245, 1243, 948, 790, 1007]

我的想法是,非线性缩放是由于内存争用。顺便说一句,早期迭代实际上做得更好 - 这可能是因为在不同的迭代中,数组被分配在不同的内存区域中。

案例 2:接下来,我在线程的方法中注释该行,并在方法本身中分配一个新数组:。测量:Foo[] arr = arrayrunrunFoo[] arr = new Foo[1024]

>>> All running times: [2053, 1966, 2089, 1937, 2046, 1909, 2011]
>>> All running times: [1048, 1178, 1100, 1194, 1367, 1271, 1207]
>>> All running times: [578, 508, 589, 571, 617, 643, 645]
>>> All running times: [330, 299, 300, 322, 331, 324, 575]

这一次,一切都像预期的那样扩展。我不会想到分配数组的位置会扮演任何角色,但显然它确实以某种方式起作用。我的想法是,这些数组以前分配得非常接近彼此,以至于开始发生一些内存争用。

案例 3:为了验证此假设,我再次取消了对该行的注释,但这次初始化了该字段,以确保要写入的内存中的位置彼此相距足够远。因此,这里我们再次使用在创建线程对象期间分配的数组,与CASE1的区别只是数组更大。Foo[] arr = arrayarraynew Foo[32000]

>>> All running times: [2113, 1983, 2430, 2485, 2333, 2359, 2463]
>>> All running times: [1172, 1106, 1163, 1181, 1142, 1169, 1188]
>>> All running times: [578, 677, 614, 604, 583, 637, 597]
>>> All running times: [343, 327, 320, 330, 353, 320, 320]

因此,内存争用似乎是导致这种情况的原因。

平台信息:

Ubuntu Server 10.04.3 LTS
8 core Intel(R) Xeon(R) CPU  X5355  @2.66GHz
~20GB ram
java version "1.6.0_26"
Java(TM) SE Runtime Environment (build 1.6.0_26-b03)
Java HotSpot(TM) 64-Bit Server VM (build 20.1-b02, mixed mode)

:这显然是一个内存争用问题。但为什么会发生这种情况呢?

  1. 逃生分析是否开始发挥作用?如果是这样,这是否意味着在 CASE2 中的方法中创建时,整个数组都分配在堆栈上?此运行时优化的确切条件是什么?当然,数组没有在堆栈上为100万个元素分配?run

  2. 即使数组是在堆栈上分配的,而不是在堆上分配的,不同线程的两个数组访问也应该至少除以512 * 4bytes = 2kb,即使在CASE1中,无论数组在哪里!这绝对比任何L1缓存行都大。如果这些影响是由于错误共享造成的,那么写入多个完全独立的缓存行对性能有何影响?(这里的一个假设是,每个数组都占用 JVM 上的一个连续内存块,该内存块是在创建数组时分配的。我不确定这是否有效。另一个假设是,数组写入不会一直到内存,而是L1缓存,因为英特尔至强确实有ccNUMA架构 - 如果我错了,请纠正我)

  3. 每个线程是否有可能有自己的本地堆部分,在其中独立分配新对象,这是在线程中分配数组时争用较低的原因?如果是这样,如果共享引用,如何收集堆垃圾区域?

  4. 为什么将数组大小增加到 ~32000 个元素可以提高可伸缩性(减少内存争用)?内存层次结构中究竟是什么原因造成的?

请准确无误,并用参考资料支持您的主张。

谢谢!


整个可运行的 Java 程序:

import java.util.ArrayList;

class MultiStackJavaExperiment {

    final class Foo {
        int x = 0;
    }

    final class Worker extends Thread {
        Foo[] array = new Foo[1024];
        int sz;

        public Worker(int _sz) {
            sz = _sz;
        }

        public void run() {
            Foo[] arr = new Foo[1024];
            //Foo[] arr = array;
            loop(arr);
        }

        public void loop(Foo[] arr) {
            int i = 0;
            int pos = 512;
            Foo v = new Foo();
            while (i < sz) {
                if (i % 2 == 0) {
                    arr[pos] = v;
                    pos += 1;
                } else {
                    pos -= 1;
                    v = arr[pos];
                }
                i++;
            }
        }
    }

    public static void main(String[] args) {
        (new MultiStackJavaExperiment()).mainMethod(args);
    }

    int size = Integer.parseInt(System.getProperty("size"));
    int par = Integer.parseInt(System.getProperty("par"));

    public void mainMethod(String[] args) {
        int times = 0;
        if (args.length == 0) times = 1;
        else times = Integer.parseInt(args[0]);
        ArrayList < Long > measurements = new ArrayList < Long > ();

        for (int i = 0; i < times; i++) {
            long start = System.currentTimeMillis();
            run();
            long end = System.currentTimeMillis();

            long time = (end - start);
            System.out.println(i + ") Running time: " + time + " ms");
            measurements.add(time);
        }

        System.out.println(">>>");
        System.out.println(">>> All running times: " + measurements);
        System.out.println(">>>");
    }

    public void run() {
        int sz = size / par;
        ArrayList < Thread > threads = new ArrayList < Thread > ();

        for (int i = 0; i < par; i++) {
            threads.add(new Worker(sz));
            threads.get(i).start();
        }
        for (int i = 0; i < par; i++) {
            try {
                threads.get(i).join();
            } catch (Exception e) {}
        }
    }

}

答案 1

溶液

使用标志运行 JVM,该标志仅在 JDK7 中可用。这解决了问题。-XX:+UseCondCardMark

解释

实质上,大多数托管堆环境使用卡表来标记发生写入的内存区域。一旦发生写入,此类内存区域在卡表中被标记为区。垃圾回收需要此信息 - 不必扫描非脏内存区域的引用。卡是连续的内存块,通常为 512 字节。卡表通常每个卡有 1 个字节 - 如果设置了此字节,则卡是脏的。这意味着具有 64 字节的卡表覆盖 64 * 512 字节的内存。通常,今天的缓存行大小为 64 字节。

因此,每次写入对象字段时,卡表中相应卡的字节必须设置为脏。单线程程序中一个有用的优化是通过简单地标记相关字节来做到这一点 - 每次都做一次写入。首先检查是否设置了字节和条件写入的替代方法需要额外的读取和条件跳转,这稍微慢一些。

但是,如果有多个处理器写入内存,则此优化可能是灾难性的,因为正在写入的相邻卡需要写入卡表中的相邻字节。因此,要写入的内存区域(上面数组中的条目)不在同一缓存行中,这是内存争用的常见原因。真正的原因是写入的脏字节位于同一缓存行中。

上面的标志的作用是 - 它通过首先检查字节是否已经设置,并仅在未设置时才设置它来实现卡表脏字节写入。这样,内存争用仅在第一次写入该卡期间发生 - 之后,仅对该缓存行进行读取。由于缓存行仅被读取,因此它可以跨多个处理器复制,并且它们不必同步即可读取它。

我观察到,在 1 线程情况下,此标志将运行时间增加了约 15-20%。

博客文章和此 bug 报告中介绍了该标志。-XX:+UseCondCardMark

相关的并发邮件列表讨论:JVM 上的数组分配和访问


答案 2

我相信你需要减少你的代码,这样它就不会做很多偶然的事情,这可能会令人困惑。在减少代码后,我很清楚您每次只访问相同的数组位置。即位置 512。

如果你最小化你的代码,重用你的线程,这样你就不会停止/启动它们,你会得到更可重复的结果。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class MultiStackJavaExperiment {
    static final int size = Integer.getInteger("size", 500000000);

    public static void main(String... args) throws ExecutionException, InterruptedException {
        int par = 8;
        for (int s = 64; s <= 64 * 1024; s *= 2) {
            int times = args.length == 0 ? 1 : Integer.parseInt(args[0]);
            long[] measurements = new long[times];

            ExecutorService es = Executors.newFixedThreadPool(par);
            List<Future<?>> futures = new ArrayList<Future<?>>(times);
            for (int i = 0; i < times; i++) {
                long start = System.currentTimeMillis();
                final int sz = size / par;
                futures.clear();
                for (int j = 0; j < par; j++) {
                    final Object[] arr = new Object[s];
                    futures.add(es.submit(new Runnable() {
                        @Override
                        public void run() {
                            final int bits = 7, arraySize = 1 << bits;
                            int i = 0;
                            int pos = 32;
                            Object v = new Object();
                            while (i < sz) {
                                if (i % 2 == 0) {
                                    arr[pos] = v;
                                    pos += 1;
                                } else {
                                    pos -= 1;
                                    v = arr[pos];
                                }
                                i++;
                            }
                        }
                    }));
                }
                for (Future<?> future : futures)
                    future.get();

                long time = System.currentTimeMillis() - start;
//                System.out.println(i + ") Running time: " + time + " ms");
                measurements[i] = time;
            }
            es.shutdown();
            System.out.println("par = " + par + " arr.length= "+ s  + " >>> All running times: " + Arrays.toString(measurements));
        }
    }
}

这表明访问值之间的距离很重要。通过为每个线程分配一个数组,您可以使用不同的 TLAB(以块为单位间隔数据)

par = 8 arr.length= 64 >>> All running times: [539, 413, 444, 444, 457, 444, 456]
par = 8 arr.length= 256 >>> All running times: [398, 527, 514, 529, 445, 441, 445]
par = 8 arr.length= 1024 >>> All running times: [419, 507, 477, 422, 412, 452, 396]
par = 8 arr.length= 4096 >>> All running times: [316, 282, 250, 232, 242, 229, 238]
par = 8 arr.length= 16384 >>> All running times: [316, 207, 209, 212, 208, 208, 208]
par = 8 arr.length= 65536 >>> All running times: [211, 211, 208, 208, 208, 291, 206]
par = 8 arr.length= 262144 >>> All running times: [366, 210, 210, 210, 210, 209, 211]
par = 8 arr.length= 1048576 >>> All running times: [296, 211, 215, 216, 213, 211, 211]

如果你把数组移动到线程内,你会得到

par = 8 arr.length= 64 >>> All running times: [225, 151, 151, 150, 152, 153, 152]
par = 8 arr.length= 256 >>> All running times: [155, 151, 151, 151, 151, 151, 155]
par = 8 arr.length= 1024 >>> All running times: [153, 152, 151, 151, 151, 155, 152]
par = 8 arr.length= 4096 >>> All running times: [155, 156, 151, 152, 151, 155, 155]
par = 8 arr.length= 16384 >>> All running times: [154, 157, 152, 152, 158, 153, 153]
par = 8 arr.length= 65536 >>> All running times: [155, 157, 152, 184, 181, 154, 153]
par = 8 arr.length= 262144 >>> All running times: [240, 159, 166, 151, 172, 154, 160]
par = 8 arr.length= 1048576 >>> All running times: [165, 162, 163, 162, 163, 162, 163]

关闭 tlab 与 相同的代码给 syou-XX:-UseTLAB

par = 8 arr.length= 64 >>> All running times: [608, 467, 467, 457, 468, 461, 428]
par = 8 arr.length= 256 >>> All running times: [437, 437, 522, 512, 522, 369, 535]
par = 8 arr.length= 1024 >>> All running times: [394, 395, 475, 525, 470, 440, 478]
par = 8 arr.length= 4096 >>> All running times: [347, 215, 238, 226, 236, 204, 271]
par = 8 arr.length= 16384 >>> All running times: [291, 157, 178, 151, 150, 151, 152]
par = 8 arr.length= 65536 >>> All running times: [163, 152, 162, 151, 159, 159, 154]
par = 8 arr.length= 262144 >>> All running times: [164, 172, 152, 169, 160, 161, 160]
par = 8 arr.length= 1048576 >>> All running times: [295, 153, 164, 153, 166, 154, 163]