集合类之番外篇:深入解析HashMap、HashTable
集合类是个非常重要的知识点,HashMap、HashTable、ConcurrentHashMap等算是集合类中的重点,可谓“重中之重”,首先来看个问题,如面试官问你:HashMap和HashTable有什么区别,一个比较简单的回答是:
1、HashMap是非线程安全的,HashTable是线程安全的。
2、HashMap的键和值都允许有null值存在,而HashTable则不行。
3、因为线程安全的问题,HashMap效率比HashTable的要高。
能答出上面的三点,简单的面试,算是过了,但是如果再问:Java中的另一个线程安全的与HashMap及其类似的类是什么?同样是线程安全,它与HashTable在线程同步上有什么不同?能把第二个问题完整的答出来,说明你的基础算是不错的了。带着这个问题,本章开始系Java之美[从菜鸟到高手演变]系列之深入解析HashMap和HashTable类应用而生!总想在文章的开头说点儿什么,但又无从说起。从最近的一些面试说起吧,感受就是:知识是永无止境的,永远不要觉得自己已经掌握了某些东西。如果对哪一块知识感兴趣,那么,请多多的花时间,哪怕最基础的东西也要理解它的原理,尽量往深了研究,在学习的同时,记得多与大家交流沟通,因为也许某些东西,从你自己的角度,是很难发现的,因为你并没有那么多的实验环境去发现他们。只有交流的多了,才能及时找出自己的不足,才能认识到:“哦,原来我还有这么多不知道的东西!”。
一、HashMap的内部存储结构
Java中数据存储方式最底层的两种结构,一种是数组,另一种就是链表,数组的特点:连续空间,寻址迅速,但是在删除或者添加元素的时候需要有较大幅度的移动,所以查询速度快,增删较慢。而链表正好相反,由于空间不连续,寻址困难,增删元素只需修改指针,所以查询慢、增删快。有没有一种来综合一下数组和链表,以便发挥他们各自的优势?答案是肯定的!就是:哈希表。哈希表具有较快(常量级)的查询速度,及相对较快的增删速度,所以很适合在海量数据的环境中使用。一般实现哈希表的方法采用“拉链法”,我们可以理解为“链表的数组”,如下图:从上图中,我们可以发现哈希表是由数组+链表组成的,一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。它的内部其实是用一个Entity数组来实现的,属性有key、value、next。接下来我会从初始化阶段详细的讲解HashMap的内部结构。
1、初始化首先来看三个常量:
static final int DEFAULT_INITIAL_CAPACITY = 16; 初始容量:16static final int MAXIMUM_CAPACITY = 1 << 30; 最大容量:2的30次方:1073741824static final float DEFAULT_LOAD_FACTOR = 0.75f; 装载因子,后面再说它的作用来看个无参构造方法,也是我们最常用的:- public HashMap() {
- this.loadFactor = DEFAULT_LOAD_FACTOR;
- threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
- table = new Entry[DEFAULT_INITIAL_CAPACITY];
- init();
- }
loadFactor、threshold的值在此处没有起到作用,不过他们在后面的扩容方面会用到,此处只需理解table=new Entry[DEFAULT_INITIAL_CAPACITY].说明,默认就是开辟16个大小的空间。另外一个重要的构造方法:
- public HashMap(int initialCapacity, float loadFactor) {
- if (initialCapacity < 0)
- throw new IllegalArgumentException("Illegal initial capacity: " +
- initialCapacity);
- if (initialCapacity > MAXIMUM_CAPACITY)
- initialCapacity = MAXIMUM_CAPACITY;
- if (loadFactor <= 0 || Float.isNaN(loadFactor))
- throw new IllegalArgumentException("Illegal load factor: " +
- loadFactor);
- // Find a power of 2 >= initialCapacity
- int capacity = 1;
- while (capacity < initialCapacity)
- capacity <<= 1;
- this.loadFactor = loadFactor;
- threshold = (int)(capacity * loadFactor);
- table = new Entry[capacity];
- init();
- }
- while (capacity < initialCapacity)
- capacity <<= 1;
- <p>public V put(K key, V value) {
- if (key == null)
- return putForNullKey(value);
- int hash = hash(key.hashCode());
- 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;
- }
- }</p><p> modCount++;
- addEntry(hash, key, value, i);
- return null;
- }</p>
- private V putForNullKey(V value) {
- for (Entry<K,V> e = table[0]; e != null; e = e.next) {
- if (e.key == null) {
- V oldValue = e.value;
- e.value = value;
- e.recordAccess(this);
- return oldValue;
- }
- }
- modCount++;
- addEntry(0, null, value, 0);
- return null;
- }
- 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);
- }
- public V put(K key, V value) {
- if (key == null)
- return putForNullKey(value);
- int hash = hash(key.hashCode());//---------------2---------------
- int i = indexFor(hash, table.length);
- for (Entry<K,V> e = table[i]; e != null; e = e.next) { //--------------3-----------
- 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;
- }
- }//-------------------4------------------
- modCount++;//----------------5----------
- addEntry(hash, key, value, i);-------------6-----------
- return null;
- }
- static int hash(int h) {
- // 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);
- }
int i = indexFor(hash, table.length);的意思,相当于int i = hash % Entry[].length;得到i后,就是在Entry数组中的位置,(上述代码5和6处是如果Entry数组中不存在新要增加的元素,则执行5,6处的代码,如果存在,即Hash冲突,则执行 3-4处的代码,此处HashMap中采用链地址法解决Hash冲突。此处经网友bbycszh指正,发现上述陈述有些问题)。重新解释:其实不管Entry数组中i位置有无元素,都会去执行5-6处的代码,如果没有,则直接新增,如果有,则将新元素设置为Entry[0],其next指针指向原有对象,即原有对象为Entry[1]。具体方法可以解释为下面的这段文字:(3-4处的代码只是检查在索引为i的这条链上有没有key重复的,有则替换且返回原值,程序不再去执行5-6处的代码,无则无处理)
上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。如, 第一个键值对A进来,通过计算其key的hash得到的i=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其i也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,i也等于0,那么C.next = B,Entry[0] = C;这样我们发现i=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起,也就是说数组中存储的是最后插入的元素。
到这里为止,HashMap的大致实现,我们应该已经清楚了。当然HashMap里面也包含一些优化方面的实现,这里也说一下。比如:Entry[]的长度一定后,随着map里面数据的越来越长,这样同一个i的链就会很长,会不会影响性能?HashMap里面设置一个因素(也称为因子),随着map的size越来越大,Entry[]会以一定的规则加长长度。
2、get(Object key)操作
get(Object key)操作时根据键来获取值,如果了解了put操作,get操作容易理解,先来看看源码的实现:- public V get(Object key) {
- if (key == null)
- return getForNullKey();
- int hash = hash(key.hashCode());
- for (Entry<K,V> e = table[indexFor(hash, table.length)];
- e != null;
- e = e.next) {
- Object k;
- if (e.hash == hash && ((k = e.key) == key || key.equals(k)))//-------------------1----------------
- return e.value;
- }
- return null;
- }
- private V getForNullKey() {
- for (Entry<K,V> e = table[0]; e != null; e = e.next) {
- if (e.key == null)
- return e.value;
- }
- return null;
- }
2、当key不为null时,先根据hash函数得到hash值,在更具indexFor()得到i的值,循环遍历链表,如果有:key值等于已存在的key值,则返回其value。如上述get()代码1处判断。
总结下HashMap新增put和获取get操作:
- //存储时:
- int hash = key.hashCode();
- int i = hash % Entry[].length;
- Entry[i] = value;
- //取值时:
- int hash = key.hashCode();
- int i = hash % Entry[].length;
- return Entry[i];
理解了就比较简单。
此处附一个简单的HashMap小应用:
- package com.xtfggef.hashmap;
- import java.util.HashMap;
- import java.util.Map;
- import java.util.Set;
- /**
- * 打印在数组中出现n/2以上的元素
- * 利用一个HashMap来存放数组元素及出现的次数
- * @author erqing
- *
- */
- public class HashMapTest {
- public static void main(String[] args) {
- int [] a = { 2,3,2,2,1,4,2,2,2,7,9,6,2,2,3,1,0};
- Map<Integer, Integer> map = new HashMap<Integer,Integer>();
- for(int i=0; i<a.length; i++){
- if(map.containsKey(a[i])){
- int tmp = map.get(a[i]);
- tmp+=1;
- map.put(a[i], tmp);
- }else{
- map.put(a[i], 1);
- }
- }
- Set<Integer> set = map.keySet();//------------1------------
- for (Integer s : set) {
- if(map.get(s)>=a.length/2){
- System.out.println(s);
- }
- }//--------------2---------------
- }
- }
此处注意两个地方,map.containsKey(),还有就是上述1-2处的代码。
理解了HashMap的上面的操作,其它的大多数方法都很容易理解了。搞清楚它的内部存储机制,一切OK!
二、HashTable的内部存储结构
HashTable和HashMap采用相同的存储机制,二者的实现基本一致,不同的是:
1、HashMap是非线程安全的,HashTable是线程安全的,内部的方法基本都是synchronized。
2、HashTable不允许有null值的存在。
在HashTable中调用put方法时,如果key为null,直接抛出NullPointerException。其它细微的差别还有,比如初始化Entry数组的大小等等,但基本思想和HashMap一样。
三、HashTable和ConcurrentHashMap的比较
如我开篇所说一样,ConcurrentHashMap是线程安全的HashMap的实现。同样是线程安全的类,它与HashTable在同步方面有什么不同呢?
之前我们说,synchronized关键字加锁的原理,其实是对对象加锁,不论你是在方法前加synchronized还是语句块前加,锁住的都是对象整体,但是ConcurrentHashMap的同步机制和这个不同,它不是加synchronized关键字,而是基于lock操作的,这样的目的是保证同步的时候,锁住的不是整个对象。事实上,ConcurrentHashMap可以满足concurrentLevel个线程并发无阻塞的操作集合对象。关于concurrentLevel稍后介绍。
1、构造方法
为了容易理解,我们先从构造函数说起。ConcurrentHashMap是基于一个叫Segment数组的,其实和Entry类似,如下:
- public ConcurrentHashMap()
- {
- this(16, 0.75F, 16);
- }
- public ConcurrentHashMap(int paramInt1, float paramFloat, int paramInt2)
- {
- if ((paramFloat <= 0F) || (paramInt1 < 0) || (paramInt2 <= 0))
- throw new IllegalArgumentException();
- if (paramInt2 > 65536) {
- paramInt2 = 65536;
- }
- int i = 0;
- int j = 1;
- while (j < paramInt2) {
- ++i;
- j <<= 1;
- }
- this.segmentShift = (32 - i);
- this.segmentMask = (j - 1);
- this.segments = Segment.newArray(j);
- if (paramInt1 > 1073741824)
- paramInt1 = 1073741824;
- int k = paramInt1 / j;
- if (k * j < paramInt1)
- ++k;
- int l = 1;
- while (l < k)
- l <<= 1;
- for (int i1 = 0; i1 < this.segments.length; ++i1)
- this.segments[i1] = new Segment(l, paramFloat);
- }
- while (j < paramInt2) {
- ++i;
- j <<= 1;
- }
this.segments = Segment.newArray(j)后,我们看出了,最终稿创建的Segment数组的大小为16.最终创建Segment对象时:
- this.segments[i1] = new Segment(cap, paramFloat);
需要cap值,而cap值来源于:
- int k = paramInt1 / j;
- if (k * j < paramInt1)
- ++k;
- int cap = 1;
- while (cap < k)
- cap <<= 1;
组后创建大小为cap的数组。最后根据数组的大小及paramFloat的值算出了threshold的值:
this.threshold = (int)(paramArrayOfHashEntry.length * this.loadFactor)。
2、put操作
- public V put(K paramK, V paramV)
- {
- if (paramV == null)
- throw new NullPointerException();
- int i = hash(paramK.hashCode());
- return segmentFor(i).put(paramK, i, paramV, false);
- }
- private static int hash(int paramInt)
- {
- paramInt += (paramInt << 15 ^ 0xFFFFCD7D);
- paramInt ^= paramInt >>> 10;
- paramInt += (paramInt << 3);
- paramInt ^= paramInt >>> 6;
- paramInt += (paramInt << 2) + (paramInt << 14);
- return (paramInt ^ paramInt >>> 16);
- }
- final Segment<K, V> segmentFor(int paramInt)
- {
- return this.segments[(paramInt >>> this.segmentShift & this.segmentMask)];
- }
- V put(K paramK, int paramInt, V paramV, boolean paramBoolean)
- {
- lock();
- try {
- Object localObject1;
- Object localObject2;
- int i = this.count;
- if (i++ > this.threshold)
- rehash();
- ConcurrentHashMap.HashEntry[] arrayOfHashEntry = this.table;
- int j = paramInt & arrayOfHashEntry.length - 1;
- ConcurrentHashMap.HashEntry localHashEntry1 = arrayOfHashEntry[j];
- ConcurrentHashMap.HashEntry localHashEntry2 = localHashEntry1;
- while ((localHashEntry2 != null) && (((localHashEntry2.hash != paramInt) || (!(paramK.equals(localHashEntry2.key)))))) {
- localHashEntry2 = localHashEntry2.next;
- }
- if (localHashEntry2 != null) {
- localObject1 = localHashEntry2.value;
- if (!(paramBoolean))
- localHashEntry2.value = paramV;
- }
- else {
- localObject1 = null;
- this.modCount += 1;
- arrayOfHashEntry[j] = new ConcurrentHashMap.HashEntry(paramK, paramInt, localHashEntry1, paramV);
- this.count = i;
- }
- return localObject1;
- } finally {
- unlock();
- }
- }
从上述的分析看出,ConcurrentHashMap基于concurrentLevel划分出了多个Segment来对key-value进行存储,从而避免每次锁定整个数组,在默认的情况下,允许16个线程并发无阻塞的操作集合对象,尽可能地减少并发时的阻塞现象。
在多线程的环境中,相对于HashTable,ConcurrentHashMap会带来很大的性能提升!
欢迎读者批评指正,有任何建议请联系:
EGG:xtfggef@gmail.com http://weibo.com/xtfggef
四、HashMap常见问题分析
1、此处我觉得网友的一篇文章说的很好, ,因为是优秀的资源,此处我整理下搬到这儿。
以下内容转自博文:
先看原问题代码:
- import java.util.HashMap;
- public class TestLock {
- private HashMap map = new HashMap();
- public TestLock() {
- Thread t1 = new Thread() {
- public void run() {
- for (int i = 0; i < 50000; i++) {
- map.put(new Integer(i), i);
- }
- System.out.println("t1 over");
- }
- };
- Thread t2 = new Thread() {
- public void run() {
- for (int i = 0; i < 50000; i++) {
- map.put(new Integer(i), i);
- }
- System.out.println("t2 over");
- }
- };
- t1.start();
- t2.start();
- }
- public static void main(String[] args) {
- new TestLock();
- }
- }
- public V put(K paramK, V paramV)
- {
- if (paramK == null)
- return putForNullKey(paramV);
- int i = hash(paramK.hashCode());
- int j = indexFor(i, this.table.length);
- for (Entry localEntry = this.table[j]; localEntry != null; localEntry = localEntry.next)
- {
- if (localEntry.hash == i) { java.lang.Object localObject1;
- if (((localObject1 = localEntry.key) == paramK) || (paramK.equals(localObject1))) {
- java.lang.Object localObject2 = localEntry.value;
- localEntry.value = paramV;
- localEntry.recordAccess(this);
- return localObject2;
- }
- }
- }
- this.modCount += 1;
- addEntry(i, paramK, paramV, j);
- return null;
- }
- private V putForNullKey(V paramV)
- {
- for (Entry localEntry = this.table[0]; localEntry != null; localEntry = localEntry.next)
- if (localEntry.key == null) {
- java.lang.Object localObject = localEntry.value;
- localEntry.value = paramV;
- localEntry.recordAccess(this);
- return localObject;
- }
- this.modCount += 1;
- addEntry(0, null, paramV, 0);
- return null;
- }
通过jconsole(或者thread dump),可以看到线程停在了transfer方法的while循环处。这个transfer方法的作用是,当Map中元素数超过阈值需要resize时,它负责把原Map中的元素映射到新Map中。我修改了HashMap,加上了@标记2和@标记3的代码片断,以打印出死循环时的状态,结果死循环线程总是出现类似这样的输出:“Thread-1,e==next:false,e==next.next:true,e:108928=108928,next:108928=108928,eq:true”。
这个输出表明:1)这个Entry链中的两个Entry之间的关系是:e=e.next.next,造成死循环。2)e.equals(e.next),但e!=e.next。因为例子中两个线程put的内容一样,并发时可能同一个key被保存了多个value,这种错误是在addEntry函数产生的,但这和线程死循环没有关系。接下来就分析transfer中那个while循环了。先所说这个循环正常的功能:src[j]保存的是映射成同一个hash值的多个Entry的链表,这个src[j]可能为null,可能只有一个Entry,也可能由多个Entry链接起来。假设是多个Entry,原来的链是(src[j]=a)->b(也就是src[j]=a,a.next=b,b.next=null),经过while处理后得到了(newTable[i]=b)->a。也就是说,把链表的next关系反向了。
再看看这个while中可能在多线程情况下引起问题的语句。针对两个线程t1和t2,这里它们可能的产生问题的执行序列做些个人分析:
1)假设同一个Entry列表[e->f->...],t1先到,t2后到并都走到while中。t1执行“e.next = newTable[i];newTable[i] = e;”这使得e.next=null(初始的newTable[i]为null),newTable[i]指向了e。这时t2执行了“e.next = newTable[i];newTable[i] = e;”,这使得e.next=e,e死循环了。因为循环开始处的“final Entry next = e.next;”,尽管e自己死循环了,在最后的“e = next;”后,两个线程都会跳过e继续执行下去。
2)在while中逐个遍历Entry链表中的Entry而把next关系反向时,newTable[i]成为了被交换的引用,可疑的语句在于“e.next = newTable[i];”。假设链表e->f->g被t1处理成e<-f<-g,newTable[i]指向了g,这时t2进来了,它一执行“e.next = newTable[i];”就使得e->g,造成了死循环。所以,理论上来说,死循环的Entry个数可能很多。尽管产生了死循环,但是t1执行到了死循环的右边,所以是会继续执行下去的,而t2如果执行“final Entry next = e.next;”的next为null,则也会继续执行下去,否则就进入了死循环。
3)似乎情况会更复杂,因为即便线程跳出了死循环,它下一次做resize进入transfer时,有可能因为之前的死循环Entry链表而被hang住(似乎是一定会被hang住)。也有可能,在put检查Entry链表时(@标记1),因为Entry链表的死循环而被hang住。也似乎有可能,活着的线程和死循环的线程同时执行在while里后,两个线程都能活着出去。所以,可能两个线程平安退出,可能一个线程hang在transfer中,可能两个线程都被hang住而又不一定在一个地方。
4)我反复的测试,出现一个线程被hang住的情况最多,都是e=e.next.next造成的,这主要就是例子put两份增量数据造成的。我如果去掉@标记3的输出,有时也能复现两个线程都被hang住的情况,但加上后就很难复现出来。我又把put的数据改了下,比如让两个线程put范围不同的数据,就能复现出e=e.next,两个线程都被hang住的情况。
上面罗哩罗嗦了很多,一开始我简单的分析后觉得似乎明白了怎么回事,可现在仔细琢磨后似乎又不明白了许多。有一个细节是,每次死循环的key的大小也是有据可循的,我就不打哈了。感觉,如果样本多些,可能出现问题的原因点会很多,也会更复杂,我姑且不再蛋疼下去。至于有人提到ConcurrentHashMap也有这个问题,我觉得不大可能,因为它的put操作是加锁的,如果有这个问题就不叫线程安全的Map了。
2、HashMap中Value可以相同,但是键不可以相同
当插入HashMap的key相同时,会覆盖原有的Value,且返回原Value值,看下面的程序:
- public class Test {
- public static void main(String[] args) {
- HashMap<String,Integer> map = new HashMap<String,Integer>();
- //出入两个Value相同的值,没有问题
- map.put("egg", 1);
- map.put("niu", 1);
- //插入key相同的值,看返回结果
- int egg = (Integer) map.put("egg", 3);
- System.out.println(egg); //输出1
- System.out.println(map.get("egg")); //输出3,将原值1覆盖
- System.out.println(map.get("niu")); //输出1
- }
- }
相同的键会被覆盖,且返回原值。
3、HashMap按值排序
给定一个数组,求出每个数据出现的次数并按照次数的由大到小排列出来。我们选用HashMap来做,key存储数组元素,值存储出现的次数,最后用Collections的sort方法对HashMap的值进行排序。代码如下:
- public class Test {
- public static void main(String[] args) {
- int data[] = { 2, 5, 2, 3, 5, 2, 3, 5, 2, 3, 5, 2, 3, 5, 2,
- 7, 8, 8, 7, 8, 7, 9, 0 };
- Map<Integer, Integer> map = new HashMap<Integer, Integer>();
- for (int i : data) {
- if (map.containsKey(i)) { //判断HashMap里是否存在
- map.put(i, map.get(i) + 1);//已存在,值+1
- } else {
- map.put(i, 1);//不存在,新增
- }
- }
- //map按值排序
- List<Map.Entry<Integer, Integer>> list = new ArrayList<Map.Entry<Integer, Integer>>(
- map.entrySet());
- Collections.sort(list, new Comparator<Map.Entry<Integer, Integer>>() {
- public int compare(Map.Entry<Integer, Integer> o1,
- Map.Entry<Integer, Integer> o2) {
- return (o2.getValue() - o1.getValue());
- }
- });
- for (Map.Entry<Integer, Integer> m : list) {
- System.out.println(m.getKey() + "-" + m.getValue());
- }
- }
- }
输出:
2-6
5-53-48-37-39-10-1转自:http://blog.csdn/zhangerqing/article/details/8193118