Java中的String是不可变的,Why?

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

什么是不可变对象?

众所周知, 在Java中, String类对象是不可变的,那么,到底什么是不可变的对象呢?

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

不能改变状态的意思是:不能改变对象内的成员变量,包括基本数据类型的值不能改变,引用类型的变量不能指向其他的对象,引用类型指向的对象的状态也不能改变

区分对象和对象的引用

对于Java初学者来说, 对于String是不可变对象,可能或多或少会存有一些疑惑。看下面代码:

String str = "ABCabcABC";
System.out.println("str = " + str);
 
str = "123456789";
System.out.println("str = " + str);

打印结果为:

s = ABCabcABC
s = 123456789

上面代码分析:
首先,在对象池中创建了一个String对象str,然后让str的值为“ABCabcABC”, 然后又让str的值修改为“123456789”。 从打印结果可以看出,str的值确实改变了。那么怎么还说String对象是不可变的呢? 其实这里存在一个误区: str只是String对象的一个引用,并不是对象本身。对象在内存中是一块内存区,成员变量越多,这块内存区占的空间越大。引用只是一个4字节的数据,里面存放了它所指向的对象的地址,通过这个地址可以访问对象。

也就是说,str只是一个引用,它指向了一个具体的对象,当str=“123456789”; 这句代码执行过之后,又在对象池中创建了一个新的对象“123456789”, 而引用str重新指向了这个新的对象,原来的对象“ABCabcABC”还在内存中存在,并没有改变,所以说,String类对象是不可变的

内存结构如下图所示:
在这里插入图片描述

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

为什么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
    
}

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

由以上的代码可以看出, 在Java中String类其实就是对字符数组的封装。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中,明明存在一些方法,调用他们可以得到改变后的值,这又是为什么呢?
比如说String类中的substring()replace()replaceAll()toLowerCase()等方法。

例如:

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

打印结果为:

a = ABCabc
a = aBCabc

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

String中replace方法的源码可以很好的说明这个问题:

public String replace(char oldChar, char newChar) {
    if (oldChar != newChar) {
        int len = value.length;
        int i = -1;
        char[] val = value; /* avoid getfield opcode */

        while (++i < len) {
            if (val[i] == oldChar) {
                break;
            }
        }
        if (i < len) {
            char buf[] = new char[len];
            for (int j = 0; j < i; j++) {
                buf[j] = val[j];
            }
            while (i < len) {
                char c = val[i];
                buf[i] = (c == oldChar) ? newChar : c;
                i++;
            }
            return new String(buf, true);
        }
    }
    return this;
}

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

比如说substring方法:

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

总而言之,最终都是在对象池中,创建了新的对象,并返回了新的对象,所以说,原来的对象并没有发生变化

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 = Hello World
s = Hello_World

在这个过程中,s始终引用的同一个String对象,但是再反射前后,这个String对象发生了变化, 也就是说,通过反射是可以修改所谓的“不可变”对象的。但是一般我们不这么做。

这个反射的实例还可以说明一个问题:如果一个对象,他组合的其他对象的状态是可以改变的,那么这个对象很可能不是不可变对象。例如一个Car对象,它组合了一个Wheel对象,虽然这个Wheel对象声明成了private final 的,但是这个Wheel对象内部的状态可以改变, 那么就不能很好的保证Car对象不可变。

Java中String为什么要设计成不可变的

在Java中String类是不可变的,简单来说,一个不可变的类就意味着他的实例是不可修改的,实例的所有信息都是在实例创建的时候被初始化并且不可被修改,不可变类的设计有很多优点。下面主要从内存同步数据结构的角度来具体说明这种不可变的概念。

  • 字符串常量池(String pool)的需要

    字符串常量池(String pool, String intern pool, String保留池) 是Java方法区,堆内存中一个特殊的存储区域。 当创建一个String对象时,假如此字符串值已经存在于常量池中,则不会再创建一个新的对象,而是引用已经存在的对象。
    以下代码只会创建一个String对象在堆内存中:

    String string1 = "abcd";
    String string2 = "abcd";
    

    下图是创建的过程:
    在这里插入图片描述
    假若字符串对象允许改变,那么将会导致各种逻辑错误,比如改变一个对象会影响到另一个独立对象。 严格来说,这种常量池的思想,是一种优化手段。

    请思考: 假若代码如下所示,s1和s2还会指向同一个实际的String对象吗?

    String s1= "ab" + "cd";
    String s2= "abc" + "d";
    

    也许这个问题违反新手的直觉, 但是考虑到现代编译器会进行常规的优化, 所以他们都会指向常量池中的同一个对象。或者,你可以用 jd-gui 之类的工具查看一下编译后的class文件。当然,强大的idea中,也可以直接打开编译后的.class文件。

  • 允许String对象缓存HashCode

    Java中String对象的哈希码被频繁地使用, 比如在hashMap 等容器中。

    在Java中,对于String的Hashcode使用是非常频繁的,例如在HashMap或HashSet中。将String设计成不可变可以保证他的Hashcode始终一致,这样Hashcode就可以被缓存并且不用担心变化。这就意味着,不需要在每次使用String的时候都去计算他的Hashcode(哈希码),字符串不变性保证了hash码的唯一性,因此可以放心地进行缓存。这也是一种性能优化手段,这也使得程序运行的更加高效。

    在String类中,关于Hashcode的代码如下:

    private int 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";
    }
    

    在这个例子中,如果String是可变的,那么就会违背set的设计初衷(set包含不重复的元素)。当然,上面的例子只是为了论证,实际上String类中没有value这个字段。

  • 安全性

    String在很多Java类中被广泛用作参数,例如网络连接,文件打开等。假设String是可变的,一个连接或者一个文件就可能被改变,这会导致严重的安全隐患。某个方法以为正在连接到一个机器,实际并没有。可变的String还可能在反射的时候引发安全问题,因为反射的参数类型也是String。

    boolean connect(string s){
        if (!isSecure(s)) {
    throw new SecurityException();
    }
        //here will cause problem, if s is changed before this by using other references.   
        causeProblem(s);
    }
    

    不可变对象天生就是线程安全的,因为不可变对象不能被改变,他们可以在多线程中被自由的共享,这就消除了对象同步的需求,不需要作任何同步处理。

    总之,String被设计成不可变的主要目的是为了安全和高效。所以,使String是一个不可变类是一个很好的设计,这也是不可变类在很多情况下被优先使用的原因。

总体来说, String不可变的原因包括设计考虑效率优化问题,以及安全性这三大方面,事实上,这也是Java面试中的许多 “为什么” 的答案。

参考原文链接:
https://www.cnblogs.com/lixuwu/p/10817588.html
https://blog.csdn.net/qq_37267015/article/details/78738512
https://blog.51cto.com/luischen/2424906

写博客是为了记住自己容易忘记的东西,另外也是对自己工作的总结,希望尽自己的努力,做到更好,大家一起努力进步!

如果有什么问题,欢迎大家一起探讨,代码如有问题,欢迎各位大神指正!

给自己的梦想添加一双翅膀,让它可以在天空中自由自在的飞翔!

猜你喜欢

转载自blog.csdn.net/smilehappiness/article/details/105895241