Java中String为什么是不可变的?

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zxd1435513775/article/details/83514099

一、引言

在我们的日常开发中,肯定必用 java.lang 包下的 String类。我们每天都使用,以至于都忽略的String是怎么实现的。甚至有的人或许把它当做基本数据类型来使用,其实不然。下面本文从Java内存模型展开,结合 JDK 中 String类的源码进行深入分析,力求能够对String类的原理和使用,作一个全面和准确的介绍。

二、Java 内存模型 和 变量

1、Java的内存模型

对于Java的内存模型,在https://blog.csdn.net/zxd1435513775/article/details/80996034文中介绍过,此处就不在做过多介绍,可以参考上文。

2、常量和变量

注意:在理解常量和变量之前,我们要特别理解Java内存模型中的:栈和堆的概念。

(1)、变量:一般把内存地址不变,值可以改变的东西称为变量。换句话说,在内存地址不变的前提下,内存的内容是可变的,例如:
public class StringExample { 

    public static void example(){  
        Student s1 = new Student(1,11);  
        Student s2 = s1; 
        System.out.printfln("s1: " +  s1.toString());   
        System.out.printfln("s2: " + s2.toString());   

        s1.d = 3;  
        s1.age = 32;  
        System.out.printfln("s1: " +  s1.toString());   
        System.out.printfln("s2: " + s2.toString());   

        System.out.println( s1 == s2 );  // true : 引用值不变,即对象内存地址不变,但内容改变
    }
}  
(2)、常量:一般把内存地址不变,则值也不可以改变的东西称为常量。典型的 String 就是不可变的,所以称之为常量(constant)。此外,我们可以通过final关键字来定义常量,但严格来说,只有基本类型被其修饰后才是常量(对基本类型来说是其值不可变,而对于对象变量来说其引用不可再变,即在栈中的引用不能改变)。

三、String 定义

1、定义(JDK1.6中)

在这里插入图片描述

2、说明:
(1)、上面是 String 类中定义的前面几行,也是它的成员变量;

(2)、String 的底层实现是基于 char[] 数组来实现的;

(3)、由 JDK 中关于String的声明可以知道:

	不同字符串可能共享同一个底层char数组。例如字符串 String s="abc" 与 s.substring(1) 就共享同一个char数组:
	char[] c={‘a’,’b’,’c’}。其中,前者的offset 和 count 的值分别为0和3,后者的offset 和 count 的值分别为1和2;
	 	
	offset 和 count 两个成员变量不是多余的,比如,在执行substring操作时;

(4)、也就是说,如果在String第一次赋值后,当char[]数字已经创建完成,则String的长度是不可变的,这是在创建数组时,
	由数组决定的;

(5)、String 不属于八种基本数据类型,String 的实例是一个对象。因为对象的默认值是null,所以String的默认值也是null;
	但它又是一种特殊的对象,有其它对象没有的一些特性(String 的不可变性导致其像八种基本类型一样,
	比如,作为方法参数时,像基本类型的传值效果一样)。例如:
	
(6)、new String() 和 new String("")都是声明一个新的空字符串,是空串不是null;
	public class StringTest {

	    public static void changeStr(String str) {
	        String s = str;
	        str += "welcome";
	        System.out.println(s);
	    }
	
	    public static void main(String[] args) {
	        String str = "1234";
	        changeStr(str);
	        System.out.println(str);
	    }
	}

	/* Output: 
	        1234
	        1234 
	*///:~ 

四、String 的不可变性

1、什么是不可变对象?

在 Java 中,String 类是不可变类的典型代表 (基本类型的包装类都是不可改变的),也是Immutable设计模式的典型应用。String变量一旦初始化后就不能更改(指的是内容不能改变,由char[]数组决定,而char[]数组长度不可变,由于数组的性质决定),禁止改变对象的状态,从而增加共享对象的坚固性、减少对象访问的错误,同时还避免了在多线程共享时进行同步的需要。那么,到底什么是不可变的对象呢?

可以这样认为:如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的。不能改变状态指的是不能改变对象内的成员变量,包括:

(1)、基本数据类型的值不能改变;

(2)、引用类型的变量不能指向其他的对象;(Java内存模型中,栈中的值不能改变)

(3)、引用类型指向的对象的状态也不能改变;(Java内存模型中,堆中定义好的对象的值不能改变)

(4)、除了构造函数之外,不应该有其它任何函数(至少是任何public函数)修改任何成员变量;

(5)、任何使成员变量获得新值的函数都应该将新的值保存在新的对象中,而保持原来的对象不被修改。
2、区分引用和对象

对于Java初学者, 对于String是不可变对象总是存有疑惑。看下面代码:

	String s = "ABCabc";
	System.out.println("s = " + s);    // s = ABCabc
	
	s = "123456";
	System.out.println("s = " + s);    // s = 123456

上述代码中,首先会创建一个 String 对象 s (栈中),然后让 s 的值为“ABCabc”(堆内存中分配空间), 然后又让 s 的值为“123456”(堆内存)。 从打印结果可以看出,s 的值确实改变了。那么怎么还说String对象是不可变的呢? 其实这里存在一个误区:

s 只是一个 String 对象的引用,并不是对象本身。对象在内存中(指堆内存)是一块内存区,成员变量越多,这块内存区占的空间越大。引用只是一个 4 字节的数据(在栈中,代表指向堆内存的地址),里面存放了它所指向的对象的地址,通过这个地址可以访问对象。 也就是说,s 只是一个引用,它指向了一个具体的对象,当 s=“123456”; 这句代码执行过之后,又创建了一个新的对象“123456”, 而引用 s 重新指向了这个心的对象,原来的对象“ABCabc”还在内存中存在,并没有改变。内存结构如下图所示:
在这里插入图片描述

Java 和 C++ 的一个不同点是,在 Java 中,引用是访问、操纵对象的唯一方式: 我们不可能直接操作对象本身,所有的对象都由一个引用指向,必须通过这个引用才能访问对象本身,包括获取成员变量的值,改变对象的成员变量,调用对象的方法等。而在C++中存在引用,对象和指针三个东西,这三个东西都可以访问对象。其实,Java中的引用和C++中的指针在概念上是相似的,他们都是存放的对象在内存中的地址值,只是在Java中,引用丧失了部分灵活性,比如Java中的引用不能像C++中的指针那样进行加减运算。

注意:引用代表的是地址

3、为什么String对象是不可变的?

要理解 String 的不可变性,首先看一下String类中都有哪些成员变量。 在JDK1.6中,String 的成员变量有以下几个:

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

	    /** The value is used for character storage. */
	    private final char value[];
	
	    /** The offset is the first index of the storage that is used. */
	    private final int offset;
	
	    /** The count is the number of characters in the String. */
	    private final int count;
	
	    /** Cache the hash code for the string */
	    private int hash; // Default to 0</string>

在JDK1.7中,String 类做了一些改动,主要是改变了 substring() 方法执行时的行为,这和本文的主题不相关。JDK1.7中String类的主要成员变量就剩下了两个:

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

	    /** The value is used for character storage. */
	    private final char value[];
	
	    /** Cache the hash code for the string */
	    private int hash; // Default to 0</string>

由以上的代码可以看出, 在 Java 中,String 类其实就是对字符数组char []的封装。JDK6中, value 是String封装的数组,offset 是 String 在这个 value 数组中的起始位置,count 是 String 所占的字符的个数。在JDK7中,只有一个 value 变量,也就是 value 中的所有字符都是属于 String 这个对象的。这个改变不影响本文的讨论。 除此之外还有一个 hash 成员变量,是该 String 对象的哈希值的缓存,这个成员变量也和本文的讨论无关。在Java中,数组也是对象。 所以 value 也只是一个引用,它指向一个真正的数组对象。其实执行了String s = “ABCabc”; 这句代码之后,真正的内存布局应该是这样的:

在这里插入图片描述

value,offset 和 count 这三个变量都是 private 的,并且没有提供 setValue,setOffset 和 setCount等公共方法来修改这些值,所以在 String 类的外部无法修改 String。也就是说一旦初始化就不能修改, 并且在 String类 的外部不能访问这三个成员。此外,value,offset 和 count 这三个变量都是final的, 也就是说在 String 类内部,一旦这三个值初始化了, 也不能被改变。所以,可以认为 String 对象是不可变的了。

那么在 String 中,明明存在一些方法,调用他们可以得到改变后的值。这些方法包括substring(), replace(), replaceAll(), toLowerCase()等。例如如下代码:

	String a = "ABCabc";
	System.out.println("a = " + a);    // a = ABCabc
	
	a = a.replace('A', 'a');
	System.out.println("a = " + a);    //a = aBCabc

那么 a 的值看似改变了,其实也是同样的误区。再次说明, a 只是一个引用, 不是真正的字符串对象,在调用 a.replace(‘A’, ‘a’) 时, 方法内部创建了一个新的 String对象 ,并把这个心的对象重新赋给了引用 a 。String中replace方法的源码可以说明问题:

在这里插入图片描述

我们可以自己查看其他方法,都是在方法内部重新创建新的 String 对象,并且返回这个新的对象,原来的对象是不会被改变的。这也是为什么像replace(), substring(),toLowerCase() 等方法都存在返回值的原因。也是为什么像下面这样调用不会改变对象的值:

	String ss = "123456";
	System.out.println("ss = " + ss);     // ss = 123456
	
	ss.replace('1', '0');
	System.out.println("ss = " + ss);     //ss = 123456
4、String对象真的不可变吗?

从上文可知,String 的成员变量是 private final 的,也就是初始化之后不可改变。那么在这几个成员中, value 比较特殊,因为它是一个引用变量,而不是真正的对象。value 是 final 修饰的,也就是说 final 不能再指向其他数组对象(地址不能改变),那么我能改变 value 指向的数组中元素的值吗? 比如,将数组中的某个位置上的字符变为下划线“_”。 至少在我们自己写的普通代码中不能够做到,因为我们根本不能够访问到这个value引用,更不能通过这个引用去修改数组,那么,用什么方式可以访问私有成员呢? 没错,用反射,可以反射出String对象中的value属性, 进而改变通过获得的value引用改变数组的结构。下面是实例代码:

	public static void testReflection() throws Exception {

	    //创建字符串"Hello World", 并赋给引用s
	    String s = "Hello World"; 
	
	    System.out.println("s = " + s); //Hello World
	
	    //获取String类中的value字段
	    Field valueFieldOfString = String.class.getDeclaredField("value");
	
	    //改变value属性的访问权限
	    valueFieldOfString.setAccessible(true);
	
	    //获取s对象上的value属性的值
	    char[] value = (char[]) valueFieldOfString.get(s);
	
	    //改变value所引用的数组中的第5个字符
	    value[5] = '_';
	
	    System.out.println("s = " + s);  //Hello_World
	}

在这个过程中,s 始终引用的同一个 String 对象,但是在反射前后,这个 String 对象发生了变化, 也就是说,通过反射是可以修改所谓的“不可变”对象的。但是一般我们不这么做。这个反射的实例还可以说明一个问题:如果一个对象,它组合的其他对象的状态是可以改变的,那么这个对象很可能不是不可变对象。例如一个Car对象,它组合了一个Wheel对象,虽然这个Wheel对象声明成了private final 的,但是这个Wheel对象内部的状态可以改变, 那么就不能很好的保证Car对象不可变。

五、String 对象创建方式

1、字面值形式:
JVM虚拟机会自动根据字符串常量池中字符串的实际情况来决定是否创建新对象 (要么不创建,要么创建一个对象,关键要看常量池中有没有)

JDK 中明确指出:

String s = "abc";

等价于:

char data[] = {'a', 'b', 'c'};
String str = new String(data);

该种方式先在栈中创建一个对String类的对象引用变量s,然后去查找 “abc”是否被保存在字符串常量池中。若 ”abc” 已经被保存在字符串常量池中,则在字符串常量池中找到值为”abc”的对象,然后将 s 指向这个对象; 否则,在堆中创建char数组 data,然后在堆中创建一个 String 对象 object,它由 data 数组支持,紧接着这个String对象 object 被存放进字符串常量池,最后将 s 指向这个对象。例如:

    private static void test01(){  
	    String s0 = "kvill";        // 1
	    String s1 = "kvill";        // 2
	    String s2 = "kv" + "ill";     // 3
	
	    System.out.println(s0 == s1);       // true  
	    System.out.println(s0 == s2);       // true  
	}

执行第 1 行代码时,“kvill” 入常量池并被 s0 指向;执行第 2 行代码时,s1 从常量池查询到” kvill” 对象并直接指向它;所以,s0 和 s1 指向同一对象。 由于 ”kv” 和 ”ill” 都是字符串字面值,所以 s2 在编译期由编译器直接解析为 “kvill”,所以 s2 也是常量池中”kvill”的一个引用。 所以,我们得出 s0 == s1 == s2;

2、通过 new 创建字符串对象 :

一律在堆中创建新对象,无论字符串字面值是否相等 (要么创建一个,要么创建两个对象,关键要看常量池中有没有)

String s = new String("abc");  

等价于:

String original = "abc"; 
String s = new String(original);

所以,通过 new 操作产生一个字符串(“abc”)时,会先去常量池中查找是否有“abc”对象,如果没有,则创建一个此字符串对象并放入常量池中。然后,在堆中再创建“abc”对象,并返回该对象的地址。所以,对于 String str = new String(“abc”):如果常量池中原来没有”abc”,则会产生两个对象(一个在常量池中,一个在堆中);否则,产生一个对象。
 
用 new String() 创建的字符串对象位于堆中,而不是常量池中。它们有自己独立的地址空间,例如,

    private static void test02(){  
	    String s0 = "kvill";  
	    String s1 = new String("kvill");  
	    String s2 = "kv" + new String("ill");  
	
	    String s = "ill";
	    String s3 = "kv" + s;    
	    
	    System.out.println(s0 == s1);       // false  
	    System.out.println(s0 == s2);       // false  
	    System.out.println(s1 == s2);       // false  
	    System.out.println(s0 == s3);       // false  
	    System.out.println(s1 == s3);       // false  
	    System.out.println(s2 == s3);       // false  
	}  

例子中,s0 还是常量池中”kvill”的引用,s1 指向运行时创建的新对象”kvill”,二者指向不同的对象。对于s2,因为后半部分是 new String(“ill”),所以无法在编译期确定,在运行期会 new 一个 StringBuilder 对象, 并由 StringBuilder 的 append 方法连接并调用其 toString 方法返回一个新的 “kvill” 对象。此外,s3 的情形与 s2 一样,均含有编译期无法确定的元素。因此,以上四个 “kvill” 对象互不相同。StringBuilder 的 toString 为:

	public String toString() {
    	return new String(value, 0, count);   // new 的方式创建字符串
    }

构造函数 String(String original) 的源码为:

	/**
     * 根据源字符串的底层数组长度与该字符串本身长度是否相等决定是否共用支撑数组
     */
    public String(String original) {
        int size = original.count;
        char[] originalValue = original.value;
        char[] v;
        if (originalValue.length > size) {
            // The array representing the String is bigger than the new
            // String itself. Perhaps this constructor is being called
            // in order to trim the baggage, so make a copy of the array.
            int off = original.offset;
            v = Arrays.copyOfRange(originalValue, off, off + size);  // 创建新数组并赋给 v
        } else {
            // The array representing the String is the same
            // size as the String, so no point in making a copy.
            v = originalValue;
        }

        this.offset = 0;
        this.count = size;
        this.value = v;
    }

猜你喜欢

转载自blog.csdn.net/zxd1435513775/article/details/83514099