热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤

这篇文章主要介绍“通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤”,在日常操作中,相信很多人在通过重写hashCode()方法将偏向锁性能提高

这篇文章主要介绍“通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤”,在日常操作中,相信很多人在通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤”的疑惑有所帮助!接下来,请跟着小编一起来学习吧!

1 微小谜题

上周在工作中我提交了对一个类的微小改动,实现了toString()方法,让日志更容易理解。令我吃惊的是,这个变动导致类的单元测试覆盖率下降了5%。我知道所有的新代码都被现有测试所覆盖。那么是哪错了呢?在比较覆盖率报告的时候,一个眼尖的同事注意到hashCode()在变更前被测试覆盖,而变更后却没有。这就说得通了:默认toString()方法调用了hashCode()方法。

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

在重写toString()之后,自定义hashCode()不再被调用。我遗漏了一项测试。

每个人都了解toString()方法,但是……

2 默认hashCode()方法是怎么实现的?

默认hashCode()方法的所返回的值叫做标识散列码(identity hash code)。从现在开始,我将使用这个术语来区分它与重写hashCode()方法返回的散列码。注:即使类重写了hashCode(),你仍然可以通过System.identityHashCode(o)来获得对象o的标识散列码。

使用内存地址的整型表示作为标识散列码是常识,也是J2SE文档所暗示的:

……通常是通过将对象的内部地址转换为整数来实现的,但这种实现技术并非Java编程语言所要求。

尽管如此,看起来还是有问题的,因为方法约定要求:

在Java应用程序执行期间,在同一对象上多次调用hashCode()方法时,hashCode()方法必须返回同一个值,无论调用的时机如何。

考虑到JVM会重新定位对象(例如在由晋升或压缩导致的GC周期中)。在计算对象的标识散列码之后,我们必须能以某种方式重新得到这个值,即使发生了对象重定位。

一种可能性是在第一次调用hashCode()时获取对象的当前内存位置,然后和对象一起保存,比如保存到对象头。这样即使对象被移动到不同的位置,它仍然留有最初的标识散列码。这种方法的一个隐患是:它无法阻止两个不同对象具有相同的标识散列码。但Java规范允许这种情况发生。

最好的确认方法是查看源代码。不幸的是,默认的java.lang.Object::hashCode()是一个本地方法。Listing 2: Object::hashCode是本地方法

public native int hashCode();

3 真正的hashCode()请出来

要注意的是,标识散列码的实现依赖于JVM。因为我只讨论OpenJDK源代码,所以我提到JVM时,总是指OpenJDK这一特定实现。代码链接指向代码仓库的Hotspot子目录。我认为这份代码中的大部分也适用于Oracle JVM,当然在个别地方可能(实际上)是不同的(稍后会详细介绍)。

OpenJDK定义了hashCode()入口点,在源代码src/share/vm/prims/jvm.h和src/share/vm/prims/jvm.cpp中:

508 JVM_ENTRY(jint, JVM_IHashCode(JNIEnv* env, jobject handle))
509   JVMWrapper("JVM_IHashCode");
510   // as implemented in the classic virtual machine; return 0 if object is NULL511   return handle == NULL ? 0 : ObjectSynchronizer::FastHashCode (THREAD, JNIHandles::resolve_non_null(handle)) ;
512 JVM_END

identity_hash_value_for也调用了ObjectSynchronizer::FastHashCode(),前者被其他一些地方(如System.identityHashCode())调用。

708 intptr_t ObjectSynchronizer::identity_hash_value_for(Handle obj) {
709   return FastHashCode (Thread::current(), obj()) ;
710 }

有人可能简单的认为ObjectSynchronizer::FastHashCode()的做法类似:

if (obj.hash() == 0) {
    obj.set_hash(generate_new_hash());
}return obj.hash();

但实际上它是包含上百行代码、看起来复杂得多的函数。不过我们可以发现一些“如果没有则生成”(if-not-exists-generate)代码,比如:

685   mark = monitor->header();
...
687   hash = mark->hash();
688   if (hash == 0) {
689     hash = get_next_hash(Self, obj);
...
701   }
...
703   return hash;

这似乎证实了我们的假设。现在让我们暂时忽略管程(monitor),只要知道它可以提供对象头。对象头保存在变量mark中。mark是指向markOop实例的指针,markOop表示位于对象头中低地址的标记字(mark word)。因此hashCode()的算法是:尝试得到标记字中记录的散列码。如果没有,用get_next_hash()生成一个,保存然后返回。

4 标识散列码的生成

如我们所见,散列码由get_next_hash()生成。这个函数提供了6种计算方法,根据全局配置hashCode选择使用哪一个。

  1. 使用随机数。

  2. 基于对象的内存地址计算。

  3. 硬编码为1(用于测试)。

  4. 从一个序列生成。

  5. 使用对象的内存地址,转换为int类型。

  6. 使用线程状态和xorshift结合。

默认方法是哪一个?OpenJDK 8使用了方法5,依据是global.hpp:

1127   product(intx, hashCode, 5,                                                \
1128           "(Unstable) select hashCode generation algorithm")                \

OpenJDK 9使用相同的默认值。查看以前的版本,OpenJDK 7和6都使用了第一个方法:随机数。

所以,除非我找错了源代码,否则OpenJDK中默认hashCode()方法的实现,和对象内存地址无关,至少从OpenJDK 6开始就是这样。

5 对象头和同步

让我们回顾几个之前没有考虑的地方。首先ObjectSynchronizer::FastHashCode()似乎过于复杂,使用了超过100行代码来执行我们认为是平凡的“得到或生成”(get-or-generate)操作。第二,管程是什么,它为什么拥有对象头?

查看标记词的结构是一个取得进展的好的起点。在OpenJDK中,它是这样的:

30 // The markOop describes the header of an object.31 //32 // Note that the mark is not a real oop but just a word.33 // It is placed in the oop hierarchy for historical reasons.34 //35 // Bit-format of an object header (most significant first, big endian layout below):36 //37 //  32 bits:38 //  --------39 //             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)40 //             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)41 //             size:32 ------------------------------------------>| (CMS free block)42 //             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)43 //44 //  64 bits:45 //  --------46 //  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)47 //  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)48 //  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)49 //  size:64 ----------------------------------------------------->| (CMS free block)50 //51 //  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)52 //  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)53 //  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)54 //  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

在32位机器和64位机器上,标记字的格式略有不同。后者有两个变体,取决于是否启用了压缩对象指针(Compressed Object Pointer)。Oracle JVM和OpenJDK 8都是默认启用的。

因此对象头可能与一个内存块或一个实际的对象关联,存在多种状态。在最简单的情况下(“普通对象”),标识散列码直接存储在对象头的低地址中。

但在其他状态下,对象头包含一个指向JavaThread或PromotedObject的指针。更复杂的是:如果我们把唯一散列码放到一个“普通对象”中,它会被移走吗?移动到哪?如果对象是有偏向的(biased),我们可以从哪里获得或设置标识散列码?什么又是有偏向的对象(biased object)呢?

让我们试着回答这些问题。

6 偏向锁

偏向对象看起来是偏向锁的结果。这是从HotSpot 6起默认启用的一个特性,试图减少锁定对象的成本。锁定操作是昂贵的,它的实现通常依赖于原子CPU指令(CAS),以便安全地处理来自不同线程的锁定和解锁请求。根据观察,在大多数应用程序中,大多数对象只被一个线程锁定,因此为原子操作付出的成本常常被浪费了。为了避免这种情况,带有偏向锁的JVM允许线程将对象设置为“偏向于”自己。如果一个对象是有偏向的,线程可以锁定和解锁对象,而无需原子指令。只要没有线程争用同一个对象,我们就会得到性能提升。

对象头中的偏向锁位(biased_lock bit)表示对象是否偏向于JavaThread*所指向的线程。锁定位(lock bit)表示该对象是否被锁定。

正是因为OpenJDK的偏向锁实现需要在标记字中写入一个指针,它需要重新定位真正的标记字(其中包含标识散列码)。

这可以解释FasttHashCode中额外的复杂性。对象头不仅包含标识散列码,也包含锁定状态(比如指向锁持有者线程的指针)。因此我们需要考虑所有情况,并找到标识散列码存储的位置。

让我们来读读FasttHashCode。我们发现的第一件事是:

601 intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) {
602   if (UseBiasedLocking) {
610     if (obj->mark()->has_bias_pattern()) {
          ...
617       BiasedLocking::revoke_and_rebias(hobj, false, JavaThread::current());
          ...
619       assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
620     }
621   }

等等,它只是撤销了现有的偏向性,并禁用了对象上的偏向锁(false意味着不要尝试重置偏向性)。看接下来的几行,这确实是一个不变量:

637   // object should remain ineligible for biased locking638   assert (!mark->has_bias_pattern(), "invariant") ;

如果我没看错,这意味着简单地请求对象的标识散列码将禁用偏向锁,这将强制要求锁定对象必须使用昂贵的原子指令,即使只有一个线程。

7 为什么偏向锁和标识散列码冲突?

通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤

要回答这个问题,我们必须了解标记字(包含标识散列码)可能存在的位置,这取决于对象的锁的状态。下面这张来自于HotSpot Wiki的图展示了转换过程:  我的(不可靠)推理如下。

对于图顶部的4种状态,OpenJDK将能够使用“轻”锁表示。在最简单的情况下(没有锁),这意味着将标识散列码和其他数据直接放在对象的标记字中:

46 //  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)

在更复杂的情况下,它需要这个空间来保存指向“锁对象”的指针。因此,标记字将被“替换”,放到其他地方。

既然只有一个线程尝试锁定对象,指针实际上会指向线程堆栈中的某个内存位置。这么做有两个优点:访问速度快(没有争用或内存访问协调),并且能够让线程确定它拥有锁(因为内存位置指向自己的堆栈)。

但这并非在所有情况下都有效。如果存在对象争用(例如许多线程都会执行到的同步语句),我们将需要一个更复杂的结构,不仅可以保存对象头副本,也保存一组等待者。如果线程执行object.wait(),就会出现对等待者列表的类似需求。

这个更丰富的数据结构就是ObjectMonitor,在图中称为“重量级”管程。对象头不再指向“被替换的标记字”,而是指向一个实际的对象(管程)。这时访问标识散列码需要“扩张管程(inflate the monitor)”:跟踪指针得到对象,读取或修改包含被替换标记字的域。这个操作更加昂贵,而且需要协调。

FasttHashCode确实有工作要做。

L640到L680处理查找对象头并检查缓存的标识散列码。我相信存在一个快速路径来探测不需要扩张管程的情况。

从L682开始需要咬紧牙关:

682   // Inflate the monitor to set hash code683   monitor = ObjectSynchronizer::inflate(Self, obj);

684   // Load displaced header and check it has hash code685   mark = monitor->header();
...
687   hash = mark->hash();

此时,如果标识散列码存在(hash != 0),JVM可以直接返回。否则需要从get_next_hash()中得到散列码,并安全地存储在ObjectMonitor保存的对象头中。

这似乎提供了一个合理的解释,为什么在不覆盖默认实现的对象上调用hashCode()导致对象不符合偏向锁的条件:

  • 为了在重定位后保持对象的标识散列码不变,需要将标识散列码存储在对象头中。

  • 请求标识散列码的线程未必关心对象是否锁定,但上它们实际上共享了锁机制使用的数据结构。这种机制是一个复杂怪兽,它不仅自身要发生变化,还要移动(替换)对象头。

  • 偏向锁能够在不使用原子操作的情况下进行锁定和解锁操作。偏向锁是高效的,如果只有一个线程锁定对象。我们可以将锁状态记录到标记字中。我不能100%肯定,但是我认为既然其他线程可能会读取标识散列码,即使只有一个线程需要锁定,标记字也会发生争用,并需要原子操作来保证准确。这否定了偏向锁的全部意义。

8 回顾

  • 默认的hashCode()实现(标识哈希码)和对象的内存地址无关,至少在OpenJDK中是这样的。在OpenJDK 6和7中,它是一个随机生成的数字。在OpenJDK 8和9中,它是一个基于线程状态的数字。这里有一个测试得出了相同的结论。

    • 证明“依赖于实现”的警告并非虚谈:Azul Zing确实从对象的内存地址生成标识散列码。

  • 在HotSpot中,标识散列码只生成一次,然后缓存在对象头的标记字中。

    • Zing使用了不同的方案来保证散列码在对象重定位后的一致的。他们在对象重定向时才保存标识散列码的值。这个时候散列码被保存在pre-header中。

  • 在HotSpot中,调用默认值hashCode()或System.identityHashCode()将使对象的锁失去偏向性。

    • 这意味着如果你对没有争用的对象进行同步(synchronized),最好重写默认的hashCode()实现,否则将错过JVM优化。

  • 在HotSpot中,可以禁用单个对象的偏向锁。

    • 这是非常有用的。我曾见过应用程序在争用的生产者-消费者队列中使用过多的偏向锁,这带来的麻烦比好处多,所以我们完全禁用了这个特性。实际上,我们可以通过在特定对象或类上调用System.identityHashCode()来实现这一点。

  • 我发现HotSpot没有标志选择默认的hashCode生成器,所以试验其他生成器可能需要编译源代码。

    • 说实话我没仔细看。Michael Rasmussen善意地指出-XX:hashCode=2可以用来更改默认值。谢谢!

9 基准测试

我编写了一个简单的JMH工具来验证这些结论。

基准测试所做的事情类似:

object.hashCode();while(true) {synchronized(object) {
        counter++;
    }
}

第一种配置(withIdHash)在使用标识散列码的对象上同步,我们预计调用hashCode()将导致偏向锁被禁用。第二种配置(withoutIdHash)实现了自定义散列码,因此不会禁用偏向锁。每个配置先用一个线程运行,然后用两个线程(带有后缀“Contended”)。

顺便说一下,我们必须启用-XX:BiasedLockingStartupDelay=0,否则JVM将等待4s时间才触发优化,这将影响测试效果。

第一次执行:

Benchmark Mode Cnt Score Error Units BiasedLockingBenchmark.withIdHash thrpt 100 35168,021 ± 230,252 ops/ms BiasedLockingBenchmark.withoutIdHash thrpt 100 173742,468 ± 4364,491 ops/ms BiasedLockingBenchmark.withIdHashContended thrpt 100 22478,109 ± 1650,649 ops/ms BiasedLockingBenchmark.withoutIdHashContended thrpt 100 20061,973 ± 786,021 ops/ms

我们可以看到,使用自定义散列码使锁定和解锁循环比使用标识散列码(禁用偏向锁)快4倍。当两个线程争用锁时,偏置锁将被禁用,因此两种散列方法之间没有显著差异。

第二次运行,禁用所有配置中的偏向锁(-XX:-UseBiasedLocking)。

Benchmark Mode Cnt Score Error Units BiasedLockingBenchmark.withIdHash thrpt 100 37374,774 ± 204,795 ops/ms BiasedLockingBenchmark.withoutIdHash thrpt 100 36961,826 ± 214,083 ops/ms BiasedLockingBenchmark.withIdHashContended thrpt 100 18349,906 ± 1246,372 ops/ms BiasedLockingBenchmark.withoutIdHashContended thrpt 100 18262,290 ± 1371,588 ops/ms

散列方法不再有任何影响,withoutIdHash也失去了它的优势。

(所有的基准测试都运行在一台 2.7 GHz Intel Core i5电脑上。)

10 参考文献

这些猜想以及我对JVM源代码的理解,来自于对关于布局、偏向锁等不同资料的拼凑。主要的资料有:

  • https://blogs.oracle.com/dave/entry/biased_locking_in_hotspot

  • http://fuseyism.com/openjdk/cvmi/java2vm.xhtml

  • http://www.dcs.gla.ac.uk/~jsinger/pdfs/sicsa_openjdk/OpenJDKArchitecture.pdf

  • https://www.infoq.com/articles/Introduction-to-HotSpot

  • http://blog.takipi.com/5-things-you-didnt-know-about-synchronization-in-java-and-scala/#comment-1006598967

  • http://www.azulsystems.com/blog/cliff/2010-01-09-biased-locking

  • https://dzone.com/articles/why-should-you-care-about-equals-and-hashcode

  • https://wiki.openjdk.java.net/display/HotSpot/Synchronization

  • https://mechanical-sympathy.blogspot.com.es/2011/11/biased-locking-osr-and-benchmarking-fun.html

11 附录:基准测试代码

package com.github.srvaroa.jmh;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

import java.util.concurrent.TimeUnit;

@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 4)
@Fork(value = 5, jvmArgsAppend = {"-XX:-UseBiasedLocking", "-XX:BiasedLockingStartupDelay=0"})
public class BiasedLockingBenchmark {

    int unsafeCounter = 0;
    Object withIdHash;
    Object withoutIdHash;

    @Setup
    public void setup() {
        withIdHash = new Object();
        withoutIdHash = new Object() {
            @Override
            public int hashCode() {
                return 1;
            }
        };
        withIdHash.hashCode();
        withoutIdHash.hashCode();
    }

    @Benchmark
    public void withIdHash(Blackhole bh) {
        synchronized(withIdHash) {
            bh.consume(unsafeCounter++);
        }
    }

    @Benchmark
    public void withoutIdHash(Blackhole bh) {
        synchronized(withoutIdHash) {
            bh.consume(unsafeCounter++);
        }
    }

    @Benchmark
    @Threads(2)
    public void withoutIdHashContended(Blackhole bh) {
        synchronized(withoutIdHash) {
            bh.consume(unsafeCounter++);
        }
    }

    @Benchmark
    @Threads(2)
    public void withIdHashContended(Blackhole bh) {
        synchronized(withIdHash) {
            bh.consume(unsafeCounter++);
        }
    }

}

到此,关于“通过重写hashCode()方法将偏向锁性能提高4倍的方法步骤”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注编程笔记网站,小编会继续努力为大家带来更多实用的文章!


推荐阅读
  • 本文详细探讨了JDBC(Java数据库连接)的内部机制,重点分析其作为服务提供者接口(SPI)框架的应用。通过类图和代码示例,展示了JDBC如何注册驱动程序、建立数据库连接以及执行SQL查询的过程。 ... [详细]
  • MySQL索引详解与优化
    本文深入探讨了MySQL中的索引机制,包括索引的基本概念、优势与劣势、分类及其实现原理,并详细介绍了索引的使用场景和优化技巧。通过具体示例,帮助读者更好地理解和应用索引以提升数据库性能。 ... [详细]
  • 深入解析 Apache Shiro 安全框架架构
    本文详细介绍了 Apache Shiro,一个强大且灵活的开源安全框架。Shiro 专注于简化身份验证、授权、会话管理和加密等复杂的安全操作,使开发者能够更轻松地保护应用程序。其核心目标是提供易于使用和理解的API,同时确保高度的安全性和灵活性。 ... [详细]
  • 毕业设计:基于机器学习与深度学习的垃圾邮件(短信)分类算法实现
    本文详细介绍了如何使用机器学习和深度学习技术对垃圾邮件和短信进行分类。内容涵盖从数据集介绍、预处理、特征提取到模型训练与评估的完整流程,并提供了具体的代码示例和实验结果。 ... [详细]
  • 实体映射最强工具类:MapStruct真香 ... [详细]
  • 本次考试于2016年10月25日上午7:50至11:15举行,主要涉及数学专题,特别是斐波那契数列的性质及其在编程中的应用。本文将详细解析考试中的题目,并提供解题思路和代码实现。 ... [详细]
  • 优化局域网SSH连接延迟问题的解决方案
    本文介绍了解决局域网内SSH连接到服务器时出现长时间等待问题的方法。通过调整配置和优化网络设置,可以显著缩短SSH连接的时间。 ... [详细]
  • 深入理解Redis的数据结构与对象系统
    本文详细探讨了Redis中的数据结构和对象系统的实现,包括字符串、列表、集合、哈希表和有序集合等五种核心对象类型,以及它们所使用的底层数据结构。通过分析源码和相关文献,帮助读者更好地理解Redis的设计原理。 ... [详细]
  • HBase运维工具全解析
    本文深入探讨了HBase常用的运维工具,详细介绍了每种工具的功能、使用场景及操作示例。对于HBase的开发人员和运维工程师来说,这些工具是日常管理和故障排查的重要手段。 ... [详细]
  • 并发编程:深入理解设计原理与优化
    本文探讨了并发编程中的关键设计原则,特别是Java内存模型(JMM)的happens-before规则及其对多线程编程的影响。文章详细介绍了DCL双重检查锁定模式的问题及解决方案,并总结了不同处理器和内存模型之间的关系,旨在为程序员提供更深入的理解和最佳实践。 ... [详细]
  • 本文探讨了《魔兽世界》中红蓝两方阵营在备战阶段的策略与实现方法,通过代码展示了双方如何根据资源和兵种特性进行战士生产。 ... [详细]
  • 最近团队在部署DLP,作为一个技术人员对于黑盒看不到的地方还是充满了好奇心。多次咨询乙方人员DLP的算法原理是什么,他们都以商业秘密为由避而不谈,不得已只能自己查资料学习,于是有了下面的浅见。身为甲方,虽然不需要开发DLP产品,但是也有必要弄明白DLP基本的原理。俗话说工欲善其事必先利其器,只有在懂这个工具的原理之后才能更加灵活地使用这个工具,即使出现意外情况也能快速排错,越接近底层,越接近真相。根据DLP的实际用途,本文将DLP检测分为2部分,泄露关键字检测和近似重复文档检测。 ... [详细]
  • 本文介绍了如何通过配置 Android Studio 和 Gradle 来显著提高构建性能,涵盖内存分配优化、并行构建和性能分析等实用技巧。 ... [详细]
  • dotnet 通过 Elmish.WPF 使用 F# 编写 WPF 应用
    本文来安利大家一个有趣而且强大的库,通过F#和C#混合编程编写WPF应用,可以在WPF中使用到F#强大的数据处理能力在GitHub上完全开源Elmis ... [详细]
  • 本文探讨了在Java中实现系统托盘最小化的两种方法:使用SWT库和JDK6自带的功能。通过这两种方式,开发者可以创建跨平台的应用程序,使窗口能够最小化到系统托盘,并提供丰富的交互功能。 ... [详细]
author-avatar
red_小火柿子
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有