垃圾友好的substring()替代方案

9
我有一个使用管道分隔的文件,我解析它以获取系统选项。 这个环境对堆分配非常敏感,我们正在尝试避免垃圾回收。
以下是我用于解析管道分隔字符串的代码。此函数被调用约35000次。 我想知道是否有更好的方法,可以减少内存占用。
static int countFields(String s) {
    int n = 1;
    for (int i = 0; i < s.length(); i++)
        if (s.charAt(i) == '|')
            n++;

    return n;
}

static String[] splitFields(String s) {
    String[] l = new String[countFields(s)];

    for (int pos = 0, i = 0; i < l.length; i++) {
        int end = s.indexOf('|', pos);
        if (end == -1)
            end = s.length();
        l[i] = s.substring(pos, end);
        pos = end + 1;
    }

    return l;
}

关于Java版本的编辑1:

由于业务需要,我们被困在JDK 1.6.0_25。

关于String和String[]使用的编辑2:

String[]用于执行系统设置逻辑。基本上,如果String[0].equals("true")则启用调试。这是使用模式。

关于垃圾回收对象的编辑3:

输入的String和String[]最终会被GC回收。输入的String是系统设置文件中的单行,文件全部处理完毕后回收,而String[]则是在整行处理完毕后回收。

解决方案编辑:

这是Peter Lawrey和zapl的解决方案的组合。此类不是线程安全的。

public class DelimitedString {

    private static final Field EMPTY = new Field("");

    private char delimiter = '|';
    private String line = null;
    private Field field = new Field();

    public DelimitedString() { }

    public DelimitedString(char delimiter) {
        this.delimiter = delimiter;
    }

    public void set(String line) {
        this.line = line;
    }

    public int length() {
        int numberOfFields = 0;
        if (line == null)
            return numberOfFields;

        int idx = line.indexOf(delimiter);
        while (idx >= 0) {
            numberOfFields++;
            idx = line.indexOf(delimiter, idx + 1);
        }
        return ++numberOfFields;
    }

    public Field get(int fieldIndex) {
        if (line == null)
            return EMPTY;

        int currentField = 0;
        int startIndex = 0;
        while (currentField < fieldIndex) {
            startIndex = line.indexOf(delimiter, startIndex);

            // not enough fields
            if (startIndex < 0)
                return EMPTY;

            startIndex++;
            currentField++;
        }

        int endIndex = line.indexOf(delimiter, startIndex);
        if (endIndex == -1)
            endIndex = line.length();

        fieldLength = endIndex - startIndex;
        if (fieldLength == 0)
            return EMPTY;

        // Populate field
        for (int i = 0; i < fieldLength; i++) {
            char c = line.charAt(startIndex + i);
            field.bytes[i] = (byte) c;
        }
        field.fieldLength = fieldLength;
        return field;
    }

    @Override
    public String toString() {
        return new String(line + " current field = " + field.toString());
    }

    public static class Field {

        // Max size of a field
        private static final int DEFAULT_SIZE = 1024;

        private byte[] bytes = null;
        private int fieldLength = Integer.MIN_VALUE;

        public Field() {
            bytes = new byte[DEFAULT_SIZE];
            fieldLength = Integer.MIN_VALUE;
        }

        public Field(byte[] bytes) {
            set(bytes);
        }

        public Field(String str) {
            set(str.getBytes());
        }

        public void set(byte[] str) {
            int len = str.length;
            bytes = new byte[len];
            for (int i = 0; i < len; i++) {
                byte b = str[i];
                bytes[i] = b;
            }
            fieldLength = len;
        }

        public char charAt(int i) {
            return (char) bytes[i];
        }

        public byte[] getBytes() {
            return bytes;
        }

        public int length() {
            return fieldLength;
        }

        public short getShort() {
            return (short) readLong();
        }

        public int getInt() {
            return (int) readLong();
        }

        public long getLong() {
            return readLong();
        }

        @Override
        public String toString() {
            return (new String(bytes, 0, fieldLength));
        }

        // Code taken from Java class Long method parseLong()
        public long readLong() {
            int radix = 10;
            long result = 0;
            boolean negative = false;
            int i = 0, len = fieldLength;
            long limit = -Long.MAX_VALUE;
            long multmin;
            int digit;

            if (len > 0) {
                char firstChar = (char) bytes[0];
                if (firstChar < '0') { // Possible leading "-"
                    if (firstChar == '-') {
                        negative = true;
                        limit = Long.MIN_VALUE;
                    } else
                        throw new NumberFormatException("Invalid leading character.");

                    if (len == 1) // Cannot have lone "-"
                        throw new NumberFormatException("Negative sign without trailing digits.");
                    i++;
                }
                multmin = limit / radix;
                while (i < len) {
                    // Accumulating negatively avoids surprises near MAX_VALUE
                    digit = Character.digit(bytes[i++], radix);
                    if (digit < 0)
                        throw new NumberFormatException("Single digit is less than zero.");
                    if (result < multmin)
                        throw new NumberFormatException("Result is less than limit.");

                    result *= radix;
                    if (result < limit + digit)
                        throw new NumberFormatException("Result is less than limit plus new digit.");

                    result -= digit;
                }
            } else {
                throw new NumberFormatException("Called readLong with a length <= 0. len=" + len);
            }
            return negative ? result : -result;
        }
    }
}

4
你正在避免最糟糕的事情,那就是将字符串重复分割成头部和逐渐变小的尾部部分。但如果你需要N个字符串,没有真正的方法可以避免创建N个字符串。(以前JVM会为从原始字符串中生成的子字符串“共享”单个字符数组,但由于一些原因现在不再这样做。) - Hot Licks
3
自Java 7更新4以来就没有了。 - Peter Lawrey
4
共享缓冲区在实际上比那个时间点(Java 2 时间)早得多就被放弃了。只是需要很长时间才能摆脱额外的偏移字段。 - Hot Licks
1
你能做得更好的唯一方法就是在解析值时分析它们并仅缓存已分析的值(数字,真/假等),也许可以将它们存入自定义对象的字段中。 - Hot Licks
1
需要注意的是,String[] 对象在空间和对象数量上并不比 long[] 对象更昂贵,并且通常不值得费力去避免创建它。它基本上是一个对象段,大约长为 (4 + N) * 8 字节。 - Hot Licks
显示剩余19条评论
8个回答

6
我会做这样的事情。
public static void main(String[] args) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader("inputfile"));
    StringBuilder sb = new StringBuilder();
    do {
        boolean flag = readBoolean(br, sb);
        long val = readLong(br, sb);
        process(flag, val);
    } while (nextLine(br));
    br.close();
}

private static void process(boolean flag, long val) {
    // do something.
}

public static boolean readBoolean(BufferedReader br, StringBuilder sb) throws IOException {
    readWord(br, sb);
    return sb.length() == 4
            && sb.charAt(0) == 't'
            && sb.charAt(1) == 'r'
            && sb.charAt(2) == 'u'
            && sb.charAt(3) == 'e';
}

public static long readLong(BufferedReader br, StringBuilder sb) throws IOException {
    readWord(br, sb);
    long val = 0;
    boolean neg = false;
    for (int i = 0; i < sb.length(); i++) {
        char ch = sb.charAt(i);
        if (ch == '-')
            neg = !neg;
        else if (ch >= '0' && ch <= '9')
            val = val * 10 + ch - '0';
        else
            throw new NumberFormatException();
    }
    return neg ? -val : val;
}

public static boolean nextLine(BufferedReader br) throws IOException {
    while (true) {
        int ch = br.read();
        if (ch < 0) return false;
        if (ch == '\n') return true;
    }
}

public static void readWord(BufferedReader br, StringBuilder sb) throws IOException {
    sb.setLength(0);
    while (true) {
        br.mark(1);
        int ch = br.read();
        switch (ch) {
            case -1:
                throw new EOFException();
            case '\n':
                br.reset();
            case '|':
                return;
            default:
                sb.append((char) ch);
        }
    }
}

这个更为复杂,但产生的垃圾非常少。实际上,StringBuilder 可以被回收利用。 ;)
注意:这不会创建一个 String 或 String[]。

Peter,感谢你的代码。这很棒,但我最终选择了zapl的解决方案,因为它更直接地替换了我的当前代码库。虽然,如果/当我的内存分析完成后,我可能会重新考虑这个问题。 - Justin

2
基本上,如果String [0]等于“true”,则启用调试。 您可以通过直接与输入字符串进行比较来消除数组和子字符串的创建。这并不像Peter Lawrey的解决方案那样避免创建输入字符串,但更改可能需要更少的工作(尽管我怀疑这一点)。
public static boolean fieldMatches(String line, int fieldIndex, String other) {
    int currentField = 0;
    int startIndex = 0;
    while (currentField < fieldIndex) {
        startIndex = line.indexOf('|', startIndex);

        // not enough fields
        if (startIndex < 0)
            return false;

        startIndex++;
        currentField++;
    }

    int start = startIndex;
    int end = line.indexOf('|', startIndex);
    if (end == -1) {
        end = line.length();
    }
    int fieldLength = end - start;

    // make sure both strings have the same length
    if (fieldLength != other.length())
        return false;

    // regionMatches does not allocate objects
    return line.regionMatches(start, other, 0, fieldLength);
}

public static void main(String[] args) {
    String line = "Config|true"; // from BufferedReader
    System.out.println(fieldMatches(line, 0, "Config"));
    System.out.println(fieldMatches(line, 1, "true"));
    System.out.println(fieldMatches(line, 1, "foobar"));
    System.out.println(fieldMatches(line, 2, "thereisnofield"));
}

输出

true
true
false
false

最终我采用了这种技术,因为它比直接替换我的当前代码库更省功夫。 - Justin

1

由于在解析输入时创建的字符串是最大的“罪犯”,其次是每次调用创建的字符串数组,因为从您的评论中可以看出,您不需要同时获取所有子字符串,因此您可以创建一个对象,逐个提供字符串,并在提供字符串时重复使用同一个 StringBuilder 对象。

下面是一个骨架类,展示了如何实现这一点:

class Splitter {
    private String s="";
    private int pos = 0;
    public void setString(String newS) {
        s = newS;
        pos = 0;
    }
    boolean tryGetNext(StringBuilder result) {
        result.delete(0, result.length());
        // Check if we have anything to return
        if (pos == s.length()) {
            return false;
        }
        // Go through the string starting at pos, adding characters to result
        // until you hit the pipe '|'
        // At that point stop and return
        while (...) {
            ...
        }
        return true;
    }
}

现在你可以按照以下方式使用这个类:
StringBuilder sb = new StringBuilder(MAX_LENGTH);
Splitter splitter = new Splitter();
for (String s: sourceOfStringsToBeSplit) {
    sb.setString(s);
    while (splitter.tryGetNext(sb)) {
        ... // Use the string from sb
    }
}

如果 sb 的内部缓冲区大小正确,此代码将在整个运行过程中创建三个对象 - SplitterStringBuilderStringBuilder 内部的字符数组。但是,在使用 StringBuilder 时需要小心,不要创建额外的对象,特别是需要避免调用 toString()。例如,不要像这样将 StringBuilder 与固定字符串进行比较,而是...
if (sb.toString().equals(targetString)) {
    ...
}

你应该写

if (targetString.length() == sb.length() && sb.indexOf(targetString) == 0) {
    ...
}

1

只是一个想法。不要分割任何内容。做相反的事情 - 将它们附加在一起(例如,在某个StringBuilder中)并将它们保存在一个大字符串或实际上StringBuilder中会更好。其中的字符串可以用|分隔,而(目前的)字符串数组则用#分隔。

然后,只需从方法splitFields返回索引-起始索引和结束索引(当前的String[])。

这里只是提出了一些想法。不确定您确切的用例场景,这取决于您如何处理返回值。

当然,您需要自己管理那个大的StringBuilder,并在不再需要该数据时从中删除数据,否则它最终会变得太大。

即使这个想法不直接适用,我希望您能理解我的观点-我认为您需要一些池或内存区域或类似的东西,您将自己管理它。


0

像这样做

static String[] splitFields(String s) {    
    List<String> list = new ArrayList<String>();
    StringBuilder sb  = new StringBuilder();
    for (int i = 0; i < s.length(); i++) {
         char charAt = s.charAt(i);
         if (charAt == '|'){
            list.add(sb.toString());
            sb = new StringBuilder();
        }else{
           sb.append(charAt);
        }
    }
    list.add(sb.toString());//last chunk
    return list.toArray(new String[list.size()]);; 
}

@HotLicks 请问您有什么建议吗? - Prabhakaran Ramaswamy
你创建了多个不必要的StringBuilder,使用一个更便宜的字符串数组就可以满足OP的方案,而且代码的目的也不明显。OP的代码可以一眼看出它试图做什么,而你的代码则明显不够清晰。 - Hot Licks
最终,您只需要运行实验,我认为这是一个有效的实验。在这种情况下,我认为您应该尝试使用sb.setLength(0)重置stringbuilder而不是创建一个新的,因为它保留了后台数组,并且可以避免GC。 - eSniff
@eSniff 这个 https://dev59.com/eG435IYBdhLWcg3w0Trc 参考说,与创建新的 StringBuilder 相比,sb.setLength(0) 是一个更昂贵的过程。 - Prabhakaran Ramaswamy
只有当你知道在SB中存储的大小时,才能按照得票最多的答案中提到的方式进行操作。而评论是关于CPU性能而不是堆开销的。但是,你应该进行实验而不是假设SF总是正确的。 - eSniff

0

更简单,是的,垃圾也更多。 - Peter Lawrey

0

0

以上的回答似乎没有解决你的问题。

他们都建议你使用 String.split() - 我承认,这也是我推荐的方法。

但那不是你想知道的。你想要一个内存占用更少的方法。而答案是没有。事实上,你正在做的比 split 更有效率。(它特殊处理单字符正则表达式,但使用 List 然后将列表导出为 String[]。)- 如果你的目标确实是减少内存占用,你不应该使用 split

然而,我没有看到你是否已经确定了 GC 问题确实是一个问题。现代 JVM 在清理短生命周期对象方面非常出色。所以如果你试图提前规划,不要这样做 - 只需使用 split。如果你已经确定了 GC 是一个问题,并且正在寻找解决方案,那么这个解决方案是你能得到的最好的(除非你实现自己的 String 对象,像 Java 以前做的那样保持相同的支持字符数组)。

你可以传入一个char[]并返回实际放入该数组的字符数,用于你的子字符串。这样你只需要一个缓冲区。这假设你正在逐个处理从此函数返回的标记。你也可以传入一个自己创建的MutableString对象。摆脱String[]将大大减轻GC的负担。

当然,现在你必须考虑诸如缓冲区溢出之类的问题,以及其他无聊的事情。这些都是由系统为您处理的。


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