前言
String是我们日常开发中很常见的类型,因为声明简单,很少出现BUG,我们使用时也就无所顾忌,常常忽略很多细节。今天我们一起来探讨下String内部的设计和其特性。
一. 理解String的不可变性
一提到String的设计原理,大部分人第一时间想到的就是“String是不可变”的。那么String的不可变性体现在哪里?究竟是否真正理解“不可变”的含义。我们先看几个问题。
1.1 怀疑人生的String问题
本节举的几个问题也许并没有工程实践的意义,看上去有点“八股”,只是为了辅助大家理解String的设计。
- 第一题
String a = "123";
String b = new String("123");
System.out.println(a == b);
最终输出结果是
falseString b = new String("123")创建了几块空间
2个- 第二题
String a = "123";
String b = "12";
String c = b + "3";
System.out.println(a == c);
最终输出结果是
false String a = "123";
String b = "12" + "3";
System.out.println(a == b);
最终输出结果是
true1.2 String内部是怎么存储的
String中实际的字符(字面量)存放在常量池中,字面量是不可更改的,但可以更改String到字面量的引用。
- 第一题的解析
String a = "123";
String b = new String("123");
两个变量的引用地址不相等,因此返回false
关于new String("123")创建了几个存储空间,根据上图可知:常量池中一个字面量空间,还有一个new出来的String对象空间。
顺带一提这两个对象不是同期创建的:
- 字面量对象在类加载过程中创建
- new出来的String对象是在运行期中创建
- 第2题的解析
String c = b + "3";
当String变量和字符串常量拼接时,会在堆中新建一个String对象来存放,不放到常量池中,因此地址不同返回false。
如果想让拼接结果放到常量池中,将b声明为final类型即可。
String b = "12" + "3";
两个字符串常量拼接后依然是字符串常量,在常量池中只保留了一份,因此地址相同返回true。
1.3 String不可变性怎么体现
- String的不变,并非说String不能改变,而是其对应的字面量值不能改变。
- 有的时候看来修改了(变长、变短、改变格式),实际是String经过了特殊处理,每次改变值时都会建立一个新的string对象,变量会指向这个新的对象,而原来的还是指向原来的对象,所以不会改变。
String a = "123";
System.out.println("23" == a.substring(1)); // false
String a = "ABC";
String b = "abc".toUpperCase();
System.out.println(a == b); // false
1.4 String为什么设计成不可变的
- 不同的字符串变量可以指向池中的同一个字符串,在运行时节约很多heap空间。
- 多线程安全,同一个字符串实例可以被多个线程共享,不用因为线程安全问题而使用同步
- 不变性也保证了hash码的唯一性,不需要重新计算,可以在创建的时候就缓存hashcode,因此字符串的处理速度要快过其它的键对象。
二. String的特性
2.1 特殊的引用传递
- Java中的参数传递分为值传递和引用传递两种,像int这种基础类型采用值传递,参数在方法内只是本身的一个copy,也就是方法内对参数进行修改不会影响方法外的参数本身。
public static void main(String[] args) {
int x = 1;
add(x);
System.out.println(x); // 1
}
public static void add(int x) {
x++;
}
- 而List这种引用类型采用引用传递,参数和本身是一个引用,对参数修改相当于对本身修改。
public static void main(String[] args) {
List<String> s = new ArrayList<>();
add(s);
System.out.println(s.size()); // 1
}
public static void add(List<String> s) {
s.add("123");
}
- String就比较特殊,String是一个对象采用引用传递,但其效果却和值传递相同
public static void main(String[] args) {
String str = "123";
add(str);
System.out.println(str); // 123
}
public static void add(String str) {
str += "4";
}
因为String的不可变性导致str拼接后生成一个新的对象,str仍然指向“123”
2.2 怎么被switch使用的?
- JDK1.7后switch支持String作为case,其实是在编译器层面实现的,在JVM和字节码层面依然只支持使用整数类型兼容的类型。
- 在switch中使用的String在编译过程中会将字符串转换成哈希码等整数类型兼容的格式。
2.3 为什么不建议使用“+”拼接?
编译器会将“+”拼接自动优化为 StringBuilder 和 append 调用,如果在循环等情况下调用 + 或者 += 就是在不停的 new StringBuilder 对象 append 了,这是及其浪费的。
2.4 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类型
- 最后逐一判断字符是否相等
String a = "123";
StringBuilder s =new StringBuilder(a);
System.out.println(a.equals(s)); // false
在判断类型时StringBuilder实现了CharSequence,因此instanceof直接返回false
2.5 intern方法有什么用?
当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。
String a = "12";
String b = "1";
String c = "2";
String d = b + c;
System.out.println(d.intern() == a); // true
System.out.println(a.intern() == d); // false
-
优点:对与大量相同字符串创建的场景,节省空间 假设数据库中有性别字段,只有男女两种类型,获取数据时每个性别都存放到堆中,如果使用intern则只会创建“男”,“女”两个对象。
-
缺点:对与大量不同的字符串创建场景,容易使常量池索引变慢 假设数据库中有邮箱字段,每个账号都不一样,如果使用intern则都会放到常量池中,使整个常量池索引变慢,影响整个系统数据获取速度。
2.6 针对安全保密高的信息,char[] 比 String 更好?
-
因为String一旦创建就不能更改,直到垃圾收集器将它回收才能消失,即使我们修改了原先的变量,实际上也是在内存中新建一个对象,原数据还是保留在内存中等待回收;此时如果解析了常量池中的内容就可能造成信息泄漏。
-
字符数组 char[] 中的元素是可以更改的,也就是说像密码等保密信息用完之后我们可以马上修改它的值而不留痕迹,从而相对于 String 有更好的安全性。
-
这样做意义有多大?
如果没有及时清空而由 GC 来清除的话暴露窗口大约是秒这个数量级,如果能够在使用后立即清除则暴露窗口大约是微秒数量级,如此简单的设计就可以降低如此多的被攻击概率,性价比是非常高的。 -
为什么不通过反射直接修改String?
String 源码里面的 char[] 很可能是多个 String 共享的,我们改掉它就会殃及别的 String。