Java中字符串享元实现的最佳替代方案

10

我的应用程序是多线程且涉及大量字符串处理。我们遇到了过度的内存消耗问题,并且分析表明这是由于字符串数据造成的。我认为使用某种飞行权重模式实现甚至缓存(我知道字符串经常被复制,尽管在这方面我没有任何硬数据)将极大地受益于内存消耗。

我查看了Java常量池和String.intern,但似乎会引起PermGen问题。

在Java中,实现全局、多线程字符串池的最佳替代方案是什么?

编辑:还请查看我之前的相关问题:Java在幕后如何实现字符串的飞行权重模式?


你的堆大小设置是多少? - Inv3r53
@scot 我们尝试过那些方法...我们的解决方案不够高效,所以无法很好地扩展。 - Dan
你意识到你刚刚说了“我确定某件事情,但没有任何数据来证明它”,你需要重新评估你认为自己知道的东西。在没有证据的情况下想出某些事情被称为推测。 - user177800
@fuzzy,我可能没有表达清楚。我知道从代码中可以看出许多字符串是重复的,但我没有执行任何测量来获取运行时数据以给出确切比例。如果您愿意,我认为我仍然可以通过静态代码分析做出有效的假设。但这不是重点。即使我实际上不需要享元模式的实现,我相信许多其他人需要。 - Dan
1
你没有理解重点,除非你实际进行分析和测量,否则你不会知道。JRE和JIT做了很多不明显的事情,其中大部分是暂时的。因此,通过阅读你编写的代码来猜测它应该做什么是一种非常糟糕的做法。我的观点是,如果没有分析和测量,你真的不知道你需要什么。而且,你认为自己理解的行为可能会从一个JRE版本到下一个版本甚至在小版本发布中发生变化。 - user177800
我正在使用 WeakHashMap 的一个版本,这可能是最快的方法(在我的测试中比 String.intern 快 3 倍)。示例程序。 基准测试。 - Stefan Reich
5个回答

8

注意:本答案使用的示例可能与现代运行时JVM库无关。特别是,在OpenJDK/Oracle 7+中,substring示例不再是问题。

我知道这与人们通常告诉你的相反,但有时明确创建新的String实例可以显著减少内存使用。

由于字符串是不可变的,因此几种方法利用了这一事实并共享支持字符数组以节省内存。然而,偶尔这实际上会增加内存,因为它阻止了那些数组未使用部分的垃圾回收。

例如,假设您正在解析日志文件的消息ID以提取警告ID。您的代码将类似于:

//Format:
//ID: [WARNING|ERROR|DEBUG] Message...
String testLine = "5AB729: WARNING Some really really really long message";

Matcher matcher = Pattern.compile("([A-Z0-9]*): WARNING.*").matcher(testLine);
if ( matcher.matches() ) {
    String id = matcher.group(1);
        //...do something with id...
}

但是看看实际存储的数据:

    //...
    String id = matcher.group(1);
    Field valueField = String.class.getDeclaredField("value");
    valueField.setAccessible(true);

    char[] data = ((char[])valueField.get(id));
    System.out.println("Actual data stored for string \"" + id + "\": " + Arrays.toString(data) );

这是整个测试行,因为匹配器只会将一个新的字符串实例包装在相同的字符数据周围。比较替换 String id = matcher.group(1);String id = new String(matcher.group(1)); 的结果。


1
如果你正在处理大量的字符串操作,请注意以下内容。字符串 s = hugeString.substring(0,1); hugeString = null; 变量 s 的长度不是“1个字符”,它与原来的 hugeString 一样长(因为它就是 hugeString)。 - Will Hartung
威尔,我不认为我理解了,子字符串将创建一个新的字符串实例,其大小为1。 - Dan
3
@Dan:根据 length() 返回的结果,“size”的大小为1。但是这两个字符串引用了同一个底层字符数组,只是记录了不同的开始和结束索引,指向字符串所引用的数组。字符数组没有复制到新的字符串中;只有对数组的引用被复制到新的字符串中。 - Mark Peters

3
这已经在JVM级别上完成了。您只需要确保不会每次显式或隐式地创建new String
即,不要这样做:
String s1 = new String("foo");
String s2 = new String("foo");

这将在堆中创建两个实例。最好这样做:
String s1 = "foo";
String s2 = "foo";

这将在堆中创建一个实例,两者将引用相同的实例(作为证据,s1 == s2 在此处将返回true)。

在循环中不要使用+=来连接字符串:

String s = "";
for (/* some loop condition */) {
    s += "new";
}
+= 每次都会在堆中隐式创建一个 new String。建议使用其他方式。
StringBuilder sb = new StringBuilder();
for (/* some loop condition */) {
    sb.append("new");
}
String s = sb.toString();

如果您需要进行“大量字符串处理”,建议使用StringBuilder或其同步版本StringBuffer,可以提供用于此类目的有用方法,例如append()insert()delete()等。另外,可以参考它的javadoc


1
我知道。我在想这是否是最好的方法?使用Interning会占用PermGen内存,如果它被耗尽,调试可能会很困难... - Dan
如果你的问题不能通过编写更高效的代码来解决,那么你有两个选择:1)不要将大量数据存储在JVM的内存中,而是存储在磁盘文件系统或数据库中,并仅在真正需要时查询它,然后在使用后将其清除。2)为JVM提供更多的内存。 - BalusC
1
我认为编写更高效的代码可以大大减少问题,但有不止一种替代方案:1. 我可以依赖Java常量池(正如您建议的那样)2. 我可以自己编写轻量级对象池。我正在尝试理解和比较这些替代方案。 - Dan
3
我认为自制的轻量级字符串并不能解决这个问题,它只会增加额外的抽象层(和额外的开销)。在JVM的字符串池中已经解决了这个问题。相反,应该查看代码,并在适当的地方使用StringBuilder - BalusC

2

Java 7/8

如果你按照被接受的答案所说,使用了Java 7或更新版本,那么你并没有按照它所说的去做。

subString()方法的实现已经发生了改变。

永远不要编写依赖于可能会发生巨大变化并且如果依赖于旧的行为可能会使情况变得更糟的实现的代码。

1950    public String substring(int beginIndex, int endIndex) {
1951        if (beginIndex < 0) {
1952            throw new StringIndexOutOfBoundsException(beginIndex);
1953        }
1954        if (endIndex > count) {
1955            throw new StringIndexOutOfBoundsException(endIndex);
1956        }
1957        if (beginIndex > endIndex) {
1958            throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
1959        }
1960        return ((beginIndex == 0) && (endIndex == count)) ? this :
1961            new String(offset + beginIndex, endIndex - beginIndex, value);
1962    }

如果您在Java 7或更新版本中使用被接受的答案,则会创建两倍于实际需要的内存使用量和垃圾,这些垃圾需要进行收集。


1

高效地在内存中压缩字符串!我曾经编写过一个超级内存高效的Set类,其中字符串被存储为一棵树。如果通过遍历字母到达叶子节点,则该条目包含在集合中。使用起来也很快,并且非常适合存储大型词典。

而且不要忘记,在我测试的几乎每个应用程序中,字符串通常是内存中最大的部分,因此如果您需要它们,请不要忽略它们。

示例:

您有3个字符串:Beer、Beans和Blood。您可以创建如下的树形结构:

B
+-e
  +-er
  +-ans
+-lood

非常适用于例如街道名称列表等的高效处理,这显然最合理的是使用固定字典,因为插入操作无法高效完成。实际上,应该创建一次结构,然后序列化并随后加载。

听起来很有趣,但我不确定我完全理解了。您介意举个例子吗? - Dan
4
据我理解,您所描述的是一种名为 Patricia trie 的数据结构:http://en.wikipedia.org/wiki/Patricia_trie - Mark Peters
+1 谢谢你的名字!现在我知道该如何寻找比我的更好的实现了 :)。 - Daniel
这被称为“Trie”。 - user177800

0
首先,您需要决定如果减少一些解析操作会对应用程序和开发人员造成多大的影响。如果在此过程中导致员工流失率翻倍,那么更快的应用程序对您来说毫无意义!根据您的问题,我认为我们可以假设您已经通过了这个测试。
其次,如果您无法消除创建对象的操作,那么您的下一个目标应该是确保它不会存活于Eden集合中。而parse-lookup可以解决这个问题。然而,“正确实现”的缓存(我不同意这个基本前提,但我不想让您听我抱怨)通常会带来线程争用。您将会用另一种内存压力替换掉原有的内存压力。
有一种变体的parse-lookup习语,它不像完全缓存那样容易出现副作用,这就是简单的预计算查找表(也称“记忆化”)。您通常会看到这种模式的是“类型安全枚举”(TSE)。使用TSE,您可以解析字符串,将其传递给TSE以检索相关的枚举类型,然后将字符串丢弃。

你正在处理的文本是自由格式的,还是输入必须遵循严格的规范?如果你的大部分文本可以归约为一组固定的可能值,那么一个TSE(文本转换引擎)可以在这里帮助你,并且可以为你的信息添加上下文/语义。这样可以在创建信息的时候就赋予其含义,而不是在使用的时候才去解读。


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