美文网首页
研究String不可变特性时遇到奇怪现象

研究String不可变特性时遇到奇怪现象

作者: still_loving | 来源:发表于2023-02-22 01:02 被阅读0次

    研究String不可变特性时遇到的问题分析

    背景

    三年前在学习String相关的概念知识的时候,看到了Java中String的不可变特性,说的是String对象一旦生成就不会变更,其他所有的操作实际上都是重新生成了新的String对象,然后我用反射机制做了一个demo,出现了一些令我迷惑的现象,当时还去了segmentfault网站上写了个提问。

    image.png

    有几个答主给出了一些见解还是很深刻的,最近重新复习的时候,又仔细追踪了一下这个问题,现在有了一点点的初步设想,不过只是猜测,我目前还没找到相关的涉及资料来支撑这个想法。我这边目前用到的JDK版本是1.8.0_351,先上代码:

    代码

    String str1 = "String";
    String str2 = "Strong";
    Field field = String.class.getDeclaredField("value");
    field.setAccessible(true);
    char[] value = (char [])field.get(str1);
    value[3] = 'o';
    

    这段代码逻辑就是为了替换String对象内的value值,因为String的不可变特性,所以常规方案肯定不行,这里就采用了一个反射的方式来强行改变了String内部value这个属性存储的值,这么改动之后,我发现了一些比较奇怪的现象:

    问题

    输出str1和str2:

    System.out.println(str1); //Strong
    System.out.println(str2);//Strong
    

    因为用反射机制把str1的内容给换成了str2的内容了,所以str1现在也是Strong。

    比较str1和str2:

    System.out.println(str1 == str2); // false
    System.out.println(str1.equals(str2)); // true
    

    这个结果,脑补了如下这张图,str1和str2在声明的时候,由于是字面量创建,在创建字符串对象的时候会去字符串常量池里面看看是否已有对应的对象,如果没有的话,还会在常量池里面加入对应的缓存记录。

    后续如果再有同样的字符串,直接从常量池里面获取对应String对象的地址返回即可。

    image.png

    而String中,本身重写了equals方法:

    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i]) // 可以看到是逐个字符对比value属性的内容
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }
    

    而str1内部的value已经通过反射调整到了"Strong"内容,因此这里比较之后,结果就为true了。

    直接输出字符串"String"、"Strong":

    System.out.println("String"); //Strong
    System.out.println("Strong"); //Strong
    

    前面说了,因为声明str1和str2的时候,采用的是字面量创建,因此会将其放入到常量池中,因此这时候直接使用字符串字面量的话,默认使用的就是前面创建str1、str2时在常量池中缓存的那个,所以对于这里的"String"字符串,就相当于有一个匿名的变量指向它,但是因为字符串常量池的缘故,所以该匿名变量所指向的地址就是前面str1指向的地址。

    所以它等价于前面直接输出str1、str2的那两句代码。

    至于println的输出,就需要追踪一下改方法的源码内部调用了:

    仔细追踪了下println()这个方法内部:

    调用链是这样:println() ----> print() ---> write() ---> textOut.write(String) ---> write(String str, int off, int len)

    public void write(String str, int off, int len) throws IOException {
        ......
        str.getChars(off, (off + len), cbuf, 0);
        write(cbuf, 0, len);
    }
    

    这个里面的str.getChars()实际内部就是利用System的arraycopy方法把字符串内部的value数组拷贝到cbuf数组中,然后写出cbuf数组内的数据。

    所以可以发现它实际上输出的仍旧是String对象内部value的值,前面的代码中因为使用了反射,将该字符串对应的内部值变更了,所以这里也就跟着一并变化了。

    输出HashCode

    System.out.println(str1.hashCode()); //-1808112969
    System.out.println(str2.hashCode()); //-1808112969
    System.out.println(System.identityHashCode(str1)); //939047783
    System.out.println(System.identityHashCode(str2)); //1237514926
    

    前面两行的输出内容是一样的,这说明此时str1和str2调用对应的hashCode方法得到的结果是一样,这是因为String类重写的hashCode方法,这里可以跟踪进入重写的hashCode内部逻辑上去看看:

    public int hashCode() {
        int h = hash; // hash默认是0
        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;
    }
    

    该方法上的注释已经说明了计算方法:

    Returns a hash code for this string. The hash code for a String object is computed as

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

    可以看到,String对象的hash值计算和内部的value数组紧密相关,此时因为使用反射,修改了str1内部value数组内的内容,因此hashCode计算的结果str1和str2是一样的,这个没毛病。

    至于下面两行调用的 System.identityHashCode() 方法,这个方法它是一个native方法,根据注释上的内容:

    Returns the same hash code for the given object as would be returned by the default method hashCode(), whether or not the given object's class overrides hashCode()。

    为给定对象返回与默认方法 hashCode() 返回的相同的哈希码,无论给定对象的类是否覆盖 hashCode()

    可以看到,它最终调用的就是Object里的hashCode方法,也就是说:这里通过identityHashCode方法调用,实际上是绕过了String自身的 hashCode方法,转而直接使用了Object的hashCode方法。

    JDK8中默认的hashCode计算方案是Marsaglia’s xor-shift 随机数生成法,它是跟线程状态有关。

    可参考 openjdk 源码:

    // sychronizer.cpp
    static inline intptr_t get_next_hash(Thread * Self, oop obj) {
      intptr_t value = 0 ;
      if (hashCode == 0) {
         // This form uses an unguarded global Park-Miller RNG,
         // so it's possible for two threads to race and generate the same RNG.
         // On MP system we'll have lots of RW access to a global, so the
         // mechanism induces lots of coherency traffic.
         value = os::random() ;
      } else
      if (hashCode == 1) {
         // This variation has the property of being stable (idempotent)
         // between STW operations.  This can be useful in some of the 1-0
         // synchronization schemes.
         intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3 ;
         value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
      } else
      if (hashCode == 2) {
         value = 1 ;            // for sensitivity testing
      } else
      if (hashCode == 3) {
         value = ++GVars.hcSequence ;
      } else
      if (hashCode == 4) {
         value = cast_from_oop<intptr_t>(obj) ;
      } else {
         // Marsaglia's xor-shift scheme with thread-specific state
         // This is probably the best overall implementation -- we'll
         // likely make this the default in future releases.
         unsigned t = Self->_hashStateX ;
         t ^= (t << 11) ;
         Self->_hashStateX = Self->_hashStateY ;
         Self->_hashStateY = Self->_hashStateZ ;
         Self->_hashStateZ = Self->_hashStateW ;
         unsigned v = Self->_hashStateW ;
         v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
         Self->_hashStateW = v ;
         value = v ;
      }
    
      value &= markOopDesc::hash_mask;
      if (value == 0) value = 0xBAD ;
      assert (value != markOopDesc::no_hash, "invariant") ;
      TEVENT (hashCode: GENERATE) ;
      return value;
    }
    

    JDK8默认走的是最后的else分支,这里出现了_hashStateX 、_hashStateY、_hashStateZ、_hashStateW。

    而在thread.cpp中:

    // thread-specific hashCode stream generator state - Marsaglia shift-xor form
    _hashStateX = os::random() ;
    _hashStateY = 842502087 ;
    _hashStateZ = 0x8767 ;    // (int)(3579807591LL & 0xffff) ;
    _hashStateW = 273326509 ;
    

    可以发现,这种算法实际上就是基于一个随机值外加三个确定值经过一系列运算后得到的一个数字。

    同时为了提升性能,JDK会将对象的hashCode值进行缓存,将对象第一次计算后的 hash 值缓存起来,下次再获取时无需重新计算,直接从缓存处获取。

    对于String类,它内部本身就设定了一个hash属性用于缓存字符串对象的hash值:

    private int hash; // Default to 0
    

    而对于native方法计算的hash值,可以参考sychronizer.cpp中的FastHashCode:

    intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) {
        ......
        if (mark->is_neutral()) {
            hash = mark->hash();              // this is a normal header
            if (hash) {                       // if it has hash, just return it
              return hash;
            }
            ......
        }
        ......
    }
    

    缓存的做法比较适合在不可变化的对象上使用,比如:String。对于存在变动的,就最好重写 hashcode 方法。

    在研究String的这个hashCode问题时,发现了一些相关的知识内容,准备专门开一篇介绍hashCode相关研究过程的总结记录。

    相关文章

      网友评论

          本文标题:研究String不可变特性时遇到奇怪现象

          本文链接:https://www.haomeiwen.com/subject/qrhykdtx.html