Java - HashMap 知识要点

1 散列表(哈希表)

研究HashMap前需要对散列表(Hash Table)有一定的理解。散列表是一种根据关键值(Key value)直接进行访问的数据结构。

我们知道在一个数组中,通过下标去查找一个数只需要O(1)的时间复杂度。散列表正是运用了数组的这个按下标随机访问的特性,实现快速查找数据的目的。

散列表中的关键概念

  • 键,关键字:现实中查询的根据,要求唯一性,比如员工的编号
  • 散列函数:将关键字映射为“数组下标”的方法
  • 散列值:关键字通过散列函数得到的值,相当于数组下标

散列函数
在真实的情况下,想要找到一个不同key与不同散列值一一对应的散列函数是几乎不可能的。

打个比方,桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面放不少于两个苹果。这就是抽屉原理。

也就是说,总会有不同的key值,通过散列函数映射到相同的散列值中去。这就是我们说的产生了散列冲突碰撞。

散列冲突
既然无法避免冲突碰撞,那么如何在计算出相同的散列值后,得到真正对应的要找的元素呢?

常用的散列冲突解决办法有两个

  • 开放寻址法(open addressing)
  • 链表法(chaining)

开放寻址法
Java中的ThreadLocalMap采用的就是“线性探测”的开放寻址法解决冲突碰撞问题。核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,再将这个值插入。

最简单的开放寻址法是“线性探测”。如下图所示,散列表大小为10,深色位置是已经有元素了,浅色位置是空闲位置。x经过散列函数得到7,但是7这个位置已经有元素了,那么线性一路向下,到达9位置又返回开头一路向下,最终发现下标为2的位置是空闲的,我们将x插入进去。


image.png

除了线性探测外,还有“二次探测”、“双重散列”等开发寻址法,都是为了能够找到空闲位置。由此可见,当空闲位置不多的时候,冲突概率就非常大。所以有关散列的数据结构中常常会引入“负载因子Load Factor”来控制散列表的操作效率,留出一定比例的空闲位置, 负载因子=填充的元素个数/散列表的长度。

当数量比较小、装载因子小的时候,适合采用开放寻址法。

链表法
链表法是最常见的解决冲突的方法。

image.png

如上图所示,每个桶中都对应一条链表。当计算出的散列值相同,那么就插入到对应桶的链表中。

极端情况下,恶意攻击者故意构造的情况下,链表法的哈希表最终退化成长长长长长长的链表,查询的时间复杂度就变成了O(n)。想想如果这个链表串了十万个数据,原来只需要几毫秒就获取结果,现在可能需要上万秒。这样可能导致系统在查询操作上就消耗大量的CPU资源,无法对其他请求进行响应,造成拒绝服务攻击(DDos)。

数组+链表的形式是链表法的经典实现,Java7中的HashMap采用的就是这种方法。事实上,链表可以改造成其他高效的动态数据结构,比如跳表、红黑树。这样,极端情况下退化的查询时间复杂度也优化为O(logn)了。Java8的HashMap采用的就是数组+链表/红黑树 的数据结构,当链表长度太长(默认超过8)且桶的数量大于等于64的时候,链表就转换为红黑树。当链表结点数少于6个的时候,红黑树又转化为链表。

当数据量大、存储对象大的时候,适合链表法。

2 HashMap的基本要点

默认初始容量:
Java 7/8都相同

虽然说这是默认初始容量,但HashMap是采用的lazy-load 原则,在首次使用put时才被初始化

// 默认16,必须是2的幂 MUST be a power of two.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

默认负载因子:
Java 7/8都相同

static final float DEFAULT_LOAD_FACTOR = 0.75f;

如何扩容

扩容发生在两种情况下:

  1. 当前元素个数超过 capacity * load_factor 时就要扩容了
    2.某个桶中链表数量超过8,但是发现桶的数量小于64,那么发生冲突的原因其实是桶太小

每次扩容会扩容原来的两倍大小,并且所有元素全部rehash到新数组中。这点和Redis不同,Redis扩容会维护一大一小两个数组,分批地Rehash数据。
newCap = oldCap<<1;

题外话,ArrayList采用的lazy-load 原则默认初始化大小为0, 一旦add进一个数,容量会变为10。扩容方案是原数组的1.5倍大小,也就是说在加入第11个数的时候,容量会变为15。

3 HashMap 的 Hash函数

Java7 的hash函数太复杂了,而且里面为了避免人为构造出长链表产生安全隐患,写了个丑陋的补丁。8为了执行效率直接就不要了。以下是Java8的Hash函数

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hashCode是一个32位的整数型,向右移16位,就只剩下高16位的内容,然后又与32位的h进行异或运算,其实相当于把高16位“折叠”到低16位上,或者说把高16位的任何变化反应到低16位上。

可以这么理解,首先,32位的hashCode提供了40亿种可能性,能够减少碰撞。但是40亿实在太长了,不适合做数组下标。于是我们考虑截取32位中的低位做index。

单单只考虑低位的话,其实会有很多碰撞的,先不说完全一样的hashCode,也会有很多hashCode可能高位不同低位相同。怎么办呢?这里异或的巧妙之处在于,两个hashCode哪怕低位相同,高位只有一个值不同,那么异或后得到的值也不同。这样就有效减少了碰撞。

举一个8位数右移4位后再与原来8位取异或的例子,8位数的高4位的变化,通过异或运算全部反应到低4位上了:


image.png

数组大小为什么一定是2的幂
关注与put()函数相关的方法,通过hash()函数得到hash值后,我们需要决定这个元素丢到哪个桶(bin)里,也就是说求得数组的index。我们常见的用百分号%的取模运算太慢,而且无法处理hash值是负数的情况。

而java中是使用低位全为1的数(power-of-two masking)与hashCode进行按位与操作,截取hash值尾部的值。本质上也是“除留余数法”。

Java7中有这样的语句

static int indexFor(int h, int length) {
  return h & (length - 1);
}

Java8中有这样的语句

i= (n-1) & hash

为了hash函数计算时,通过一个值和2^n-1也就是末位全为1的数进行按位与&操作。

4 HashMap 的线程安全性

为什么是线程不安全的
线程不安全主要和put()方法有关

  1. 当A线程put一个元素,发现一个空闲位置时,A线程的时间片用完了。此时B线程同样准备put一个元素,计算出来的位置也是这个位置,插入进去后,A线程运行,仍然认为这个地方是个空闲位置,就直接覆盖了B线程插入的数据。
  2. 在扩容后transfer的过程中(新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中),如果两个线程同时做transfer方法,会产生环形链表,可能导致死循环,CPU到100% 疫苗:JAVA HASHMAP的死循环

想要线程安全,应该用ConcurrentHashMap

  • Java1.7中 ConcurrentHashMap中使用了segment分段锁
  • Java8中改为CAS

5 HashMap 的历史改进

Java7 的HashMap的问题

  • 产生环形链表,可能导致死循环,CPU到100% 疫苗:JAVA HASHMAP的死循环

  • 潜在安全隐患 CVE-2011-4858

    • Apache Tomcat 5.5.35之前版本,6.0.35之前的6.x版本以及7.0.23之前的7.x版本中存在的安全隐患。当远程攻击者通过HTTP发送多个特制参数,导致HashMap中链表非常长,最终导致拒绝服务。

7到8做了哪些改进

//由链表转成红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
//由红黑树转成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
  • 数据结构
    • 7 的时候是经典的数组+链表
    • 8 的时候是数组+链表/红黑树
  • 扩容时插入顺序的改进
    • 7 的时候是头插法,8的时候是尾插法
    • 虽然有意按照顺序插入了,但是也无法从根本上解决线程不安全的问题。只是把死锁的概率降低了
  • 函数方法,配合Java8引入的Lambda 表达式
  • 新API

6 LinkedHashMap

LinkedHashMap这个名字常常让人误解,以为是通过链表法解决冲突的散列表。

如果你这样想,就把LinkedHashMap想得太简单了。既然只是实现了链表法,为啥不直接用HashMap呢。

简单点说,如果你知道LRU(Least Recently Used 最近最少使用)缓存淘汰算法的话,LinkedHashMap其实就是LRU的实现。

LinkedHashMap继承了HashMap,所以它里面的链表也可以转化为红黑树。但是这里的链表不是单链表,而是双向链表了。HashMap的Node值有成员hash,key,value,next,LinkedHashMap的Node在此基础上添加了两个成员before 和 after。双向链表的before和after维护的并不是一个桶bin中链表的前后关系,而是维护的结点插入顺序(或者访问顺序)。

image.png

按插入顺序排列的LinkedHashMap
维护了元素的插入顺序(insertion-order),哪怕后面出现相同key值覆盖原来的值,插入顺序仍然不变。

import java.util.HashMap;
import java.util.LinkedHashMap;

public class Main {
    public static void main(String[] args) {
        HashMap<String,Integer> mymap = new LinkedHashMap<>();
        mymap.put("a",1);
        mymap.put("b",2);
        mymap.put("c",3);
        mymap.put("d",4);


        mymap.put("a",9);
        mymap.get("c");
        mymap.forEach( (k,v)-> System.out.println("key=" + k + " value=" + v));
    }
}

运行后得到如下结果,按照a,b,c,d的插入顺序输出:


image.png

按访问顺序排列的LinkedHashMap
使用下面的构造器,accessOrder设置为true,可以使得输出的顺序是:之前访问的排在前面,最近使用的排在后面。这就是我们说的LRU cache实现。

/**
     * Constructs an empty <tt>LinkedHashMap</tt> instance with the
     * specified initial capacity, load factor and ordering mode.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @param  accessOrder     the ordering mode - <tt>true</tt> for
     *         access-order, <tt>false</tt> for insertion-order
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

例子

import java.util.HashMap;
import java.util.LinkedHashMap;

public class Main {
   public static void main(String[] args) {
       HashMap<String,Integer> mymap = new LinkedHashMap<>(16,0.75f,true);
       mymap.put("a",1);
       mymap.put("b",2);
       mymap.put("c",3);
       mymap.put("d",4);


       mymap.put("a",9);
       mymap.get("c");
       mymap.forEach( (k,v)-> System.out.println("key=" + k + " value=" + v));
   }
}

得到结果:


image.png

我们可以看到,最近使用的是get("c")方法,也就是说c是最后访问的,排在最后,a是倒是第二访问的,所以排在倒数第二的位置,依此类推。

注意LinkedHashMap同样是线程不安全的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,451评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,172评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,782评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,709评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,733评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,578评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,320评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,241评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,686评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,878评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,992评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,715评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,336评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,912评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,040评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,173评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,947评论 2 355

推荐阅读更多精彩内容