Java基础之String漫谈(二)

1. 导读

上期分享了本人关于String四个问题, 本期我们继续探讨String中的两个问题:
.1 String既然已经实现了Comparable接口, 为什么还要提供内部类----CaseInsensitiveComparator;
.2 使用 "+" 拼接String究竟干了什么? 为什么在循环中不让使用"+"拼接String;

2. String为什么要提供内部类CaseInsensitiveComparator

先来看下String实现了Comparable接口后做了什么:

     public int compareTo(String anotherString) {
        int len1 = value.length;
        int len2 = anotherString.value.length;
        int lim = Math.min(len1, len2);
        char v1[] = value;
        char v2[] = anotherString.value;

        int k = 0;
        while (k < lim) {
            char c1 = v1[k];
            char c2 = v2[k];
            if (c1 != c2) {
                return c1 - c2;
            }
            k++;
        }
        return len1 - len2;
    }

String::compareTo做了三件事:
.1 比较两个字符串的长度, 找出最小值;
.2 比较最小长度中的字符是否相同, 因底层使用ASCII码存储, 10进制的ASCII是纯数字, 可直接减得出比较结果(compareTo规定: 返回-1是小于; 0是等于; 1是大于);
.3 如果最小长度的字符都相同, 再比较两个字符串的长度是否相同;

字符串是可能含有大小写的, 在String::compareTo中认为A和a是不同的, 那么在忽略大小写的场景中就不适用了;既然String提供了基于Comparator的内部类, 是不是对这种场景做了特殊处理呢?我们接下来看CaseInsensitiveComparator的核心实现:

     public int compare(String s1, String s2) {
            int n1 = s1.length();
            int n2 = s2.length();
            int min = Math.min(n1, n2);
            for (int i = 0; i < min; i++) {
                char c1 = s1.charAt(i);
                char c2 = s2.charAt(i);
                if (c1 != c2) {
                    c1 = Character.toUpperCase(c1);
                    c2 = Character.toUpperCase(c2);
                    if (c1 != c2) {
                        c1 = Character.toLowerCase(c1);
                        c2 = Character.toLowerCase(c2);
                        if (c1 != c2) {
                            // No overflow because of numeric promotion
                            return c1 - c2;
                        }
                    }
                }
            }
            return n1 - n2;
        }

可以看到compare的逻辑和String:compareTo大同小异, 只是在第二步的时候做了特殊处理:
.1 先将char字符转换成大写作比较(如果是数字则不变);
.2 如果大写比较不符, 再转换成小写做比较;
.3 如果小写比较还是不符, 证明该char字符为数字, 直接比较即可;

上面只是说明了这两者实现的不同, 还是没有说明为什么这么实现; 要解答这个首先需要说明下Comparable 和 Comparator的异同:
.1 两者都是接口, 都是实现对象的比较的, 返回值都是{-1, 0, 1};
.2 Comparable需要重写Comparable::compareTo方法, 会对比较对象的代码形成侵入; Comparator由一个比较目标对象的策略类来实现, 同时比较策略则由编写者指定, 无需侵入比较对象的代码;
故而String实现Comparable接口提供了一种内排序的方式, 而Comparator提供了一种不改变比较对象代码, 实现比较的策略, 如果对CaseInsensitiveComparator的实现并不满意, 也可以自己实现MySelfComparator;

划重点:
.1 CaseInsensitiveComparator的实现只是String作者提供了一种不同于String::compareTo的比较策略, 如果说Compareable是比较的内部实现, 那么Comparator就是比较的外部实现;
.2 Comparator这种方式实现了策略模式, 将变与不变完美分类; 关于设计模式后面再开专题分享;
.3 Comparator接口中还有个equals方法没有实现, 不实现这个方法为什么不报错呢? 因为所有类的父类都是Object, Object::equals已经对这个方法做了实现, 也就不报错了;
.4 如果Compareable::compareTo 或者 Comparator::compare的实现的比较结果与equals不符时, 你需要考虑这种情况会不会有影响;比如HashMap中先调用equals再调用的compareTo, 这时候如果equals与compareTo的结果是不一致, 不就引起问题了; 虽然实现了Compareable接口不强制重写equals方法, 但是不一致的情况还是需要考虑下的;

3. String字符串拼接的三种方式比较

对于字符串拼接, 我们可以使用一下三种方式:
.1 "+", 加号拼接是我们最熟悉的;
.2 concat方法, 调用String::concat方法实现拼接;
.3 StringBuild::append方法实现拼接;
我们先来看看三种拼接方式的效率差异:

    long startTime = System.currentTimeMillis();
        
        String temp = "123";
        for(int i = 0; i < 100000; i++) {
            temp = temp + "123";
        }
        System.out.println(String.format("+ 拼接用时: %d毫秒", System.currentTimeMillis() - startTime));
        
        startTime = System.currentTimeMillis();
        temp = "123";
        for(int i = 0; i < 100000; i++) {
            temp = temp.concat("123");
        }
        System.out.println(String.format("concat 拼接用时: %d毫秒", System.currentTimeMillis() - startTime));
    
        startTime = System.currentTimeMillis();
        StringBuilder str = new StringBuilder("123");
        for(int i = 0; i < 100000; i++) {
            str.append("123");
        }
        temp = str.toString();
        System.out.println(String.format("StringBuilder 拼接用时: %d毫秒", System.currentTimeMillis() - startTime));

这是实验代码, 分别使用"+", concat 和 StringBuild::append 进行了10万次的字符串拼接; 拼接的字符串统一使用""的静态字符串, 从前次的分享可知这种声明的字符串会被缓存在JVM的常量池中, 所以三种方式都是对同一个对象的不断拼接最终形成新的String对象;那我们来看看结果:


拼接结果一

这是按上面代码顺序执行的结果, 可以清晰的看到, 在10万这个数量级, 使用"+"进行拼接字符串的效率明显低于其他两种拼接方式, 为什么使用"+"拼接会这么慢呢?
.1我们来看下"+"拼接字符串的底层实现:
编译器对这种方式做了优化: 上面for循环中的代码被优化成:

    temp = new StringBuilder(temp).append("123").toString();

. 每次拼接都会new 一个StringBuild对象;
. 调用StringBuild::append进行拼接;
. 再调用StringBuild::toString生成新的String对象;
知道了"+"拼接的底层原理, 试着来分析下这种方式慢的原因:
.1.1 每次拼接都会生成两个新的对象, StringBuild 和 String, 创建一次对象就要消耗一次操作时间;
.1.2 创建对象就需要申请内存, 而整个应用的内存空间是固定的, 循环次数多了以后, 必然导致创建对象时内存不够用, 这时候就会触发GC, 而GC为了清理无效对象, 会停止应用(stop the world), 这是一个及其耗时的操作;
.1.3 "+"拼接的方式慢在创建对象和GC;

.2 我们再来看下concat的拼接:

    public String concat(String str) {
        int otherLen = str.length();
        if (otherLen == 0) {
            return this;
        }
        int len = value.length;
        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);
        return new String(buf, true);
    }

从String::concat的实现可知这种拼接方式做了什么:
.1 判空, 拼接的字符串是空的, 返回原字符串;
.2 新生成一个char[], 长度是旧字符串和拼接字符串的长度之和;
.3 将旧字符串拷贝到新数组中;
.4 将拼接字符串拷贝到新数组中;
.5 返回一个机遇新数组的String对象;
从底层实现可看出这种拼接方式的耗时操作主要是新建String对象和两次数组拷贝操作; 同时也要看到String::concat也是每次调用都会返回一个新的String对象;

.3 最后来看下最快的StringBuild::append的实现:

    public AbstractStringBuilder append(String str) {
        if (str == null)
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;
    }

可看出这种方式String::concat的核心思想基本相同, 但是有三点不同:
.1 StringBuild在生成时会维护一个长度可变的char[], 默认大小是构造函数传入字符串的长度加16; 所以每次每次都会判断是否拼接字符串的长度加上已有字符串的长度是否超过数组的长度; 超过数组就扩容(大小是当前数组长度 << 1 + 2), 然后拷贝现有数据至新数组;
.2 判空逻辑更改: 不会直接返回而会拼接"null"字符串;
.3 最后就是返回的不是String而是当前的StringBuild对象, 只有在调用StringBuild::toString时才会返回新的String对象;
StringBuild::append不仅减少新对象的产生, 连数组的拷贝操作也尽量减少了, 他拼接耗时最少也就不足为奇了;

划重点:
.1 字符串拼接耗时:StringBuild::append < String::concat < "+";
.2 在循环中不要使用"+"进行字符串拼接;
.3 对于上面的例子因为涉及到了JVM的常量池, 所以又做了一次验证, 把StringBuild::append 和 “+”的执行顺序做了对调, 下面是执行的结果:


拼接结果二

第一点的结论依然成立;
.4 StringBuild 和 StringBuffer都是继承了AbstractStringBuilder这个抽象类, 两个唯一的区别就是StringBuffer是线程安全的(所有方法都用了synchronized做了修饰);

这次分享的内容就是这些了, 上面内容的不正之处欢迎指正; 如果对于String有其他的问题也欢迎一起交流; 最后, 感谢阅读;

你可能感兴趣的:(Java基础之String漫谈(二))