Java-String:从初始化开始的发散思考
String 的创建
一般来说,Java 创建 String 对象有2种方式:
- 字面值创建。
String s1 = "hello";
-
new
创建。String s2 = new String("hello");
问题来了:这两种方式创建 String 对象有什么区别吗?
比较一下好了=>
比较 String
比较 String 有两种方法:==
和 equals
-
==
比较的是两个对象的引用是否指向同一内存地址。 -
equals
比较的是两个字符串对象的引用指向的内存地址所存储的字面值,不关心是否指向同一内存地址。
- java Object 对象的equals方法,实际上就是用 == 比较两个对象的引用。
- 而 String 重写了 Object 的 equals 方法,先用 == 比较对象引用,若引用相同则两个对象字面值一定相同;若引用不同,再比较字面值。
public static void demo1(){
// 1个字面值创建,1个 new 创建
System.out.println("demo1-----------------");
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2); // 结果:false,指向不同的内存地址
System.out.println(s1.equals(s2));// 结果:true
}
public static void demo2(){
// 1个 new 创建,1个字面值创建
System.out.println("demo2-----------------");
String s1 = new String("hello");
String s2 = "hello";
System.out.println(s1 == s2); // 结果:false,指向不同的内存地址
System.out.println(s1.equals(s2));// 结果:true
}
public static void demo3(){
// 2个都是 new 创建
System.out.println("demo3-----------------");
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // 结果:false,指向不同的内存地址
System.out.println(s1.equals(s2));// 结果:true
}
public static void demo4(){
// 2个都是字面值创建
System.out.println("demo4-----------------");
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // 结果:true,指向同一内存地址
System.out.println(s1.equals(s2));// 结果:true
}
上面的例子中,可以看出,仅当两个字符串都是使用字面值创建时,它们才会指向同一个内存地址,为什么呢?
这就涉及到了 java String 的内存管理。
内存模型
jvm 数据存储主要分布在两大区:
- stack,存储基本类型、对象的引用,以及线程中的方法调用记录。
- heap,存放由用户通过 new 操作创建的对象。
字面值初始化 String 的内存分配
堆中有一块名叫 “String Constant Pool” 的字符串常量池,专门用于存储字符串常量,一个字符串常量在这个字符串常量池中只存储一份。
于是,当你使用字面值创建字符串常量“hello”
时,JVM 会先去 “String Constant Pool” 查一遍有没有“hello”
这个字符串常量,若查到了,会直接把引用指向该内存地址;如果没有查到,就在常量池中申请新的空间,把“hello”
放进去。
也就是说,当使用 String s= “hello”;
定义变量 s 的时候, “hello”
存储在堆区,s 实际上是字符串常量 “hello”
的引用,指向 “String Constant Pool” 中 “hello”
所在内存的地址,s 本身则存储在栈区。
new String() 的内存分配
由 new 创建的 String 对象,也会被分配在堆区,但不是 “String Constant Pool” 。
在 new
一个新的 String
对象时,JVM 会做一下两件事:
- 在堆区创建该
String
对象,并让栈区的对象引用指向它; - 在常量池中查询是否已存在相同的字符串:
- 如果有,就将堆区的空间和常量池中的空间通过
String.inter()
关联起来; - 如果没有,则在常量池中申请空间存放该字符串对象,再做关联。
- 如果有,就将堆区的空间和常量池中的空间通过
String 不可变
如前所述,在Java中,new
出来的对象是存在堆区的,而对象变量仅仅是一个引用,存在栈区。
即:对 Object obj = new XXX();
这行代码,new XXX()
出来的结果是存在堆区的,但 obj
是存在栈区的,它指向堆区中 new XXX()
对象所在的内存地址。
所以,当你进行 obj = obj1
这样的操作给 obj
赋值时,实际上只是改变了 obj
的引用,使它指向 obj1
所指向的内存地址。
String 也是 Object,因此同样具有上述特性。
示例:
public static void demo5(){
// 2个都是字面值创建,对其中一个赋新值,再改回来
System.out.println("demo5-----------------");
String s1="hello";
String s2="hello";
System.out.println(s1==s2);// 结果:true
s2= "World?";
System.out.println(s1==s2);// 结果:false,s2指向了一个新的字符常量所在内存地址
s2="hello";
System.out.println(s1==s2);// 结果:true,又重新指回去了
}
public static void demo6(){
// 2个都是 new 创建,将其中一个 赋值给 另一个
System.out.println("demo6-----------------");
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // 结果:false
s1 = s2;
System.out.println(s1 == s2); // 结果:true,s1 = s2使得 s1 指向了 s2 所指的内存地址
System.out.println(s1.equals(s2));// 结果:true
}
从上面的例子上看,每次对 String 对象做赋值操作的时候,都仅仅是改变了引用的指向,原字符串本身并没有改变。
除此之外,String 类定义中没有对外暴露任何改变对象状态的入口,因此在 String 类的外部,也无法通过类似 setXXX()
这样的方法进行对象内容的修改。
那这些很明显对字符串做了修改的方法,包括substring, replace, replaceAll, toLowerCase等,到底是怎么回事呢?
以 replace
为例看一下=>
java String 替换
public static void demo7(){
// replace操作
System.out.println("demo7-----------------");
String s1 = "hello";
System.out.println(s1); //结果:hello
System.out.println(s1.replace('h','H'));//结果:Hello
System.out.println(s1);//结果:hello
String s2 = new String("hello");
System.out.println(s2); //结果:hello
System.out.println(s2.replace('h','H'));//结果:Hello
System.out.println(s2);//结果:hello
}
从replace
执行结果上看,replace
只是将替换后的结果返了回来,s 及 “hello World” 本身并没有发生改变。
从replace
实现上看,当需要做替换操作的时候,replace
其实是创建并返回了一个新的String,而不是对原字符串做修改,源码如下:
public String replace(char oldChar, char newChar) {
if (oldChar != newChar) {
int len = value.length;
int i = -1;
char[] val = value; /* avoid getfield opcode */
while (++i < len) {
if (val[i] == oldChar) {
break;
}
}
if (i < len) {
char buf[] = new char[len];
for (int j = 0; j < i; j++) {
buf[j] = val[j];
}
while (i < len) {
char c = val[i];
buf[i] = (c == oldChar) ? newChar : c;
i++;
}
return new String(buf, true);
}
}
return this;
}
那么,String 真的是完全不可变的吗?
String 强行修改
先看一下 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
/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;
......
Java 的 String 实际上是对字符数组的封装,数组也是一个引用。由于value[]
被声明为 private final
,所以 String 对象一旦被初始化,它的指向就不能改变,只能指向它最开始指向的数组,而不能指向其他数组;那么问题来了:
- 字符数组的引用能不能修改呢?
- 如何访问 private 对象?
用反射:
private static void demo8() throws Exception{
// 反射修改 String 中的 value
System.out.println("demo8-----------------");
String s = "hello";
System.out.println("s = "+s); //结果:hello
//获取String类中的value字段
Field valueFieldOfString = String.class.getDeclaredField("value");
//改变value字段的访问权限
valueFieldOfString.setAccessible(true);
//获取s对象上的value属性的值
char[] value = (char[]) valueFieldOfString.get(s);
//改变value所引用的数组中的第0个字符
value[0] = 'H';
System.out.println("s = "+s); //结果:Hello
}
运行结果:
s = hello
s = Hello
通过字面值初始化 s,s指向“hello”,然后再通过反射获得 value的访问权限,对value做修改。
从结果上看,value指向的值确实被修改了,猜测修改的是“hello”字符串本身,为了印证这个猜测,做一下测试:
private static void demo9() throws Exception {
// 2个都是字面值创建,比较 String 引用,和 value 引用;修改其中一个的 value值。
System.out.println("demo9-----------------");
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);// 结果:true
//获取String类中的value字段
Field valueFieldOfString = String.class.getDeclaredField("value");
//改变value字段的访问权限
valueFieldOfString.setAccessible(true);
//获取s对象上的value属性的值
char[] value_s1 = (char[]) valueFieldOfString.get(s1);
char[] value_s2 = (char[]) valueFieldOfString.get(s2);
System.out.println(value_s1 == value_s2);//结果:true,s1 和 s2 的引用也是相同的
value_s1[0]='H';
System.out.println(s1);
System.out.println(s2);
System.out.println(s1 == s2);// 结果:true
}
可以看到,s1 和 s2 指向同一个字符串数组,当 s1 通过value 引用修改字符数组时,s2 指向的字符数组也被修改了。就是这个样子的:
字符串数组、字符串对象、字符串对象的引用上面的例子说明了一个问题:如果一个对象,它组合/包含的其他对象的状态是可以改变的,那么这个对象很可能不是不可变对象。
例如:尽管 String 对外隐藏了字符串对象,并将其设为 final private
,但我们仍然有办法对它进行访问和修改。