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

一.几个概念

在我们正式开始看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

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

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,571评论 18 399
  • Tip:笔者马上毕业了,准备开始 Java 的进阶学习计划。于是打算先从 String 类的源码分析入手,作为后面...
    石先阅读 11,990评论 16 58
  • 写着写着发现简书提醒我文章接近字数极限,建议我换一篇写了。 建议52:推荐使用String直接量赋值 一般对象都是...
    我没有三颗心脏阅读 1,331评论 2 4
  • 今天写这篇总结,是因为从我练习瑜伽到现在,虽然在体式上稍有进步,但是在心理技能上却遇到了瓶颈,感觉不在状态。于...
    卿云依依阅读 909评论 0 0
  • 【组名】Sunshine 【组呼】别改天了,就今天吧,别以后了,就现在吧! 【组徽】 【组歌歌词】 我相信我就是我...
    晓猪佩琪阅读 287评论 1 1