[Java] String类深度解析

22 篇文章 14 订阅
10 篇文章 1 订阅

Table of Contents

概述

String类成员变量

重要方法解析

equals方法

其他String的用法

intern()方法和字符串比较

hashcode方法

字符串拼接方法

其他方法

length方法

isEmpty方法

charAt方法

startWith方法

String 对象内存分配问题

正则表达式


本文基于jdk1.8

概述

String这个类,在java的世界里无人不知无人不晓,并且其实现原理也一直在更新,知道的人多,真正掌握的不多,这篇博客会尽量深入的解析String类的方方面面。

从java的体系角度来讲,String类并不像Object类,Class类那样在一些关键技术以及特性,如继承,虚拟机类加载机制上发挥着重要作用。String对象是不可变的。String类中每一个看起来会修改String值的方法,实际上都是创建了一个全新的String对象。

但是String类却仍然是非常常用的一个类,我们来看一下他是怎么实现的。

String类成员变量

在String类里面存储字符的数据结构定义如下:

    /** The value is used for character storage. */
    private final char value[];

首先这是一个方final关键字修饰的变量,这是String对象不可变的原因所在。

其次可以看到,是一个char类型的数组。什么意思?就是说java里面一个字符串里的字符事实上是以数组的形式在保存,其中的每一个字符都是char类型的。同时使用final修饰,意味着是不可变的。

那么char是个什么东西呢?了解java基础的朋友应该知道,char是java的基本数据类型之一。Java语言提供了八种基本类型。六种数字类型(四个整数型,两个浮点型),一种字符类型,还有一种布尔型。关于java基本类型的已经有非常多文章了,可以参考这里。这里char就是这一种字符类型。

  • char类型是一个单一的 16 位 Unicode 字符;
  • 最小值是 \u0000(即为0);
  • 最大值是 \uffff(即为65,535);
  • char 数据类型可以储存任何字符;
  • 例子:char letter = 'A';。

因此,明白了这个字符串的存储结构之后,它的很多方法就很好理解了。如计算length,无非就是返回数组长度,isEmpty方法无非就是判断一下数组长度是否为0.

除value之外,String类仅有的两个重要成员变量的另一个就是它的hash值,定义如下:

    /** Cache the hash code for the string */
    private int hash; // Default to 0

一个String对象的字符内容,和这个String的hash值,就是这个字符串的全部精华了。

重要方法解析

equals方法

String类的equals方法定义如下:

  public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }

从代码里不难看出,重写的equals方法里,是根据字符串的类容逐个比较,若字符串内容一致则返回true.

那么如下代码的返回结果依次是什么呢?

        String s1 = "1";
        String s2 = "1";

        String s3 = new String("1");
        String s4 = new String("1");
        System.out.println(s1 == s2);
        System.out.println(s1.equals(s2));
        System.out.println(s3.equals(s4));

        System.out.println(s3 == s4);
        System.out.println(s1 == s3);

答案是:true,true,true,false.false

有一个结果很奇怪,第一个,为什么==的比较返回了true呢?难道是String对象的==比较和其他对象有区别?显然不是,关于两个==的判定是由java语言机制规定的,不可能不一致,否则就出大事了。因此只有一种可能,s1与s2在内存里就是指向的同一个内存上的对象。上面代码的内存图如下:

因为String太过常用,JAVA类库的设计者在实现时做了个小小的变化,即采用了享元模式,每当生成一个新内容的字符串时,他们都被添加到一个共享池(位于常量池)中,当第二次再次生成同样内容的字符串实例时,就共享此对象,而不是创建一个新对象,但是这样的做法仅仅适合于通过=符号进行的初始化.

另外必须引起注意的是:如上图所示,在使用等于符号定义字符串的时候,字符串的引用是在栈内存里,而字符串对象是在常量池中的。

其他String的用法

上面的情形在笔者看来是有一定实际价值,值得掌握的,但下面这些内容则只需要了解即可。

考虑下面这张情形:

        String s1 = "hello";
        String s3 = "he" + "llo";
        String s4 = "hel" + new String("lo");
        
        String s5 = new String("hello");
        
        String s7 = "h";
        String s8 = "ello";
        String s9 = s7 + s8;

        System.out.println(s1 == s3);//true
        System.out.println(s1 == s4);//false
        System.out.println(s1 == s9);//false
        System.out.println(s4 == s5);//false

s1和s3打印结果为true,s1和s4为false,s1和s9为false. s4和s5为false。

s3中字面量的拼接其实就是“hello”,jvm在编译期间就已经对它进行优化,所以s1和s3是相等的。

String s4 = "hel" + new String("lo") 实质上是两个对象的相加,编译器不会进行优化,相加的结果存在堆中生成一个新的String对象,而s1存在字符串常量池中,当然不相等。

s9则也会创建出一个新的对象出来。因此s1和s9结果为false。原因在于虚拟机在处理字符串相加的时候,都是静态字符串(如"
a"+"b")的结果会添加到字符串池,如果其中含有变量(如s9中的s7以及s8)则不会进入字符串常量池中。

s4是创建在堆当中的一个新的对象,s5也是创建在堆中的一个新的对象。他们当然不是同一个对象,结果为false。

intern()方法和字符串比较

String类中的intern方法的作用是返回字符串对象的规范化表示形式。

它遵循以下规则:对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

其返回结果遵顼:是一个字符串,内容与此字符串相同,但一定取自具有唯一字符串的池

因此虽然在返回值中调用intern方法并没有什么效果,但是实际上后台这个方法会做一系列的动作和操作。在调用“ab”.intern()方法的时候会返回”ab”,但是这个方法会首先检查字符串常量池中是否有”ab”这个字符串,如果存在则返回这个字符串的引用,否则就将这个字符串添加到字符串常量池中,然会返回这个字符串的引用。(重要)

考虑以下代码:

        String str1 = "a";
        String str2 = "b";
        String str3 = "ab";
        String str4 = str1 + str2;
        String str5 = new String("ab");

        System.out.println(str5.equals(str3));
        System.out.println(str5 == str3);
        System.out.println(str5.intern() == str3);
        System.out.println(str5.intern() == str4);

其打印结果为:true ,false, true, false. 原因如下

  • 第一、str5.equals(str3)这个结果为true,不用太多的解释,因为字符串的值的内容相同。
  •  第二、str5 == str3对比的是引用的地址是否相同,由于str5采用new String方式定义的,所以地址引用一定不相等。所以结果为false。
  •  第三、当str5调用intern的时候,会检查字符串池中是否含有该字符串。由于之前定义的str3已经进入字符串池中,所以会得到相同的引用。
  •  第四,当str4 = str1 + str2后,str4的值也为”ab”,但是为什么这个结果会是false呢?原因就和上面的s9的例子是一样的。

hashcode方法

实现的源代码如下:

 /**
     * Returns a hash code for this string. The hash code for a
     * {@code String} object is computed as
     * <blockquote><pre>
     * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
     * </pre></blockquote>
     * using {@code int} arithmetic, where {@code s[i]} is the
     * <i>i</i>th character of the string, {@code n} is the length of
     * the string, and {@code ^} indicates exponentiation.
     * (The hash value of the empty string is zero.)
     *
     * @return  a hash code value for this object.
     */
    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

此处的hash是一个int类型的值,定义如下:

private int hash; 

它是一个 private 修饰的变量,作用是来存放 String 对象的 hashCode。

String的hashCode计算方法,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。

31 的二进制为: 11111,占用 5 个二进制位,在进行乘法运算时,可以转换为 (i << 5) - 131*i等价于(i<<5)-i,这两个运算是一样的。

主要是因为31是一个奇质数,如果是偶数的话,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。

31有一个很好的特性,就是用移位和减法来代替乘法

一个字符串的hashcode计算过程如下:

String str = "ray";

value = {'r', 'a', 'y'};
hash = 0;
value.length = 3;


val = value;
val[0] = "r";
val[1] = "a";
val[2] = "y";

h = 31 * 0 + r = r;

h = 31 * (31 * 0 + r) + a = 31 * r + a;

h = 31 * (31 * (31 * 0 + r) + a) + y = 31 * 31 * r + 31 * a + y;

哈希计算公式可以计为s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] 

另外:对于是否应该使用31作为权重,一直都有不同的观点。但目前的jdk源代码仍然使用的是31.

字符串哈希可以做很多事情,通常是类似于字符串判等,判回文之类的。但是仅仅依赖于哈希值来判断其实是不严谨的,除非能够保证不会有哈希冲突,通常这一点很难做到。

就拿jdk中String类的哈希方法来举例,字符串"gdejicbegh"与字符串"hgebcijedg"具有相同的hashCode()返回值-801038016,并且它们具有reverse的关系。这个例子说明了用jdk中默认的hashCode方法判断字符串相等或者字符串回文,都存在反例。

关于乘法溢出:

java 中int基本数据类型的最大范围(2147483647),于是作了默认的类型提升(type promotion),中间结果做为long类型存放,返回结果时目标数据类型int不能够容纳下结果,于是根据Java的基础类型的变窄转换(Narrowing primitive conversion)规则,把结果宽于int类型宽度的部分全部丢弃,也就是只取结果的低32位 。更多

字符串拼接方法

字符串的拼接也是我们经常使用的方法。这里着重比较使用加号拼接和使用String类的concat方法以及使用StringBuilder拼接的区别。String对象是不可变的,你可以给一个String对象加任意多的别名。

使用加号:

使用加号是我们只能在class文件里通过反编译才能看到编译器是如何处理加号的。在jdk1.5之后,编译器事实上已经是在用StringBuilder实现,编译器自动引入了java.lang.StringBuilder类,虽然在源代码中没有使用它,会自动将+号替换为StringBuilder的append方法,可以通过命令javap -v class文件来查看.

既然已经自动转换成StringBuilder了,那么是否意味着任何情况下都直接使用加号吗?答案不是的,编译器虽然做到了把加号解析为StringBuilder,但是没有那么智能,因此需要我们自己判断什么时候使用,考虑如下代码:

        String string = new String();
        for (int i = 0; i < 50000; i++) {
            string = string + i;
        }

注意,在每次把加号解析为StringBuilder的时候编译器都是new了一个StringBuilder出来,这意味着在上面这种情况下,会不停的new新的StringBuilder出来,因此效率就会大大降低。但是,值得一提的是,这和拼接的内容有关系,若直接拼接两个字符,如:

String string = "d" + "r";

则编译器不会每次都去new一个StringBuilder而是直接拼接。

因此在这种情况下,即不断的循环的情况下,我们就应该在外面new个StringBuilder对象,然后在循环里面append最好,若是在一行里,则直接使用+号拼接即可。

StringBuilder的append方法底层的实现调用了System类的一个方法arrayCopy,该方法为一个native方法。

另外,和StringBuilder有关的还有另一个类,叫做StringBuffer,这两个类最大的区别在于StringBuffer的append方法是使用了synchronized修饰的,这保证了它在并发情况下的正确性。

而String类自己提供的concat方法内部的实现也是调用了System类的另一个copyOf的方法,但是这个类最后的返回时new了一个String对象,这意味着在循环很多的情况下,依然会存在new大量对象的情况。

其他方法

length方法

返回一个字符串的长度

public int length() {
        return value.length;
    }

实现原理非常简单,因为保存字符内容的是一个Char类型的数组,因此直接返回这个数组的length即可。

isEmpty方法

判断一个字符串是否为空字符串

public boolean isEmpty() {
        return value.length == 0;
    }

实现原理同上,判断数组的长度是否为0即可。

charAt方法

返回指定下标处的字符

    public char charAt(int index) {
        if ((index < 0) || (index >= value.length)) {
            throw new StringIndexOutOfBoundsException(index);
        }
        return value[index];
    }

先做了必要的范围验证。实现原理也是同上,直接根据数组的下标取对应的字符即可。

startWith方法

判断一个字符串是否以某一串字符prefix打头,核心实现逻辑如下

   public boolean startsWith(String prefix, int toffset) {
        char ta[] = value;
        int to = toffset;
        char pa[] = prefix.value;
        int po = 0;
        int pc = prefix.value.length;
        // Note: toffset might be near -1>>>1.
        if ((toffset < 0) || (toffset > value.length - pc)) {
            return false;
        }
        while (--pc >= 0) {
            if (ta[to++] != pa[po++]) {
                return false;
            }
        }
        return true;
    }

ta[]是当前String对象的一个拷贝,

pa[]是传入的prefix字符串的一个拷贝

pc是prefix字符串的长度,比较的逻辑在while循环里面:

 while (--pc >= 0) {
            if (ta[to++] != pa[po++]) {
                return false;
            }
        }
        return true;

根据prefix长度,逐一比较。

值得一提的是,endsWith方法的实现直接使用了startsWith的实现逻辑。只是在调用的时候同时指定了length:

 public boolean endsWith(String suffix) {
        return startsWith(suffix, value.length - suffix.value.length);
    }

 

String 对象内存分配问题

String是一个特殊的包装类数据,可以用:

String str = new String("abc");

String str = "abc";

两种形式来创建,第一种是用new来创建对象的,它会在堆中按照new对象的流程创建一个新的对象出来,每调用一次就会创建一个新的对象;而第二种是先在栈中创建一个对String类的对象引用str ,然后查找常量池中有没有存放"abc",如果没有,则将"abc"存放常量池,并令str 指向"abc",如果已经有"abc",则直接令str指向"abc"。

正则表达式

在java当中,字符串的操作主要集中在String,StringBuffer和StringTokenizer类当中。

String当中自带了一个非常有用的正则表达式工具-split方法,其功能是将字符串从正则表达式匹配的地方切开。

String类还自带的一个正则表达式工具是'替换',你可以只替换正则表达式第一个匹配的字符串,或是替换所有匹配的地方。

                                         字符

B指定字符B
\xhh十六进制为oxhh的字符
\uhhhh十六进制表示为oxhhhh的Unicode字符
\t制表符Tab
\n换行符
\r回车
\f换页
\e转义

                                       字符类

 任意字符
[abc]包含a,b和c的任何字符,和a|b|c作用相同
[^abc]除了a,b和c之外的任何字符
[a-zA-Z]从a到z或A到Z的任何字符
[abc[hij]]  任意a,b,c,h,i和j字符
\s空白符
\S非空白符
\d数字[0-9]
\D 非数字[^0-9]
\w词字符[a-zA-Z0-9]
\W非词字符[^\w]

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值