String作为我们最常用的Java类之一,在日常开发过程中充当着重要角色?那么大家真的了解String吗?让我们一起看看下面的问题:
- String内存结构?对象存储在堆上还是栈上?
- 一个String有多长?占内存多大?
- 字符串拼接过程中内存对象是怎么管理的?
- String.intern是用来干嘛的?
- String底层数据结构是什么?
- String为什么设计成final类?
- 请简单实现下String的equals方法
- String的hashCode方法有了解吗?有没有可能两个String的值不同,但是hashCode相同?这种情况被称为什么?有什么解决方案?
String内存分配与最大长度
要谈String的内存分配与最大大小,我们首先应该清楚String中Java中有两种创建方式,如下所示:
// 直接使用""定义
String hello = "Hello";
// 使用new关键词创建
String nextHello = new String("nextHello");
那么这两种创建的String有什么不同呢?我们一起来看下,编写测试代码如下:
public class StringTest {
public void testString(){
String s1 = "Hello";
String s2 = new String("nextHello");
System.out.println(s1);
System.out.println(s2);
s1 = "World";
System.out.println(s1);
}
}
查看StringTest类字节码,如下所示:
可以看出使用"“创建的Hello和World字符串均声明在常量池中,而常量池中一个字符串的最大长度为65535个字符,故使用”"创建的字符串最大长度为65535.最大内存大小为65535*2 byte 约等于 128 KB。
继续查看StringTest的字节码文件,可以看到String s2 = new String("nextHello");
对应的字节码如下所示:
可以看出这里首先在常量池中生成nextHello字符串,随后在堆上创建String对象,通过astore_2指令将新创建的对象引用赋值给s2。
ldc,astore_2这两个指令的说明也能印证上述结论,两个指令的说明如下所示:
那么通过new String创建的字符串最大长度是多少呢?由于String底层使用char数组实现,故其最大长度为Integer.MAX_VALUE个字符(理论上),最大内存大小为2*Integer.MAX_VALUE byte 约等于 4GB。
String为什么被设计成final?
从final那些事儿一文中可以知道,final修饰类时,则该类不可被继承,也就意味着其内部的实现和依赖是不可更改的,进而导致不论如何创建的String实例,其必然关联的是String对象,而不可能是其他类的对象,String不可变,那么什么是String不可变,为什么要设计成不可变的呢?
String不可变
以下述代码为例:
String s = "abc";
s = "abcd";
其执行顺序如下,常量池创建"abc"字符串,变量s指向常量池新创建的字符串,随后在常量池中创建字符串"abcd",再次将s指向"abcd"所在地址,而不是将字符串"abc"修改为字符串"abcd"。
为什么String不可变
在源码中可以看到,为了实现String不可变,在String内部,底层数据结构是char数组,该数组同样final修饰,从final那些事儿一文可知,如果final修饰数组,只要数组指向地址不变,其内部元素值可以发生改变,故而进一步也用final修饰了String类,同样在该类并没有暴露任何访问内部成员字段的接口,进而最终实现String不可变。
当然反射修改String底层数组中的元素值仍然是可行的,但没有必要
String不可变的优势
String不可变主要有以下优势:
- 安全:在Java中,函数传参使用引用传递,String作为广泛应用的类型,如果其可变,在多个函数的层次传递过程中,极有可能被修改导致异常,同时HashMap,HashSet等类都可以使用String作为key,如果String可变,则键值,整个存储结构就乱套了。
- 内存利用率高,提升效率:在大量使用字符串的场景下,字符串重用,减少内存开销,避免频繁GC
String拼接原理
在日常开发中,我们经常使用 " + " 号进行字符串拼接,那么其底层是如何实现的呢?编写测试代码如下:
public class Person {
private int mAge = 100;
public Person(int mAge) {
this.mAge = mAge;
}
public int getAge() {
return mAge;
}
}
public class StringTest {
public void testString() {
Person person = new Person(200);
String s1 = "String Test";
int a = 300;
System.out.println("Test String append:" + s1 + ",person.Age:" + person.getAge() + ",a:" + a);
}
}
public class Main {
public static void main(String[] args) {
StringTest stringTest = new StringTest();
stringTest.testString();
}
}
查看StringTest的testString函数字节码,如下图所示:
可以看出,对于System.out.println("Test String append:" + s1 + ",person.Age:" + person.getAge() + ",a:" + a);
从其字节码可以看出,“Test String append:”,",person.Age:“以及”,a:"这三个字符串,存储在字符串常量池中,通过ldc指令取出,在整个代码执行流程中,主要是StringBuilder.append来实现字符串拼接的,最终将拼接后的内容输出。
以上例子都是变量参与字符串拼接,那么如果有常量参与,又会怎样呢?
可以看出,当常量参与字符串拼接时,编译器会直接替换内容(如变量s3),如果参与拼接的全是常量,则在编译时就会直接完成拼接操作(如变量s4)。
String.intern
查看String类的源码,可以看到String.intern是个native方法,声明如下所示:
/**
* 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();
从函数注释可以看出,当该函数被调用时,如果字符串常量池中已经有一个相同的字符串(equals返回true),则直接返回常量池中的字符串,如果常量池中不存在,则添加字符串到常量池并返回该字符串的引用。
对于直接使用""创建的字符串而言,我们知道其创建在常量池中,故而调用其intern方法与直接访问并没有内存上的实际差异,我们重点来看下使用new关键字创建字符串的场景。首先我们来思考下String s2 = new String("Hello") + new String(" String");
这行代码会创建几个对象?
其实前文中已有答案,其一共会有6个对象生成,如下图所示:
了解了new String参与时,相关对象的创建过程,我们接下来看下下述代码:
public void testString() {
String s1 = new String("Hello String");
String s3 = s1.intern();
String s2 = "Hello String";
System.out.println(s1 == s2);
System.out.println(s2 == s3);
}
运行后可以看到 s1 == s2返回false,s2 == s3返回true,也进一步说明String.intern的含义,s1不等于s2主要是因为s1指向的是堆内存的字符串地址,而s2指向的是常量池中的内存地址,s3等于s2,主要是经过s1.intern后,s3指向的是常量池中已经存在的"Hello String"字符串,后面创建s2时,由于常量池中已经存在,故两个变量指向的是同一个内存地址。
那么我们再来看下如下字符串拼接代码:
public class StringTest {
public void testString() {
String s1 = new String("Hello") + new String(" String");
s1.intern();
String s2 = "Hello String";
System.out.println(s1 == s2);
}
}
这里应该输出true还是false呢?这里返回的是true,大家猜对了吗?
查看这段代码对应的字节码如下:
可以看出,s1的赋值代码实际上等价于String s1 = StringBuilder.toString()而StringBuilder.toString是通过new String创建一个堆内存对象,此时再执行s1.intern,由于字符串常量池中无该字符串,则会将该字符串的引用添加到常量池中,进而后续创建的s2指向的也是常量池中该字符串的引用地址(内容一致)
String.equals
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
String.equals源码如上所示,可以看出其首先比较的时两个对象的引用地址是否相同,如果相同的话直接返回true,随后逐位比较字符串的内容,如果有一位不相同,则返回false,否则返回true。
String.hashCode
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
String.hashCode源码如上所示,可以看到其是逐位递归,依此对每一位字符应用算法, 将最后一位的算法取值作为全串的hash值。
最后一位的算法取值 = 31 * 前一位的算法取值 + 当前位的字符编码
从其算法可以看出,必然会出现两个不同的字符串hash取值一样的情况,这种情况被称为hash冲突,比较常见的hash冲突解决方案有链地址法和开放地址法,链地址法的经典实现就是HashMap,将hash值相同的key以链表形式存储下来,而开放地址法指的是在已有的hash值相同的元素后方,寻找空余位置处存放当前元素,当然也有其他的hash冲突解决方案,我们后续单独介绍。