代码运行环境:JDK 8
首先思考几个问题:
- String对象在不同的JDK中是如何实现的?
- String对象的不可变性是什么样的?
- 下面这段代码的输出结果是什么?
String s1 = new String("aaa")+new String("");
s1.intern();
String s2 = "aaa";
System.out.println(s1==s2);
String s3 = new String("bbb")+new String("");
s3 = s3.intern();
String s4 = "bbb";
System.out.println(s3==s4);
String s5 = new String("hi") + new String("j");
s5.intern();
String s6 = "hij";
System.out.println(s5 == s6);
String对象的实现
Java6 及以前的版本
String对象是对 char 数组的封装,主要包括四个成员变量:char数组、偏移量offset、字符数量count、哈希值hash
。
使用 offset 和 count 两个属性可以定位 char 数组,获取字符串。这么做可以高效、快速地共享数组对象,同时节省内存空间。
缺点是可能导致内存泄露。
Java7 和 Java8 版本
去掉了 offset 和 count 两个变量,String对象占用的空间变少了。
String.substring
方法不再共享char[]
,解决了可能导致的内存泄漏问题
Java9 版本
使用byte[]
替换char[]
,新增了一个属性 coder,这是一个编码格式的标识。
在Java程序中,String占用的空间最大,然后大多数String只有 Latin-1 字符,Latin-1 字符只需要一个字节就够了。一个 char 字符占 2 个字节,就有很多空间要浪费。JDK1.9的 String 类为了节约内存空间,于是使用了1个字节的 byte 数组来存放字符串。
coder的作用是,在计算字符串长度或者使用
indexOf()
函数时,我们需要根据这个字段,判断如何计算字符串长度。coder属性默认有0和1两个值,0代表Latin-1(单字节编码),1代表UTF-16。如果String判断字符串只包含了Latin-1,则coder属性值为0,反之则为1。
String 对象的不可变性
先看看下面的这段代码:
String str = "Hello";
str = "World";
咦,str 它变了,说好的String对象不可变性呢?
这里涉及到两个含义,一个是对象本身,一个是对象引用。对象本身是内存中的一块内存地址。对象引用则是指向该内存地址的引用。在这两行代码里面,str 只是一个对象引用。
第一行代码执行的时候,创建了一个字符串对象 ”Hello“,然后 str 指向 ”Hello“ 对象的地址。第二行代码,创建了一个字符串对象 “World” 对象,str 重新指向了 “World”对象的地址。“Hello” 和 “World” 对象都没有改变,改变的只是对象引用 str 的值。
再来聊聊 String 类的代码实现
String 类被 final 关键字修饰了,所以这个类不能被继承,final类的方法默认都是 final 的(final方法不能被子类的方法覆盖,但可以被继承)。
String 类的属性char[]
也被final
和private
修饰了。final成员变量表示常量,只能被赋值一次,赋值后值不再改变。这就意味着String对象一旦创建成功,不能再对它进行改变。这也是String对象的不可变性。
优点
- 保证String对象的安全性,防止了可能出现的恶意修改
- 保证hash属性值不会频繁变更,确保了唯一性,使得类似HashMap容器才能实现相应的key-value缓存功能。
- 可以实现字符串常量池。
创建字符串对象的方式
// 字符串常量
String str = "Hello World";
// 构造方法
String str = new String("Hello World");
使用第一种方式的时候,JVM会检查字符串常量池中是否存在该字符串,如果存在则直接返回该对象的引用,否则在会在常量池中创建这个字符串。这种方式可以减少同一个值得字符串对象的重复创建,节约内存。
使用第二种方式,首先在编译类文件的时候,“Hello World” 常量字符串会被放入常量结构中,在类加载的时候,“Hello World” 将在常量池中创建,然后在调用构造函数的时候,引用常量池中“Hello World”字符串,在堆内存中创建一个String对象,最后,str 变量引用String对象。
String对象的优化
1、构建超大字符串
使用+
拼接字符串的时候,会被编译器优化成StringBuilder的方式,但是优化的时候,如果存在循环的情况,可能会创建多个StringBuilder实例,所以可以显示的使用StringBuilder拼接字符串。
在多线程编程中,可以使用StringBuffer。
2、字符串的分割
因为正则表达式的性能是非常不稳定的,使用不恰当会引起回溯问题,很可能导致CPU居高不下。
所以我们应该慎重使用Split()方法,我们可以用String.indexOf()方法代替Split()方法完成字符串的分割。如果实在无法满足需求,你就在使用Split()方法时,对回溯问题加以重视就可以了。
3、使用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™ 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
方法时,会去查看字符串常量池中是否有等于该对象的字符串的引用,如果有,就返回常量池中的字符串引用。
如果没有的话要分两种情况讨论:
- 在JDK1.6版本中,会复制堆中的字符串到常量池中,并返回该字符串引用,堆内存中原有的字符串由于没有引用指向它,将会通过垃圾回收器回收。
- 在JDK1.7版本以后,由于常量池已经合并到了堆中,所以不会再复制具体字符串了,只是会把首次遇到的字符串的引用添加到常量池中;
现在再看看开头的那三段代码,第一次比较的代码,如下所示:
// 创建两个对象,常量池一个,s1为字符串引用对象,两者指向对中的同一块"aaa"
String s1 = new String("aaa");
System.out.println("aaa:"+System.identityHashCode("aaa"));// 621009875
System.out.println("s1:"+System.identityHashCode(s1));// 1265094477
// 空串对象
String s2 = new String("");
// s1+s2以后,s3变成一个新的对象
String s3 = s1+s2;
System.out.println("s3:"+System.identityHashCode(s3));// 2125039532
// s1和s3指向同一块地址,所以""没在常量池中?
// 此处调用intern方法,因为常量池中包含"aaa"字符串,所以并没有实质性改变
System.out.println("s1 VS s3:"+(s1.intern()==s3.intern()));// true
// 调用intern方法前后,s3字符串并没有发生变化
System.out.println("s3:"+System.identityHashCode(s3));// 2125039532
// s4指向常量池的对象,所以s3和s4的内存地址不一致,所以最后返回false
String s4 = "aaa";
System.out.println("s4:"+System.identityHashCode(s4));// 621009875
System.out.println(s3==s4);// false
第二次比较的代码就容易理解了,正如上面提到的inter方法的作用,s3 = s3.intern();
,返回值为字符串常量池中的字符串对象,所以s3
的内存地址发生了变化,s3和s4相等。
最后看第三次比较的内容,这是一段很有意思的代码。
// 此处共创建了几个对象?
// 常量池"hi","j",以及hi和j的字符串实例对象,s5也是字符串实例对象“hij”,注意此时常量池中并没有"hij"字符串
String s5 = new String("hi") + new String("j");
// 此时s5对象的内存地址为621009875
System.out.println("s5:"+System.identityHashCode(s5));// 621009875
// 调用intern方法,这一步很重要,因为常量池中并没有"hij"字符串,所有s5对象引用复制到hashtable中,字符串常量池和s5指向同一块内存
s5.intern();
// 打印出s5的内存地址,确认s5并没有变化
System.out.println("s5:"+System.identityHashCode(s5));// 621009875
// 创建一个字符串常量,因为字符串常量池中已经存在,直接引用
String s6 = "hij";
// 打印s6的内存地址,内存地址和s5的一致,所以s5和s6相等
System.out.println("s6:"+System.identityHashCode(s6));// 621009875
System.out.println(s5 == s6); // true
如何利用intern
方法节约内存呢?
假设要创建一些用户对象,这些用户在地址信息上有重合的,比如省份,城市等信息,这时候我们可以在每次赋值的时候使用String类的intern
方法,如果常量池中存在相同的值,就会重复使用该对象,返回对象引用,这样一开始的对象就可以被回收掉。