转载,请说明出处:http://blog.csdn.net/zhangerqing
Java集合类是个非常重要的知识点,HashMap、HashTable、ConcurrentHashMap等算是集合类中的重点,可谓“重中之重”,首先来看个问题,如面试官问你:HashMap和HashTable有什么区别,一个比较简单的回答是:
1、HashMap是非线程安全的,HashTable是线程安全的。
2、HashMap的键和值都允许有null值存在,而HashTable则不行。
3、因为线程安全的问题,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; 初始容量:16
static final int MAXIMUM_CAPACITY = 1
<< 30; 最大容量:2的30次方:1073741824
static 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个大小的空间。另外一个重要的构造方法:
01.public HashMap(int initialCapacity, float loadFactor) {
02. if (initialCapacity < 0)
03. throw new IllegalArgumentException("Illegal initial capacity: " +
04. initialCapacity);
05. if (initialCapacity > MAXIMUM_CAPACITY)
06. initialCapacity = MAXIMUM_CAPACITY;
07. if (loadFactor <= 0 || Float.isNaN(loadFactor))
08. throw new IllegalArgumentException("Illegal load factor: " +
09. loadFactor);
10.
11. // Find a power of 2 >= initialCapacity
12. int capacity = 1;
13. while (capacity < initialCapacity)
14. capacity <<= 1;
15.
16. this.loadFactor = loadFactor;
17. threshold = (int)(capacity * loadFactor);
18. table = new Entry[capacity];
19. init();
20. }
就是说传入参数的构造方法,我们把重点放在:
while (capacity < initialCapacity)
capacity <<= 1;
上面,该代码的意思是,实际的开辟的空间要大于传入的第一个参数的值。举个例子:
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的大小。
2、put(Object key,Object value)操作
当调用put操作时,首先判断key是否为null,如下代码1处:
01. public V put(K key, V value) {
02. if (key == null)
03. return putForNullKey(value);
04. int hash = hash(key.hashCode());
05. int i = indexFor(hash, table.length);
06. for (Entry e = table[i]; e != null; e = e.next) {
07. Object k;
08. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
09. V oldValue = e.value;
10. e.value = value;
11. e.recordAccess(this);
12. return oldValue;
13. }
14. } modCount++;
15. addEntry(hash, key, value, i);
16. return null;
17. }
如果key是null,则调用如下代码:
01. private V putForNullKey(V value) {
02. for (Entry e = table[0]; e != null; e = e.next) {
03. if (e.key == null) {
04. V oldValue = e.value;
05. e.value = value;
06. e.recordAccess(this);
07. return oldValue;
08. }
09. }
10. modCount++;
11. addEntry(0, null, value, 0);
12. return null;
13. }
就是说,获取Entry的第一个元素table[0],并基于第一个元素的next属性开始遍历,直到找到key为null的Entry,将其value设置为新的value值。
如果没有找到key为null的元素,则调用如上述代码的addEntry(0, null, value, 0);增加一个新的entry,代码如下:
01.void addEntry(int hash, K key, V value, int bucketIndex) {
02. Entry e = table[bucketIndex];
03. table[bucketIndex] = new Entry(hash, key, value, e);
04. if (size++ >= threshold)
05. resize(2 * table.length);
06. }
先获取第一个元素table[bucketIndex],传给e对象,新建一个entry,key为null,value为传入的value值,next为获取的e对象。如果容量大于threshold,容量扩大2倍。
如果key不为null,这也是大多数的情况,重新看一下源码:
01.public V put(K key, V value) {
02. if (key == null)
03. return putForNullKey(value);
04. int hash = hash(key.hashCode());//---------------2---------------
05. int i = indexFor(hash, table.length);
06. for (Entry e = table[i]; e != null; e = e.next) {//--------------3-----------
07. Object k;
08. if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
09. V oldValue = e.value;
10. e.value = value;
11. e.recordAccess(this);
12. return oldValue;
13. }
14. }//-------------------4------------------
15. modCount++;//----------------5----------
16. addEntry(hash, key, value, i);-------------6-----------
17. return null;
18. }
看源码中2处,首先会进行key.hashCode()操作,获取key的哈希值,hashCode()是Object类的一个方法,为本地方法,内部实现比较复杂,我们
会在后面作单独的关于Java中Native方法的分析中介绍。hash()的源码如下:
01.static int hash(int h) {
02. // This function ensures that hashCodes that differ only by
03. // constant multiples at each bit position have a bounded
04. // number of collisions (approximately 8 at default load factor).
05. h ^= (h >>> 20) ^ (h >>> 12);
06. return h ^ (h >>> 7) ^ (h >>> 4);
07. }
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操作容易理解,先来看看源码的实现:
01.public V get(Object key) {
02. if (key == null)
03. return getForNullKey();
04. int hash = hash(key.hashCode());
05. for (Entry e = table[indexFor(hash, table.length)];
06. e != null;
07. e = e.next) {
08. Object k;
09. if (e.hash == hash && ((k = e.key) == key || key.equals(k)))//-------------------1----------------
10. return e.value;
11. }
12. return null;
13. }
意思就是:1、当key为null时,调用getForNullKey(),源码如下:
01.private V getForNullKey() {
02. for (Entry e = table[0]; e != null; e = e.next) {
03. if (e.key == null)
04. return e.value;
05. }
06. return null;
07. }
2、当key不为null时,先根据hash函数得到hash值,在更具indexFor()得到i的值,循环遍历链表,如果有:key值等于已存在的key值,则返回其value。如上述get()代码1处判断。
总结下HashMap新增put和获取get操作:
01.//存储时:
02.int hash = key.hashCode();
03.int i = hash % Entry[].length;
04.Entry[i] = value;
05.
06.//取值时:
07.int hash = key.hashCode();
08.int i = hash % Entry[].length;
09.return Entry[i];
理解了就比较简单。
此处附一个简单的HashMap小算法应用:
01.package com.xtfggef.hashmap;
02.
03.import java.util.HashMap;
04.import java.util.Map;
05.import java.util.Set;
06.
07./**
08. * 打印在数组中出现n/2以上的元素
09. * 利用一个HashMap来存放数组元素及出现的次数
10. * @author erqing
11. *
12. */
13.public class HashMapTest {
14.
15. public static void main(String[] args) {
16.
17. int [] a = {2,3,2,2,1,4,2,2,2,7,9,6,2,2,3,1,0};
18.
19. Map map = new HashMap();
20. for(int i=0; i set = map.keySet();//------------1------------
30. for (Integer s : set) {
31. if(map.get(s)>=a.length/2){
32. System.out.println(s);
33. }
34. }//--------------2---------------
35. }
36.}
此处注意两个地方,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类似,如下:
01.public ConcurrentHashMap()
02. {
03. this(16, 0.75F, 16);
04. }
默认传入值16,调用下面的方法:
01.public ConcurrentHashMap(int paramInt1, float paramFloat, int paramInt2)
02. {
03. if ((paramFloat <= 0F) || (paramInt1 < 0) || (paramInt2 <= 0))
04. throw new IllegalArgumentException();
05.
06. if (paramInt2 > 65536) {
07. paramInt2 = 65536;
08. }
09.
10. int i = 0;
11. int j = 1;
12. while (j < paramInt2) {
13. ++i;
14. j <<= 1;
15. }
16. this.segmentShift = (32 - i);
17. this.segmentMask = (j - 1);
18. this.segments = Segment.newArray(j);
19.
20. if (paramInt1 > 1073741824)
21. paramInt1 = 1073741824;
22. int k = paramInt1 / j;
23. if (k * j < paramInt1)
24. ++k;
25. int l = 1;
26. while (l < k)
27. l <<= 1;
28.
29. for (int i1 = 0; i1 < this.segments.length; ++i1)
30. this.segments[i1] = new Segment(l, paramFloat);
31. }
你会发现比HashMap的构造函数多一个参数,paramInt1就是我们之前谈过的initialCapacity,就是数组的初始化大小,paramfloat为loadFactor(装载因子),而paramInt2则是我们所要说的concurrentLevel,这三个值分别被初始化为16,0.75,16,经过:
01.while (j < paramInt2) {
02. ++i;
03. j <<= 1;
04. }
后,j就是我们最终要开辟的数组的size值,当paramInt1为16时,计算出来的size值就是16.通过:
this.segments = Segment.newArray(j)后,我们看出了,最终稿创建的Segment数组的大小为16.最终创建Segment对象时:
01.this.segments[i1] = new Segment(cap, paramFloat);
需要cap值,而cap值来源于:
01.int k = paramInt1 / j;
02. if (k * j < paramInt1)
03. ++k;
04. int cap = 1;
05. while (cap < k)
06. cap <<= 1;
组后创建大小为cap的数组。最后根据数组的大小及paramFloat的值算出了threshold的值:
this.threshold = (int)(paramArrayOfHashEntry.length * this.loadFactor)。
2、put操作
01.public V put(K paramK, V paramV)
02. {
03. if (paramV == null)
04. throw new NullPointerException();
05. int i = hash(paramK.hashCode());
06. return segmentFor(i).put(paramK, i, paramV, false);
07. }
与HashMap不同的是,如果key为null,直接抛出NullPointer异常,之后,同样先计算hashCode的值,再计算hash值,不过此处hash函数和HashMap中的不一样:
01.private static int hash(int paramInt)
02. {
03. paramInt += (paramInt << 15 ^ 0xFFFFCD7D);
04. paramInt ^= paramInt >>> 10;
05. paramInt += (paramInt << 3);
06. paramInt ^= paramInt >>> 6;
07. paramInt += (paramInt << 2) + (paramInt << 14);
08. return (paramInt ^ paramInt >>> 16);
09. }
01.final Segment segmentFor(int paramInt)
02. {
03. return this.segments[(paramInt >>> this.segmentShift & this.segmentMask)];
04. }
根据上述代码找到Segment对象后,调用put来操作:
01.V put(K paramK, int paramInt, V paramV, boolean paramBoolean)
02.{
03. lock();
04. try {
05. Object localObject1;
06. Object localObject2;
07. int i = this.count;
08. if (i++ > this.threshold)
09. rehash();
10. ConcurrentHashMap.HashEntry[] arrayOfHashEntry = this.table;
11. int j = paramInt & arrayOfHashEntry.length - 1;
12. ConcurrentHashMap.HashEntry localHashEntry1 = arrayOfHashEntry[j];
13. ConcurrentHashMap.HashEntry localHashEntry2 = localHashEntry1;
14. while ((localHashEntry2 != null) && (((localHashEntry2.hash != paramInt) || (!(paramK.equals(localHashEntry2.key)))))) {
15. localHashEntry2 = localHashEntry2.next;
16. }
17.
18. if (localHashEntry2 != null) {
19. localObject1 = localHashEntry2.value;
20. if (!(paramBoolean))
21. localHashEntry2.value = paramV;
22. }
23. else {
24. localObject1 = null;
25. this.modCount += 1;
26. arrayOfHashEntry[j] = new ConcurrentHashMap.HashEntry(paramK, paramInt, localHashEntry1, paramV);
27. this.count = i;
28. }
29. return localObject1;
30. } finally {
31. unlock();
32. }
33.}
先调用lock(),lock是ReentrantLock类的一个方法,用当前存储的个数+1来和threshold比较,如果大于threshold,则进行rehash,将当前的容量扩大2倍,重新进行hash。之后对hash的值和数组大小-1进行按位于操作后,得到当前的key需要放入的位置,从这儿开始,和HashMap一样。
从上述的分析看出,ConcurrentHashMap基于concurrentLevel划分出了多个Segment来对key-value进行存储,从而避免每次锁定整个数组,在默认的情况下,允许16个线程并发无阻塞的操作集合对象,尽可能地减少并发时的阻塞现象。
在多线程的环境中,相对于HashTable,ConcurrentHashMap会带来很大的性能提升!