【集合篇】Map 集合详解

Map 集合详解

HashMap 和 Hashtable 的区别

线程安全性:

HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);

效率:

因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它

对 null 键 和 null 值 的支持:

HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException

初始容量大小和每次扩充容量大小的不同 :

① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍

② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,利用右位移运算)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。

底层数据结构:

  • JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。

    桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。

  • Hashtable 没有这样的机制。

HashMap 和 HashSet 区别

image-20230412165201719

用途:

  • HashMap:用于存储键值对(Key-Value)映射关系的数据结构,其中每个键唯一对应一个值。
  • HashSet:用于存储不重复元素的集合,它基于 HashMap 实现,只存储元素而没有键值对的映射关系。

存储方式:

  • HashMap:存储键值对,通过键查找值。键可以是任何非空对象,值可以是任何对象。
  • HashSet:存储不重复的元素,通过元素本身来进行查找和判重。

内部实现:

  • HashMap:内部使用数组和链表(或红黑树)的组合来实现,通过哈希函数将键映射到数组的索引位置,以提高键的查找效率。
  • HashSet:基于 HashMap 实现,它的元素就是 HashMap 的键,值则是一个固定的常量(一直是一个 Object 对象)。

操作和用法

  • HashMap:适用于需要存储键值对关系的情况,例如缓存、映射关系等。
  • HashSet:适用于存储不重复元素的情况,例如需要快速判断某个元素是否存在等。

性能

  • HashMap:相对于 HashSet,HashMap 需要存储键值对,因此额外占用一些内存,但可以存储更多的信息。
  • HashSet:相对于 HashMap,HashSet 只需要存储元素,占用的内存较少,但不能存储键值对关系。

总之,HashMap 适用于存储键值对关系,而 HashSet 适用于存储不重复元素的集合。实际上,HashSet 在内部使用了 HashMap 来实现,它们之间存在一定的关联和相似性。

hashCode 源码

hashCode() 方法返回的值是一个 int 类型的数字,用于表示对象的哈希值。这个哈希值不一定是对象的地址,也不一定是唯一的。在计算哈希值时,一般会使用对象的属性来计算。例如,如果一个 Person 类具有 nameage 两个属性,那么可以将它们的哈希值合并起来计算:

public int hashCode() {
    int result = 17;
    result = 31 * result + name.hashCode();
    result = 31 * result + age;
    return result;
}

这段代码中使用了一个经典的哈希算法,称为“31 哈希法”。这个算法将初始值设为一个质数 17,然后将属性的哈希值依次乘以 31 并相加,得到最终的哈希值。由于 31 是一个奇素数,可以保证乘法过程不会产生哈希冲突。

需要注意的是,虽然哈希值不一定是地址,但是在 Java 中,如果没有为对象指定 hashCode() 方法的实现,那么默认情况下,它的 hashCode() 方法会返回对象的地址,因此在这种情况下,两个对象的哈希值可能会相同,但这并不是一个好的哈希算法。因此,在实现自定义的哈希算法时,需要保证哈希值的分布尽可能均匀,并且不容易产生哈希冲突。

为什么哈希函数能降低哈希碰撞?

因为好的哈希函数可以将输入的数据均匀、随机地映射到哈希空间,降低了碰撞的可能性,从而提高了哈希表等数据结构的性能和稳定性。

HashMap 和 TreeMap 区别

TreeMapHashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。

image-20230414162045562

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力

实现SortedMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:

public class Person {
    private Integer age;
    public Person(Integer age) {
        this.age = age;
    }
    public Integer getAge() {
        return age;
    }

    public static void main(String[] args) {
        TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}

可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。

上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:

TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
  int num = person1.getAge() - person2.getAge();
  return Integer.compare(num, 0);
});

综上,相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力SortedMap)以及对集合内元素的搜索的能力NavigableMap)。

HashSet 如何检查重复?

当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcodeHashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。

直接看一下HashSet中的源码:

在 JDK1.8 中,实际上无论 HashSet 中是否已经存在了某元素,HashSet 都会直接插入,只是会在 add() 方法的返回值处告诉我们插入前是否存在相同元素

// Returns: true if this set did not already contain the specified element
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
        return map.put(e, PRESENT)==null;
}

HashSet 通过 HashMap 的键的唯一性来实现对元素的重复检查。在 HashSet 中,元素被存储为 HashMap 的键,而值都是固定的一个常量对象。当我们向 HashSet 添加元素时,实际上是将这个元素作为键,常量对象作为对应的值存储在 HashMap 中。

在 HashMap 中,键是唯一的,这意味着当我们尝试将相同的元素作为键添加到 HashMap 中时,新的元素会覆盖掉旧的元素。因此,当我们向 HashSet 添加元素时,实际上是在利用 HashMap 的去重特性来保证 HashSet 中的元素唯一性。

例如,考虑以下代码:

HashSet<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(3);
set.add(1);  // 尝试添加重复元素
System.out.println(set.size());  // 输出为 3,因为重复元素被去重

在上述示例中,尝试向 HashSet 中添加重复的元素 1 时,并没有导致 HashSet 中出现重复元素,这是因为 HashSet 利用了 HashMap 的键唯一性来进行去重。

因此,HashSet 能够自动检查并防止重复元素的存在。

HashMap的底层实现

JDK1.8 之前

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode 经过扰动函数(hash方法)处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法(数组+链表)解决冲突

JDK1.8 之后

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

JDK1.7和JDK1.8的hash方法源码对比:

  1. JDK 1.8 的 hash 方法 (运用了三目运算符)相比于 JDK 1.7 hash 方法更加简化,但是原理不变;
  2. JDK 1.7 的 hash 方法的性能会稍差一点点,因为扰动了 4 次

使用扰动函数(hash方法)之后可以减少碰撞

HashMap 的长度为什么是 2 的幂次方

这是因为 HashMap 在计算 key 的哈希值后,需要通过哈希值和数组长度计算出该 key 在数组中的位置

具体的计算方式是 (n - 1) & hash(数组的长度减一的差和 key 的哈希值相与),其中 n 是数组的长度,hash 是 key 的哈希值。

这个计算方式的作用是将哈希值的高位和低位进行一次异或运算,得到 key 在数组中的位置。 如果数组的长度不是 2 的幂次方,那么在计算 (n - 1) & hash 时,得到的结果可能会存在一些哈希值无法均匀分布到数组中的位置的情况,从而导致某些位置上的链表或红黑树会变得过长,影响 HashMap 的性能。因此,为了避免这种情况的发生,HashMap 的长度总是保持为 2 的幂次方。 另外,对于长度为 2 的幂次方的数组,计算 (n - 1) & hash 时,等价于对数组长度取模,这种计算方式的效率比较高,而且可以保证均匀分布。因此,HashMap 采用长度为 2 的幂次方的数组,可以提高 HashMap 的性能和效率

HashMap 多线程操作导致死循环问题

在多线程环境下,如果多个线程同时对 HashMap 进行操作,可能会导致 HashMap 的链表或红黑树出现环形,从而导致死循环问题。这种问题通常发生在以下情况下:

  1. 多个线程同时调用 put() 方法,导致链表或红黑树的结构发生变化,从而导致链表或红黑树出现环形。
  2. 多个线程同时调用 resize() 方法,导致数组扩容时链表或红黑树的结构发生变化,从而导致链表或红黑树出现环形。 当出现链表或红黑树环形时,会导致 HashMap 的遍历操作进入死循环,从而影响程序的性能和可靠性。

为了解决这个问题,可以采用以下几种方式:

  1. 使用线程安全的 ConcurrentHashMap,它是线程安全的 HashMap,可以安全地在多线程环境下进行操作。
  2. 对于需要在多线程环境下使用 HashMap 的情况,可以使用锁来保证同一时间只有一个线程能够对 HashMap 进行操作。
  3. 避免多个线程同时对 HashMap 进行操作,可以将对 HashMap 的操作放在同步块中,确保同一时间只有一个线程能够对 HashMap 进行修改。
  4. 通过使用线程安全的并发数据结构,例如 ConcurrentLinkedQueue、ConcurrentHashMap 等,避免在多线程环境下使用 HashMap。

HashMap 有哪几种常见的遍历方式?

HashMap 的 7 种遍历方式与性能分析!

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMapHashtable 都是线程安全的集合类,但是它们在实现和性能上有一些区别:

  1. 底层数据结构:
    • JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
    • Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  2. 实现线程安全的方式(重要):
  • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment分段锁),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
  • 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表 / 红黑树的数据结构来实现,并发控制使用 synchronizedCAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
  • Hashtable(同一把锁): 使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
  1. 对 null 值的支持不同

    • Hashtable 不允许 keynull
    • ConcurrentHashMap 则**( JDK7 时)**允许 key 和 value 均为 nullJDK8 后不再允许(因为使用了 CAS – 保证线程安全的,对 null 值的支持发生了变化)。
  2. 扩容机制不同:Hashtable 在扩容时使用的是原来容量的 2 倍加 1,而 ConcurrentHashMap 在扩容时采用的则是分段锁技术,当一个段需要进行扩容时,只需要锁定这个段,而不需要锁定整个哈希表。

    ConcurrentHashMap 在设计上更加注重并发性能,通过分段锁来减小锁粒度,而不是仅对一个段进行扩容。

ConcurrentHashMap 线程安全底层具体实现

JDK1.8 之前

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的个数一旦初始化就不能改变Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。

Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。

JDK1.8 之后

  • Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。
  • ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
  • Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

锁粒度解析

锁粒度指的是锁定的范围大小,通常分为粗粒度锁和细粒度锁两种类型。

  1. 粗粒度锁 粗粒度锁指的是锁定范围较大的锁,通常是对整个对象或整个方法进行加锁。粗粒度锁的优点是实现简单,易于控制,缺点是锁定范围太大,会导致并发性能下降,降低程序的并发度。
  2. 细粒度锁 细粒度锁指的是锁定范围较小的锁,通常是对对象中的某个属性或某一段代码进行加锁。细粒度锁的优点是锁定范围小,可以提高程序的并发度,缺点是实现复杂,容易出现死锁等问题。

在实际开发中,应该根据实际情况选择合适的锁粒度。如果锁的范围太大,会导致并发性能下降,如果锁的范围太小,会增加锁的竞争,降低程序的并发度可以通过压力测试等方式来确定合适的锁粒度

JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?

线程安全实现方式

  • JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock
  • JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。

Hash 碰撞解决方法 :

  • JDK 1.7 采用拉链法,
  • JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。

并发度

  • JDK 1.7 最大并发度是 Segment 的个数,默认是 16。
  • JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

为什么 HashMap 链表转红黑树的阈值为 8 呢?

这是一个经验性的设定。这个设定是为了在哈希表中维护合适的性能和空间开销之间找到平衡。

选择 8 是基于一系列性能测试和实际应用场景得出的。当链表长度超过这个阈值时,链表的查找性能可能会下降,而转换为红黑树可以提高性能。然而,如果链表长度较短,转换为红黑树可能会带来额外的开销,所以选择一个合适的阈值是一个权衡。

HashMap 的扩容机制了解吗?

了解。当 HashMap 中的元素数量达到一定阈值时,就会触发扩容操作,以保持哈希桶的负载因子在一个合适的范围内,从而减少哈希冲突,提高查找、插入和删除的性能。

HashMap 的扩容机制主要包括以下 3 个步骤:

  1. 创建新的哈希桶数组:当 HashMap 中的元素数量达到阈值时,创建一个新的数组,其大小是原数组的两倍。
  2. 将旧数据转移到新数组:遍历旧的哈希桶数组中的每个元素,将其重新计算哈希值后放入新的哈希桶数组中。这是一个耗时操作,但只需要进行一次。
  3. 更新引用:将 HashMap 的哈希桶数组引用指向新的数组,同时更新阈值等信息。

扩容操作会在 HashMap 的插入操作中触发,具体的触发时机是当元素数量超过了负载因子乘以当前数组容量时。默认情况下,负载因子为 0.75,即当元素数量超过当前数组容量的 75% 时,会触发扩容。

扩容在什么时候呢?为什么扩容因子是 0.75?

HashMap 在进行扩容的时候,通常是当当前容器中的元素数量超过了容器大小的 75% 时触发扩容操作。

这个扩容因子(load factor)的选择是为了在空间和时间上达到一个平衡,从而保证 HashMap 在不至于浪费过多内存的情况下,仍能保持较低的哈希冲突,提供高效的查找、插入和删除操作。

为什么扩容因子选择 0.75 呢?

这是一个折中的选择,考虑了时间和空间的平衡。如果扩容因子设置得太小,会导致哈希冲突过多,影响了 HashMap 的性能;如果设置得太大,虽然哈希冲突可能减少,但是会导致 HashMap 占用更多的内存空间。

为什么是 2 倍?

使用 2 的次幂作为数组的容量可以确保 hashCode 的高位和低位能够更好地均匀分布在数组的索引位置上。这样一来,在计算元素存放位置时,只需要进行位运算,而不需要使用取模等耗时的操作,提高了计算效率。

另外,使用 2 的次幂作为容量也方便进行扩容操作,因为 2 的次幂的二进制表示只有一个 1,这样在扩容时只需要将高位多出的 1 变为 0,就可以得到新的容量。这种设计可以减少内存空间的浪费。

总的来说就是:

  • 这样设计在计算元素存放位置时可以提高计算效率;
  • 在进行扩容时可以减少内存空间的浪费。
  • 既提高了效率又减少了时间。

高位是在左边

  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在使用 Easypoi 进行 Excel 导入时,如果需要导入集合类型的数据,可以通过以下步骤实现: 1. 在 Java 实体类中定义属性时,需要使用集合类型,比如 List、Set、Map 等。 2. 在 Excel 模板中,需要在对应的单元格中输入集合数据,以逗号分隔不同的元素,比如“1,2,3,4,5”。 3. 使用 Easypoi 的 ExcelImportUtil.importExcelMore 方法导入 Excel 文件时,需要设置一个参数为集合类型的 Class 对象,比如 List.class、Set.class、Map.class,具体根据实体类中属性的类型来决定。 4. 在导入后,可以通过获取 Java 实体类中集合属性的值来获取导入的集合数据。 例如,假设有一个实体类 User,其中包含一个 List 类型的属性 hobbies: ```java public class User { private String name; private Integer age; private List<String> hobbies; // getter 和 setter 省略 } ``` 在 Excel 模板中,我们可以在第三列中输入用户的爱好,以逗号分隔不同的元素。 接着,在导入 Excel 文件时,需要设置集合类型的 Class 对象: ```java List<User> userList = ExcelImportUtil.importExcelMore( new File("user.xlsx"), User.class, ImportParams.DEFAULT, List.class); ``` 最后,在获取每个 User 对象时,可以通过 getHobbies() 方法获取导入的爱好数据: ```java for (User user : userList) { System.out.println(user.getName() + " 的爱好有:" + user.getHobbies()); } ``` 以上就是使用 Easypoi 导入集合类型数据的详细步骤。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值