第六篇章——字符串常量池

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永久代改为元空间,但是字符串常量池还是存在堆中

为什么要从永久代调整到堆空间?

  1. 永久代的空间小
  2. 永久代执行垃圾回收的频率低

证明

通过调用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...时,发现字符串常量池中的数量并不会增加,由此可以得到以下两个结论。

  • 字符串常量池不会出现重复的字符串
  • 当字符串常量池已经存在相同的字符串时,对于该字符串的引用将指向原有字符串的地址

在这里插入图片描述

字符串拼接操作

首先先得到结论,我们在进一步的通过案例去证明该结论

  1. 常量与常量的拼接结果在常量池,原理是编译器优化
  2. 常量池中不会存在相同内容的常量
  3. 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
  4. 如果拼接的结果调用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的底层原理

  1. StringBuilder s = new StringBuilder();
  2. s.append(“a”)
  3. s.append(“b”)
  4. s.toString() --> 约等于 new String(“ab”)

在这里插入图片描述

常量的拼接

  1. 字符串拼接操作不一定使用的是StringBuilder
    如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
  2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。

在这里插入图片描述

拼接操作与append操作的效率对比

我们执行以下代码可以发现拼接的操作远远低于append操作,这是由以下两个原因引起的。

  1. 对于append操作来说,只需要创建一个StringBuilder对象;而对于拼接操作来说,每一次拼接都会创建一个StringBuilder对象,并且在调用StringBuilder.toString()方法时,还会额外的创建一个String对象
  2. 使用拼接的操作,内存中由于创建了很多个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”);

对于对象的创建,我们直接来看字节码文件会非常的清楚

  1. 在堆中创建一个String的对象,这是毋庸置疑的
  2. 如果字符串常量池中没有ab,则在池中创建一个ab的对象

在这里插入图片描述

String s2 = new String(“a”) + new String(“b”);

这种情况的字节码相对来说比较复杂

  1. 对于字符串的拼接,之前了解过底层使用StringBuilder,所以肯定会创建一个StringBuilder对象
  2. new String(“a”) 在堆中创建一个String对象
  3. 在常量池中创建一个a的对象
  4. new String(“b”) 在堆中创建一个String对象
  5. 在常量池中创建一个b的对象
  6. 调用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()后,明显减少内存的占用

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值