在什么时候值得重用Java中的数组?

时间:2009-12-23 21:08:53

标签: java performance optimization memory-management

在值得重用之前,缓冲区需要有多大才能使用?

或者,换句话说:我可以重复分配,使用和丢弃byte []对象或运行池来保存和重用它们。我可能会分配很多经常被丢弃的小缓冲区,或者一些不会被丢弃的大缓冲区。汇集它们的大小比重新分配要便宜,小分配与大分配相比如何?

编辑:

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

EDIT2:

你发布了很多很好的一般规则和讨论,但这个问题确实要求数字。发布'(以及代码)!理论很棒,但证据就是数字。如果各个系统的结果有所不同并不重要,我只是在寻找一个粗略的估计(数量级)。似乎没有人知道性能差异是1.1,2,10或100+的因素,这是重要的。对于使用大型阵列的任何Java代码(网络,生物信息学等)都很重要。

建议获得良好的基准:

  1. 在基准测试中运行之前预热代码。方法应至少被调用 1000 10000次以获得完整的JIT优化。
  2. 确保基准测试方法至少运行 1 10秒,并尽可能使用System.nanotime,以获得准确的计时。
  3. 在仅运行最少应用程序的系统上运行基准测试
  4. 运行基准测试3-5次并报告所有时间,因此我们看到它是多么一致。

  5. 我知道这是一个模糊且有点苛刻的问题。我会定期查看这个问题,答案会得到评论并持续评分。懒惰的答案不会(见下面的标准)。如果我没有任何彻底的答案,我会附上一笔赏金。无论如何,我可能会额外奖励一个非常好的答案。

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

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

    我不知道的是:

    • 我希望在标准的现代CPU上运行内存分配的速度有多快(MB / s)?
    • 分配大小如何影响分配率?
    • 分配数量/大小与池中重复使用的收支平衡点是什么?

    路由到ACCEPTED答案(越多越好):

    • 最近的白皮书显示了分配数据和现代CPU上的GC(最近一年左右,JVM 1.6或更高版本)
    • 我可以运行的简明正确的微基准代码
    • 说明分配影响绩效的方式和原因
    • 测试此类优化的真实案例/轶事

    上下文:

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

    是的,如果有人有兴趣使用它,那么该库将作为开源发布。

11 个答案:

答案 0 :(得分:26)

如果你想要一个简单的答案,那就是没有简单的答案。没有任何呼叫答案(并暗示人们)“懒惰”会有所帮助。

  

我希望在标准的现代CPU上运行内存分配的速度有多快(MB / s)?

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

  

分配大小如何影响分配率?

见上文。

  

分配数量/大小与池中重复使用的收支平衡点是什么?

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

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

假设您的堆很大,分配和GC大型对象(在一个GC循环中)的生命周期成本接近分配对象时将内存归零的成本。

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

我不打算为你写一个基准,因为我知道它会给你带来虚假的答案。

编辑2 :回应OP的评论。

  

所以,我应该期望分配的运行速度与System.arraycopy一样快,或者完全JITed数组初始化循环(在我的最后一个工作台上大约1GB / s,但我怀疑结果)?

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

  

根据堆大小,您是说为JVM使用分配更多内存实际上会降低性能吗?

不,我说它可能增加性能。显著。 (前提是您没有遇到操作系统级别的虚拟内存效果。)

  

分配只适用于数组,我代码中的其他几乎所有内容都在堆栈上运行。它应该简化测量和预测性能。

也许。坦率地说,我认为你不会通过回收缓冲来获得很大的改善。

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

答案 1 :(得分:14)

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

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

在我的机器上,32kb超过了年轻的空间。因此重用它是有意义的。

答案 2 :(得分:3)

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

答案 3 :(得分:3)

从完全不同的方向回答:让你的图书馆用户决定。

最终,无论您如何优化库,它都只是大型应用程序的一个组件。如果那个较大的应用程序很少使用你的库,就没有理由支付维护一个缓冲池 - 即使那个池只有几百千字节。

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


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

(2)这可以让你进入另一个关于各种 invoke 操作码的相对性能的争论。

答案 4 :(得分:2)

简短回答:不要缓冲。

原因如下:

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

何时应回收对象:

  • 只有它很重。内存的大小不会让它变重,但本机资源和CPU周期会这样做,这会增加成本和CPU周期。
  • 如果它们是“ByteBuffer”而不是byte []
  • ,您可能想要回收它们

答案 5 :(得分:1)

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

如果你分配但不使用整个缓冲区,它也需要重复使用,因为你不会浪费时间将你永远不会使用的内存归零。

答案 6 :(得分:1)

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

  1. 内存使用是否是一个问题?如果它是一个小应用程序可能不值得担心。
  2. 汇集的真正好处是避免内存碎片。分配/释放内存的开销很小,但缺点是如果你反复分配许多不同大小的许多对象,内存就会变得更加碎片化。使用池可以防止碎片。

答案 7 :(得分:1)

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

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

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

您可能会发现gc甚至根本没有被调用。因此编写代码来优化这将完全浪费时间。

今天有大量的内存空间我怀疑90%的时间都不值得去做。您无法根据参数确定这一点 - 它太复杂了。只需简介 - 简单准确。

答案 8 :(得分:1)

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

代码:

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]);
    }
}

答案 9 :(得分:1)

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

对于计算,我需要1000个不同的矩阵,大小为1000 x 1000,因此它似乎是一个不错的测试。

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

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。我不知道如果矩阵更大,它是否仍然会更快。

以下是相关代码:

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);
    }        
}

答案 10 :(得分:0)

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

复制文件示例

例如,如果要复制文件,则需要从输入流中读取并写入输出流。 TIME顺序为O(n),因为时间将与文件大小成比例。但是SPACE命令将是O(1),因为你需要做的程序会占用固定数量的内存(你只需要一个固定的缓冲区)。在这种情况下,很明显重用在程序开头实例化的缓冲区很方便。

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

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

  • 尝试修复缓冲区的大小(甚至 牺牲一点记忆力。)
  • 试着看看它的结构是什么 执行:例如,如果你是 算法遍历某种树 你和缓冲区有关系 每个节点,也许你只需要O(log n)缓冲...所以你可以做一个 对所需空间的猜测。
  • 如果你需要不同的缓冲区,但是 你可以安排分享的东西 不同的部分相同 数组...也许它更好 溶液
  • 当您释放缓冲区时,您可以 将其添加到缓冲池中。那 pool可以是一个由它排序的堆 “拟合”标准(缓冲区 最适合应该是第一个)。

我想说的是:没有固定的答案。如果您实例化了可以重用的东西......可能最好重复使用它。棘手的部分是找到如何做到这一点,而不会产生缓冲管理开销。这时算法分析就派上用场了。

希望它有帮助...:)