关于性能:在什么时候值得重用Java中的数组?

At what point is it worth reusing arrays in Java?

在需要重用之前,缓冲区在Java中需要多大?

或者,换一种方式:我可以重复分配、使用和丢弃byte[]对象,或者运行池来保存和重用它们。我可能会分配很多经常被丢弃的小缓冲区,或者一些不经常被丢弃的大缓冲区。在多大的规模下,集中缓冲区比重新分配缓冲区更便宜,与大缓冲区相比,小缓冲区又如何呢?

编辑:

好的,具体参数。比如说英特尔酷睿2双核CPU,最新的操作系统虚拟机版本。这个问题不像听起来那么含糊…一个小代码和一个图表可以回答这个问题。

编辑2:

你已经发布了很多很好的一般规则和讨论,但这个问题确实需要数字。张贴它们(也要编码)!理论很好,但证据是数字。不管结果是否因系统而异,我只是在寻找一个粗略的估计(数量级)。似乎没有人知道性能差异是否是1.1、2、10或100+的一个因素,这是很重要的。对于任何与大型阵列一起工作的Java代码,网络、生物信息学等都是很重要的。

获得良好基准的建议:

  • 在基准测试中运行代码之前先预热代码。所有方法都应至少调用100010000次以获得完整的JIT优化。
  • 确保基准方法至少运行110秒,如果可能,请使用system.nantime以获得准确的计时。
  • 在只运行最少应用程序的系统上运行基准测试
  • 运行基准3-5次,并一直报告,所以我们看到它是多么的一致。
  • 我知道这是一个模糊的,有点苛刻的问题。我会定期检查这个问题,答案会得到评论并得到一致的评价。懒惰的回答不会(见下面的标准)。如果我没有任何彻底的答案,我将附上一笔赏金。不管怎样,我可能会奖励一个非常好的答案加一点额外的。

    我所知道的(不需要重复):

    • Java内存分配和GC快速且越来越快。
    • 对象池曾经是一种很好的优化,但现在它大多数时候都会损害性能。
    • 对象池通常不是一个好主意,除非对象的创建成本很高。

    我不知道的是:

    • 在标准的现代CPU上,内存分配的运行速度应该有多快?
    • 分配大小如何影响分配率?
    • 分配数量/大小与池中重用的平衡点是什么?

    找到公认答案的途径(越多越好):

    • 最近的白皮书显示了现代CPU上的分配和GC数据(最近一年左右,JVM 1.6或更高版本)
    • 我可以运行一个简洁和正确的微基准测试的代码
    • 解释分配如何以及为什么影响性能
    • 测试这种优化的真实例子/轶事

    语境:

    我正在开发一个库,向Java添加LZF压缩支持。这个库通过添加额外的压缩级别(更多的压缩)和与来自C lzf库的字节流的兼容性来扩展h2 dbms lzf类。我正在考虑的一件事是,是否值得尝试重用用于压缩/解压缩流的固定大小缓冲区。缓冲区可能是~8kb或~32kb,在原始版本中是~128kb。每个流可以分配一次或多次缓冲区。我正试图弄清楚如何处理缓冲区以获得最佳性能,并着眼于将来可能的多线程处理。

    是的,如果有人对这个感兴趣,这个库将作为开放源码发布。


    如果你想要一个简单的答案,那就是没有简单的答案。没有多少打电话的答案(暗示人们)"懒惰"会有帮助。

    How fast should I expect memory allocations to run (MB/s) on a standard modern CPU?

    以JVM可以使内存为零的速度,假设分配不会触发垃圾收集。如果它确实触发了垃圾收集,那么在不知道使用了什么GC算法、堆大小和其他参数,以及在应用程序的整个生命周期内对应用程序的非垃圾对象工作集进行分析的情况下,就无法预测。

    How does allocation size effect allocation rate?

    见上文。

    What's the break-even point for number/size of allocations vs. re-use in a pool?

    如果你想要一个简单的答案,那就是没有简单的答案。

    黄金法则是,堆越大(达到可用的物理内存量),垃圾对象的分摊成本越小。使用快速复制垃圾收集器,随着堆的增大,释放垃圾对象的摊余成本接近零。GC的成本实际上是由(简单地说)GC必须处理的非垃圾对象的数量和大小决定的。

    在假定堆很大的情况下,分配和GC处理大型对象(在一个GC周期内)的生命周期成本接近于在分配对象时将内存归零的成本。

    编辑:如果您只需要一些简单的数字,那么就编写一个简单的应用程序来分配和丢弃大型缓冲区,并使用各种GC和堆参数在您的计算机上运行它,然后看看会发生什么。但要注意,这并不能给您一个实际的答案,因为实际的GC成本取决于应用程序的非垃圾对象。

    我不会为你写基准,因为我知道这会给你虚假的答案。

    编辑2:回应OP的评论。

    So, I should expect allocations to run about as fast as System.arraycopy, or a fully JITed array initialization loop (about 1GB/s on my last bench, but I'm dubious of the result)?

    理论上是的。在实践中,很难以将分配成本与GC成本分开的方式进行衡量。

    By heap size, are you saying allocating a larger amount of memory for JVM use will actually reduce performance?

    不,我是说这可能会提高性能。明显地。(前提是您不会遇到操作系统级的虚拟内存效果。)

    Allocations are just for arrays, and almost everything else in my code runs on the stack. It should simplify measuring and predicting performance.

    也许吧。坦率地说,我认为回收缓冲液不会有什么改进。

    但是,如果您打算沿着这条路径走下去,请创建一个包含两个实现的缓冲池接口。第一个是一个真正的线程安全缓冲池,它回收缓冲区。第二个是虚拟池,它在每次调用alloc时只分配一个新的缓冲区,并将dispose视为一个no-op。最后,允许应用程序开发人员通过setBufferPool方法和/或构造函数参数和/或运行时配置属性在池实现之间进行选择。应用程序还应该能够提供自己生成的缓冲池类/实例。


    当它比年轻的空间大的时候。

    如果数组大于线程本地年轻空间,则直接在旧空间中分配。旧空间的垃圾收集比年轻空间慢得多。因此,如果数组大于年轻空间,那么重用它可能是有意义的。

    在我的机器上,32KB超过了年轻的空间。所以重用它是有意义的。


    一个完全不同方向的答案:让您的库的用户来决定。

    最终,无论您如何优化您的库,它都只是一个更大应用程序的组件。如果较大的应用程序不经常使用您的库,那么就没有理由维护一个缓冲池——即使这个缓冲池只有几百千字节。

    因此,将池机制创建为一个接口,并根据一些配置参数选择库使用的实现。将默认值设置为基准测试确定的最佳解决方案。1是的,如果使用接口,则必须依赖于JVM足够智能以进行内联调用。2

    (1)通过"基准测试",我指的是一个长期运行的程序,它在分析器之外运行您的库,并传递各种输入。轮廓仪是非常有用的,但是测量一小时的挂钟时间后的总吞吐量也是如此。在具有不同堆大小的几个不同计算机上,以及在单线程和多线程模式下运行的几个不同的JVM上。

    (2)这可以让您进入关于各种调用操作码的相对性能的另一个争论领域。


    您忽略了有关线程安全的任何内容。如果它将被多个线程重用,那么您将不得不担心同步问题。


    简短回答:不要缓冲。

    原因如下:

    • 不要优化它,直到它成为瓶颈
    • 如果回收它,池管理的开销将成为另一个瓶颈。
    • 试着相信JIT。在最新的JVM中,可以将数组分配到堆栈中,而不是堆中。
    • 相信我,JRE通常会比你的DIY更快更好地处理它们。
    • 保持简单,便于阅读和调试

    回收对象的时间:

    • 只有当它很重的时候。内存的大小不会使它变得很重,但是本地资源和CPU周期会使它变得很重,这需要额外的完成和CPU周期。
    • 如果它们是"bytebuffer",而不是byte[]


    我遇到了这个线程,因为我在一个有一千个顶点的图上实现了一个floyd warshall-all-pairs连接算法,所以我尝试以两种方式实现它(重新使用矩阵或创建新的矩阵),并检查经过的时间。

    为了计算,我需要1000个不同的矩阵,大小为1000 x 1000,所以这似乎是一个不错的测试。

    我的系统是带以下虚拟机的Ubuntu Linux。

    1
    2
    3
    java version"1.7.0_65"
    Java(TM) SE Runtime Environment (build 1.7.0_65-b17)
    Java HotSpot(TM) 64-Bit Server VM (build 24.65-b04, mixed mode)

    重新使用矩阵大约慢10%(平均运行时间超过5次执行17354ms,而不是15708ms。我不知道如果矩阵大得多,它是否还会更快。

    相关代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    private void computeSolutionCreatingNewMatrices() {
        computeBaseCase();
        smallest = Integer.MAX_VALUE;
        for (int k = 1; k <= nVertices; k++) {
            current = new int[nVertices + 1][nVertices + 1];
            for (int i = 1; i <= nVertices; i++) {
                for (int j = 1; j <= nVertices; j++) {
                    if (previous[i][k] != Integer.MAX_VALUE && previous[k][j] != Integer.MAX_VALUE) {
                        current[i][j] = Math.min(previous[i][j], previous[i][k] + previous[k][j]);
                    } else {
                        current[i][j] = previous[i][j];
                    }
                    smallest = Math.min(smallest, current[i][j]);
                }
            }
            previous = current;
        }
    }

    private void computeSolutionReusingMatrices() {
        computeBaseCase();
        current = new int[nVertices + 1][nVertices + 1];
        smallest = Integer.MAX_VALUE;
        for (int k = 1; k <= nVertices; k++) {            
            for (int i = 1; i <= nVertices; i++) {
                for (int j = 1; j <= nVertices; j++) {
                    if (previous[i][k] != Integer.MAX_VALUE && previous[k][j] != Integer.MAX_VALUE) {
                        current[i][j] = Math.min(previous[i][j], previous[i][k] + previous[k][j]);
                    } else {
                        current[i][j] = previous[i][j];
                    }
                    smallest = Math.min(smallest, current[i][j]);
                }
            }
            matrixCopy(current, previous);
        }
    }

    private void matrixCopy(int[][] source, int[][] destination) {
        assert source.length == destination.length :"matrix sizes must be the same";
        for (int i = 0; i < source.length; i++) {
            assert source[i].length == destination[i].length :"matrix sizes must be the same";
            System.arraycopy(source[i], 0, destination[i], 0, source[i].length);
        }        
    }


    查看一个微基准(下面的代码),无论数组的大小和使用时间如何,我的机器上的时间都没有明显的差异(我不会发布时间,您可以在您的机器上轻松地运行它:-)。我怀疑这是因为垃圾在这么短的一段时间内都是活着的,清理垃圾没什么可做的。数组分配可能需要调用calloc或malloc/memset。根据CPU的不同,这将是一个非常快速的操作。如果数组存活的时间更长,使其超过初始GC区域(托儿所),那么分配多个数组的时间可能会更长一些。

    代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    import java.util.Random;

    public class Main
    {
        public static void main(String[] args)
        {
            final int size;
            final int times;

            size  = 1024 * 128;
            times = 100;

            // uncomment only one of the ones below for each run
            test(new NewTester(size), times);  
    //        test(new ReuseTester(size), times);
        }

        private static void test(final Tester tester, final int times)
        {
            final long total;

            // warmup
            testIt(tester, 1000);
            total = testIt(tester, times);

            System.out.println("took:  " + total);
        }

        private static long testIt(final Tester tester, final int times)
        {
            long total;

            total = 0;

            for(int i = 0; i < times; i++)
            {
                final long start;
                final long end;
                final int value;

                start = System.nanoTime();
                value = tester.run();
                end   = System.nanoTime();
                total += (end - start);

                // make sure the value is used so the VM cannot optimize too much
                System.out.println(value);
            }

            return (total);
        }
    }

    interface Tester
    {
        int run();
    }

    abstract class AbstractTester
        implements Tester
    {
        protected final Random random;

        {
            random = new Random(0);
        }

        public final int run()
        {
            int value;

            value = 0;

            // make sure the random number generater always has the same work to do
            random.setSeed(0);

            // make sure that we have something to return so the VM cannot optimize the code out of existence.
            value += doRun();

            return (value);
        }

        protected abstract int doRun();
    }

    class ReuseTester
        extends AbstractTester
    {
        private final int[] array;

        ReuseTester(final int size)
        {
            array = new int[size];
        }

        public int doRun()
        {
            final int size;

            // make sure the lookup of the array.length happens once
            size = array.length;

            for(int i = 0; i < size; i++)
            {
                array[i] = random.nextInt();
            }

            return (array[size - 1]);
        }
    }

    class NewTester
        extends AbstractTester
    {
        private int[] array;
        private final int length;

        NewTester(final int size)
        {
            length = size;
        }

        public int doRun()
        {
            final int   size;

            // make sure the lookup of the length happens once
            size = length;
            array = new int[size];

            for(int i = 0; i < size; i++)
            {
                array[i] = random.nextInt();
            }

            return (array[size - 1]);
        }
    }

    我忘了这是一个管理内存系统。

    实际上,你可能有错误的心态。确定它何时有用的适当方法取决于应用程序、运行它的系统以及用户使用模式。

    换句话说,只需对系统进行概要分析,确定垃圾收集所花费的时间占典型会话中应用程序总时间的百分比,并查看是否值得对此进行优化。

    你可能会发现GC根本没有被调用。因此,编写代码来优化这一点完全是浪费时间。

    今天的记忆空间很大,我怀疑90%的时间根本不值得去做。你不能根据参数来确定这一点——这太复杂了。只是轮廓-简单和准确。


    比缓冲区大小更重要的是分配的对象数和分配的总内存。

  • 内存使用是否有问题?如果这是一个小应用程序,可能不值得担心。
  • 池的真正优点是避免内存碎片。分配/释放内存的开销很小,但缺点是,如果重复分配多个不同大小的对象,则内存会变得更分散。使用池可以防止碎片化。


    请记住,缓存效果可能比"new int[size]"及其相应集合的成本更重要。因此,如果具有良好的时间位置,重用缓冲区是一个好主意。重新分配缓冲区而不是重用它意味着每次都可能得到不同的内存块。正如其他人所提到的,当你的缓冲器不适合年轻一代时,情况尤其如此。

    如果您进行了分配,但没有使用整个缓冲区,那么重用也会有好处,因为您不会浪费时间将从未使用过的内存清零。


    我认为你需要的答案与"顺序"有关(测量空间,而不是时间!)算法的。

    复制文件示例

    例如,如果要复制文件,则需要从输入流中读取文件并写入输出流。时间顺序是O(N),因为时间将与文件大小成比例。但是空间顺序将是O(1),因为您需要执行的程序将聚焦固定内存量(您只需要一个固定缓冲区)。在这种情况下,很明显可以方便地重用在程序开始时实例化的缓冲区。

    将缓冲区策略与算法执行结构关联起来

    当然,如果您的算法需要无限的缓冲区,并且每个缓冲区的大小不同,那么您可能无法重用它们。但它给了你一些线索:

    • 尝试修复缓冲区的大小(偶数牺牲一点记忆)。
    • 试着看看它的结构执行:举例来说,如果你算法遍历某种树你的缓冲区与每个节点,也许您只需要O(日志n)缓冲器…所以你可以做一个对所需空间的合理猜测。
    • 如果您需要不同的缓冲区,但是你可以安排分享东西相同的不同段数组…也许更好解决方案。
    • 当你释放一个缓冲时,你可以将它添加到缓冲池中。那池可以是由"拟合"标准(缓冲器最适合首先)。

    我想说的是:没有固定的答案。如果您实例化了一些可以重用的东西…也许更好的方法是重用它。棘手的部分是找到如何在不引起缓冲区管理开销的情况下完成这项工作。这就是算法分析的用武之地。

    希望有帮助…:)