Java中排序(内存映射?)文件中的二进制搜索

Posted

技术标签:

【中文标题】Java中排序(内存映射?)文件中的二进制搜索【英文标题】:Binary search in a sorted (memory-mapped ?) file in Java 【发布时间】:2010-10-18 16:54:03 【问题描述】:

我正在努力将 Perl 程序移植到 Java,并在学习过程中学习 Java。原始程序的核心组件是Perl module,它使用二进制搜索在 +500 GB 排序的文本文件中进行字符串前缀查找 (本质上,“寻找”到文件中间的一个字节偏移,回溯到最近的换行符,将行前缀与搜索字符串进行比较,“寻找”到该字节偏移的一半/两倍,重复直到找到......)

我已经尝试了几种数据库解决方案,但发现对于这种大小的数据集,在绝对查找速度方面没有什么比这更好的了。您知道现有的任何实现此类功能的 Java 库吗?如果做不到这一点,您能否指出一些在文本文件中进行随机访问读取的惯用示例代码?

另外,我不熟悉新的(?)Java I/O 库,但它是否可以选择对 500 GB 文本文件进行内存映射(我在 64 位机器上,有可用内存)并对内存映射的字节数组进行二进制搜索?我很想听听您分享有关此问题和类似问题的任何经验。

【问题讨论】:

【参考方案1】:

我发布一个要点https://gist.github.com/mikee805/c6c2e6a35032a3ab74f643a1d0f8249c

这是一个相当完整的示例,基于我在堆栈溢出和一些博客中发现的内容,希望其他人可以使用它

import static java.nio.file.Files.isWritable;
import static java.nio.file.StandardOpenOption.READ;
import static org.apache.commons.io.FileUtils.forceMkdir;
import static org.apache.commons.io.IOUtils.closeQuietly;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.trimToNull;

import java.io.File;
import java.io.IOException;
import java.nio.Buffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;

public class FileUtils 

    private FileUtils() 
    

    private static boolean found(final String candidate, final String prefix) 
        return isBlank(candidate) || candidate.startsWith(prefix);
    

    private static boolean before(final String candidate, final String prefix) 
        return prefix.compareTo(candidate.substring(0, prefix.length())) < 0;
    

    public static MappedByteBuffer getMappedByteBuffer(final Path path) 
        FileChannel fileChannel = null;
        try 
            fileChannel = FileChannel.open(path, READ);
            return fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size()).load();
         
        catch (Exception e) 
            throw new RuntimeException(e);
        
        finally 
            closeQuietly(fileChannel);
        
    

    public static String binarySearch(final String prefix, final MappedByteBuffer buffer) 
        if (buffer == null) 
            return null;
        
        try 
            long low = 0;
            long high = buffer.limit();
            while (low < high) 
                int mid = (int) ((low + high) / 2);
                final String candidate = getLine(mid, buffer);
                if (found(candidate, prefix)) 
                    return trimToNull(candidate);
                 
                else if (before(candidate, prefix)) 
                    high = mid;
                 
                else 
                    low = mid + 1;
                
            
         
        catch (Exception e) 
            throw new RuntimeException(e);
         
        return null;
    

    private static String getLine(int position, final MappedByteBuffer buffer) 
        // search backwards to the find the proceeding new line
        // then search forwards again until the next new line
        // return the string in between
        final StringBuilder stringBuilder = new StringBuilder();
        // walk it back
        char candidate = (char)buffer.get(position);
        while (position > 0 && candidate != '\n') 
            candidate = (char)buffer.get(--position);
        
        // we either are at the beginning of the file or a new line
        if (position == 0) 
            // we are at the beginning at the first char
            candidate = (char)buffer.get(position);
            stringBuilder.append(candidate);
        
        // there is/are char(s) after new line / first char
        if (isInBuffer(buffer, position)) 
            //first char after new line
            candidate = (char)buffer.get(++position);
            stringBuilder.append(candidate);
            //walk it forward
            while (isInBuffer(buffer, position) && candidate != ('\n')) 
                candidate = (char)buffer.get(++position);
                stringBuilder.append(candidate);
            
        
        return stringBuilder.toString();
    

    private static boolean isInBuffer(final Buffer buffer, int position) 
        return position + 1 < buffer.limit();
    

    public static File getOrCreateDirectory(final String dirName)  
        final File directory = new File(dirName);
        try 
            forceMkdir(directory);
            isWritable(directory.toPath());
         
        catch (IOException e) 
            throw new RuntimeException(e);
        
        return directory;
    

【讨论】:

【参考方案2】:

我有类似的问题,所以我从这个线程中提供的解决方案创建(Scala)库:

https://github.com/avast/BigMap

它包含用于对大文件进行排序和在此排序文件中进行二进制搜索的实用程序...

【讨论】:

【参考方案3】:

如果您正在处理一个 500GB 的文件,那么您可能希望使用比二分查找更快的查找方法 - 即基数排序,它本质上是散列的一种变体。执行此操作的最佳方法实际上取决于您的数据分布和查找类型,但如果您正在查找字符串前缀,应该有一个很好的方法来执行此操作。

我发布了一个整数的基数排序解决方案的示例,但您可以使用相同的想法 - 基本上是通过将数据分成桶来缩短排序时间,然后使用 O(1) 查找来检索数据桶这是相关的。

Option Strict On
Option Explicit On

Module Module1

Private Const MAX_SIZE As Integer = 100000
Private m_input(MAX_SIZE) As Integer
Private m_table(MAX_SIZE) As List(Of Integer)
Private m_randomGen As New Random()
Private m_operations As Integer = 0

Private Sub generateData()
    ' fill with random numbers between 0 and MAX_SIZE - 1
    For i = 0 To MAX_SIZE - 1
        m_input(i) = m_randomGen.Next(0, MAX_SIZE - 1)
    Next

End Sub

Private Sub sortData()
    For i As Integer = 0 To MAX_SIZE - 1
        Dim x = m_input(i)
        If m_table(x) Is Nothing Then
            m_table(x) = New List(Of Integer)
        End If
        m_table(x).Add(x)
        ' clearly this is simply going to be MAX_SIZE -1
        m_operations = m_operations + 1
    Next
End Sub

 Private Sub printData(ByVal start As Integer, ByVal finish As Integer)
    If start < 0 Or start > MAX_SIZE - 1 Then
        Throw New Exception("printData - start out of range")
    End If
    If finish < 0 Or finish > MAX_SIZE - 1 Then
        Throw New Exception("printData - finish out of range")
    End If
    For i As Integer = start To finish
        If m_table(i) IsNot Nothing Then
            For Each x In m_table(i)
                Console.WriteLine(x)
            Next
        End If
    Next
End Sub

' run the entire sort, but just print out the first 100 for verification purposes
Private Sub test()
    m_operations = 0
    generateData()
    Console.WriteLine("Time started = " & Now.ToString())
    sortData()
    Console.WriteLine("Time finished = " & Now.ToString & " Number of operations = " & m_operations.ToString())
    ' print out a random 100 segment from the sorted array
    Dim start As Integer = m_randomGen.Next(0, MAX_SIZE - 101)
    printData(start, start + 100)
End Sub

Sub Main()
    test()
    Console.ReadLine()
End Sub

End Module

【讨论】:

【参考方案4】:

对于这种情况,我是 Java MappedByteBuffers粉丝。它燃烧得很快。下面是我为您整理的一个 sn-p,它将缓冲区映射到文件,寻找到中间,然后向后搜索到换行符。这应该足以让您继续前进?

我在自己的应用程序中有类似的代码(查找、阅读、重复直到完成),经过基准测试 java.io 在生产环境中对 MappedByteBuffer 进行流式传输,并将结果发布在我的博客 (Geekomatic posts tagged 'java.nio') 上,其中包含原始数据、图表和所有内容。

两秒总结? 我的基于MappedByteBuffer 的实现快了大约 275%。 YMMV。

为了处理大于~2GB 的文件,这是由于演员阵容和.position(int pos) 造成的问题,我精心设计了由MappedByteBuffers 数组支持的分页算法。您需要在 64 位系统上工作才能处理大于 2-4GB 的文件,因为 MBB 使用操作系统的虚拟内存系统来发挥它们的魔力。

public class StusMagicLargeFileReader  
    private static final long PAGE_SIZE = Integer.MAX_VALUE;
    private List<MappedByteBuffer> buffers = new ArrayList<MappedByteBuffer>();
    private final byte raw[] = new byte[1];

    public static void main(String[] args) throws IOException 
        File file = new File("/Users/stu/test.txt");
        FileChannel fc = (new FileInputStream(file)).getChannel(); 
        StusMagicLargeFileReader buffer = new StusMagicLargeFileReader(fc);
        long position = file.length() / 2;
        String candidate = buffer.getString(position--);
        while (position >=0 && !candidate.equals('\n')) 
            candidate = buffer.getString(position--);
        //have newline position or start of file...do other stuff    
    
    StusMagicLargeFileReader(FileChannel channel) throws IOException 
        long start = 0, length = 0;
        for (long index = 0; start + length < channel.size(); index++) 
            if ((channel.size() / PAGE_SIZE) == index)
                length = (channel.size() - index *  PAGE_SIZE) ;
            else
                length = PAGE_SIZE;
            start = index * PAGE_SIZE;
            buffers.add(index, channel.map(READ_ONLY, start, length));
            
    
    public String getString(long bytePosition) 
        int page  = (int) (bytePosition / PAGE_SIZE);
        int index = (int) (bytePosition % PAGE_SIZE);
        raw[0] = buffers.get(page).get(index);
        return new String(raw);
    

【讨论】:

我不敢相信 NIO 缓冲区使用 int 作为偏移量,排除了使用超过 2 GB 的可能性。这在今天的机器上几乎是愚蠢的。在这种情况下,尽可能快地排除此处给出的上下文中的方法。 请注意,FileChannel.map() 函数需要很长时间,但 ByteBuffer 本身只需要整数。您可以使用比 2GB 大得多的文件,只是任何特定的映射视图本身只能是 2GB。 (记录在案的 Win32 操作系统也有同样的限制) @dmeister:检查 javadocs——ByteBuffer 都是关于 int 的。它是 2002 年 2 月发布的 Java 1.4 的一部分……他们可能在 2000 年或 2001 年开始使用 API。 我已经更新了代码。我在一个小文件上进行了测试,但是对于一个真正的大文件(我在一个 360GB 的 tar 球上进行基准测试),一些整数包装成负数是一个问题。 缓冲区的数量是固定的,取决于文件大小。关键在StusMagicLargeFileReader 的构造函数中,MBB 被实例化。 MBB 的数量取决于文件大小。【参考方案5】:

我也有同样的问题。我试图在排序文件中找到所有以某个前缀开头的行。

这是我编写的一个方法,主要是此处找到的 Python 代码端口:http://www.logarithmic.net/pfh/blog/01186620415

我已经对其进行了测试,但还没有彻底测试。不过,它不使用内存映射。

public static List<String> binarySearch(String filename, String string) 
    List<String> result = new ArrayList<String>();
    try 
        File file = new File(filename);
        RandomAccessFile raf = new RandomAccessFile(file, "r");

        long low = 0;
        long high = file.length();

        long p = -1;
        while (low < high) 
            long mid = (low + high) / 2;
            p = mid;
            while (p >= 0) 
                raf.seek(p);

                char c = (char) raf.readByte();
                //System.out.println(p + "\t" + c);
                if (c == '\n')
                    break;
                p--;
            
            if (p < 0)
                raf.seek(0);
            String line = raf.readLine();
            //System.out.println("-- " + mid + " " + line);
            if (line.compareTo(string) < 0)
                low = mid + 1;
            else
                high = mid;
        

        p = low;
        while (p >= 0) 
            raf.seek(p);
            if (((char) raf.readByte()) == '\n')
                break;
            p--;
        

        if (p < 0)
            raf.seek(0);

        while (true) 
            String line = raf.readLine();
            if (line == null || !line.startsWith(string))
                break;
            result.add(line);
        

        raf.close();
     catch (IOException e) 
        System.out.println("IOException:");
        e.printStackTrace();
    
    return result;

【讨论】:

【参考方案6】:

我不知道有任何库具有该功能。但是,Java 中外部二进制搜索的正确代码应该类似于:

class ExternalBinarySearch 
final RandomAccessFile file;
final Comparator<String> test; // tests the element given as search parameter with the line. Insert a PrefixComparator here
public ExternalBinarySearch(File f, Comparator<String> test) throws FileNotFoundException 
    this.file = new RandomAccessFile(f, "r");
    this.test = test;

public String search(String element) throws IOException 
    long l = file.length();
    return search(element, -1, l-1);

/**
 * Searches the given element in the range [low,high]. The low value of -1 is a special case to denote the beginning of a file.
 * In contrast to every other line, a line at the beginning of a file doesn't need a \n directly before the line
 */
private String search(String element, long low, long high) throws IOException 
    if(high - low < 1024) 
        // search directly
        long p = low;
        while(p < high) 
            String line = nextLine(p);
            int r = test.compare(line,element);
            if(r > 0) 
                return null;
             else if (r < 0) 
                p += line.length();
             else 
                return line;
            
        
        return null;
     else 
        long m  = low + ((high - low) / 2);
        String line = nextLine(m);
        int r = test.compare(line, element);
        if(r > 0) 
            return search(element, low, m);
         else if (r < 0) 
            return search(element, m, high);
         else 
            return line;
        
    

private String nextLine(long low) throws IOException 
    if(low == -1)  // Beginning of file
        file.seek(0);           
     else 
        file.seek(low);
    
    int bufferLength = 65 * 1024;
    byte[] buffer = new byte[bufferLength];
    int r = file.read(buffer);
    int lineBeginIndex = -1;

    // search beginning of line
    if(low == -1)  //beginning of file
        lineBeginIndex = 0;
     else 
        //normal mode
        for(int i = 0; i < 1024; i++) 
        if(buffer[i] == '\n') 
            lineBeginIndex = i + 1;
            break;
        
        
    
    if(lineBeginIndex == -1) 
        // no line begins within next 1024 bytes
        return null;
    
    int start = lineBeginIndex;
        for(int i = start; i < r; i++) 
            if(buffer[i] == '\n') 
                // Found end of line
                return new String(buffer, lineBeginIndex, i - lineBeginIndex + 1);
                return line.toString();
            
        
        throw new IllegalArgumentException("Line to long");


请注意:我专门编写了这段代码:角落案例的测试还不够好,代码假设没有单行大于 64K,等等。

我还认为为行开始的偏移量建立索引可能是一个好主意。对于 500 GB 的文件,该索引应存储在索引文件中。您应该使用该索引获得一个不那么小的常数因子,因为不需要在每个步骤中搜索下一行。

我知道这不是问题所在,但构建一个前缀树数据结构,如 (Patrica) Tries(在磁盘/SSD 上)可能是进行前缀搜索的好主意。

【讨论】:

谢谢,我会研究一下 Patricia Tries(我还没有看到 Trie 在磁盘上而不是在内存中的样子) 至于寻找行首,原来的 perl 模块只是在每次查找后用 readLine() 刷新部分行。当您考虑它时,这不会干扰二进制搜索本身。文本文件有 ~29x10^9 行,因此字节偏移量的索引本身可能会变得很快。【参考方案7】:

如果你真的想尝试内存映射文件,我在 Java nio 中找到了tutorial on how to use memory mapping。

【讨论】:

【参考方案8】:

这是您想要实现的目标的一个简单示例。我可能会首先索引文件,跟踪每个字符串的文件位置。我假设字符串由换行符(或回车符)分隔:

    RandomAccessFile file = new RandomAccessFile("filename.txt", "r");
    List<Long> indexList = new ArrayList();
    long pos = 0;
    while (file.readLine() != null)
    
        Long linePos = new Long(pos);
        indexList.add(linePos);
        pos = file.getFilePointer();
    
    int indexSize = indexList.size();
    Long[] indexArray = new Long[indexSize];
    indexList.toArray(indexArray);

最后一步是转换为数组,以便在进行大量查找时稍微提高速度。我也可能会将Long[] 转换为long[],但我没有在上面显示。最后是从给定索引位置读取字符串的代码:

    int i; // Initialize this appropriately for your algorithm.
    file.seek(indexArray[i]);
    String line = file.readLine();
            // At this point, line contains the string #i.

【讨论】:

你是否有足够的内存来将索引列表保存在内存中? 这取决于条目的数量。人们总是可以写出索引并使用 LongBuffer,可能是 mmap'd。 这是一个很酷的想法,但是文本文件超过 500GB,这几乎排除了这种方法。无论如何,即使您使用 seek 跳转到某行的中间,随后调用 readLine() 也会将您带到最近的换行符,几乎不会增加开销。 仅仅因为文本文件很大并不意味着索引会很大,特别是如果每​​一行都是唯一的。另外,我的方法不会看到一行的中间,你总是会寻找你感兴趣的行的开头。

以上是关于Java中排序(内存映射?)文件中的二进制搜索的主要内容,如果未能解决你的问题,请参考以下文章

gdb 搜索核心转储内存

Java NIO内存映射---上G大文件处理(转)

计算机程序的思维逻辑 (61) - 内存映射文件及其应用 - 实现一个简单的消息队列

JAVA NIO之浅谈内存映射文件原理与DirectMemory

ELF二进制分析静态与动态。汇编代码如何?指令内存映射的变化?

Android 逆向Android 逆向基本概念 ( 定位内存中的修改点 | 基址寻址法 | 搜索定位法 )