引言

今天刷题时遇到了一个很奇怪的问题,我们知道java HashMap的扩容是有成本的,为了减少扩容的次数和成本,可以给HashMap设置初始容量大小,如下所示:

HashMap<String, Integer> map0 = new HashMap<String, Integer>(100000);

但是在实际使用的过程中,发现性能不但没有提升,反而显著下降了!代码里对HashMap的操作也只有遍历了,看来是遍历出了问题,于是做了一番测试,得到如下结果:

  • HashMap的迭代器遍历性能与 initial capacity 有关

迭代器测试

贴上测试代码:

public class MapForEachTest {

    public static void main(String[] args) {
HashMap<String, Integer> map0 = new HashMap<String, Integer>(100000); initDataAndPrint(map0); HashMap<String, Integer> map1 = new HashMap<String, Integer>(); initDataAndPrint(map1); } private static void initDataAndPrint(HashMap map) { initData(map); long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) {
forEach(map);
}
long end = System.currentTimeMillis();
System.out.println("");
System.out.println("HashMap Size: " + map.size() + " 耗时: " + (end - start) + " ms");
} private static void forEach(HashMap map) {
for (Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); it.hasNext();){
Map.Entry<String, Integer> item = it.next();
System.out.print(item.getKey());
// do something
} } private static void initData(HashMap map) {
map.put("a", 0);
map.put("b", 1);
map.put("c", 2);
map.put("d", 3);
map.put("e", 4);
map.put("f", 5);
} }

这是运行结果

我们将第一个Map初始化10w大小,第二个map不指定大小(实际16),两个存储相同的数据,但是用迭代器遍历100次的时候发现性能迥异,一个36ms一个4ms,实际上性能差距更大,这里的4ms是600次System.out.print的耗时,这里将print注掉再试下

for (Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); it.hasNext();){
Map.Entry<String, Integer> item = it.next();
// System.out.print(item.getKey());
// do something
}

输出结果如下:

可以发现第二个map耗时几乎为0,第一个达到了28ms,遍历期间没有进行任何操作,既然石锤了和 initial capacity 有关,下一步我们去看看为什么会这样,找找Map迭代器的源码看看。

迭代器源码探究

我们来看看Map.entrySet().iterator()的源码;

public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}

其中EntryIterator是HashMap的内部抽象类,源码并不多,我全部贴上来并附上中文注释


abstract class HashIterator {
// 下一个Node
Node<K,V> next; // next entry to return
// 当前Node
Node<K,V> current; // current entry
// 预期的Map大小,也就是说每个HashMap可以有多个迭代器(每次调用 iterator() 会new 一个迭代器出来),但是只能有一个迭代器对他remove,否则会直接报错(快速失败)
int expectedModCount; // for fast-fail // 当前节点所在的数组下标,HashMap内部是使用数组来存储数据的,不了解的先去看看HashMap的源码吧
int index; // current slot HashIterator() {
// 初始化 expectedModCount
expectedModCount = modCount;
// 浅拷贝一份Map的数据
Node<K,V>[] t = table;
current = next = null;
index = 0;
// 如果 Map 中数据不为空,遍历数组找到第一个实际存储的素,赋值给next
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
} public final boolean hasNext() {
return next != null;
} final Node<K,V> nextNode() {
// 用来浅拷贝table,和别名的作用差不多,没啥用
Node<K,V>[] t;
// 定义一个e指存储next,并在找到下一值时返它自己
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException(); // 使current指向e,也就是next,这次要找的值,并且让next = current.next,一般为null
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
} /**
* 删除元素,这里不讲了,调的是HashMap的removeNode,没啥特别的
**/
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
// 用来保证快速失败的
expectedModCount = modCount;
}
}

上面的代码一看就明白了,迭代器每次寻找下一个元素都会去遍历数组,如果 initial capacity 特别大的话,也就是说 threshold 也大,table.length就大,所以遍历比较耗性能。

table数组的大小设置是在resize()方法里:

Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

其他遍历方法

注意代码里我们用的是Map.entrySet().iterator(),实际上和keys().iterator(), values().iterator() 一样,源码如下:

final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
} final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
} final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}

这两个就不分析了,性能一样。

实际使用中对集合的遍历还有几种方法:

  • 普通for循环+下标
  • 增强型for循环
  • Map.forEach
  • Stream.forEach

普通for循环+下标的方法不适用于Map,这里不讨论了。

增强型for循环

增强行for循环实际上是通过迭代器来实现的,我们来看两者的联系

源码:

private static void forEach(HashMap map) {
for (Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); it.hasNext();){
Map.Entry<String, Integer> item = it.next();
System.out.print(item.getKey());
// do something
}
} private static void forEach0(HashMap<String, Integer> map) {
for (Map.Entry entry : map.entrySet()) {
System.out.print(entry.getKey());
}
}

编译后的字节码:

// access flags 0xA
private static forEach(Ljava/util/HashMap;)V
L0
LINENUMBER 41 L0
ALOAD 0
INVOKEVIRTUAL java/util/HashMap.entrySet ()Ljava/util/Set;
INVOKEINTERFACE java/util/Set.iterator ()Ljava/util/Iterator; (itf)
ASTORE 1
L1
FRAME APPEND [java/util/Iterator]
ALOAD 1
INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
IFEQ L2
L3
LINENUMBER 42 L3
ALOAD 1
INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
CHECKCAST java/util/Map$Entry
ASTORE 2
L4
LINENUMBER 43 L4
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEINTERFACE java/util/Map$Entry.getKey ()Ljava/lang/Object; (itf)
CHECKCAST java/lang/String
INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/String;)V
L5
LINENUMBER 45 L5
GOTO L1
L2
LINENUMBER 46 L2
FRAME CHOP 1
RETURN
L6
LOCALVARIABLE item Ljava/util/Map$Entry; L4 L5 2
// signature Ljava/util/Map$Entry<Ljava/lang/String;Ljava/lang/Integer;>;
// declaration: item extends java.util.Map$Entry<java.lang.String, java.lang.Integer>
LOCALVARIABLE it Ljava/util/Iterator; L1 L2 1
// signature Ljava/util/Iterator<Ljava/util/Map$Entry<Ljava/lang/String;Ljava/lang/Integer;>;>;
// declaration: it extends java.util.Iterator<java.util.Map$Entry<java.lang.String, java.lang.Integer>>
LOCALVARIABLE map Ljava/util/HashMap; L0 L6 0
MAXSTACK = 2
MAXLOCALS = 3 // access flags 0xA
// signature (Ljava/util/HashMap<Ljava/lang/String;Ljava/lang/Integer;>;)V
// declaration: void forEach0(java.util.HashMap<java.lang.String, java.lang.Integer>)
private static forEach0(Ljava/util/HashMap;)V
L0
LINENUMBER 50 L0
ALOAD 0
INVOKEVIRTUAL java/util/HashMap.entrySet ()Ljava/util/Set;
INVOKEINTERFACE java/util/Set.iterator ()Ljava/util/Iterator; (itf)
ASTORE 1
L1
FRAME APPEND [java/util/Iterator]
ALOAD 1
INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
IFEQ L2
ALOAD 1
INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
CHECKCAST java/util/Map$Entry
ASTORE 2
L3
LINENUMBER 51 L3
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEINTERFACE java/util/Map$Entry.getKey ()Ljava/lang/Object; (itf)
INVOKEVIRTUAL java/io/PrintStream.print (Ljava/lang/Object;)V
L4
LINENUMBER 52 L4
GOTO L1
L2
LINENUMBER 53 L2
FRAME CHOP 1
RETURN
L5
LOCALVARIABLE entry Ljava/util/Map$Entry; L3 L4 2
LOCALVARIABLE map Ljava/util/HashMap; L0 L5 0
// signature Ljava/util/HashMap<Ljava/lang/String;Ljava/lang/Integer;>;
// declaration: map extends java.util.HashMap<java.lang.String, java.lang.Integer>
MAXSTACK = 2
MAXLOCALS = 3

都不用耐心观察,两个方法的字节码除了局部变量不一样其他都几乎一样,由此可以得出增强型for循环性能与迭代器一样,实际运行结果也一样,我不展示了,感兴趣的自己去copy文章开头和结尾的代码试下。

还是贴上吧

Map.forEach

先说一下为什么不把各种方法一起运行同时打印性能,这是因为CPU缓存的原因和JVM的一些优化会干扰到性能的判断,附录全部测试结果有说明

直接来看源码吧

@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}

很简短的源码,就不打注释了,从源码我们不难获取到以下信息:

  • 该方法也是快速失败的,遍历期间不能删除元素
  • 需要遍历整个数组
  • BiConsumer加了@FunctionalInterface注解,用了 lambda

第三点和性能无关,这里只是提下

通过以上信息我们能确定这个性能与table数组的大小有关。

但是在实际测试的时候却发现性能比迭代器差了不少:

其中详细原因等我下期的文章吧,这里不讲了

Stream.forEach

Stream与Map.forEach的共同点是都使用了lambda表达式。但两者的源码没有任何复用的地方。

不知道你有没有看累,先上测试结果吧:

耗时比Map.foreach还要高点。

下面讲讲Straam.foreach顺序流的源码,这个也不复杂,不过累的话先去看看总结吧。

Stream.foreach的执行者是分流器,HashMap的分流器源码就在HashMap类中,是一个静态内部类,类名叫 EntrySpliterator

下面是顺序流执行的方法

public void forEachRemaining(Consumer<? super Map.Entry<K,V>> action) {
int i, hi, mc;
if (action == null)
throw new NullPointerException();
HashMap<K,V> m = map;
Node<K,V>[] tab = m.table;
if ((hi = fence) < 0) {
mc = expectedModCount = m.modCount;
hi = fence = (tab == null) ? 0 : tab.length;
}
else
mc = expectedModCount;
if (tab != null && tab.length >= hi &&
(i = index) >= 0 && (i < (index = hi) || current != null)) {
Node<K,V> p = current;
current = null;
do {
if (p == null)
p = tab[i++];
else {
action.accept(p);
p = p.next;
}
} while (p != null || i < hi);
if (m.modCount != mc)
throw new ConcurrentModificationException();
}
}

从以上源码中我们也可以轻易得出遍历需要顺序扫描所有数组

总结

至此,Map的四种遍历方法都测试完了,我们可以简单得出两个结论

  • Map的遍历性能与内部table数组大小有关,也就是说与常用参数 initial capacity 有关,不管哪种遍历方式都是的
  • 性能(由高到低):迭代器 == 增强型For循环 > Map.forEach > Stream.foreach

这里就不说什么多少倍多少倍的性能差距了,抛开数据集大小都是扯淡,当我们不指定initial capacity的时候,四种遍历方法耗时都是3ms,这3ms还是输入输出流的耗时,实际遍历耗时都是0,所以数据集不大的时候用哪种都无所谓,就像不加输入输出流耗时不到1ms一样,很多时候性能消耗是在遍历中的业务操作,这篇文章不是为了让你去优化代码把foreach改成迭代器的,在大多数场景下并不需要关注迭代本身的性能,Stream与Lambda带来的可读性提升更加重要。

所以此文的目的就当是知识拓展吧,除了以上说到的遍历性能问题,你还应该从中能获取到的知识点有:

  • HashMap的数组是存储在table数组里的
  • table数组是resize方法初始化的,new Map不会初始化数组
  • Map遍历是table数组从下标0递增排序的,所以他是无序的
  • keySet().iterator,values.iterator, entrySet.iterator 来说没有本质区别,用的都是同一个迭代器
  • 各种遍历方法里,只有迭代器可以remove,虽然增强型for循环底层也是迭代器,但这个语法糖隐藏了 remove 方法
  • 每次调用迭代器方法都会new 一个迭代器,但是只有一个可以修改
  • Map.forEach与Stream.forEach看上去一样,实际实现是不一样的

附:四种遍历源码

private static void forEach(HashMap map) {
for (Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); it.hasNext();){
Map.Entry<String, Integer> item = it.next();
// System.out.print(item.getKey());
// do something
}
} private static void forEach0(HashMap<String, Integer> map) {
for (Map.Entry entry : map.entrySet()) {
System.out.print(entry.getKey());
}
} private static void forEach1(HashMap<String, Integer> map) {
map.forEach((key, value) -> {
System.out.print(key);
}); } private static void forEach2(HashMap<String, Integer> map) {
map.entrySet().stream().forEach(e -> {
System.out.print(e.getKey());
}); }

附:完整测试类与测试结果+一个奇怪的问题

代码很丑,不要介意


public class MapForEachTest { public static void main(String[] args) {
HashMap<String, Integer> map0 = new HashMap<String, Integer>(100000);
HashMap<String, Integer> map1 = new HashMap<String, Integer>();
initData(map0);
initData(map1); testIterator(map0);
testIterator(map1);
testFor(map0);
testFor(map1);
testMapForeach(map0);
testMapForeach(map1);
testMapStreamForeach(map0);
testMapStreamForeach(map1); } private static void testIterator(HashMap map) { long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) {
forEach(map);
}
long end = System.currentTimeMillis();
System.out.println("");
System.out.println("HashMap Size: " + map.size() + " 迭代器 耗时: " + (end - start) + " ms");
} private static void testFor(HashMap map) { long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) {
forEach0(map);
}
long end = System.currentTimeMillis();
System.out.println("");
System.out.println("HashMap Size: " + map.size() + " 增强型For 耗时: " + (end - start) + " ms");
} private static void testMapForeach(HashMap map) { long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) {
forEach1(map);
}
long end = System.currentTimeMillis();
System.out.println("");
System.out.println("HashMap Size: " + map.size() + " MapForeach 耗时: " + (end - start) + " ms");
} private static void testMapStreamForeach(HashMap map) { long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++) {
forEach2(map);
}
long end = System.currentTimeMillis();
System.out.println("");
System.out.println("HashMap Size: " + map.size() + " MapStreamForeach 耗时: " + (end - start) + " ms");
} private static void forEach(HashMap map) {
for (Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); it.hasNext();){
Map.Entry<String, Integer> item = it.next();
System.out.print(item.getKey());
// do something
}
} private static void forEach0(HashMap<String, Integer> map) {
for (Map.Entry entry : map.entrySet()) {
System.out.print(entry.getKey());
}
} private static void forEach1(HashMap<String, Integer> map) {
map.forEach((key, value) -> {
System.out.print(key);
}); } private static void forEach2(HashMap<String, Integer> map) {
map.entrySet().stream().forEach(e -> {
System.out.print(e.getKey());
}); } private static void initData(HashMap map) {
map.put("a", 0);
map.put("b", 1);
map.put("c", 2);
map.put("d", 3);
map.put("e", 4);
map.put("f", 5);
} }

测试结果:

如果你认真看了上面的文章的话,会发现测试结果有个不对劲的地方:

MapStreamForeach的耗时似乎变少了

我可以告诉你这不是数据的原因,从我的测试测试结果来看,直接原因是因为先执行了 Map.foreach,如果你把 MapForeach 和 MapStreamForeach 调换一下执行顺序,你会发现后执行的那个耗时更少。 至于这个问题的根本的原因,你有兴趣可以自己探索下,或者等我之后的文章

HashMap源码:聊聊Map的遍历性能问题(一)的更多相关文章

  1. 死磕Java之聊聊HashMap源码(基于JDK1.8)

    死磕Java之聊聊HashMap源码(基于JDK1.8) http://cmsblogs.com/?p=4731 为什么面试要问hashmap 的原理

  2. HashMap 源码详细分析(JDK1.8)

    一.概述 本篇文章我们来聊聊大家日常开发中常用的一个集合类 - HashMap.HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现.HashMap 允许 null 键和 null 值, ...

  3. HashMap源码解读(JDK1.7)

    哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出 ...

  4. 一、基础篇--1.2Java集合-HashMap源码解析

    https://www.cnblogs.com/chengxiao/p/6059914.html  散列表 哈希表是根据关键码值而直接进行访问的数据结构.也就是说,它能通过把关键码值映射到表中的一个位 ...

  5. HashMap 源码解析

    HashMap简介: HashMap在日常的开发中应用的非常之广泛,它是基于Hash表,实现了Map接口,以键值对(key-value)形式进行数据存储,HashMap在数据结构上使用的是数组+链表. ...

  6. JAVA源码分析-HashMap源码分析(一)

    一直以来,HashMap就是Java面试过程中的常客,不管是刚毕业的,还是工作了好多年的同学,在Java面试过程中,经常会被问到HashMap相关的一些问题,而且每次面试都被问到一些自己平时没有注意的 ...

  7. Java集合---HashMap源码剖析

    一.HashMap概述二.HashMap的数据结构三.HashMap源码分析     1.关键属性     2.构造方法     3.存储数据     4.调整大小 5.数据读取           ...

  8. 【转】Java HashMap 源码解析(好文章)

    ­ .fluid-width-video-wrapper { width: 100%; position: relative; padding: 0; } .fluid-width-video-wra ...

  9. 给jdk写注释系列之jdk1.6容器(4)-HashMap源码解析

    前面了解了jdk容器中的两种List,回忆一下怎么从list中取值(也就是做查询),是通过index索引位置对不对,由于存入list的元素时安装插入顺序存储的,所以index索引也就是插入的次序. M ...

随机推荐

  1. boltdb的实现和改进

    整个代码不是很复杂,可以从代码中理解如何实现. 特点:btree,很小巧,但实现了完整事务机制,稳定,即使丢电也不会导致数据库错误. 整个结构如下: meta page (前两页) --- > ...

  2. 03- web表单测试

    软件分为 b/s c/s两种架构 表单测试 1.用户注册,登录,信息提交. 2.用户查询商品. 3.用户订购商品. 4.用户查询订单等. 表单测试实例 表单数据添加测试(一) 添加按钮可用,测试点击添 ...

  3. B/S与C/S架构的区别

    C/S架构优点:1)界面比较丰富2)安全性高3)实现的功能更多,可以实现更复杂的业务4)客户端可以处理部分业务,分担服务器端压力缺点:1)需要开发独立的客户端软件,开发成本高2)需要安装独立的客户端软 ...

  4. 【并发编程】线程池是否需要手动关闭吗?以Hutool中的线程池为例

    Hutool工具包中使用线程池的API是: ThreadUtil.execute() /** * 直接在公共线程池中执行线程 * * @param runnable 可运行对象 */ public s ...

  5. Thinkphp5 -项目前序安装Composer命令工具具体步骤

    一.Composer 进入官网, 选择download 往下拉,选择最新版本composer.phar下载: 二.创建composer.bat,内容为: @ECHO OFF php "%~d ...

  6. Xposed学习二:实现机制

    在上一篇我们学习了如何在AS中创建Xposed模块,本篇来分析下官方教程中redClock的实现原理.本系列文章基于version-51 public void handleLoadPackage(X ...

  7. springboot项目部署(war包)

    将springboot项目打包成war,并且部署到tomcat.比较麻烦,自己踩的坑也比较多.算了一下,找bug的时间,有两天熬到凌晨2点. 修改pom.xml使得打包成war <groupId ...

  8. 获取汉字首字母并分组排列 PHP

    1.代码class Character{ /** * 数组根据首首字母排序 */ /** * 二维数组根据首字母分组排序 * @param array $data 二维数组 * @param stri ...

  9. Python JWT 介绍

    Python JWT 介绍 目录 Python JWT 介绍 1. JWT 介绍 2. JWT 创建 token 2.1 JWT 生成原理 2.2 JWT 校验 token 原理 3. 代码实现 4. ...

  10. JavaScript中DOM与BOM的区别

    1.BOM BOM全称为Brower Object Model,中文翻译为浏览器对象模型,提供了独立于内容而与浏览器窗口进行交互的对象.描述了与浏览器进行交互的方法和接口.通过BOM可以用来获取或设置 ...