说明:本文是阅读《Java程序性能优化》(作者:葛一明)一书中关于Map接口一节的笔记。


一、基本概念

1、常用的一些Map接口实现以及相关的一些接口、类等之间的类图结构如下,其中的HashMap与Hashtable都直接或者间接的实现了Map接口,但是Hashtable的大部分方法都做了同步,而HashMap没有,所以HashMap不是线程安全的。其次Hashtable不允许key或者value使用null值,而HashMap却是可以的。最后,在内部算法上,它们对key的hash算法和hash值到内存索引的映射算法不同。

Java map 整型 key map java 使用_Java

尽管有这些不同之处,但是它们的性能相差不大(其实我觉得还是有点大),如下代码所示,对Hashtable、HashMap和同步的HashMap(使用Collections.synchronizedMap(Map<K,V> m)方法产生)分别做100000次get操作,在我机器上分别大概耗时250ms、130ms、180ms。

Map<String, String> hashTable = new Hashtable<String, String>();
for (int i = 0; i < 100000; i++) {
	hashTable.put(String.valueOf(i), String.valueOf(i));
}

Map<String, String> hashMap = new HashMap<String, String>();
for (int i = 0; i < 100000; i++) {
	hashMap.put(String.valueOf(i), String.valueOf(i));
}

Map<String, String> map = new HashMap<String, String>();
Map<String, String> syncHashMap = Collections.synchronizedMap(map);
for (int i = 0; i < 100000; i++) {
	syncHashMap.put(String.valueOf(i), String.valueOf(i));
}

String tmp = null;

long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
	tmp = hashTable.get(String.valueOf(i));
}
long end = System.currentTimeMillis();
System.out.println(end - start);

start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
	tmp = hashMap.get(String.valueOf(i));
}
end = System.currentTimeMillis();
System.out.println(end - start);

start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
	tmp = syncHashMap.get(String.valueOf(i));
}
end = System.currentTimeMillis();
System.out.println(end - start);



二、HashMap的实现原理

1、简单地说,HashMap就是将key做hash算法,然后将hash值映射到内存地址,直接取得key所对应的数据。在HashMap中,底层数据结构使用的是数组,所谓的内存地址就是数组的下标索引。

2、HashMap的高性能需要保证以下几点:

  • hash算法必须是高效的
  • hash值到内存地址(数组索引)的算法是快速的
  • 根据内存地址(数组索引)可以直接取得对应的值

首先,对于hash算法,在HashMap中,hash算法有关的代码如下:


HashMap中的hash方法:


/**
 * Applies a supplemental hash function to a given hashCode, which
 * defends against poor quality hash functions.  This is critical
 * because HashMap uses power-of-two length hash tables, that
 * otherwise encounter collisions for hashCodes that do not differ
 * in lower bits. Note: Null keys always map to hash 0, thus index 0.
 */
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);
}


Object类中的hashCode方法:

public native int hashCode();


HashMap计算hash值,以下是以get方法中的实现为例:

Java map 整型 key map java 使用_数组_02


从以上代码中可以看出,要计算HashMap的key的hash值,前后分别调用了Object类的hashCode()方法和HashMap类中的hash()方法,而Object类的hashCode()方法默认是native的实现(子类没有进行覆写),native方法通常比一般方法快,因为它直接调用操作系统本地链接库的API,所以可以认为不存在性能问题,而hash()函数的实现全部是基于位运算的,位运算也比算术、逻辑运算快,所以性能也是很高的。


注意:由于hashCode()方法是可以进行覆写的,所以,为了保证HashMap的性能,应该保证覆写后的hashCode()也是高效的。
接着,当得到key的hash值后,需要通过该hash值来得到内存地址,在HashMap中是调用indexFor方法来取得内存地址的,如下:


/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
	return h & (length-1);
}


依然以HashMap中的get方法实现为例来看看是如何调用indexFor方法来取得内存地址的,如下,indexFor函数通过将hash值和数组长度按位取与来直接得到数组索引。最后,通过得到的数组下标索引便可取得对应的值,直接的内存访问速度也是很快的。所以可以认为HashMap是高性能的。



Java map 整型 key map java 使用_Java map 整型 key_03

三、Hash冲突

1、如下图所示,如果需要存放到HashMap中的两个元素1和元素2,通过计算hash值后发现对应内存中的同一个地址,此时就出现了hash冲突的情况。



Java map 整型 key map java 使用_Java_04

HashMap的底层是使用数组来实现的,但是数组中的元素是一个Entry类的对象,所以HashMap更详细的结构描述如下图:

Java map 整型 key map java 使用_数组_05


从图中可以看出HashMap的内部是一个Entry数组,每一个Entry元素包括key、value、next、hash四项,其中的next就是指向了另一个Entry元素。如下代码是HashMap的put方法的相关源代码,从中可以发现当put()操作出现hash冲突时,新的Entry依然会被安放在对应的索引下标内,并替换原有的值。同时,为了保证旧值不会丢失,会将新的Entry的next域指向旧值,这就实现了在一个数组索引空间内存放多个值项,所以如上图所示,HashMap实际上是一个链表的数组。


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;
		// 如果当前的key已经存在于HashMap中
		if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
			V oldValue = e.value;
			e.value = value;
			e.recordAccess(this);
			return oldValue;
		}
	}

	modCount++;
	// 添加当前的表项到i位置
	addEntry(hash, key, value, i);
	return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
	Entry<K,V> e = table[bucketIndex];
	// 将新增元素放到位置i,并让它的next指向旧的元素
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

基于HashMp的这种实现机制,只要hashCode()和hash()方法实现得足够好,能够尽可能减少冲突的产生,则对HashMap的操作几乎等价于对数组的随机访问,性能是很好的。但是如果hashCode()和hash()方法实现较差,在大量冲突产生的情况下,HashMap事实上就退化成几个链表,对HashMap的操作等价于遍历链表,性能就会很差。如下代码所示,有两个类BadHash和GoodHash,分别用它们来作为HashMap的key,产生10000个对象并存入HashMap中,然后再使用get方法进行10000次操作,在我机器上对于BadHash的put和get分别大概耗时600ms,12000ms,而对于GoodHash的put和get分别大概耗时6ms,2ms,这就是是否存在冲突以及随机访问和链表遍历的性能差距。

interface Common {}

class BadHash implements Common {

	@Override
	public int hashCode() {
		// 完全产生冲突
		return 1;
	}
}

class GoodHash implements Common {
	// 没有覆写hashCode()方法,使用父类的native的hashCode()方法
}

public class MapDemo02 {

	public static void main(String[] args) {
		Map<Common, Common> hashMap = new HashMap<Common, Common>();
		
		long start = System.currentTimeMillis();
		for (int i = 0; i < 10000; i++) {
			// Common c = new BadHash();
			Common c = new GoodHash();
			hashMap.put(c, c);
		}
		long end = System.currentTimeMillis();
		System.out.println(end - start);
		
		// Common c = new BadHash();
		Common c = new GoodHash();
		start = System.currentTimeMillis();
		for (int i = 0; i < 10000; i++) {
			hashMap.get(c);
		}
		end = System.currentTimeMillis();
		System.out.println(end - start);
	}

}


四、HashMap容量参数

1、由于HashMap是基于数组的机构,所以需要在数组空间不足时进行扩容,而数组的扩容相对比较耗时,所以合理的使用容量参数有助于优化HashMap的使用性能。

2、在HashMap的构造函数中,有两个构造函数可以指定其容量参数:


  • public HashMap(int initialCapacity)
  • public HashMap(int initialCapacity, float loadFactor)

其中的initialCapacity就是指定了HashMap的初始容量,而loadFactor指定了其负载因子。初始容量就是数组的大小,HashMap会使用大于等于initialCapacity且是2的指数次幂的最小整数作为内置数组的大小。而负载因子又叫做填充比,它是介于0和1之间的浮点数,它决定了HashMap在扩容之前,其内部数组的填充度。默认情况下,HashMap初始大小为16,负载因子为0.75。负载因子 = 元素个数 / 内部数组总大小


3、在实际使用中,负载因子也可以设置为大于1的数,但是这样做,HashMap将必然产生大量冲突。比如数组大小为10,现在的负载因子却大于1,那么在HashMap的元素个数等于10时,HashMap都还不会进行扩容,那么当要存储第11个元素时,其中的数组的某个下标位置必然要存储大于1个以上的元素,HashMap就会退化成链表形式,就出现了冲突。


4、在HashMap的内部,还维护了一个threshold变量,它始终被定义为当前数组总容量和负载因子的乘积,表示HashMap的阀值,当HashMap的实际容量超过该阀值时,HashMap就会进行扩容,所以HashMap的实际填充率不会超过负载因子。


5、HashMap中实现扩容的相关代码如下,从代码上看,HashMap的扩容会遍历整个HashMap,所以应该避免发生,而设置合理的初始大小和负载因子,可以减少扩容次数,从而提高使用性能。


/**
 * Rehashes the contents of this map into a new array with a
 * larger capacity.  This method is called automatically when the
 * number of keys in this map reaches its threshold.
 *
 * If current capacity is MAXIMUM_CAPACITY, this method does not
 * resize the map, but sets threshold to Integer.MAX_VALUE.
 * This has the effect of preventing future calls.
 *
 * @param newCapacity the new capacity, MUST be a power of two;
 *        must be greater than current capacity unless current
 *        capacity is MAXIMUM_CAPACITY (in which case value
 *        is irrelevant).
 */
void resize(int newCapacity) {
	Entry[] oldTable = table;
	int oldCapacity = oldTable.length;
	if (oldCapacity == MAXIMUM_CAPACITY) {
		threshold = Integer.MAX_VALUE;
		return;
	}
	// 建立新的数组
	Entry[] newTable = new Entry[newCapacity];
	// 将原有数据转移到新的数组中
	transfer(newTable);
	table = newTable;
	// 重新设置阀值,为新的容量和负载因子的乘积
	threshold = (int)(newCapacity * loadFactor);
}

/**
 * Transfers all entries from current table to newTable.
 */
void transfer(Entry[] newTable) {
	Entry[] src = table;
	int newCapacity = newTable.length;
	// 遍历原数组内所有表项
	for (int j = 0; j < src.length; j++) {
		Entry<K,V> e = src[j];
		// 当该表项索引有值存在时,则进行迁移
		if (e != null) {
			src[j] = null;
			do {
				// 进行数据迁移,该索引值下有冲突的记录
				Entry<K,V> next = e.next;
				// 计算该表项在新数组内的索引,并放置到新的数组中,并建立新的链表关系
				int i = indexFor(e.hash, newCapacity);
				e.next = newTable[i];
				newTable[i] = e;
				e = next;
			} while (e != null);
		}
	}
}

6、如下代码所示:

Map<String, String> hashMap = new HashMap<String, String>();  // 测试时使用了不同的初始容量和负载因子
for (int i = 0; i < 100000; i++) {
	String keyValue = Double.toString(Math.random());
	hashMap.put(keyValue, keyValue);
}

分别使用不同的初始容量和负载因子来进行测试,如下图是在我机器上的测试结果(经过多次执行采样求大体上的一个平均值):这里使用了String类作为key,这是一个系统类,使用的是默认的native的hashCode()方法,所以它的性能是较高的,不容易出现冲突。

Java map 整型 key map java 使用_Map_06


然后再来看看以上不同的测试案例中,在执行100000次put操作后,HashMap的数组容量是多大呢?如下图:要注意的是数组的大小并不是HashMap有效元素个数,HashMap有效元素个数始终是100000。当初始容量为16,负载因子为1时,所使用的内存空间最小,维护一个较小的数组可以节省不少系统资源,所以在上面的put操作中性能还是不错的。但是在实际中得注意,一个较大的负载因子意味着使用较小的内存空间,而空间越小,越可能引起hash冲突,因此就更加需要一个可靠的hashCode()方法。


Java map 整型 key map java 使用_Java map 整型 key_07


PS:如何求的HashMap内部数组的大小?如下图所示,在Eclipse Debug模式下,查看所使用的HashMap,其中就可以看到table数组,数组后面的数值就是数组的大小,还可以看到阀值threshold的值。


Java map 整型 key map java 使用_数组_08


五、LinkedHashMap


1、HashMap本身是无序的,也就是说被存入到HashMap中的元素,在遍历HashMap时,其输出是无序的;而LinkedHashMap是一种有序的HashMap,如果希望元素保存输入时的顺序,可以使用LinkedHashMap。


2、LinkedHashMap继承了HashMap,因此它也具备了HashMap的高性能特性。在HashMap的基础上,LinkedHashMap又在内部增加了一个链表,用来存放元素的顺序。


3、LinkedHashMap提供了两种类型的顺序:


  • 元素插入时的顺序
  • 最近访问的顺序

可以通过构造函数"public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)"来指定排序行为,其中的accessOrder为true时,按照元素最后访问时间排序;为false时,按照插入顺序排序,默认为false。


4、在内部实现中,LinkedHashMap通过继承HashMap.Entry类,实现了LinkedHashMap.Entry,为HashMap.Entry增加了before和after属性用来记录某一表项的前驱和后继,并构成循环链表,所以LinkedHashMap的Entry的大体结构如下(继承自HashMap固有的特性没画出):增加了前驱指向后继,后继指向前驱的特性,构成了一个循环链表。


Java map 整型 key map java 使用_Java map 整型 key_09


5、按访问时间排序


如下代码所示,LinkedHashMap根据元素最后访问时间进行排序,每当使用get()方法访问某一元素时,该元素就会被移动到链表的尾部。


Map<String, String> linkedHashMap = new LinkedHashMap<String, String>(16, 0.75F, true);
		
linkedHashMap.put("1", "AAA");
linkedHashMap.put("2", "BBB");
linkedHashMap.put("3", "CCC");
linkedHashMap.put("4", "DDD");
		
for (Iterator<String> iter = linkedHashMap.keySet().iterator(); iter.hasNext(); ) {
	String key = iter.next();
	System.out.println(key + " --> " + linkedHashMap.get(key));
}

但是在运行时出现了异常" Exception in thread "main" java.util.ConcurrentModificationException ",该异常一般会在集合迭代过程中被修改时抛出,不仅仅是LinkedHashMap,所有的集合都不允许在迭代器模式中使用非迭代器来修改

集合的结构,这里就是因为LinkedHashMap在按最后访问时间进行排序的前提下,使用了LinkedHashMap本身的get()方法,导致访问的该元素被移动到链表的尾部,导致结构被修改。

六、TreeMap


1、TreeMap与HashMap相比,它完全是另一种不同的Map的实现,它实现了SortedMap接口,所以它可以对元素进行排序;但是TreeMap的性能却比HashMap略低。


2、对TreeMap的迭代输出将会以元素顺序进行。注意,TreeMap的排序方式与LinkedHashMap不同,LinkedHashMap是基于元素进入集合的顺序或被访问的先后顺序来排序;而TreeMap是基于元素的key的固有顺序。为了确定key的排序算法,可以使用两种方式:


  • 在TreeMap的构造函数中传入一个Comparator接口对象
  • 元素的key实现Comparable接口


而且,对于TreeMap而言,排序的过程是必须的,所以必须使用两种排序方式之一,如果都没有使用则会抛出"java.lang.ClassCastException"异常。


3、TreeMap的内部实现是基于红黑树的。红黑树是一种平衡查找数,其统计性能要优于平衡二叉树,它具有良好的最坏情况运行时间,可以在O(log n)(n是树中元素的个数)时间内做查找、插入和删除。所以TreeMap也可以在最坏的情况下使用O(log n)的时间来做查找、插入和删除。


4、在排序的基础上,TreeMap还提供了其它的有关的接口来进行集合的筛选并得到一个结果也是有序的Map,如下:


  • public SortedMap<K,V> subMap(K fromKey, K toKey)
  • public SortedMap<K,V> headMap(K toKey)
  • public SortedMap<K,V> tailMap(K fromKey)
  • public K firstKey()
  • public K lastKey()

实例如下代码所示:


class Student implements Comparable<Student> {

	private String name;
	private int score;
	
	public Student(String name, int score) {
		this.name = name;
		this.score = score;
	}
	
	/**
	 * 排序算法
	 */
	@Override
	public int compareTo(Student stu) {
		if (this == stu) {
			return 0;
		}
		if (this.score > stu.score) {
			return 1;
		}
		return -1;
	}

	@Override
	public String toString() {
		return "Student [name=" + name + ", score=" + score + "]";
	}

}

public class MapDemo04 {

	public static void main(String[] args) {
		Student s1 = new Student("zhangsan", 90);
		Student s2 = new Student("lisi", 80);
		Student s3 = new Student("wangwu", 79);
		Student s4 = new Student("zhaoliu", 92);
		
		Map<Student, Student> treeMap = new TreeMap<Student, Student>();
		treeMap.put(s1, s1);
		treeMap.put(s2, s2);
		treeMap.put(s3, s3);
		treeMap.put(s4, s4);
		
		// 筛选出成绩介于lisi和zhaoliu之间的学生
		Map<Student, Student> tmpMap = ((SortedMap<Student, Student>)treeMap).subMap(s2, s4);
		for (Iterator<Entry<Student, Student>> iter = tmpMap.entrySet().iterator(); iter.hasNext(); ) {
			System.out.println(iter.next().getKey());
		} 
	}

}