Java基础之刨根问底第5集——字符串

原文转自我自己的个人公众号:Java基础之刨根问底第5集——字符串(由于是拷贝过来的,如果排版有问题,请看公众号文章)

  • 本系列不适合初学者,读者应具备一定的Java基础。

  • 从本集开始,本系列依据Java11编写。

注意:最近看到一组统计数据,作为JAVA8的下一个LTS(长期支持)版本,JAVA11目前的的使用率已经快追上JAVA8了,所以从本集开始,本系列会基于JAVA11编写。 

JAVA中的字符串

 

字符串的使用在任何一个程序语言中都是非常广泛的,JAVA中通常可以使用以下三种方式来存储字符:

  • String类

  • StringBuffer类

  • StringBuilder类

    其中,String类是比较特殊的,作为一个对象,它不属于8种原始数据类型,但也有别于其他普通对象,主要是因为String是不可变的,也就是说String类型一旦初始化后,值就不能再发生改变了。

    基于String的不可变性,为了提高效率和节省空间,JVM内部存在一个字符常量池。字符常量池初始是空的,只要程序中初始化一个字符串,就会放入池中暂存,当下一次需要同样的字符串时,就可以直接从池中共享,而不需要重新创建了(理想情况),接下来我们就来详细看看String类型。

1.常量池

    实际上JVM中的常量池可以存储很多类型的常量,字符串常量池只是其中的一种,例如,int型对象会放入整型常量池,前提是范围不在-32768到32767之间,因为在这个范围中的整型有对应的指令,不需要创建对象。

    下面用一个简单的例子,直观地展示下字符串常量池。

    代码比较简单,就是在类中初始化一个字符串,如下:


public class StringExample {
    private  String str = "abc";

    public static void main(String[] args) {

    }
}

​​​        编译成功后,使用javap反汇编class中的字节码,我们只需要关注结果中的Constant pool部分,如下图所示:

        其中的#2就是程序中初始化的String类型的str,可以看到它又指向了#22,下面的#22就是在字符串常量池中缓存的“abc”。

    当再次初始化一个值为“abc”的字符串时,就不会再创建新的对象了,代码如下:

public class StringExample {
    private String str = "abc";
    private String str2 = "abc";

    public static void main(String[] args) {

    }
}

    从上图中可以看到,常量池中有且只有一个“abc”。变量str和str2会共享常量池中“abc”这个对象的引用。

2.new关键字

    当String通过new关键字初始化时,情况就会有些不同了,因为new会强制在堆上创建一个对象。将例子中的初始化修改为:

private String str = new String("abc");

    这时可以看到,虽然也在字符串常量池中放入的值为“abc”的对象,但在#4的时候还使用构造函数创建了一个String对象。因此,这里虽然只是一行代码,实际上是创建了2个对象,而上一个例子中只有一个“abc”对象(在字符串常量池中)(面试中常常考到的知识点)。

    因此,在实际编程中,为了尽可能减少对象的创建个数,可以尽量避免使用new关键字来初始化字符串,而尽可能多地使用直接赋值的方式。

3.字符串常量池的位置

    在Java6的时候,字符串常量池存储在永久代(PermGen)中,但因为永久代的大小是固定的,虽然可以设置大小,但毕竟不灵活,这导致了Java6中的字符串常量池一直都比较受限(莫名其妙的OOM),甚至很多框架中会自己用WeakHashMap来替代JVM自身的常量池。下面给出一个WeakHashMap实现常量池的代码示例:

private static final WeakHashMap<String, WeakReference<String>> s_manualCache =
            new WeakHashMap<String, WeakReference<String>>(100000);

    private static String manualIntern(final String str) {
        final WeakReference<String> cached = s_manualCache.get(str);
        if (cached != null) {
            final String value = cached.get();
            if (value != null)
                return value;
        }
        s_manualCache.put(str, new WeakReference<String>(str));
        return str;
    }

    自从Java7将永久代取消后,字符串常量池就被放在了堆中,这样一来,不但大小可以跟着堆内存一起浮动,还可以享受垃圾回收器的照顾。

4.字符串常量池的大小

    在Java7u40之前,默认大小是1009。字符串常量池的数据结构和实现与HashMap非常相似,这里的1009指的是HashMap中的桶的个数。

    解释下“桶”的含义:简单来说,HashMap中有一个数组,每当有对象要放入HashMap中时,先会对这个对象进行HashCode的计算,然后放入数组的对应位置。由于不同对象的HashCode可能会相同,即:Hash碰撞,因此一个位置上实际上是一个链表,所以一个位置上就像一个桶一样放了多个HashCode相同的对象。

    从Java7u40开始字符串常量池桶的大小就增大到了60013,到Java11的时候,又增大到了65536。有资料中说java6、7、8中桶的大小是固定的。我在java8中测试了一下,确实是固定的,但我在用java11测试时发现桶的大小是会自动扩容的,测试用例代码如下:

public static void main(String[] args) throws InterruptedException {
        long num = 4000000;
        long begin = System.currentTimeMillis();
        for(int i = 0; i < num; i++) {
            if(i % 50000 == 0) {
                System.out.println(i + ":" + (System.currentTimeMillis() - begin));
                begin = System.currentTimeMillis();
            }
            Integer.toString(i).intern();
        }
        Thread.sleep( Duration.ofSeconds( 5 ).toMillis() );
    }

    运行程序是加上-XX:+PrintStringTableStatistics参数,用来输出字符串池的统计信息,最后一行的sleep是为了让统计信息有时间计算,否则可能无法输出。输出的结果如下:

    可以看到,这里的桶的数量已经到了524288,经过我的反复测试,每次扩容是按照2的n次方来扩的,比如第一次扩容是65536的2倍,然后是4倍,接着是8倍,以此类推。图中bucket size就是一个桶上链表的长度。

    从java7开始,可以通过-XX:StringTableSize参数来显示的设置桶的大小。

    在设置大小时,参考HashMap的实现方式,建议设置一个素数。对于HashMap来说,java11已经不要求是一个素数了,但字符串池的实现目前不得而知有没有优化,所以设置时还是设置一个素数比较好。

5.“桶”的个数对性能的影响

    由于字符串池的实现类似一个HashMap,因此就会有Hash碰撞的问题,多个不同的字符串可能会落在同一个桶上,桶数量越少,Hash碰撞的频率就越频繁,桶上链表的长度就会越长,访问时的效率就会越慢。

    我把上面例子里字符的数量改成3百万,输出如下:

。。。。。。

    

        冒号前是目前的记录数量,冒号后是最近向池中放入5万条记录的耗时,可以看到,耗时越来越长,就是因为Hash碰撞的原因导致桶上的链表越来越长。统计信息如下:

    从统计中可以看到,最长的一个桶的长度已经达到了92。

6.字符串池的使用

    了解了字符串池后,怎么用呢?

    用法通常有两种:

  • 使用赋值的方式初始化String类型的变量

    这种方式是最简单和直接的,只要通过“String str = "abc"”这种方式,就会共享地使用在字符串池中已存在的“abc”对象,如果不存在会先向池中放入该对象。注意:使用new的方式则无论如何都会创建新的对象

  • 使用String类中的intern()方法

    intern()方法会先在字符串池中寻找有没有要的字符串,如果有则返回引用,如果没有则向池中放入该字符串后返回引用(上面的例子中有用到)。我能想到的一种使用方式是,如果程序中有大量可重复的字符串,可以在程序启动时,先用intern方法把这些字符串放入池中缓存。用的时候再用intern方法取出。

7.StringBuffer和StringBuilder

    相比于不可变的String类型,StringBuffer和StringBuilder则是可变的,其中StringBuffer是线程安全的,而StringBuilder则是线程不安全的(理论上拥有更好的性能)。

    我用了一个字符串拼接的例子对比了一下他们的性能,代码如下:

public static void main(String[] args) {
        int times = 100000;
        System.out.println(timer(times, String.class));
        //System.out.println(timer(times, StringBuffer.class));
        //System.out.println(timer(times, StringBuilder.class));
    }

    private static long timer(int times, Class clazz) {
        String str = "";
        StringBuffer sBuffer = new StringBuffer("");
        StringBuilder sBuilder = new StringBuilder("");

        long begin = System.currentTimeMillis();
        if (clazz == String.class) {
            stringTimeTest(times, str);
        } else if (clazz == StringBuffer.class) {
            sBufferTimeTest(times, sBuffer);
        } else if (clazz == StringBuilder.class) {
            sBuilderTimeTest(times, sBuilder);
        } else {
            throw new RuntimeException("error");
        }
        long end = System.currentTimeMillis();
        return end - begin;
    }

    private static void sBuilderTimeTest(int times, StringBuilder sBuilder) {
        for (int i = 0; i < times; i++) {
            sBuilder.append(i);
        }
    }

    private static void sBufferTimeTest(int times, StringBuffer sb) {
        for (int i = 0; i < times; i++) {
            sb.append(i);
        }
    }

    private static void stringTimeTest(int times, String str) {
        for (int i = 0; i < times; i++) {
            str += i;
        }
    }

    例子比较简单,就是分别用StringBuffer和StringBuilder的append方法以及“+”号来拼接10万个字符。

    首先是String类型的变量用“+”号拼接,耗时4052ms;

    其次是StringBuffer使用append拼接,耗时16ms;

    最后是StringBuilder使用append拼接,耗时0ms(^_^)。

8.总结

  • 使用赋值的方式初始化String,尽量不用new的方式;

  • 有字符串拼接需求的,尽量使用StringBuilder,如果要线程安全使用StringBuffer。尤其是大量拼接,绝对不要用“+”号。

更多内容请关注我的公众号:

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值