- 数组, 链表, 哈希表
- 集合概述
- 源码分析(面试)
- ArrayList
- HashMap
- 底层数据结构:
- Node
类 - 为什么需要链表?
- 为什么jdk1.8要在特定条件下转为红黑树? 什么特定条件?
- JDK1.8下HashMap 的完整示意图
- 节点插入链表时, JDK1.7 和 JDK1.8的不同
- 数组扩容 resize()方法:
- 初始化容量是多少? 有什么规定?
- HashMap线程不安全的表现?
- 如何保证 HashMap 线程安全?
- 1)使用 java.util.Collections 类的
synchronizedMap
方法包装一下 HashMap,得到线程安全的HashMap,其原理就是对所有的修改操作都加上 synchronized。方法如下: - 2)使用线程安全的
HashTable
类代替,该类在对数据操作的时候都会上锁,也就是加上 synchronized - 3)使用线程安全的
ConcurrentHashMap
类代替,该类在 JDK 1.7 和 JDK 1.8 的底层原理有所不同,JDK 1.7 采用数组 + 链表存储数据,使用分段锁 Segment 保证线程安全;JDK 1.8 采用数组 + 链表/红黑树存储数据,使用 CAS + synchronized 保证线程安全。
- 1)使用 java.util.Collections 类的
- 为什么HashTable和ConCurrtntHashMap不允许键值为null?
- HashMap和HashTable的异同点:
- 介绍下 fail-safe 和 fail-fast 机制
- 为什么 fail-safe 不会抛出异常
- fail-fast 的原理是什么
数组, 链表, 哈希表
数组:
数组处理一组数据类型相同的数据,但不允许动态定义数组的大小,即在使用数组之前必须确定数组的大小。而在实际应用中,用户使用数组之前有时无法准确确定数组的大小,只能将数组定义成足够大小,这样数组中有些空间可能不被使用,从而造成内存空间的浪费。
数组必须事先定义固定的长度(元素个数),不能适应数据动态地增减的情况。当数据增加时,可能超出原先定义的元素个数,造成数组越界;当数据减少时,造成内存浪费。
特点:
- 数组是相同数据类型的元素的集合
- 数组中的各元素的存储是有先后顺序的,它们在内存中按照这个先后顺序连续存放在一起
- 数组元素用整个数组的名字和它自己在数组中的顺序位置来表示。例如,a[0]表示名字为a的数组中的第一个元素,a[1]代表数组a的第二个元素,以此类推。
链表:
链表有很多种不同的类型:单向链表,双向链表,循环链表。
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。 相比于线性表顺序结构,操作复杂。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而线性表和顺序表相应的时间复杂度分别是O(logn)和O(1)。
使用链表结构可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。特点:
链表动态地进行存储分配,可以适应数据动态地增减的情况,且可以方便地插入、删除数据项。因为数组中插入、删除数据项时,需要移动其它数据项,而链表的插入与删除时,只需要改变个别元素之间的关系即可,这大大提高了链表的删除与插入的速度。哈希表
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
左边是个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。特点:
1) 访问速度很快
由于散列表有散列函数,可以将指定的 Key 都映射到一个地址上,所以在访问一个 Key(键)对应的 Value(值)时,根本不需要一个一个地进行查找,可以直接跳到那个地址。所以我们在对散列表进行添加、删除、修改、查找等任何操作时,速度都很快。2) 需要额外的空间
首先,散列表实际上是存不满的,如果一个散列表刚好能够存满,那么肯定是个巧合。而且当散列表中元素的使用率越来越高时,性能会下降,所以一般会选择扩容来解决这个问题。另外,如果有冲突的话,则也是需要额外的空间去存储的,比如链地址法,不但需要额外的空间,甚至需要使用其他数据结构。
这个特点有个很常用的词可以表达,叫作“空间换时间”,在大多数时候,对于算法的实现,为了能够有更好的性能,往往会考虑牺牲些空间,让算法能够更快些。
3) 无序
散列表还有一个非常明显的特点,那就是无序。为了能够更快地访问元素,散列表是根据散列函数直接找到存储地址的,这样我们的访问速度就能够更快,但是对于有序访问却没有办法应对。
4) 可能会产生碰撞
没有完美的散列函数,无论如何总会产生冲突,这时就需要采用冲突解决方案,这也使散列表更加复杂。通常在不同的高级语言的实现中,对于冲突的解决方案不一定一样。
集合概述
图示集合关系
Iterable, Collection 家族 (List, Set, Queue)
Map 家族
集合和数组的区别:
1. 长度区别
- 集合长度可变
-
2. 存储数据类型区别:
数组可以存储基本类型, 也可以存储引用类型
-
3. 存储元素内容区别:
数组只能存储同一种类型
集合可以存储不用类型 (不加泛型的前提下, 加泛型的话, 可以使用反射来添加不同类型的元素)
Collection 中各常用集合的特点:
List接口: 有序存储, 可重复
ArrayList: 数组; 查询快,增删慢,线程不安全,效率高,可以存储重复元素
LinkedList: 双向链表(1.6之前是循环, 1.7取消了循环); 查询慢,增删快,线程不安全,效率高,可以存储重复元素
Vector: 数组; 查询快,增删慢,线程安全,效率低,可以存储重复元素
Stack: 栈, 是Vector的子类, 先进后出, 后进先出, 线程安全
Set接口: 不可重复, 做内部排序
HashSet: 哈希表; 元素无序且唯一, 线程不安全,效率高, 可以存储null
LinkedHashSet: 链表+哈希表; 链表保证了元素的顺序与存储顺序一致,哈希表保证了元素的唯一性。线程不安全,效率高, 可存null
TreeSet: 二叉树, 可以保证元素顺序, 要添加的元素必须实现Comparable接口, 否则报错
Map接口: 键值对存储, 双列集合 (注意: Map 没有继承 Collection 接口)
HashMap: 哈希表; Key 可以为null, 但只能有一个, value可以为null, 非线程安全
注意: HashMap的get方法在没有查询到key所对应的value时, 也会返回null, 所以想要判断是否存在key, 应该使用
containsKey
方法LinkedHashMap: 双向链表+hash表;
TreeMap: 红黑树, 要put的元素中key必须实现Comparable接口, 否则报错; 非线程安全
HashTable: 哈希表; 线程安全; Key, Value 都不能为null
源码分析(面试)
ArrayList
继承, 实现图
ArrayList
继承于**AbstractList**
,实现了**List**
,**RandomAccess**
,**Cloneable**
,**java.io.Serializable**
这些接口。RandomAccess
是一个标志接口,表明实现这个这个接口的 List 集合是支持快速随机访问的。在ArrayList
中,我们可以通过元素的序号快速获取元素对象,这就是快速随机访问。ArrayList
实现了**Cloneable**
接口 ,即覆盖了函数clone()
,能被克隆。ArrayList
实现了java.io.Serializable
接口,这意味着ArrayList
支持序列化,能通过序列化去传输。扩容机制★★★★★
多线程下不安全
红框中的其实是两句话, 非原子:
element[size] = e;
size++;
所以会出现线程安全问题HashMap
底层数据结构:
在jdk1.7中底层是数组+链表, jdk1.8中是数组+链表/红黑树, 使用Node类来存储Key和Value
Node
类 为什么需要链表?
首先,数组的长度是有限的对吧,在有限的数组上使用哈希,那么哈希冲突是不可避免的,很有可能两个元素计算得出的 index 是相同的,那么如何解决哈希冲突呢?拉链法。也就是把 hash 后值相同的元素放在同一条链表上。比如说:
为什么jdk1.8要在特定条件下转为红黑树? 什么特定条件?
当然这里还有一个问题,那就是当 Hash 冲突严重时,在数组上形成的链表会变的越来越长,由于链表不支持索引,要想在链表中找一个元素就需要遍历一遍链表,那显然效率是比较低的。为此,JDK 1.8 引入了红黑树,当链表的长度大于 8 的时候就会转换为红黑树,不过,在转换之前,会先去查看 table 数组的长度是否大于 64,如果数组的长度小于 64,那么 HashMap 会优先选择对数组进行扩容
**resize**
,而不是把链表转换成红黑树。
JDK1.8下HashMap 的完整示意图
节点插入链表时, JDK1.7 和 JDK1.8的不同
1.7 采用 头插法
1.8 采用 尾插法
为什么不再用头插法而改用尾插法? JDK 1.7 中采用的头插法在多线程环境下可能会造成循环链表问题
由于 JDK 1.7 中 HashMap 使用头插会改变链表上元素的的顺序,在旧数组向新数组转移元素的过程中修改了链表中节点的引用关系,因此 JDK 1.8 改成了尾插法,在扩容时会保持链表元素原本的顺序,避免了链表成环的问题
数组扩容 resize()方法:
何时扩容?
1)
Capacity
:HashMap 当前最大容量/长度
2)LoadFactor
:负载因子,默认值0.75f
如果当前存入的数据数量大于 Capacity * LoadFactor 的时候,就会进行数组扩容resize
。就比如当前的 HashMap 的最大容量大小为 100,当你存进第 76 个的时候,判断发现需要进行 resize了,那就进行扩容。当然,HashMap 的扩容不是简单的扩大点容量这么简单的。如何扩容? 分为两步:
1)扩容:创建一个新的 Entry 或 Node 空数组,长度是原数组的 2 倍
2)ReHash:遍历原 Entry 或 Node 数组,把所有的 Entry 或 Node 节点重新 Hash 到新数组为什么要 ReHash 呢?直接复制到新数组不行吗?
显然是不行的,因为数组的长度改变以后,Hash 的规则也随之改变。index 的计算公式是这样的:
index = HashCode(key) & (Length - 1)
比如说数组原来的长度(Length)是 4,Hash 出来的值是 2 ,然后数组长度翻倍了变成 16,显然 Hash 出来的值也就会变了。画个图解释下:
初始化容量是多少? 有什么规定?
初始化容量是16, 经验得来的, 最常用; 规定必须是2的次幂, 跟hash函数有关系, 为了能够均匀分布
HashMap线程不安全的表现?
关于 JDK 1.7 中 HashMap 的线程不安全,上面已经说过了,就是会出现环形链表。虽然 JDK 1.8 采用尾插法避免了环形链表的问题,但是它仍然是线程不安全的,我们来看看 JDK 1.8 中 HashMap 的
put
方法:
注意上图我圈出来的代码,如果没有发生 Hash 冲突就会直接插入元素。
假设线程 1 和线程 2 同时进行 put 操作,恰好这两条不同的数据的 hash 值是一样的,并且该位置数据为null,这样,线程 1 和线程 2 都会进入这段代码进行插入元素。假设线程 1 进入后还没有开始进行元素插入就被挂起,而线程 2 正常执行,并且正常插入数据,随后线程 1 得到 CPU 调度进行元素插入,这样,线程 2 插入的数据就被覆盖了。
总结一下 HashMap 在 JDK 1.7 和 JDK 1.8 中为什么不安全:JDK 1.7:由于采用头插法改变了链表上元素的的顺序,并发环境下扩容可能导致循环链表的问题
JDK 1.8:由于 put 操作并没有上锁,并发环境下可能发生某个线程插入的数据被覆盖的问题
如何保证 HashMap 线程安全?
1)使用 java.util.Collections 类的
synchronizedMap
方法包装一下 HashMap,得到线程安全的HashMap,其原理就是对所有的修改操作都加上 synchronized。方法如下:public static
Map synchronizedMap(Map m) 2)使用线程安全的
HashTable
类代替,该类在对数据操作的时候都会上锁,也就是加上 synchronized3)使用线程安全的
ConcurrentHashMap
类代替,该类在 JDK 1.7 和 JDK 1.8 的底层原理有所不同,JDK 1.7 采用数组 + 链表存储数据,使用分段锁 Segment 保证线程安全;JDK 1.8 采用数组 + 链表/红黑树存储数据,使用 CAS + synchronized 保证线程安全。为什么HashTable和ConCurrtntHashMap不允许键值为null?
处于多线程安全的考虑, 因为如果允许的话, 那么map.get(key)==null就会存在两种结果, 一是不存在该键值对, 二是该key对应的value就是null, 就需要再调用map.containsKey(key)再去判断是那种情况, 单线程下没有问题, 但是到了多线程下, 如果A线程调用map.get(key)==null, 正要调用map.containsKey(key)时, B线程正好map.put(key, null), 就会出现问题.
HashMap和HashTable的异同点:
1. 键值对是否为空: HashMap可以允许键值对为null, HashTable不允许
2. 初始化容量: HashMap是16, HashTable是11; 两者的加载因子都是0.75
3. 扩容机制不同: 当现有容量大于总容量 * 负载因子时,
HashMap
扩容规则为当前容量翻倍,Hashtable
扩容规则为当前容量翻倍 + 1;4. 迭代器不同: 两者都有Iterator迭代器, 并且该迭代器是 fail-fast(快速失败) 的; 但是, HashTable还有另外一个迭代器, Enumeration, 这个是 fail-safe(失败安全) 的.
介绍下 fail-safe 和 fail-fast 机制
1)快速失败 fail-fast:一种快速发现系统故障的机制。一旦发生异常,立即停止当前的操作,并上报给上层的系统来处理这些故障。(java.util包下的集合都是快速失败的)
个人理解: 在调用前尽可能的预先识别出一些错误信息, 一方面可以避免执行复杂的其他代码, 另一方面可以对预先识别的错误做一些针对性的处理; 例如java.util包下的集合, 当 Iterator 这个迭代器被创建后,除了迭代器本身的方法 remove 可以改变集合的结构外,其他的因素如若改变了集合的结构,都将会抛出
ConcurrentModificationException
异常HashMap<String, String> map = new HashMap();
map.put("a" , "a");
map.put("b" , "b");
Iterator<String> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
String key = (String) iterator.next(); // 执行删除后再遍历时, java.util.ConcurrentModificationException
if(Objects.equals("a", key)) {
map.remove(key);
}
}
// 第一次循环遍历的时候,我们删除了集合 key = "a" 的元素,集合的结构被改变了,
// 所以第二次遍历迭代器的时候,就会抛出异常。
2)失败安全 fail-safe:在故障发生之后会维持系统继续运行。(java.util.concurretnt包下的集合都是失败安全的)
顾名思义,和 fail-fast 恰恰相反,当我们对集合的结构做出改变的时候,fail-safe 机制不会抛出异常。
java.util.concurrent 包下的容器都是 fail-safe 的,比如**ConcurrentHashMap**
,可以在多线程下并发使用,并发修改。同时也可以在 for-each 增强循环中进行 add/remove。ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.put("a", "a");
map.put("b", "b");
map.put("c", "c");
Iterator<String> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
String key = (String) iterator.next();
if(Objects.equals("a", key)) {
map.remove(key);
}
}
for (String s : map.keySet()) {
System.out.println(s);
}
/**
* 输出结果
* b
* c
*/
为什么 fail-safe 不会抛出异常
这是因为,当集合的结构被改变的时候,fail-safe 机制会复制一份原集合的数据,然后在复制的那份数据上进行遍历。因此,虽然 fail-safe 不会抛出异常,但存在以下缺点:
不能保证遍历的是最新内容。也就是说迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的;
- 复制时需要额外的空间和时间上的开销。
fail-fast 的原理是什么
从源码我们可以发现,迭代器在执行 next() 等方法的时候,都会调用checkForComodification
这个方法,查看 modCount 和 expectedModCount 是否相等,如果不相等则抛出异常终止遍历,如果相等就返回遍历。
expectedModcount 这个值在对象被创建的时候就被赋予了一个固定的值即 modCount,也就是说 expectedModcount 是不变的,但是 modCount 在我们对集合的元素的个数做出改变(删除、插入)的时候会被改变(修改操作不会)。那如果在迭代器下次遍历元素的时候,发现 modCount 这个值发生了改变,那么走到这个判断语句时就会抛出异常。