Java集合框架——Map

江枫思渺然 提交于 2020-03-17 08:09:30

hashMap:存放键值对的容器

1、数据结构:
1.7:数组+链表
1.8:数组+链表+红黑树

(1.7Entry类;1.8Node类。本质一样)
内部包含了一个 Entry 类型的数组 table。Entry 存储着键值对,又存放了下一个Entry。所以Entry其实是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。

1.8较1.7引入了红黑树对hashMap数据结构进行优化;

引入原因:提高性能
当链表过长导致索引效率慢,利用红黑树快速增删改查优点,将时间复杂度由O(n)–>O(log(n))


应用场景:链表长度 >8,转化为红黑树

2、主要使用API:
1.7与1.8基本相同

V get(Object key); // 获得指定键的值
V put(K key, V value);  // 添加键值对
void putAll(Map<? extends K, ? extends V> m);  // 将指定Map中的键值对 复制到 此Map中
V remove(Object key);  // 删除该键值对

boolean containsKey(Object key); // 判断是否存在该键的键值对;是 则返回true
boolean containsValue(Object value);  // 判断是否存在该值的键值对;是 则返回true
 
Set<Map.Entry<K,V>> entrySet() //将所有key-value生成一个Set
Set<K> keySet();  // 单独抽取key序列,将所有key生成一个Set
Collection<V> values();  // 单独value序列,将所有value生成一个Collection

void clear(); // 清除哈希表中的所有键值对
int size();  // 返回哈希表中所有 键值对的数量
boolean isEmpty(); // 判断HashMap是否为空;size == 0时 表示为 空 

3、一般使用流程:

  1. 声明1个 HashMap的对象
  2. 向 HashMap 添加数据(放入 键 - 值对)
  3. 获取 HashMap 的某个数据
  4. 获取 HashMap 的全部数据:遍历HashMap
        //1. 声明1个 HashMap的对象
        Map<String, Integer> map = new HashMap<String, Integer>();

        // 2. 向HashMap添加数据(放入 键 - 值对)
        map.put("Android", 1);
        map.put("Java", 2);
        map.put("产品经理", 3);

		//3. 获取 HashMap 的某个数据
        System.out.println("key = 产品经理时的值为:" + map.get("产品经理"));

      /**
        * 4. 获取 HashMap 的全部数据:遍历HashMap
        * 核心思想:
        * 步骤1:获得key-value对(Entry) 或 key 或 value的Set集合
        * 步骤2:遍历上述Set集合(使用for循环 、 迭代器(Iterator)均可)
        * 方法共有3种:分别针对 key-value对(Entry) 或 key 或 value
        */

        // 方法1:获得key-value的Set集合 再遍历
        // 1. 获得key-value对(Entry)的Set集合
        Set<Map.Entry<String, Integer>> entrySet = map.entrySet();

        // 2. 遍历Set集合,从而获取key-value
        // 2.1 通过for循环
        for(Map.Entry<String, Integer> entry : entrySet){
            System.out.print(entry.getKey());
            System.out.println(entry.getValue());
        }

        // 2.2 通过迭代器:先获得key-value对(Entry)的Iterator,再循环遍历
        Iterator iter1 = entrySet.iterator();
        while (iter1.hasNext()) {
            // 遍历时,需先获取entry,再分别获取key、value
            Map.Entry entry = (Map.Entry) iter1.next();
            System.out.print((String) entry.getKey());
            System.out.println((Integer) entry.getValue());
        }


        // 方法2:获得key的Set集合 再遍历
        // 1. 获得key的Set集合
        Set<String> keySet = map.keySet();

        // 2. 遍历Set集合,从而获取key,再获取value
        // 2.1 通过for循环
        for(String key : keySet){
            System.out.print(key);
            System.out.println(map.get(key));
        }

        // 2.2 通过迭代器:先获得key的Iterator,再循环遍历
        Iterator iter2 = keySet.iterator();
        String key = null;
        while (iter2.hasNext()) {
            key = (String)iter2.next();
            System.out.print(key);
            System.out.println(map.get(key));
        }


        // 方法3:获得value的Set集合 再遍历
        // 1. 获得value的Set集合
        Collection valueSet = map.values();

        // 2. 遍历Set集合,从而获取value
        // 2.1 获得values 的Iterator
        Iterator iter3 = valueSet.iterator();
        // 2.2 通过遍历,直接获取value
        while (iter3.hasNext()) {
            System.out.println(iter3.next());
        }

    }


}

// 注:对于遍历方式,推荐使用针对 key-value对(Entry)的方式:效率高
// 原因:
   // 1. 对于 遍历keySet 、valueSet,实质上 = 遍历了2次:1 = 转为 iterator 迭代器遍历、2 = 从 HashMap 中取出 key 的 value 操作(通过 key 值 hashCode 和 equals 索引)
   // 2. 对于 遍历 entrySet ,实质 = 遍历了1次 = 获取存储实体Entry(存储了key 和 value )

4、重要参数:

  // 1. 容量(capacity): 必须是2的幂 
  static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量 = 16 
  static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 =  2的30次方(若传入的容量过大,将被最大值替换)

  // 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度 
  final float loadFactor; // 实际加载因子
  static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子 = 0.75

  // 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表
  //    扩容阈值 = 容量 x 加载因子
  int threshold;

  // 4. 其他
  transient Node<K,V>[] table;  
  transient int size;// HashMap的大小,即 HashMap中存储的键值对的数量
 

  /** 
   * 与红黑树相关的参数
   */
   // 1. 桶的树化阈值:当链表长度 > 该值时,则将链表转换成红黑树
   static final int TREEIFY_THRESHOLD = 8; 
   // 2. 桶的链表还原阈值:当在扩容resize(),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
   static final int UNTREEIFY_THRESHOLD = 6;
   // 3. 最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
   // 否则,若桶内元素太多时,则直接扩容,而不是树形化
   // 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
   static final int MIN_TREEIFY_CAPACITY = 64;

加载因子(负载因子):
在查找效率&空间利用率之间寻找一种平衡,
loadFactor越小,链表越短,查找效率越高,但也需要注意频繁扩容损耗性能
loadFactor越大,链表越长,空间利用率越高。

5、map.put():存储操作流程

1、计算桶下标:hash(key)

  1. tab = resize()创建hashMap时并没有初始化,第一次调用put时触发初始化条件
  2. h = key.hashCode()计算hash值(得到32位的hash值)
  3. h ^ (h >>> 16)将键的hashcode的高16位异或低16位(高位运算),减少hash冲突(简单理解为使元素分布更加均匀,提高查询效率)JDK1.8
  4. (length - 1) & hash取模:取模运算开销大,可以利用位运算代替
令一个数y与x-1做 与运算(前提x是2的n次方)
y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的
y   : 10110010
x   : 00010000
y%x : 00000010
  1. 所以key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

JDK1.8做了2次扰动处理 =1次位运算 + 1次异或运算;
JDK1.7做了9次扰动处理 =4次位运算 + 5次异或运算;
初始化: (1.8集成在扩容函数中,1.7是inflateTable(threshold)😉)


(补充)对key为null(返回hash值为0),HashMap 使用第 0 个桶存放键为 null 的键值对。

2、put():具体存放的数据结构 (JDK1.8)

  1. 尝试插入or更新数组;
  2. 如果发生Hash碰撞,判断当前节点的数据结构(红黑树or链表)
    2.1:优先判断红黑树,如果是则插入or更新红黑树
    2.2:否则插入or更新链表
  3. 如果是插入链表节点,需要判断(是否树化?链表长度>8)
  4. 插入节点后判断(是否扩容)

1.7采用头插法
1.8采用尾插法

5、扩容:基本原理

  1. 设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。

  2. 为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

  3. 扩容resize()实现需要将oldTable中所有键值对放入newTable中,这很费时,并且要重新计算桶下标

6、扩容:重新计算桶下标

由上述可知,通过位运算代替取模运算。假如原来容量为16,扩容两倍为32。

capacity     : 00010000(16)
new capacity : 00100000(32)

capacity -1     : 00001111
new capacity -1 : 00011111
只有第五位不同

JDK1.8 对于一个 Key,它的哈希值 hash 在第 5 位:

为 0,那么 hash%00010000 = hash%00100000,桶位置和原来一致;
为 1,hash%00010000 = hash%00100000 + 16,桶位置是原位置 + 原容量。

7、扩容:具体流程JDK1.8

  1. 异常情况判断(当前为最大容量则不扩容)
  2. 创建两倍原容量新数组
  3. 重点区别)遍历旧数组中的元素,重新计算下标(优化),尾插法添加到新数组中

1.8较1.7在扩容方面优化较大:

1、重新计算下标时:
1.8 直接if ((e.hash & oldCap) == 0)判断放在原索引还是原索引+oldCap
1.7则要重新执行计算下标(包括9次扰动)


2、插入位置不同:
1.8 采用尾插法
1.7 采用头插法(在并发扩容会出现环形链表死锁情况)


3、插入数据的时机:
1.8 先插入后扩容
1.7 先扩容后插入

7、JDK 1.8与 JDK 1.7 的区别总结

  1. 数据结构
  2. hash值计算
  3. 初始化方式(调用函数不同,但都是第一次调用put方法才初始化Map)
  4. 插入数据方式
  5. 扩容后重新计算下标方式
  6. 插入数据需要扩容时的插入时机

8、计算数组容量capacity:主要应用在构造函数中
因为要保持capacity是2的n次方才能满足位运算代替取模。
但是hashMap的构造方法中并没有强制要求传入容量为2的n次方,这里因为内部会自动转换成2的n次方,原理如下:

  1. 掩码定义:
    在这里插入图片描述

  2. 先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:

mask |= mask >> 1    11011000
mask |= mask >> 2    11111110
mask |= mask >> 4    11111111
  1. mask+1 是大于原始数字的最小的 2 的 n 次方。
num     10010000
mask+1 100000000
  1. 以下是 HashMap 中计算数组容量的代码:
static final int MAXIMUM_CAPACITY = 1 << 30; //int类型最大值

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    //移位求掩码,int为32位
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

>> : 带符号位右移
>>> : 无符号位右移
^ : 按位异或

9、补充:1.7并发死锁问题

HashMap在jdk1.7中采用头插入法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题。
而在jdk1.8中采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

具体参考:Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?

10、补充:为什么String、Integer 这样的包装类适合作为 key 键:
因为这些包装类保证了hash的不可更改性&计算准确性

  1. final类型保证key不可更改
  2. 重写了equals和hashCode方法,计算准确减少hash冲突

11、补充:HashMap 中的 key若 Object类型, 则需实现哪些方法:
equals和hashCode方法:

  1. hashCode:实现不好会导致严重的hash冲突
  2. equals:要保证key在hashMap中的唯一性

12、补充:线程不安全:
因为多线程环境下,使用Hashmap进行put操作会引起死循环,所以在并发情况下不能使用HashMap。
而hashTable性能不佳,在并发情况下推荐使用ConcurrentHashMap。

参考文章:Java 容器

参考文章:JAVA集合框架

参考文章:Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?

ConcurrentHashMap(1.7)

1、出现原因:

  1. hashMap并发不安全,不能使用;
  2. hashTable虽然线程安全,但是并发效率极低,加锁后排斥读写操作
  3. Collections提供的同步包装器利用比较粗粒度的同步方式,高并发下性能不佳

2、数据结构:
在这里插入图片描述
1、ConcurrentHashMap在JDK1.7是由Segment数组和HashEntry链表结构组成。
2、Segment扮演锁(ReentrantLock)的角色,HashEntry中用volatile修饰value&next
3、ConcurrentHashMap采用了分段锁,并发度高,默认的并发级别为 16 即 Segment数组长度。

volatile特性:可见性、有序性、不能保证原子性

*有些方法需要跨段访问,size()等,可能需要锁住这个表而不是某段
这需要按顺序加锁解锁,否则容易出现死锁,因此顺序是固定的,由final修饰段数组

3、put操作原理:

  1. 异常判断(判断val==null?报错 (不能插入null值)
  2. 根据 key 计算出 hashcode(二次哈希)
  3. 定位到segment
  4. scanAndLockForPut()会去查找是否有key相同的Node(即通过 key 的 hashcode 定位到 HashEntry)
  5. tryLock()尝试获取锁。失败则存在线程竞争,利用scanAndLockForPut()自旋获取锁
  6. 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,无论如何都保证能获取锁成功
  7. 拿到锁后,遍历HashEntry,更新or插入key-value;

扩容问题ConcurrentHashMap同样存在,但它进行的不是整体的扩容,而是单独对Segment进行扩容

4、get操作原理:

  1. 与put类似,首先两次定位到HashEntry上
  2. 由于value是volatile修饰,保证可见性,所有get操作不用加锁,非常高效

5、size()操作原理:涉及分段锁的一个副作用

  1. 等价于统计所有Segment里元素的大小后求和。在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?
  2. 不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove方法全部锁住,但是这种做法显然非常低效。
  3. 因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的;如果尝试的次数超过 3 次,就需要对每个Segment 加锁
  4. 那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。
    其实分段锁还限制了Map的初始化操作

6、以上基于JDK1.7存在的问题:

  1. 查询时需要遍历链表,查询效率不高,1.8中优化引入红黑树,O(n)->O(logn)

1.8中ConcurrentHashMap

主要优化:

  1. 抛弃了segment锁(ReentrantLock),改用CAS+synchronized,并发度进一步提升
  2. 不再使用Segment,初始化操作大大简化,修改为lazy_load形式,有效避免初始开销
  3. 引入红黑树

1、put流程:

  1. if (key == null || value == null) throw new NullPointerException();key和value不能为空
  2. 根据 key 计算出 hashcode 。
  3. 判断是否需要进行初始化initTable();。(1.7中没有这一步,分段锁限制了初始化)
  4. 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  5. 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  6. 如果都不满足,则利用 synchronized 锁写入数据。(遍历链表or红黑树、更新or尾部插入数据)
  7. 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

CAS是什么?
是乐观锁的一种实现方式,,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。


CAS局限性:
CAS不一定能保证数据没被其它线程修改,例如ABA问题;


解决方法:
版本号、时间戳

CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?

  1. synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。
  2. 针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
  3. 所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的
  4. 另外,相比于ReentrantLock,他可以减少内存消耗

2、get流程:与hashMap一致
3、size()操作原理:
也是利用分而治之计数,最后求和即可

SynchronizedMap&HashTable

1、讲讲早期的并发容器:

  1. 粗粒度的同步方式,对所有方法上锁,锁住整张表,在高并发下性能极低

2、SynchronizedMap:

  1. Collections.synchronizedMap(Map)创建一个SynchronizedMap
  2. 它的内部维护了一个普通对象Map,还有排斥锁mutex
  3. 它有两个构造器,如果你传入了mutex参数,则将对象排斥锁赋值为传入的对象。如果没有,则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map。
  4. 再操作map时,它的所有方法都会上锁

3、HashTable:

HashTable效率也比较低下,原因是:它只要操作数据都会上锁,例如get方法

HashTable和HashMap区别:

  1. 对null值的处理不同;HashMap的key-value都能为null,但HashTable则不允许
  2. 初始化容量不同;HashMap 的初始容量为16,Hashtable 初始容量为11,两者的负载因子默认都是:0.75。
  3. 扩容机制不同;当现有容量大于扩容阈值时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
  4. 迭代器不同;HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。
  5. 实现方式不同;Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。

4、fail—fast & fail—safe:

HashTable的key-value为空值会报空指针异常;原因是:
这是因为Hashtable使用的是安全失败机制(fail-safe),这种机制会使你此次读到的数据不一定是最新的数据。

如果你使用null值,就会使得其无法判断对应的key是不存在还是为空,因为你无法再调用一次contain(key)来对key是否存在进行判断,ConcurrentHashMap同理。


fail-fast是啥?

  1. 快速失败(fail—fast)是java集合中的一种机制, 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
  2. 原理:迭代器遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
  3. Tip:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。
    因此,不能依赖于这个异常是否抛出而进行并发操作的编程

场景:
快速失败(fail—fast),java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。
安全失败(fail—safe),java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!