集合类之番外篇:深刻解析HashMap、HashTablejava
Java集合类是个很是重要的知识点,HashMap、HashTable、ConcurrentHashMap等算是集合类中的重点,可谓“重中之重”,首先来看个问题,如面试官问你:HashMap和HashTable有什么区别,一个比较简单的回答是:面试
一、HashMap是非线程安全的,HashTable是线程安全的。算法
二、HashMap的键和值都容许有null值存在,而HashTable则不行。数组
三、由于线程安全的问题,HashMap效率比HashTable的要高。安全
能答出上面的三点,简单的面试,算是过了,可是若是再问:Java中的另外一个线程安全的与HashMap及其相似的类是什么?一样是线程安全,它与HashTable在线程同步上有什么不一样?能把第二个问题完整的答出来,说明你的基础算是不错的了。带着这个问题,本章开始系Java之美[从菜鸟到高手演变]系列之深刻解析HashMap和HashTable类应用而生!总想在文章的开头说点儿什么,但又无从提及。从最近的一些面试提及吧,感觉就是:知识是永无止境的,永远不要以为本身已经掌握了某些东西。若是对哪一块知识感兴趣,那么,请多多的花时间,哪怕最基础的东西也要理解它的原理,尽可能往深了研究,在学习的同时,记得多与你们交流沟通,由于也许某些东西,从你本身的角度,是很难发现的,由于你并无那么多的实验环境去发现他们。只有交流的多了,才能及时找出本身的不足,才能认识到:“哦,原来我还有这么多不知道的东西!”。数据结构
1、HashMap的内部存储结构
Java中数据存储方式最底层的两种结构,一种是数组,另外一种就是链表,数组的特色:连续空间,寻址迅速,可是在删除或者添加元素的时候须要有较大幅度的移动,因此查询速度快,增删较慢。而链表正好相反,因为空间不连续,寻址困难,增删元素只需修改指针,因此查询慢、增删快。有没有一种数据结构来综合一下数组和链表,以便发挥他们各自的优点?答案是确定的!就是:哈希表。哈希表具备较快(常量级)的查询速度,及相对较快的增删速度,因此很适合在海量数据的环境中使用。通常实现哈希表的方法采用“拉链法”,咱们能够理解为“链表的数组”,以下图:多线程
从上图中,咱们能够发现哈希表是由数组+链表组成的,一个长度为16的数组中,每一个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。通常状况是经过hash(key)%len得到,也就是元素的key的哈希值对数组长度取模获得。好比上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。因此十二、2八、108以及140都存储在数组下标为12的位置。它的内部实际上是用一个Entity数组来实现的,属性有key、value、next。接下来我会从初始化阶段详细的讲解HashMap的内部结构。并发
一、初始化
首先来看三个常量:
static final int DEFAULT_INITIAL_CAPACITY = 16; 初始容量:16
static final int MAXIMUM_CAPACITY = 1
<< 30; 最大容量:2的30次方:1073741824
static final float DEFAULT_LOAD_FACTOR = 0.75f;
装载因子,后面再说它的做用
来看个无参构造方法,也是咱们最经常使用的:app
loadFactor、threshold的值在此处没有起到做用,不过他们在后面的扩容方面会用到,此处只需理解table=new Entry[DEFAULT_INITIAL_CAPACITY].说明,默认就是开辟16个大小的空间。另一个重要的构造方法:函数
就是说传入参数的构造方法,咱们把重点放在:
上面,该代码的意思是,实际的开辟的空间要大于传入的第一个参数的值。举个例子:
new HashMap(7,0.8),loadFactor为0.8,capacity为7,经过上述代码后,capacity的值为:8.(1 << 2的结果是4,2 << 2的结果为8<此处感谢网友wego1234的指正>)。因此,最终capacity的值为8,最后经过new Entry[capacity]来建立大小为capacity的数组,因此,这种方法最红取决于capacity的大小。
二、put(Object key,Object value)操做
当调用put操做时,首先判断key是否为null,以下代码1处:
若是key是null,则调用以下代码:
就是说,获取Entry的第一个元素table[0],并基于第一个元素的next属性开始遍历,直到找到key为null的Entry,将其value设置为新的value值。
若是没有找到key为null的元素,则调用如上述代码的addEntry(0, null, value, 0);增长一个新的entry,代码以下:
先获取第一个元素table[bucketIndex],传给e对象,新建一个entry,key为null,value为传入的value值,next为获取的e对象。若是容量大于threshold,容量扩大2倍。
若是key不为null,这也是大多数的状况,从新看一下源码:
看源码中2处,首先会进行key.hashCode()操做,获取key的哈希值,hashCode()是Object类的一个方法,为本地方法,内部实现比较复杂,咱们
会在后面做单独的关于Java中Native方法的分析中介绍。hash()的源码以下:
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[]会以必定的规则加长长度。
二、get(Object key)操做
get(Object key)操做时根据键来获取值,若是了解了put操做,get操做容易理解,先来看看源码的实现:
意思就是:一、当key为null时,调用getForNullKey(),源码以下:
二、当key不为null时,先根据hash函数获得hash值,在更具indexFor()获得i的值,循环遍历链表,若是有:key值等于已存在的key值,则返回其value。如上述get()代码1处判断。
总结下HashMap新增put和获取get操做:
理解了就比较简单。
此处附一个简单的HashMap小算法应用:
此处注意两个地方,map.containsKey(),还有就是上述1-2处的代码。
理解了HashMap的上面的操做,其它的大多数方法都很容易理解了。搞清楚它的内部存储机制,一切OK!
2、HashTable的内部存储结构
HashTable和HashMap采用相同的存储机制,两者的实现基本一致,不一样的是:
一、HashMap是非线程安全的,HashTable是线程安全的,内部的方法基本都是synchronized。
二、HashTable不容许有null值的存在。
在HashTable中调用put方法时,若是key为null,直接抛出NullPointerException。其它细微的差异还有,好比初始化Entry数组的大小等等,但基本思想和HashMap同样。
3、HashTable和ConcurrentHashMap的比较
如我开篇所说同样,ConcurrentHashMap是线程安全的HashMap的实现。一样是线程安全的类,它与HashTable在同步方面有什么不一样呢?
以前咱们说,synchronized关键字加锁的原理,实际上是对对象加锁,不论你是在方法前加synchronized仍是语句块前加,锁住的都是对象总体,可是ConcurrentHashMap的同步机制和这个不一样,它不是加synchronized关键字,而是基于lock操做的,这样的目的是保证同步的时候,锁住的不是整个对象。事实上,ConcurrentHashMap能够知足concurrentLevel个线程并发无阻塞的操做集合对象。关于concurrentLevel稍后介绍。
一、构造方法
为了容易理解,咱们先从构造函数提及。ConcurrentHashMap是基于一个叫Segment数组的,其实和Entry相似,以下:
默认传入值16,调用下面的方法:
你会发现比HashMap的构造函数多一个参数,paramInt1就是咱们以前谈过的initialCapacity,就是数组的初始化大小,paramfloat为loadFactor(装载因子),而paramInt2则是咱们所要说的concurrentLevel,这三个值分别被初始化为16,0.75,16,通过:
后,j就是咱们最终要开辟的数组的size值,当paramInt1为16时,计算出来的size值就是16.经过:
this.segments = Segment.newArray(j)后,咱们看出了,最终稿建立的Segment数组的大小为16.最终建立Segment对象时:
须要cap值,而cap值来源于:
组后建立大小为cap的数组。最后根据数组的大小及paramFloat的值算出了threshold的值:
this.threshold = (int)(paramArrayOfHashEntry.length * this.loadFactor)。
二、put操做
与HashMap不一样的是,若是key为null,直接抛出NullPointer异常,以后,一样先计算hashCode的值,再计算hash值,不过此处hash函数和HashMap中的不同:
根据上述代码找到Segment对象后,调用put来操做:
先调用lock(),lock是ReentrantLock类的一个方法,用当前存储的个数+1来和threshold比较,若是大于threshold,则进行rehash,将当前的容量扩大2倍,从新进行hash。以后对hash的值和数组大小-1进行按位于操做后,获得当前的key须要放入的位置,从这儿开始,和HashMap同样。
从上述的分析看出,ConcurrentHashMap基于concurrentLevel划分出了多个Segment来对key-value进行存储,从而避免每次锁定整个数组,在默认的状况下,容许16个线程并发无阻塞的操做集合对象,尽量地减小并发时的阻塞现象。
在多线程的环境中,相对于HashTable,ConcurrentHashMap会带来很大的性能提高!
欢迎读者批评指正,有任何建议请联系:
EGG:xtfggef@gmail.com http://weibo.com/xtfggef
4、HashMap常见问题分析
一、此处我以为网友huxb23@126的一篇文章说的很好,分析多线程并发写HashMap线程被hang住的缘由 ,由于是优秀的资源,此处我整理下搬到这儿。
如下内容转自博文:http://blog.163.com/huxb23@126/blog/static/625898182011211318854/
先看原问题代码:
就是启了两个线程,不断的往一个非线程安全的HashMap中put内容,put的内容很简单,key和value都是从0自增的整数(这个put的内容作的并很差,以至于后来干扰了我分析问题的思路)。对HashMap作并发写操做,我原觉得只不过会产生脏数据的状况,但反复运行这个程序,会出现线程t一、t2被hang住的状况,多数状况下是一个线程被hang住另外一个成功结束,偶尔会两个线程都被hang住。说到这里,你若是以为很差好学习ConcurrentHashMap而在这瞎折腾就手下留情跳过吧。
好吧,分析下HashMap的put函数源码看看问题出在哪,这里就罗列出相关代码(jdk1.6):
经过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了。
二、HashMap中Value能够相同,可是键不能够相同
当插入HashMap的key相同时,会覆盖原有的Value,且返回原Value值,看下面的程序:
相同的键会被覆盖,且返回原值。
三、HashMap按值排序
给定一个数组,求出每一个数据出现的次数并按照次数的由大到小排列出来。咱们选用HashMap来作,key存储数组元素,值存储出现的次数,最后用Collections的sort方法对HashMap的值进行排序。代码以下:
输出:
2-6
5-5
3-4
8-3
7-3
9-1
0-1
转自:http://blog.csdn.NET/zhangerqing/article/details/8193118