StringTable
本专栏学习内容来自尚硅谷宋红康老师的视频以及《深入理解JVM虚拟机》第三版
有兴趣的小伙伴可以点击视频地址观看,也可以点击下载电子书
String的基本特性
-
String声明为final,不可被继承
-
String实现了Serializable接口,表示字符串支持序列化,实现了Comparable接口,表示String可比较大小
-
String在jdk8及以前内部使用char[]存储字符串数据,jdk9时改为了用byte[]存储字符串数据
-
String代表不可变的字符序列,简称:不可变性
-
通过字面量的方式给一个字符串赋值,此时字符串值声明在字符串常量池中
String str = "abc";
为什么从char[]改为byte[]?
使用byte[]而不是char[]的原因是为了节约内存。因为绝大多数字符串只包含英文字母数字等字符,可使用Latin-1编码方案,一个字符占用一个byte。 如果这个时候使用char[],一个char要占用两个byte,会占用双倍的内存空间。 点击跳转
String的不可变性
通过以下代码来体会String的不可变性
当对字符串重新赋值时,需要重写指定内存区域复制,不能使用原有的value进行赋值
@Test
void test1(){
String a = "abc";
String b = "abc";
System.out.println(a == b);//true
}
通过重新赋值的方式,并不是改变字符串常量池中abc的值,而是在字符串常量池中重新开辟一块空间给abcde
@Test
void test2(){
String a = "abc";
String b = "abc";
b = "abcde";
System.out.println(a == b);//false
System.out.println(a);//abc
System.out.println(b);//abcde
}
当对现有的字符串进行连续操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
@Test
void test3(){
String a = "abc";
String b = "abc";
b += "def";
System.out.println(a);//abc
System.out.println(b);//abcdef
}
当调用String的replace方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
@Test
void test4(){
String a = "abc";
String b = a.replace('a','m');
System.out.println(a);//abc
System.out.println(b);//mbc
}
StringTable的基本特性
StringTable又称StringPool,一般大家都称呼为字符串常量池。
字符串常量池中是不会存储相同内容的字符串的
-
字符串常量池是一个固定大小的HashTable,默认值是大小长度是1009。如果放进StringTabled的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了之后直接会造成的影响就是当调用String.intern时性能会大幅下降
-
使用
-XX:StringTableSize
可设置StringTable的长度 -
在JDK6中StringTable是固定的,也就是1009长度,所以如果常量池中的字符串过多就会导致效率下降很快,StringTableSize设置大小没有要求
-
在JDK7中,StringTable的长度默认值是60013,StringTableSize设置大小没有要求
-
在JDK8中,StringTable的长度默认值是60013,1009是可设置的最小值
如果设置小于1009的值,程序启动时就会报错
String的内存分配
- 在Java语言中有8种基本数据类型和一种比较特殊的类型String,这些类型为了使他们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
- Java6及以前,字符串常量池存放在永久代
- Java7中,对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内
- 所有的字符串都保存在堆中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了
- 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑Java7中使用String.intern()
- Java8永久代改为元空间,但是字符串常量池还是存在堆中
为什么要从永久代调整到堆空间?
- 永久代的空间小
- 永久代执行垃圾回收的频率低
证明
通过调用String.intern()
方法可以将字符串放入到字符串常量池中
/**
* @Author HKX
* @DATE 2022/8/16 16:32
* 概要:-Xms6m -Xmx6m
*/
public class StringTest {
public static void main(String[] args) {
HashSet<String> set = new HashSet<>();
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
将堆空间大小设置为6m,执行上述代码,程序抛出内存溢出,而抛出内存溢出的地方在堆空间,由此可以证明字符串常量池存放在堆空间
String的基本操作
通过以下案例,可以观察字符串常量池中字符串的数量,当我们新放入1,2,3...
时,字符串常量池中的数量会逐一增减,当我们重复放入1,2,3...
时,发现字符串常量池中的数量并不会增加,由此可以得到以下两个结论。
- 字符串常量池不会出现重复的字符串
- 当字符串常量池已经存在相同的字符串时,对于该字符串的引用将指向原有字符串的地址
字符串拼接操作
首先先得到结论,我们在进一步的通过案例去证明该结论
- 常量与常量的拼接结果在常量池,原理是编译器优化
- 常量池中不会存在相同内容的常量
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
- 如果拼接的结果调用
intern()
方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址
面试题讲解
常量与常量的拼接结果在常量池,原理是编译器优化
@Test
void test5() {
String a = "ab";
String b = "a" + "b";
System.out.println(a == b);//true
}
执行上述代码返回true,这是因为已经被编译器优化,来看一下编译的结果,在编译器就直接将b拼接完成
只要其中有一个是变量,结果就在堆中。
@Test
void test6(){
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";//编译期优化
//如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
String s5 = s1 + "hadoop"; //结果存储在堆中
String s6 = "javaEE" + s2; //结果存储在堆中
String s7 = s1 + s2; //结果存储在堆中
System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
变量拼接的原理是StringBuilder
@Test
public void test7(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
/*
如下的s1 + s2 的执行细节:(变量s是我临时定义的)
补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
*/
String s4 = s1 + s2;
System.out.println(s3 == s4);//false
}
可以通过jclasslib来观察s1+s2
的底层原理
- StringBuilder s = new StringBuilder();
- s.append(“a”)
- s.append(“b”)
- s.toString() --> 约等于 new String(“ab”)
常量的拼接
- 字符串拼接操作不一定使用的是StringBuilder
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。 - 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
拼接操作与append操作的效率对比
我们执行以下代码可以发现拼接的操作远远低于append操作,这是由以下两个原因引起的。
- 对于append操作来说,只需要创建一个StringBuilder对象;而对于拼接操作来说,每一次拼接都会创建一个StringBuilder对象,并且在调用
StringBuilder.toString()
方法时,还会额外的创建一个String对象 - 使用拼接的操作,内存中由于创建了很多个StringBuilder对象以及String对象,内存占用更大,更容易引发GC,需要花费额外的时间
@Test
public void test9(){
long start = System.currentTimeMillis();
// method1(100000);//4609
method2(100000);//5
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
}
public void method1(int highLevel){
String src = "";
for(int i = 0;i < highLevel;i++){
src = src + "a";//每次循环都会创建一个StringBuilder、String
}
}
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
}
这里的method2
其实还可以继续优化,在JDK8中,调用StringBuilder的空参构造器,实际上创建了一个大小为16的char数组,当长度满时,则会触发扩容机制,这有点类似于ArrayList(关于ArrayList的底层原理,可以去看小黄的这篇文章 点击跳转)。
而同ArrayList一样,在创建StringBuilder时可以调用他的有参构造器,其中提供了创建StringBuilder长度的有参构造器,这样我们就不需要进行扩容。
//执行过程只需要3ms
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder(highLevel);
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
}
intern()方法
理解
intern()
方法是使用C/C++编写的本地方法
第一块红块翻译过来大致意思为:当调用intern()
方法时,如果字符串常量池中已存在该字符,则返回该字符的地址;如果池中没有该字符,则将该字符写入池并且返回池中该字符的地址。
第二块红块翻译过来大致意思为:对于任意两个字符串,当且仅当s.equals(t) = true
,那么s.intern() == t.intern() = true
如何保证变量s指向的是字符串常量池中的数据?
//1,使用字面量的方式赋值
String s = "abc";
//2.使用intern()方法
String s = new String("abc").intern();
String s = StringBuilder.toString.intern();
new String()到底创建了几个对象
对比以下两种创建字符串的方法,我们来探究其中的底层逻辑
@Test
void test10(){
String s1 = new String("ab");
String s2 = new String("a") + new String("b");
}
String s1 = new String(“ab”);
对于对象的创建,我们直接来看字节码文件会非常的清楚
- 在堆中创建一个String的对象,这是毋庸置疑的
- 如果字符串常量池中没有ab,则在池中创建一个ab的对象
String s2 = new String(“a”) + new String(“b”);
这种情况的字节码相对来说比较复杂
- 对于字符串的拼接,之前了解过底层使用StringBuilder,所以肯定会创建一个StringBuilder对象
- new String(“a”) 在堆中创建一个String对象
- 在常量池中创建一个a的对象
- new String(“b”) 在堆中创建一个String对象
- 在常量池中创建一个b的对象
- 调用StringBuilder.toString()方法,会在堆中创建一个String对象
两种方式的最大不同点在于new String("ab")
会在常量池中创建ab对象,而String s2 = new String("a") + new String("b")
不会再常量池中创建ab对象
经典面试题
public class StringTest1 {
public static void main(String[] args) {
/**
* 对于s1来说,创建了new String("1")对象,并且在字符串常量池中放入了1
*/
String s1 = new String("1"); //s1的地址指向堆中的new String("1")
s1.intern(); //常量池中已经存在,这部操作相当于不起作用
String s2 = "1"; //s2此时指向字符串常量池中的"1"
System.out.println(s1 == s2);//JDK6:false JDK7/8:false
/**
* 对于s3来说,他并没有在字符串常量池中创建"11"
*/
String s3 = new String("1") + new String("1");//s3的地址指向堆中的地址
s3.intern();//在字符串常量池中创建"11"
String s4 = "11";//s4指向字符串常量池中的“11”
System.out.println(s3 == s4);//JDK6:false JDK7/8:true
}
}
为什么在s3与s4比较是会出现两种不同的情况呢?
我们知道JDK6及以前是将字符串常量池存放在永久代的,在JDK7以后将字符串常量池存放在堆中。
intern()
方法使用
- JDK6中,将这个字符串对象尝试放入常量池
- 如果池中有,则不会放入。返回已有的池中的对象地址
- 如果没有,会把此对象复制一份,放入池中,并返回池中的对象地址
- JDK7起,将这个字符串对象尝试放入常量池
- 如果池中有,则不会放入。返回已有的池中的对象地址
- 如果没有,会把此对象的引用地址复制一份,放入池中,并返回池中的对象地址
如下图所示,JDK7起,上述情况下,为了节约空间,字符串常量池会存储对象的地址
这道经典面试题,小黄在实验的过程中发现,放在main方法执行与JUnit测试执行输出的结果不一样。
//JDK8环境下
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);//JUnit:false main:true
这是因为在JUnit测试执行的情况下,不知何种原因,会往字符串常量池中存入“11”,也就是说在代码执行前就已经有“11”的存在,这我们可以通过观察字符串常量池中的数量来验证这个结果。
空间效率测试
对于程序中大量存在重复的字符串时,使用intern()
方法可以节省内存
public class StringIntern2 {
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
public static void main(String[] args) {
Integer[] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
arr[i] = new String(String.valueOf(data[i % data.length]));
// arr[i] = new String(String.valueOf(data[i % data.length])).intern();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.gc();
}
}
执行上述代码,在不使用intern()
,发现会占用大量内存
使用intern()
后,明显减少内存的占用