文本文件中求整数和的最快方法

49

问题

假设你有一个大的 ASCII 纯文本文件,每行都有一个随机非负整数,每个整数的范围从 0 到 1,000,000,000。文件有 1 亿行。如何最快地读取文件并计算所有整数的总和?

限制条件:我们只有 10MB 的 RAM 可以使用。文件大小为 1GB,因此我们不想读取整个文件然后处理。

下面是我尝试过的各种解决方案。结果让我感到相当惊讶。

我是否遗漏了更快的方法?

请注意:下面给出的所有时间都是运行该算法共计 10 次的时间(运行一次并丢弃;启动计时器;运行 10 次;停止计时器)。该机器是一台相对较慢的 Core 2 Duo。

方法 1:自然方法

首先尝试的是显而易见的方法:

private long sumLineByLine() throws NumberFormatException, IOException {
    BufferedReader br = new BufferedReader(new FileReader(file));
    String line;
    long total = 0;
    while ((line = br.readLine()) != null) {
        int k = Integer.parseInt(line);
        total += k;
    }
    br.close();
    return total;
}

请注意,最大可能的返回值为10^17,而这个值很容易放入一个long中,因此我们不必担心溢出问题。

在我的电脑上,运行11次并排除第一次运行大约需要92.9秒

方法2:微小的调整

受到这个问题中的评论启发,我尝试不创建一个新的int k来存储解析行的结果,而是直接将解析的值添加到total中。所以代码变成了这样:

    while ((line = br.readLine()) != null) {
        int k = Integer.parseInt(line);
        total += k;
    }

变成了这样:

    while ((line = br.readLine()) != null)
        total += Integer.parseInt(line);

我确信这不会有任何影响,而且认为编译器生成的两个版本的字节码应该是相同的。但出乎意料的是,它确实缩短了一点时间:我们将时间降低到92.1秒

方法三:手动解析整数

目前代码中让我困扰的一件事是我们将String转换为int,然后在最后进行相加,这样做可能会更快吗?如果我们自己解析String会怎么样呢?类似这样...

private long sumLineByLineManualParse() throws NumberFormatException,
        IOException {
    BufferedReader br = new BufferedReader(new FileReader(file));
    String line;
    long total = 0;
    while ((line = br.readLine()) != null) {
        char chs[] = line.toCharArray();
        int mul = 1;
        for (int i = chs.length - 1; i >= 0; i--) {
            char c = chs[i];
            switch (c) {
            case '0':
                break;
            case '1':
                total += mul;
                break;
            case '2':
                total += (mul << 1);
                break;
            case '4':
                total += (mul << 2);
                break;
            case '8':
                total += (mul << 3);
                break;
            default:
                total += (mul*((byte) c - (byte) ('0')));   
            }
            mul*=10;
        }
    }
    br.close();
    return total;
}

我认为这可能会节省一些时间,特别是使用一些位移优化来进行乘法操作。但将其转换为字符数组的开销可能会抵消所有的收益:现在需要148.2秒

第四种方法:二进制处理

我们可以尝试的最后一件事是以二进制数据的方式处理文件。

如果您不知道整数的长度,则从前面解析整数很麻烦。反向解析要容易得多:首个数字是个位数,下一个是十位数,以此类推。因此,处理整个文件最简单的方法是从后往前读取。

如果我们分配一个(比如)8MB的byte[]缓冲区,我们可以用文件的最后8MB填充它,处理它,然后读取前面的8MB,以此类推。我们需要小心,不要在移动到下一个块时搞乱正在解析的数字,但这是唯一的问题。

当我们遇到数字时,我们将其加入总数中(根据其在数字中的位置适当地乘以系数),然后将系数乘以10,以便准备好下一个数字。如果我们遇到任何不是数字的东西(CR或LF),我们只需重置系数即可。

private long sumBinary() throws IOException {
    RandomAccessFile raf = new RandomAccessFile(file, "r");
    int lastRead = (int) raf.length();
    byte buf[] = new byte[8*1024*1024];
    int mul = 1;
    long total = 0;
    while (lastRead>0) {
        int len = Math.min(buf.length, lastRead);
        raf.seek(lastRead-len);
        raf.readFully(buf, 0, len);
        lastRead-=len;
        for (int i=len-1; i>=0; i--) {
            //48 is '0' and 57 is '9'
            if ((buf[i]>=48) && (buf[i]<=57)) {
                total+=mul*(buf[i]-48);
                mul*=10;
            } else
                mul=1;
        }
    }
    raf.close();
    return total;
}

这段代码运行时间为30.8秒!比以前最好的结果快了3倍

后续问题

  1. 为什么速度提升这么明显?我本来就期望它能赢,但没有想到会如此出色。是不是主要是将其转换为String的开销?以及所有与字符集相关的内部担忧?
  2. 如果使用MappedByteBuffer可以帮助减少开销吗?我有一种感觉,调用从缓冲区中读取数据的方法的开销会减慢速度,特别是在从缓冲区倒序读取时。
  3. 将文件正向读取而不是反向读取,但仍然向后扫描缓冲区,这样会更好吗?想法是先读取文件的第一个块,然后向后扫描,但舍弃末尾的一半数字。然后当你读取下一个块时,设置偏移量,以便从丢弃的数字的开头读取。
  4. 还有没有我没想到的可能会有显着差异的东西?

更新:更多意外的结果

首先,有一个观察。我应该早就意识到了,但我认为基于String 的读取效率低下的原因不是创建所有String对象所花费的时间,而是它们的生命周期太短暂:我们有1亿个对象需要垃圾收集器处理。这肯定会让它受挫。

现在根据人们发布的答案/评论进行一些实验。

使用较大的缓冲区是否有欺骗性?

有人建议由于BufferedReader使用默认缓冲区大小为16KB,而我使用了8MB的缓冲区,所以我没有进行合理的比较。如果你使用更大的缓冲区,它肯定会更快。

结果令人震惊。用8MB的缓冲区运行sumBinary()方法(方法4)昨天需要30.8秒。今天,代码没有改变,风向改变了,运行时间为30.4秒。如果我将缓冲区大小降至16KB以查看其速度降低多少,它反而变得更快了!现在只需23.7秒即可运行。疯狂吧。谁料到呢?!

一些实验表明,16KB是最优的。也许Java的人也做了同样的实验,这就是为什么他们选择了16KB!

问题是否受到I/O限制?

我也在想这个问题。花费多少时间在磁盘访问上,多少时间用于计算数字?如果像一个被广泛支持的答案建议的那样,几乎全部时间都花费在磁盘访问上,那么无论我们做什么改进,都不会有太大的提高。

可以通过注释掉所有的解析和数字计算代码,但保留文件读取代码来测试。

private long sumBinary() throws IOException {
    RandomAccessFile raf = new RandomAccessFile(file, "r");
    int lastRead = (int) raf.length();
    byte buf[] = new byte[16 * 1024];
    int mul = 1;
    long total = 0;
    while (lastRead > 0) {
        int len = Math.min(buf.length, lastRead);
        raf.seek(lastRead - len);
        raf.readFully(buf, 0, len);
        lastRead -= len;
        /*for (int i = len - 1; i >= 0; i--) {
            if ((buf[i] >= 48) && (buf[i] <= 57)) {
                total += mul * (buf[i] - 48);
                mul *= 10;
            } else
                mul = 1;
        }*/
    }
    raf.close();
    return total;
}

现在执行时间只有3.7秒!我认为这并不是I/O限制导致的。

当然,一些I/O速度可能来自磁盘缓存命中。但这并不是重点:我们仍然需要20秒的CPU时间(还可以使用Linux的time命令进行确认),这足以尝试减少它。

向前扫描而不是向后扫描

在我的原帖中,我坚持认为反向扫描文件比正向扫描更有道理。我没有解释清楚。我的想法是,如果您向前扫描一个数字,则必须累加扫描数字的总值,然后再加上它。如果您向后扫描,则可以在扫描时将其添加到累积总数中。我的潜意识对自己有某种意义(稍后会有更多解释),但我错过了一个关键点,其中一个答案指出:为了向后扫描,我每次迭代都要进行两次乘法,但是向前扫描只需要一次。所以我编写了一个向前扫描的版本:

private long sumBinaryForward() throws IOException {
    RandomAccessFile raf = new RandomAccessFile(file, "r");
    int fileLength = (int) raf.length();
    byte buf[] = new byte[16 * 1024];
    int acc = 0;
    long total = 0;
    int read = 0;
    while (read < fileLength) {
        int len = Math.min(buf.length, fileLength - read);
        raf.readFully(buf, 0, len);
        read += len;
        for (int i = 0; i < len; i++) {
            if ((buf[i] >= 48) && (buf[i] <= 57))
                acc = acc * 10 + buf[i] - 48;
            else {
                total += acc;
                acc = 0;
            }
        }
    }
    raf.close();
    return total;
}

这段代码运行时间为20.0秒,比向后扫描版本效率更高。不错!

乘法缓存

然而我在晚上意识到的一件事是,虽然每次迭代需要执行两个乘法运算,但有可能使用缓存来存储这些乘法计算结果,这样就可以避免在向后迭代时重复计算。当我醒来时很高兴发现有人也想到了同样的方法!

关键在于,我们要扫描的数字中最多只有10个数字,且仅有10种可能的数字,因此对于一个数字对总和的贡献值最多有100种可能。我们可以预先计算好这些贡献值,并在向后扫描代码中使用它们。这应该能够超过向前扫描版本的效率,因为我们已经完全摆脱了乘法运算。(请注意,我们无法用向前扫描的方式实现这一点,因为累加器的乘积可能达到10^9。只有在后向扫描的情况下,两个操作数都受到了一些限制。)

private long sumBinaryCached() throws IOException {
    int mulCache[][] = new int[10][10];
    int coeff = 1;
    for (int i = 0; i < 10; i++) {
        for (int j = 0; j < 10; j++)
            mulCache[i][j] = coeff * j;
        coeff *= 10;
    }

    RandomAccessFile raf = new RandomAccessFile(file, "r");
    int lastRead = (int) raf.length();
    byte buf[] = new byte[16 * 1024];
    int mul = 0;
    long total = 0;
    while (lastRead > 0) {
        int len = Math.min(buf.length, lastRead);
        raf.seek(lastRead - len);
        raf.readFully(buf, 0, len);
        lastRead -= len;
        for (int i = len - 1; i >= 0; i--) {
            if ((buf[i] >= 48) && (buf[i] <= 57))
                total += mulCache[mul++][buf[i] - 48];
            else
                mul = 0;
        }
    }
    raf.close();
    return total;
}

这个程序运行了26.1秒,可以说是令人失望的。倒序阅读从I/O角度来看不太高效,但我们已经发现 I/O 不是主要问题。我本以为这会带来很大的积极差异。也许数组查找的开销和我们替换的乘法一样昂贵。(我确实尝试过将数组变为 16x16,并使用位移进行索引,但没有帮助。)

看起来前向扫描是解决问题的关键。

使用MappedByteBuffer

接下来要添加的是MappedByteBuffer,以查看它是否比原始的RandomAccessFile更有效。代码需要进行少量更改。

private long sumBinaryForwardMap() throws IOException {
    RandomAccessFile raf = new RandomAccessFile(file, "r");
    byte buf[] = new byte[16 * 1024];
    final FileChannel ch = raf.getChannel();
    int fileLength = (int) ch.size();
    final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, 0,
            fileLength);
    int acc = 0;
    long total = 0;
    while (mb.hasRemaining()) {
        int len = Math.min(mb.remaining(), buf.length);
        mb.get(buf, 0, len);
        for (int i = 0; i < len; i++)
            if ((buf[i] >= 48) && (buf[i] <= 57))
                acc = acc * 10 + buf[i] - 48;
            else {
                total += acc;
                acc = 0;
            }
    }
    ch.close();
    raf.close();
    return total;
}

这似乎稍微改善了一下:我们现在达到了19.0秒。我们又创造了一个人生新纪录!

多线程如何呢?

其中一个提出的答案是使用多个核心。我有点惭愧,没有想到这一点!

这个答案因为假设这是一个I/O受限的问题而备受争议。考虑到I/O的结果,这似乎有点过于苛刻了!无论如何,值得一试。

我们将使用fork/join来实现。这里有一个类来表示文件部分计算的结果,要注意可能会有左边的部分结果(如果我们从一个数字的一半开始),以及右边的部分结果(如果缓冲区在数字的一半结束)。该类还有一个方法,允许我们将两个这样的结果粘合在一起,成为相邻子任务的组合结果。

private class SumTaskResult {
    long subtotal;
    int leftPartial;
    int leftMulCount;
    int rightPartial;

    public void append(SumTaskResult rightward) {
        subtotal += rightward.subtotal + rightPartial
                * rightward.leftMulCount + rightward.leftPartial;
        rightPartial = rightward.rightPartial;
    }
}
现在重点来了:计算结果的RecursiveTask。对于小问题(少于64个字符),它调用computeDirectly()在单个线程中计算结果;对于更大的问题,它将问题分成两部分,在不同的线程中解决这两个子问题,然后合并结果。

现在重点来了:计算结果的RecursiveTask。对于小问题(少于64个字符),它调用computeDirectly()在单个线程中计算结果;对于更大的问题,它将问题分成两部分,在不同的线程中解决这两个子问题,然后合并结果。

private class SumForkTask extends RecursiveTask<SumTaskResult> {

    private byte buf[];
    // startPos inclusive, endPos exclusive
    private int startPos;
    private int endPos;

    public SumForkTask(byte buf[], int startPos, int endPos) {
        this.buf = buf;
        this.startPos = startPos;
        this.endPos = endPos;
    }

    private SumTaskResult computeDirectly() {
        SumTaskResult result = new SumTaskResult();
        int pos = startPos;

        result.leftMulCount = 1;

        while ((buf[pos] >= 48) && (buf[pos] <= 57)) {
            result.leftPartial = result.leftPartial * 10 + buf[pos] - 48;
            result.leftMulCount *= 10;
            pos++;
        }

        int acc = 0;
        for (int i = pos; i < endPos; i++)
            if ((buf[i] >= 48) && (buf[i] <= 57))
                acc = acc * 10 + buf[i] - 48;
            else {
                result.subtotal += acc;
                acc = 0;
            }

        result.rightPartial = acc;
        return result;
    }

    @Override
    protected SumTaskResult compute() {
        if (endPos - startPos < 64)
            return computeDirectly();
        int mid = (endPos + startPos) / 2;
        SumForkTask left = new SumForkTask(buf, startPos, mid);
        left.fork();
        SumForkTask right = new SumForkTask(buf, mid, endPos);
        SumTaskResult rRes = right.compute();
        SumTaskResult lRes = left.join();
        lRes.append(rRes);
        return lRes;
    }

}
请注意,此处操作的是byte[],而不是整个MappedByteBuffer。 这样做的原因是我们希望保持磁盘访问的顺序性。 我们将采用相当大的块,进行分叉/合并,然后移动到下一个块。
以下是执行此操作的方法。 请注意,我们将缓冲区大小提高到1MB(之前不太理想,但在这里更明智)。
private long sumBinaryForwardMapForked() throws IOException {
    RandomAccessFile raf = new RandomAccessFile(file, "r");
    ForkJoinPool pool = new ForkJoinPool();

    byte buf[] = new byte[1 * 1024 * 1024];
    final FileChannel ch = raf.getChannel();
    int fileLength = (int) ch.size();
    final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, 0,
            fileLength);
    SumTaskResult result = new SumTaskResult();
    while (mb.hasRemaining()) {
        int len = Math.min(mb.remaining(), buf.length);
        mb.get(buf, 0, len);
        SumForkTask task = new SumForkTask(buf, 0, len);
        result.append(pool.invoke(task));
    }
    ch.close();
    raf.close();
    pool.shutdown();
    return result.subtotal;
}

现在,令人沮丧的是,这段很好的多线程代码现在需要32.2秒。为什么这么慢?我花了相当长的时间调试,认为我做错了什么。

事实证明只需要进行一点小调整。我原本认为64是解决小问题和大问题之间的合理阈值,结果证明那完全荒谬。

就像这样考虑。子问题的大小完全相同,因此它们应该在几乎相同的时间内完成。所以切分成的块数不应该超过可用处理器的数量。在我使用的机器上,只有两个核心,将阈值降到64是荒谬的:它只会增加额外的开销。

现在不要限制它只使用两个核心,即使有更多可用。也许正确的做法是在运行时找出处理器的数量,并将其分割成那么多块。

无论如何,如果我将阈值更改为512KB(缓冲区大小的一半),它现在可以在13.3秒内完成。将其降至128KB或64KB将允许使用更多核心(最多8或16个),并且不会显着影响运行时间。

因此,多线程确实起了很大的作用。

这是一个漫长的过程,但我们从需要92.9秒开始,现在只需要13.3秒...这是原始代码速度的七倍。而这不是通过改善渐近(大Oh)时间复杂度来实现的,它从一开始就是线性(最优的)...这一切都是为了改善常数因子。

干得好。

我想下一步应该尝试使用GPU...

附:生成随机数文件

我使用以下代码生成了随机数,并将其重定向到文件中。显然,我不能保证您会获得完全相同的随机数 :)

public static void genRandoms() {
    Random r = new Random();
    for (int i = 0; i < 100000000; i++)
        System.out.println(r.nextInt(1000000000));
}

2
它更快是因为你将缓冲区设置为8mb,而BufferedReader使用16k。即使倒序读取文件会更有效率(实际上并不是这样!),但只需将读取器中的缓冲区增加到相同的级别,就可以获得更多的收益。你正在比较苹果和橙子。 - Thomas Jungblut
可能更有效的方法是将FileInputStream包装成BufferedInputStream,然后在其上创建InputStreamReader(这样缓冲发生在最低级别)。但解码字符集仍然会产生开销。 - Durandal
这种程序应该是I/O绑定的,因为读取文件受机械速度限制,而解析和添加整数涉及每个字符一次乘以10、一次减法和一次加法。当然,如果您愿意,您可以通过使其内存分配字符串对象来大大降低速度。 - Mike Dunlavey
“如果你不知道整数的长度,从前面解析整数会很麻烦。”恰恰相反!首先将累加器设置为0,然后对于每个下一个数字,将累加器乘以10并添加该数字的值。在非数字处,存储累加器的值并将其重置为0。(但是您的问题规定每行只有一个整数。) - Marc van Leeuwen
1
人们总是未能测试他们的假设,即文件处理是I/O限制的。我无法告诉你有多少次我听到过这样的论点:C++的fstream很慢并不重要,因为CPU比磁盘快得多。基准测试总是表明这是错误的。需要非常小心才能跟上现代磁盘流传输速度为120MB/s(旋转)或500MB/s(固态),当然缓存速度更快。 - Ben Voigt
显示剩余18条评论
7个回答

11

您的主要瓶颈将是文件I/O。解析和添加数字不应对算法产生影响,因为这可以在单独的线程中完成,而文件I/O正在等待磁盘。

几年前,我研究了如何以最快的方式从文件中读取,并找到了一些很好的建议 - 我按照以下扫描程序实现了它:

// 4k buffer size.
static final int SIZE = 4 * 1024;
static byte[] buffer = new byte[SIZE];

// Fastest because a FileInputStream has an associated channel.
private static void ScanDataFile(Hunter p, FileInputStream f) throws FileNotFoundException, IOException {
    // Use a mapped and buffered stream for best speed.
    // See: http://nadeausoftware.com/articles/2008/02/java_tip_how_read_files_quickly
    final FileChannel ch = f.getChannel();
    long red = 0L;
    do {
        final long read = Math.min(Integer.MAX_VALUE, ch.size() - red);
        final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, red, read);
        int nGet;
        while (mb.hasRemaining() && p.ok()) {
            nGet = Math.min(mb.remaining(), SIZE);
            mb.get(buffer, 0, nGet);
            for (int i = 0; i < nGet && p.ok(); i++) {
                p.check(buffer[i]);
                //size += 1;
            }
        }
        red += read;
    } while (red < ch.size() && p.ok());
    // Finish off.
    p.close();
    ch.close();
    f.close();
}

在测试速度之前,您可能需要调整此技术,因为它利用了一个名为Hunter的接口对象来搜索数据。

正如您所看到的,这个建议是在2008年提出的,自那时以来Java已经有了许多改进,因此这可能无法提高效率。

添加

我没有测试过这个,但这应该适用于您的测试,并使用相同的技术:

class Summer {

    long sum = 0;
    long val = 0;

    public void add(byte b) {
        if (b >= '0' && b <= '9') {
            val = (val * 10) + (b - '0');
        } else {
            sum += val;
            val = 0;
        }
    }

    public long getSum() {
        return sum + val;
    }
}

private long sumMapped() throws IOException {
    Summer sum = new Summer();
    FileInputStream f = new FileInputStream(file);
    final FileChannel ch = f.getChannel();
    long red = 0L;
    do {
        final long read = Math.min(Integer.MAX_VALUE, ch.size() - red);
        final MappedByteBuffer mb = ch.map(FileChannel.MapMode.READ_ONLY, red, read);
        int nGet;
        while (mb.hasRemaining()) {
            nGet = Math.min(mb.remaining(), SIZE);
            mb.get(buffer, 0, nGet);
            for (int i = 0; i < nGet; i++) {
                sum.add(buffer[i]);
            }
        }
        red += read;
    } while (red < ch.size());
    // Finish off.
    ch.close();
    f.close();
    return sum.getSum();
}

1
谢谢!很惊讶地发现它根本不是I/O绑定的。我已经相应地更新了帖子... - chiastic-security
@chiastic-security 我忍不住实现了一个类似的解决方案,只需要1.9秒。 - maaartinus
@maaartinus 啊,我的时间是针对运行十次的,不过 :) 我只运行一次并且丢弃,然后启动计时器,再运行十次。在我的机器上使用你的代码执行这个操作需要22.8秒。 - chiastic-security
@chiastic-security 我明白了。通过简化条件,我已经实现了一些速度提升(1.9 -> 1.4)。我还尝试将其复制到 byte[] 中,但没有帮助。 - maaartinus

9
为什么这样会更快?
创建字符串比进行简单计算要昂贵得多。
使用MappedByteBuffer有没有办法做得更好?
有一点,是我使用的。它避免了内存到内存的复制,即不需要byte[]。
我有种感觉,调用从缓冲区读取数据的方法的开销会减慢速度,
如果它们很简单,那么这些方法将被内联。
特别是当从缓冲区向后读取时。
它不会变慢,实际上正向解析更简单/更快,因为您只使用一个*,而不是两个。
读取文件时是否应该向前而不是向后,但仍然以反向扫描缓冲区的方式进行?
我不明白为什么你需要向后读取。
想法是首先读取文件的第一个块,然后向后扫描,但舍弃掉末尾的一半数字。然后当你读取下一个块时,你将偏移量设置为从你舍弃的数字的开头开始读取。
听起来过于复杂了。我会在单次读取中读取整个文件,一次性内存映射整个文件。除非文件大小超过2GB,否则不需要使用块,即使这样,我也会一次性读取。
还有什么重要的事情我没有想到可能会有很大的差别吗?
如果数据在磁盘缓存中,则比其他任何事情都更有效。

4

您可以选择更大的缓冲区大小,以及更快的编码转换(从字符串到Unicode)。

BufferedReader br = new BufferedReader(new InputStreamReader(
        new FileInputStream(file), StandardCharsets.US_ASCII),
        1_024_000_000);

你消除字符串使用的方法,通过使用二进制InputStream/RandomAccessFile值得学习。如果源文件被压缩,那就更好了。在Unix下,可以选择gzip格式,其中xxx.txt.gz解压缩为xxx.txt。这可以用GZipInputStream进行读取。它具有整体加速服务器目录中文件传输的优点。

3

我认为还有另外一种方法来完成这个任务。

这是一个典型的多进程编程问题。在C语言中,有一个名为MPI的库可以解决此类问题。

它的想法是将整数列表分成4部分,每个部分由不同的进程进行求和。完成后,再将这些进程的结果相加。

在Java中,可以使用线程(伪并行)和Java并发来完成这个任务。

例如,4个不同的线程对列表的4个不同部分进行求和。最后将它们相加。

电话公司使用网格计算机来执行此类并行编程技术以对它们的交易进行求和。

这里唯一的问题(瓶颈)是IO操作。读取文件需要很长时间。如果可以让多个线程同时读取不同部分的文件... 这是非常复杂的方法,而且我认为这样做没有太多好处,因为硬盘不会因为被多个线程使用就旋转得更快,但是还有其他类似的方法。你可以在这里了解更多信息:通过多个线程访问文件使用多线程读取单个文件: 是否会加速?


10
多进程很难解决I/O绑定问题。 - maaartinus
7
在旋转磁盘上,由于需要进行额外的寻址操作,它甚至有可能变得更慢。 - CodesInChaos
线程可能帮助加速这里的处理,唯一的方法是让一个线程读取数据,另一个线程处理数据(但读取线程大部分时间都会处于睡眠状态,等待操作系统从磁盘获取数据)。 - user1781290
2
结果表明它不是I/O限制的,多线程确实有很大帮助!请参见帖子更新。 - chiastic-security
@J.F.Sebastian 我忍不住了,做了这件事。现在它是IO绑定的。 - maaartinus
显示剩余5条评论

2

来源:http://nadeausoftware.com/articles/2008/02/java_tip_how_read_files_quickly

要获得最佳的Java读取性能,请记住以下四点:

  • 通过一次读取数组而不是逐字节读取来最小化I/O操作。一个8K字节的数组大小是很好的选择。
  • 通过一次读取数组而不是逐字节读取来最小化方法调用。使用数组索引来获取数组中的字节。
  • 如果您不需要线程安全,请最小化线程同步锁。要么对线程安全类进行更少的方法调用,要么使用非线程安全类(如FileChannel和MappedByteBuffer)。
  • 最小化JVM / OS、内部缓冲区和应用程序数组之间的数据复制。使用具有内存映射的FileChannel或直接/包装的数组ByteBuffer。

2

基于这条评论: "简单地将所有字节相加更快",我提出了一种变化的解决方案。

原答案建议将问题分成块,使用多线程计算每个块的总和,并在最后将它们相加。

这个想法可以用来在向后扫描中将乘法数量降至O(1),而不需要任何表查找和线程(或将其与线程结合)。只需利用乘法在加法上的分配方式,并将所有个位数加入一个累加器,十位数加入另一个累加器,百位数和千位数则分别加入自己的累加器。这根本不需要任何乘法。

将多个线程的结果组合的缩减步骤也可以使用每个位置的累加器来完成。计算总和的最终步骤将需要乘法(或利用10仅有两个位设置的事实并使用位移和加法),但仅需要9次乘法就足够了。


是的,这是我的第一个想法。123 + 456 + 78 = (1+4)*100 + (2+5+7)*10 + (3+6+8)*1。你会有10个长整型变量,每个变量代表相同位数上所有数字的和,最后你可以用类似以下的方式将它们相加:total = l0 + l1*10 + ... + l9 * 1000000000 - Kip
此外,保持10个单独的长变量,而不是一个包含10个长整型的数组,可能有助于提高性能,因为在Java中会检查所有数组访问。 (这是我以前遇到过的微观优化问题之一。) - Kip

1
这里有几个问题:
  1. 任何基于读取行的解决方案都会处理每个字符两次。例如编译器不会这样做,它们一次读取一个字符并直接派发。
  2. 任何基于readLine()的解决方案都会创建字符串。
  3. 您正在使用不同的缓冲区大小。
  4. 您正在使用不同的I/O技术。
  5. 在某些情况下,您正在使用字符转换,而在其他情况下则没有。
  6. 您过度分析了文件。您并不真正关心空格在哪里或有多少空格,只要它将数字彼此分开即可。
我的解决方案:
    BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file), 8*1024*1024/2);
    long    total = 0;
    int i;
    while ((i = bis.read()) != -1)
    {
        byte    b = (byte)i;
        long    number = 0;
        while (b >= '0' && b <= '9')
        {
            number = number*10+b-'0';
            if ((i = bis.read()) == -1)
                break;
            b = (byte)i;
        }
        total += number;
    }

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