美文网首页追梦 javajava程序员
jdk源码分析(三)——String类

jdk源码分析(三)——String类

作者: 活成理想中的样子 | 来源:发表于2017-03-19 22:01 被阅读281次

    一.几个概念

    在我们正式开始看String源码之前,先来了解几个概念,对这几个概念的理解,将有助于提升我们对代码的认识。
    1.字面量
    字面量是用于表达源代码中一个固定值的表示法。数字,字符串等都有字面量表示。例如:

    final int n = 1;
    String s = "Hello World!"
    

    上述代码中1、"Hello World!"就是字面量。
    2.常量池
    (1)class文件中的常量池
    在class文件中,除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用。
    我们编写如下代码,并查看其class文件内容:

    public class Literals {
        final int n = 1;
        String s = "Hello World!";
    }
    

    在上图中我们可以看到,字面量"1"、"Hello World!"出现在Constant pool列表中。
    (2)运行时常量池
    根据《java虚拟机规范》的规定,java虚拟机所管理的内存将会包括以下几个运行时数据区域:


    class文件的常量池中的信息,将在类加载后进入方法区中的常量池存储。

    3.字符集
    字符集是一个系统支持的所有抽象字符的集合。常见的字符集有ascii字符集、Unicode字符集。
    4.字符编码
    字符编码是我们对字符集的一套编码规则,将具体的字符进行“数字化”,便于计算机理解和处理。例如我们常用的UTF-8字符编码是对Unicode字符集的一种具体编码规范。
    5.码位
    我们已经知道了字符集和字符编码的概念,那么如何对具体的字符集进行字符编码呢?这就要用到码位(code point)的概念:码位是表示一个字符在码空间中的数值。例如:ascii包含128个码位(范围是0-127),数字0的码位是48。

    二.核心代码

    1.类定义

    public final class String
            implements java.io.Serializable, Comparable<String>, CharSequence
    

    需要注意的是,String类被声明为final的,意味着它不可以被继承。
    另外,类实现了Serializable接口使它可以被序列化;实现了Comparable接口便于字符串之前的比较;实现了CharSequence接口,该接口是char值的一个可读序列,它声明了如下几个方法:

    public interface CharSequence {
        // 获取字符序列长度
        int length();
        // 获取某个指定位置的字符
        char charAt(int index);
        // 获取子序列
        CharSequence subSequence(int start, int end);
        // 将字符序列转换为字符串
        public String toString();
    }
    

    2.存储机制
    类的定义中实现了CharSequence接口,我们其实已经大概可以了解,String是基于“字符序列”来实现的。通过看源代码,我们可以确认:String是基于字符数组来进行字符的存储与管理的。代码如下:

    // 字符数组,用于存储字符串中的字符
    private final char value[];
    // 字符串中第一个字符的下标
    private final int offset;
    // 字符串中存储的字符个数
    private final int count;
    

    以上代码便构成了String工作的基础:使用value数组来进行字符存储,使用offset和count来进行标记和记录。基本所有的方法都是围绕着这三个家伙展开的。

    当我们运行如下代码时,程序实际上做了哪些事情呢?

    String s = "Hello World!";
    

    (1)在常量池中添加"Hello World!"字面量。
    (2)在堆区创建一个String类型的对象实例。
    (3)在栈区本地变量表中创建变量s,并指向堆区中的实例。
    如下图所示:

    此外,为了节省空间,实际上String实例中的字符数组是可以被其他String实例复用的,这也就是offset变量和count变量存在的原因了,我们稍后再继续讨论这个问题。
    3.常用方法
    (1)构造方法
    我们常用的构造方法有如下几个:

    // 利用另一个字符串来生成一个新的字符串
    String s1 = new String("Hello World!");
    // 利用字节数组来生成字符串
    String s2 = new String(s1.getBytes(), 0, s1.length(), "UTF-8");
    char[] charArray = {'j', 'a', 'v', 'a'};
    // 利用字符数组来生成字符串
    String s3 = new String(charArray);
    

    我们分别来看一下这三个构造方法。

    第一个构造方法:
    public String(String original) {
        // 获取原字符串中的字符个数
        int size = original.count;
        // 获取原字符数组
        char[] originalValue = original.value;
        char[] v;
        // 判断原字符数组长度是否大于有效字符个数,之所以需要判断,是因为有可能offset不等于0
        // 即字符数组不是从第一个位置开始存储的
        if (originalValue.length > size) {
            // 获取原字符串中的首字符下标
            int off = original.offset;
            // 对原数组进行拷贝
            v = Arrays.copyOfRange(originalValue, off, off + size);
        } else {
            // 原字符数组长度等于字符个数,也即offset=0
            v = originalValue;
        }
        // 以下是对构成字符串的3个要素进行赋值
        this.offset = 0;
        this.count = size;
        this.value = v;
    }
    

    第二个构造方法:

    public String(byte bytes[], int offset, int length, String charsetName)
            throws UnsupportedEncodingException {
        if (charsetName == null)
            throw new NullPointerException("charsetName");
        checkBounds(bytes, offset, length);
        // 将字节数组反序列化为字符数组
        char[] v = StringCoding.decode(charsetName, bytes, offset, length);
        this.offset = 0;
        this.count = v.length;
        this.value = v;
    }
    
    // 边界检查,检查传入的字节数组,起始下标、长度是否有效
    // 这里有一个疑惑:为何该方法被声明为static的?不知是何用意
    // 因为这个方法只在构造方法中被用到了,不是static也完全没有问题
    private static void checkBounds(byte[] bytes, int offset, int length) {
        if (length < 0)
            throw new StringIndexOutOfBoundsException(length);
        if (offset < 0)
            throw new StringIndexOutOfBoundsException(offset);
        if (offset > bytes.length - length)
            throw new StringIndexOutOfBoundsException(offset + length);
    }
    

    这里我们看到,代码的核心逻辑在这一句:

    char[] v = StringCoding.decode(charsetName, bytes, offset, length);
    

    我们继续看StringCoding.decode的实现:

    // 线程级缓存,缓存反序列化器
    private static ThreadLocal decoder = new ThreadLocal();
    
    static char[] decode(String charsetName, byte[] ba, int off, int len)
            throws UnsupportedEncodingException {
        // 从线程级缓存中获取反序列化器
        StringDecoder sd = (StringDecoder) deref(decoder);
        // 如果charsetName为null,默认使用ISO-8859-1字符编码
        String csn = (charsetName == null) ? "ISO-8859-1" : charsetName;
        // 缓存中没有反序列化器,或者虽然有,但是之前反序列化的字符集与这次不同,则重新生成decoder
        if ((sd == null) || !(csn.equals(sd.requestedCharsetName())
                || csn.equals(sd.charsetName()))) {
            sd = null;
            try {
                Charset cs = lookupCharset(csn);
                if (cs != null)
                    sd = new StringDecoder(cs, csn);
            } catch (IllegalCharsetNameException x) {
            }
            if (sd == null)
                throw new UnsupportedEncodingException(csn);
            // 将decoder放入线程级缓存,以备下次使用
            set(decoder, sd);
        }
        // 调用StringDecoder完成反序列化
        return sd.decode(ba, off, len);
    }
    
    // 从缓存中获取反序列器,此处使用了软引用,便于jvm在内存不足时,释放该缓存
    private static Object deref(ThreadLocal tl) {
        SoftReference sr = (SoftReference) tl.get();
        if (sr == null)
            return null;
        return sr.get();
    }
    
    // 判断字符集是否支持,并加载字符集处理类
    private static Charset lookupCharset(String csn) {
        if (Charset.isSupported(csn)) {
            try {
                return Charset.forName(csn);
            } catch (UnsupportedCharsetException x) {
                throw new Error(x);
            }
        }
        return null;
    }
    
    // 将对象的软引用放入线程级缓存
    private static void set(ThreadLocal tl, Object ob) {
        tl.set(new SoftReference(ob));
    }
    

    这段代码较长,大体是利用了线程级缓存来缓存decoder,这样就不必每次都实例化新的decoder,同时线程级缓存也确保了反序列化的操作是线程安全的。其中ThreadLocal和SoftReference结合的用法可以为我们所借鉴。
    第三个构造方法:

    public String(char value[]) {
        this.offset = 0;
        this.count = value.length;
        this.value = StringValue.from(value);
    }
    

    StringValue.from(value)方法的具体实现如下:

    static char[] from(char[] value) {
        return Arrays.copyOf(value, value.length);
    } 
    

    也就是进行了数组拷贝,代码比较简单,我们不再赘述。
    (2)字符串比较方法

    public int compareTo(String anotherString) {
        int len1 = count;
        int len2 = anotherString.count;
        // 获取两个字符串中长度较小者的长度
        int n = Math.min(len1, len2);
        char v1[] = value;
        char v2[] = anotherString.value;
        int i = offset;
        int j = anotherString.offset;
    
        // 如果两个字符串的offset相等
        if (i == j) {
            int k = i;
            int lim = n + i;
            // 逐个字符比较,如果相同位上的字符不同,则按照Unicode的大小进行比较
            while (k < lim) {
                char c1 = v1[k];
                char c2 = v2[k];
                if (c1 != c2) {
                    return c1 - c2;
                }
                k++;
            }
        } else { // 两个字符串的offset不相等
            // 逐个字符比较,如果相同位上的字符不同,则按照Unicode的大小进行比较
            while (n-- != 0) {
                char c1 = v1[i++];
                char c2 = v2[j++];
                if (c1 != c2) {
                    return c1 - c2;
                }
            }
        }
        // 如果仍然没有比较出大小,说明前面n个字符都相等,则长度大的字符串更大
        return len1 - len2;
    }
    

    对于这个方法的实现,我有些疑惑,原理上是对两个字符串中的字符数组进行逐个比较,这种比较方法即是”字典顺序“比较。我的疑惑在于,为什么要判断两个字符串的offset是否相等呢?直接进行else分支中的while循环不就可以了吗?这一点暂时没有想通。
    我们常用的equals方法也是基于“字典顺序”比较,主要逻辑与compareTo方法类似,此处就不再贴出代码。
    (3)hashCode方法

    // 缓存字符串的hashCode,默认为0
    private int hash;
    
    // 计算字符串的hashCode
    public int hashCode() {
        int h = hash;
        int len = count;
        // 如果之前没有计算过hashCode,且字符串长度不为0,则进行计算
        if (h == 0 && len > 0) {
            int off = offset;
            char val[] = value;
    
            // 利用公式h=31*h + c计算hashCode,c为字符数组中每个字符的code point
            for (int i = 0; i < len; i++) {
                h = 31*h + val[off++];
            }
            // 将计算好的hashCode缓存起来,以便下次使用
            hash = h;
        }
        return h;
    }
    

    我们在jdk源码分析(一)中分析如何覆盖hashCode方法时,曾讲到《effective java》中提到的一种方法,此处即是使用了这种方法来计算hashCode。
    此外,在这段代码中,值得注意的是:将整数值与char值相加会得到什么呢?根据java基本类型间的强制转换规则,char型将会被转换为int型,然后与int类型的值相加。那么char在转换为int时该如何取值呢?其实这就利用了码位(code point)的概念,我们可以通过程序来看一下。

    String s = "abc123中国";
    for (int i = 0; i < s.length(); i++) {
        System.out.println((int)s.charAt(i) + "," + s.codePointAt(i));
    }
    

    运行程序,得到的结果如下:

    97,97
    98,98
    99,99
    49,49
    50,50
    51,51
    20013,20013
    22269,22269
    

    由此可知,char字符转换为整型时,其值为其在Unicode字符集中的码位。

    (4)substring方法

    public String substring(int beginIndex, int endIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > count) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        if (beginIndex > endIndex) {
            throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
        }
        return ((beginIndex == 0) && (endIndex == count)) ? this :
                new String(offset + beginIndex, endIndex - beginIndex, value);
    }
    

    在经过对参数的校验后,substring方法最终调用了一个有三个参数的构造方法,我们来看一下:

    String(int offset, int count, char value[]) {
        this.value = value;
        this.offset = offset;
        this.count = count;
    }
    

    我们刚才在讲到String的存储结构时说,不同String实例是可以共用字符数组的,此处得到了印证:利用substring方法得到的子字符串和原字符串使用同一个字符数组value,只是offset和count不同而已。

    String类中的方法还有很多,例如用于字符串连接的concat方法,字符串查找的indexOf方法,字符串替换的replace方法,以及获取子字符串的substring方法等等,这些方法的原理不外乎围绕着字符数组value、下标offset、字符串长度count这几个变量来展开,万变不离其宗,此处不一一列举。

    三.相关类

    除了String类之外,我们日常编码时还经常使用StringBuffer和StringBuilder,它们是对String的有益补充。由于String中的字符数组被声明为final的,在赋值后就不允许被修改了,因此通常意义上,我们认为String是”不可变“的。当我们需要对字符串的值进行频繁修改时,就可以使用StringBuffer和StringBuilder了。
    我们来简单看一下这两个类。

    public final class StringBuffer
            extends AbstractStringBuilder
            implements java.io.Serializable, CharSequence
    
    public final class StringBuilder
            extends AbstractStringBuilder
            implements java.io.Serializable, CharSequence
    

    从定义中我们发现,他们继承自同一个父类AbstractStringBuilder,同时也实现了CharSequence接口,而String类也同样实现了CharSequence接口,因此这三个类具有很多相同的方法,我们就拿length方法来比较一下。
    StringBuilder类中,length方法(继承自AbstractStringBuilder类)如下:

    public int length() {
        return count;
    }
    

    而在StringBuffer类中,length方法如下:

    public synchronized int length() {
        return count;
    }
    

    显然,在StringBuffer中,方法的调用是同步的,在多线程环境中,一个线程需要等待另一个线程执行完length方法后,才可以执行,这也就是为什么我们常说StringBuffer是线程安全的原因。
    大体来看,StringStringBufferStringBuilder三个类的差别主要如下:

    线程安全 可变
    String
    StringBuffer
    StringBuilder

    此外,刚才说String在通常意义上我们认为是”不可变“的,但是也并非绝对,我们仍然可以利用反射来改变String的值,如下:

    String java = "java";
    System.out.println("old value:" + java);
    try {
        Field field = java.getClass().getDeclaredField("value");
        field.setAccessible(true);
        char[] value = (char[]) field.get(java);
        value[0] = 'g';
        System.out.println("new value:" + java);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    

    执行程序,我们会得到如下结果:

    old value:java
    new value:gava
    
    参考资料

    1.《深入理解java虚拟机》
    2.Java常量池理解与总结
    3.初探Java字符串
    4.Java常量池理解与总结
    5.维基百科:码位
    6.维基百科:Unicode

    本文已迁移至我的博客:http://ipenge.com/40983.html

    相关文章

      网友评论

      • c2d0190fdf12:楼主compareTo方法分析中的offset判断的疑惑是正确的,应该是分析的源码版本比较低,jdk1.7中的compareTo方法已经做了改进,不再判断offset是否相等:

        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;
        }

      本文标题:jdk源码分析(三)——String类

      本文链接:https://www.haomeiwen.com/subject/dxzhnttx.html