Java HashMap透析
HashMap 是数组和链表组合组成的复杂结构,哈希值决定了键值在数组的位置,当哈希值相同时则以链表形式存储,当链表长度到达设定的阈值则会对其进行树化,这样做是为了保证数据安全和数据相关操作的效率
HashMap 性能表现取决于哈希码的有效性,所以 hashCode 和 equals 的基本约定规则尤为重要,如:equals 相等,hashCode 一定要相等;重写了 hashCode 也要重写 equals;hashCode 需要保持一致性,状态改变返回的哈希值仍然要一致;equals 的对称、反射、传递等特性
HashMap 与 Hashtable、TreeMap 的区别
HashMap:基于数组的非同步哈希表,支持 null 键或值,是键值对存取数据场景的首选
Hashtable:基于数组的同步哈希表,不支持null键或值,因为同步导致性能影响,很少被使用
TreeMap:基于红黑树提供顺序访问的 Map,比 HashMap 节省空间,但它的数据操作(查、增、删)时间复杂度均为:O(log(n)),这点与 HashMap 不同。支持空值,当键为空时且未实现 Comparator 接口,会出现 NullPointerException ,实现了 Comparator 接口并对 null 对象进行判断可实现正常存入
HashMap、Hashtable、TreeMap 均以键值对形式存储或操作数据元素。HashMap、TreeMap 继承自 AbstractMap 类,Hashtable 继承自 Dictionary 类,三者均实现 Map 接口
HashMap 源码解析
HashMap()
public HashMap(int initialCapacity, float loadFactor){ // ... this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
初始化 HashMap 时仅设置了一些初始值,但在开始处理数据时,如 .put() 方法内渐渐开始复杂起来
HashMap.put()
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { // 定义新tab数组及node对象 Node<K,V>[] tab; Node<K,V> p; int n, i; // 如果原table是空的或者未存储任何元素则需要先初始化进行tab的初始化 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 当数组中对应位置为null时,将新元素放入数组中 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 若对应位置不为空时处理哈希冲突 else { Node<K,V> e; K k; // 1 - 普通元素判断: 更新数组中对应位置数据 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 2 - 红黑树判断:当p为树的节点时,向树内插入节点 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 3 - 链表判断:插入节点 else { for (int binCount = 0; ; ++binCount) { // 找到尾结点并插入 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 判断链表长度是否达到树化阈值,达到就对链表进行树化 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } // 更新链表中对应位置数据 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 如果存在这个映射就覆盖 if (e != null) { // existing mapping for key V oldValue = e.value; // 判断是否允许覆盖,并且value是否为空 if (!onlyIfAbsent || oldValue == null) e.value = value; // 回调以允许LinkedHashMap后置操作 afterNodeAccess(e); return oldValue; } } // 更新修改次数 ++modCount; // 检查数组是否需要进行扩容 if (++size > threshold) resize(); // 回调以允许LinkedHashMap后置操作 afterNodeInsertion(evict); return null; }
当 table 为 null,会通过 resize() 初始化,且 resize() 有两个作用,一是创建并初始化 table ,二是在 table 容量不满足需求时进行扩容:
if (++size > threshold) resize();
具体的键值对存储位置计算方法为:
if ((p = tab[i = (n - 1) & hash]) == null) // 向数组赋值新元素 tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 如果新插入的结点和table中p结点的hash值,key值相同的话 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 如果是红黑树结点的话,进行红黑树插入 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { // 代表这个单链表只有一个头部结点,则直接新建一个结点即可 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 链表长度大于8时,将链表转红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 及时更新p p = e; } } // 如果存在这个映射就覆盖 if (e != null) { // existing mapping for key V oldValue = e.value; // 判断是否允许覆盖,并且value是否为空 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); // 回调以允许LinkedHashMap后置操作 return oldValue; } }
留意 .put() 方法中的 hash 计算,它并不是 key 的 hashCode ,而是将 key 的 hashCode 高位数据移位到低位进行异或运算,这样一些计算出来的哈希值主要差异在高位时的数据,就不会因 HashMap 里哈希寻址时被忽略容量以上的高位,那么即可有效避免此类情况下的哈希碰撞
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
HashMap.resize()
final Node<K,V>[] resize() { // 把当前底层数组赋值给oldTab,为数据迁移工作做准备 Node<K,V>[] oldTab = table; // 获取当前数组的大小,等于或小于0表示需要初始化数组,大于0表示需要扩容数组 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取扩容的阈值(容量*负载系数) int oldThr = threshold; // 定义并初始化新数组长度和目标阈值 int newCap, newThr = 0; // 判断是初始化数组还是扩容,等于或小于0表示需要初始化数组,大于0表示需要扩容数组。若 if(oldCap > 0)=true 表示需扩容而非初始化 if (oldCap > 0) { // 判断数组长度是否已经是最大,MAXIMUM_CAPACITY =(2^30) if (oldCap >= MAXIMUM_CAPACITY) { // 阈值设置为最大 threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 目标阈值扩展2倍,数组长度扩展2倍 newThr = oldThr << 1; // double threshold } // 表示需要初始化数组而不是扩容 else if (oldThr > 0) // 说明调用的是HashMap的有参构造函数,因为无参构造函数并没有对threshold进行初始化 newCap = oldThr; // 表示需要初始化数组而不是扩容,零初始阈值表示使用默认值 else { // 说明调用的是HashMap的无参构造函数 newCap = DEFAULT_INITIAL_CAPACITY; // 计算目标阈值 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 当目标阈值为0时需重新计算,公式:容量(newCap)*负载系数(loadFactor) if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 根据以上计算结果将阈值更新 threshold = newThr; // 将新数组赋值给底层数组 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // ------------------------------------------------------------------------------------- // 此时已完成初始化数组或扩容数组,但原数组内的数据并未迁移至新数组(扩容后的数组),之后的代码则是完成原数组向新数组的数据迁移过程 // ------------------------------------------------------------------------------------- // 判断原数组内是否有存储数据,有的话开始迁移数据 if (oldTab != null) { // 开始循环迁移数据 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; // 将数组内此下标中的数据赋值给Node类型的变量e,并判断非空 if ((e = oldTab[j]) != null) { oldTab[j] = null; // 1 - 普通元素判断:判断数组内此下标中是否只存储了一个元素,是的话表示这是一个普通元素,并开始转移 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; // 2 - 红黑树判断:判断此下标内是否是一颗红黑树,是的话进行数据迁移 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 3 - 链表判断:若此下标内包含的数据既不是普通元素又不是红黑树,则它只能是一个链表,进行数据转移 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } // 返回初始化完成或扩容完成的新数组 return newTab; }
容量和负载系数决定了数组容量,空余太多会造成空间浪费,使用太满会影响操作性能
如果能够明确知道 HashMap 将要存取的键值对的数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:负载因子 * 容量 > 元素数量
所以,预先设置的容量需要满足,大于 预估元素数量 / 负载因子,同时它是 2 的幂数
但需要注意的是:
如果没有特别需求,不要轻易进行更改,因为 JDK 自身的默认负载因子是非常符合通用场景的需求的。如果确实需要调整,建议不要设置超过 0.75 的数值,因为会显著增加冲突,降低 HashMap 的性能。如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。
HashMap.get()
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // 将table赋值给变量tab并判断非空 && tab 的厂部大于0 && 通过位运算得到求模结果确定链表的首节点赋值并判断非空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 判断首节点hash值 && 判断key的hash值(地址相同 || equals相等)均为true则表示first即为目标节点直接返回 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 若首节点非目标节点,且还有后续节点时,则继续向后寻找 if ((e = first.next) != null) { // 1 - 树:判断此节点是否为树的节点,是的话遍历树结构查找节点,查找结果可能为null if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 2 - 链表:若此节点非树节点,说明它是链表,遍历链表查找节点,查找结果可能为null do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
HashMap 为什么会被树化
为了保证数据安全及相关操作效率
因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能
而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件
Atas ialah kandungan terperinci Java HashMap透析. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!

Alat AI Hot

Undresser.AI Undress
Apl berkuasa AI untuk mencipta foto bogel yang realistik

AI Clothes Remover
Alat AI dalam talian untuk mengeluarkan pakaian daripada foto.

Undress AI Tool
Gambar buka pakaian secara percuma

Clothoff.io
Penyingkiran pakaian AI

AI Hentai Generator
Menjana ai hentai secara percuma.

Artikel Panas

Alat panas

Notepad++7.3.1
Editor kod yang mudah digunakan dan percuma

SublimeText3 versi Cina
Versi Cina, sangat mudah digunakan

Hantar Studio 13.0.1
Persekitaran pembangunan bersepadu PHP yang berkuasa

Dreamweaver CS6
Alat pembangunan web visual

SublimeText3 versi Mac
Perisian penyuntingan kod peringkat Tuhan (SublimeText3)

Topik panas



Panduan Nombor Sempurna di Jawa. Di sini kita membincangkan Definisi, Bagaimana untuk menyemak nombor Perfect dalam Java?, contoh dengan pelaksanaan kod.

Panduan untuk Penjana Nombor Rawak di Jawa. Di sini kita membincangkan Fungsi dalam Java dengan contoh dan dua Penjana berbeza dengan contoh lain.

Panduan untuk Weka di Jawa. Di sini kita membincangkan Pengenalan, cara menggunakan weka java, jenis platform, dan kelebihan dengan contoh.

Panduan untuk Nombor Smith di Jawa. Di sini kita membincangkan Definisi, Bagaimana untuk menyemak nombor smith di Jawa? contoh dengan pelaksanaan kod.

Dalam artikel ini, kami telah menyimpan Soalan Temuduga Spring Java yang paling banyak ditanya dengan jawapan terperinci mereka. Supaya anda boleh memecahkan temuduga.

Java 8 memperkenalkan API Stream, menyediakan cara yang kuat dan ekspresif untuk memproses koleksi data. Walau bagaimanapun, soalan biasa apabila menggunakan aliran adalah: bagaimana untuk memecahkan atau kembali dari operasi foreach? Gelung tradisional membolehkan gangguan awal atau pulangan, tetapi kaedah Foreach Stream tidak menyokong secara langsung kaedah ini. Artikel ini akan menerangkan sebab -sebab dan meneroka kaedah alternatif untuk melaksanakan penamatan pramatang dalam sistem pemprosesan aliran. Bacaan Lanjut: Penambahbaikan API Java Stream Memahami aliran aliran Kaedah Foreach adalah operasi terminal yang melakukan satu operasi pada setiap elemen dalam aliran. Niat reka bentuknya adalah

Panduan untuk TimeStamp to Date di Java. Di sini kita juga membincangkan pengenalan dan cara menukar cap waktu kepada tarikh dalam java bersama-sama dengan contoh.

Kapsul adalah angka geometri tiga dimensi, terdiri daripada silinder dan hemisfera di kedua-dua hujungnya. Jumlah kapsul boleh dikira dengan menambahkan isipadu silinder dan jumlah hemisfera di kedua -dua hujungnya. Tutorial ini akan membincangkan cara mengira jumlah kapsul yang diberikan dalam Java menggunakan kaedah yang berbeza. Formula volum kapsul Formula untuk jumlah kapsul adalah seperti berikut: Kelantangan kapsul = isipadu isipadu silinder Dua jumlah hemisfera dalam, R: Radius hemisfera. H: Ketinggian silinder (tidak termasuk hemisfera). Contoh 1 masukkan Jejari = 5 unit Ketinggian = 10 unit Output Jilid = 1570.8 Unit padu menjelaskan Kirakan kelantangan menggunakan formula: Kelantangan = π × r2 × h (4
