HashCode方法的调用对Java锁的影响

在回顾以前写的锁升级的代码时,意外发现hashCode方法的调用会对锁产生影响,于是做了几个测试并查阅了一些资料,把最终的结果记录于此。

结论

首先上结论,一个对象在调用原生hashCode方法后(来自Object的,未被重写过的),该对象将无法进入偏向锁状态,起步就会是轻量级锁。若hashCode方法的调用是在对象已经处于偏向锁状态时调用,它的偏向状态会被立即撤销,并且锁会升级为重量级锁。

缘由

我们都知道,Java对象的对象头里包含两个部分,一个是Mark Word,另一个是类型指针。而对于Mark Word而言,通过精细的划分,其在64 bit的空间内可以变着花样的存储各类信息。而Hash Code正是其中之一,如下图:
Mark Word
可以看到,只有当对象出于无锁态时,才可以存储Hash Code,而这个值意义重大(下文会详述),于是在Java的设计中,为了保存这个值而放弃了这个对象的偏向能力并对锁的变化作出了调整。

测试

下面通过一些代码的demo看一下锁变化的具体展现,当然在运行代码之前需要加一些虚拟机参数。

-XX:+UseBiasedLocking  // 偏向锁默认是关掉的,这里需要手动打开
-XX:BiasedLockingStartupDelay=0  // 偏向锁会有延时启动,这里把延时设为0
代码一(没有hashCode方法的调用):
public static void main(String[] args) {
    Object lock = new Object();
    System.out.println("初始:" + ClassLayout.parseInstance(lock).toPrintable());
    new Thread(() -> {
        synchronized (lock) {
            System.out.println("上锁:" + ClassLayout.parseInstance(lock).toPrintable());
        }
        System.out.println("解锁:" + ClassLayout.parseInstance(lock).toPrintable());
    }).start();
}

输出日志:
日志截屏1

通过日志可以看出:
1. 对象一开始即为偏向状态,且由于没有记录线程ID,为匿名偏向
2. 上锁后锁时保持偏向,并记录线程ID;
3. 解锁后依然保持偏向,并记录线程ID。

代码二(在上锁前进行hashCode方法的调用):
public static void main(String[] args) {
    Object lock = new Object();
    System.out.println("初始状态:" + ClassLayout.parseInstance(lock).toPrintable());
    System.out.println(lock.hashCode());
    System.out.println("HashCode调用后状态:" + ClassLayout.parseInstance(lock).toPrintable());
    new Thread(() -> {
        synchronized (lock) {
            System.out.println("上锁:" + ClassLayout.parseInstance(lock).toPrintable());
        }
        System.out.println("解锁:" + ClassLayout.parseInstance(lock).toPrintable());
    }).start();
}

输出日志:
日志截屏2-1
日志截屏2-2
通过日志可以看出:
1. 对象一开始为匿名偏向
2. 调用hashCode方法后,转为无锁态,并存储了hashcode
3. 上锁后,转为轻量级锁(P.S. 此时hashcode存在拿到锁的线程的Lock Record里面);
4. 解锁后再次转为无锁态,并存储着hashcode

代码三(在上锁后进行hashCode方法的调用):
public static void main(String[] args) {
    Object lock = new Object();
    System.out.println("初始状态:" + ClassLayout.parseInstance(lock).toPrintable());
    new Thread(() -> {
        synchronized (lock) {
            System.out.println("上锁:" + ClassLayout.parseInstance(lock).toPrintable());
            System.out.println(lock.hashCode());
            System.out.println("HashCode调用后状态:" + ClassLayout.parseInstance(lock).toPrintable());
        }
        System.out.println("解锁:" + ClassLayout.parseInstance(lock).toPrintable());
    }).start();
}

输出日志:

日志截屏3-1
日志截屏3-2
通过日志可以看出:
1. 对象一开始为匿名偏向
2. 上锁后锁时保持偏向,并记录线程ID;
3. 调用hashCode方法后,升级为重量级锁
4. 解锁后依然为重量级锁

P.S. 经过额外的几次扩展试验发现,在解锁后,虽然仍为重量级锁,但事实上,在一定时间后(百毫秒级别),会转为无锁态,并在下一次的加锁时,进入轻量级锁状态,并在解锁后转为无锁态
P.P.S 为什么要直接升级为重量级锁轻量级锁Lock Record是不是此时不方便更新之前拷贝的Mark Word
对于这其中的详细逻辑,暂且还未了解,等哪天空下来细心研究一下,再填这个坑。

引经据典

Java为什么会为了hashcode作出如此的改变,通过查阅资料,发现在《深入理解Java虚拟机》的13.3.5 偏向锁这一小节,有如下的解释:

在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。

而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。

因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObiectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能"。

书中对于哈希码和偏向锁的总结的非常清晰,可以解释以上演示代码结果的产生,建议认真通读一遍,定会豁然开朗。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值