当前位置 博文首页 > String小四的博客:java源码分析-String类不可变性讨论

    String小四的博客:java源码分析-String类不可变性讨论

    作者:[db:作者] 时间:2021-07-19 13:30

    java源码分析-String类不可变性讨论

    在很多面试过程中,有一个经常被问到的问题,请你谈一谈String对象为什么说是不可变的?

    也许你会说,因为它被final修饰了,所以不可变。如果你这样回答,那么只能说,还是太年轻了。今天我们就好好讨论一下String对象的不可变性。

    1.不可变对象

    ? 要聊String对象的不可变性,我们先要搞明白什么是不可变对象?不可变对象,顾名思义,对象在创建之后对象就不能在改变了。

    对象的状态

    ? 对象的状态指的是存储在状态变量中的数据(状态变量包括实例或者静态域),还包括这个对象依赖的对象的域。

    举例说明:

    public class ObjectState {
    
        private final int num;
    
        private final Map<String,Object> map;
    
        public ObjectState(int num, Map<String, Object> map) {
            this.num = num;
            this.map = map;
        }
    
    }
    

    ? 上面的例子我们知道num是实例变量,也就是ObjectState的状态变量,HashMap集合对象也是。但是我们还要知道其实HashMap的状态不仅存储在HashMap本身,还存储在许多的Map .Entry对象中。例如下面的类ObjectState, 它的状态是由num和map的状态共同构成的,而map中又会包含很多的Map.Entry,这些Map.Entry对象的状态也属于ObjectState对象状态的一部分。

    final的作用

    ? 知道了对象的状态之后,我们再看一下final作用。

    1)final修饰类的时候,表示该类不能被继承;

    2)final修饰方法的时候,表示该方法不能被重写;

    3)final修饰变量时,表示该变量不能被修改;

    前面两个好理解,但是第三个的说法是不是与上面讲的map的例子有冲突了,map不也被final修饰了吗?应该不可变啊。其实不冲突!上面我们也提到了map本身没有改变,但是map集合中的entiry对象却并不受控制。本质上就是final修饰基本类型和引用类型的区别。

    基本类型不多解释,final修饰之后,一旦赋值就不能再被改变了。而对于引用类型,final修饰他们的时候,只能保证引用本身没有变化,而对于引用所指向的对象内部的变化是不能够限制的。

    我们再举个例子理解一下:

    public class ObjectState2 {
    
        private final int num;
    
        private final String[] arr;
    
        public ObjectState2(int num, String[] arr) {
            this.num = num;
            this.arr = arr;
        }
    
        public void modify(){
            this.arr[0] = "2";
        }
    }
    

    可以看到尽管arr数组被final修饰,按理来说,一旦通过构造函数创建arr就不在改变,但是我们依旧可以通过modify方法对arr数组中的元素进行修改。这样也就破坏了对象的不可变性了。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-viiNRoPs-1614417022625)(C:\Users\viruser.v-desktop\AppData\Roaming\Typora\typora-user-images\image-20210225203026684.png)]

    所以,对于引用类型,需要保证该引用类型的状态也是不可变的,也就是说要保证引用类型所依赖的对象域也是不可改变的。

    2.String类不可变型分析

    要理解String类的不可变性,首先看一下String类中都有哪些成员变量。在JDK1.8中,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
    
        /** use serialVersionUID from JDK 1.0.2 for interoperability */
        private static final long serialVersionUID = -6849794470754667710L;    
        /**
         * Class String is special cased within the Serialization Stream Protocol.
         *
         * A String instance is written into an ObjectOutputStream according to
         * <a href="{@docRoot}/../platform/serialization/spec/output.html">
         * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
         */
        private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0];
    

    首先可以看到,String类使用了final修饰符,表明String类是不可继承的。
    然后,我们主要关注String类的成员变量value,value是char[]类型,因为String对象实际上是用这个字符数组进行封装的。再看value的修饰符,使用了private,也没有提供setter方法,所以在String类的外部不能修改value,同时value也使用了final进行修饰,那么在String类的内部也不能修改value,但是上面final修饰引用类型变量的内容提到,这只能保证value不能指向其他的对象,但value指向的对象的状态是可以改变的。通过查看String类源码可以发现,String类不可变,关键是因为SUN公司的工程师,在后面所有String的方法里都很小心的没有去动字符数组里的元素。所以String类不可变的关键都在底层的实现,而不仅仅是一个final。

    值得注意的是,hash没有用final修饰呢?我们来看hash的计算源码:

    /**
         * Returns a hash code for this string. The hash code for a
         * {@code String} object is computed as
         * <blockquote><pre>
         * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
         * </pre></blockquote>
         * using {@code int} arithmetic, where {@code s[i]} is the
         * <i>i</i>th character of the string, {@code n} is the length of
         * the string, and {@code ^} indicates exponentiation.
         * (The hash value of the empty string is zero.)
         *
         * @return  a hash code value for this object.
         */
    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,在调用hashCode才会真正计算hash值。而这个值的计算公式我们不难理解,对于一个长度为n的字符串s,hash=s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1],很明显,这个值每次计算都是一个固定值。

    3.String真就不可变吗

    ? String真就不可变吗?当我们思考这个的时候,可能已经想到了java有一个很强大的特性:反射。是的。虽然不能直接修改String对象的内容,但是,我们依然可以通过反射来进行一些骚操作,从而打破String对象的不可变性!

    3.1通过反射

    ? 下面我们通过反射来修改String对象的内容:

    /**
     * 通过反射打破String的不可变性
     */
    public class StringBreakImmutability {
    
        public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
            String str1 = new String("ABC");
            System.out.println("反射前:"+str1);
            //获取Class对象
            Class clazz = str1.getClass();
            //获取字段Field对象信息
            Field value = clazz.getDeclaredField("value");
            value.setAccessible(true);
            //获取值
            char[] ch = (char[]) value.get(str1);
            ch[0] = 'B';
            System.out.println("反射后:"+str1);
        }
    }
    

    运行结果:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rHaOgjYf-1614417022628)(C:\Users\viruser.v-desktop\AppData\Roaming\Typora\typora-user-images\image-20210227151415753.png)]

    很明显,通过java的反射机制,我们成功打破String对象的不可变性,修改了其内容。

    3.2通过JNI

    ? 其实还有其他方法能够改变String对象的内容。JNI(java native Interface)就是另一种可以打破String不可变性的方法。JNI是一种java通过调用c/c++的方式来完成相应的与操作系统相关的底层动作的技术。也就是说我们通过java可以调用c/c++方法,而c/c++是偏底层的语言,可以做一些java本身无法做到的事情,那么修改String对象内容也不在话下了。

    下面我们就来一步一步实现一下(测试基于Linux环境下):

    (1)写一个java类,调用native方法;

    public class JNIDemo {
    	private String str = new String("hello java");
        {
            //系统加载其他语言的函数
            System.load("/home/sj/test/jni_string.so");
        }
    
        //native标识本地方法
        public native void stringJNI();
    
        public static void main(String[] args) {
           	JNIDemo demo = new JNIDemo();
            System.out.println("before jni, str:"+demo.str);   
            demo.stringJNI();
           	System.out.println("after jni, str:"+demo.str);   
        }
    }
    

    将java文件上传至linux系统/home/sj/test文件夹下。

    (2)通过javac命令编译该文件生成字节码文件;

    /usr/local/jdk1.8/bin/javac JNIDemo.java
    

    这样该路径下就会生成.class字节码文件JNIDemo.class。

    (3)通过javah命令获取头文件;

    /usr/local/jdk1.8/bin/javah JNIDemo
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-91amNn0H-1614417022630)(C:\Users\viruser.v-desktop\AppData\Roaming\Typora\typora-user-images\image-20210227164609425.png)]

    打开这个头文件,我们看看里面有些什么?

    vim JNIDemo.h
    
    /* DO NOT EDIT THIS FILE - it is machine generated */
    #include <jni.h>
    /* Header for class JNIDemo */
    
    #ifndef _Included_JNIDemo
    #define _Included_JNIDemo
    #ifdef __cplusplus
    extern "C" {
    #endif
    /*
     * Class:     JNIDemo
     * Method:    stringJNI
     * Signature: ()V
     */
    JNIEXPORT void JNICALL Java_JNIDemo_stringJNI
      (JNIEnv *, jobject);
    
    #ifdef __cplusplus
    }
    #endif
    #endif
    

    JNIEXPORT void JNICALL Java_JNIDemo_stringJNI 这句需要我们关注,因为后面的c语言实现的方法名称必须“Java_JNIDemo_helloJNI”一致。

    (4)用c写一个native方法;

    vim jni_string.c
    
    #include <jni.h>
    #include "JNIDemo.h"
    #include <stdio.h>
    
    JNIEXPORT void JNICALL Java_JNIDemo_stringJNI(JNIEnv *env,jobject obj){
    	//得到java类名
        jclass java_class = (*env)->GetObjectClass(env,obj);
    	//获取strin类型,注意string的类型签名有个分号;
        jfieldID id_str = (*env)->GetFieldID(env,java_class,"str","Ljava/lang/String;");
    	//修改java的string成员变量值
        char* c_ch = "hello c";
    	//字符数组c_ch转换成jstring类型   
        jstring cstr = (*env)->NewStringUTF(env,c_ch);
    	//设置java的string类型变量s的值
        (*env)->SetObjectField(env,obj,id_str,cstr);
    }
    

    (5)使用cjni.c生成动态链接库文件:cJNI.so

    gcc  -fPIC -I /usr/local/jdk1.8/include  -I /usr/local/jdk1.8/include/linux   -shared -o jni_string.so jni_string.c
    

    /usr/local/jdk1.8 是linux系统安装jdk源码路径;

    注意生成的动态链接库文件名称cJNI.so要与一开始的java代码中System.load("/home/sj/jni/test/jni_string.so");对应。

    这样该路径下就有如下的五个文件了:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YFk4CEMu-1614417022632)(C:\Users\viruser.v-desktop\AppData\Roaming\Typora\typora-user-images\image-20210227170012005.png)]

    (6)运行java程序,查看结果;

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SZ7KdYLR-1614417022633)(C:\Users\viruser.v-desktop\AppData\Roaming\Typora\typora-user-images\image-20210227170826671.png)]

    可以看到,我们通过JNI技术成功的修改了String对象的值。也就是我们前面说的通过JNI也可以打破iString对象的不可变性!

    cs