1. 程式人生 > >Android 效能優化之String篇

Android 效能優化之String篇

Android 效能優化之 String篇

關於String相關知識都是老掉牙的東西了,但我們經常可能在不經意的String 字串拼接的情況下浪費記憶體,影響效能,也常常會成為觸發記憶體OOM的最後一步。
所以本文對String字串進行深度解析,有助於我們日常開發中提高程式的效能,解決因String 而導致的效能問題。

首先我們先回顧一下String型別的本質

String型別的本質

先看一下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. 
 * @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;

開啟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.”。

這句話總結歸納了String的一個最重要的特點:

String是值不可變(immutable)的常量,是執行緒安全的(can be shared)。
接下來,String類使用了final修飾符,表明了String類的第二個特點:String類是不可繼承的。

String類表示字串。java程式中的所有字串,如“ABC”,是實現這個類的例項

字串是常量,它們的值不能被建立後改變。支援可變字串字串緩衝區。因為字串物件是不可改變的,所以它們可以被共享。例如:

String str = "abc";

相當於

String s = new String("abc"
);

這裡實際上建立了兩個String物件,一個是”abc”物件,儲存在常量空間中,一個是使用new關鍵字為物件s申請的空間,儲存引用地址。

在執行到雙引號包含字串的語句時,JVM會先到常量池裡查詢,如果有的話返回常量池裡的這個例項的引用,否則的話建立一個新例項並置入常量池裡,如上面所示,str 和 s 指向同一個引用.

String的定義方法歸納起來總共為以下四種方式:

  • 直接使用”“引號建立;
  • 使用new String()建立;
  • 使用new String(“abcd”)建立以及其他的一些過載建構函式建立;
  • 使用過載的字串連線操作符+建立。

常量池

在討論String的一些本質,先了解一下常量池的概念java中的常量池(constant pool)技術,是為了方便快捷地建立某些物件而出現的,當需要一個物件時,就可以從池中取一個出來(如果池中沒有則建立一個),則在需要重複重複建立相等變數時節省了很多時間。常量池其實也就是一個記憶體空間,不同於使用new關鍵字建立的物件所在的堆空間。
在編譯期被確定,並被儲存在已編譯的.class檔案中的一些資料。它包括了關於類、方法、介面等中的常量,也包括字串常量。常量池還具備動態性(java.lang.String.intern()),執行期間可以將新的常量放入池中。

常量池是為了避免頻繁的建立和銷燬物件而影響系統性能,其實現了物件的共享。

java中基本型別的包裝類的大部分都實現了常量池技術,
即Byte,Short,Integer,Long,Character,Boolean;

Java String物件和字串常量的關係?

JAVA中所有的物件都存放在堆裡面,包括String物件。字串常量儲存在JAVA的.class檔案的常量池中,在編譯期就確定好了。

比如我們通過以下程式碼塊:

String s = new String( "myString" );

其中字串常量是”myString”,在編譯時被儲存在常量池的某個位置。在執行階段,虛擬機發現字串常量”myString”,它會在一個內部字串常量列表中查詢,如果沒有找到,那麼會在堆裡面建立一個包含字元序列[myString]的String物件s1,然後把這個字元序列和對應的String物件作為名值對( [myString], s1 )儲存到內部字串常量列表中。如下圖所示:

image

如果虛擬機器後面又發現了一個相同的字串常量myString,它會在這個內部字串常量列表內找到相同的字元序列,然後返回對應的String物件的引用。維護這個內部列表的關鍵是任何特定的字元序列在這個列表上只出現一次。

例如,String s2 = “myString”,執行時s2會從內部字串常量列表內得到s1的返回值,所以s2和s1都指向同一個String物件。但是String物件s在堆裡的一個不同位置,所以和s1不相同。

JAVA中的字串常量可以作為String物件使用,字串常量的字元序列本身是存放在常量池中,在字串內部列表中每個字串常量的字元序列對應一個String物件,實際使用的就是這個物件。

String 在 JVM 的儲存結構

String 在 JVM 的儲存結構
一般而言,Java 物件在虛擬機器的結構如下:
物件頭(object header):8 個位元組
Java 原始型別資料:如 int, float, char 等型別的資料,各型別資料佔記憶體如 表 1. Java 各資料型別所佔記憶體.
引用(reference):4 個位元組
填充符(padding)

如果對於 String(JDK 6)的成員變數宣告如下:

private final char value[]; 
  private final int offset; 
  private final int count; 
  private int hash;

JDK6字串記憶體佔用的計算方式:

首先計算一個空的 char 陣列所佔空間,在 Java 裡陣列也是物件,因而陣列也有物件頭,故一個數組所佔的空間為物件頭所佔的空間加上陣列長度,即 8 + 4 = 12 位元組 , 經過填充後為 16 位元組。

那麼一個空 String 所佔空間為:

物件頭(8 位元組)+ char 陣列(16 位元組)+ 3 個 int(3 × 4 = 12 位元組)+1 個 char 陣列的引用 (4 位元組 ) = 40 位元組。

因此一個實際的 String 所佔空間的計算公式如下:

8*( ( 8+12+2*n+4+12)+7 ) / 8 = 8*(int) ( ( ( (n) *2 )+43) /8 )

其中,n 為字串長度。

String 方法很多時候我們移動客戶端常用於文字分析及大量字串處理,
比如高頻率的拼接字串,Log日誌輸出,會對記憶體效能造成一些影響。可能導致記憶體佔用太大甚至OOM。
頻繁的字串拼接,使用StringBuffer或者StringBuilder代替String,可以在一定程度上避免OOM和記憶體抖動。

String 一些提高效能方法

String的contact()方法

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);
    }

這是concat()的原始碼,它看上去就是一個數字拷貝形式,我們知道陣列的處理速度是非常快的,但是由於該方法最後是這樣的:return new String(0, count + otherLen, buf);這同樣也建立了10W個字串物件,這是它變慢的根本原因。

String的intern()方法

當呼叫 intern 方法時,如果池已經包含一個等於此 String 物件的字串(該物件由 equals(Object) 方法確定),則返回池中的字串。否則,將此 String 物件新增到池中,並且返回此 String 物件的引用。

例如:

“abc”.intern()方法的返回值還是字串”abc”,表面上看起來好像這個方法沒什麼用處。但實際上,它做了個小動作:
檢查字串池裡是否存在”abc”這麼一個字串,如果存在,就返回池裡的字串;如果不存在,該方法會把”abc”新增到字串池中,然後再返回它的引用。

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

過多得使用 intern()將導致 PermGen 過度增長而最後返回 OutOfMemoryError,因為垃圾收集器不會對被快取的 String 做垃圾回收,所以如果使用不當會造成記憶體洩露。

關於擷取字串方法的效能比較

  • 對於從大文字中擷取少量字串的應用,String.substring()將會導致記憶體的過度浪費。
  • 對於從一般文字中擷取一定數量的字串,擷取的字串長度總和與原始文字長度相差不大,現有的 String.substring()設計恰好可以共享原始文字從而達到節省記憶體的目的。

使用StringBuilder 提高效能

在拼接動態字串時,儘量用 StringBuffer 或 StringBuilder的 append,這樣可以減少構造過多的臨時 String 物件。但是如何正確的使用StringBuilder呢?

初始合適的長度

StringBuilder繼承AbstractStringBuilder,開啟AbstractStringBuilder的原始碼

/**
 * 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;

我們可以看到
StringBuilder的內部有一個char[], 不斷的append()就是不斷的往char[]裡填東西的過程。
new StringBuilder(),並且 時char[]的預設長度是16,

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;
    }

然後如果StringBuilder的剩餘容量,無法新增全部內容,如果要append第17個字元,怎麼辦?可以看到enlargeBuffer函式,用System.arraycopy成倍複製擴容!導致記憶體的消耗,增加GC的壓力。
這要是在高頻率的回撥或迴圈下,對記憶體和效能影響非常大,或者引發OOM。

同時StringBuilder的toString方法,也會造成char陣列的浪費。

public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

我們的優化方法是StringBuilder在append()的時候,不是直接往char[]裡塞東西,而是先拿一個String[]把它們都存起來,到了最後才把所有String的length加起來,構造一個合理長度的StringBuilder。

重用的StringBuilder

/**
     * 參考BigDecimal, 可重用的StringBuilder, 節約StringBuilder內部的char[]
     *
     * 參考下面的示例程式碼將其儲存為ThreadLocal.
     *
     * <pre>
     * private static final ThreadLocal<StringBuilderHelper> threadLocalStringBuilderHolder = new ThreadLocal<StringBuilderHelper>() {
     *  &#64;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;
        }
    }

這個做法來源於JDK裡的BigDecimal類

Log真正需要時候做拼接

對於那些需要高頻率拼接列印Log的場景,封裝一個LogUtil,來控制日誌在真正需要輸出時候才去做拼接。比如:

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);
        }
    }

總結幾個簡單題目

String s1 = new String("s1") ; 
String s2 = new String("s1") ;

上面建立了幾個String物件?

答案:3個 ,編譯期Constant Pool中建立1個,執行期heap中建立2個.

String s1 = "s1";  
String s2 = s1;  
s2 = "s2";

s1指向的物件中的字串是什麼?

答案: “s1”

總結

關於String 效能優化,瞭解String 在 JVM 中的儲存結構,String 的 API 使用可能造成的效能問題以及解決方法,就總結到這。若有錯漏,歡迎補充。

Android 之美 從0到1