hashCode 对 Java 锁的影响
引用周志明老师在<深入理解 Java 虚拟机>中的一段话
在 Java 语言里面一个对象如果计算了哈希码,就应该一直保持该值不变(强烈建议但不强制,因为用户可以重载 hashCode() 方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的 API 都可能存在出错的风险.而作为绝大多数对象哈希码来源的 Object::hashCode() 方法,返回的是对象的一致哈希码 (Identity Hash Code),这个值是能强制保持不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变.因此,当一个对象已经计算过一致哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致哈希码请求时,它的偏向状态会被立即撤销,并且会膨胀为重量级锁.在重量级锁的实现中,对象头指向重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态(标志为 01)下的 Mark Word,其中自然可以存储原来的哈希码,偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡性质的优化,也就是说它并非总是对程序运行有利.如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的.在具体问题具体分析的前提下,有时候使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能
上述观点表达了在不同时期调用 hashCode() 方法会对锁的状态产生影响,具体如下
1、未加锁前调用 hashCode() 方法,在加锁时不能进入偏向锁状态
2、在偏向锁状态下调用 hashCode() 方法,偏向锁会膨胀为重量级锁
在论证上述观点之前先简单了解一下 JVM 对象头存储了哪些信息
下面我们就来验证一下
测试时需要添加下面两个 Jvm 启动参数
1 2 | -XX:+UseBiasedLocking // 偏向锁默认是关掉的,这里需要手动打开 -XX:BiasedLockingStartupDelay= 0 // 偏向锁会有延时启动,这里把延时设为 0 |
为了打印方便,我们扩展了 jol 的包,有需要的可以从这里获取 https://www.cnblogs.com/xiaomaomao/p/17356429.html 对应的 jar 包
示例代码 1、未调用 hashCode() 方法
1 2 3 4 5 6 7 8 9 10 11 12 | 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(); } } |
输出结果
1 2 3 | 初始: 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() 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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(); } } |
输出结果
1 2 3 4 5 | 初始: 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()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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(); } } |
输出结果
1 2 3 4 5 | 初始: 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 中,升级为重量级锁之后,锁不会降级,解锁之后依然是重量级锁
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?