您好,登錄后才能下訂單哦!
這篇文章將為大家詳細講解有關深入了解Java中字符串的用法,小編覺得挺實用的,因此分享給大家做個參考,希望大家閱讀完這篇文章后可以有所收獲。
初學Java時我們已經知道Java中可以分為兩大數據類型,分別為基本數據類型和引用數據類型。而在這兩大數據類型中有一個特殊的數據類型String,String屬于引用數據類型,但又有區別于其它的引用數據類型。可以說它是數據類型中的一朵奇葩。那么,本篇文章我們就來深入的認識一下Java中的String字符串。
在常量池部分我們了解了三種常量池,分別為:字符串常量池、Class文件常量池以及運行時常量池。而字符串的內存分配則和字符串常量池有著莫大的關系。
我們知道,實例化一個字符串可以通過兩種方法來實現,第一種最常用的是通過字面量賦值的方式,另一種是通過構造方法傳參的方式。代碼如下:
String str1="abc"; String str2=new String("abc");復制代碼
這兩種方式在內存分配上有什么不同呢? 相信大家在初學Java的時候老師都有給我們講解過:
1.通過字面量賦值的方式創建String,只會在字符串常量池中生成一個String對象。 2.通過構造方法傳入String參數的方式會在堆內存和字符串常量池中各生成一個String對象,并將堆內存上String的引用放入棧。
這樣的回答正確嗎?至少在現在看來并不完全正確,因為它完全取決于使用的Java版本。上一篇文章《溫故知新--你不知道的JVM內存分配》談到HotSpot虛擬機在不同的JDK上對于字符串常量池的實現是不同的,摘錄如下:
在JDK7以前,字符串常量池在方法區(永久代)中,此時常量池中存放的是字符串對象。而在JDK7中,字符串常量池從方法區遷移到了堆內存,同時將字符串對象存到了Java堆,字符串常量池中只是存入了字符串對象的引用。
這句話應該怎么理解呢?我們以String str1=new String("abc")為例來分析:
先來分析一下JDK6的內存分配情況,如下圖所示:
當調用new String("abc")后,會在Java堆與常量池中各生成一個“abc”對象。同時,將str1指向堆中的“abc”對象。
而在JDK7及以后版本中,由于字符串常量池被移到了堆內存,所以內存分配方式也有所不同,如下圖所示:
當調用了new String("abc")后,會在堆內存中創建兩個“abc"對象,str1指向其中一個”abc"對象,而常量池中則會生成一個“abc"對象的引用,并指向另一個”abc"對象。
至于Java中為什么要這么設計,我們在上篇文章中也已經解釋了: 因為String是Java中使用最頻繁的一種數據類型,為了節省程序內存提高程序性能,Java的設計者們開辟了一塊字符串常量池區域,這塊區域是是所有類共享的,每個虛擬機只有一個字符串常量池。因此,在使用字面量方式賦值的時候,如果字符串常量池中已經有了該字符串,則不會在堆內存中重新創建對象,而是直接將其指向了字符串常量池中的對象。
在了解了String的內存分配之后,我們需要再來認識一下String中一個很重要的方法:String.intern()。
很多讀者可能對于這一方法并不是太了解,但并不代表他不重要。我們先來看一下intern()方法的源碼:
/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class {@code String}. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this {@code String} object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this {@code String} object is added to the * pool and a reference to this {@code String} object is returned. * <p> * It follows that for any two strings {@code s} and {@code t}, * {@code s.intern() == t.intern()} is {@code true} * if and only if {@code s.equals(t)} is {@code true}. * <p> * All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * <cite>The Java™ Language Specification</cite>. * * @return a string that has the same contents as this string, but is * guaranteed to be from a pool of unique strings. */ public native String intern();復制代碼
emmmmm....居然是一個native方法,不過沒關系,即使看不到源碼我們也能從其注釋中得到一些信息:當調用intern方法的時候,如果字符串常量池中已經包含了一個等于該String對象的字符串,則直接返回字符串常量池中該字符串的引用。否則,會將該字符串對象包含的字符串添加到常量池,并返回此對象的引用。
了解了intern方法的用途之后,來看一個簡單的列子:
public class Test { public static void main(String[] args) { String str1 = "hello world"; String str2 = new String("hello world"); String str3=str2.intern(); System.out.println("str1 == str2:"+(str1 == str2)); System.out.println("str1 == str3:"+(str1 == str3)); } }復制代碼
上面的一段代碼會輸出什么?編譯運行之后如下:
如果理解了intern方法就很容易解釋這個結果了,從上面截圖中可以看到,我們的運行環境是JDK8。
String str1 = "hello world"; 這行代碼會首先在Java堆中創建一個對象,并將該對象的引用放入字符串常量池中,str1指向常量池中的引用。
String str2 = new String("hello world");這行代碼會通過new來實例化一個String對象,并將該對象的引用賦值給str2,然后檢測字符串常量池中是否已經有了與“hello world”相等的對象,如果沒有,則會在堆內存中再生成一個值為"hello world"的對象,并將其引用放入到字符串常量池中,否則,不會再去創建。這里,第一行代碼其實已經在字符串常量池中保存了“hello world”字符串對象的引用,因此,第二行代碼就不會再次向常量池中添加“hello world"的引用。
String str3=str2.intern(); 這行代碼會首先去檢測字符串常量池中是否已經包含了”hello world"的String對象,如果有則直接返回其引用。而在這里,str2.intern()其實剛好返回了第一行代碼中生成的“hello world"對象。
因此【System.out.println("str1 == str3:"+(str1 == str3));】這行代碼會輸出true.
如果切到JDK6,其打印結果與上一致,至于原因讀者可以自行分析。
上一節中我們通過一個例子認識了intern()方法的作用,接下來,我們對上述例子做一些修改:
public class Test { public static void main(String[] args) { String str1=new String("he")+new String("llo"); String str2=str1.intern(); String str3="hello"; System.out.println("str1 == str2:"+(str1 == str2)); System.out.println("str2 == str3:"+(str2 == str3)); } }復制代碼
先別急著看下方答案,思考一下在JDK7(或JDK7之后)及JDK6上會輸出什么結果?
我們先來看下我們先來看下JDK8的運行結果:
通過運行程序發現輸出的兩個結果都是true,這是為什么呢?我們通過一個圖來分析:
String str1=new String("he")+new String("llo"); 這行代碼中new String("he")和new String("llo")會在堆上生成四個對象,因為與本例無關,所以圖上沒有畫出,new String("he")+new String("llo")通過”+“號拼接后最終會生成一個"hello"對象并賦值給str1。
String str2=str1.intern(); 這行代碼會首先檢測字符串常量池,發現此時還沒有存在與”hello"相等的字符串對象的引用,而在檢測堆內存時發現堆中已經有了“hello"對象,遂將堆中的”hello"對象的應用放入字符串常量池中。
String str3="hello"; 這行代碼發現字符串常量池中已經存在了“hello"對象的引用,因此將str3指向了字符串常量池中的引用。
此時,我們發現str1、str2、str3指向了堆中的同一個”hello"對象,因此,就有了上邊兩個均為true的輸出結果。
我們將運行環境切換到JDK6,來看下其輸出結果:
有點意思!相同的代碼在不同的JDK版本上輸出結果竟然不相等。這是怎么回事呢?我們還通過一張圖來分析:
String str1=new String("he")+new String("llo"); 這行代碼會通過new String("he")和new String("llo")會分別在Java堆與字符串常量池中各生成兩個String對象,由于與本例無關,所以并沒有在圖中畫出。而new String("he")+new String("llo")通過“+”號拼接后最終會在Java堆上生成一個"hello"對象,并將其賦值給了str1。
String str2=str1.intern(); 這行代碼檢測到字符串常量池中還沒有“hello"對象,因此將堆中的”hello“對象復制到了字符串常量池,并將其賦值給str2。
String str3="hello"; 這行代碼檢測到字符串常量池中已經有了”hello“對象,因此直接將str3指向了字符串常量池中的”hello“對象。 此時str1指向的是Java堆中的”hello“對象,而str2和str3均指向了字符串常量池中的對象。因此,有了上面的輸出結果。
通過這兩個例子,相信大家因該對String的intern()方法有了較深的認識。那么intern()方法具體在開發中有什么用呢?推薦大家可以看下美團技術團隊的一篇文章《深入解析String#intern》中舉的兩個例子。限于篇幅,本文不再舉例分析。
前兩節我們認識了String的內存分配以及它的intern()方法,這兩節內容其實都是對String內存的分析。到目前為止,我們還并未認識String類的結構以及它的一些特性。那么本節內容我們就此來分析。先通過一段代碼來大致了解一下String類的結構(代碼取自jdk8):
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ private final char value[]; /** Cache the hash code for the string */ private int hash; // Default to 0 // ...}復制代碼
可以看到String類實現了Serializable接口、Comparable接口以及CharSequence接口,意味著它可以被序列化,同時方便我們排序。另外,String類還被聲明為了final類型,這意味著String類是不能被繼承的。而在其內部維護了一個char數組,說明String是通過char數組來實現的,同時我們注意到這個char數組也被聲明為了final,這也是我們常說的String是不可變的原因。
Java的設計團隊一直在對String類進行優化,這就導致了不同jdk版本上String類的實現有些許差異,只是我們使用上并無感知。下圖列出了jdk6-jdk9中String源碼的一些變化。
可以看到在Java6之前String中維護了一個char 數組、一個偏移量 offset、一個字符數量 count以及一個哈希值 hash。 String對象是通過 offset 和 count 兩個屬性來定位 char[] 數組,獲取字符串。這么做可以高效、快速地共享數組對象,同時節省內存空間,但這種方式很有可能會導致內存泄漏。
在Java7和Java8的版本中移除了 offset 和 count 兩個變量了。這樣的好處是String對象占用的內存稍微少了些,同時 String.substring 方法也不再共享 char[],從而解決了使用該方法可能導致的內存泄漏問題。
從Java9開始,String中的char數組被byte[]數組所替代。我們知道一個char類型占用兩個字節,而byte占用一個字節。因此在存儲單字節的String時,使用char數組會比byte數組少一個字節,但本質上并無任何差別。 另外,注意到在Java9的版本中多了一個coder,它是編碼格式的標識,在計算字符串長度或者調用 indexOf() 函數時,需要根據這個字段,判斷如何計算字符串長度。coder 屬性默認有 0 和 1 兩個值, 0 代表Latin-1(單字節編碼),1 代表 UTF-16 編碼。如果 String判斷字符串只包含了 Latin-1,則 coder 屬性值為 0 ,反之則為 1。
在本節內容的開頭我們已經知道了字符串的不可變性。那么為什么我們還可以使用String的substring方法進行裁剪,甚至可以直接使用”+“連接符進行字符串的拼接呢?
關于substring的實現,其實我們直接深入String的源碼查看即可,源碼如下:
public String substring(int beginIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = value.length - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); }復制代碼
從這段代碼中可以看出,其實字符串的裁剪是通過實例化了一個新的String對象來實現的。所以,如果在項目中存在大量的字符串裁剪的代碼應盡量避免使用String,而是使用性能更好的StringBuilder或StringBuffer來處理。
關于字符串的拼接有很多實現方法,在這里我們舉三個例子來進行一個性能對比,分別如下:
使用”+“操作符拼接字符串
public class Test { private static final int COUNT=50000; public static void main(String[] args) { String str=""; for(int i=0;i<COUNT;i++) { str=str+"abc"; } }復制代碼
使用String的concat()方法拼接
public class Test { private static final int COUNT=50000; public static void main(String[] args) { String str=""; for(int i=0;i<COUNT;i++) { str=str+"abc"; } }復制代碼
使用StringBuilder的append方法拼接
public class Test { private static final int COUNT=50000; public static void main(String[] args) { StringBuilder str=new StringBuilder(); for(int i=0;i<COUNT;i++) { str.append("abc"); } }復制代碼
如上代碼,通過三種方法分別進行了50000次字符串拼接,每種方法分別運行了20次。統計耗時,得到以下表格:
拼接方法 | 最小用時(ms) | 最大用時(ms) | 平均用時(ms) |
---|---|---|---|
"+"操作符 | 4868 | 5146 | 4924 |
String的concat方法 | 2227 | 2456 | 2296 |
StringBuilder的append方法 | 4 | 12 | 6.6 |
從以上數據中可以很直觀的看到”+“操作符的性能是最差的,平均用時達到了4924ms。其次是String的concat方法,平均用時也在2296ms。而表現最為優秀的是StringBuilder的append方法,它的平均用時竟然只有6.6ms。這也是為什么在開發中不建議使用”+“操作符進行字符串拼接的原因。
”+“操作符的實現原理由于”+“操作符是由JVM來完成的,我么無法直接看到代碼實現。不過Java為我們提供了一個javap的工具,可以幫助我們將Class文件進行一個反匯編,通過匯編指令,大致可以看出”+“操作符的實現原理。
public class Test { private static final int COUNT=50000; public static void main(String[] args) { for(int i=0;i<COUNT;i++) { str=str+"abc"; } }復制代碼
把上邊這段代碼編譯后,執行javap,得到如下結果:
注意圖中的”11:“行指令處實例化了一個StringBuilder,在"19:"行處調用了StringBuilder的append方法,并在第”27:"行處調用了String的toString()方法。可見,JVM在進行”+“字符串拼接時也是用了StringBuilder來實現的,但為什么與直接使用StringBuilder的差距那么大呢?其實,只要我們將上邊代碼轉換成虛擬機優化后的代碼一看便知:
public class Test { private static final int COUNT=50000; public static void main(String[] args) { String str=""; for(int i=0;i<COUNT;i++) { str=new StringBuilder(str).append("abc").toString(); } }復制代碼
可見,優化后的代碼雖然也是用的StringBuilder,但是StringBuilder卻是在循環中實例化的,這就意味著循環了50000次,創建了50000個StringBuilder對象,并且調用了50000次toString()方法。怪不得用了這么長時間!!!
String的concat方法的實現原理關于concat方法可以直接到String內部查看其源碼,如下:
public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) { return this; } int len = value.length; char buf[] = Arrays.copyOf(value, len + otherLen); str.getChars(buf, len); return new String(buf, true); }復制代碼
可以看到,在concat方法中使用Arrays的copyOf進行了一次數組拷貝,接下來又通過getChars方法再次進行了數組拷貝,最后通過new實例化了String對象并返回。這也意味著每調用一次concat都會生成一個String對象,但相比”+“操作符卻省去了toString方法。因此,其性能要比”+“操作符好上不少。
至于StringBuilder其實也沒必要再去分析了,畢竟”+“操作符也是基于StringBuilder實現的,只不過拼接過程中”+“操作符創建了大量的對象。而StringBuilder拼接時僅僅創建了一個StringBuilder對象。
關于深入了解Java中字符串的用法就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。