• String源码浅析


    如果问你,开发过程中用的最多的类是哪个?你可能回答是HashMap,一个原因就是HashMap的使用量的确很多,还有就是HashMap的内容在面试中经常被问起。

    但是在开发过程中使用最多的类其实并不是HashMap类,而是“默默无闻”的String类。假如现在问你String类是怎么实现的?这个类为什么是不可变类?这个类为什么不能被继承?这些问题你都能回答么。本文就从String源代码出发,来看下String到底是怎么实现的,并详细介绍下String类的API的用法。

    String源码结构

    首先要说明的是本文的源码是以JDK11为基准,选择JDK11的原因是JDK11是一个LTS版本(长期支持版本),没选择现阶段还在广泛使用的JDK8的原因是想在看源码的过程中学习下JDK的新特性。

    还有要说下的就是:大家在看源码时一定要注意JDK的版本,因为不同版本的实现有较大的差异。比如说String的实现在高低版本中就差异比较大。如果你是一个博客主,更加要注明代码的版本了,不然读者可能会很疑惑,为什么和自己之前看的不一样。

    好了,下面就言归正传来看下String在JDK11中的实现代码。

     public final class String implements Serializable, Comparable<String>, CharSequence {
       @Stable
       //字节数组,存放String的内容,如果你看的是较低版本的源代码,这个变量可能是char[]类型,这个其实是JDK9开始对String做的一个优化
       //具体是做了什么优化我们下面再讲,这边先卖个关子
       private final byte[] value;
       //也是和String压缩优化有关,指定当前的LATIN1码还是UTF16码
       private final byte coder;
       //哈希值
       private int hash;
       //序列化Id
       private static final long serialVersionUID = -6849794470754667710L;
       //优化压缩开关,默认开启
       static final boolean COMPACT_STRINGS = true;
       private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0];
       public static final Comparator<String> CASE_INSENSITIVE_ORDER = new String.CaseInsensitiveComparator();
       static final byte LATIN1 = 0;
       static final byte UTF16 = 1;
       
       //... 下面部分代码省略
     }
    

    从实现的接口看,String类有如下特点:

    • String类被final关键字修饰,因此不能被继承。
    • String的成员变量value使用final修饰,因此是不可变的,线程安全;
    • String类实现了Serializable接口,可以实现序列化。
    • String类实现了Comparable,可以比较大小。
    • String类实现了CharSequence接口,String本质是个数组,低版本中是char数组,JDK9以后优化成byte数组,从String的成员变量value就可以看出来。

    这边说一个看源代码的小技巧:看一个类的源代码时,我们先看下这个类实现了哪些接口,就可以大概知道这个类的主要作用功能是什么了。

    JDK9对String的优化

    这边首先要讲下JDK 9中对String的优化,如果你不了解这块优化点的话,看String的代码时会感到非常疑惑。

    背景知识

    在Java中,一个字节char占用两个字节的内存空间。在低版本的JDK中,String的内部默认维护的是一个char[]数组,也就是说一个字符串中包含一个字符,这个字符串内部就包含一个相应长度的字符数组。这样就会出现下面这种情况:

     String s = "ddd";
     String s1 = "自由之路";
    

    上面两个字符串内部的情况实际上是:

     char[] value = ['d','d','d'];
     char[] value1 = ['自','由','之','路'];
    

    对于字符串s,我们发现其中每个字符其实都是可以用一个字节表示的,而现在使用两个字符的char类型来表示,明显就浪费了一倍的内存空间。

    而且根据统计,在实际程序运行中,字符串中包含的字符大多都是可以用一个字节表示的字符,所以优化的空间很大。优化的方式就是在String内部使用byte[]数组来表示字符串,而不是使用char[]数组。当检测到,字符串中的所有字符在Unicode码集中的码值可以使用一个字节表示时,就可以节省一半的空间。

    时间换空间的方式

    JDK6 中的Compressed Strings

    其实在JDK6中就对String类做过类似的优化:在Java 6引入了Compressed Strings,对于one byte per character的字符串使用byte[],对于two bytes per character的字符串继续使用char[]。

    使用-XX:+UseCompressedStrings来开启上面的优化。不过由于开启这个特性后会造成一些不可知的异常,这个特性在java7中被废弃了,然后在java8被移除。

    JDK9中的Compact String

    Java 9 重新采纳字符串压缩这一概念。

    和JDK6不同的是:无论何时我们创建一个所有字符都能用一个字节的 LATIN-1 编码来描述的字符串,都将在内部使用字节数组的形式存储,且每个字符都只占用一个字节。另一方面,如果字符串中任一字符需要多于 8 比特位来表示时,该字符串的所有字符都统统使用两个字节的 UTF-16 编码来描述。因此基本上能如果可能,都将使用单字节来表示一个字符。

     //占用3个字节
     String ss = new String("ddd");
     //占用14个字节
     String s = "自由之路ddd";
    

    现在的问题是:所有的字符串操作如何执行? 怎样才能区分字符串是由 LATIN-1 还是 UTF-16 来编码?为了处理这些问题,字符串的内部实现进行了一些调整。引入了一个 final 修饰的成员变量 coder, 由它来保存当前字符串的编码信息。

     //所有的字符串都用byte数组存储
     private final byte[] value; 
     //用coder标示字符串中所有的字符是不是都可以用一个字节表示,它的值只有两个LATIN1:1,标示所有字符都可以用一个字节表示,UTF16:标示字符串中部分字符需要两个字节表示。
     private final byte coder;
     //下面是两个常量
     static final byte LATIN1 = 0;
     static final byte UTF16 = 1;
    

    现在,大多数的字符串操作都将检查 coder 变量,从而采取特定的实现:

     public int indexOf(int ch, int fromIndex) {
       return isLatin1() 
        ? StringLatin1.indexOf(value, ch, fromIndex) 
        : StringUTF16.indexOf(value, ch, fromIndex);
     } 
     
     private boolean isLatin1() {
       return COMPACT_STRINGS && coder == LATIN1;
     } 
    

    我们再看下String的一个常用方法:

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

    这个方法是要计算字符串的长度,含义也很清楚。根据coder字段判断当前的字符串中一个字符使用几个字节表示,如果是coder等于0,也是LATIN1模式,那么所有字符都是用一个字节表示,直接返回byte[]数组的长度就可以。

    如果coder等于1,那么标示字符串中所有字符都是用两个字节表示的,计算字符串的长度需要将byte[]数组除以2。value.length >> coder就是这个意思。

    因为对String做了上面的优化,所以String的很多方法在操作时都需要判断现在的模式是LATIN1还是UTF16模式,具体的方法这边就不一一举例了。但是这些判断对使用String的开发者时无感的。

    当然,String的这个优化特性可以关闭,使用下面的启动参数就可以。

     +XX:-CompactStrings
    

    String的常用构造方法

     //构建空字符串
     public String() {
      this.value = "".value;
      this.coder = "".coder;
     }
    
    //根据已有的字符串,创建一个新的字符串
     @HotSpotIntrinsicCandidate
     public String(String original) {
      this.value = original.value;
      this.coder = original.coder;
      this.hash = original.hash;
     }
    
    //根据字符数组,创建字符串,创建的过程中有压缩优化的逻辑,具体见下面的方法
     public String(char[] value) {
      this((char[])value, 0, value.length, (Void)null);
     }
    
    String(char[] value, int off, int len, Void sig) {
      if (len == 0) {
       this.value = "".value;
       this.coder = "".coder;
      } else {
       if (COMPACT_STRINGS) {
        //如果发现这个字符数组可以压缩,就使用LATIN1方式
        byte[] val = StringUTF16.compress(value, off, len);
        if (val != null) {
         this.value = val;
         this.coder = 0;
         return;
        }
       }
       //不能进行压缩优化,还是使用UTF16的方式
       this.coder = 1;
       this.value = StringUTF16.toBytes(value, off, len);
      }
     }
    

    String中还有很多构造方法,但是都会大同小异,大家可以自己看源代码。

    String常用方法总结

    这边总结下String的常用方法,一些比较简单的方法就不具体讲了。我们挑选一些比较重要的方法,具体讲下他们的使用方法。

    • codePointAt(int index):返回下标是index的字符在Unicode码集中的码点值;
    • codePoints():返回字符串中每个字符在Unicode码集中的码点值;
    • compareToIgnoreCase(String other):忽略大小写比较字符大小;
    • concat(String other):字符串拼接函数;
    • equalsIgnoreCase(String other):忽略大小写比较字符串;
    • format:字符串格式化函数,比较有用;
    • getBytes(String charSet):获取字符串在特定编码下的字节数组;
    • indexOf(String s):返回字符串s的下标,不存在返回-1;
    • intren():作用是检测常量池中是否有当前字符串,有的话就返回常量池中的对像,没有的话就将当前对像放入常量池。
    • isBlank():如果字符串为空或只包含空白字符,则返回true,否则返回false,JDK11新加的API;
    • length():返回字符长度;
    • lines():从字符串返回按行分割的Stream,行分割福包括:n ,r 和rn,stream包含了按顺序分割的行,行分隔符被移除了,这个方法会类似split(),但性能更好;这个也是JDK11新加的API
    • matchs(String regex):和某个正则是否匹配;
    • regionMatches(int firstStart, String other, int otherStart, int len):当某个字符串调用该方法时,表示从当前字符串的firstStart位置开始,取一个长度为len的子串;然后从另一个字符串other的otherStart位置开始也取一个长度为len的子串,然后比较这两个子串是否相同,如果这两个子串相同则返回true,否则返回false。
    • repeat():返回一个字符串,其内容是字符串重复n次后的结果,JDK11新加入的函数;
    • String[] split(String regex, int limit):分割字符串,注意limit参数的使用,下面会详细讲;
    • startsWith(String prefix, int toffset):判断字符串是否以prefix打头;
    • replace(char oldChar, char newChar):使用newChar替换所有的oldChar,不是基于正则表达式的;
    • replace(CharSequence target, CharSequence replacement):替换所有,基于正则表达式的;
    • replaceFirst(String regex, String replacement):替换regex匹配的第一个字符串,基于正则表达式;
    • replaceAll(String regex, String replacement):替换regex匹配的所有字符串,基于正则表达式;
    • strip() :去除字符串前后的“全角和半角”空白字符,这个函数在JDK中11才引入,注意和trim的区别,关于全角和半角的区别,可以参考这篇文章,还提供了stripLeading()和stripTrailing(),可以分别去掉头部或尾部的空格;
    • subString(int fromIndex):从指定位置开始截取到字符串结尾部分的子串;
    • subString(int fromIndex,int endIndex):截取字符串指定下标的子串;
    • toCharArray():转换成字符数组;
    • toUpperCase(Locale locale) :小写转换成大写;
    • toLowerCase(Locale locale):大写转换成小写;
    • trim():去除字符串前后的空白字符(空格、tab键、换行符等,具体的话是去除ascll码小于32的字符),注意trim和strip的区别;
    • valueof系列方法:将其他类型的数据转换成String类型,比如将bool、int和long等类型转换成String类型。

    concat字符串拼接函数

    concat函数是字符串拼接函数,介绍这个函数并不是因为这个函数比较重要或者实现比较复杂。而是因为通过这个函数的源代码我们可以看出很多String的特性。

     public String concat(String str) {
      //如果被拼接的字符串的长度是0,直接返回自己
      int olen = str.length();
      if (olen == 0) {
       return this;
      } else {
       byte[] buf;
       //如果当前字符串和被拼接的字符串的编码模式相同,都是LATIN1或者都是UTF16
       if (this.coder() == str.coder()) {
        byte[] val = this.value;
        buf = str.value;
        //计算出新字符串所需字节的长度
        int len = val.length + buf.length;
        byte[] buf = Arrays.copyOf(val, len);
        //使用系统函数拷贝
        System.arraycopy(buf, 0, buf, val.length, buf.length);
        //根据新的字节数组生成一个新的字符串
        return new String(buf, this.coder);
       } else {
        //当前字符串和被拼接的字符串的编码模式不同,那么必须使用UTF16的编码模式
        int len = this.length();
        buf = StringUTF16.newBytesFor(len + olen);
        this.getBytes(buf, 0, (byte)1);
        str.getBytes(buf, len, (byte)1);
        return new String(buf, (byte)1);
       }
      }
     }
    

    format函数

    String的format方法是一个很有用的方法,可以用来对字符串、数字、日期和时间等进行格式化。

    //对整数格式化,4位显示,不足4位补0
    //超过4位,还是原样显示
    int num = 999;
    String str = String.format("%04d", num);
    System.out.println(str);
    
    //对日期进行格式化
    String format = String.format("%tF", new Date());
    System.out.println(format);
    

    format方法还有很多用法,大家可以自己查询使用。

    regionMatches

    该方法的定义如下:

    regionMatches(int firstStart, String other, int otherStart, int len)
    
    

    当某个字符串调用该方法时,表示从当前字符串的firstStart位置开始,取一个长度为len的子串;然后从另一个字符串other的otherStart位置开始也取一个长度为len的子串,然后比较这两个子串是否相同,如果这两个子串相同则返回true,否则返回false。

    该方法还有另一种重载:

    str.regionMatches(boolean ignoreCase, int firstStart, String other, int otherStart, int len)
    
    

    可以看到只是多了一个boolean类型的参数,用来确定比较时是否忽略大小写,当ignoreCase为true表示忽略大小写。

    split函数

    String的split函数我们平时也经常使用,但是估计很多人都没有注意这个函数的第二个参数:limit

    public String[] split(String regex, int limit)
    

    首先,split方法的作用是根据给定的regex去分割字符串,将分割完成的字符数组返回。其中limit参数的作用是:

    • 当limit>0时,limit代表最后的数组长度,同时一共会分割limit-1次,最后没有切割完成的直接放在一起;

    • 当limit=0时(默认值),会尽量多去分割,并且如果分割完的字符数组末尾是空字符串,会去除这个空字符串;

    • 当limit<0时,会尽量多去分割,但不会去掉末尾的空字符串。

    下面举个列子:

    String s1 = "博客园|CSDN||";
    
    String[] split1 = s1.split("\|", 2);
    System.out.println("split1 length:" + split1.length);
    System.out.println("split1 content:" + Arrays.toString(split1));
    String[] split2 = s1.split("\|", 0);
    System.out.println("split2 length:" + split2.length);
    System.out.println("split2 content:" + Arrays.toString(split2));
    String[] split3 = s1.split("\|", -1);
    System.out.println("split3 length:" + split3.length);
    System.out.println("split3 content:" + Arrays.toString(split3));
    
    System.out.println("---换一个复杂点的字符串---");
    s1 = "|博客园||CSDN|自由之路ddd|";
    
    split1 = s1.split("\|", 2);
    System.out.println("split1 length:" + split1.length);
    System.out.println("split1 content:" + Arrays.toString(split1));
    split2 = s1.split("\|", 0);
    System.out.println("split2 length:" + split2.length);
    System.out.println("split2 content:" + Arrays.toString(split2));
    split3 = s1.split("\|", -1);
    System.out.println("split3 length:" + split3.length);
    System.out.println("split3 content:" + Arrays.toString(split3));
    

    下面是输出结果,对照着这个结果大家就应该能明白split方法的使用了

    split1 length:2
    split1 content:[博客园, CSDN|自由之路ddd|]
    split2 length:3
    split2 content:[博客园, CSDN, 自由之路ddd]
    split3 length:4
    split3 content:[博客园, CSDN, 自由之路ddd, ]
    ---换一个复杂点的字符串---
    split1 length:2
    split1 content:[, 博客园||CSDN|自由之路ddd|]
    split2 length:5
    split2 content:[, 博客园, , CSDN, 自由之路ddd]
    split3 length:6
    split3 content:[, 博客园, , CSDN, 自由之路ddd, ]
    

    再举个JDK中的列子:

    The input "boo:and:foo", for example, yields the following results with these parameters:

    Regex     Limit     Result    
    : 2 { "boo", "and:foo" }
    : 5 { "boo", "and", "foo" }
    : -2 { "boo", "and", "foo" }
    o 5 { "b", "", ":and:f", "", "" }
    o -2 { "b", "", ":and:f", "", "" }
    o 0 { "b", "", ":and:f" }

    总结

    • String类被final关键字修饰,因此不能被继承;
    • String的成员变量value使用final修饰,因此是不可变的,线程安全;
    • String中的方法对字符串的操作都会生成一个新的String对象,如果你需要一个可修改的字符串,应该使用 StringBuffer 或者 StringBuilder。否则会有大量时间浪费在垃圾回收上,因为每次试图修改都有新的string对象被创建出来;
    • JDK9开始对String进行了优化,内部彻底使用byte[]数组来代替char数组。

    参考

    公众号推荐

    欢迎大家关注我的微信公众号「程序员自由之路」

    人生的主旋律其实是苦难,快乐才是稀缺资源。在困难中寻找快乐,才显得珍贵~
  • 相关阅读:
    SVN trunk(主线) branch(分支) tag(标记) 用法详解和详细操作步骤
    svn branch and merge(svn切换分支和合并)详解
    WPF 后台任务 等待动画 样例 && C# BackgroundWorker 详解
    WPF CheckBox 滑块 样式 开关
    WPF自适应可关闭的TabControl 类似浏览器的标签页
    Bootstrap WPF Style(二)--Glyphicons 字体图标
    WPF 中的 Pack URI地(资源文件加载)
    Bootstrap WPF Style,Bootstrap风格的WPF样式
    tomcat修改server.xml的虚拟目录,启动eclipse后清空
    js修改css属性值
  • 原文地址:https://www.cnblogs.com/54chensongxia/p/13626963.html
Copyright © 2020-2023  润新知