hashCode 对 Java 锁的影响

引用周志明老师在<深入理解 Java 虚拟机>中的一段话

在 Java 语言里面一个对象如果计算了哈希码,就应该一直保持该值不变(强烈建议但不强制,因为用户可以重载 hashCode() 方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的 API 都可能存在出错的风险.而作为绝大多数对象哈希码来源的 Object::hashCode() 方法,返回的是对象的一致哈希码 (Identity Hash Code),这个值是能强制保持不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变.因此,当一个对象已经计算过一致哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致哈希码请求时,它的偏向状态会被立即撤销,并且会膨胀为重量级锁.在重量级锁的实现中,对象头指向重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态(标志为 01)下的 Mark Word,其中自然可以存储原来的哈希码,偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡性质的优化,也就是说它并非总是对程序运行有利.如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的.在具体问题具体分析的前提下,有时候使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能

上述观点表达了在不同时期调用 hashCode() 方法会对锁的状态产生影响,具体如下

1、未加锁前调用 hashCode() 方法,在加锁时不能进入偏向锁状态

2、在偏向锁状态下调用 hashCode() 方法,偏向锁会膨胀为重量级锁

在论证上述观点之前先简单了解一下 JVM 对象头存储了哪些信息

下面我们就来验证一下

测试时需要添加下面两个 Jvm 启动参数

-XX:+UseBiasedLocking  				// 偏向锁默认是关掉的,这里需要手动打开
-XX:BiasedLockingStartupDelay=0  	// 偏向锁会有延时启动,这里把延时设为 0

为了打印方便,我们扩展了 jol 的包,有需要的可以从这里获取 https://www.cnblogs.com/xiaomaomao/p/17356429.html 对应的 jar 包

示例代码 1、未调用 hashCode() 方法

public class HashCodeDemo {
    public static void main(String[] args) {
        Object lock = new Object();
        System.out.println("初始:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("上锁:" + ClassLayout.parseInstance(lock).toPrintableSimple());
            }
            System.out.println("解锁:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        }).start();
    }
}

输出结果

初始:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101 // 后 3 位的 101 代表偏向锁状态,前面 54 位并没有记录线程 ID,这种情况称为匿名偏向
上锁:00000000 00000000 00000010 10011101 01010111 10011111 01011000 00000101 // 上锁之后由于后 3 位是 101 代表偏向锁状态,前面 54 位代表线程 ID, 55、56 位的 00 代表偏向时间戳
解锁:00000000 00000000 00000010 10011101 01010111 10011111 01011000 00000101 // 解锁之后由于后 3 位是 101 代表偏向锁状态,前面 54 位代表线程 ID, 55、56 位的 00 代表偏向时间戳

从上面的结果中可以看出,单个线程情形下使用 synchronized 进行加锁,JVM 会优先使用偏向锁

按照周志明老师的说法,一个对象如果提前调用了 hashCode() 方法,那么它就不会再进入偏向锁状态了

示例代码 2、在加锁前调用 hashCode() 方法

public class HashCodeDemo {
    public static void main(String[] args) {
        Object lock = new Object();
        System.out.println("初始:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        System.out.println("hashCode: "+ Integer.toBinaryString(lock.hashCode()));
        System.out.println("HashCode 调用后:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("上锁:" + ClassLayout.parseInstance(lock).toPrintableSimple());
            }
            System.out.println("解锁:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        }).start();
    }
}

输出结果

初始:              00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101 // 初始是匿名偏向状态
hashCode:          11001110 00100010 11111110 1000100                                      // 对象的 hashCode 转成二进制
hashCode 调用后:   00000000 00000000 00000000 01100111 00010001 01111111 01000100 00000001 // 调用 hashCode 之后,不再是匿名偏向状态,转而变成无锁状态,从 26 ~ 56 位代表 hashCode 值
上锁:              00000000 00000000 00000000 11111101 00001101 00001111 11110100 10010000 // 上锁后,不再是偏向锁状态,直接升级为轻量级锁,前 62 位便是指向轻量级锁 Lock Record 的地址
解锁:              00000000 00000000 00000000 01100111 00010001 01111111 01000100 00000001 // 解锁后,恢复至无锁状态,哈希码值依旧保存在 Mark Word 里面

如果加锁前调用 hashCode() 方法,那么在加锁时不会进入偏向锁状态,而是直接升级为轻量级锁,对象的哈希码值存储在锁偏向的那个线程中,轻量级锁解锁之后会恢复至加锁前的状态(此处加锁前是无锁状态)

为什么不会进入偏向锁状态呢?

通过观察 Mark Word 结构可以得知,调用了 hashCode() 方法之后生成的哈希码值会存储在无锁状态的对象头中,获取锁时,轻量级锁的 Mark Word 没有多余空间存储 31 bit 的 hashCode 值,因为轻量级锁的 Mark Word 总共就 64 bit,其中 54 bit 用于存放偏向线程 ID,所以这个时候就只能被迫升级为轻量级锁,升级为轻量级锁之后, hashCode 存储在当前持有锁的线程内部,解锁时就是恢复至加锁前的对象头状态

示例代码 3、处于偏向锁状态下调用 hashCode()

public class HashCodeDemo {
    public static void main(String[] args) {
        Object lock = new Object();
        System.out.println("初始:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("上锁:" + ClassLayout.parseInstance(lock).toPrintableSimple());
                System.out.println("hashCode: "+ Integer.toBinaryString(lock.hashCode()));
                System.out.println("hashCode 调用后状态:" + ClassLayout.parseInstance(lock).toPrintableSimple());
            }
            System.out.println("解锁:" + ClassLayout.parseInstance(lock).toPrintableSimple());
        }).start();
    }
}

输出结果

初始:                 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101     // 初始是匿名偏向状态
上锁:                00000000 00000000 00000001 01010000 00111100 01001110 01100000 00000101     // 上锁之后是偏向锁状态,前 54 位是偏向线程 ID
hashCode:            11111101 10101111 00110101 1111100                                          // 对象的 hashCode 转成二进制
hashCode 调用后状态:  00000000 00000000 00000001 01010000 00111001 10010110 10000101 00111010     // 轻量级锁状态下调用 hashCode() 之后直接膨胀为重量级锁,前 64 位是指向 monitor 对象的地址
解锁:                00000000 00000000 00000001 01010000 00111001 10010110 10000101 00111010     // 重量级锁解锁后依旧是重量级锁,因为升级为重量级锁之后不会再降级了

如果处于偏向锁状态下调用 hashCode() 方法,那么偏向锁会被撤销,进而膨胀为重量级锁(这里为什么不会升级成轻量级锁呢,这个疑问后续再研究),对象的哈希码值将会存储在 Object Monitor 中,升级为重量级锁之后,锁不会降级,解锁之后依然是重量级锁

  

 

posted @ 2023-05-18 11:16  变体精灵  阅读(175)  评论(0编辑  收藏  举报