coding……
但行好事 莫问前程

Java编程拾遗『String类』

从概念上讲,Java字符串就是Unicode字符序列。例如,串“Java\u2122”由5个Unicode字符J、a、v、a和TM。每个用双引号括起来的字符串都是String类的一个实例:

String e = ""; //空字符串
String greeting = "hello"; 

1. Java API String常用方法

在Java API中,String定义如下:

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

需要注意的是String类是final修饰的,也就是讲是不可变类,这个回头再讲,先来看一下Java API String类常用方法:

方法 说明
public String()
public String(byte bytes[])
public String(byte bytes[], Charset charset)
public String(byte bytes[], int offset, int length)
public String(byte bytes[], int offset, int length, Charset charset)
public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], String charsetName)
public String(char value[])
public String(char value[], int offset, int count)
public String(int[] codePoints, int offset, int count)
public String(String original)
public String(StringBuffer buffer)
public String(StringBuilder builder)
构造函数
public char charAt(int index) 返回字符串内部持有的char数组index位置对应的字符
public int codePointAt(int index) 返回字符串字符串内部持有的char数组index索引位置的的十进制码位值
public int codePointBefore(int index) 返回字符串字符串内部持有的char数组index索引前的十进制码位值
public int codePointCount(int beginIndex, int endIndex) 返回字符串字符串内部持有的char数组beginIndex到endIndex之间的码位个数
public int compareTo(String anotherString) 字符串比较
public int compareToIgnoreCase(String str) 忽略大小写字符串比较
public String concat(String str) 字符串拼接
public boolean contains(CharSequence s) 判断CharSequence s是否是当前字符串的子串
public boolean contentEquals(CharSequence cs) 判断CharSequence s内容跟当前字符串是否相等
public boolean contentEquals(StringBuffer sb) 判断StringBuffer sb内容跟当前字符串是否相等
public static String copyValueOf(char data[]) 静态方法,char数组转String
public static String copyValueOf(char data[], int offset, int count) 静态方法,copy char数组从index offset起count个char为字符串
public boolean endsWith(String suffix) 判断字符串是否以suffix结束
public boolean equals(Object anObject) 判断字符串跟anObject内容是否相等
public boolean equalsIgnoreCase(String anotherString) 忽略大小写,判断字符串跟anObject内容是否相等
public static String format(Locale l, String format, Object… args) 格式化String,l为语言环境
public static String format(String format, Object… args) 格式化String
public byte[] getBytes() 通过操作系统默认支持的编码方式获取字符串编码byte数组
public byte[] getBytes(Charset charset) 通过指定编码方式获取字符串编码byte数组
public byte[] getBytes(String charsetName) 通过指定编码方式获取字符串编码byte数组
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) 当前字符串srcBegin到srcEnd区间内的子串,拷贝到目标char数组dstBegin开始的位置
public int hashCode() 返回字符串hashCode
public int indexOf(int ch) 返回十进制码位ch在字符串内部持有的char数组首次出现的index
public int indexOf(int ch, int fromIndex) 返回十进制码位ch在字符串内部持有的char数组从fromIndex开始首次出现的index
public int indexOf(String str) 返回str在字符串内部持有的char数组首次出现的index
public int indexOf(String str, int fromIndex) 返回str在字符串内部持有的char数组从fromIndex开始首次出现的index
public native String intern() 显式地将字符串放入常量池,返回常量池中改字符串常量的地址
public boolean isEmpty() 判断字符串是否为空
public static String join(CharSequence delimiter, CharSequence… elements)
public static String join(CharSequence delimiter,
Iterable<? extends CharSequence> elements)
连接字符串,连接符为delimiter
public int lastIndexOf(int ch) 返回十进制码位ch在字符串内部持有的char数组最后出现的index
public int lastIndexOf(int ch, int fromIndex) 返回十进制码位ch在字符串内部持有的char数组从fromIndex开始最后出现的index
public int lastIndexOf(String str) 返回str在字符串内部持有的char数组最后出现的index
public int lastIndexOf(String str, int fromIndex) 返回str在字符串内部持有的char数组从fromIndex开始最后出现的index
public int length() 返回字符串内部持有的char数组长度
public boolean matches(String regex) 判断字符串是否符合正则表达式
public int offsetByCodePoints(int index, int codePointOffset) 字符串内部持有的char数组从给定的index处偏移codePointOffset个代码位的索引
public boolean regionMatches(boolean ignoreCase, int toffset,
String other, int ooffset, int len)
检测两个字符串在一个区域内是否相等
public boolean regionMatches(int toffset, String other, int ooffset,
int len)
不忽略大小写,检测两个字符串在一个区域内是否相等
public String replace(char oldChar, char newChar)
public String replace(CharSequence target, CharSequence replacement)
public String replaceAll(String regex, String replacement)
public String replaceFirst(String regex, String replacement)
字符串替换
public String[] split(String regex) 根据regex匹配分割字符串
public boolean startsWith(String prefix) 判断字符串是否以prefix开始
public boolean startsWith(String prefix, int toffset) 判断字符串从toffset开始的子串是否已prefix开始
public CharSequence subSequence(int beginIndex, int endIndex) 截取字符串beginIndex到endIndex之间的子串,取件左闭右开
public String substring(int beginIndex) 截取字符串从beginIndex到字符串结束的子串,包括beginIndex
public String substring(int beginIndex, int endIndex) 截取字符串beginIndex到endIndex之间的子串,取件左闭右开
public char[] toCharArray() 获取字符串内部持有的char数组
public String toLowerCase()
public String toLowerCase(Locale locale)
字符串转小写,Local为语言环境
public String toString() toString方法
public String toUpperCase()
public String toUpperCase(Locale locale)
字符串转大写,Local为语言环境
public String trim() 去空格
public static String valueOf(boolean b)
public static String valueOf(char data[])
public static String valueOf(char c)
public static String valueOf(char data[], int offset, int count)
public static String valueOf(double d)
public static String valueOf(float f)
public static String valueOf(int i)
public static String valueOf(long l)
public static String valueOf(Object obj)
转字符串

关于上述codePoint的概念,我会在之后的一篇讲编码的文章中详细讲述,简单的讲,codePoint就是码位,Java中一个码位可以用一个或两个码元表示(这也就是为什么Java中String的一个字符有的占一个char,有的占两个char)。关于编码的相关知识,我会在下篇文章中讲解。

2. Java API String实现

String内部持有一个final类型的char数组,如下:

private final char value[];

final修饰value[],final修饰的字段创建以后就不可改变,有人认为这就就是String是不可变类这一说法的原因,其实这种理解是错误的,因为final只保证引用是不可变的,并不保证内容不可变,这点我会在本文中详细讲解。Java Api String类的大多数方法都试基于这个char数组实现的。

2.1 Constructor

这里特殊讲一下参数为char数组的构造函数,如下:

public String(char value[]) {
    this.value = Arrays.copyOf(value, value.length);
}

public String(char value[], int offset, int count) {
    if (offset < 0) {
        throw new StringIndexOutOfBoundsException(offset);
    }
    if (count <= 0) {
        if (count < 0) {
            throw new StringIndexOutOfBoundsException(count);
        }
        if (offset <= value.length) {
            this.value = "".value;
            return;
        }
    }
    // Note: offset or count might be near -1>>>1.
    if (offset > value.length - count) {
        throw new StringIndexOutOfBoundsException(offset + count);
    }
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

可以发现,初始化时,都是通过Arrays.copyOf创造一个新的数组,而不是直接使用构造函数传进来的数组,这样对外部char数组的修改就不会影响到String了,这也是我即将要讲的String的不可变性就是依赖这种内部封装实现的,而不单单依靠一个final char数组。

2.2 charAt

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

charAt返回String内部持有的char数组指定index的char。如果字符串中只有单字节字符,字符串字符的个数跟内部持有的char数组长度时相等的,比如”a卓立b”,字符串中字符的个数为4,内部持有的字符数组长度也是4,所以charAt(1),返回是’卓’,没什么问题。但是假如字符串是”a𤭢立b”,因为字符’𤭢’在JVM中用4字节(两个char)表示,所以charAt(1)会返回“半个”𤭢字符,无法正常表示,便展示为一个?,如下:

class Untitled {
    public static void main(String[] args) {
        //系统默认编码
        System.out.println(System.getProperty("file.encoding"));

        String str1 = "a卓立b";
        System.out.println("str1: " + str1);
        System.out.println("str1.length(): " + str1.length());
        System.out.println("str1.charAt(1): " + str1.charAt(1));

        System.out.println("----------------------------------");

        String str2 = "a𤭢立b";
        System.out.println("str2: " + str2);
        System.out.println("str2.length(): " + str2.length());
        System.out.println("str2.charAt(1): " + str2.charAt(1));
    }
}

运行结果:

UTF-8
str1: a卓立b
str1.length(): 4
str1.charAt(1): 卓
----------------------------------
str2: a𤭢立b
str2.length(): 5
str2.charAt(1): ?

2.3 codePointAt

public int codePointAt(int index) {
    if ((index < 0) || (index >= value.length)) {
        throw new StringIndexOutOfBoundsException(index);
    }
    return Character.codePointAtImpl(value, index, value.length);
}

codePoint就是码位的意思,是指计算机编码字符集中的一个个位置,每个字符都有一个对应的码位。比如,ASCII码包含128个码位,范围是01(16进制)到7F(16进制),扩展ASCII码包含256个码位,范围是0到FF(16进制),而Unicode包含1,114,112个码位,范围是0(16进制)到10FFFF(16进制)。Unicode字符集中,码位是使用16位的码元来表示的,有的码位使用一个码元(16位,2字节)表示,有的则要使用两个码元(32位,4字节)表示。由于Java中char是使用Unicode编码的,所以其实一个char就可以理解位一个码元。而codePointAt的作用是返回字符串内部持有的字符数组index索引位置的码元的十进制表示。这里同样要分双字节字符和四字节字符讨论,如下:

class Untitled {
    public static void main(String[] args) {
        //系统默认编码
        System.out.println(System.getProperty("file.encoding"));

        String str1 = "a卓立b";
        System.out.println("str1: " + str1);
        System.out.println("str1.length(): " + str1.length());
        System.out.println("str1.charAt(1): " + str1.charAt(1));
        //使用构造函数public String(char value[])
        System.out.println("str1.codePointAt(1): " + new String(Character.toChars(str1.codePointAt(1))));
        //使用构造函数public String(int[] codePoints, int offset, int count)
        System.out.println("str1.codePointAt(2): " + new String(new int[]{str1.codePointAt(2)}, 0, 1));

        System.out.println("----------------------------------");

        String str2 = "a𤭢立b";
        System.out.println("str2: " + str2);
        System.out.println("str2.length(): " + str2.length());
        System.out.println("str2.charAt(1): " + str2.charAt(1));

        System.out.println("str2.codePointAt(1): " + new String(Character.toChars(str2.codePointAt(1))));
        System.out.println("str2.codePointAt(1): " + new String(new int[]{str2.codePointAt(1)}, 0, 1));

        //'𤭢'是双字节字符,所以'立'在内部char数组中的index是3(注意不是2)
        System.out.println("str2.codePointAt(3): " + new String(new int[]{str2.codePointAt(3)}, 0, 1));

        //如果使用codePointAt(2),会得到一个非法的codePoint,因为char数组index 2位置是字符'𤭢'的低16位。
        //对于四字节字符,可以获取合法codePoint的前提是index必须是字符数组该四字节字符的高16位char的index
        System.out.println("str2.codePointAt(2): " + new String(new int[]{str2.codePointAt(2)}, 0, 1));
    }
}

运行结果:

UTF-8
str1: a卓立b
str1.length(): 4
str1.charAt(1): 卓
str1.codePointAt(1): 卓
str1.codePointAt(2): 立
----------------------------------
str2: a𤭢立b
str2.length(): 5
str2.charAt(1): ?
str2.codePointAt(1): 𤭢
str2.codePointAt(1): 𤭢
str2.codePointAt(3): 立
str2.codePointAt(2): ?

2.4 codePointCount

String的length()方法,返回的是字符串内部持有的字符数组的length,但是上面也讲过,有些字符使用unicode编码是占4个字节(2个char)的,所以使用length()方法,并不一定跟字符串中字符的个数完全相同。codePointCount方法返回字符串内部持有的字符数组从beginIndex到endIndex之间(区间左闭右开)码位的个数(一个码位其实就等同于字符串中一个字符),如下:

class Untitled {
    public static void main(String[] args) {
        //系统默认编码
        System.out.println(System.getProperty("file.encoding"));

        String str1 = "a卓立b";
        System.out.println("str1: " + str1);
        System.out.println("str1.length(): " + str1.length());
        System.out.println("str1.codePointCount(): " + str1.codePointCount(0, str1.length()));

        System.out.println("----------------------------------");

        String str2 = "a𤭢立b";
        System.out.println("str2: " + str2);
        System.out.println("str2.length(): " + str2.length());
        System.out.println("str2.codePointCount(): " + str2.codePointCount(0, str2.length()));
    }
}

运行结果:

UTF-8
str1: a卓立b
str1.length(): 4
str1.codePointCount(): 4
----------------------------------
str2: a𤭢立b
str2.length(): 5
str2.codePointCount(): 4

2.5 getBytes

字符在计算机中的表示都是二进制的,在Unicode字符集中,每个字符都有一个对应的码位,同一个码位使用不同的编码方式得到的结果,而计算机中存储的就是编码后的二进制串。比如汉字”中”,使用UTF-16编码结果是\u4E2D(16位,2个字节),而使用UTF-8编码结果则是\uE4B8AD(24位,3个字节)。getBytes用于将字符串按照特定编码将字符串转化位byte数组(计算机中的表示形式),如果getBytes方法中没有指定编码方式,就会使用”file.encoding”系统默认的编码方式

public class Untitled {

    public static void main(String[] args) {
        //系统默认编码
        System.out.println(System.getProperty("file.encoding"));
        System.out.print("UTF-8: ");
        printHexString("中".getBytes());

        System.out.print("UTF-16: ");
        printHexString("中".getBytes(StandardCharsets.UTF_16BE));        
    }

    private static void printHexString(byte[] b) {
        StringBuilder hex = new StringBuilder();
        for (int i = 0; i < b.length; i++) {
             hex.append(Strings.padStart(Integer.toHexString(b[i] & 0xFF), 2, '0'));
        }
        String result = "\\u" + hex.toString().toUpperCase();
        System.out.println(result);
    }
}

运行结果:

UTF-8
UTF-8: \uE4B8AD
UTF-16: \u4E2D

系统默认编码是UTF-8,同一个字符串”中”使用UTF-8编码,结果是\uE4B8AD,使用UTF-16编码,结果是\u4E2D,符合预期。回想以下,在之前讲述基本数据类型时,讲过char在Java中占两个字节使用Unicode编码,其实意思就是,Java中char使用UTF-16编码存储。回过来,我们知道,UTF-16编码是变长2/4字节编码方式,所以这也是为什么之前”𤭢”可以占两个char的原因。

2.6 intern

intern在平时开发中并不是很常用,相信包括我绝大多数人对这个方法都试一知半解,在开发中也不知道如何使用它。有过面试经历的人肯定都知道,这几乎是个被面烂了的面试题。现在我们就看一下,intern到底是干嘛的。先看两个常见的面试题:

Q1:String s1 = new String(“123”);创建了几个对象?

Q2:intern方法的作用是什么?

我当时找工作前也准备过类似的题目,在网上找到了“标准”答案:

A1:若常量池中已经存在字符串”123″,则直接引用,也就是此时只会创建一个对象。如果常量池中不存在”123″,则先会在常量池中创建一个字符串常量”123″,然后再引用这个对象,也就是有两个。

A2:当一个String对象调用intern()方法时,会检查常量池中是否有相同的字符串常量,如果有,则返回其的引用,如果没有,则在常量池中新建一个字符串常量并返回它的引用。

我也一直感觉这就是标准答案,没去仔细想过一些细节性的问题。直到一次偶然的机会,我想到一个问题,new Stirng创建String实例时,会检查常量池中是否有字符串常量,如果常量池中不存在,那么则会在常量池中创建一个常量然后引用。intern方法调用时,也会检查常量池中是否存在该常量,存在则引用,不存在则先新建后引用。好像看起来并没有区别,那么还是用intern干嘛,下面这行代码使用到底有什么意义:

String s = new Stirng("123").intern();

因为好像不使用intern也会检查常量池,不存在就会新建字符串常量。然后再看一段代码,如下:

public static void main(String[] args){
    String s1 = "123";
    String s2 = new String("123");
    String s3 = new String("123").intern();

    System.out.println("s1 == s2: " + String.valueOf(s1 == s2));
    System.out.println("s1 == s3: " + String.valueOf(s1 == s3));
    System.out.println("s2 == s3: " + String.valueOf(s2 == s3));
}

运行结果:

s1 == s2: false
s1 == s3: true
s2 == s3: false

看到如上的结果,我又产生了疑惑,到底intern是干嘛的。下面我们来从Java的角度,看一下上述结果的原因,也来解释一下上面提到的两个被问烂了的面试题。

2.6.1 字面量和运行时常量池

JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串常量池,相同内容的字符串对象可以引用字符串常量池中同一个字符串常量(基于之前讲的字符串的不可变性)。Java6及之前的版本,JVM中有一块内存区域方法区,运行时常量池便维护在方法区中。从Java7之后,在Java堆中开辟了一块区域存放运行时常量池。常量池,主要用来存储编译期生成的各种字面量符号引用

了解Class文件结构的同学可能都知道,在java代码被javac编译之后,class文件结构中是包含一部分Constant pool的。比如以下代码:

public class Intern {
    public static void main(String[] args){
        String s1 = "123";
    }
}

经过编译后,Constant pool内容如下:

Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // 123
   #3 = Class              #22            // com/zhuoli/service/thinking/java/string0/Intern
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lcom/zhuoli/service/thinking/java/string0/Intern;
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               args
  #15 = Utf8               [Ljava/lang/String;
  #16 = Utf8               s1
  #17 = Utf8               Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Intern.java
  #20 = NameAndType        #5:#6          // "<init>":()V
  #21 = Utf8               123
  #22 = Utf8               com/zhuoli/service/thinking/java/string0/Intern
  #23 = Utf8               java/lang/Object

上面Constant pool中,#16 s1就是符号引用,#21 123就是字面量。Class文件中的Constant pool部分的内容,会在运行期被运行时常量池加载进去

2.6.2 new String(“123”)到底创建了几个对象

下面,我们可以来分析下如下一行代码,创建对象情况:

String s1 = new String("123");

从上节字面量和运行时常量池的分析可以知道,在编译期,符号引用s1和字面量123会被加入到Class文件的Constant pool中,然后在类加载阶段,这两个常量会进入字符串常量池。这个“进入”阶段,并不会直接把Constant pool中所有的常量全部都加载进来,而是会做个比较,如果需要加到字符串常量池中的字符串已经存在,那么就不需要再把字符串字面量加载进来了。本文刚开始讲的“如果常量池中不存在”123″,则先会在常量池中创建一个字符串常量”123″ ”,就是表述将Constant pool中的常量加载到字符串常量池,在比较阶段,字符串常量池中不存在该字符串字面量并将字符串字面量加载进来的过程。在运行期,执行到new String(“123”)的时候,要在Java堆中创建一个字符串对象,而这个对象所对应的字符串字面量是保存在字符串常量池中的。但是,String s1 = new String(“123”);对象的符号引用s1是保存在Java虚拟机栈上的,它保存的是堆中刚刚创建出来的的字符串对象的引用。如下图:

 常量池中的对象是在编译期就确定好了的,在类被加载的时候创建的,如果类加载时,该字符串常量在常量池中已经有了,那这一步就省略了。堆中的对象是在运行期才确定的,在代码执行到new的时候创建的。所以new String(“123”),会创建建一个或两个对象。

2.6.3 intern()方法的作用

上面讲到,编译期生成的各种字面量符号引用是运行时常量池中比较重要的一部分来源,但是并不是全部。那么还有一种情况,可以在运行期向运行时常量池中增加常量。那就是String的intern方法。当一个String实例调用intern()方法时,Java查找常量池中是否有相同Unicode的字符串常量,如果有,则返回其的引用,如果没有,则在常量池中增加一个Unicode等于str的字符串并返回它的引用;所以intern()方法的作用是:

  • 如果字符串常量池中没有当前字符串相同的常量,则在字符串常量池创建一个常量
  • 返回字符串常量池中常量的引用

现在回到本节最开始的那段代码,为什么运行结果是:

s1 == s2: false
s1 == s3: true
s2 == s3: false

A2中说的“如果有的话就直接返回其引用”,指的是会把字面量对象的引用直接返回给定义的对象引用,这个过程是不会在Java堆中再创建一个String对象的。 String s3 = new String(“123”).intern();,在不调用intern情况,s3指向的是JVM在堆中创建的那个对象的引用的(如图中的s2)。但是当执行了intern方法时,s3将指向字符串常量池中的那个字符串常量。

2.6.4 intern()正确打开方式

到这我们其实可以发现,上文中调用intern()方法好像并没有什么意义,因为即使不适用intern()方法,new String(“123”)时,123作为一个字面量也会被加载到Class文件的Constant pool中,进而加入到运行时常量池中。显然这种情况是不需要使用intern()的,那么intern()的正确打开方式是什么?

首先来看两段代码:

//示例1
String s1 = "123";
String s2 = "456";
String s3 = s1 + s2;
//示例2
String s4 = "123" + "456";

经过反编译后,两段代码分别如下:

//示例1
String s1 = "123";
String s2 = "456";
String s3 = (new StringBuilder()).append(s1).append(s2).toString();
//示例2
String s4 = “123456”;

可以发现,同样是字符串拼接,s3和s4在经过编译器编译后的实现方式并不一样。s3被转化成StringBuilder及append,而s4被直接拼接成新的字符串。究其原因,就是因为编译器做了代码优化,对于s4而言,编译器可以确定一个字面量。而对于s3,编译器无法知道s3内容是什么,只能等到运行时才知道结果。下面我们反编译一下class文件,看一下Class文件的Constant pool有什么不同:

//示例1
Constant pool:
   #1 = Methodref          #9.#27         // java/lang/Object."<init>":()V
   #2 = String             #28            // 123
   #3 = String             #29            // 456
   #4 = Class              #30            // java/lang/StringBuilder
   #5 = Methodref          #4.#27         // java/lang/StringBuilder."<init>":()V
   #6 = Methodref          #4.#31         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #7 = Methodref          #4.#32         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #8 = Class              #33            // com/zhuoli/service/thinking/java/string0/Intern1
   #9 = Class              #34            // java/lang/Object
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Lcom/zhuoli/service/thinking/java/string0/Intern1;
  #17 = Utf8               main
  #18 = Utf8               ([Ljava/lang/String;)V
  #19 = Utf8               args
  #20 = Utf8               [Ljava/lang/String;
  #21 = Utf8               s1
  #22 = Utf8               Ljava/lang/String;
  #23 = Utf8               s2
  #24 = Utf8               s3
  #25 = Utf8               SourceFile
  #26 = Utf8               Intern1.java
  #27 = NameAndType        #10:#11        // "<init>":()V
  #28 = Utf8               123
  #29 = Utf8               456
  #30 = Utf8               java/lang/StringBuilder
  #31 = NameAndType        #35:#36        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #32 = NameAndType        #37:#38        // toString:()Ljava/lang/String;
  #33 = Utf8               com/zhuoli/service/thinking/java/string0/Intern1
  #34 = Utf8               java/lang/Object
  #35 = Utf8               append
  #36 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #37 = Utf8               toString
  #38 = Utf8               ()Ljava/lang/String;

可以看到Constant pool中字面量只有#28 123和#29 456。

//示例2
Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // 123456
   #3 = Class              #22            // com/zhuoli/service/thinking/java/string0/Intern2
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lcom/zhuoli/service/thinking/java/string0/Intern2;
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               args
  #15 = Utf8               [Ljava/lang/String;
  #16 = Utf8               s4
  #17 = Utf8               Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Intern2.java
  #20 = NameAndType        #5:#6          // "<init>":()V
  #21 = Utf8               123456
  #22 = Utf8               com/zhuoli/service/thinking/java/string0/Intern2
  #23 = Utf8               java/lang/Object

Constant pool中字面量只有#21 123456。所以按照之前的理论,示例1中的123456是无法加入到字符串常量池中的,这时候就是要用到intern的时候了。很多时候,我们在程序中得到的字符串是只有在运行期才能确定的,在编译期是无法确定的,那么也就没办法在类加载时被加入到字符串常量池中。这时候,对于那种可能经常使用的字符串,使用intern进行定义,每次JVM运行到这段代码的时候,就会直接把常量池中该字面值的引用返回,这样可以减少大量字符串对象的创建。如下代码:

static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
    Integer[] DB_DATA = new Integer[10];
    Random random = new Random(10 * 10000);
    for (int i = 0; i < DB_DATA.length; i++) {
        DB_DATA[i] = random.nextInt();
    }
    long t = System.currentTimeMillis();
    for (int i = 0; i < MAX; i++) {
         arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
    }

    System.out.println((System.currentTimeMillis() - t) + "ms");
    System.gc();
}

以上代码中,我们明确的知道,会有很多重复的相同的字符串产生,但是这些字符串的值都是只有在运行期才能确定的。所以,只能我们通过intern显示的将其加入常量池,这样可以减少很多字符串的重复创建。这里要特别感谢一下HollisChuang大神的博文,让我真正搞明白了这一知识点,本节的例子基本都来自这篇文章。

2.7 hashCode

String内部维护了一个私有的成员变量hash,用于缓存hashCode方法的值,第一次调用hashCode()的时候,会把结果保存在hash这个变量中,以后再调用就直接返回保存的值。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];
        }
        hash = h;
    }
    return h;
}

如果缓存的hash不为0,就直接返回了,否则根据字符数组中的内容计算hash,计算方法是:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

s表示字符串,s[0]表示第一个字符,n表示字符串长度,s[0]*31^(n-1)表示31的n-1次方再乘以第一个字符的值。为什么要用这个计算方法呢?这个式子中,hash值与每个字符的值有关,每个位置乘以不同的值,hash值与每个字符的位置也有关。使用31大概是因为两个原因,一方面可以产生更分散的散列,即不同字符串hash值也一般不同,另一方面计算效率比较高,31*h与32*h-h即 (h<<5)-h等价,可以用更高效率的移位和减法操作代替乘法操作。在Java中,普遍采用以上思路来实现hashCode。

3. String的不可变性

我们都知道String是个不可变类,相信很多人只能讲到final修饰class,不允许继承子类修改,内部持有的char数组也是final修饰的,不可变。这里先讲一下结论,String的不可变性由以上两点是无法保证的,String的不可变性,关键是因为SUN公司的工程师,在后面所有String的方法里很小心的没有去动内部char数组的元素,没有暴露内部成员字段(比如上面讲的初始化时通过Arrays.copyOf创造一个新的数组,而不是直接使用构造函数传进来的数组)。而且设计师还很小心地把整个String设成final禁止继承,避免被其他人继承后破坏。所以除了final,更多的是底层方法的设计。考验的是工程师构造数据类型,封装数据的功力。

3.1 什么是不可变性

String不可变很简单,如下图,给一个已有字符串”abcd”第二次赋值成”abcedl”,不是在原内存地址上修改数据,而是重新指向一个新对象,新地址。

3.2 String为什么是不可变的

再来看一下Java API中String类的定义:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    //……
}

首先String类是用final关键字修饰,这说明String不可继承,可以防止子类修改。其次,String类的主力成员字段value是个char[ ]数组,而且是用final修饰,我们都知道final成员变量在创建后就不可改变了。很多人到此就认为String类时不可变的了。但其实final只保证引用不可变,并不保证内容不可变。如下:

public class FinalTest {
    public static void main(String[] args) {
        final int[] a1 = {1, 2, 3};
        int[] a2 = {4, 5};
        //编译报错
        a1 = a2;
    }
}

a1数组因为final修饰不能修改其引用,但是可以修改其地址:

final int[] a1 = {1, 2, 3};
a1[1] = 4; //这时候a1数组时{1, 4, 3}

所以仅仅由final是保证不了String的不可变性的。String的不可变性,关键是因为SUN公司的工程师,在后面所有String的方法里很小心的没有去动内部char数组的元素,没有暴露内部成员字段。而且设计师还很小心地把整个String设成final禁止继承,避免被其他人继承后破坏。所以除了final,更多的是底层方法的设计。考验的是工程师构造数据类型,封装数据的功力。同样因为String的不可变性,基于这一特性,上节Stirng字符串常量池才可以实现。

参考链接:

  1. 我终于搞清楚了和String有关的那点事儿
  2. 在java中String类为什么要设计成final?
  3. 《Java编程的逻辑》
  4. 维基百科 — UTF-16

赞(0) 打赏
Zhuoli's Blog » Java编程拾遗『String类』
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

zhuoli's blog

联系我关于我

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏