深入剖析HashSet和HashMap实现

HashSet是一个包含非重复元素的集合,如何实现的,要从底层实现代码看起。

背景

首先非重复元素如何定义,看Set的描述:

More formally, sets contain no pair of elements e1 and e2 such that e1.equals(e2), and at most one null element.

Set不会找到两个元素,并且两个元素满足e1.equals(e2)为true;并且最多只有一个null元素。

如果没有重写equals方法,查看Object类中equal方法的实现,==比较的其实是两个对象在内存中的地址。

public boolean equals(Object obj) {
    return (this == obj);
}

说起equals方法,就不得不说hashCode方法了。Java中对于hashCode有个常规协定

The general contract of hashCode is:

  • Whenever it is invoked on the same object more than once during an execution of a Java application, the hashCode method must consistently return the same integer, provided no information used in equals comparisons on the object is modified. This integer need not remain consistent from one execution of an application to another execution of the same application.
  • If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
  • It is not required that if two objects are unequal according to the equals(java.lang.Object) method, then calling the hashCode method on each of the two objects must produce distinct integer results. However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.
  • 程序执行期间,在同一个对象上执行多次hashCode方法,都返回相同的整数,前提是equals比较中所使用的字段没有被修改。跨应用中的hashCode方法调用返回的整数不要求相同。
  • 如果两个对象根据equals方法比较相同,那hashCode返回的整数也必须相同。
  • 如果两个对象equals方法比较不相同,调用hashCode返回的整数不需要不同。但是程序员应该知道为不相等的对象生成不同的整数可以提高哈希表的性能。

HashSet的底层实现

HashSet的底层是通过HashMap实现的,将元素作为map的key以达到去重的目的,value使用的是同一个虚拟的Object实例。

private transient HashMap<E,Object> map;

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

public boolean add(E e) {
   return map.put(e, PRESENT)==null;
}

HashMap的底层实现

到最后我们要看HashMap的实现了,简单说就是一个数组+链表的结合。

  • 默认初始容量16
  • 默认负荷系数0.75
  • Entry数组
  • 大小
  • 阈值:初始值等于初始容量
  • 负荷系数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final Entry<?,?>[] EMPTY_TABLE = {};
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size;
int threshold;
final float loadFactor;
Entry元素

Entry是链表的结果,key为Map中的key,value为Map中的value,hash为key的hash结果,next为下一个元素。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}
添加元素
  • 如果数组为空(即map初始化后第一次添加元素)扩充table
  • 如果key为null,则调用putForNullKey方法,null位于table的下标0处
  • 算出key的hash值
  • 通过hash值算出元素在table中的下标值
    • 如果该位置元素不为空,然后需要比较元素的hash值和上面算出的hash值是否相等,同时元素的key对象和要出入的key是否为同一对象(相同的地址 ==比较为true)或者equals方法是否为true。如果满足条件,则更新该entry的value值;若不满足则遍历整个链表。
    • 如果为空直接添加新的entry。
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}
扩充table

对toSzie算出最小的2的幂值,用了Integer.highestOneBit((toSize -1) << 1)。减一之后左移一位,然后取最高位值,其余为补0。

为什么数组长度必须为2的幂值,请继续看。

/**
 * 扩充table
**/
private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}
计算hash值

hashSeed值为0,将key的hashCode值做多次位移和异或运算

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    h ^= k.hashCode();
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
计算元素位置

这里的逻辑很简单:将hash值跟数组长度-1做了按位与。

在进行查找的时候是通过key的hash值,如果我们将元素的位置分布得尽量均匀一些,尽量做到每个位置上只有一个元素,达到O(1)的查找。这种查找通过取余就可以做到,在Java中如何做到比较快的取余呢,答案是位与运算。

上面扩充数组的时候我们保证长度为2的幂值,那减一之后就是每位都是1。做位与运算就能保证低位不同的hash值会落在不同的位置上,降低冲突(碰撞),最大程度做到均匀分布,减少链表的出现(查找变成O(n))。

static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}
添加entry

添加新的元素时要检查元素个数是否达到阈值,否则要做扩容处理,新table的容量为当前table长度的两倍。

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}
resize

新table的容量为当前table长度的两倍(table.length >= size),将旧数据中的数据迁移到新的数组中,迁移的过程中要重新计算元素在新数组中的位置。网上很多地方提到这个操作rehash,但我觉得reindex反而更恰当一些。JDK中对rehash有额外的定义,就是initHashSeedAsNeeded。当新的容量>=jdk.map.althashing.threshold的配置时,会重新计算key的hash值,即hash(e.key)。

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
reindex
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

原文链接

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

推荐阅读更多精彩内容

  • 实际上,HashSet 和 HashMap 之间有很多相似之处,对于 HashSet 而言,系统采用 Hash 算...
    曹振华阅读 2,499评论 1 37
  • 一、基本数据类型 注释 单行注释:// 区域注释:/* */ 文档注释:/** */ 数值 对于byte类型而言...
    龙猫小爷阅读 4,214评论 0 16
  • 1.什么是HashMap 基于哈希表的Map接口的非同步实现 此实现提供所有可选的映射操作,并允许使用null值和...
    苍賢阅读 501评论 0 1
  • 见天
    黄梦仙阅读 147评论 0 0
  • 他或许会停在雨季 寒冬的风里裹着畸形的雨 带着痛苦,也带着恨意 想起你 他会停在雨季 那些如竹笋般放肆生长的 忘记...
    啤酒瘦肉阅读 251评论 0 3