Android 性能优化之String篇
NorTrower
8年前
<p>关于String相关知识都是老掉牙的东西了,但我们经常可能在不经意的String 字符串拼接的情况下浪费内存,影响性能,也常常会成为触发内存OOM的最后一步。</p> <p>所以本文对String字符串进行深度解析,有助于我们日常开发中提高程序的性能,解决因String 而导致的性能问题。</p> <p>首先我们先回顾一下String类型的本质</p> <h2><strong>String类型的本质</strong></h2> <p>先看一下String的头部源码</p> <pre> <code class="language-java">/** Strings are constant; their values cannot be changed after they * are created. String buffers support mutable strings. * Because String objects are immutable they can be shared. * @see StringBuffer * @see StringBuilder * @see Charset * @since 1.0 */ public final class String implements Serializable, Comparable<String>, CharSequence { private static final long serialVersionUID = -6849794470754667710L; private static final char REPLACEMENT_CHAR = (char) 0xfffd;</code></pre> <p>打开String的源码,类注释中有这么一段话“Strings are constant; their values cannot be changed after they are created. String buffers support mutable strings.Because String objects are immutable they can be shared.”。</p> <p>这句话总结归纳了String的一个最重要的特点:</p> <p>String是值不可变(immutable)的常量,是线程安全的(can be shared)。</p> <p>接下来,String类使用了final修饰符,表明了String类的第二个特点:String类是不可继承的。</p> <p>String类表示字符串。java程序中的所有字符串,如“ABC”,是实现这个类的实例</p> <p>字符串是常量,它们的值不能被创建后改变。支持可变字符串字符串缓冲区。因为字符串对象是不可改变的,所以它们可以被共享。例如:</p> <pre> <code class="language-java">String str = "abc";</code></pre> <p>相当于</p> <pre> <code class="language-java">String s = new String("abc");</code></pre> <p>这里实际上创建了两个String对象,一个是”abc”对象,存储在常量空间中,一个是使用new关键字为对象s申请的空间,存储引用地址。</p> <p>在执行到双引号包含字符串的语句时,JVM会先到常量池里查找,如果有的话返回常量池里的这个实例的引用,否则的话创建一个新实例并置入常量池里,如上面所示,str 和 s 指向同一个引用.</p> <p>String的定义方法归纳起来总共为以下四种方式:</p> <ul> <li>直接使用”“引号创建;</li> <li>使用new String()创建;</li> <li>使用new String(“abcd”)创建以及其他的一些重载构造函数创建;</li> <li>使用重载的字符串连接操作符+创建。</li> </ul> <h2><strong>常量池</strong></h2> <p>在讨论String的一些本质,先了解一下常量池的概念java中的常量池(constant pool)技术,是为了方便快捷地创建某些对象而出现的,当需要一个对象时,就可以从池中取一个出来(如果池中没有则创建一个),则在需要重复重复创建相等变量时节省了很多时间。常量池其实也就是一个内存空间,不同于使用new关键字创建的对象所在的堆空间。</p> <p>在编译期被确定,并被保存在已编译的.class文件中的一些数据。它包括了关于类、方法、接口等中的常量,也包括字符串常量。常量池还具备动态性(java.lang.String.intern()),运行期间可以将新的常量放入池中。</p> <p>常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。</p> <p>java中基本类型的包装类的大部分都实现了常量池技术,</p> <p>即Byte,Short,Integer,Long,Character,Boolean;</p> <h2><strong>Java String对象和字符串常量的关系?</strong></h2> <p>JAVA中所有的对象都存放在堆里面,包括String对象。字符串常量保存在JAVA的.class文件的常量池中,在编译期就确定好了。</p> <p>比如我们通过以下代码块:</p> <pre> <code class="language-java">String s = new String( "myString" );</code></pre> <p>其中字符串常量是”myString”,在编译时被存储在常量池的某个位置。在运行阶段,虚拟机发现字符串常量”myString”,它会在一个内部字符串常量列表中查找,如果没有找到,那么会在堆里面创建一个包含字符序列[myString]的String对象s1,然后把这个字符序列和对应的String对象作为名值对( [myString], s1 )保存到内部字符串常量列表中。如下图所示:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/8e45ffc757883b66dd99ae2206f38a3f.jpg"></p> <p>如果虚拟机后面又发现了一个相同的字符串常量myString,它会在这个内部字符串常量列表内找到相同的字符序列,然后返回对应的String对象的引用。维护这个内部列表的关键是任何特定的字符序列在这个列表上只出现一次。</p> <p>例如,String s2 = “myString”,运行时s2会从内部字符串常量列表内得到s1的返回值,所以s2和s1都指向同一个String对象。但是String对象s在堆里的一个不同位置,所以和s1不相同。</p> <p>JAVA中的字符串常量可以作为String对象使用,字符串常量的字符序列本身是存放在常量池中,在字符串内部列表中每个字符串常量的字符序列对应一个String对象,实际使用的就是这个对象。</p> <p>这个目前网上阐述的最多关于这个String对象和字符串常量的关系,网上各有说法,但是这个猜想也是有问题的</p> <h2><strong>String 在 JVM 的存储结构</strong></h2> <p>String 在 JVM 的存储结构</p> <p>一般而言,Java 对象在虚拟机的结构如下:</p> <p>对象头(object header):8 个字节</p> <p>Java 原始类型数据:如 int, float, char 等类型的数据,各类型数据占内存如 表 1. Java 各数据类型所占内存.</p> <p>引用(reference):4 个字节</p> <p>填充符(padding)</p> <p>如果对于 String(JDK 6)的成员变量声明如下:</p> <pre> <code class="language-java">private final char value[]; private final int offset; private final int count; private int hash;</code></pre> <p>JDK6字符串内存占用的计算方式:</p> <p>首先计算一个空的 char 数组所占空间,在 Java 里数组也是对象,因而数组也有对象头,故一个数组所占的空间为对象头所占的空间加上数组长度,即 8 + 4 = 12 字节 , 经过填充后为 16 字节。</p> <p>那么一个空 String 所占空间为:</p> <p>对象头(8 字节)+ char 数组(16 字节)+ 3 个 int(3 × 4 = 12 字节)+1 个 char 数组的引用 (4 字节 ) = 40 字节。</p> <p>因此一个实际的 String 所占空间的计算公式如下:</p> <p>8*( ( 8+12+2*n+4+12)+7 ) / 8 = 8*(int) ( ( ( (n) *2 )+43) /8 )</p> <p>其中,n 为字符串长度。</p> <p>String 方法很多时候我们移动客户端常用于文本分析及大量字符串处理,比如高频率的拼接字符串,Log日志输出,会对内存性能造成一些影响。可能导致内存占用太大甚至OOM。</p> <p>频繁的字符串拼接,使用StringBuffer或者StringBuilder代替String,可以在一定程度上避免OOM和内存抖动。</p> <h2><strong>String 一些提高性能方法</strong></h2> <h3><strong>String的contact()方法</strong></h3> <pre> <code class="language-java">public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) { return this; } char buf[] = new char[count + otherLen]; getChars(0, count, buf, 0); str.getChars(0, otherLen, buf, count); return new String(0, count + otherLen, buf); }</code></pre> <p>这是concat()的源码,它看上去就是一个数字拷贝形式,我们知道数组的处理速度是非常快的,但是由于该方法最后是这样的:return new String(0, count + otherLen, buf);这同样也创建了10W个字符串对象,这是它变慢的根本原因。</p> <h3><strong>String的intern()方法</strong></h3> <p>当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。</p> <p>例如:</p> <p>“abc”.intern()方法的返回值还是字符串”abc”,表面上看起来好像这个方法没什么用处。但实际上,它做了个小动作:</p> <p>检查字符串池里是否存在”abc”这么一个字符串,如果存在,就返回池里的字符串;如果不存在,该方法会把”abc”添加到字符串池中,然后再返回它的引用。</p> <pre> <code class="language-java">String s1 = new String("111"); String s2 = "sss111"; String s3 = "sss" + "111"; String s4 = "sss" + s1; System.out.println(s2 == s3); //true System.out.println(s2 == s4); //false System.out.println(s2 == s4.intern()); //true</code></pre> <p>过多得使用 intern()将导致 PermGen 过度增长而最后返回 OutOfMemoryError,因为垃圾收集器不会对被缓存的 String 做垃圾回收,所以如果使用不当会造成内存泄露。</p> <h2><strong>关于截取字符串方法的性能比较</strong></h2> <ul> <li>对于从大文本中截取少量字符串的应用,String.substring()将会导致内存的过度浪费。</li> <li>对于从一般文本中截取一定数量的字符串,截取的字符串长度总和与原始文本长度相差不大,现有的 String.substring()设计恰好可以共享原始文本从而达到节省内存的目的。</li> </ul> <h2><strong>使用StringBuilder 提高性能</strong></h2> <p>在拼接动态字符串时,尽量用 StringBuffer 或 StringBuilder的 append,这样可以减少构造过多的临时 String 对象。但是如何正确的使用StringBuilder呢?</p> <h3><strong>初始合适的长度</strong></h3> <p>StringBuilder继承AbstractStringBuilder,打开AbstractStringBuilder的源码</p> <pre> <code class="language-java">/** * A modifiable {@link CharSequence sequence of characters} for use in creating * and modifying Strings. This class is intended as a base class for * {@link StringBuffer} and {@link StringBuilder}. * @see StringBuffer * @see StringBuilder * @since 1.5 */ abstract class AbstractStringBuilder { static final int INITIAL_CAPACITY = 16; private char[] value; private int count; private boolean shared;</code></pre> <p>我们可以看到</p> <p>StringBuilder的内部有一个char[], 不断的append()就是不断的往char[]里填东西的过程。</p> <p>new StringBuilder(),并且 时char[]的默认长度是16,</p> <pre> <code class="language-java">private void enlargeBuffer(int min) { int newCount = ((value.length >> 1) + value.length) + 2; char[] newData = new char[min > newCount ? min : newCount]; System.arraycopy(value, 0, newData, 0, count); value = newData; shared = false; }</code></pre> <p>然后如果StringBuilder的剩余容量,无法添加全部内容,如果要append第17个字符,怎么办?可以看到enlargeBuffer函数,用System.arraycopy成倍复制扩容!导致内存的消耗,增加GC的压力。</p> <p>这要是在高频率的回调或循环下,对内存和性能影响非常大,或者引发OOM。</p> <p>同时StringBuilder的toString方法,也会造成char数组的浪费。</p> <pre> <code class="language-java">public String toString() { // Create a copy, don't share the array return new String(value, 0, count); }</code></pre> <p>我们的优化方法是StringBuilder在append()的时候,不是直接往char[]里塞东西,而是先拿一个String[]把它们都存起来,到了最后才把所有String的length加起来,构造一个合理长度的StringBuilder。</p> <h2><strong>重用的StringBuilder</strong></h2> <pre> <code class="language-java">/** * 参考BigDecimal, 可重用的StringBuilder, 节约StringBuilder内部的char[] * * 参考下面的示例代码将其保存为ThreadLocal. * * <pre> * private static final ThreadLocal<StringBuilderHelper> threadLocalStringBuilderHolder = new ThreadLocal<StringBuilderHelper>() { * @Override * protected StringBuilderHelper initialValue() { * return new StringBuilderHelper(256); * } * }; * * StringBuilder sb = threadLocalStringBuilderHolder.get().resetAndGetStringBuilder(); * * </pre> */ public class StringBuilderHolder { private final StringBuilder sb; public StringBuilderHolder(int capacity) { sb = new StringBuilder(capacity); } /** * 重置StringBuilder内部的writerIndex, 而char[]保留不动. */ public StringBuilder resetAndGetStringBuilder() { sb.setLength(0); return sb; } }</code></pre> <p>这个做法来源于JDK里的BigDecimal类</p> <h2><strong>Log真正需要时候做拼接</strong></h2> <p>对于那些需要高频率拼接打印Log的场景,封装一个LogUtil,来控制日志在真正需要输出时候才去做拼接。比如:</p> <pre> <code class="language-java">public void log(String msg ){ if (BuildConfig.DEBUG){ Log.e("TAG","Explicit concurrent mark sweep " + "GC freed 10477(686KB) AllocSpace objects, 0(0B) " + "LOS objects, 39% free, 9MB/15MB, paused 915us total 28.320ms"+msg); } }</code></pre> <h2><strong>总结几个简单题目</strong></h2> <pre> <code class="language-java">String s1 = new String("s1") ; String s2 = new String("s1") ;</code></pre> <p>上面创建了几个String对象?</p> <p>答案:3个 ,编译期Constant Pool中创建1个,运行期heap中创建2个.</p> <pre> <code class="language-java">String s1 = "s1"; String s2 = s1; s2 = "s2";</code></pre> <p>s1指向的对象中的字符串是什么?</p> <p>答案: “s1”</p> <h2><strong>总结</strong></h2> <p>关于String 性能优化,了解String 在 JVM 中的存储结构,String 的 API 使用可能造成的性能问题以及解决方法,就总结到这。若有错漏,欢迎补充。</p> <p><strong>参考文章</strong></p> <p><a href="/misc/goto?guid=4959725388499848101" rel="nofollow,noindex">https://www.ibm.com/developerworks/cn/java/j-lo-optmizestring/</a></p> <p> </p> <p>来自:http://www.androidchina.net/5940.html</p> <p> </p>