前言
String 是Java语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的 Immutable类,被声明为 final class,所有属性也都是 final 的(hashcode不是)。
也由于它的不可变性,所以类似拼接,裁剪字符串等操作,都会产生新的String对象。
由于字符串操作的普遍性,所以相关操作的效率,往往对应用性能有明显的影响。
String是我们在Java中最常使用的数据类型之一。然而只是使用还远远不够,我们还需要深入了解一下String的低层原理,以及Java对String类型的优化。
本文主要针对String展开分析,主要有:
- 创建字符串以及字符串常量池(Strng Pool)
- Java对字符串类型的优化
- String 类设置为 final (不可变)的原因。
- String 类部分源码解析。
- Java8 中 String的优化
创建字符串
JVM为了提升性能,减少内存开销,引入入了String Pool(字符串常量池)来存储字符串对象,避免字符串的重复创建。
创建字符串对象的方式
在Java中,创建字符串对象有两种方式:
1)直接字面赋值。String str = "abc";
2)使用new关键字创建对象。String str = new String("abc");
直接字面赋值:
采用字面直接赋值创建字符串时,JVM会首先去 String Pool 中查找是否存在该对象。
如果不存在,则会在String Pool 中创建该对象,然后将该对象的引用地址返回。
如果存在,则直接将String Pool 中的引用地址返回,String Pool 中不再创建对象。
new 关键字创建对象:
采用new关键字显式的创建对象时,JVM同样也会检查String Poll 中是否有该字符串对象。如果没有,则在String Pool 中创建该字符串对象。
如果有,则不对 String Pool 做任何操作。
此处不同的地方在于,使用new关键字创建对象,会在Heap的对象区中创建字符串对象,并且返回的是堆中字符串对象的引用。而不是String Pool 中的引用。
所以,使用new关键字创建一个新的字符串对象时,涉及到的是两个对象**,String Pool 中的字符串对象,和Heap中的字符串对象。这一点一定要注意。
两种方式的区别
由此看来,两者最大的区别在于:
直接字面赋值返回的是 String Pool 中字符串对象的引用。
new关键字创建,总是会在Heap中生成一个新的字符串对象,并且返回的是这个新对象的引用。
字符串的比较
一般是用 equals
比较,String重写了equals()方法
了解过JVM内存模型可以知道,值类型是直接存储在栈的局部变量的,而对象等引用类型,在栈中存储的是引用变量,指向的是Heap中对象的地址。
"==" 进行比较时,实际上是比较两个对象在栈中的直接值,也就是说对于值类型,就是直接比较值。对于引用类型,就是比较的引用的内存地址,也就是比较的是否指向的是同一个对象。
所以如下代码就可以合理的解释了:
String a = "ABC";
String b = "ABC";
String c = new String("ABC");
System.out.println(a==b); // true。都指向 String Pool 同一个对象
System.out.println(a==c); // false。a是指向 String Pool 中对象,c是指向堆内存中String对象。
System.out.println(c==d); // false。两个都是指向堆内存中不同的对象。
关于常量池
1)在Java7之后,常量池从持久代转移到了堆内存中。同时在Java8中移除掉了持久代,而换成了元数据区(Metaspace),元数据区大小与本地内存大小相关。
(具体可以参考JVM内存结构)
在Java7u40版本中扩展了字符串常量池大小到60013,这个值允许你再池中存储30000个独立字符串。
2)常量池中的对象也会被回收,Java7之前,常量池在持久代,所以触发常量池回收是在持久代GC时。当Java7之后移动到堆中之后,JVM会在每次MinorGC 或者 FullGC 时判断是否有必要对常量池对像进行回收。
判断常量池是否可以回收,采用可达性算法。
3)Java中不只是字符串用到了常量池,除了Double 和 Float,其他基本变量的包装类型也用到了常量池。例如Integer会将-128~127的数值存在常量池中。
字符串优化
由于字符串的操作在Java编码中非常普遍,因此性能上的优化显得尤为重要。主要体现在以下几点:
1)字符串对象是不可变的。
不可变是基于线程安全性的考虑。对于不可变对象,天然就是线程安全的,无需进行额外的开销来进行线程同步。这样在多线程环境下会非常高效。
事实上,Java的8种基本类型封装类都是不可变的。
2)字符串拼接的编译期优化。
对于 String str = "a"+"b"+"c"
这样在编译期就可以识别的对象,Java不会创建多个字符串对象进行拼接。而是直接创建一个 "abc" 对象。
事实上,通过反编译源码可以看到,上面的代码试试上是创建了一个StringBuilder然后进行连接 String str = new StringBuilder().append("a").append("b").append("c").toString()
3)字符串操作的优化
由于字符串是不可变的,因此我们使用 substring,toUpperCase,toLowerCase时,原字符串都不会改变,而是把结果放入一个新的对象返回。
4)substring的优化
之前的版本中。substring操作是共享一个char[]数组的,该操作其实就是对原来的char[]数组调整了offset 和 count。这样做的好处是通过共享char[] 数组节省内存开销,同时 substring操作的时间复杂度是O(1)
但是这样存在一个容易造成内存泄漏的隐患。如果我们substring后的结果生命周期长于原来的字符串,那么就会导致原来的字符串无法回收。造成内存泄漏。
在Java8中,移除了offset 和 count 的定义,修复了可能会出现内存泄漏的问题。同时substring的时间复杂度由 O(1) 变为了O(n)
String 定义为 final 的原因
String被设置为不可变的原因,总结起来主要有两个:安全 和 高效。
事实上,不仅 String的 value 是 private final的,String类也被设置为final。Java中8种基础对象的 value 和封装类都是 final 的。
这样做是为了避免被其他类继承从而破坏了不变性。
为什么设为不可变对象呢。其目的主要有三点:
1)缓存hashCode
Java中经常会用到字符串的哈希值。例如在HashMap中,字符春的不可变能保证其hashCode永远保持一致,这样就可以避免一些不必要的麻烦。这也意味着,在每次使用hashCode的时候不用都计算,只要计算一次就可以,这样更加高效。
2)逻辑的正确性和安全性
String的不变性,保证了在其他类使用时候的正确性。例如HashSst,其中的键是唯一的。如果存入的是可变对象,那可能就破坏了HashSet的唯一性。
比如,我们用StringBuilder当做参数放入HashSet:
class Test{
public static void main(String[] args){
HashSet<StringBuilder> hs=new HashSet<StringBuilder>();
StringBuilder sb1=new StringBuilder("aaa");
StringBuilder sb2=new StringBuilder("aaabbb");
hs.add(sb1);
hs.add(sb2); //这时候HashSet里是{"aaa","aaabbb"}
StringBuilder sb3=sb1;
sb3.append("bbb"); //这时候HashSet里是{"aaabbb","aaabbb"}
System.out.println(hs);
}
}
//Output:
//[aaabbb, aaabbb]
可以看到,在开始的时候,我们插入的是不同的对象,但是我们在外部将对象进行修改之后。HashSet的值变成相同的了。这就在不经意间破坏了HashSet键的唯一性。这样的错误更隐蔽,也是我们最不希望看到的。
所以在使用一些涉及到唯一性数据的时候,一定要注意对象是否可变。
同时String被广泛的使用在其他Java类中当做参数,例如网络连接,打开文件等。如果字符串可变,那么类似操作可能导致安全问题。可变的字符串也可能导致反射的安全问题,因为反射的参数也是字符串。
String被设计为不可变的话,他的使用就会变的非常简单,不用考虑其他变量会改变。
3)并发编程的使用
不可变对象不能被改变,因此天然就是线程安全的,无需额外的同步操作来保证在多线程条件下的安全性。
同时,因为无需额外的同步处理,在编写并发编程时使用起来会非常简单,并发情况下的性能也是非常高效的。
4)提高性能
字符串不可变,使得字符串可以使用字符串常量池,来缓存字符串对象。这样就避免了重复创建字符串对象产生的额外开销。
同时,字符串的hashCode也可以缓存在对象中,只需要一次计算就可以。因为字符串对象不可变,其hashCode也就不会改变。
String 源码解析
hashCode()
在 String 类中,除了hashCode,其他属性都被定义为final 。hash被给定了一个0的默认值。只在第一次调用hashCode()方法时,才会被运算。因为String是不可变的,所以hashCode不可变,因此只要计算一次并缓存起立即可,提高效率。
我们可以从源码看出:
/** Cache the hash code for the string */
private int hash; // Default to 0
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;
}
substring
Java8中对该方法做了优化,修复了可能导致内存泄漏的问题。
在这之前是和String对象共享一个char[]数组,在Java8中,substring生成了一个新的String对象返回。
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
intern
该方法是一个 native 方法,返回的是该字符串在常量池的引用。
字符串调用intern()方法时,JVM会去 String Pool 中寻找匹配这个字符串的值。如果有这个值,则返回该引用。如果没有,则在 String Pool 中增加该字符串的值,并且返回常量池的引用。
这个方法用于在运行时扩充常量池。
(如果有什么错误或者建议,欢迎留言指出)
(本文内容是对各个知识点的转载整理,用于个人技术沉淀,以及大家学习交流用)
参考资料:
Java字符串迟深度解析
Java总结篇系列:Java String
深入理解Java中的String
Java8中String的变化
Java性能优化——字符串优化处理
Java 1.7.0_06中String类内部实现的一些变化
Java中substring真的会引起内存泄漏吗