源码阅读计划 String

源码

1 定义

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

1.可以看到是final类型的

2.实现了ava.io.Serializable(支持序列化与反序列化)、 Comparable<String>(排序)、 CharSequence接口

2 属性

//final类型说明String初始化后是不可修改的 底层是由Char数组实现的.
private final char value[];

//String还默认缓存了字符串的hash值 个人理解是用于快速比较 默认为0
private int hash; // Default to 0

//这两行代码用于序列化
private static final long serialVersionUID = -6849794470754667710L;    
private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];

1.不可修改  2.由char[]储存  3.默认存储了hash

p.s.因为String实现了Serializable接口,所以支持序列化和反序列化支持。Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常(InvalidCastException)。

3 构造方法

String的构造方法有16种之多,这里就写一个有意思的.

一个特殊的保护类型的构造方法

String除了提供了很多公有的供程序员使用的构造方法以外,还提供了一个保护类型的构造方法(Java 7),我们看一下他是怎么样的:

String(char[] value, boolean share) {
    // assert share : "unshared not supported";
    this.value = value;
}

从代码中我们可以看出,该方法和 String(char[] value)有两点区别,第一个,该方法多了一个参数: boolean share,其实这个参数在方法体中根本没被使用,也给了注释,目前不支持使用false,只使用true。那么可以断定,加入这个share的只是为了区分于String(char[] value)方法,不加这个参数就没办法定义这个函数,只有参数不能才能进行重载。那么,第二个区别就是具体的方法实现不同。我们前面提到过,String(char[] value)方法在创建String的时候会用到 会用到Arrays的copyOf方法将value中的内容逐一复制到String当中,而这个String(char[] value, boolean share)方法则是直接将value的引用赋值给String的value。那么也就是说,这个方法构造出来的String和参数传过来的char[] value共享同一个数组。并没有重新占用内存拷贝而是一个视图. 那么,为什么Java会提供这样一个方法呢? 首先,我们分析一下使用该构造函数的好处:

首先,性能好,这个很简单,一个是直接给数组赋值(相当于直接将String的value的指针指向char[]数组),一个是逐一拷贝。当然是直接赋值快了。

其次,共享内部数组节约内存

但是,该方法之所以设置为protected,是因为一旦该方法设置为公有,在外面可以访问的话,那就破坏了字符串的不可变性。例如如下YY情形:

扫描二维码关注公众号,回复: 2741489 查看本文章
char[] arr = new char[] {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'};
String s = new String(0, arr.length, arr); // "hello world"
arr[0] = 'a'; // replace the first character with 'a'
System.out.println(s); // aello world

如果构造方法没有对arr进行拷贝,那么其他人就可以在字符串外部修改该数组,由于它们引用的是同一个数组,因此对arr的修改就相当于修改了字符串。

所以,从安全性角度考虑,他也是安全的。对于调用他的方法来说,由于无论是原字符串还是新字符串,其value数组本身都是String对象的私有属性,从外部是无法访问的,因此对两个字符串来说都很安全。

string-substring-jdk6

上图是jdk6中的实现方式,源码如下:

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

public String substring(int beginIndex, int endIndex) {
    //check boundary
    return  new String(offset + beginIndex, endIndex - beginIndex, value);
}

在Java 7 之有很多String里面的方法都使用这种“性能好的、节约内存的、安全”的构造函数。比如:substringreplaceconcatvalueOf等方法(实际上他们使用的是public String(char[], int, int)方法,原理和本方法相同,已经被本方法取代)。

但是在Java 7中,substring已经不再使用这种“优秀”的方法了,为什么呢? 虽然这种方法有很多优点,但是他有一个致命的缺点,对于sun公司的程序员来说是一个零容忍的bug,那就是他很有可能造成内存泄露。 看一个例子,假设一个方法从某个地方(文件、数据库或网络)取得了一个很长的字符串,然后对其进行解析并提取其中的一小段内容,这种情况经常发生在网页抓取或进行日志分析的时候。下面是示例代码。

String aLongString = "...a very long string..."; 
String aPart = data.substring(20, 40);
return aPart;

在这里aLongString只是临时的,真正有用的是aPart,其长度只有20个字符,但是它的内部数组却是从aLongString那里共享的,因此虽然aLongString本身可以被回收,但它的内部数组却不能(如下图)。这就导致了内存泄漏。如果一个程序中这种情况经常发生有可能会导致严重的后果,如内存溢出,或性能下降。

2aqQFnf

新的实现虽然损失了性能,而且浪费了一些存储空间,但却保证了字符串的内部数组可以和字符串对象一起被回收,从而防止发生内存泄漏,因此新的substring比原来的更健壮。

string-substring-jdk7

上图是JDK7中的实现方式,源码如下:

public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

虽然substring方法已经为了其鲁棒性放弃使用这种share数组的方法,但是这种share数组的方法还是有一些其他方法在使用的,这是为什么呢?首先呢,这种方式构造对应有很多好处,其次呢,其他的方法不会将数组长度变短,也就不会有前面说的那种内存泄露的情况(内存泄露是指不用的内存没有办法被释放,比如说concat方法和replace方法,他们不会导致元数组中有大量空间不被使用,因为他们一个是拼接字符串,一个是替换字符串内容,不会将字符数组的长度变得很短!)

4 其他方法

length() 返回字符串长度

isEmpty() 返回字符串是否为空

charAt(int index) 返回字符串中第(index+1)个字符

char[] toCharArray() 转化成字符数组

trim() 去掉两端空格

toUpperCase() 转化为大写

toLowerCase() 转化为小写

String concat(String str) //拼接字符串

String replace(char oldChar, char newChar) //将字符串中的oldChar字符换成newChar字符

//以上两个方法都使用了String(char[] value, boolean share);

boolean matches(String regex) //判断字符串是否匹配给定的regex正则表达式

boolean contains(CharSequence s) //判断字符串是否包含字符序列s

String[] split(String regex, int limit) 按照字符regex将字符串分成limit份。

String[] split(String regex)

进一步

对于源码大致了解了 那使用过程中还有很多知识需要去了解

1 常量池

String s1 = "Str";
String s2 = new String("Str");
String s3 = new String("Str").intern();

System.out.println(s1 == s2); //False
System.out.println(s1 == s3); //True

不是说每当我们使用new创建字符串的时候,都会到字符串池检查(查到就直接发返回,未查到就创建并返回),然后返回吗,那应该都是true才对啊?

首先需要了解几个知识:

符号引用: s1 s2 s3 引用了字面量

字面量: "Str"

在编译期 符号引用s1会和字面量Str一起加入Class常量池中 然后在类加载阶段会一起进入JVM的常量池.而在进入JVM常量池的时候 并不会直接把所有类中定义的常量全部都加载进来,而是会做个比较,如果需要加到字符串常量池中的字符串已经存在,那么就不需要再把字符串字面量加载进来了 

所以 "若常量池中已经存在”Str”,则直接引用,也就是此时只会创建一个对象" 说的就是这个字符串字面量在字符串池中被创建的过程。

而在运行期,new String("Str")执行到的时候,是要在Java堆中创建一个字符串对象的,而这个对象所对应的字符串字面量是保存在字符串常量池中的。但是,String s = new String("Str")对象的符号引用s是保存在Java虚拟机栈上的,他保存的是堆中刚刚创建出来的的字符串对象的引用。

其实很简单我们比较的s1和s2 并不是那个没有重新创建 在常量池中相同的字面量 而是在堆中创建出来的新地址 s1 s2 是两个不同的对象 肯定不相等了啊

到这里就不得不提起那个金典的面试题了,下面这行代码创建了几个对象.

String s = new String("Str");

答案很简单就是如果常量池中已经有了Str 就创建一个对象 如果没有就是两个

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

那intern()呢?

编译期生成的各种字面量符号引用是运行时常量池中比较重要的一部分来源,但是并不是全部。那么还有一种情况,可以在运行期像运行时常量池中增加常量。那就是Stringintern方法。

当一个String实例调用intern()方法时,Java查找常量池中是否有相同Unicode的字符串常量,如果有,则返回其的引用,如果没有,则在常量池中增加一个Unicode等于str的字符串并返回它的引用;

盗图一张...下图就很清晰的说明了intern()的原理:

intern

对于String s3 = new String("Hollis").intern(),在不调用intern情况,s3指向的是JVM在堆中创建的那个对象的引用的(如图中的s2)。但是当执行了intern方法时,s3将指向字符串常量池中的那个字符串常量。

由于s1和s3都是字符串常量池中的字面量的引用,所以s1==s3。但是,s2的引用是堆中的对象,所以s2!=s1。

而intern()的最主要的意义是在运行期将新创建(如拼接)的字符串加入常量池中,这样对于再次调用此字符串的情况就可以结束字符串的重复创建.

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显示的将其加入常量池,这样可以减少很多字符串的重复创建。

*进一步理解

加号拼接

String s1 = "1" + "23";
String s2 = "123";
String x = new StringBuffer().append("1").append("23").toString();
System.out.println(s1 == s2); //ture
System.out.println(x == s2); //false

并不是我想象中的对于 + 拼接的处理是编程append的形式.

而是JVM编译器对字符串做了优化,在编译时s1就已经被优化成“123”,s1和s2指向字符串常量池同一个字符串常量(字面量),所以==比较为true。

intern()

String s1 = new String("1") + new String("23");
String s2 = "123";
System.out.println(s1 == s2); //false

这里返回false没毛病 而加上intern()试一下 

String s1 = new String("1") + new String("23");
String x = s1.intern();
String s2 = "123";

System.out.println(s1 == s2); //true
System.out.println(x == s2); //true

这里 s1 == s2 返回的是ture,为什么呢?

x == s2 是因为intern方法会判断如果常量池中没有123就将其加入并返回常量池中的地址.x == s2 没毛病

但 s1 == s2 个人认为是s1.intern 发现常量池没有123这个常量对象 就把 s1放进了常量池 , 然后s2 ="123" 发现123已经存在常量池了 就直接给常量池的123引用给 s2了

那让我们在看一个示例:

String s2 = "123";
String s1 = new String("1") + new String("23");
String x = s1.intern();

System.out.println(s1 == s2); //false
System.out.println(x == s2); //true

这里我们先创建了s2 在编译期就将其加入了常量池, 所以在s1.intern的时候发现常量池中有值就直接将其返回并没有把s1加入常量池

所以s1不等于s2

2 不可变性

如果字符串可变的话,当两个引用指向指向同一个字符串时,对其中一个做修改就会影响另外一个。

特别要注意的是,String类的所有方法都没有改变字符串本身的值,都是返回了一个新的对象。

缓存Hashcode

Java中经常会用到字符串的哈希码(hashcode)。例如,在HashMap中,字符串的不可变能保证其hashcode永远保持一致,这样就可以避免一些不必要的麻烦。这也就意味着每次在使用一个字符串的hashcode的时候不用重新计算一次,这样更加高效。

在String类中,有以下代码:

private int hash;//this is used to cache hash code.

以上代码中hash变量中就保存了一个String对象的hashcode,因为String类不可变,所以一旦对象被创建,该hash值也无法改变。所以,每次想要使用该对象的hashcode的时候,直接返回即可。

使其他类的使用更加便利

在介绍这个内容之前,先看以下代码:

HashSet<String> set = new HashSet<String>();
set.add(new String("a"));
set.add(new String("b"));
set.add(new String("c"));

for(String a: set)
    a.value = "a";

在上面的例子中,如果字符串可以被改变,那么以上用法将有可能违反Set的设计原则,因为Set要求其中的元素不可以重复。上面的代码只是为了简单说明该问题,其实String类中并没有value这个字段值。

安全性

String被广泛的使用在其他Java类中充当参数。比如网络连接、打开文件等操作。如果字符串可变,那么类似操作可能导致安全问题。因为某个方法在调用连接操作的时候,他认为会连接到某台机器,但是实际上并没有(其他引用同一String对象的值修改会导致该连接中的字符串内容被修改)。可变的字符串也可能导致反射的安全问题,因为他的参数也是字符串。

代码示例:

boolean connect(string s){
    if (!isSecure(s)) { 
throw new SecurityException(); 
}
    //如果s在该操作之前被其他的引用所改变,那么就可能导致问题。   
    causeProblem(s);
}

不可变对象天生就是线程安全的

因为不可变对象不能被改变,所以他们可以自由地在多个线程之间共享。不需要任何同步处理。

3 equals() & hashcode()

所有Java类的父类——java.lang.Object中定义了两个重要的方法:

public boolean equals(Object obj)
public int hashCode()

首先会看一个错误使用这两个方法的例子,然后再解释equals和hashcode是如何协同工作的。

一个常犯的错误

先看以下代码:

import java.util.HashMap;

public class Apple {
    private String color;

    public Apple(String color) {
        this.color = color;
    }

    public boolean equals(Object obj) {
        if(obj==null) return false;
        if (!(obj instanceof Apple))
            return false;   
        if (obj == this)
            return true;
        return this.color.equals(((Apple) obj).color);
    }

    public static void main(String[] args) {
        Apple a1 = new Apple("green");
        Apple a2 = new Apple("red");

        //hashMap stores apple type and its quantity
        HashMap<Apple, Integer> m = new HashMap<Apple, Integer>();
        m.put(a1, 10);
        m.put(a2, 20);
        System.out.println(m.get(new Apple("green")));
    }
}

上面的代码执行过程中,先是创建个两个Apple,一个green apple和一个red apple,然后将这来两个apple存储在map中,存储之后再试图通过map的get方法获取到其中green apple的实例。读者可以试着执行以上代码,数据结果为null。也就是说刚刚通过put方法放到map中的green apple并没有通过get方法获取到。你可能怀疑是不是green apple并没有被成功的保存到map中,但是,通过debug工具可以看到,它已经被保存成功了。

hashcode()惹的祸

造成以上问题的原因其实比较简单,是因为代码中并没有重写hashcode方法。hashcodeequals的约定关系如下:

1、如果两个对象相等,那么他们一定有相同的哈希值(hash code)。

2、如果两个对象的哈希值相等,那么这两个对象有可能相等也有可能不相等。(需要再通过equals来判断)

如果你了解Map的工作原理,那么你一定知道,它是通过把key值进行hash来定位对象的,这样可以提供比线性存储更好的性能。实际上,Map的底层数据结构就是一个数组的数组(准确的说其实是一个链表+数组)。第一个数组的索引值是key的哈希码。通过这个索引可以定位到第二个数组,第二个数组通过使用equals方法进行线性搜索的方式来查找对象。

image

其实,一个哈希码可以映射到一个桶(bucket)中,hashcode的作用就是先确定对象是属于哪个桶的。如果多个对象有相同的哈希值,那么他们可以放在同一个桶中。如果有不同的哈希值,则需要放在不同的桶中。至于同一个桶中的各个对象之前如何区分就需要使用equals方法了。

hashcode方法的默认实现会为每个对象返回一个不同的int类型的值。所以,上面的代码中,第二个apple被创建出来时他将具有不同的哈希值。可以通过重写hashCode方法来解决。

public int hashCode(){
    return this.color.hashCode();   
}

在判断两个对象是否相等时,不要只使用equals方法判断。还要考虑其哈希码是否相等。尤其是和hashMap等与hash相关的数据结构一起使用时。

猜你喜欢

转载自blog.csdn.net/qq_30054997/article/details/81607537