前言

Java中String的应用无处不在,无论是算法题还是面试题,String都独占一方,甚至是无数面试者心中难以名状的痛。本文着重对String(若无特地说明,默认是JDK 8版本)常见的问题来进行介绍:

(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)

1. 字符串的不可变性

我们先来看看下面这段代码:

public class Test {  
 public static void main(String[] args) {  
 String str1 = new String("abc");  
 String str2 = new String("abc");  
 System.out.println("str1 == str2:" + str1 == str2);  
 }  
}

一般都能看出来,这运行结果肯定是false啊,可是为什么呢?前文有提到过System.identityHashCode()是用于判断两个对象是否是内存中的同一对象:

System.out.println("str1:" + System.identityHashCode(str1)); //str1:22307196  
System.out.println("str2:" + System.identityHashCode(str2)); //str2:10568834

从关键词new就可以看出,这两个String变量在堆上不可能是同一块内存。其表现(本图是基于JDK1.7):
str1!=str3(3).jpg
那么如果加入以下代码,其输出结果会是怎么样的呢?

String str3 = str1;  
System.out.println("str1 == str3:" + str1 == str3);  
str3 += "ny";  
System.out.println("str1 == str3:" + str1 == str3);

第一个结果为true,而第二个结果为false。显而易见,第二个结果出现不同是因为str3赋值为"ny",那么这整个过程是怎么表现的呢?

当str3赋值为str1的时候,实际上是str3与str1指向同一块内存地址:
str1==str3(2).jpg
而str3赋值为str3+"ny"时,实际上是在常量池重新创建了一个新的常量"abcny",并且赋予了不同的内存地址,即:
str1!=str3(3).jpg
总结一下:字符串一旦创建,虚拟机就会在常量池里面为此字符串分配一块内存,所以它不能被改变。所有的字符串方法都是不能改变自身的,而是返回一个新的字符串。

如果需要改变字符串的话,可以考虑使用StringBufferStringBuilder来,否则每次改变都会创建一个新的字符串,很浪费内存。

2. JDK 6和JDK 7中substring的原理及区别

JDK6和JDK7中的substring(int beginIndex, int endIndex)方法的实现是不同的,为简单起见,后文中用substring()代表(int beginIndex, int endIndex)方法。首先我们先连接一下substring()方法的作用:

String str = "我不是你最爱的小甜甜了吗?";  
str = str.substring(1,3);  
System.out.println(str);

运行结果为:

不是

我们可以看到,substring()方法的作用是截取字段并返回其[beginIndex, endIndex-1]的内容

接下来我们来看看JDK 6和JDK 7在实现substring()时原理上的不同。

JDK 6的substring

String是通过字符数组来实现的,我们先来看下源码:

public String(int offset, int count, char value[]) {  
 this.value = value;  
 this.offset = offset;  
 this.count = count;  
}  
​  
public String substring(int beginIndex, int endIndex) {  
 //check boundary  
 return  new String(offset + beginIndex, endIndex - beginIndex, value);  
}

可以看到,在JDK 6中,String类包含3个重要的成员变量:char value[](存储真正的字符串数组)、int offset(数组的第一个位置索引)、int count(字符串中包含的字符个数)。

而在虚拟机中,当调用substring方法的时候,堆上会创建一个新的string对象,但是这个string与原先的string一样,指向同一个字符数组,它们之间只是offset和count不相同而已
JDK6substring.jpg
这种结构看上去挺好的,只需要创建一个字符数组,然后可以通过调整offset和count就可以返回不同的字符串了。但事实证明,这种情况还是比较少见的,更常见的是从一个很长很长的字符串中切割出需要用到的一小段字符序列,这种结构会导致很长的字符数组一直在被使用,无法回收,可能导致内存泄露。所以一般都是这么解决的,原理就是生成一个新的字符并引用它。

str = str.substring(1, 3) + "";

JDK 7的substring

所以在JDK 7提出了一个新的substring()截取字符串的实现:

public String(char value[], int offset, int count) {  
 //check boundary  
 this.value = Arrays.copyOfRange(value, offset, offset + count);  
}  
​  
public String substring(int beginIndex, int endIndex) {  
 //check boundary  
 int subLen = endIndex - beginIndex;  
 return new String(value, beginIndex, subLen);  
}

我们可以看到,String构造函数的实现已经换成了Arrays.copyOfRange()方法了,这个方法最后会生成一个新的字符数组。也就是说,使用substring()方法截取字段,str不会使用之前的字符数组,而是引用新生成的字符数组
JDK7Substring.jpg
总结一下:JDK 6与JDK 7在实现substring()方法时最大的不同在于,前者沿用了原来的字符数组,而后者引用了新创建的字符数组

3. replaceFirst、replaceAll、replace区别

从字面上看,这三者的区别在于名称:replace(替换)、replaceAll(替换全部)、replaceFirst(替换第一个符合条件)。在从功能、源码上对这三者进行介绍之前,我们先来看看这道题:

public static void main(String[] args) {  
 String str = "I.am.fine.";  
 System.out.println(str.replace(".", "\\\\"));  
 System.out.println(str.replaceAll(".", "\\\\"));  
 System.out.println(str.replaceFirst(".", "\\\\"));  
}

运行结果为:

I\am\fine\  
\\\\\\\\\\\  
\.am.fine.

做对了吗?下面来分别对这三者进行介绍。

replace

结合题目中的执行replace()方法后的输出结果,我们来看看在Java中的replace()的源码:

public String replace(CharSequence target, CharSequence replacement) {  
 return Pattern.compile(target.toString(), Pattern.LITERAL).  
 matcher(this).replaceAll(Matcher.quoteReplacement(replacement.toString()));  
}

可以看到replace()只支持入参为字符序列,而且实现的是完全替换,只要符合target的字段都进行替换。

replaceAll

在进行介绍之前我们先看看源码:

public String replaceAll(String regex, String replacement) {  
 return Pattern.compile(regex).matcher(this).replaceAll(replacement);  
}

我们可以看到,replaceAll()支持入参为正则表达式,而且此方法也是实现字段的完全替换。从运行结果中我们能看到所有的字符都被替换了,其实是因为"."在正则表达式中表示"所有字符",如果想要只替换"."而非全部字段,则可以这么写:

System.out.println(str.replaceAll("\\.", "\\\\"));

replaceFirst

其实从上面的运行结果来看,也知道replaceFirst也是支持入参为正则表达式,但是此方法实现的是对第一个符合条件的字段进行替换

public String replaceFirst(String regex, String replacement) {  
 return Pattern.compile(regex).matcher(this).replaceFirst(replacement);  
}

总结一下,replace不支持入参为正则表达式但能实现完全替换;replaceAll支持入参为正则表达式且能实现完全替换;replaceFirst支持入参为正则表达式,但替换动作只发生一次

4. String对“+”的“重载”

当我们查看String的源码时,我们可以看到:

private final char value[];

而且在上文我们已经提到String具有不可变性,可当我们在使用“+”对字符串进行拼接时,却可以成功。它的原理是什么呢?举个栗子:

public static void main(String\[\] args) {  
 String str = "abc";  
 str += "123";  
}

然后我们查看反编译后的结果:
StringBuilder拼接.png
可以看到,虽然我们没有用到java.lang.StringBuilder类,但编译器为了执行上述代码时会引入StringBuilder类,对字符串进行拼接

其实很多人认为使用”+“拼接字符串的功能可以理解为运算符重载,但Java是不支持运算符重载的(但C++支持)。

运算符重载:在计算机程序设计中,运算符重载(operator overloading)是多态的一种。运算符重载就是对已有的运算符进行定义,赋予其另一种功能,以适应不同的数据类型。

从反编译的代码来看,其实这只是一种Java语法糖。

总结一下,String使用"+"进行拼接的原理是编译器使用了StringBuilder.append()方法进行拼接,且这是一种语法糖

5. 字符串拼接的几种方式和区别

字符串拼接是字符串处理中常用的操作之一,即将多个字符串拼接到一起,但从上文我们已经知道了String具有不可变性,那么字符串拼接又是怎么做到的呢?

String.concat()拼接

在介绍concat原理之前,我们先看看concat是怎么使用的:

public static void main(String[] args) {  
 String str = "我不是你最爱的小甜甜了吗?";  
 str = str.concat("你是个好姑娘");  
 System.out.println(str);  
}

运行结果为:

我不是你最爱的小甜甜了吗?你是个好姑娘

我们可以看到,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()的拼接实际上是,创建一个长度为已有字符串和待拼接字符串的长度之和的字符数组,然后将两个字符串的值赋值到新的字符数组中,最后利用这个字符数组创建一个新的String对象

StringBuilder.append()拼接

上文在介绍String的"+"拼接时,StringBuilder已经出来混个脸熟了,现在我们看个例子:

public static void main(String[] args) {  
 StringBuilder sb = new StringBuilder("我不是你最爱的小甜甜了吗?");  
 sb.append("你是个好姑娘");  
 System.out.println(sb.toString());  
}

运行结果同上,接下来我们来看看StringBuilder的实现原理。StringBuilder内部同String类似,也封装了一个字符数组:

char[] value;

与String相比,StringBuilder的字符数组并不是final修饰的,即可修改。而且字符数组中不一定所有位置都已经被使用了,StringBuilder有一个专门记录使用字符个数的实例变量:

int count;

而StringBuilder.append()的源码如下:

public StringBuilder append(String str) {  
 super.append(str);  
 return this;  
}

可以看到StringBuilder.append()方法是采用父类AbstractStringBuilder的append()方法

public AbstractStringBuilder append(String str) {  
 if (str == null)  
 return appendNull();  
 int len = str.length();  
 ensureCapacityInternal(count + len);  
 str.getChars(0, len, value, count);  
 count += len;  
 return this;  
}

ensureCapacityInternal()方法用于扩展字符数组长度(有兴趣的读者可以查看其扩展的方法),所以这里的append方法会直接拷贝字符到内部的字符数组中,如果字符数组长度不够,则进行扩展

StringBuffer.append()拼接

StringBuffer和StringBuilder结构类似,且父类都是AbstractStringBuilder,二者最大的区别在于StringBuffer是线程安全的,我们来看下StringBuffer.append()的源码:

public synchronized StringBuffer append(String str) {
 toStringCache = null;  
 super.append(str);  
 return this;  
}

可以看到,StringBuffer.append()方法是使用synchronized进行声明,说明这是一个线程安全的方法,而上文StringBuilder.append()则不是线程安全的方法。

StringUtils.join()拼接

这个拼接方式适用于字符串集合的拼接,举个栗子:

public static void main(String[] args) {  
 List<String> list = new ArrayList<>();  
 list.add("我不是你最爱的小甜甜了吗?");  
 list.add("你是个好姑娘");  
 String s = new String();  
 s = StringUtils.join(list, s);  
 System.out.println(s);  
}

运行结果同上,接下来我们来看一下原理:

public static String join(Collection var0, String var1) {  
     StringBuffer var2 = new StringBuffer();  
    ​  
     for(Iterator var3 = var0.iterator();   
         var3.hasNext(); var2.append((String)var3.next())) {  
         if (var2.length() != 0) {  
            var2.append(var1);  
         }  
     }  
     return var2.toString();  
 }

StringUtils.join()方法中依然是使用StringBuffer和Iterator迭代器来实现,而且如果集合类中的数据不是String类型,在遍历集合的过程中还会强制转换成String。

总结一下,加上上文介绍的使用“+”进行字符串拼接的方式,此文一共介绍了五种字符串拼接的方式,分别是:使用"+"、使用String.concat()、使用StringBuilder.append()、使用StringBuffer.append()、使用StringUtils.join()。需要强调的是:

  1. 使用StringBuilder.append()的方式是效率最高的;
  2. 如果不是在循环体中进行字符串拼接,用"+"方式就行了;
  3. 如果在并发场景中进行字符串拼接的话,要使用StringBuffer代替StringBuilder。

6. Integer.toString()和String.valueOf()的区别

Integer.toString()方法和String.valueOf()方法来进行int类型转String,举个栗子:

public static void main(String[] args) {  
 int i = 1;  
 String integerTest = Integer.toString(i);  
 String stringTest = String.valueOf(i);  
}

平常我们在使用这两个方法来进行int类型转String时,并没有对其加以区分,这次就来深究一下它们之间有何区别,以及使用哪个方法比较好

Integer.toString()方法

以下为Integer.toString()的实现源码,其中的stringSize()方法会返回整型数值i的长度,getChars()方法是将整型数值填充字符数组buf:

public static String toString(int i) {  
 if (i == Integer.MIN_VALUE)  
 return "-2147483648";  
 int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);  
 char[] buf = new char[size];  
 getChars(i, size, buf);  
 return new String(buf, true);  
}

可以看到,Integer.toString()先是通过判断整型数值的正负性来给出字符数组buf的大小,然后再将整型数值填充到字符数组中,最后返回创建一个新的字符串并返回

在包装类中不仅是Integer,同理Double、Long、Float等也有对应的toString()方法

String.valueOf()方法

String.valueOf()相对于Integer.toString()方法来说,有大量的重载方法,在此列举出几个典型的方法。

public static String valueOf(Object obj)

这个方法的入参是Object类型,所以只需要调用Object的toString()方法即可(在编写类的时候,最好重写其toString()方法)。

public static String valueOf(Object obj) {  
 return (obj == null) ? "null" : obj.toString();  
}
public static String valueOf(char data[])

当入参为字符数组时,看过上文的String.concat()方法的原理,我们几乎可以下意识地反应:这里的字符数组,应该是用于创建一个新的字符串对象来并返回该字符串了

public static String valueOf(char data[]) {  
 return new String(data);  
}

除了字符数组外,字符也是通过转换成字符数组后,创建一个新的字符串对象来返回字符串。

public static String valueOf(boolean b)

其实布尔型数值的返回结果只有两种:true或false,所以只要对这两个数值进行字符处理即可。

public static String valueOf(boolean b) {  
 return b ? "true" : "false";  
}
public static String valueOf(int i)

上文我们介绍了Integer.toString()方法,这方法String.valueOf()就用到了。而且重载的入参类型不止int,还有long、float、double等。

public static String valueOf(int i) {  
 return Integer.toString(i);  
}

总结一下,我们看到String.valueOf()有许多重载方法,且关乎于包装类如Integer等的方法内部还是调用了包装类自己的方法如Integer.toString()。因其内部重载了不同类型转换成String的处理,所以推荐使用String.valueOf()方法

7. switch对String的支持(JDK 7及其后版本)

在JDK 7之前,switch只支持对int、short、byte、char这四类数据做判断,而在JVM内部实际上只支持对int类型的处理。因为虚拟机在处理之前,会将如short等类型数据转换成int型,再进行条件判断

在JDK 7的中switch增加了对String的支持,照常,先举个栗子:

public static void main(String[] args) {  
 String str = "abc";  
 switch (str) {  
 case "ab":  
 System.out.println("ab");  
 break;  
 case "abc":  
 System.out.println("abc");  
 break;  
 default:  
 break;  
 }  
}

运行结果为:

ab

因为switch关键词不像是类和方法,可以直接查看源码,所以这里采用查看编译后的Class文件和查看反编译的方式。首先我们查看编译后的Class文件:

public static void main(String[] var0) {  
 String var1 = "abc";  
 byte var3 = -1;  
 switch(var1.hashCode()) {  
 case 3105:  
 if (var1.equals("ab")) {  
 var3 = 0;  
 }  
 break;  
 case 96354:  
 if (var1.equals("abc")) {  
 var3 = 1;  
 }  
 }  
 ......  
}

可以看到,switch的入参为字符串"abc"的hashCode,switch进行判断的依然还是整数,而且进行判断的字符串也被转换成整型数值,在case中还使用了equals()方法对字符串进行判断,以确认是否进行case内代码的下一步操作。接下来我们看看反编译之后的情况:
switch的String.png
看到黄色框的代码,我们可以知道String需要转换成int类型的整型数据之后才能进行在switch中进行判断。而红色框中的代码中我们可以看到,这个过程不止使用了hashCode()方法,还使用了equals()方法对字符串进行判断。

但也因switch判断字符串的实现原理是求出String的hashCode,所以String不能赋值为null,否则会报NullPointerException。

总结一下,switch支持String本质上还是switch在对int类型数值进行判断

结语

在日常使用的时候,我们对于String的态度就像是对待空气,只有在出问题了才会发现之前没对它加以了解。此文以面试为契机,对String及其相关操作的原理进行回顾。

这篇肝了我两天,手都快残了。

如果本文对你的面试、学习有帮助,请给一个赞吧,这会是我最大的动力~

参考资料:

Java中String对象的不可变性

在Java虚拟机中,字符串常量到底存放在哪

了解JDK 6和JDK 7中substring的原理及区别

java的replaceFirst和(反斜杠)[replace、replaceAll和replaceFirst的区别]

String 重载 "+" 原理分析

字符串拼接的几种方式和区别

Java—String.valueof()和Integer.toString()的不同

java switch是如何支持String类型的?


NYforSF
60 声望18 粉丝