java集合学习目录
一、基本概要
0. 辅助工具类
0.1 Collection 和 Collections 有什么区别?
java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。Collection接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有List与Set。
Collections则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行
排序
、搜索
以及线程安全
等各种操作。
0.2 comparable 和 comparator的区别?
包路径不一致
Comparable
接口在java.lang
包下,它有一个compareTo(Object obj)
方法用来排序;Comparator
接口在java.util
包下,它有一个compare(Object obj1, Object obj2)
方法用来排序比较(排序)
一个类实现了
Comparable
接口,意味着该类的对象可以直接进行比较(排序),但比较(排序)的方式只有一种,很单一;一个类如果想要保持原样,又需要进行不同方式的比较(排序),就可以定制比较器(实现
Comparator
接口)。
Comparable
更多的像一个内部比较器,而Comparator
更多的像一个外部比较器(体现了一种策略模式,耦合性较低);
- 如果对象的排序需要基于自然顺序,请选择
Comparable
;- 如果需要按照对象的不同属性进行排序,请选择
Comparator
。
1.什么是集合
-
集合:用于存储数据的容器
-
数组和集合的区别:
- 数组是固定长度的;集合是可变长度的。
- 数组可以存储基本数据类型,也可以存储引用数据类型;集合只能存储引用数据类型。
- 数组是Java语言中内置的数据类型,是线性排列的,执行效率和类型检查都比集合快,集合提供了众多的属性和方法,方便操作。
-
数组和集合联系:
通过集合的
toArray()
方法可以将集合转换为数组,通过Arrays.asList()
方法可以将数组转换为集合
2.集合的分类
Java 集合容器分为 Collection 和 Map 两大类,也就是说Map
接口和Collection
接口是所有集合框架的父接口。
2.1 Collection接口
Collection集合的子接口有Set,List两个;
- Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等;
- List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等;
2.2 Map接口
Map接口下的子接口有HashMap、TreeMap、Hashtable、ConcurrentHashMap等
二、集合框架底层数据结构
1. 😊Collection
1.1 ❤List
- ArrayList: Object数组
- Vector: Object数组
- LinkedList: 双向循环链表
1.2 ❤Set
- HashSet(无序,唯一):基于 HashMap 实现的,底层采用 HashMap 来保存元素,放到 HashSet集合中的元素等同于放到HashMap集合key部分了。
- LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的。
- TreeSet(有序,唯一): 基于TreeMap实现,放到 TreeSet集合中的元素等同于放到TreeMap集合key部分。
2. 😊Map
-
❤HashMap: JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突);
JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间;
-
LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
-
HashTable: 数组+链表(哈希表)组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
-
TreeMap: 红黑树(自平衡的排序二叉树)
3. 线程安全的集合
- vector:就比arraylist多了个同步化机制(线程安全),因为效率较低,现在已经不太建议使用。在web应用中,特别是前台页面,往往效率(页面响应速度)是优先考虑的。
- statck:堆栈类,先进后出。
- hashtable:就比hashmap多了个线程安全。
- enumeration:枚举,相当于迭代器。
- currentHashMap:current包下的(实现Map接口)
三、集合具体讲解
1. Collection接口
1.1 Collection集合主要有List、Set和Queue接口
- List:一个有序集合(元素存入集合的顺序和取出的顺序一致),元素可以重复可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
- Set:一个无序集合(存入和取出顺序有可能不一致),不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
- Queue/Deque,则是 Java 提供的标准队列结构的实现,除了集合的基本功能,它还支持类似先入先出(FIFO, First-in-First-Out)或者后入先出(LIFO,Last-In-First-Out)等特定行为。常用实现类有ArrayDeque、ArrayBlockingQueue、LinkedBlockingDeque
1.2 list和set的区别
1.3 list集合
1.3.1 ArrayList
底层数据结构是数组,查询快,增删慢,线程不安全,效率高,可以存储重复元素;
1.3.2 LinkedList
底层数据结构是链表,查询慢,增删快,线程不安全,效率高,可以存储重复元素;
1.3.3 Vector
底层数据结构是数组,查询快,增删慢,线程安全,效率低,可以存储重复元素;
1.3.4 小结
1.4 Set集合
1.4.1 HashSet
底层数据结构采用哈希表实现,元素无序且唯一,线程不安全,效率高,可以存储null元素;
元素的唯一性是靠所存储元素类型是否重写
hashCode()
和equals()
方法来保证的,如果没有重写这两个方法,则无法保证元素的唯一性。
- 实现唯一性的比较过程:
存储元素首先会使用
hash()
算法函数生成一个int类型hashCode
散列值,然后已经的所存储的元素的hashCode
值比较,如果hashCode
不相等,则所存储的两个对象一定不相等,此时存储当前的新的hashCode
值处的元素对象;如果
hashCode
相等,存储元素的对象还是不一定相等,此时会调用equals()
方法判断两个对象的内容是否相等,如果内容相等,那么就是同一个对象,无需存储;如果比较的内容不相等,那么就是不同的对象,就该存储了,此时就要采用哈希的解决地址冲突算法,在当前
hashCode
值处类似一个新的链表, 在同一个hashCode
值的后面存储存储不同的对象,这样就保证了元素的唯一性。
-
Object类中的hashCode()的方法
是所有子类都会继承这个方法,这个方法会用Hash算法算出一个Hash(哈希)码值返回,HashSet会用Hash码值去和数组长度取模, 模(这个模就是对象要存放在数组中的位置)相同时才会判断数组中的元素和要加入的对象的内容是否相同,如果不同才会添加进去。
-
Hash算法
Set的实现类的集合对象中不能够有重复元素,HashSet也一样他是使用了一种标识来确定元素的不重复,HashSet用一种算法(hash算法)来保证HashSet中的元素是不重复的, HashSet采用哈希算法,底层用数组存储数据。默认初始化容量16,加载因子0.75。
Hash算法存放值
Set hs=new HashSet(); hs.add(o); o.hashCode(); // o%当前总容量 (0–15)(%:除留余数法) 根据计算出来的hashcode值是否一致: 1.值不一样则不发生冲突-->直接存放 2.值一样则发生冲突 2.1 进行比较 o1.equals(o2) 相等则不添加,也就是覆盖; 不相等,找一个空位添加;
-
覆盖hashCode()方法的原则
- 1、一定要让那些我们认为相同的对象返回相同的hashCode值
- 2、尽量让那些我们认为不同的对象返回不同的hashCode值,否则,就会增加冲突的概率。
- 3、尽量的让hashCode值散列开(两值用异或运算可使结果的范围更广)
HashSet 的实现比较简单,相关HashSet的操作,基本上都是直接调用底层HashMap的相关方法来完成,我们应该为保存到HashSet中的对象覆盖
hashCode()和equals()
,因为再将对象加入到HashSet中时,会首先调用hashCode方法计算出对象的hash值,接着根据此hash值调用HashMap中的hash方法,得到的值& (length-1)得到该对象在hashMap的transient Entry[] table
中的保存位置的索引,接着找到数组中该索引位置保存的对象,并调用equals方法比较这两个对象是否相等,如果相等则不添加,注意:所以要存入HashSet的集合对象中的自定义类必须覆盖
hashCode(),equals()
两个方法,才能保证集合中元素不重复。在覆盖equals()和hashCode()
方法时, 要使相同对象的hashCode()方法返回相同值,覆盖equals()方法再判断其内容。为了保证效率,所以在覆盖hashCode()方法时, 也要尽量使不同对象尽量返回不同的Hash码值。如果数组中的元素和要加入的对象的hashCode()返回了相同的Hash值(相同对象),才会用equals()方法来判断两个对象的内容是否相同。
1.4.2 LinkedHashSet
底层数据结构采用链表和哈希表共同实现,链表保证了元素的顺序与存储顺序一致,哈希表保证了元素的唯 一性。线程不安全,效率高。
1.4.3 TreeSet
底层数据结构采用二叉树来实现,元素唯一且已经排好序;
- 唯一性同样需要重写hashCode和equals()方法,二叉树结构保证了元素的有序性。
- 根据构造方法不同,分为自然排序(无参构造)和比较器排序(有参构造);
- 自然排序要求元素必须实现Compareable接口,并重写里面的compareTo()方法,元素通过比较返回的int值来判断排序序列,返回0说明两个对象相同,不需要存储;
- 比较器排序需要在TreeSet初始化是时候传入一个实现Comparator接口的比较器对象,或者采用匿名内部类的方式new一个Comparator对象,重写里面的compare()方法;
1.4.4 小结
-
Set具有与Collection完全一样的接口,因此没有任何额外的功能,不像前面有两个不同的List。实际上Set就是Collection,只 是行为不同。(这是继承与多态思想的典型应用:表现不同的行为。)
-
Set不保存重复的元素。
-
Set 存入Set的每个元素都必须是唯一的,因为Set不保存重复元素。加入Set的元素必须定义equals()方法以确保对象的唯一性。Set与Collection有完全一样的接口。Set接口不保证维护元素的次序。
1.5 List和Set的总结
list | set |
---|---|
元素放入有序,元素可重复 | 元素无放入顺序,且不可重复,重复元素会覆盖掉 |
list支持for循环,也就是通过下标来遍历,也可以用迭代器 | set只能用迭代,因为他无序,无法用下标来取得想要的值。) |
和数组类似,List可以动态增长,查找元素效率高(arraylist),插入删除元素效率低,因为会引起其他元素位置改变 | 检索元素效率低下(无索引),删除和插入效率高,插入和删除不会引起元素位置改变 |
注意:set集合元素虽然无放入顺序,但是元素在set中的位置是有该元素的HashCode决定的,其位置其实是固定的,加入Set 的Object必须定义equals()方法
1.6 ArrayList、LinkedList、Vector 区别
三者都是实现集合框架中的 List 接口,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供搜索、添加或者删除的操作,都提供迭代器以遍历其内容等功能。
-
数据结构实现:ArrayList 和 Vector 是动态数组的数据结构实现,而 LinkedList 是双向循环链表的数据结构实现。
-
随机访问效率:ArrayList 和 Vector 比 LinkedList 在根据索引随机访问的时候效率要高,因为 LinkedList 是链表数据结构,需要移动指针从前往后依次查找。
-
增加和删除效率:在非尾部的增加和删除操作,LinkedList 要比 ArrayList 和 Vector 效率要高,因为 ArrayList 和 Vector 增删操作要影响数组内的其他数据的下标,需要进行数据搬移。因为 ArrayList 非线程安全,在增删元素时性能比 Vector 好。
-
内存空间占用:一般情况下LinkedList 比 ArrayList 和 Vector 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,分别是前驱节点和后继节点
-
线程安全:
-
ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
-
Vector 使用了 synchronized 来实现线程同步,是线程安全的。
-
-
扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍容量,而 ArrayList 只会增加 50%容量。
-
使用场景:在需要频繁地随机访问集合中的元素时,推荐使用 ArrayList,希望线程安全的对元素进行增删改操作时,推荐使用Vector,而需要频繁插入和删除操作时,推荐使用 LinkedList。
1.7 ArrayList和LinkedList
ArrayList | LinkedList | |
---|---|---|
优点 | ArrayList是基于动态数组的数据结构,地址连续, 实现了 RandomAccess 接口,根据索引进行随机查询效率非常高,时间复杂度O(1); | LinkedList基于链表的数据结构,地址是任意的,所以在开辟内存空间的时候不需要一个连续的地址,对于新增和删除操作add和remove,LinedList比较占优势。LinkedList 适用于要头尾操作或插入指定位置的场景 |
缺点 | 因为地址连续, 所以在非尾部的增加和删除操作,影响数组内的其他数据的下标,需要进行数据搬移,比较消耗性能(尾插效率高哈哈!) | 因为LinkedList要移动指针,所以查询操作性能比较低。 |
适用场景 | 适合顺序添加、随机访问的场景。 | 当需要对数据进行多次增加删除修改时采用LinkedList。 |
1.8 总结适用场景
Iterator
2. Iteraror迭代器
❤2.1 关于Iterator 迭代器
2.1.1 Iterator迭代器是什么?用来干什么?(用来遍历集合元素)
Iterator 接口提供遍历任何 Collection 的接口。
我们可以从一个 Collection 中使用迭代器方法来获取迭代器实例。
迭代器取代了 Java 集合框架中的 Enumeration,迭代器允许调用者在迭代过程中移除元素。
2.1.2 迭代器怎么用?他的特点是什么?
Iterator 使用代码如下:以ArrayList为例
List<String> list = new ArrayList<>(); Iterator<String> it = list. iterator(); while(it. hasNext()){ String obj = it. next(); System. out. println(obj); }
Iterator 的特点:
只能单向遍历,但是更加安全,因为它可以确保,在当前遍历的集合元素被更改的时候,就会抛出 ConcurrentModificationException 异常。
2.1.3 如何边遍历边移除 Collection 中的元素?
边遍历边修改 Collection 的唯一正确方式是使用 Iterator.remove() 方法,如下:
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
// do something
it.remove();
}
一种最常见的错误代码如下:
for(Integer i : list){
list.remove(i)
}
//运行以上错误代码会报 ConcurrentModificationException 异常。这是因为当使用 foreach(for(Integer i : list)) 语句时,会自动生成一个iterator 来遍历该 list,但同时该 list 正在被 Iterator.remove() 修改。Java 一般不允许一个线程在遍历 Collection 时另一个线程修改它。
❤2.2 遍历一个 List 有哪些不同的方式?每种方法的实现原理是什么?Java 中 List 遍历的最佳实践是什么?
2.2.1 遍历方式有以下几种:
-
for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后停止。😊ArrayList使用最佳,不建议LinkedList使用,Set不能用
-
迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏蔽不同数据集合的特点,统一遍历集合的接口。Java 在 Collections 中支持了 Iterator 模式。😊Set只能用迭代遍历;LinkedList也可用
-
foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能在遍历过程中操作数据集合,例如删除、替换。😊LinkedList可以用
2.2.2 最佳实践:Java Collections 框架中提供了一个 RandomAccess 接口,用来标记 List 实现是否支持 Random Access。
如果一个数据集合实现了该接口,就意味着它支持 Random Access,按位置读取元素的平均时间复杂度为 O(1),如ArrayList。如果没有实现该接口,表示不支持 Random Access,如LinkedList。推荐的做法就是,支持 Random Access 的列表可用 for 循环遍历,否则建议用 Iterator 或 foreach 遍历。
Map接口
3. Map接口
Map用于保存具有一一对应的映射关系的数据,key和value,它们都可以使任何引用类型的数据,但key不能重复。(重复就会被覆盖)所以通过指定的key就可以取出对应的value。
Map 接口提供 3 种集合的视图: Map 的内容可以被当作
①一组 key 集合;
②一组 value 集合;
③或者一组 key-value 映射;
3.1 Map接口下的几个常见实现类(重点学习HashMap)
-
❤HashMap:允许null值和null键。 null键只能有一个,null值可以有多个
-
null 可以作为键,这样的null键只有一个,可以有一个或多个键所对应的值为 null。
-
不保证映射的顺序,特别是它不保证该顺序恒久不变;
-
-
Hashtable :哈希表(散列表)线程安全,每个方法都用synchronized修饰,适用于多线程
-
TreeMap: 基于key有序的key value散列表
-
CurrentHashMap:线程安全的hashmap,主要就是为了应对hashmap在并发环境下不安全而诞生的,
ConcurrentHashMap大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响。
-
LinkedHashMap:LinkedHashMap 继承自 HashMap,它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成;
另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得LinkedHashMap可以保持键值对的插入顺序
3.2 Hashtable
就是哈希表(也叫散列表),由数组+链表组合而成的;
是根据关键码值(Key value)而直接进行访问的数据结构。即:它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
特点:
-
Hashtable 的函数都是同步的(用synchronized修饰),所以它是线程安全的。
HashTable线程安全的策略实现代价比较大,而且简单粗暴,get/put所有相关操作都是synchronized的,这就相当于给整个哈希表加了一把大锁。
-
Hashtable的key、value都不可以为null;
-
Hashtable中的映射不是有序的;
-
因为Hashtable所有方法都加了锁,所以在多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
3.3 TreeMap
TreeMap是一个基于key有序的key value散列表。
- map根据其键的自然顺序排序,或者根据map创建时提供的Comparator排序;
- 不是线程安全的;
- key 不可以存入null;
- 底层是基于红黑树实现的;
特点:它最大的特点是遍历时是有顺序的,根据key的排序规则来
验证有序:
public void test1() {
Map<Integer, String> treeMap = new TreeMap<>();
treeMap.put(11, "a");
treeMap.put(1, "b");
treeMap.put(4, "c");
treeMap.put(3, "d");
treeMap.put(8, "e");
// 遍历
System.out.println("默认排序:");
treeMap.forEach((key, value) -> {
System.out.println("key: " + key + ", value: " + value);
});
}
结果:
key:1,value:b
key:3,value:d
key:4,value:c
key:8,value:e
key:11,value:a
3.4 CurrentHashMap
专门设计用于多线程环境下的高效、线程安全的哈希表实现(线程安全且高效)
3.4.1 概述:
ConcurrentHashMap是Java中ConcurrentMap接口(Map的子接口)的实现类,它提供了线程安全的哈希表操作。与Hashtable和同步的HashMap相比,ConcurrentHashMap在性能上有较大的优势。
主要就是为了应对hashmap在并发环境下不安全而诞生的,ConcurrentHashMap大量的利用了
volatile,final,CAS等lock-free
技术来减少锁竞争对于性能的影响。ConcurrentHashMap的设计目标是提供高并发性能。它的内部结构采用了分段锁的机制,将整个哈希表分割为多个子表,每个子表都有自己的锁。这种设计使得多个线程可以同时访问不同的子表,从而提高并发度。
3.4.2 特点:
-
是线程安全的,多个线程可以并发读写而不需要额外的同步手段;
-
高效的读操作,在读操作上不需要加锁,可以实现并发读取,从而提高读操作的性能;
-
不允许空键和空值,如果插入了空键或空值,会抛出NullPointerException异常;
-
Read-Write并发,ConcurrentHashMap支持同时进行读和写操作,读操作不会阻塞其他读操作,从而实现了高效的并发读写。
注意:虽然ConcurrentHashMap是线程安全的,但在某些特定的业务需求中仍需考虑额外的同步措施。
3.4.3 其他区别:
-
Hashtable是全局加锁,CurrentHashMap是局部加锁;
-
ConcurrentHashMap结构基本上和Java8的HashMap一样,不过保证线程安全性。
-
ConcurrentHashMap避免了对全局加锁,而是改成了局部加锁操作,这样就极大地提高了并发环境下的操作速度;
3.4.5 CurrentHashMap的实现原理
因为jdk1.7之前map是数组+链表的数据结构,而jdk1.8之后使用了数组+链表+红黑树,所以currentHashMap的实现原理也不尽相同
(1)在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现
-
Segment(分段锁)–可以减少锁的粒度
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)
-
内部结构:
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图:
从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。
第一次Hash定位到Segment;
第二次Hash定位到元素所在的链表的头部。
-
该结构的优劣势
- **优势:**写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)所以,通过这种结构,ConcurrentHashMap的并发能力可以大大的提高。
- **劣势:**这种结构Hash的过程要比普通的HashMap要长。
(2)JDK1.8版本的CurrentHashMap的实现原理
JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作。
简要介绍下CAS
。
CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 : ①内存位置(V)②预期原值③(A)新值(B)
如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
JDK8中彻底放弃了Segment,转而采用的是Node
,其设计思想也不再是JDK1.7中的分段锁思想。
Node: 保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。
代码如下:
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
...
}
(3)两个版本的区别
- 数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
- 锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
3.5 LinkedHashMap
LinkedHashMap 继承自 HashMap,它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得LinkedHashMap可以保持键值对的插入顺序。
4. HashMap
4.1 HashMap的数据结构
-
JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)
-
JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容,否则会将链表转化为红黑树,以减少搜索时间
4.2 HashMap的实现原理(存储)
4.2.1 实现原理
HashMap 基于 Hash 算法实现的
- put:当我们往Hashmap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标;
- 存储时,如果出现hash值相同的key,此时有两种情况。
- ①如果key相同,则覆盖原始值;
- ②如果key不同(出现冲突),则将当前的key-value放入链表中
- get:获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
如下图所示:
4.2.2 JDK1.8之前和之后的实现
在Java中,保存数据有两种比较简单的数据结构:数组和链表。
数组的特点是:寻址容易,插入和删除困难;
链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
- JDK1.8之前采用的是拉链法。
拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
- JDK1.8之后
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8),但是数组长度小于64时会首先进行扩容,否则会将链表转化为红黑树,以减少搜索时间。
4.2.3 JDK1.7和JDK 1.8 的比较
JDK1.8解决优化了以下问题:
-
resize 扩容优化
-
引入了红黑树,目的是避免单条链表过长而影响查询效率
-
解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
比较不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了扩容函数resize() 中 |
hash值计算方式 | 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算 |
存放数据的规则 | 无冲突时,存放数组;冲突时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8 & 数组长度 < 64,扩容;冲突 & 数组长度 > 64:链表树化并存放红黑树 |
插入数据方式 | 头插法(先将原位置的数据都向后移动1位,再插入数据到头位置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存储位置的计算方式 | 全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量) |
4.3 HashMap是怎么解决hash冲突的
理解了Hash Map的实现过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。需要注意Jdk 1.8中对HashMap的实现做了优化,当链表中的节点数据超过八个之后,该链表会转为红黑树来提高查询效率,从原来的O(n)到O(logn)。
下面我们具体来看:我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行!!!
4.3.1 哈希
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。
简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个**基本特性**:
根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
4.3.2 哈希冲突
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做冲突,这就是哈希冲突(也叫哈希碰撞)
我们通常将拥有相同哈希值的对象组织成一个链表放在hash值所对应的bucket下,但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4
(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化。
4.3.3 哈希函数(进行扰动优化)
上面提到的问题,主要是因为如果使用hashCode取余(除留余数法),那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,
在JDK 1.8中的hash()函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);
JDK1.8 新增红黑树的优化
通过上面的链地址法(使用散列表)和扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少,但是当我们的HashMap中存在大量数据时,加入我们某个bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn);
4.3.4 总结
总结一下HashMap使用了哪些方法来有效解决哈希冲突的:(建议去看看大话数据结构这本书,讲的确实可以)
1. 使用拉链法(使用散列表)来链接拥有相同hash值的数据(也称为链地址法)
2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均
3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快
另外大话数据结构里面讲了这几种方法
-
散列函数的构造方法(也就是求我们hash值的几种方法)其中用的最多的还是除留余数法
-
解决hash冲突的方法 链地址法也就是我们常说的拉链法用的最多
4.4 对比 Hashtable、HashMap、TreeMap 有什么不同?
Hashtable、HashMap、TreeMap 都是最常见的 Map 实现,是以键值对的形式存储和操作数据的容器。
- Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的即线程安全,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
- HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,支持 null 键和值等。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选。
- TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、remove 之类操作都是 O(logn) 的时间复杂度,具体顺序可以由指定的 Comparator 来决定,或者根据键的自然顺序来判断
4.5 HashMap 和 ConcurrentHashMap 的区别
- ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用CAS算法。)
- HashMap的键值对允许有null,但是ConCurrentHashMap都不允许。
4.6 ConcurrentHashMap 和 Hashtable 的区别?
jdk1.8采用的数据结构跟hashmap1.8的结构一样,数组+链表/红黑树,hashtable和jdk1.8之前的hashmap的底层数据机构类似都是采用数组+链表的形式,数组是hashmap的主体,链表则是主要为了解决哈希冲突而存在的
4.6.1 主要区别
ConcurrentHashMap 和 Hashtable 的区别主要体现在底层数据结构和实现线程安全的方式上不同。
-
底层数据结构:JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
-
实现线程安全的方式(重要):
① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈,效率越低。
4.6.2 底层数据结构对比
HashTable
ConcurrentHashMap
-
JDK1.7
-
JDK 1.8(TreeBin: 红黑树节点 ;Node: 链表节点))
4.6.3 总结
ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。ConcurrentHashMap 锁的方式是稍微细粒度的。
4.7 CurrentHashMap的底层实现原理
JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的数据结构,结构如下:
一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,segment继承了ReentrantLock,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
- 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
- Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
JDK1.8
在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
结构如下:
4.8 面试问题答疑
4.8.1 如果使用Object作为HashMap的Key,应该怎么办呢
重写hashCode()
和equals()
方法
- 重写
hashCode()
是因为需要计算数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快,但可能会导致更多的Hash碰撞; - 重写
equals()
方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;
4.8.2 为什么HashMap中String、Integer这样的包装类适合作为key?
String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率
- 都是final类型,即不可变性,保证key的不可更改性,不会存在同一对象获取hash值不同的情况
- 内部已重写了
equals()
、hashCode()
等方法,遵守了HashMap内部的规范,不容易出现Hash值计算错误的情况;
4.8.3 HashMap 的长度为什么是2的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
- 这个算法应该如何设计呢?
我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制与操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
- 那为什么是两次扰动呢?
这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;
4.8.4 HashMap的扩容操作是怎么实现的?
①在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
②每次扩展的时候,新容量为旧容量的2倍;
③扩展后元素的位置要么在原位置,要么移动到原位置 + 旧容量的位置。
在
putVal()
中使用到了2次resize()
方法,resize()
方法在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)
是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+旧容量的位置。
4.8.5 HashSet如何检查重复?HashSet是如何保证数据不可重复的?
向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,还要结合equles方法比较。HashSet 中的add()方法会使用HashMap 的put()方法。
HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap 的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,所以不会重复( HashMap 比较key是否相等是先比较hashcode 再比较equals )。
以下是HashSet 部分源码:
private static final Object PRESENT = new Object();
private transient HashMap<E,Object> map;
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
// 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
return map.put(e, PRESENT)==null;
}
4.8.6 HashSet和HashMap的区别
HashMap | HashSet | |
---|---|---|
父接口 | 实现了Map接口 | 实现Set接口 |
存储数据 | 存储键值对 | 仅存储对象 |
添加元素 | 调用put()向map中添加元素 | 调用add()方法向Set中添加元素 |
计算哈希值 | HashMap使用键(Key)计算hashcode | HashSet使用对象来计算hashcode值,对于两个对象来说hashcode可能相同,需要用equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false |
获取元素速度 | HashMap相对于HashSet较快,因为它是使用唯一的键获取对象 | HashSet较HashMap来说比较慢 |