HashMap是一种十分常用的数据结构,作为一个应用开发人员,对其原理、实现的加深理解有助于更高效地进行数据存取。本文所用的jdk版本为1.5。
使用HashMap
《Effective JAVA》中认为,99%的情况下,当你覆盖了equals方法后,请务必覆盖hashCode方法。默认情况下,这两者会采用Object的“原生”实现方式,即:
-
protected
native
int
hashCode();
-
public
boolean
equals(Object obj) {
-
return
(
this
== obj);
-
}
hashCode方法的定义用到了native关键字,表示它是由C或C++采用较为底层的方式来实现的,你
可以认为它返回了该对象的内存地址;而缺省equals则认为,只有当两者引用同一个对象时,才认为它们是相等的。如果你只是覆盖了equals()而没
有重新定义hashCode(),在读取HashMap的时候,除非你使用一个与你保存时引用完全相同的对象作为key值,否则你将得不到该key所对应
的值。
另一方面,你应该尽量避免使用“可变”的类作为HashMap的键。如果你将一个对象作为键值并保存在HashMap中,之后又改变了其状态,那么HashMap就会产生混乱,你所保存的值可能丢失(尽管遍历集合可能可以找到)。
HashMap存取机制
Hashmap实际上是一个数组和链表的结合体,利用数组来模拟一个个桶(类似于Bucket Sort)以快速存取不同hashCode的key,对于相同hashCode的不同key,再调用其equals方法从List中提取出和key所相对应的value。
Java中hashMap的初始化主要是为initialCapacity和loadFactor这两个属性
赋值。前者表示hashMap中用来区分不同hash值的key空间长度,后者是指定了当hashMap中的元素超过多少的时候,开始自动扩容,。默认情
况下initialCapacity为16,loadFactor为0.75,它表示一开始hashMap可以存放16个不同的hashCode,当填充
到第12个的时候,hashMap会自动将其key空间的长度扩容到32,以此类推;这点可以从源码中看出来:
-
void
addEntry(
int
hash, K key, V value,
int
bucketIndex) {
-
Entry<K,V> e = table[bucketIndex];
-
table[bucketIndex] = new
Entry<K,V>(hash, key, value, e);
-
if
(size++ >= threshold)
-
resize(2
* table.length);
-
}
而每当hashMap扩容后,内部的每个元素存放的位置都会发生变化(因为元素的最终位置是其
hashCode对key空间长度取模而得),因此resize方法中又会调用transfer函数,用来重新分配内部的元素;这个过程成为
rehash,是十分消耗性能的,因此在可预知元素的个数的情况下,一般应该避免使用缺省的initialCapacity,而是通过构造函数为其指定一
个值。例如我们可能会想要将数据库查询所得1000条记录以某个特定字段(比如ID)为key缓存在hashMap中,为了提高效率、避免rehash,
可以直接指定initialCapacity为2048。
另一个值得注意的地方是,hashMap其key空间的长度一定为2的N次方,这一点可以从一下源码中看出来:
-
int
capacity =
1
;
-
while
(capacity < initialCapacity)
-
capacity <<= 1
;
即使我们在构造函数中指定的initialCapacity不是2的平方数,capacity还是会被赋值为2的N次方。
为什么Sun Microsystem的工程师要将hashMap key空间的长度设为2的N次方呢?这里参考R.W.Floyed给出的衡量散列思想的三个标准: 一个好的hash算法的计算应该是非常快的,
一个好的hash算法应该是冲突极小化,
如果存在冲突,应该是冲突均匀化。
为了将各元素的hashCode保存至长度为Length的key数组中,一般采用取模的方式,即index
= hashCode %
Length。不可避免的,存在多个不同对象的hashCode被安排在同一位置,这就是我们平时所谓的“冲突”。如果仅仅是考虑元素均匀化与冲突极小
化,似乎应该将Length取为素数(尽管没有明显的理论来支持这一点,但数学家们通过大量的实践得出结论,对素数取模的产生结果的无关性要大于其它数
字)。为此,Craig Larman and Rhett Guthrie《Java
Performence》中对此也大加抨击。为了弄清楚这个问题,Bruce Eckel(Thinking in
JAVA的作者)专程采访了java.util.hashMap的作者Joshua
Bloch,并将他采用这种设计的原因放到了网上(http://www.roseindia.net/javatutorials
/javahashmap.shtml) 。
上述设计的原因在于,取模运算在包括Java在内的大多数语言中的效率都十分低下,而当除数为2的N次方时,取模运算将退化为最简单的位运算,其效率明显提升(按照Bruce Eckel给出的数据,大约可以提升5~8倍) 。看看JDK中是如何实现的:
-
static
int
indexFor(
int
h,
int
length) {
-
return
h & (length-
1
);
-
}
当key空间长度为2的N次方时,计算hashCode为h的元素的索引可以用简单的与操作来代替笨拙的取模
操作!假设某个对象的hashCode为35(二进制为100011),而hashMap采用默认的initialCapacity(16),那么
indexFor计算所得结果将会是100011 & 1111 = 11,即十进制的3,是不是恰好是35 Mod 16。
上面的方法有一个问题,就是它的计算结果仅有对象hashCode的低位决定,而高位被统统屏蔽了;以上面为
例,19(10011)、35(100011)、67(1000011)等就具有相同的结果。针对这个问题, Joshua
Bloch采用了“防御性编程”的解决方法,在使用各对象的hashCode之前对其进行二次Hash,参看JDK中的源码:
-
static
int
hash(Object x) {
-
int
h = x.hashCode();
-
h += ~(h << 9
);
-
h ^= (h >>> 14
);
-
h += (h << 4
);
-
h ^= (h >>> 10
);
-
return
h;
-
}
采用这种旋转Hash函数的主要目的是让原有hashCode的高位信息也能被充分利用,且兼顾计算效率以及数据统计的特性,其具体的原理已超出了本文的领域。
加快Hash效率的另一个有效途径是编写良好的自定义对象的HashCode,String的实现采用了如下的计算方法:
-
for
(
int
i =
0
; i < len; i++) {
-
h = 31
*h + val[off++];
-
}
-
hash = h;
这种方法HashCode的计算方法可能最早出现在Brian W. Kernighan和Dennis
M. Ritchie的《The C Programming
Language》中,被认为是性价比最高的算法(又被称为times33算法,因为C中乘数常量为33,JAVA中改为31),实际上,包括List在
内的大多数的对象都是用这种方法计算Hash值。
另一种比较特殊的hash算法称为布隆过滤器,它以牺牲细微精度为代价,换来存储空间的大量节俭,常用于诸如判断用户名重复、是否在黑名单上等等。
Fail-Fast机制
众所周知,HashMap不是线程安全的集合类。但在某些容错能力较好的应用中,如果你不想仅仅因为1%的可能性而去承受hashTable的同步开销,则可以考虑利用一下HashMap的Fail-Fast机制,其具体实现如下:
-
Entry<K,V> nextEntry() {
-
if
(modCount != expectedModCount)
-
throw
new
ConcurrentModificationException();
-
……
-
}
其中modCount为HashMap的一个实例变量,并且被声明为volatile,表示任何线程都可以看
到该变量被其它线程修改的结果(根据JVM内存模型的优化,每一个线程都会存一份自己的工作内存,此工作内存的内容与本地内存并非时时刻刻都同步,因此可
能会出现线程间的修改不可见的问题)
。使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程中,通过每次比较两者是否相等来判断
HashMap是否在内部或被其它线程修改。HashMap的大多数修改方法都会改变ModCount,参考下面的源码:
-
public
V put(K key, V value) {
-
K k = maskNull(key);
-
int
hash = hash(k);
-
int
i = indexFor(hash, table.length);
-
for
(Entry<K,V> e = table[i]; e !=
null
; e = e.next) {
-
if
(e.hash == hash && eq(k, e.key)) {
-
V oldValue = e.value;
-
e.value = value;
-
e.recordAccess(this
);
-
return
oldValue;
-
}
-
}
-
modCount++;
-
addEntry(hash, k, value, i);
-
return
null
;
-
}
以put方法为例,每次往HashMap中添加元素都会导致modCount自增。其它诸如remove、clear方法也都包含类似的操作。
从上面可以看出,HashMap所采用的Fail-Fast机制本质上是一种乐观锁机制,通过检查状态——没有问题则忽略——有问题则抛出异常的方式,来避免线程同步的开销,下面给出一个在单线程环境下发生Fast-Fail的例子:
-
class
Test {
-
public
static
void
main(String[] args) {
-
java.util.HashMap<Object,String> map=new
java.util.HashMap<Object,String>();
-
map.put(new
Object(),
"a"
);
-
map.put(new
Object(),
"b"
);
-
java.util.Iterator<Object> it=map.keySet().iterator();
-
while
(it.hasNext()){
-
it.next();
-
map.put(""
,
""
);
-
System.out.println(map.size());
-
}
-
}
运行上面的代码会抛出
java.util.ConcurrentModificationException,因为在迭代过程中修改了HashMap内部的元素导致
modCount自增。若将上面代码中 map.put(new Object(), "b")
这句注释掉,程序会顺利通过,因为此时HashMap中只包含一个元素,经过一次迭代后已到了尾部,所以不会出现问题,也就没有抛出异常的必要了。
在通常并发环境下,还是建议采用同步机制。这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止意外的非同步访问。
LinkedHashMap
遍历HashMap所得到的数据是杂乱无章的,这在某些情况下客户需要特定遍历顺序时是十分有用的。比如,这
种数据结构很适合构建 LRU 缓存。调用 put 或 get 方法将会访问相应的条目(假定调用完成后它还存在)。putAll
方法以指定映射的条目集合迭代器提供的键-值映射关系的顺序,为指定映射的每个映射关系生成一个条目访问。Sun提供的J2SE说明文档特别规定任何其他
方法均不生成条目访问,尤其,collection 集合类的操作不会影响底层映射的迭代顺序。
LinkedHashMap的实现与 HashMap
的不同之处在于,前者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序通常就是集合中元素的插入顺序。该类定义了
header、before与after三个属性来表示该集合类的头与前后“指针”,其具体用法类似于数据结构中的双链表,以删除某个元素为例:
-
private
void
remove() {
-
before.after = after;
-
after.before = before;
-
}
实际上就是改变前后指针所指向的元素。
显然,由于增加了维护链接列表的开支,其性能要比 HashMap
稍逊一筹,不过有一点例外:LinkedHashMap的迭代所需时间与其的所包含的元素成比例;而HashMap
迭代时间很可能开支较大,因为它所需要的时间与其容量(分配给Key空间的长度)成比例。一言以蔽之,随机存取用HashMap,顺序存取或是遍历用
LinkedHashMap。
LinkedHashMap还重写了removeEldestEntry方法以实现自动清除过期数据的功能,
这在HashMap中是无法实现的,因为后者其内部的元素是无序的。默认情况下,LinkedHashMap中的removeEldestEntry的作
用被关闭,其具体实现如下:
-
protected
boolean
removeEldestEntry(Map.Entry<k,v> eldest) {
-
return
false
;
-
} </k,v>
可以使用如下的代码覆盖removeEldestEntry:
-
private
static
final
int
MAX_ENTRIES =
100
;
-
-
protected
boolean
removeEldestEntry(Map.Entry eldest) {
-
return
size() > MAX_ENTRIES;
-
}
它表示,刚开始,LinkedHashMap中的元素不断增长;当它内部的元素超过MAX_ENTRIES(100)后,每当有新的元素被插入时,都会自动删除双链表中最前端(最旧)的元素,从而保持LinkedHashMap的长度稳定。
缺省情况下,LinkedHashMap采取的更新策略是类似于队列的FIFO,如果你想实现更复杂的更新逻
辑比如LRU(最近最少使用)
等,可以在构造函数中指定其accessOrder为true,因为的访问元素的方法(get)内部会调用一个“钩子”,即recordAccess,其
具体实现如下:
-
void
recordAccess(HashMap<K,V> m) {
-
LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
-
if
(lm.accessOrder) {
-
lm.modCount++;
-
remove();
-
addBefore(lm.header);
-
}
-
}
上述代码主要实现了这样的功能:如果accessOrder被设置为true,则每次访问元素时,都将该元素
移至headr的前面,即链表的尾部。将removeEldestEntry与accessOrder一起使用,就可以实现最基本的内存缓存,具体代码可
参考http://bluepopopo.javaeye.com/blog/180236。
WeakHashMap
99%的Java教材教导我们不要去干预JVM的垃圾回收机制,但JAVA中确实存在着与其密切相关的四种引用:强引用、软引用、弱引用以及幻象引用。
Java中默认的HashMap采用的是采用类似于强引用的强键来管理的,这意味着即使作为key的对象已经不存在了(指没有任何一个引用指向它),也仍然会保留在HashMap中,在某些情况下(例如内存缓存)中,这些过期的条目可能会造成内存泄漏等问题。
WeakHashMap采用的策略是,只要作为key的对象已经不存在了(超出生命周期),就不会阻止垃圾收
集器清空此条目,即使当前机器的内存并不紧张。不过,由于GC是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象,除非你显示地调用
它,可以参考下面的例子:
-
public
static
void
main(String[] args) {
-
Map<String, String>map = new
WeakHashMap<String, String>();
-
map.put(new
String(
"Alibaba"
),
"alibaba"
);
-
while
(map.containsKey(
"Alibaba"
)) {
-
try
{
-
Thread.sleep(500
);
-
} catch
(InterruptedException ignored) {
-
}
-
System.out.println("Checking for empty"
);
-
System.gc();
-
}
上述代码输出一次Checking for
empty就退出了主线程,意味着GC在最近的一次垃圾回收周期中清除了new
String(“Alibaba”),同时WeakHashMap也做出了及时的反应,将该键对应的条目删除了。如果将map的类型改为HashMap的
话,由于其内部采用的是强引用机制,因此即使GC被显示调用,map中的条目依然存在,程序会不断地打出Checking for
empty字样。另外,在使用WeakHashMap的情况下,若是将 map.put(new String("Alibaba"),
"alibaba"); 改为 map.put("Alibaba", "alibaba"); 程序还是会不断输出Checking for
empty。这与前面我们分析的WeakHashMap的弱引用机制并不矛盾,因为JVM为了减小重复创建和维护多个相同String的开销,其内部采用
了蝇量模式(《Java与模式》),此时的“Alibaba”是存放在常量池而非堆中的,因此即使没有对象指向“Alibaba”,它也不会被GC回收。
弱引用特别适合以下对象:占用大量内存,但通过垃圾回收功能回收以后很容易重新创建。
介于HashMap和WeakHashMap之中的是SoftHashMap,它所采用的软引用的策略指的
是,垃圾收集器并不像其收集弱可及的对象一样尽量地收集软可及的对象,相反,它只在真正 “需要”
内存时才收集软可及的对象。软引用对于垃圾收集器来说是一种“睁一只眼,闭一只眼”方式,即
“只要内存不太紧张,我就会保留该对象。但是如果内存变得真正紧张了,我就会去收集并处理这个对象。”
就这一点看,它其实要比WeakHashMap更适合于实现缓存机制。遗憾的是,JAVA中并没有实现相关的SoftHashMap类(Apache和
Google提供了第三方的实现),但它却是提供了两个十分重要的类java.lang.ref.SoftReference以及
ReferenceQueue,可以在对象应用状态发生改变是得到通知,可以参考
com.alibaba.common.collection.SofthashMap中processQueue方法的实现:
-
private
ReferenceQueue queue =
new
ReferenceQueue();
-
ValueCell vc;
-
Map hash = new
HashMap(initialCapacity, loadFactor);
-
……
-
while
((vc = (ValueCell) queue.poll()) !=
null
) {
-
if
(vc.isValid()) {
-
hash.remove(vc.key);
-
} else
{
-
valueCell.dropped--;
-
}
-
}
-
}
processQueue方法会在几乎所有SoftHashMap的方法中被调用到,JVM会通过
ReferenceQueue的poll方法通知该对象已经过期并且当前的内存现状需要将它释放,此时我们就可以将其从hashMap中剔除。事实上,默
认情况下,Alibaba的MemoryCache所使用的就是SoftHashMap。
分享到:
相关推荐
在Java编程语言中,HashMap是集合框架中一个重要的类,用于存储键值对的数据结构。面试中,HashMap的源码分析与实现是一个常见的考察点,...深入学习和实践HashMap源码,能够帮助我们更好地理解和优化Java应用程序。
易语言HashMap类是一种在易语言编程环境中实现的高效数据结构,它主要用于存储键值对(key-value pairs),提供快速的数据存取。...通过深入学习和实践,开发者可以更好地利用HashMap类解决实际编程问题。
马士兵老师的HashMap学习笔记深入剖析了这一核心组件的工作原理,旨在帮助开发者更深入地理解其内部机制。本文将结合马士兵老师的讲解,详细阐述HashMap在不同JDK版本中的put操作流程,以及可能遇到的死循环问题。 ...
#### 基本概念与原理 HashMap是Java集合框架的一部分,它实现了Map接口,能够存储键值对数据。其内部使用哈希表来存储数据,通过计算键的哈希码来确定元素的存储位置,这使得查找、插入和删除操作的平均时间复杂度...
- **LinkedList**: 虽然插入和删除效率高,但由于查找也需要遍历,所以查找效率与`ArrayList`相同,也是O(n)。 **应用场景** - **HashMap**: 适用于需要快速查找、插入和删除,并且能接受偶尔的慢速查找(如存在...
本文将深入探讨`HashMap`中几种典型的哈希函数构造方法,并通过示例代码来帮助读者理解这些函数的工作原理。 #### 二、基础知识 在深入了解具体的哈希函数之前,我们先回顾一下哈希表的基本概念: - **哈希表**:...
《深入理解Delphi DCL与HashMap实现》 在软件开发领域,数据结构是构建高效算法的基础,对于Delphi开发者来说,DCL(Data Control Library)提供了丰富的数据结构控件库,使得开发者能够方便地在Delphi环境下实现...
哈希表(Hashmap)是一种常见的数据结构,它在计算机科学和编程中扮演着重要的角色。在C语言中实现哈希表,可以帮助我们快速地存储和查找数据,...理解这些原理并实践,对于提升C语言编程能力及数据结构掌握至关重要。
标题中的"HashM.rar_hashmap"表明这是一个关于哈希映射(HashMap)的C语言实现项目,而"HashM.doc"可能是包含详细代码和解释的...这将帮助你深入理解HashMap的内部工作原理,并可能为你的C语言编程实践提供宝贵经验。
通过分析和学习"test-hashmap.c"源代码,你可以深入了解哈希表的内部工作原理,这对于开发高效、可靠的Linux系统级程序大有裨益。同时,这也是提升C语言编程技巧和理解数据结构的好机会。记得实践是检验真理的唯一...
HashMap,HashTable,ConcurrentHashMap 之关联 HashMap、HashTable、ConcurrentHashMap 是 Java 集合类中的重点,以下...理解它们的内部结构和特点是非常重要的,为此,我们需要不断学习和实践,掌握它们的原理和应用。
在IT领域,哈希表(Hash Map)是一种广泛使用的数据结构,它提供了...总的来说,这个项目提供了一个实践理解哈希表和哈希函数的平台,无论是对于初学者还是经验丰富的开发者,都能从中学习到数据结构与算法的重要知识。
本指南将围绕"Java毕业设计指南与项目实践"这一主题,深入探讨相关知识点,帮助你顺利进行毕业设计。 一、Java基础 在进行Java毕业设计前,你需要对Java基础有扎实的理解,包括语法、面向对象编程(OOP)概念、异常...
通过本文的介绍,相信读者能更好地理解Map的工作原理、常用方法及其在实际应用中的使用。HashMap提供了高效但无序的存储,LinkedHashMap维护插入顺序,TreeMap提供了有序存储。选择合适的Map实现类和优化Map的使用,...
通过对这些知识点的学习和实践,你可以深入了解Scala编程,掌握HashMap的实现原理,以及使用现代构建工具和测试框架进行软件开发的方法。这对于提升编程技能和理解大型项目的工作流程都是非常有益的。
本项目实践了一个自定义的HashMap实现,旨在帮助开发者深入理解其内部工作原理。`HashMap`是基于哈希表的数据结构,通过高效的哈希函数将键映射到相应的槽位,实现快速的插入、查找和删除操作。 哈希表是一种数据...
散列表的运作原理基于散函数(Hash Function),它能将任意键转化为一个固定大小的索引,从而使得查找、插入和删除操作的时间复杂度达到O(1)的理想状态。 在实际应用中,散列表可能会遇到冲突,即不同的键可能会被...
《算法基础与在线实践》是一本深入浅出的教材,旨在帮助读者理解并掌握算法的基础知识,同时结合实际编程环境,提升算法的在线实践能力。该书内容全面,清晰度高,目录结构分明,便于读者查找和学习。作为一本与Java...
总之,ReactHashMapVisualizer是一个结合React技术和JavaScript数据结构的实践项目,它帮助开发者加深对React开发的理解,同时也能学习到哈希映射这一重要数据结构的工作原理。通过分析和参与这个项目,你可以提升...
本资源“Java程序设计研究与实践-理论和实践.zip”包含了一份深入探讨Java编程的PDF文档,旨在帮助读者从理论到实践全面掌握Java语言。 理论部分,主要涵盖了以下几个关键知识点: 1. **Java语言基础**:包括Java...