![c7a1309217d3cbdadb26f8b865a028d8.png](https://i-blog.csdnimg.cn/blog_migrate/9b0b279fa15e12a245633f0a4b308393.png)
Java系列之集合
接口继承关系和实现
集合类存放在java.util包中,主要有三种:Set(集)、list(列表包含Queue)和map(映射)
Collection:是集合List、Set、Queue的最基本的接口 Iterator:迭代器,可以通过迭代器遍历集合中的数据 Map:是映射表的基础接口
![81927529abb360797a61c60fa1ab872e.png](https://i-blog.csdnimg.cn/blog_migrate/d46263db7287f72599bf2559fdf0eb0b.jpeg)
![a82a03070493caa89b023c4e60e1fc68.png](https://i-blog.csdnimg.cn/blog_migrate/532e99a3e39e0661f9daee8984fb409b.png)
List
Java 的 List 是非常常用的数据类型。List 是有序的 Collection。Java List 一共三个实现类:分别是 ArrayList、Vector 和 LinkedList。
ArrayList(数组)
ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。 当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。
特点:
- 排列有序,可以重复
- 底层使用数组实现
- 查询快,增删慢
- 线程不安全
- 当容量不足时,ArrayList是当前容量的 1.5倍+1
Vector(数组实现、线程安全)
Vector 与 ArrayList 一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问 ArrayList 慢。
特点:
- 排列有序,可以重复
- 底层数组实现
- 查询快,增删慢
- 线程安全
- 当容量不足时,Vector默认扩展一倍容量
LinkList(链表)
LinkedList 是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另外,他还提供了 List 接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。
操作表头和表尾:
linkedList.addFirst("");
linkedList.addLast("");
linkedList.getFirst();
linkedList.getLast();
特点:
- 排列有序,可重复
- 底层使用双向循环链表数据结构
- 查询慢,增删快,add()和remove()方法快
- 线程不安全
Set
Set 注重独一无二的性质,该体系集合用于存储无序(存入和取出的顺序不一定相同)元素,值不能重复。对象的相等性本质是对象 hashCode 值(java 是依据对象的内存地址计算出的此序号)判断的,如果想要让两个不同的对象视为相等的,就必须覆盖 Object 的 hashCode 方法和 equals 方法。
HashSet(Hash表)
哈希表边存放的是哈希值。HashSet 存储元素的顺序并不是按照存入时的顺序(和 List 显然不同) 而是按照哈希值来存的所以取数据也是按照哈希值取得。元素的哈希值是通过元素的hashcode 方法来获取的, HashSet 首先判断两个元素的哈希值,如果哈希值一样,接着会比较equals 方法 如果 equls 结果为 true ,HashSet 就视为同一个元素。如果 equals 为 false 就不是同一个元素。哈希值相同 equals 为 false 的元素是怎么存储呢,就是在同样的哈希值下顺延(可以认为哈希值相同的元素放在一个哈希桶中)。也就是哈希一样的存一列。如图 1 表示 hashCode 值不相同的情况;HashSet 通过 hashCode 值来确定元素在内存中的位置。一个 hashCode 位置上可以存放多个元素。
特点:
- 排列无序(存入顺序和取出顺序不一定相同)不可以重复
- 底层使用Hash表实现
- 存取速度快
- 内部是HashMap
内部是HashMap
//HashSet的构造方法
public HashSet() {
map = new HashMap<>();
}
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
TreeSet(二叉树)
TreeSet()是使用二叉树的原理对新 add()的对象按照指定的顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入的二叉树指定的位置。
Integer 和 String 对象都可以进行默认的 TreeSet 排序,而自定义类的对象是不可以的,自己定义的类必须实现Comparable 接口,并且覆写相应的 compareTo()函数,才可以正常使用。
在覆写 compare()函数时,要返回相应的值才能使 TreeSet 按照一定的规则来排序
比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零或正整数。
特点:
- 排列无序,不可重复
- 底层使用二叉树实现
- 排序存储
- 内部是TreeMap的SortedSet
内部是TreeMap的SortedSet
public TreeSet() {
this(new TreeMap<E,Object>());
}
/**
*public interface NavigableMap<K,V> extends SortedMap<K,V>
*/
private transient NavigableMap<E,Object> m;
TreeSet(NavigableMap<E,Object> m) {
this.m = m;
}
private static final Object PRESENT = new Object();
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
LinkHashSet(HashSet+LinkedHashMap)
对于 LinkedHashSet 而言,它继承与 HashSet、又基于 LinkedHashMap 来实现的。LinkedHashSet 底层使用 LinkedHashMap 来保存所有元素,它继承与 HashSet,其所有的方法操作上又与 HashSet 相同,因此 LinkedHashSet 的实现上非常简单,只提供了四个构造方法,并通过传递一个标识参数,调用父类的构造器,底层构造一个 LinkedHashMap 来实现,在相关操作上与父类 HashSet 的操作相同,直接调用父类 HashSet 的方法即可。
特点:
- 采用Hash表存储,并用双链表记录插入顺序
- (有序)插入顺序与输出顺序一致,不可重复
- 增删快、查询慢
- 内部是LinkedHashMap
内部是LinkedHashMap
public LinkedHashSet() {
super(16, .75f, true);
}
/**
super(16, .75f, true);
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
Map
HashMap(数组+链表+红黑树)
HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections 的 synchronizedMap 方法使HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。
HashMap理解(摘抄自:https://zhuanlan.zhihu.com/p/31610616):
HashMap是一个用于存储K-V键值(Entry)对的一个集合,这些键值对分散存储在一个数组中,这个数组就是HashMap的主要组成部分。HashMap初始化时,每一个元素的初始值都是NULL。 对于HashMap,我们最常用的是两个方法:Get方法和Put方法。 当调用Put方法的时候,hashMap.put("apple",0)
,首先需要通过Hash函数来计算确定数据插入位置的index。
index = Hash("apple")
假设最后计算的index是2
![86712c3c4a44e3e2208c0fb7f53c2d8c.png](https://i-blog.csdnimg.cn/blog_migrate/ed78c1a8e35397072b5736dbc0d8d058.png)
但是,因为HashMap的长度是有限制的,当插入的数据越来越多的时候,可能会出现index冲突的情况,比如
![b6c643cfd66c6dc6d798e575cff2473c.png](https://i-blog.csdnimg.cn/blog_migrate/3efb6756cbc7eb514d25fb8b05a62c45.png)
当两个key的Hash值计算的index相同时候,应该如何解决冲突呢? 可以通过链表来解决。
HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头结点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时候,只需要插入到对应的链表即可,新来的Entry插入链表时使用的是"头插法"。
![e7990ebe1de2cbb5af6ff7ba11fe78a6.png](https://i-blog.csdnimg.cn/blog_migrate/869adb7d2c2a062c81311a69c46fce03.png)
Get方法的原理
使用Get方法根据Key来查找Value的时候,首先会吧输入的Key做一次Hash映射,得到对应的Index:
index = Hash("apple")
由于刚才所说的Hash冲突,同一个位置可能会匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找,假设我们要查找的Key是“apple”:
![6554b6e4638022abdd915aa174b3b612.png](https://i-blog.csdnimg.cn/blog_migrate/34a911a4becd5ca0fae7c1f8364131e4.png)
第一步:我们查看的是头节点Entry6,Entry6的KEY是banana,显然不是我们要找的结果。 第二步:我们查看的是Next节点Entry1,Entry1的KEY是apple,正是我们要找的结果。 之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大
HashMap的默认初始长度是16,并且每次自定扩展或是手动初始化时,长度必须是2的次幂。 选择16,是为了服务于从KEY映射到HashMap数组的对应位置,会用到一个Hash函数:index = Hash("apple")
如何实现一个尽量均匀分布的Hash函数呢?我们通过利用KEY的HashCode值来做某种运算。 发明者采用了位运算(Length是HashMap的长度):index = HashCode(Key) & (Length - 1)
下面我们以值为“book”的Key来演示整个过程:
1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
为什么HashMap的默认初始长度是16,并且每次自定扩展或是手动初始化时,长度必须是2的次幂? 这样做不仅仅效果上等同于取模( index = HashCode(key) % length
)而且大大提高了性能。至于为什么采用16,我们可以采用10试一下会出现什么问题?
![c5cd5f70b56f3afc9227c107e2f80965.png](https://i-blog.csdnimg.cn/blog_migrate/a700c246d2282273cf75a18543190923.png)
单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 101110001110101110 1011 :
![e3bb6b75eb5e998aa6cb5383be2116ad.png](https://i-blog.csdnimg.cn/blog_migrate/e049ffd3d0f39242b5ae900a7089d24f.png)
让我们再换一个HashCode 101110001110101110 1111 试试 :
![48d639e8a79a4b4ff6dfae51a42b635e.png](https://i-blog.csdnimg.cn/blog_migrate/d786b6810b71095b98c11f7682b31c56.png)
是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!这样,显然不符合Hash算法均匀分布的原则。反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
高并发环境下,HashMap可能出现的致命问题:摘抄自:(https://zhuanlan.zhihu.com/p/31614195)
- Rehash
Rehash是HashMap在扩容时候的一个步骤,HashMap的容量是有限的,当经过多次数据插入,使得HashMap达到一定的饱和度。Key的映射位置发生冲突的几率会逐渐提高。这个时候,HashMap需要扩展它的长度,也就是进行Resize。
影响发生Resize的因素有两个:
- Capacity HashMap的当前长度。是2的次幂。
- LoadFactor HashMap负载因子,默认值为0.75F。
衡量HashMap是否进行Resize的条件如下:
实际存放数据的个数 >= hashMap当前的长度 * 75%
HashMap.size >= Capacity * LoadFactor
Resize做了哪些事情?
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
- ReHash 遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大之后,Hash的规则也会随之改变。
当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。
Resize前的HashMap:
![b7cc9aa6e7d90a73cc7e4cfc869aa310.png](https://i-blog.csdnimg.cn/blog_migrate/04b8754ebb3422e5f3df573e08172cf7.png)
Resize之后的HashMap:
![aba1287fd2b02ce0c3082bc57820577e.png](https://i-blog.csdnimg.cn/blog_migrate/965b86054543fb8a49402c22c18512a4.png)
ReHash的Java代码如下:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
Java7实现
![d75d7c450290071a83775f544ba0edb8.png](https://i-blog.csdnimg.cn/blog_migrate/6d220b5206c3d800cabf239361588be1.jpeg)
大方向上,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。
- capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
- loadFactor:负载因子,默认为 0.75。
- threshold:扩容的阈值,等于 capacity * loadFactor
Java8实现
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。 根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
![52a2cf320905ce697565117b0adebb19.png](https://i-blog.csdnimg.cn/blog_migrate/75ab8a43a079b773ebdb9b497a03fc43.jpeg)
ConcurrentHashMap
Segment段
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个segment。
线程安全(Segment继承ReentrantLock加锁)
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
![c21f3e5be1e38ac79e5d8c92dafe5516.png](https://i-blog.csdnimg.cn/blog_migrate/27c6b4595e90d6c9646d4082ed1ff209.jpeg)
并行度(默认16)
concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
Java8实现(引入了红黑树)
Java8 对 ConcurrentHashMap 进行了比较大的改动,Java8 也引入了红黑树
![d2545db7d22bddf43744ec09e64040bc.png](https://i-blog.csdnimg.cn/blog_migrate/b7a5522a6d363ebb791e95037cdf917f.jpeg)
HashTable(线程安全)
Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用ConcurrentHashMap 替换。
TreeMap(可以排序)
TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap。在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException 类型的异常。
LinkHashMap(记录插入顺序)
LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 Iterator 遍历LinkedHashMap 时, 先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
结尾
好记性不如烂笔头,本文为个人学习总结。
- 笔者:shaofeer
- 个人网站(首发):https://quxuecx.com
- 邮箱: shaofeer@163.com
- QQ:337081267
- 公众号:“趣学程序”
我这里整理了很多的学习资料、编程源码、学习笔记、面试心得,如果有需要,可以通过关注“趣学程序”公众号来获取哟~
http://weixin.qq.com/r/SSpxaX-E2iKlrTsK939b (二维码自动识别)