JVM深入理解(5)——字符串常量池

写在最前,本篇文章大部分来源于b站尚硅谷JVM全套教程的提炼,并附带自己的理解。主要是为了帮助自己理解,和用于复习。如果同时还能对其他人有所裨益,那就更好不过了。如果有谬误的地方,还请不吝指出。

String基本特性

  1. 两种定义方式

    1. 使用字面量直接赋值
    2. new生成对象
  2. 声明为 final 不可被继承

  3. JDK1.8及之前用char[]数组存储。JDK1.9之后使用byte[]数组容器存储。
    为什么要改?
    1.8及以前,用两个字节来表示一个字符,但在实际运用中,发现大多数字符串只使用了拉丁字符,它们只需要1个字节就能存放得下,这样来看,一半的空间就被浪费掉了。
    所以改变了内部存储结构,从UTF-16到byte数组,并加上一个encoding-flag域,用于判断字符串该用哪种方式存储。
    不仅如此,基于String类型的类都相应地进行了修改。

  4. 具备不可变的特性

    基于字面量赋值的String变量存储于字符串常量池中。
    一旦一个字符串常量被创建,就不会被修改。
    譬如:+=、replace等方法只是新创建了一个字符串,并非在原来的字符串上修改。

  5. 字符串常量池不会存储相同内容的字符串。
    String Pool 是一个固定大小的Hashtable,JDK6默认长度1009,JDK7以后默认60013。如果放进池的String非常多,就会冲突严重而使得链表变长,使得性能大幅下降。 长度是固定的,可以提前用-XX:StringTableSize设置大小。在JDK6可以任意设置。JDK8及以后最小设置为1009。

String的内存分配

常量池类似一个Java系统级别提供的缓存。8种基本类型的常量池都是系统协调的。

String类型比较特殊,如果想要存放在常量池中:
如果字面量赋值,直接存在常量池中。
当我们调用intern方法时,会在常量池中生成对应字符串。(可以通过制造OOM证明常量池处于堆中)

为什么要将字符串常量池放在堆中?(JDK8以后)

  1. 永久代默认内存较小
  2. 永久代垃圾回收频率低

如果字符序列是一样的, 就不会创建新的字符串。

字符串拼接操作

  1. 常量与常量的拼接结果在常量池,原理是编译期优化(在生成字节码文件的时候就已经完成了拼接)
  2. 只要其中有一个变量,结果就在堆中(非常量池区域),原理是StringBuilder
  3. 如果拼接结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回对象地址。

代码示例

这里用我曾经使用过的一个代码例子:

        String s1="hello";
        String s2="hell";
        String s3="o";
        String s4="hello";
        String s5=new String("hellow");
        String s6=new String("hellow");
        System.out.println(s1==s4); //1
        System.out.println("hell"+"o"==s4);//2
        System.out.println((s2+s3)==s4); //3
        System.out.println(s5.intern()==s5);//4


第一个打印语句为true说明:字面量赋值,只要字符序列相同,就指向常量池中的同一个字符串。T
第二个打印语句为true说明:常量相加不会在堆中创建字符串**(在本文中,以堆指代常量池之外的部分)**
第三个打印语句为false说明:如果在拼接字符前后出现了变量,则相当于在堆空间中new String()(无论变量本身是否由字面量赋值)(其实就是StringBuilder::toString()方法)
intern():相当于做一个校验,判断字符串常量池中是否存在这个值,如果存在,则返回其地址;如果不存在,则在常量池中加载一份,再返回地址。所以第四个语句为false。

找到第一个打印语句的字节码指令

 55 new #10 <java/lang/StringBuilder>
 58 dup
 59 invokespecial #11 <java/lang/StringBuilder.<init> : ()V>
 62 aload_2
 63 invokevirtual #12 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
 66 aload_3
 67 invokevirtual #12 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
 70 invokevirtual #13 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>

可以发现如果拼接字符串出现变量,其实是创建了一个StringBuilder来进行字符串拼接。
注意:如果变量被声明为final,即此变量中途不会再改变其引用对象,那么就会被视作为常量,不会使用StringBuilder进行拼接。当然,如果直接使用字面量拼接,也不会使用StringBuilder。

结合这些知识,我们来判断一下下面这两个方法哪个更快:

String method1(int level){
        String src = "";
        for(int i=0;i<level;i++){
            src=src+"a";
        }
        return src;
    }

String method2(int level){
     StringBuilder src = new StringBuilder();
     for(int i=0;i<level;i++){
         src.append("a");
     }
     return src.toString();
 }

实际上,在method1中的每次循环,都会创建一个新的StringBuilder对象,并且还要调用toString()方法,所以显然method2更快。
method2:内存中由于创建了较多的StringBuilder和String对象,内存占用更大,可能需要GC。

那么method1是否还有改进的空间的呢?
在创建StringBuilder对象时,有一个可选的参数capacity,如果初始就能知道要容纳数组的大小,就可以避免扩容产生的额外的空间。

intern()的使用*

intern()方法官方解释 :对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

如果不是通过双引号声明的Stirng对象,可以使用intern方法,从字符串常量池中查询当前字符串是否存在,若不存在,则将字符串放入池中,并返回池中的引用地址。

注意:调用intern方法的字符串无论是指向堆中的对象还是常量/字面量,都会去常量池中寻找字符串,并且只会返回常量池中相应字符串的地址。

intern()方法用于保证字符串在内存中只有一份拷贝。

intern的难题*

抛出intern问题之前,先问两个问题:

new String(“ab”)会创建几个对象?
假设之前常量池中不存在“ab”,则会创建两个对象。
堆中创建的对象和常量池中的对象。

new String(“a”)+new String(“b”)创建几个对象?
对象1:StringBuilder对象
对象2:new String(“a”)堆中对象
对象3:常量池中“a”对象
对象4:new String(“b”)堆中对象
对象5:常量池中“b”对象
对象6:StringBuilder对象 toString()方法创建对象于堆中(但没有在字符串常量池中生成对象)

下面来讲intern() (注意jdk6和jdk7/8的区别):

String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s==s2);

String s3 = new String("1")+new String("1");
//注意此时常量池中没有创建新的对象
s3.intern();
String s4 = "11";
System.out.println(s3==s4);

jdk6中,结果是false, false
jdk7/8中,结果是false,true

jdk6,如果没有这个字符串,intern()一定会在常量池创建变量
是jdk7/8以后,如果已经在堆中创建了一个字符串对象且没放入常量池,则调用intern()会在常量池中存放一个指向这个对象的引用,使得后面所有字面量相同的字符串都会指向这一个对象,而不会再到常量池中创建新的变量。‘

总结几个关键点:

  1. intern()方法:注意jdk版本的差异,差异在于jdk7以后为了不重复创建字符串,会去堆中寻找。而jdk6只会在常量池中寻找。
  2. StringBuilder的toString()方法和new String的区别是,前者不会在常量池中创建对象
    以下代码将第一行注释和不注释会得到一样的效果,证明它不会在常量池中创建对象。
     String s = new StringBuilder("a").append("b").toString();
    String s1 = new String(new char[]{'a', 'b'});
    s1.intern();
    System.out.println("ab"==s1);//true
    
  3. 变量和常量的区别:无论是拼接字符串或是StringBuilder都要通过变量才能达到上述效果,因为如果是常量,会在编译期阶段就被优化为字面量了。
    通过idea查看字节码文件,上一段代码其实就是:
    String s = "a" + "b";
    String s1 =new String(new char[]{'a', 'b'});
    s1.intern();
    System.out.println("ab" == s1);
    
    我们更改代码:
     String s = new StringBuilder("ab").toString();
    
    发现编译后依然是:
    String s = "ab";
    
    证实了我们的想法。
  4. 在大量使用字符串变量的场景,使用intern字符串可以降低内存消耗,因为堆中对象会被GC回收,而只剩下常量池中的对象。

G1的String去重操作

去重并不包括常量池,因为其本身就只有一份,而是指堆中new对象的去重。

通过测试发现(官方):

  1. 堆存活数据集合String对象占了25%
  2. 堆存活数据集合里,重复的String对象有13.5%
  3. String对象平均长度45

在G1垃圾回收器自动实现对重复的String对象去重,避免浪费内存。

步骤:

  1. 当垃圾回收器工作时,访问堆上存活的对象,检查是否是候选的要去重的String对象
  2. 如果是,则将其引用插入到队列中,一个去重线程在后台运行处理队列。处理一个元素意味着从队列里删除,并尝试去重它引用的String对象
  3. 使用一个hastable来记录所有的被String对象使用的不重复的char数组。当去重时,会查这个hashtable,看是否已经存在一个一样的char数组。
  4. 如果存在,则会对象被调整为引用那个数组,释放对原数组的引用,被垃圾回收器回收。
  5. 如果不存在,则char数组会被插入到hashtable,留给以后使用。

UseStringDeduplication:开启string去重,默认不开启。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值