您的位置:首页 > 编程语言 > Java开发

Java String 字符串类细节探秘

2016-09-09 18:42 567 查看
一. 字符串基本知识要点

  字符串类型String是Java中最常用的引用类型。我们在使用Java字符串的时候,通常会采用两种初始化的方式:1. String str = "Hello World"; 2. String str = new String("Hello World"); 这两种方式都可以将变量初始化为java字符串类型,通过第一种方式创建的字符串又被称为字符串常量。需要注意的是,Java中的String类是一个final类,str指向的字符串对象存储于堆中,而str本身则是存储在栈中的一个引用罢了。字符串对象一旦被初始化,则不允许再次被修改。从如下String的定义中我们可以验证以上所述:

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前有final修饰,表示是final类;而其中存储的字符数组value[],也是由final修饰,表明一旦被赋值,则不允许再次修改。

  那么,使用如上两种字符串初始化的方式有什么不同呢?我们可以通过如下代码体会:

public class EqualTest {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = new String("Hello");
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}


  程序输出结果为false和true。从== 和equals的区别上,我们一般这样来总结:==比较的是两个对象的引用,对象必须一模一样;equals则比较的是对象的内容,字符串内容一致便返回true。这说明,两种初始化的方式所构造的字符串对象,内容是一致的(可以理解为values数组一致),但是却是两个不同的对象,分别存储在内存的不同位置。其实,这两种初始化方式的最大不同在于,s1被初始化在字符串常量池中,而s2则存储在堆中。那么,什么是字符串常量池呢?

  字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串池,每当代码创建字符串常量时,JVM会首先检查字符串常量池。如果字符串已经存在池中,就返回池中的实例引用。如果字符串不在池中,就会实例化一个字符串常量并放到池中。Java能够进行这样的优化是因为字符串是不可变的final类型,共享的时候不用担心数据冲突(读写不冲突,因为不能写,相当于数据库中的S锁,即共享锁)。在常量池中,任何字符串至多维护一个对象。字符串常量总是指向常量池中的一个对象。通过new操作符创建的字符串对象不指向池中的任何对象,但是可以通过使用字符串的intern()方法来指向其中的某一个。java.lang.String.intern()返回一个池字符串,就是一个在全局常量池中有了一个入口。如果该字符串以前没有在全局常量池中,那么它就会被添加到里面。

  Java String类中有很多基本的方法。主要分成以下两个部分:

  1)和value[]相关的方法:

int length(); //返回String长度,即value[]数组长度;

char charAt(int index); //返回指定位置字符;

int indexOf(int ch, int fromIndex); //从fromIndex位置开始,查找ch字符在字符串中首次出现的位置;

char[] toCharArray(); //将字符串转换成一个新的字符数组

  2)和其他字符串相关的方法:

int indexOf(String str, int fromIndex); //从fromIndex位置开始,查找str字符串在字符串中首次出现的位置;

int lastIndexOf(String str, int fromIndex); //从fromIndex位置开始,反向查找str字符串在字符串中首次出现的位置;

boolean contains(String str); //contains内部实现也是调用的indexOf,找不到则返回-1

boolean startsWith(String str); //判断字符串是否以str开头

boolean endsWith(String str); //判断字符串是否以str结尾

String replace(CharSequence target, CharSequence replacement); //使用replacement替换target

String substring(int beginIndex, int endIndex); //字符串截取,不传第二个参数则表示直接截取到字符串末尾

String[] split(String regex); // 以regex作为分割点进行字符串分割

  另外一个值得注意的细节是,String是不可变字符串对象,StringBuilder和StringBuffer是可变字符串对象(其内部的字符数组长度可变),StringBuffer线程安全,StringBuilder非线程安全。关于String的append操作,会在下面结合具体的例子进行解释。

二. 几个关于String的程序分析

2.1 intern的程序示例

  参看如下程序:

public class StringTest1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
String s1 = "hello world";
String s2 = new String("hello world");
String s3 = s2.intern();
System.out.println(s1 == s2);
System.out.println(s1 == s3);
}
}


  程序的输出是false,true。关于==和equals的区别在上面已做了详细的解释,由于s1是分配在字符串常量池中,s2则存储在堆中,因此两个对象并不是同一个对象,==操作返回false。而intern在JDK 1.7及以下,都是返回一个池字符串,该池字符串和原来的String对象的内容一致。若池中无该常量则添加,若有,则直接返回该常量的引用。因此,s1和s3是一个对象。说白了,在JVM的字符串常量池中,对于每一个字符串,只有一个共享的对象。

2.2 通过字节码进行深入分析

  当情况变得复杂的时候,参看如下程序:

public class StringTest2 {
public static void main(String[] args){
String baseStr = "base";
final String baseFinalStr = "base";
//extend
String s1 = "baseext";
String s2 = "base" + "ext";
String s3 = baseStr + "ext";
String s4 = baseFinalStr + "ext";
String s5 = new String("baseext").intern();
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1 == s4);
System.out.println(s1 == s5);
}
}


  这段程序乍一看非常复杂,里面有final String(final是限制在String对象的引用上,即该引用不能再更改所指向的String对象,String对象本身便是final类型的),还有字符串常量,以及字符串对象,和各个对象之间的“+”操作(“+”操作在下面的程序中详细解释)。那么我们不禁会问,在“+”操作的过程中,JVM到底是如何进行对象转换和操作呢?要想搞清楚这个问题,我们需要深入Bytecode一探究竟。使用javap -v XXX.class命令,可以打印出字节码文件中的符号表和指令等信息,该段程序的字节码输出如下:



  这里的constant pool指的是JVM内存结构中的运行时常量池,是方法区的一部分(参见周志明 《深入理解Java虚拟机》),我们上文提到的字符串常量池只是constant pool的一部分,除此之外,它还主要用来存储编译期生成的各种字面量和符号引用。javap -v的输出主要分为constant pool和方法体指令两部分,而指令中的操作数则是常量池中的序号。为了方便接下来的描述,我们先对常用的JVM字节码指令做一下说明:

LDC        将int, float或String型常量值从常量池中推送至栈顶;
ASTORE_<N>    Store reference into local variable,将栈顶的引用赋值给第N个局部变量;
ALOAD        将指定的引用类型本地变量推送至栈顶
INVOKE VIRTUAL  调用实例方法
INVOKE SPECIAL  调用超类构造方法等初始化方法
INVOKE STATIC   调用静态方法
NEW         创建一个对象,并将其引用值压入栈顶
DUP         复制栈顶数值并将复制值压入栈顶


  以上指令是需要仔细理解的。使用javap -v进行字节码的查看和理解可能比较困难,因为你要将 #序号 和 constant pool中的字面量不断照应已方便理解。Eclipse中提供了Bytecode Outline的插件可以很方便的查看和理解bytecode。插件的安装请自行百度,这里不再赘述。这里贴出本段代码的outline:

// access flags 0x9
public static main([Ljava/lang/String;)V
L0  //
LINENUMBER 5 L0
LDC "base"  //将"base"从常量池推送至栈顶
ASTORE 1    //赋值给baseStr变量
L1
LINENUMBER 6 L1
LDC "base"
ASTORE 2    //赋值给baseFinalStr变量
L2
LINENUMBER 8 L2
LDC "baseext"
ASTORE 3  
L3
LINENUMBER 9 L3
LDC "baseext"  //注意,这里直接将"baseext"赋值给了s2,而没有进行"+"操作!!!
ASTORE 4
L4
LINENUMBER 10 L4
NEW java/lang/StringBuilder  //创建StringBuilder对象
DUP
ALOAD 1 //将baseStr推送至栈顶
INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String; //获取baseStr的value
INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V  //将创建的StringBuilder对象初始化为上一步获得的value
LDC "ext"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;  //调用StringBuilder对象的append实例方法
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;  //调用StringBuilder对象的toString实例方法
ASTORE 5  //将toString的结果赋值给s3
L5
LINENUMBER 11 L5
LDC "baseext"  //s4也是直接赋值
ASTORE 6
L6
LINENUMBER 12 L6
NEW java/lang/String
DUP
LDC "baseext"
INVOKESPECIAL java/lang/String.<init> (Ljava/lang/String;)V
INVOKEVIRTUAL java/lang/String.intern ()Ljava/lang/String; //调用intern()方法
ASTORE 7
//===================================以下为 输出部分,可以忽略=========================================
.......
  .......
L19
LINENUMBER 17 L19
RETURN
L20 //类似于符号表,对应于local variable和变量编号
LOCALVARIABLE args [Ljava/lang/String; L0 L20 0
LOCALVARIABLE baseStr Ljava/lang/String; L1 L20 1
LOCALVARIABLE baseFinalStr Ljava/lang/String; L2 L20 2
LOCALVARIABLE s1 Ljava/lang/String; L3 L20 3
LOCALVARIABLE s2 Ljava/lang/String; L4 L20 4
LOCALVARIABLE s3 Ljava/lang/String; L5 L20 5
LOCALVARIABLE s4 Ljava/lang/String; L6 L20 6
LOCALVARIABLE s5 Ljava/lang/String; L7 L20 7
MAXSTACK = 3
MAXLOCALS = 8


  如果想深入理解,请逐行理解以上字节码程序。根据程序的分析,我们不难得出输出结果:true false true true。

  s1和s2,s4,s5都是指向字符串常量池中的同一个字符串常量。s2和s4中的“+”并没有起任何作用。String中使用 + 字符串连接符进行字符串连接时,连接操作最开始时如果都是字符串常量,编译后将尽可能多的直接将字符串常量连接起来,形成新的字符串常量参与后续连接。而s3中的第一个操作数是String对象类型,因此会首先以最左边的字符串为参数创建StringBuilder对象,然后依次对右边进行append操作,最后将StringBuilder对象通过toString()方法转换成String对象。

  这里要注意的一点是,对于final字段修饰的字符串常量,编译期直接进行了常量替换。如果final修饰的不是字符串常量,而是字符串对象,如final String a = new String("baseStr"); 则和没有final修饰的情况是一样的,同样需要用StringBuilder进行append并toString才可以。

  我们再通过一个程序来更深入的理解字符串常量和“+”操作符。程序如下:

public class AppendTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
String a = "aa";
String b = "bb";
String c = "xx" + "yy " + a + "zz" + "mm" + b;
System.out.println(c);
}
}


  程序输出自然不用赘述,我们通过同样的方法查看Bytecode的outline,输出如下:

// access flags 0x21
public class com/yelbosh/java/str/AppendTest {

// compiled from: AppendTest.java

// access flags 0x1
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lcom/yelbosh/java/str/AppendTest; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

// access flags 0x9
public static main([Ljava/lang/String;)V
L0
LINENUMBER 6 L0
LDC "aa"
ASTORE 1
L1
LINENUMBER 7 L1
LDC "bb"
ASTORE 2
L2
LINENUMBER 8 L2
NEW java/lang/StringBuilder
DUP
LDC "xxyy " //直接load的是字符串常量“xxyy ”
INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V
ALOAD 1
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder; //之后都是在调用StringBuilder对象的append实例方法
LDC "zz"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
LDC "mm"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ALOAD 2
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 3
L3
LINENUMBER 9 L3
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 3
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L4
LINENUMBER 10 L4
RETURN
L5
LOCALVARIABLE args [Ljava/lang/String; L0 L5 0
LOCALVARIABLE a Ljava/lang/String; L1 L5 1
LOCALVARIABLE b Ljava/lang/String; L2 L5 2
LOCALVARIABLE c Ljava/lang/String; L3 L5 3
MAXSTACK = 3
MAXLOCALS = 4
}


  通过这个程序,更印证了我们如上的结论。

  通过这个深入分析,我们在写代码的时候,也要注意使用StringBuilder对象。如果直接在for循环中使用“+”操作符进行字符串对象(常量无所谓)的拼接,那么实际上在每次循环的时候,都要创建StringBuilder,然后append,再toString出来,因此性能是十分低下的。这个时候,就需要在循环外声明StringBuilder对象,然后在循环内调用append方法进行拼接。另外要注意的是,StringBuilder是线程不安全的,如果涉及到多个线程同时对StringBuilder的append操作,请使用synchronized或lock确保并发访问的安全性,或者转而使用线程安全的StringBuffer。

总结:Java String是非常灵活的一个对象,但是只要把细节搞清楚,问题还是很简单的。在实际编码的过程中,一定要考虑字符串操作的性能和线程安全问题,这样才能更好的运用字符串完成自己的业务逻辑。希望这篇博文能对您的学习有些帮助,如果错误,请不吝赐教。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: