3488 2017-09-15 2020-06-25

前言:提起Java容器,很多人首先想到是List、Set、Map,这是对的。但其实还有一个更低一层次的容器类,即String类(char的容器,数组就先算了吧),尽管String并没有出现在上一节的类图中。

一、类结构

1、概述

类结构声明如下:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
        private final char value[];
        private int hash;
        // 其它略
}

从声明中我们可以看出,String类实现了代表可序列化的Serializable接口、可比较的Comparable接口、和字符序列CharSequence接口,这些接口的实现没有什么特别的,都是常规性的。值得一提的是,String类被设置为final,这就有点含义了。

2、为什么是final

final意味着不可更改,不能被继承。为什么要怎么设计?首先要理解String是不可变的,这样可以带来很多好处,如:

  • 效率性:String类是一个对象类型,是对char数组的封装,内部维护的char数组不应该随意被更改(那么hashCode也不用多次计算),且在常量池能被多次引用(只读不改) 。示例如下:
String a = "123";
String b = a;
b = "456";
// 结果a="123",b="456"

String aa = "123";
String bb = "123";
System.out.println(aa == bb);
// 结果为true
  • 安全性:String作为JDK底层核心类,使用非常广泛,需要配合特定的类加载器来保持唯一性,以便不会被外部轻易修改,这样可以避免很多安全问题。如String被广泛用于数据库、Socket中,如果String是可变,黑客改变字符串指向的对象的值,从而造成安全漏洞。

二、构造方法

1、概述

大概分为三种:

  • 以String为参数:这种比较简单,直接把对类字段value、hash复制值即可。
  • 以字节数组为参数:不作深究,了解即可,代码如下
// 将给定的字节数组转换为某种特定的编码的字符串,如utf8、gbk
public String(byte bytes[], int offset, int length, String charsetName)
        throws UnsupportedEncodingException {
    if (charsetName == null)
        throw new NullPointerException("charsetName");
    checkBounds(bytes, offset, length);
    this.value = StringCoding.decode(charsetName, bytes, offset, length);
}

public String(byte bytes[], int offset, int length, Charset charset) {
    if (charset == null)
        throw new NullPointerException("charset");
    checkBounds(bytes, offset, length);
    this.value =  StringCoding.decode(charset, bytes, offset, length);
}
  • 以char[]数组为参数:这种麻烦一点,但也是最为本质的一种构造参数,代码如下
public String(char value[]) {
    // 表明不是通过简单的数组赋值,为什么呢? 我们等下再去验证
    this.value = Arrays.copyOf(value, value.length);
}

public static char[] copyOf(char[] original, int newLength) {
    char[] copy = new char[newLength];
    System.arraycopy(original, 0, copy, 0,
                     Math.min(original.length, newLength));
    return copy;
}

public static native void arraycopy(Object src,  int  srcPos,
                                    Object dest, int destPos,
                                    int length);
// 这就是那个native方法了,不做深究                                         
JVM_ENTRY(void, JVM_ArrayCopy(JNIEnv *env, jclass ignored, jobject src, jint src_pos,
                               jobject dst, jint dst_pos, jint length))
  JVMWrapper("JVM_ArrayCopy");
  // Check if we have null pointers
  if (src == NULL || dst == NULL) {
    THROW(vmSymbols::java_lang_NullPointerException());
  }
  arrayOop s = arrayOop(JNIHandles::resolve_non_null(src));
  arrayOop d = arrayOop(JNIHandles::resolve_non_null(dst));
  assert(s->is_oop(), "JVM_ArrayCopy: src not an oop");
  assert(d->is_oop(), "JVM_ArrayCopy: dst not an oop");
  // Do copy
  s->klass()->copy_array(s, src_pos, d, dst_pos, length, thread);
JVM_END   

2、arraycopy

JDK源码不用简单的字符数组复制(即通过数组坐标),而采用native方法来实现字符数组复制操作,肯定是有原因的,大概猜测一下,是性能因素。测试代码如下:

public static void main(String[] args) {
    char[] test1 = new char[]{'1', '2', '3'};
    char[] test2 = test1;
    test1[0] = '4';
    // 结果是4,说明数组地址也是个引用,即数组不是原子类型
    System.out.println(test2[0]);       

    // 简单复制耗时:0ms:ϧ
    // native方法复制耗时:0ms:ϧ
    testCopyArray(1000);
    // 简单复制耗时:6ms:蚟
    // native方法复制耗时:0ms:蚟,此时还看不出差距,往下
    testCopyArray(100000);

    // 简单复制耗时:14ms:陿
    // native方法复制耗时:9ms:陿
    testCopyArray(10000000);
    // 简单复制耗时:246ms:,生僻字已经显示不出了
    // native方法复制耗时:114ms:
    testCopyArray(100000000);
}

private static void testCopyArray(int length) {
    char[] chars1 = new char[length];
    for (int i = 0; i < length; i++) {
        chars1[i] = (char)i;
    }
    char[] chars1_copy1 = new char[length];
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < length; i++) {
        chars1_copy1[i] = chars1[i];
    }
    long endTime = System.currentTimeMillis();
    System.out.print("简单复制耗时:" + (endTime - startTime) + "ms:");
    System.out.println(chars1_copy1[length - 1]);

    char[] chars1_copy2 = new char[length];
    startTime = System.currentTimeMillis();
    System.arraycopy(chars1, 0, chars1_copy2, 0, length);
    endTime = System.currentTimeMillis();
    System.out.print("native方法复制耗时:" + (endTime - startTime) + "ms:");
    System.out.println(chars1_copy2[length - 1]);
}

现在可以得出结果了,是性能因素,至于内部原理,暂且不管。

三、常用方法

下面跟着我来看一下String类里面的对于字符串操作的方法封装代码(由于是修订版,这个部分就不重复写了)。

1、字符串比较

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];// A 65
        char c2 = v2[k];// B 66
        if (c1 != c2) {
            return c1 - c2;// A - B = -1
        }
        k++;
    }
    return len1 - len2;
}

2、字符串连接

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);// buff里前len个为value的值,前面讲解过了
    str.getChars(buf, len);// 等效于arraycopy(str.value, 0, buf, len, otherLen),即复制后半部分
    return new String(buf, true);
}

void getChars(char dst[], int dstBegin) {
    System.arraycopy(value, 0, dst, dstBegin, value.length);
} 

3、字符串包含

这个方法包含一定的算法思想,单独拿出来讲解,算法大致分为这么几步:

  1. 纠错处理
  2. 从指定的位置上开始比较(这意味着我们可以指定忽视字符前n个比较字符)
  3. 当遇到第一个匹配的字符时停下,进行第二轮比较,即比较第二个匹配字符
  4. 当且仅当成功匹配目标字符的长度时,才算成功,否则一直向前比较,直到串尾
public boolean contains(CharSequence s) {
    return indexOf(s.toString()) > -1;// 追踪此方法,有下一步-->
}

/** 
 *  中间有一些重构,但最终是会走到这一步
 *  假设boolean isContains = "abcde".contains("cd");
 *  相当于调用indexOf("abcde", 0, "abcde".length, "cd", 0, "cd".length, 0); 
 */
static int indexOf(char[] source, int sourceOffset, int sourceCount,
        char[] target, int targetOffset, int targetCount,
        int fromIndex) {
    if (fromIndex >= sourceCount) {// 如果开始对比的坐标大于等于源字符串的长度,如果目标字符串长度为0,返回源字符串长度,不然返回-1
        return (targetCount == 0 ? sourceCount : -1);
    }
    if (fromIndex < 0) {// 如果开始比较的坐标小于0,那么归0处理
        fromIndex = 0;
    }
    if (targetCount == 0) {// 如果目标字符串的长度为0,返回对比的坐标。
        return fromIndex;
    }
    // 其实以上都是一些纠错处理,下面才是真正的对比算法步骤
    char first = target[targetOffset];// 获取目标字符串的对比坐标所对应的值
    int max = sourceOffset + (sourceCount - targetCount);// 暂时不知,设它为3-->而后得知它是表示找到首个匹配字符的最大次数

    for (int i = sourceOffset + fromIndex; i <= max; i++) {// 循环了max-sourceOffset + fromIndex次,即3 + 0 + 0次
        /* Look for first character. */
        if (source[i] != first) {// 如果源字符串的第i个字符不是first
        //while(1 <= max && 'a' != 'c')-->while(true)-->    
        //while(2 <= max && 'b' != 'c')-->while(true)-->
        //while(3 <= max && 'c' != 'c')-->while(false)-->break if,此时i = 3;
            while (++i <= max && source[i] != first);
        }

        /* Found first character, now look at the rest of v2 */
        if (i <= max) {// i < = 3
            int j = i + 1;// j = 4,即首个匹配字符后面的对比坐标
            int end = j + targetCount - 1;// end = j + "cd".length - 1 = 5,标识二次比较的次数限制

        // for(k = 0 + 1; 4 < 5 && source[4] == target[1]; j++, k++)-->true
        // for(k = 2; 5 < 5 && source[5] == target[2]; j++, k++)-->true 
            for (int k = targetOffset + 1; j < end && source[j]
                    == target[k]; j++, k++);
            if (j == end) {// 找到完全匹配的字符串
                /* Found whole string. */
                return i - sourceOffset;// 返回 3 - 0,即“首个”匹配字符坐标
            }
        }
    }
    return -1;
}

4、equals方法

String的equals方法,其本质还是char字符的比较。

public boolean equals(Object anObject) {
    if (this == anObject) {// 如果是自己对比自己。由此我们可以得出,比较两个对象是不是同一对象,用==来
        return true;
    }
    if (anObject instanceof String) {// 如果这个对象是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;
}

5、hashCode方法

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];// h = 31 * h + int(value[i])
        }
        hash = h;
    }
    return h;
}

总结:当看到神秘的哈希码这样来的时,你是否和我一样,有着些许惊讶。于是乎,我断言,”AB”.hashCode() = 31 * (31 * 0 + 65) + 66 = 2081。我是对的。

6、忽视前后空格

public String trim() {
    int len = value.length;
    int st = 0;
    char[] val = value;    /* avoid getfield opcode */

    while ((st < len) && (val[st] <= ' ')) {// (int)' ' = 32,由此推测(char)32之前可能都代表着某种空格含义
        st++;// 记录下头部空格的个数,方便截取
    }
    while ((st < len) && (val[len - 1] <= ' ')) {
        len--;// 记录下尾部空格的个数,方便截取
    }
    return ((st > 0) || (len < value.length)) ? substring(st, len) : this;//截取
}

四、比较

下面我们比较下String、StringBuffer、StringBuilder的字符串拼接速度,测试代码如下

public class StringAppend {

    private static int[] times = {10, 100, 1000, 10000, 100000, 200000};

    public static void main(String[] args) {
        testString();
        testStringBuffer();
        testStringBuilder();
    }

    private static void testString() {
        long start = System.currentTimeMillis();
        String test = "a";
        for (int i = 0; i < times.length; i++) {
            for (int j = 0; j < times[i]; j++) {
                test = test + "a";
            }
            System.out.println("testString:次数为" + times[i] + ",执行耗时" + (System.currentTimeMillis() - start) + "ms");
        }
        System.out.println();
    }

    private static void testStringBuffer() {
        long start = System.currentTimeMillis();
        StringBuffer buffer = new StringBuffer("a");
        for (int i = 0; i < times.length; i++) {
            for (int j = 0; j < times[i]; j++) {
                buffer.append("a");
            }
            System.out.println("testStringBuffer:次数为" + times[i] + ",执行耗时" + (System.currentTimeMillis() - start) + "ms");
        }
        System.out.println();
    }

    /**
     * 顶呱呱
     */
    private static void testStringBuilder() {
        long start = System.currentTimeMillis();
        StringBuilder builder = new StringBuilder("a");
        for (int i = 0; i < times.length; i++) {
            for (int j = 0; j < times[i]; j++) {
                builder.append("a");
            }
            System.out.println("testStringBuilder:次数为" + times[i] + ",执行耗时" + (System.currentTimeMillis() - start) + "ms");
        }
        System.out.println();
    }
}
// 输出结果如下
testString:次数为10,执行耗时0ms
testString:次数为100,执行耗时1ms
testString:次数为1000,执行耗时11ms
testString:次数为10000,执行耗时214ms
testString:次数为100000,执行耗时9566ms
testString:次数为200000,执行耗时38060ms

testStringBuffer:次数为10,执行耗时0ms
testStringBuffer:次数为100,执行耗时0ms
testStringBuffer:次数为1000,执行耗时0ms
testStringBuffer:次数为10000,执行耗时1ms
testStringBuffer:次数为100000,执行耗时7ms
testStringBuffer:次数为200000,执行耗时13ms

testStringBuilder:次数为10,执行耗时0ms
testStringBuilder:次数为100,执行耗时0ms
testStringBuilder:次数为1000,执行耗时0ms
testStringBuilder:次数为10000,执行耗时0ms
testStringBuilder:次数为100000,执行耗时4ms
testStringBuilder:次数为200000,执行耗时9ms

由输出发现String类在次数为100以后,与后两者根本不是一个级别的。下面是一个简单代码,用于测试String,如下

public class SimpleStringTest {
    public static void main(String[] args) {
        String test = "aa";
        test = test + "bb";
        System.out.println(test);
    }
}

通过javap -verbose SimpleStringTest.class命令是看不出有什么特别的(具体原因不明,猜测可能是JDK为了节省篇幅或存在优化),通过第三方工具jclasslib查看,如下

 0 ldc #2 <aa> // 将#2(aa)推送至栈顶
 2 astore_1 // 将栈顶引用型数值存入第二个本地变量
 3 new #3 <java/lang/StringBuilder> // 创建一个对象,并将其引用值压入栈顶
 6 dup // 复制栈顶数值并将复制值压入栈顶(感觉这一步与上面一步有点重复)
 7 invokespecial #4 <java/lang/StringBuilder.<init>> // 调用超类构造方法,实例初始化方法,私有方法
10 aload_1 // 将第二个引用类型本地变量推送至栈顶
11 invokevirtual #5 <java/lang/StringBuilder.append> // 调用实例方法
14 ldc #6 <bb> // 将#6(bb)推至栈顶
16 invokevirtual #5 <java/lang/StringBuilder.append> // 调用实例方法
19 invokevirtual #7 <java/lang/StringBuilder.toString> // 调用实例方法
22 astore_1 // 将栈顶引用型数值存入第二个本地变量
23 getstatic #8 <java/lang/System.out> // 获取指定类的静态域,并将其压入栈顶
26 aload_1 // 将第二个引用类型本地变量推送至栈顶
27 invokevirtual #9 <java/io/PrintStream.println> // 调用实例方法
30 return // 返回void

上面代码可理解为

public class SimpleStringTest1 {
    public static void main(String[] args) {
        String test = "aa";
        test = test + "bb";
        System.out.println(test);
        
        StringBuilder test1 = new StringBuilder();
        test1.append("aa");
        test1.append("bb");
        String temp = test1.toString();
        System.out.println(temp);
    }
}

上述代码其对应字节码为

 0 ldc #2 <aa> // 将int、float或String类型常量值从常量池推送至栈顶
 2 astore_1 // 将栈顶引用型数值存入第二个本地变量
 3 new #3 <java/lang/StringBuilder> // 创建一个对象,并将其引用值压入栈顶
 6 dup // 复制栈顶数值并将复制值压入栈顶(感觉new和dup是绑定的,有某种联系)
   // 经过百度,大致是确实需要两个引用,一个有<init>调用,另一个则是<init>后的引用
 7 invokespecial #4 <java/lang/StringBuilder.<init>> // 调用超类构造方法,实例初始化方法,私有方法
10 aload_1 // 将第二个引用类型本地变量推送至栈顶
11 invokevirtual #5 <java/lang/StringBuilder.append> // 调用实例方法
14 ldc #6 <bb> // 将#6(bb)推至栈顶
16 invokevirtual #5 <java/lang/StringBuilder.append> // 调用实例方法
19 invokevirtual #7 <java/lang/StringBuilder.toString> // 调用实例方法
22 astore_1 // 将栈顶引用型数值存入第二个本地变量
23 getstatic #8 <java/lang/System.out> // 获取指定类的静态域,并将其压入栈顶
26 aload_1 // 将第二个引用类型本地变量推送至栈顶
27 invokevirtual #9 <java/io/PrintStream.println> // 调用实例方法
// 分隔符 //
30 new #3 <java/lang/StringBuilder>
33 dup
34 invokespecial #4 <java/lang/StringBuilder.<init>>
37 astore_2 // 把SringBuild存到了Slot2
38 aload_2
39 ldc #2 <aa>
41 invokevirtual #5 <java/lang/StringBuilder.append>
44 pop // 将栈顶数值弹出
45 aload_2  // 加载第二个引用类型本地变量
46 ldc #6 <bb>
48 invokevirtual #5 <java/lang/StringBuilder.append>
51 pop
52 aload_2
53 invokevirtual #7 <java/lang/StringBuilder.toString>
56 astore_3
57 getstatic #8 <java/lang/System.out>
60 aload_3
61 invokevirtual #9 <java/io/PrintStream.println>
64 return

从字节码上看,一下子就明白了。

最后看下StringBuffer和StringBuilder的差别,跟踪两者源代码,如下

public final class StringBuffer extends AbstractStringBuilder implements Serializable, CharSequence { 
	public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }
}

public final class StringBuffer extends AbstractStringBuilder implements Serializable, CharSequence { 
    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }
}

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

两者的区别在于StringBuffer加了一个关键字synchronized,做了同步化处理,所以性能稍稍慢点,而StringBuilder没有做同步化处理,所以性能稍稍好点,两者其实都是调用父类的方法。其他方法同理,都只是一个synchronized关键字的区别。

需要注意的是,如果在编译时期,一个String类型的常量值是确定的,那么编译器会对其进行优化,例子如下

String a = "aa" + "bb" + "cc";
// 完全等效于
String a = "aabbcc";

所以这种情况下,String是最快的。

总访问次数: 313次, 一般般帅 创建于 2017-09-15, 最后更新于 2020-06-25

进大厂! 欢迎关注微信公众号,第一时间掌握最新动态!