Hash的优劣及具体应用

前言

当进行数据查询时,数组可以直接通过下标迅速访问数组中的元素。而链表则需要从第一个元素开始一直找到需要的元素位置,显然,数组的查询效率会比链表的高。当进行增加或删除元素时,在数组中增加一个元素,需要移动大量元素,在内存中空出一个元素的空间,然后将要增加的元素放在其中。同样,如果想删除一个元素,需要移动大量元素去填掉被移动的元素。而链表只需改动元素中的指针即可实现增加或删除元素。Hash的出现,具备了数组的快速查询的优点,又融合链表方便快捷的增加删除元素的优势,是一种结合了数组和链表的优点的折中方案。

什么是hash

Hash中文翻译为散列,是一类函数的统称,特点是定义域无限,值域有限。把任意长度的输入(又叫做预映射, pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。若关键字为k,则其值存放在f(k)的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系f为散列函数,按这个思想建立的表为散列表。
对不同的关键字可能得到同一散列地址,即k1≠k2,而f(k1)=f(k2),这种现象称为碰撞,这时候,就产生了hash冲突。

解决冲突

当冲突发生时,我们需要想办法解决冲突,一般常用的方法有:开放定址法、链地址法。
1.开放定址法
当冲突发生时,探测其他位置是否有空地址 (按一定的增量逐个的寻找空的地址),将数据存入。根据探测时使用的增量的取法,分为:线性探测、平方探测、伪随机探测等。

新的Hash地址函数为 Hash_new (Key) = (Hash(Key) + d i) mod m;i = 1,2…k (k<= m-1).m表示集合的元素数,i表示已经探测的次数。

线性探测(Linear Probing)

d i = a * i + b; a\b为常数。

相当于逐个探测地址列表,直到找到一个空置的,将数据放入。

平方探测(Quadratic Probing)

d i = a * i 2 (i <= m/2) m是Key集合的总数。a是常数。

探测间隔 i2 个单元的位置是否为空,如果为空,将地址存放进去。

伪随机探测

d i = random(Key);

探测间隔为一个伪随机数。
2.链地址法
上面所说的开放定址法的原理是遇到冲突的时候查找顺着原来哈希地址查找下一个空闲地址然后插入,但有一个问题就是如果空间不足,就无法处理冲突也无法插入数据,因此需要装填因子(空间/插入数据)>=1,这时候就可以用链地址法。链地址法的原理时如果遇到冲突,他就会在原地址新建一个空间,然后以链表结点的形式插入到该空间。如图所示,现在有哈希算法为H(key)=key mod 16,也就是把不同数据插入到它们各自除以16得到的余数的结点后面,余数相同对应同一链表。
在这里插入图片描述

优缺点

优点:插入和查找速度快,时间复杂度为O(1)

缺点:

1. 扩展性差,需要提前预测数据量的大小
  
  2. 不能有序遍历数据

应用场景

Hash主要应用于数据结构中和密码学中。
  
  用于数据结构时,主要是为了提高查询的效率,这就对速度比较重视,对抗碰撞不太看中,只要保证hash均匀分布就可以。在密码学中,hash算法的作用主要是用于消息摘要和签名,换句话说,它主要用于对整个消息的完整性进行校验。

二、HashMap、HashTable、ConcurrentHashMap三者的区别

1.HashMap和HashTable的区别

从null的键值:HashMap键中只允许有一个为null,但是值可以多个为null,所以判断为空时get()方式得到的,可能是键为null,可能是值为null,应该用containsKey()方法来判断。HashTable键值都不能为null。
从安全性上考虑:HashMap底层没有多线程的机制,适用于单线程,在多线程环境下,操作HashMap会导致各种各样的线程安全问题,比如在HashMap扩容在哈希时出现的死循环问题,脏读问题等。Collections虽然提供了一个方法来为HashMap实现多线程操作的,但是在一定情况下是不安全的。它只是部分方法同步,但是整体的方法不同步。HashTable适用于多线程机制,所以在多线程条件下HashTable是线程安全的。 HashTable可以实现同步,但是效率低,因为它把整个hash表都锁住了。
在并发场景下,Hashtable和由同步包装器包装的HashMap(Collections.synchronizedMap(Map<K,V> m) )可以代替HashMap,但是它们都是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。

public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable,
java.io.Serializable {
 
public synchronized V put(K key, V value) {
    // 省略
 }
public synchronized V remove(Object key) {
   
 }
public synchronized V get(Object key) {
    // 省略
 }
public synchronized int size() {
    return count;
 }
}

从上面的源码可以看出,Hashtable操作数据的所有方法都是同步方法,都使用了锁,并且使用当前对象作为锁,如果同一时刻只能有一个线程操作Hashtable,其他线程会被阻塞等待。
Collections.synchronizedMap(Map<K,V> m) 的源码

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
  return new SynchronizedMap<>(m);
}

SynchronizedMap源码

private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {
private final Map<K,V> m;
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;  // 使用当前对象作为锁
}
public int size() {
synchronized (mutex) {return m.size();}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
}

通过SynchronizedMap的源码,我们也发现SynchronizedMap使用当前对象作为锁,同一时刻只能有一个线程操作SynchronizedMap,其他线程还是会被阻塞。Hashtable和SynchronizedMap使用synchronized来保证线程安全,使用当前对象作为锁。在线程竞争激烈的情况下效率非常低下。因为当一个线程访问Hashtable的同步方法时,其他线程访问Hashtable的同步方法可能会进入阻塞或轮询状态。如线程A使用put进行添加元素,线程B不但不能使用put方法添加元素,并且也不能使用get方法。

这时候我们就想,有没有一种既实现了同步,又效率高的呢,答案是ConcurrentHashMap,HashTable是锁住了整张hash表,一次只能又一个线程操作,在ConcurrentHashMap中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。特别地,在理想状态下,ConcurrentHashMap 可支持16个线程执行并发写操作,及任意数量线程的读操作,可以简单理解为ConcurrentHashMap是将hash表分成16个桶,锁机制只是锁住一个桶,所以这16个桶-可以同时操作,效率就明显提升了。
ConcurrentHashMap继承的结构图:
在这里插入图片描述

	(顺便提一下)jdk1.8中的HashMap解决了两大问题:
	1.解决了jdk1.7中的HashMap在并发场景下造成死循环的问题;
	2.解决了jdk1.7中HashMap查询效率低的问题(通过红黑树);

2.ConcurrentHashMap底层原理

锁分段技术 :Hashtable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问Hashtable的线程都必须竞争同一把锁。假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
在这里插入图片描述
ConcurrentHashMap的put方法源码

public V put(K key, V value) {
  if (value == null)
    throw new NullPointerException();
  int hash = hash(key.hashCode());
  return segmentFor(hash).put(key, hash, value, false);
}

ConcurrentHashMap的put操作被ConcurrentHashMap委托给特定的 Segment 段来实现。也就是说,当我们向ConcurrentHashMap中put一个Key/Value对时,首先会获得Key的哈希值并对其再次哈希,然后根据最终的hash值定位到这条记录所应该插入的段,定位段的segmentFor()方法源码如下:

final Segment<K,V> segmentFor(int hash) {
   return segments[(hash >>> segmentShift) & segmentMask];
}

根据key的hash值的高n位就可以确定元素到底在哪一个Segment中。紧接着,调用这个段的put()方法来将目标Key/Value对插入到Segment段中,Segment段的put()方法的源码如下所示:

V put(K key, int hash, V value, boolean onlyIfAbsent) {
   lock(); // 上锁
   try {
     int c = count;
     if (c++ > threshold) // ensure capacity
       rehash();
     HashEntry<K,V>[] tab = table;  // table是Volatile的
     int index = hash & (tab.length - 1);  // 定位到段中特定的桶
     HashEntry<K,V> first = tab[index];  // first指向桶中链表的表头
     HashEntry<K,V> e = first;
     // 检查该桶中是否存在相同key的节点
     while (e != null && (e.hash != hash || !key.equals(e.key))) 
       e = e.next;
     V oldValue;
     if (e != null) { // 该桶中存在相同key的节点
       oldValue = e.value;
       if (!onlyIfAbsent)
         e.value = value; // 更新value值
    }else { // 该桶中不存在相同key的节点
       oldValue = null;
       ++modCount;
       tab[index] = new HashEntry<K,V>(key, hash, first, value); // 创建HashEntry并将其链到表头
       count = c;
    }
     return oldValue; // 返回旧值(该桶中不存在相同key的结点,则返回null)
  } finally {
     unlock(); // 在finally子句中解锁
  }
}

ConcurrentHashMap的get方法源码 当我们从ConcurrentHashMap中查询一个指定Key的键值对时,首先会
定位其应该存在的段,然后查询请求委托给这个段进行处理,源码如下:

public V get(Object key) {
  int hash = hash(key.hashCode());
  return segmentFor(hash).get(key, hash);
}

Segment中get操作的源码:

V get(Object key, int hash) {
  if (count != 0) {  // read-volatile,首先读count变量
    HashEntry<K,V> e = getFirst(hash);  // 获取桶中链表头结点
    while (e != null) {
      if (e.hash == hash && key.equals(e.key)) {  // 查找链中是否存在指定Key的键值对
        V v = e.value;
        if (v != null)  // 如果读到value域不为 null,直接返回
          return v; 
        return readValueUnderLock(e); // recheck
     }
      e = e.next;
      }
 }
  return null;  // 如果不存在,直接返回null
}

jdk1.7到1.8,ConcurrentHashMap的变化:
锁方面: 由分段锁(Segment继承自ReentrantLock)转为 CAS+synchronized实现;

注:synchronized之前一直都是重量级的锁,性能差,但是后来java官方对它进行了优化:针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程,然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的(偏向锁–>CAS轻量级锁–>自旋–>重量级锁)。

数据结构层面: 将Segment变为了Node,减小了锁粒度,使每个Node独立,由原来默认的并发度16变成了每个Node都独立,提高了并发度;
hash冲突: 1.7中发生hash冲突采用链表存储,1.8中先使用链表存储,后面满足条件后会转换为红黑树来优化查询;

注:根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。

查询复杂度: jdk1.7中链表查询复杂度为O(N),jdk1.8中红黑树优化为O(logN));

总结

Hashtable和SynchronizedMap使用synchronized来保证线程安全,使用当前对象作为锁,同一时刻只能有一个线程操作,其他线程会被阻塞,所以竞争越激烈效率越低。ConcurrentHashMap无论是读操作还是写操作都具有很高的性能:在进行读操作时不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响对其它段的访问。 ConcurrentHashMap只能保证单个方法是同步的,不能保证先读后写的原子性。

参考:https://blog.csdn.net/weixin_43718267/article/details/89419899

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值