关于String的这9个问题,值得一看

前言

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

  • 字符串的不可变性
  • JDK 1.6和JDK 1.7中substring的原理及区别
  • replaceFirst、replaceAll、replace区别
  • String对“+”的“重载”
  • 字符串拼接的几种方式和区别
  • Integer.toString()和String.valueOf()的区别
  • switch对String的支持(JDK 1.7及其后版本)
  • 字符串常量池、Class常量池、运行时常量池
  • String.intern()方法

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

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.identityHashCode()的作用是用来判断两个对象是否是内存中同一个对象,跟用==判断内存地址是否一样的效果一样

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

从关键词new就可以看出,这两个String变量在堆上不可能是同一块内存。其表现(本图是基于JDK1.7,至于字符串常量池后文会介绍):
在这里插入图片描述
那么如果加入以下代码,其输出结果会是怎么样的呢?

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指向同一块内存地址:
在这里插入图片描述
而str3赋值为str3+“ny"时,实际上是在常量池重新创建了一个新的常量"abcny”,并且赋予了不同的内存地址,即:
在这里插入图片描述
总结一下:字符串一旦创建,虚拟机就会在常量池里面为此字符串分配一块内存,所以它不能被改变。所有的字符串方法都是不能改变自身的,而是返回一个新的字符串。

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

2. JDK 1.6和JDK 1.7中substring的原理及区别

JDK 1.6和JDK 1.7中的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 1.6和JDK 1.7在实现substring()时原理上的不同。

JDK 1.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 1.6中,String类包含3个重要的成员变量:char value[](存储真正的字符串数组)、int offset(数组的第一个位置索引)、int count(字符串中包含的字符个数)。

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

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

JDK 1.7的substring

所以在JDK 1.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不会使用之前的字符数组,而是引用新生成的字符数组
在这里插入图片描述
总结一下:JDK 1.6与JDK 1.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";
}

然后我们查看反编译后的结果:
在这里插入图片描述
可以看到,虽然我们没有用到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 1.7及其后版本)

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

在JDK 1.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内代码的下一步操作。接下来我们看看反编译之后的情况:
在这里插入图片描述
看到黄色框的代码,我们可以知道String需要转换成int类型的整型数据之后才能进行在switch中进行判断。而红色框中的代码中我们可以看到,这个过程不止使用了hashCode()方法,还使用了equals()方法对字符串进行判断。

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

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

8. 字符串常量池、Class常量池、运行时常量池

在Java的内存分配中经常听到关于常量池的描述,但名声最大的还是运行时常量池,对于字符串常量池和Class常量池近乎没有印象,甚至是混在一起,在此将这几个概念进行区分。

字符串常量池

在不知道这个名词之前,笔者以为字符串会跟类的其他信息一样存储在方法区(或永久代)中,但遇到它之后,笔者发觉这事情没那么简单。

我们来看看它和永久代的搬家史:

  • JDK 1.7之前,字符串常量池在永久代中
  • JDK 1.7,将字符串常量池移出了永久代,搬到了DataSegument中,一个在堆中一个相对特殊的位置(失去唯一引用也不会被回收)
  • JDK 1.8,永久代被元空间取代了

字符串常量池中的内容是在字符串对象实例的引用值(字符串常量池中存储的是引用值,具体的字符串对象实例存放在堆的另一块空间),而且在HotSpot VM中实现的字符串常量池是一个由C++实现的StringTable,结构跟Hashtable类似,但区别在于不能自动扩容。这个StringTable在每个HotSpot VM中是被所有的类共享的。

这么说可能有点抽象,不如使用HSDB来亲眼看看吧。举个栗子:

class NY{
    String str = "nyfor2020";
}
public class Test {
    public static void main(String[] args) {
        NY ny1 = new NY();
        NY ny2 = new NY();
        try {
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在命令提示符中输入“jps”查看进程号后,在命令提示符中输入:

 java -classpath "%JAVA_HOME%/lib/sa-jdi.jar" sun.jvm.hotspot.HSDB

打开HSDB,输入进程号后使用Object Histogram找到相应类之后,可以找到两个NY对象引用的字符串的地址是同一个。
在这里插入图片描述

Class常量池

在《深入理解Java虚拟机》中对Class常量池的介绍是从这里引入:

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法去的运行时常量池中存放。

字面量即常量概念,如文本字符串、被声明为final的常量值等。而符号引用即一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候能直接定位到目标即可。

一般所说的类常量有以下三类:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

关于常量池中的每一个常量表示什么含义在此就不赘述,想了解的朋友可以参考《深入理解Java虚拟机》的第六章。举个栗子:

public class Test {
    public static void main(String[] args) {
        String s1 = "nyfor2020";
    }
}

当我们使用以下命令进行反编译:

javap -verbose Test.class

在这里插入图片描述
在反编译之后我们可以直接看到Class常量池中的内容,有类的全限定名、方法的描述符和字段的描述符。

也就是说,当Java文件被编译成Class文件的过程之后,就会生成Class常量池。那么运行时常量池又是什么时候产生的呢?

运行时常量池

运行时常量池是方法区的一部分,用于存放Class文件编译后生成的Class常量池等信息

接下来我们结合类加载过程来认识这几个常量池之间的关系:
在这里插入图片描述
在JVM进行类加载过程中必须经过加载、连接、初始化这三个阶段(在《Java的继承(深入版)中有介绍),而连接过程又包括了验证、准备和解析这三个阶段。

当类加载到内存后,JVM就会将Class常量中的内容存放到运行时常量池中。而在Class常量池中存储的是字面量和符号引用,而非真正的对象实例,所以在经过解析之后,会将符号引用替换为直接引用,而在解析过程中会去查询字符串常量池,以保证运行时常量池所应用的字符串与字符串常量池中的信息一致。

9. 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&trade; 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();

我们可以看到,intern()方法是一个本地方法,注释描述的大致意思是:

“当intern()方法被调用时,如果常量池中存在当前字符串,就会直接返回当前字符串;如果常量池中没有此字符串,会将此字符串放入常量池中后,再返回”。该方法的作用就是把字符串加载到常量池中

刚刚在介绍字符串常量池时提到它在JDK 1.6和JDK 1.7的内存位置发生了变化,所以在不同版本的JDK中intern()方法的表现也有所差别。举个栗子:

public static void main(String[] args) {
    String str1 = new String("1") + new String("1");
    str1.intern();
    String str2 = "11";
    System.out.println(str1 == str2);
}

在JDK 1.6中的运行结果为false,在JDK 1.7中的运行结果为true。为什么会出现这种情况呢?主要是字符串常量池的内存位置变了,导致intern()的内部实现也发生了变化。

在JDK 1.6中的intern()

intern()方法将字符串复制到字符串常量池,然后返回一个该字符串在常量池的引用,但是str1并没接收到这个应用,所以str1指向的还是堆,但是str2指向的是常量区,所以这两个地址不一样
在这里插入图片描述

在JDK 1.7中的intern()

在JDK 1.7中的intern()方法,(在字符串常量池找不到该字符串时)将该字符串对象在堆里的引用注册到常量池,以后在使用相同字面量声明的字符串对象则都指向该地址,也就是该字符串在堆中的地址。
在这里插入图片描述
等等,如果把intern()的位置下移一行之后呢?(基于JDK 1.7)

public static void main(String[] args) {
    String str1 = new String("1") + new String("1")
    String str2 = "11";
    str1.intern();
    System.out.println(str1 == str2);
    System.out.println(System.identityHashCode(str1));
    System.out.println(System.identityHashCode(str2));
}

运行结果为:

false
22307196
10568834

可以看到intern()的执行顺序改变之后,字符串常量池**已经存储了"1"和"11"**引用了,所以str2依然指向的是常量池中的引用,而str1指向的是new出来的字符串对象地址。

结语

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

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

参考资料:

Java中String对象的不可变性

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

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

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

String 重载 “+” 原理分析

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

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

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

JVM | 运行时常量池和字符串常量池及 intern()

Java中几种常量池的区分

《深入理解Java虚拟机》

本文已授权发布在微信公众号:Java后端。

posted @ 2020-03-15 14:03  NYfor2018  阅读(320)  评论(0编辑  收藏  举报