【问题标题】:Comparison of String and StringBuilder manipulation in terms of memory usageString 和 StringBuilder 操作在内存使用方面的比较
【发布时间】:2013-12-24 03:09:22
【问题描述】:

根据 KathySierra 的 SCJP 学习指南:

java.lang.StringBuffer 和 java.lang.StringBuilder 类应该 当您必须对字符串进行修改时使用。 正如我们所讨论的,String 对象是不可变的,所以如果你选择这样做 对 String 对象进行大量操作,最终会得到很多 字符串池中废弃的字符串对象数

为了弄清楚这一点,我浏览了 String 类和 StringBuilder source here 的代码。

String 的简化代码如下所示:

public final class String(){
     private final char [] value; //Final helps in immutability, I guess.
     public String (String original){
         value = original.value;
      }
}

StringBuilder的简化版是这样的:

public final class StringBuilder{
    char [] value;
    public StringBuilder(String str) {
        value = new Char[(str.length() + 16)]; // 16 here is implementation dependent.
    append(str);
}

public StringBuilder append(String str){

            //Add 'str' characters in value array if its size allows,
        else
            // Create new array of size newCapacity and copy contents of 'value' in that.
            //value = Arrays.copyOf(value, newCapacity);// here old array object is lost.

        return this;
    }
}

假设我们有一个案例如下:

使用字符串类:

String s1 = "abc"; // Creates one object on String pool.
s1 = s1+"def"; // Creates two objects - "def " on String pool
// and "abcdef" on the heap.

如果我使用StringBuilder,代码变成:

 StringBuilder s1 = StringBuilder("abc");

 // Creates one String object "abc " on String pool.
 // And one StringBuilder object "abc" on the heap.
 s1.append("def");
 // Creates one string object "def" on String pool.
 // And changes the char [] inside StringBuilder to hold "def" also.

在 StringBuilder s2 = s1.append("def"); 中,保存字符串的 char 数组将被新的 char 数组替换的机会相同。旧数组现在引用较少,将被垃圾回收。

我的查询是:

使用简单的字符串连接和StringBuilder append() 方法,进入字符串池的String 对象的数量是相同的。

根据上面列出的代码,StringBuilder 确实首先使用了更大的 char 数组,而 String 对象包含一个与它所保存的字符串长度相同的 char 数组。

  1. StringBuilder 的使用如何比平时更高效 String 用于字符串操作的类?
  2. SCJP Guide 中的陈述是否有误?

【问题讨论】:

    标签: java string stringbuilder


    【解决方案1】:

    关键是expandCapacity函数:

    void expandCapacity(int minimumCapacity) {
        int newCapacity = (value.length + 1) * 2; //important part here
        if (newCapacity < 0) {
            newCapacity = Integer.MAX_VALUE;
        } else if (minimumCapacity > newCapacity) {
            newCapacity = minimumCapacity;
        }
        value = Arrays.copyOf(value, newCapacity);
    }
    

    每次需要调整大小时,通过将底层数组的大小调整 2 倍,追加 1 个字符所需的 amortized time 被最小化。

    Wikipedia有很好的解释:

    随着n个元素的插入,容量形成几何级数。以任何恒定比例扩展数组可确保插入 n 个元素总体上花费 O(n) 时间,这意味着每次插入都需要摊销的恒定时间。这个比例 a 的值导致了时空折衷:每次插入操作的平均时间约为 a/(a-1),而浪费的单元数以 (a-1)n 为界。 a 的选择取决于库或应用程序:有些教科书使用 a = 2,但 Java 的 ArrayList 实现使用 a = 3/2,而 Python 的列表数据结构的 C 实现使用 a = 9/8。

    如果其大小低于某个阈值(例如容量的 30%),许多动态数组也会取消分配一些底层存储。此阈值必须严格小于 1/a,以支持具有摊销常数成本的混合插入和删除序列。

    在教授摊销分析时,动态数组是一个常见的例子。

    现在对于您的特定示例,它不会产生影响,但是您会在附加大量字符时看到效果:

    public static void main(String[] args){
        int numAppends = 200000;
        int numRepetitions = 3;
        long[] time1 = new long[numRepetitions];
        long[] time2 = new long[numRepetitions];
        long now;
        for (int k = 0; k < numRepetitions; k++){
            String s = "";
            now = System.nanoTime();
            for(int i = 0; i < numAppends ; i++) {
                s = s + "a";
            }
            time1[k] = (System.nanoTime() - now) / 1000000;
            StringBuilder sb = new StringBuilder();
            now = System.nanoTime();
            for(int i = 0; i < numAppends ; i++) {
                sb.append("a");     
            }
            time2[k] = (System.nanoTime() - now) / 1000000;
            System.out.println("Rep "+k+", time1: "+time1[k]+ " ms, time2: " + time2[k] + " ms");
        }
    }
    

    输出:

    Rep 0, time1: 13509 ms, time2: 7 ms
    Rep 1, time1: 13086 ms, time2: 1 ms
    Rep 2, time1: 13167 ms, time2: 1 ms
    

    另外,我计算了 Arrays.copyOf() 方法在 extendCapacity() 内部被调用的次数以进行基准测试:第一次迭代是 49 次,但在第二次和第三次迭代中只有 15 次迭代。输出如下:

    newCapacity: 34
    newCapacity: 70
    newCapacity: 142
    newCapacity: 286
    newCapacity: 574
    newCapacity: 1150
    newCapacity: 2302
    newCapacity: 4606
    newCapacity: 9214
    newCapacity: 18430
    newCapacity: 36862
    newCapacity: 73726
    newCapacity: 147454
    newCapacity: 294910
    newCapacity: 42
    Rep 2, time1: 12955 ms, time2: 134 ms
    

    【讨论】:

    • 添加了摊销成本的微基准和维基百科参考。
    • 我的主要观点是“在字符串池上创建的字符串对象”在这两种情况下都是相同的!那么SCJP Guide中给出的说法是错误的?
    • 第一种情况会创建更多的String对象,即200001个String对象。使用 StringBuilder 时,只有 log2(20000/16) = 10.28,因此会创建 13 个数组,其中 16 是初始数组大小。实际上,我会尝试计算数组的数量并将其添加到答案中。
    • 你是对的,因为每次附加的字符串都是a。但是,如果我们在每次迭代中附加唯一的 String 。让我们先说“a”然后说“b”等等。然后s.append("a"); s.append("b"); 将使a ,b 等,字符串池上的字符串对象。我说的对吗?
    • 只有两次,一次用于“a”,一次用于“b”,但不是第一个版本的 20000 次。
    【解决方案2】:

    只有循环创建字符串才会更有效率。如果你有一个循环:

    String[] strings = { "a", "b", "c", "d" };
    String result = "";
    for( String s : strings) {
        result += s;
    }
    

    StringBuilder 版本会生成更少的对象,导致更少的 GC:

    String[] strings = { "a", "b", "c", "d" };
    StringBuilder builder = new StringBuilder();
    for( String s : strings) {
        builder.append(s);
    }
    

    虽然第一个会导致在每次循环运行时发送一个对象进行 GC,但第二个不会。

    最终,由于字符串构建器数组的大小增加了一倍,因此不会发生很多分配。

    【讨论】:

    • 感谢您的回复。你能评论SCJP guide关于字符串池对象的声明吗?
    • 语句本身没有多大意义,只有声明为文字的字符串才会进入池。连接两个字符串的结果不包含在字符串池中。
    【解决方案3】:

    操作不仅仅是连接。想象一下,您想在字符串的中间插入一个字符。你会怎么做,因为字符串是不可变的?您必须创建一个新字符串。使用 StringBuilder 你可以insert(int offset, c)

    StringBuilder javadoc

    你有类似的方法

    delete(int start, int end)
    // Removes the characters in a substring of this sequence.
    
    replace(int start, int end, String str)
    // Replaces the characters in a substring of this sequence with characters in the specified String.
    
    reverse()
    // Causes this character sequence to be replaced by the reverse of the sequence.
    
    insert(int dstOffset, CharSequence s)
    // Inserts the specified CharSequence into this sequence.
    

    【讨论】:

      【解决方案4】:

      StringBuilder 的使用如何比普通的 String 类更有效地处理字符串?

      当您在循环中执行许多操作时,它大大更有效率。考虑任何需要遍历单个字符的字符串转换或替换函数,例如为 XML 或 HTML 转义 &lt;, &gt;, &amp;, ", ' 字符的函数:

      public static String xmlEscape(String s) {
          StringBuilder sb = new StringBuilder(
              (int)Math.min(Integer.MAX_VALUE, s.length() * 5L / 4));
          for (int i = 0; i < s.length(); i++) {
              char c = s.charAt(i);
              if (c == '<') sb.append("&lt;");
              else if (c == '>') sb.append("&gt;");
              else if (c == '&') sb.append("&amp;");
              else if (c == '"') sb.append("&quot;");
              else if (c == '\'') sb.append("&#039;");
              else sb.append(c);
          }
          return sb.toString();
      }
      

      StringBuilder 数组的初始大小比输入字符串大一点,以便容纳原始文本和可能的替换。输出文本在该预分配缓冲区中累积,并且很可能在循环期间不需要任何额外的内存分配。

      如果上述函数在 String 而不是 StringBuilder 中累积输出,则每次处理单个字符时都会再次复制 整个 输出,从而将其降级为二次(即,糟糕! ) 性能。

      关于第二个问题:

      SCJP指南中的说法有误吗?

      坦率地说,是的。说会有“字符串池中被遗弃的字符串对象”是极其误导的。据我所知,术语“字符串池”仅指String.intern() 方法使用的实习池。只有当 ClassLoader 加载一个类并将字符串字面量常量从源代码加载到内存时,才会将字符串自动放入实习池。

      在运行时操作 String 对象肯定不会将额外的对象放入实习池(除非你故意调用.intern())。

      SCJP 指南应该说的是:

      String 对象是不可变的,因此如果您选择对 String 对象进行大量操作,您最终会在 heap 中得到大量废弃的 String 对象。

      堆上的废弃对象并不是最大的问题,因为垃圾收集器会迅速吃掉它们。在进行多次操作时使用 StringBuilders 的真正原因是首先避免不必要的字符复制。如@jmiserez 的基准所示,这对性能产生了巨大影响。

      【讨论】:

      • 不是每个append("&amp;lt;") 都会在字符串池上创建一个&amp;lt; 对象吗?我的问题更多是针对正在创建的String 池对象?谢谢
      • @AmanArora 不,绝对不是,无论您附加到 String 还是 StringBuilder 都是如此。加载类时,字符串文字一次加载到内存中。因为它们是恒定的,所以无需每次使用或每次调用都创建新的。
      猜你喜欢
      • 1970-01-01
      • 2021-09-03
      • 2012-10-31
      • 1970-01-01
      • 2015-06-07
      • 1970-01-01
      • 2013-04-03
      • 2011-11-25
      • 2019-02-25
      相关资源
      最近更新 更多