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有其他的问题也欢迎一起交流; 最后, 感谢阅读;

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容