为了一次存储便能得到所查记录,在记录的存储位置和它的关键字之间建立一个确定的对应关系H,已H(key)作为关键字为key的记录在表中的位置,这个对应关系H为哈希(Hash)函数, 按这个思路建立的表为哈希表。
哈希表也叫散列表。从根本上来说,一个哈希表包含一个数组,通过特殊的关键码(也就是key)来访问数组中的元素。
哈希表的主要思想:
(1)存放Value的时候,通过一个哈希函数,通过关键码(key)进行哈希运算得到哈希值,然后得到映射的位置, 去寻找存放值的地方 ,
(2)读取Value的时候,也是通过同一个哈希函数,通过关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置,从那个位置去读取。
哈希函数
哈希表的组成取决于哈希算法,也就是哈希函数的构成。哈希函数计算过程会将键转化为数组的索引。
一个好的哈希函数至少具有两个特征:
(1)计算要足够快;
(2)最小化碰撞,即输出的哈希值尽可能不会重复。
那接下来我们就来看下几个常见的哈希函数:
直接定址法
- 取关键字或关键字的某个线性函数值为散列地址。
- 即 f(key) = key 或 f(key) = a*key + b,其中a和b为常数。
除留余数法
将整数散列最常用方法是除留余数法。除留余数法的算法实用得最多。
我们选择大小为m的数组,对于任意正整数k,计算k除以m的余数,即f(key)=k%m,f(key)<m。这个函数的计算非常容易(在Java中为k% M)并能够有效地将键散布在0到M-1的范围内。
数字分析法
- 当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
- 仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。
平方取中法
- 先计算出关键字值的平方,然后取平方值中间几位作为散列地址。
- 随机分布的关键字,得到的散列地址也是随机分布的。
随机数法
- 选择一个随机函数,把关键字的随机函数值作为它的哈希值。
- 通常当关键字的长度不等时用这种方法。
每种数据类型都需要相应的散列函数.
例如,Interge的哈希函数就是直接获取它的值:
public static int hashCode(int value) {
return value;
}
对于字符串类型则是使用了s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]的算法:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
hash = h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
}
return h;
}
public static int hashCode(byte[] value) {
int h = 0;
for (byte v : value) {
h = 31 * h + (v & 0xff);
}
return h;
}
public static int hashCode(byte[] value) {
int h = 0;
int length = value.length >> 1;
for (int i = 0; i < length; i++) {
h = 31 * h + getChar(value, i);
}
return h;
}
double类型则是使用位运算的方式进行哈希计算:
public int hashCode() {
long bits = doubleToLongBits(value);
return (int)(bits ^ (bits >>> 32));
}
public static long doubleToLongBits(double value) {
long result = doubleToRawLongBits(value);
if ( ((result & DoubleConsts.EXP_BIT_MASK) == DoubleConsts.EXP_BIT_MASK)
&&
(result & DoubleConsts.SIGNIF_BIT_MASK) != 0L)
result = 0x7ff8000000000000L;
return result;
}
于是Java让所有数据类型都继承了超类Object类,并实现hashCode()方法。接下来我们看下Object.hashcode方法。Object类中的hashcode方法是一个native方法。
public native int hashCode();
hashCode 方法的实现依赖于jvm,不同的jvm有不同的实现,我们看下主流的hotspot虚拟机的实现。hotspot 定hashCode方法在src/share/vm/prims/jvm.cpp中,源码如下:
JVM_ENTRY(jint, JVM_IHashCode(JNIEnv* env, jobject handle))
JVMWrapper("JVM_IHashCode");
return handle == NULL ? 0 : ObjectSynchronizer::FastHashCode (THREAD, JNIHandles::resolve_non_null(handle)) ;
JVM_END
接下来我们看下ObjectSynchronizer::FastHashCode 方法是如何返回hashcode的,ObjectSynchronizer::FastHashCode 在synchronized.hpp文件中,
intptr_t ObjectSynchronizer::identity_hash_value_for(Handle obj) {
return FastHashCode (Thread::current(), obj()) ;
}
intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) {
if (UseBiasedLocking) {
if (obj->mark()->has_bias_pattern()) {
// Box and unbox the raw reference just in case we cause a STW safepoint.
Handle hobj (Self, obj) ;
// Relaxing assertion for bug 6320749.
assert (Universe::verify_in_progress() ||
!SafepointSynchronize::is_at_safepoint(),
"biases should not be seen by VM thread here");
BiasedLocking::revoke_and_rebias(hobj, false, JavaThread::current());
obj = hobj() ;
assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
}
}
ObjectMonitor* monitor = NULL;
markOop temp, test;
intptr_t hash;
// 获取调用hashCode() 方法的对象的对象头中的mark word
markOop mark = ReadStableMark (obj);
// object should remain ineligible for biased locking
assert (!mark->has_bias_pattern(), "invariant") ;
if (mark->is_neutral()) { //普通对象
hash = mark->hash(); // this is a normal header
//如果mark word 中已经保存哈希值,那么就直接返回该哈希值
if (hash) { // if it has hash, just return it
return hash;
}
// 如果mark word 中还不存在哈希值,那就调用get_next_hash(Self, obj)方法计算该对象的哈希值
hash = get_next_hash(Self, obj); // allocate a new hash code
// 将计算的哈希值CAS保存到对象头的mark word中对应的bit位,成功则返回,失败的话可能有几下几种情形:
//(1)、其他线程也在install the hash并且先于当前线程成功,进入下一轮while获取哈希即可
//(2)、有可能当前对象作为监视器升级成了轻量级锁或重量级锁,进入下一轮while走其他case;
temp = mark->copy_set_hash(hash); // merge the hash code into header
// use (machine word version) atomic operation to install the hash
test = (markOop) Atomic::cmpxchg_ptr(temp, obj->mark_addr(), mark);
if (test == mark) {
return hash;
}
// If atomic operation failed, we must inflate the header
// into heavy weight monitor. We could add more code here
// for fast path, but it does not worth the complexity.
} else if (mark->has_monitor()) { //重量级锁
// 果对象是一个重量级锁monitor,那对象头中的mark word保存的是指向ObjectMonitor的指针,
//此时对象非加锁状态下的mark word保存在ObjectMonitor中,到ObjectMonitor中去拿对象的默认哈希值:
monitor = mark->monitor();
temp = monitor->header();
assert (temp->is_neutral(), "invariant") ;
hash = temp->hash();
//(1)如果已经有默认哈希值,则直接返回;
if (hash) {
return hash;
}
// Skip to the following code to reduce code size
} else if (Self->is_lock_owned((address)mark->locker())) { //轻量级锁锁
//如果对象是轻量级锁状态并且当前线程持有锁,那就从当前线程栈中取出mark word:
temp = mark->displaced_mark_helper(); // this is a lightweight monitor owned
assert (temp->is_neutral(), "invariant") ;
hash = temp->hash(); // by current thread, check if the displaced
//(1)如果已经有默认哈希值,则直接返回;
if (hash) { // header contains hash code
return hash;
}
}
// Inflate the monitor to set hash code
monitor = ObjectSynchronizer::inflate(Self, obj);
// Load displaced header and check it has hash code
mark = monitor->header();
assert (mark->is_neutral(), "invariant") ;
hash = mark->hash();
//计算默认哈希值并保存到mark word中后再返回
if (hash == 0) {
hash = get_next_hash(Self, obj);
temp = mark->copy_set_hash(hash); // merge hash code into header
assert (temp->is_neutral(), "invariant") ;
test = (markOop) Atomic::cmpxchg_ptr(temp, monitor, mark);
if (test != mark) {
hash = test->hash();
assert (test->is_neutral(), "invariant") ;
assert (hash != 0, "Trivial unexpected object/monitor header usage.");
}
}
// We finally get the hash
return hash;
}
关于对象头、java内置锁的内容请阅读《高并发核心编程:卷2》。
ObjectSynchronizer :: FastHashCode()也是通过调用identity_hash_value_for方法返回值的. 调用了get_next_hash()方法生成hash值,源码如下:
static inline intptr_t get_next_hash(Thread * Self, oop obj) {
intptr_t value = 0 ;
if (hashCode == 0) { //随机数 openjdk6、openjdk7 采用的是这种方式
// This form uses an unguarded global Park-Miller RNG,
// so it's possible for two threads to race and generate the same RNG.
// On MP system we'll have lots of RW access to a global, so the
// mechanism induces lots of coherency traffic.
value = os::random() ;
} else
if (hashCode == 1) { //基于对象内存地址的函数
// This variation has the property of being stable (idempotent)
// between STW operations. This can be useful in some of the 1-0
// synchronization schemes.
intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3 ;
value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
} else
if (hashCode == 2) { //恒等于1(用于敏感性测试)
value = 1 ; // for sensitivity testing
} else
if (hashCode == 3) { //自增序列
value = ++GVars.hcSequence ;
} else
if (hashCode == 4) { //将对象的内存地址强转为int
value = cast_from_oop<intptr_t>(obj) ;
} else {
//生成hash值的方式六: Marsaglia's xor-shift scheme with thread-specific state
//(基于线程具体状态的Marsaglias的异或移位方案) openjdk8之后采用的就是这种方式
// Marsaglia's xor-shift scheme with thread-specific state
// This is probably the best overall implementation -- we'll
// likely make this the default in future releases.
unsigned t = Self->_hashStateX ;
t ^= (t << 11) ;
Self->_hashStateX = Self->_hashStateY ;
Self->_hashStateY = Self->_hashStateZ ;
Self->_hashStateZ = Self->_hashStateW ;
unsigned v = Self->_hashStateW ;
v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
Self->_hashStateW = v ;
value = v ;
}
value &= markOopDesc::hash_mask;
if (value == 0) value = 0xBAD ;
assert (value != markOopDesc::no_hash, "invariant") ;
TEVENT (hashCode: GENERATE) ;
return value;
}
到底用的哪一种计算方式,和参数hashCode有关系,在src/share/vm/runtime/globals.hpp中配置了默认:
openjdk6:
product(intx, hashCode, 0, \
"(Unstable) select hashCode generation algorithm") \
openkjdk8:
product(intx, hashCode, 5, \
"(Unstable) select hashCode generation algorithm") \
也可以通过虚拟机启动参数-XX:hashCode=n来做修改。
到这里你知道hash值是如何生成的了吧。
哈希表因为其本身结构使得查找对应的值变得方便快捷,但是也带来了一些问题,问题就是无论使用哪种方式生成hash值,总有产生相同值的时候。接下来我们就来看下如何解决hash值相同的问题。
hash 碰撞(哈希冲突)
对于两个不同的数据元素通过相同哈希函数计算出来相同的哈希地址(即两不同元素通过哈希函数取模得到了同样的模值),这种现象称为哈希冲突或哈希碰撞。
一般来说,哈希冲突是无法避免的。如果要完全避免的话,那么就只能一个字典对应一个值的地址,这样一来, 空间就会增大,甚至内存溢出。减少哈希冲突的原因是Hash碰撞的概率就越小,map的存取效率就会越高。 常见的哈希冲突的解决方法有开放地址法和链地址法:
开放地址法
开放地址法又叫开放寻址法、开放定址法,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。开放地址法需要的表长度要大于等于所需要存放的元素。
按照探测序列的方法,可以细分为线性探查法、平法探查法、双哈希函数探查法等。
这里为了更好的展示三种方法的效果,我们用例子来看看:设关键词序列为{47,7,29,11,9,84,54,20,30},哈希表长度为13,装载因子=9/13=0.69,哈希函数为f(key)=key%p=key%11
关键词(key) | 47 | 7 | 29 | 11 | 9 | 84 | 54 | 20 | 30 |
---|---|---|---|---|---|---|---|---|---|
散列地址k(key) | 3 | 7 | 7 | 0 | 9 | 7 | 10 | 9 | 8 |
(1)线性探测法
当我们的所需要存放值的位置被占了,我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。
公式:fi=(f(key)+i) % m ,0 ≤ i ≤ m-1i会逐渐递增加1)
具体做法: 探查时从地址d开始,首先探查T[d],然后依次探查T[d+1]…直到T[m-1],然后又循环到T[0]、T[1],…直到探查到有空余的地址或者直到T[d-1]为止。
用线性探测法处理冲突得到的哈希表如下
缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。
(2)平方探查法
当我们的所需要存放值的位置被占了,会前后寻找而不是单独方向的寻找。
公式:fi=(f(key)+di) % m,0 ≤ i ≤ m-1
具体操作:探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+di],di 为增量序列12、-12,22、-22, ……,q2、-q2 且q≤1/2 (m-1) ,直到探查到 有空余地址或者到 T[d-1]为止。
用平方探查法处理冲突得到的哈希表如下
(3)双哈希函数探查法
公式:fi=(f(key)+i*g(key)) % m (i=1,2,……,m-1)
其中f(key) 和g(key) 是两个不同的哈希函数,m为哈希表的长度。
具体步骤:
双哈希函数探测法,先用第一个函数f(key)对关键码计算哈希地址,一旦产生地址冲突,再用第二个函数 g(key)确定移动的步长因子,最后通过步长因子序列由探测函数寻找空的哈希地址。
开发地址法,通过持续的探测,最终找到空的位置。为了解决这个问题,引入了链地址法。
链地址法
在哈希表每一个单元中设置链表,某个数据项对的关键字还是像通常一样映射到哈希表的单元中,而数据项本身插入到单元的链表中.
链地址法简单理解如下:
来一个相同的数据,就将它插入到单元对应的链表中,在来一个相同的,继续给链表中插入。
链地址法解决哈希冲突的例子如下:
(1)采用除留余数法构造哈希函数,而 冲突解决的方法为 链地址法。
(2)具体的关键字列表为(19,14,23,01,68,20,84,27,55,11,10,79),则哈希函数为f(key)=key MOD 13。则采用除留余数法和链地址法后得到的预想结果应该为:
哈希造表完成后,进行查找时,首先是根据哈希函数找到关键字的位置链,然后在该链中进行搜索,如果存在和关键字值相同的值,则查找成功,否则若到链表尾部仍未找到,则该关键字不存在。
哈希表作为一个非常常用的查找数据结构,它能够在O(1)的时间复杂度下进行数据查找,时间主要花在计算hash值上。在Java中,典型的Hash数据结构的类是HashMap。
然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:
当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,
所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。