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

Published on 2024-10-28 16:36 in 分类: 博客 with 狂盗一枝梅
分类: 博客

从上一篇文章《深入理解偏向锁、轻量级锁、重量级锁》中已经介绍了关于锁升级相关的知识,在其中研究偏向锁的过程中,无意中调用了hashCode方法,然后锁的状态发生了明显不符合预期的变化。

一、偏向锁回顾

首先看下偏向锁的案例,这个之前已经介绍过了,这里重新拿出来的目的是为了作为对照组比较实验结果

需要加入依赖

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.32</version>
</dependency>
<!--Java Object Layout -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.17</version>
</dependency>

下面是偏向锁案例的代码

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

/**
 * 启动前注意加上jvm启动参数:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
 *
 * @author kdyzm
 * @date 2024/10/28
 */
@Slf4j
public class HashCodeEffect {

    public static void main(String[] args) throws InterruptedException {
        User lock = new User();
        Thread threadA = new Thread(() -> {
            //① lock.hashCode();
            log.info("抢占锁前lock的状态:\n{}", lock.getObjectStruct());
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    if (i == 99) {
                        //② lock.hashCode();
                        log.info("占有锁lock的状态:\n{}", lock.getObjectStruct());
                    }
                }
            }
            //③ lock.hashCode();
            log.info("释放锁后lock的状态:\n{}", lock.getObjectStruct());
        }, "A");
        Thread threadB = new Thread(() -> {
            //④ lock.hashCode();
            log.info("抢占锁前lock的状态:\n{}", lock.getObjectStruct());
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    if (i == 99) {
                        //⑤ lock.hashCode();
                        log.info("占有锁lock的状态:\n{}", lock.getObjectStruct());
                    }
                }
            }
            //⑥ lock.hashCode();
            log.info("释放锁后lock的状态:\n{}", lock.getObjectStruct());
        }, "B");
        threadA.start();
        threadA.join();
        threadB.start();
        threadB.join();
    }

    @ToString
    @Setter
    @Getter
    public static class User {
        private String userName;

        //对象结构字符串
        public String getObjectStruct() {
            return ClassLayout.parseInstance(this).toPrintable();
        }
    }
}

使用两个线程串行对一个对象获取锁、释放锁,其中不断使用JOL工具打印锁对象的内存结构以观察锁状态,最后结果是

线程名 加锁前 加锁中 释放锁后
线程A biasable(可偏向的) biased(偏向锁,偏向线程A) biased(偏向锁,偏向线程A)
线程B biased(偏向锁,偏向线程A) thin lock(轻量级锁) non-biasable(无锁)

在这个案例中,锁状态的变化流程图如下

image-20241028104439473

在代码中有标记hashCode插入的地方①②③④⑤⑥,对应流程图中的标记如下

image-20241028110043098

接下来对这些地方依次插入lock.hashCode()方法调用,观察调用hashCode方法对锁状态的影响。

二、hashCode方法调用的影响

① 线程A获取锁前调用hashCode方法

线程名 加锁前 加锁中 释放锁后
线程A 可偏向->调用hashCode方法->无锁 thin lock(轻量级锁) 无锁
线程B 无锁 thin lock(轻量级锁) non-biasable(无锁)

其流程图状态变化

image-20241028132432322

可以看到如果当前锁状态是“可偏向”状态,这时候调用hashCode方法,锁状态会变成“无锁”状态,之后线程A再获取锁,锁状态无法偏向成偏向锁,直接成为轻量级锁。

② 线程A获取锁后调用hashCode方法

线程名 加锁前 加锁中 释放锁后
线程A biasable(可偏向的) 偏向锁->调用hashCode方法->fat lock(重量级锁) fat lock(重量级锁)->无锁
线程B 无锁 thin lock(轻量级锁) 无锁

其流程图状态变化

image-20241028135557238

同步代码块内,线程A获取到锁之后锁已经变成了偏向锁状态,但是这时候调用hashCode方法,会让偏向锁跳过轻量级锁直接膨胀成重量级锁,重量级锁在释放锁后并没有立即变成无锁状态,而是稍等了一下才变成无锁状态。

③线程A释放锁,④线程B获取锁前调用hashCode方法

③、④位置均是偏向锁状态,所以可以任意放开其中之一注释即可。

线程名 加锁前 加锁中 释放锁后
线程A biasable(可偏向的) biased(偏向锁,偏向线程A) biased(偏向锁,偏向线程A)
线程B 偏向锁->调用hashCode方法->无锁 thin lock(轻量级锁) 无锁

其流程图状态变化

image-20241028140725382

同步代码块外,偏向锁状态下调用hashCode方法,锁会变成无锁状态。

⑤ 线程B获取锁后调用hashCode方法

线程名 加锁前 加锁中 释放锁后
线程A biasable(可偏向的) biased(偏向锁,偏向线程A) biased(偏向锁,偏向线程A)
线程B biased(偏向锁,偏向线程A) 轻量级锁->调用hashCode方法->重量级锁 重量级锁->无锁

其流程图状态变化

image-20241028141825046

在同步代码块内,B线程获取到锁之后,锁状态先变成轻量级锁状态,之后调用hashCode方法后,就变成了重量级锁状态,释放锁后过了一段时间才变成了无锁状态。

⑥ 线程B释放锁后调用hashCode方法

线程名 加锁前 加锁中 释放锁后
线程A biasable(可偏向的) biased(偏向锁,偏向线程A) biased(偏向锁,偏向线程A)
线程B biased(偏向锁,偏向线程A) thin lock(轻量级锁) non-biasable(无锁)->无锁(带hashCode)

其流程图状态变化

image-20241028154134776

虽然都是无锁,但是还是有些变化,调用hashCode方法之后,无锁状态的MarkWord中的hashCode字段就被填充了。

重写hashCode方法的影响

重写锁类的hashCode方法后,调用锁对象的hashCode方法,并不会影响Mark Word中的hashCode字段的值。换句话说,重写hashCode后,调用或者不调用hashCode方法都不会影响锁的状态了。

三、总结

若锁类没有重写hashCode方法,锁状态将受hashCode方法调用的影响,这分为两种情况:

  1. 同步代码块内,调用hashCode方法,无论当前锁状态是什么,都会立即变成重量级锁。
  2. 同步代码块外,调用hashCode方法,无论当前锁状态是什么,都会立即变成无锁状态。

四、探究原因

hashCode方法的调用为什么会影响锁状态呢?几乎所有的多线程开发的书籍中都没有给出原因,但是在周志明的《深入理解Java虚拟机:JVM高级特性与最佳实践(第三版)》中却给出了答案,第五部分第13章第13.3.5节:偏向锁中有这么一段话

当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?

在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求 [1] 时(注意,这里说的计算请求应来自于对Object::hashCode()或者System::identityHashCode(Object)方法的调用,如果重写了对象的hashCode()方法,计算哈希码时并不会产生这里所说的请求。),它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。

翻译下这是什么意思:

对象头中存储的是一致性哈希码,它是未重写hashCode方法的前提下通过Object::hashCode()或者System::identityHashCode(Object)方法计算得到的,这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。

这样可以解释第一个问题了:为什么无锁状态无法升级成偏向锁

偏向锁由于其作用机制原因无法存储计算出来的hashCode码,所以一旦无锁状态下调用hashCode方法,当前锁就再也无法进入偏向锁状态了,也可能由于此原因,偏向锁被释放后偏向锁状态也不会被撤销。而轻量级锁可以将整个Mark Word备份到栈帧中的Displaced Mark Word中,等释放锁就可以将其还原回去;重量级锁则有ObjectMonitor类记录非加锁状态下的MarkWord。轻量级锁和重量级锁均支持记录无锁状态的MarkWord,而偏向锁则无法记录,所以无锁状态不能转化为偏向锁,只能跳过偏向锁直接升级轻量级锁。

这样还有两个问题没有解决:

  1. 在同步代码块下偏向锁调用hashCode方法为什么会跳过轻量级锁直接膨胀为重量级锁
  2. 在同步代码块下轻量级锁调用hashCode方法为什么会膨胀为重量级锁

轻量级锁升级重量级锁可能还有原因可循:轻量级锁栈帧中已经保存了无锁状态的MarkWord(不包含hashCode码),一旦生成hashCode,当前状态下的轻量级锁肯定无法满足条件,要么撤销轻量级锁,重新加轻量级锁,要么就升级重量级锁,选择升级重量级锁倒也无可厚非。

至于在同步代码块下偏向锁调用hashCode方法为什么会跳过轻量级锁直接膨胀为重量级锁,就不得而知了。



END.


#java #多线程编程
目录
复制 复制成功