vlambda博客
学习文章列表

【JDK源码】String源码学习笔记

代码运行环境:JDK 8

首先思考几个问题:

  1. String对象在不同的JDK中是如何实现的?

  2. String对象的不可变性是什么样的?

  3. 下面这段代码的输出结果是什么?

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对象不可变性呢?

再来聊聊 String 类的代码实现

String 类被 final 关键字修饰了,所以这个类不能被继承,final类的方法默认都是 final 的(final方法不能被子类的方法覆盖,但可以被继承)。

String 类的属性char[]也被finalprivate修饰了。final成员变量表示常量,只能被赋值一次,赋值后值不再改变。这就意味着String对象一旦创建成功,不能再对它进行改变。这也是String对象的不可变性。

优点

  1. 保证String对象的安全性,防止了可能出现的恶意修改

  2. 保证hash属性值不会频繁变更,确保了唯一性,使得类似HashMap容器才能实现相应的key-value缓存功能。

  3. 可以实现字符串常量池。

创建字符串对象的方式

// 字符串常量
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&trade; 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方法时,会去查看字符串常量池中是否有等于该对象的字符串的引用,如果有,就返回常量池中的字符串引用。

如果没有的话要分两种情况讨论:

  1. 在JDK1.6版本中,会复制堆中的字符串到常量池中,并返回该字符串引用,堆内存中原有的字符串由于没有引用指向它,将会通过垃圾回收器回收。

  2. 在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

最后看第三次比较的内容,这是一段很有意思的代码。

// 此处共创建了几个对象?
// 常量池"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方法节约内存呢?