JVM-StringTable


前言

String是java语言最重要的类,也是最重要的数据类型。
但是你真的了解String吗?试从jvm角度分析String
以及深度剖析String类的常见面试题

一、String特性

声明方式:
(1)字面量
即String a = “a”;
(2)对象式
String a = new String(“a”);
经过堆的学习,new出来的肯定都是去堆中创建对象。
而字面量方式会去堆中的字符串常量池引用对象,若没有,再在其中创建对象。


String类特点:

  • String是不可变类

具有以下特点:

  1. 类中所有的变量都不能进行修改。【因此也可以说是常量】
  2. 类中不提供setXXX()方法;
  3. 类不可被继承,使用final修饰【继承之后有可能子类实例有修改父类变量的能力】
  4. 类的成员全部使用final修饰
  5. 只提供带参构造方法指定对象值,一旦创建,直到对象被销毁,值都是永久的
  6. 提供getter方法时,必须返回对象的clone(),确保不会因为浅拷贝而导致对象值被修改
  • Serializable:为了java对象能够跨阶层的传输,提供Serializable这个接口标记对象能够从和到二进制流的相互转化。

String的底层实现
jdk1.8使用char[]
jdk9使用的是byte[]加上@Stable注解
在这里插入图片描述

动机:早期的String使用char[]来存储,但是实际使用的大部分String的数据都是基于Latin-1(拉丁)字符,而拉丁字符数量是小于256个的,因此每个拉丁字符完全可以使用一个字节就能存的下,因此使用两个字节的char来存储导致总有一半空间被浪费了。
因此,使用byte[]来代替char[].
并且,为了照顾其他编码的(UTF-8等)语言,允许他们在自己的编码首部加上一个自己编码的标识,这样的标识让jvm可以去读取几个连续的byte的,因此也可以解决一个byte不够用的场景。

在这里插入图片描述
同时,基于String的类,如StringBuilder、StringBuffer、JVM内部String操作都做了修改。


String的重新赋值都会导致一个新的String变量被制造出来。
在这里插入图片描述


一道关于不可变性的面试题:
在这里插入图片描述
str的值不会改变,ch的内部会改变。
主要因为String的不可变性,形参str的引用值被改变,指向了新的String对象。
而java是值传递方式,每次只会把当前对象的引用复制一份传到方法中,因此原来引用的值不会修改,在方法内部修改形参的引用不会影响到调用者的原版引用。


有几种方式的String使用会使用常量池:
①字面量;

②intern //当前字符串引用从常量池中查找地址并返回引用;若未查找到会在常量池中创建并返回

③常量: 常量型变量【这里的变量不是变的意思】会在类加载时的prepare阶段就赋值,且之后不会修改。


字符串常量池的底层实现:HashTable【这可能是为什么字符串常量池叫做StringTable的原因】

HashTable是什么?
HashTable可以理解为并发版的HashMap,与后者不同,他的键和值都不允许null【hashMap都允许】。
HashTable由于实现了并发,因此性能较低;且性能低于ConcurrentHashMap,因此现在不推荐使用。
HashTable等Map的底层实现都是数组

正因为HashTable的键的唯一性,字符串常量池中的字符串不会有重复的.
既然是数组,其最大大小就可以设置【当其充斥度达到0.75时,就会扩容。减少Hash碰撞的发生,降低性能】
在这里插入图片描述
jkd6,默认1009
jdk7,默认60013
jdk8时,StringTable默认为60013,若设置最低位1009
命令:

  • 查看StringTable大小:jinfo -flag StringTableSize
  • 修改StringTable大小:-XX:StringTableSize

测试不同大小字符串常量池对操作字符串时程序数据的影响:
(1)生成十万行随机字符串
(2)设置不同大小常量池读取,测试时间差距

static class MakeText{

        public static void main(String[] args) {
            FileWriter fw = null;
            try {
                fw = new FileWriter("a.txt");

                for (int i = 0; i < 100000; i++) {
                    fw.write(getString());
                }
            } catch (IOException e) {
                e.printStackTrace();
            }finally {
                try {
                    fw.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

       public static String getString() {
           Random random = new Random();
           int len = random.nextInt(10);
           StringBuilder sb = new StringBuilder();
           for (int i = 0; i < len; i++) {
               sb.append((char)(random.nextInt(60) + 'A'));
           }
           sb.append("\n");
           return sb.toString();
       }

    }

    static class ReadText{

        public static void main(String[] args) {
            long start = System.currentTimeMillis();
            try(
                    BufferedReader br = new BufferedReader(new FileReader("a.txt"));

            ) {
                String line = null;
                while ((line = br.readLine()) != null) {
                    line.intern(); //若字符串常量池中没有这个值,就在常量池创建它
                }
                System.out.println(System.currentTimeMillis() - start);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

使用常量池大小为1009:
在这里插入图片描述

使用常量池大小为10000009
在这里插入图片描述
性能提示了七倍。


jdk7中,由于StringTable从永久代移入到堆中,StringTable才能放心大胆的扩容。
为什么移入了堆中效果拔群?

  • 移入堆中,GC的频率大大提高【永久代几乎不会发生GC,而字符串又是常常创建一次性的,需要大量GC】
  • 堆的空间大小远远大于永久代,更不容易发生OOM
    在这里插入图片描述

二、String的基本操作

2.1 添加到常量池

下图的代码中,打开debug的内存监控,发现第一轮一到十的输出导致每次操作String类对象数目加一【原来就有2000多个,恐怖如斯】
而第二轮不会增加一个String常量池。
可见使用双引号的字面量会直接在字符串常量池查找或者创建对象。
在这里插入图片描述
相同的String字面量具有相同的码点序列【即其编码之后的值是一样的】

官方提供的·方法中的字符串创建case:
当方法中字符串创建时,首先通过toString()在字符串常量池生成一个引用,该对象中的该部位拥有一个指向该StringTable位置的引用,同时给栈帧也返回一个引用。
在这里插入图片描述

2.2 拼接

几个结论:【VeryImportant】
在这里插入图片描述


case1:
证明拼接的字符串会被编译器优化为拼接后结果存储在常量池中

IDEA有自动反编译的效果:打开.class文件,可以直接看到编译后的代码

  public static void main(String[] args) {
        String a = "a" + "b" + "c";
        String b = "abc";

        System.out.println(a == b); //true
        System.out.println(a.equals(b));//true
    }

a经过编译期优化,变成字节码中就会变成 String a = "abc";
因此会在常量池创建常量对象,b就会直接去常量池寻找

在这里插入图片描述
从字节码也可以看到,第一个变量就是:abc"


case2 :证明,只要有一个是变量,拼接的结果就会存储到

public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = "a" + "b";
        String s5 = s1 + "b";
        String s6 = "a" + s2;
        String s7 = s1 + s2;

        String s9 = s6.intern();
        String s10 = s4.intern();
        String s11 = s5.intern();

        System.out.println(s3 == s4); //true
        System.out.println(s3 == s5); //false
        System.out.println(s3 == s6); //false
        System.out.println(s4 == s5); //false
        System.out.println(s6 == s5); //false
        System.out.println(s9 == s10); //true
        System.out.println(s7 == s10); //false
        System.out.println(s11 == s4); //true

    }

当拼接操作中出现了一个变量,拼接的结果就相当于进行了 new String () ,因此存储到了堆中。


变量拼接的底层实现:

  1. 创建StringBuilder类【注,1.5之前使员工的StringBuffer】
  2. 调用append()方法;
  3. 调用toString()方法;【toString()的底层是一个String类的构造方法,约等于new String(“xxx”)】

case:使用字节码查看拼接操作的过程

    public static void main(String[] args) {
        String a = "a";
        String b = "b";
        String ab = a + b;
    }
 0 ldc #7 <a>
 2 astore_1
 3 ldc #9 <b>
 5 astore_2
 6 aload_1
 7 aload_2
 8 invokedynamic #11 <makeConcatWithConstants, BootstrapMethods #0>
13 astore_3
14 return

走来两次加载字符串常量池,并存储到局部变量表。
注意看第三个:

【其实我也被震惊到了,这和老师说的不一样啊】
invokedynamic?
查了一下,这是jdk9的变动,将字符串拼接的行为交给了一个工厂类:StringConcatFactory.makeConcatWithConstants

invoke-dynamic 调用一个方法名为invoke,返回值为callsite的方法。这方法的前四个参数为methodhandler,methodhandler.lookup,strong,methodtype. 它可用于支持编译lambda 函数什么的。

jdk8左右的版本:
在这里插入图片描述
这个才是老师说的那样:按照创建对象的三部曲:
(1)new (2)dup复制引用到操作数栈 (3)调用构造方法


《结论三》;常量的String成员拼接时,会在编译器进行优化,被等价为一个融合后的String。字符串常量的赋值在prepare阶段。
在这里插入图片描述

public static void main(String[] args) {
        final String a = "a";
        final String b = "b";
        String ab = a + b;
        String ab2 = "a" + "b";
        System.out.println(ab == ab2);//true
    }
 0 ldc #7 <a>
 2 astore_1
 3 ldc #9 <b>
 5 astore_2
 6 ldc #11 <ab>
 8 astore_3
 9 ldc #11 <ab>
11 astore 4
13 getstatic #13 <java/lang/System.out>
16 aload_3
17 aload 4
19 if_acmpne 26 (+7)
22 iconst_1
23 goto 27 (+4)
26 iconst_0
27 invokevirtual #19 <java/io/PrintStream.println>
30 return

从命令0到11都基本上是一毛一样的指令,ldc是指从字符串常量池取字符。
这与字面量的字节码操作如出一辙【即编译器优化】,可以得出结论:
final成员 = 字面量成员

另外一个建议:
开发时多使用final,可以早点赋值【prepare】,而且会直接创建常量池变量。


总结一下:
“变量”的拼接使用对象拼接【jdk9使用StringComcatFactory,jdk使用StrignBuilder】
常量的拼接使用字面量方式。


为什么StringBuilder的拼接速度远远高于+的拼接
两个对象创建
内存占用大,gC

还可以改进:
需要扩容,每次扩容都复制一次,前面的丢弃
StringBuilder的有参构造【int capacity】

三、intern()方法

3.1 api文档描述:

在这里插入图片描述
当常量池已经存在这个String值时,返回该常量引用;
当不存在时,会在常量池创建一个,并返回引用。


3.2 引出的几个面试题

  1. String ab = new String(“ab”); 创建了几个对象?
    两个对象:
  • new创建的正常对象;
  • 字符串常量池创建的常量
 0 new #7 <java/lang/String>
 3 dup
 4 ldc #9 <ab>
 6 invokespecial #11 <java/lang/String.<init>>
 9 astore_1
10 return

看前四句:
平时堆中创建对象的流程应当是三步走策略:
new, dup, invokespecial

这次好像多了一个不速之客。
前面说过,ldc是指去字符串常量池加载内容。
这就很奇怪了,我已经new了对象了,为什么还去字符串常量池得到引用?
因为创建对象时,会检查常量池有没有这个常量,没有就会创建并返回引用。


  1. String ab = new String(“a”) + new String(“b”);中创建了几个对象?
 0 new #7 <java/lang/String>
 3 dup
 4 ldc #9 <a>
 6 invokespecial #11 <java/lang/String.<init>>
 9 new #7 <java/lang/String>
12 dup
13 ldc #14 <b>
15 invokespecial #11 <java/lang/String.<init>>
18 invokedynamic #16 <makeConcatWithConstants, BootstrapMethods #0>
23 astore_1
24 return
  • 出现+号,必定创建了StringBuilder对象;《我是9以上版本,创建StringComcatFactory对象》
  • new String()创建堆中a对象;
  • ldc说明了字符串常量池出现了“a”
  • new String() b
  • 字符串常量池出现b
  • +导致StringBuilder进行append(), 最后toString()中创建new String(“ab”).

正常的new String()与StringBuilder类的toString()中的new String()有什么区别?
正常的new String()会创建两个String对象,一个堆中正常对象,一个String常量池对象;
toString()中的new String()只会创建堆中变量,不会在字符串常量池添加对象。


重头戏:
3. 看图吧:在这里插入图片描述
此时s == s2 为false; s3 == s4 为false【jdk7之前】, s3 == s4 为true【jdk7及以后】的原因?

第一个为false的原因:
String a = new String(“1”);会创建两个对象,第一个是堆中的,第二个是常量池中的。
第二句,a.intern(),因为常量池中已有对象,所以没有作用。

因此,引用a会指向堆中的正常变量地址。
引用b指向常量池中地址。
两者不是一个对象,自然是false。

s3 == s4辨析;
两个new String()并且+,经过上题的分析,会首先创建五个对象,最后toString()生成一个new String(),且这个new String()不会放到常量池中。
因此s3指向堆中new的对象;
下句s3.intern()会生效,因为他此时并没有在常量池生成对象。
s4会指向常量池的对象。

照上面的分析,s3与s4应该指向的是不同的对象,那肯定是fasle了,那为什么只有jdk7之前的版本才是这样的?
jdk7时,字符串常量池移入堆区,此时有一种新的特性出现了:
当堆中已经有该String变量时,intern()不会在创建新的对象,而是在StringTable中创建一个指针,指向之前创建的String对象,因此s4经过两次指针应用的中转,最终指向之前s3创建的String
因此s3与s4指向同一个变量。
而jdk6时,两者都不在一个区,只能选择创建新对象,因此之前确实是false。

注意,是intern()方法会跳过生成new String()的操作,不要误以为都是这样的,不然上面那个jdk7也是true了。【使用字面量还是会毫不犹豫的真的去创建实打实的对象,而不会创建指针,只是intern()的个人行为】


变形

在这里插入图片描述
字面值不会生成引用,因此只会真的去常量池创建对象。
故s3、s4指向不同的变量。
在这里插入图片描述


注意点总结:

(1)使用+拼接变量会引起StringBuilder或者StringConcatFactory对象的创建。
(2)StringBuilder的toString()不会在常量池创建对象,普通显式书写的new String()会同时在常量池创建对象。
(3)intern()在1.7之后,再堆中已经有该String变量的情况下不会创建对象,而是生成引用。

指针指向示意:

在这里插入图片描述


case:
证明擅用intern()对时间和空间效率上的提升。


    public static void main(String[] args) {
        final int CAPACITY = 100_000_0;
        final  int[] pool = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        String[] result = new String[CAPACITY];

        long start = System.currentTimeMillis();

        for (int i = 0; i < CAPACITY; i++) {
            String s = new String(String.valueOf(pool[i % pool.length]));
//            String s = new String(String.valueOf(pool[i % pool.length])).intern();
            result[i] = s;
        }

        System.out.println(System.currentTimeMillis() - start);

        try {
            Thread.sleep(1000_000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可见时间和空间消耗都降低了许多。

前者的数组存储使得时刻有引用指向这个堆区的对象,而newString()又是买一送一的,导致StringTable中又造了这个对象,而String Tbale的字符串得不到数组的引用,就是恰白饭的,因此白白损耗了空间。
而堆区中实际被数组引用的对象由于得不到回收,一直占着堆区不放,后来就会越来越难分配空间,到了后来甚至需要不断的GC勉强维持生活,等到GC也GC不动了就会OOM。

后者那句因为intern()前面的引用接收的是字符串常量池的地址,因此堆中的地址会被GC掉,自然空间有所下降。相对的堆区较为空旷,分配效率高,又不会进行GC,执行效率也就提高了。

四、C1的String去重

堆内存中25%的内容都是String对象,而其中有13%都是重复的。

而大规模的java应用的性能瓶颈都在于内存空间不足,需要频繁地GC。
因此减少这String的堆内存一半浪费很有必要。

C1编译器设计者的想法是,若堆中有等值String的对象,将不再创建对象,而是直接指向这个对象的引用。

既然涉及到去重,我们最熟悉的HashMap与HashTable又登场了。
垃圾回收器在GC时会去查询这个记录所有不重复byte[]的HashTable,然后帮助重复的String引用指向Hashtable中已经存在的引用,而将没有引用指向的那个重复串释放。
在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值