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

研究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相关研究过程的总结记录。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容