如上图所示:
单列集合Collection
(1)单列集合根接口,用于存储一系列符合某种规则的元素
(2)Collection集合有两个重要的子接口,分别是List和Set
(3)List集合的特点是元素有序、可重复。该接口的主要实现类有ArrayList和LinkedList
(4)Set集合的特点是元素无序并且不可重复。该接口的主要实现类有HashSet和TreeSet
双列集合Map
(1)双列集合根接口,用于存储具有键(key)、值(Value)映射关系的元素
(2)Map集合中每个元素都包含一对键值,并且Key唯一,在使用Map集合时通过指定的Key找到对应的Value
(3)Map接口的主要实现类有HashMap和TreeMap、hash
ArrayList
1、底层是数组实现的。查询快、增删慢。线程不安全–> 相对效率高
2、扩容机制:
JDK1.7源码:底层数组,在调用构造器的时候,数组长度初始化为10;扩容时,扩展为原数组的1.5倍。
JDK1.8源码:底层数组,在调用构造器时,底层数组为{},在调用add方法后底层数组才重新赋值新数组,新数组的长度为10–>节省了内存,在add后才创建长度为10的数组。
Vector
1、底层是数组实现的。查询快、增删慢。线程安全–> 效率低
2、扩容机制:
底层数组,在调用构造器的时候,数组长度初始化为10;扩容时,Vector底层扩容长度时原数组的2倍。
LinkedList
底层是双向链表实现的。增删快、查询慢
HashSet
所存储的元素无索引,不可重复,无序。
底层是哈希表=数组+链表;利用HashMap来完成的。如果放入HashSet中的数据,一定要重写两个方法:hasCode,equals。
当向HashSet集合中添加一个元素时,首先会调用该元素的hashCode()方法来确定元素的存储位置,然后再调用元素对象的equals()方法来确保该位置没有重复元素。
在对对象进行处理时,可以在model层利用系统的快捷键创建hascode+equals方法,来去重。
LinkedHashSet
所存储的元素无索引,不可重复,有序(元素添加的顺序)。
基于HashSet和链表。适用于先进先出的场景
TreeSet
1、底层采用平衡二叉树来存储元素,所存储的元素无索引,不可重复,无序,但可以利用比较器自定义对元素进行排序。
二叉树就是每个节点最多有两个子节点的有序树,每个节点及其子节点组成的树称为子树,左侧的节点称为"左子树",右侧的节点称为"右子树",其中左子树上的元素小于它的根结点,而右子树上的元素大于它的根结点。
2、元素储存:
TreeSet集合没有元素时,新增的第1个元素会在二叉树最顶层;
接着新增元素时,首先会与根结点元素比较;
如果小于根节点元素就与左边的分支比较;
如果大于根节点元素就与右边的分支比较;
以此类推
3、排序原理:
向TreeSet集合添加元素时,都会调用compareTo()方法进行比较排序,该方法是Comparable接口中定义的,因此要想对集合中的元素进行排序,就必须实现Comparable接口。
Java中大部分的类都实现了Comparable接口,并默认实现了接口中的CompareTo()方法,如Integer、Double和String等。
HashMap
1、它用于存储键值映射关系,该集合的键和值允许为空,但键不能重复,且集合中的元素是无序的。
JDK1.2 效率高 线程不安全 key可以存放null值,并且key的null值也遵循唯一的特点
底层是由哈希表结构组成的,其实就是"数组+链表"的组合体,数组是HashMap的主体结构,链表则主要是为了解决哈希值冲突而存在的分支结构。正因为这样特殊的存储结构。HashMap集合对于元素的增、删、改、查操作表现出的效率都比较高。
2、底层原理:
依赖hashCode()方法计算键哈希值,通过键的哈希值找到存储位置。
如果哈希值计算重复(冲突),则比较equals()方法,来比较键是否相同。
如果equals方法返回的是true,会使用新值将老值替换。并且返回老值。
如果equals方法返回false,将后添加的元素放在之前元素的下列。
水平:数组结构
竖直:链表结构
3、hash冲突:
JDK1.8前:通过拉链法解决冲突。
JDK1.8后:数组长度>=64+链表长度>=8,转化成红黑树。(先扩容后转化:resize、treeifyBin)
TreeMap
唯一、有序(按照升序或降序)
底层是通过二叉树的原理来保证键的唯一性,这与TreeSet集合存储的原理是一样,因此TreeMap中所有的键是按照某种顺序排列的。
LinkedHashMap
唯一,有序(按照输入顺序进行输出)
Hashtable
JDK1.0 效率低 线程安全 key不可以存放null值
Hashtable类有一个子类Properties。Properties主要用来存储字符串类型的键和值,在实际开发中,经常使用Properties集合类来存取应用的配置项
BlockingQueue
1、继承Queue接口
2、BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。Java线程池中用的比较多。
3、实现类
ArrayBlockingQueue
LinkedBlockingQueue
PriorityBlockingQueue
SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。
DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。
4、ArrayBlockingQueue 和 LinkedBlockingQueue
底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。
是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。
锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。
内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。
重点知识:
HashMap
线程不安全的, HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。
1.7JDK
1、核心成员变量
初始化桶大小默认值16
默认的负载因子(0.75)
当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
2、Entry 是 HashMap 中的一个内部类,成员变量:
key 就是写入时的键。
value 值。
next 用于实现链表结构。
hash 存放的是当前 key 的 hashcode。
3、PUT方法
(1)判断当前数组是否需要初始化。
(2)如果 key 为空,则 put 一个空值进去。
(3)根据 key 计算出 hashcode。
(4)根据计算出的 hashcode 定位出所在桶。
(5)如果桶是一个链表则需要遍历判断里面的 hashcode、key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值。
(6)如果桶是空的,说明当前位置没有数据存入;新增一个 Entry 对象写入当前位置。
(7)当调用 addEntry 写入 Entry 时需要判断是否需要扩容。
(8)如果需要就进行两倍扩充,并将当前的 key 重新 hash 并定位。
(9)而在 createEntry 中会将当前位置的桶传入到新建的桶中,如果当前桶有值就会在位置形成链表。
4、GET方法
(1)首先也是根据 key 计算出 hashcode,然后定位到具体的桶中。
(2)判断该位置是否为链表。
(3)不是链表就根据 key、key 的 hashcode 是否相等来返回值。
(4)为链表则需要遍历直到 key 及 hashcode 相等时候就返回值。
(5)啥都没取到就直接返回 null 。
1.8JDK
1、核心成员变量
TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值。
HashEntry 修改为 Node。
Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。
2、PUT方法
(1)判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
(2)根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
(3)如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
(4)如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
(5)如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
(6)接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
(7)如果在遍历过程中找到 key 相同时直接退出遍历。
(8)如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
(9)最后判断是否需要进行扩容。
3、GET 方法
(1)首先将 key hash 之后取得所定位的桶。
(2)如果桶为空则直接返回 null 。
(3)否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
(4)如果第一个不匹配,则判断它的下一个是红黑树还是链表。
(5)红黑树就按照树的查找方式返回值。
(6)不然就按照链表的方式遍历匹配返回值。
ConcurrentHashMap
线程安全安全的
1.7JDK
1、核心成员变量是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表
2、Segment 是 ConcurrentHashMap 的一个内部类。一个ConcurrentHashMap里包含一个Segment数组,每个Segment里包含一个HashEntry数组,我们称之为table,每个HashEntry是一个链表结构的元素。
3、HashEntry则用于存储键值对数据。HashEntry 和 HashMap 非常类似,唯一的区别就是其中的核心数据 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性和顺序(防止重排)。
原理:ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock(可重入锁)。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。
4、PUT方法
首先是通过 key hash定位到 Segment,之后在对应的 Segment 中进行具体的 put。
虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。
首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
尝试自旋获取锁。
如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。
再结合图看看 put 的流程。
(1)定位segment:取得key的hashcode值进行一次再散列(通过Wang/Jenkins算法),拿到再散列值后,以再散列值的高位进行取模得到当前元素在哪个segment上
(2)定位table:同样是取得key的再散列值以后,用再散列值的全部和table的长度进行取模,得到当前元素在table的哪个元素上。
(3)将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
(4)遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
(5)不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
(6)最后会解除在 1 中所获取当前 Segment 的锁。
5、GET方法
Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。
由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
1.8JDK
1.7 解决了并发问题,且能支持 N 个 Segment 这么多次数的并发,但依然存在 HashMap 在 1.7 版本中的问题:查询遍历链表效率太低。
抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。
将 1.7 中存放数据的 HashEntry 改为 Node,但作用都是相同的。其中的 val next 都用了 volatile 修饰,保证了可见性。
再结合图看看PUT方法
(1)根据 key 计算出 hashcode 。
(2)判断是否需要进行初始化。
(3)f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
(4)如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
(5)如果都不满足,则利用 synchronized 锁写入数据。
(6)如果链表的长度大于8,并且node数组的长度大于64,就会将当前链表转化为红黑树。如果链表的长度又小于8,就会将红黑树转化为链表。
GET方法
(1)根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
(2)如果是红黑树那就按照树的方式获取值。
(3)就不满足那就按照链表的方式遍历获取值。
1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),取消了 ReentrantLock 改为了 synchronized,新版的 JDK 中对 synchronized 优化了。
经典面试题:
1、填装因子,负载因子,加载因子 为什么是0.75?
为1:空间利用率得到了很大的满足,很容易碰撞,产生链表->查询效率低
为0.5:碰撞的概率低,扩容,产生链表的几率低,查询效率高,空间利用率太低
0.5~1 取中间值:0.75
2、主数组的长度为什么必须为2^n?
(1)h & (length-1) 等效于 h%length 操作,等效的前提就是:length必须是2 的倍数!
(2)防止哈希冲突,位置冲突:
3、HashMap 和 HashTable的区别
线程安全:HashMap非线程安全,HashTable线程安全。
效率:HashMap效率比较高,HashTable基本被淘汰了。
NULL:HashMap可以存储Null的Key和Value,HashTable不行。
初始容量和扩容机制:创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小
底层数据结构:HashMap支持链表转红黑树,HashTable不支持。
4、头插法和尾插法
JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
5、HashMap为什么是线程不安全的?
两个线程put的key,落到同一个hash桶的话,put的时候要插入到链表尾部,他们可能去修改同一个链表尾部节点的next引用,这样就会丢失一份数据。
两个线程同时进行扩容,不加锁也会产生数据丢失问题。
即使他不产生数据丢失,HashMap也不能保证数据的可见性。可能一个线程put了数据之后,另一个线程的缓存没有刷新。
两个线程同时put,size值更新失败。(++size)
6、HashMap为什么在链表长度为8时转化为红黑树?
HashMap的性能依赖于hashcode的设计,如果hashcode设计得不好(不均衡),那么容易发生哈希冲突,导致HashMap退化成链表,转红黑树是为了解决这种退化的情况。
为什么是8?因为红黑树的内存开销比较大,那么在正常情况,即hashcode设计的随机的情况下,应该尽量避免链表转红黑树。作者通过计算,发现正常情况下,哈希桶的元素个数符合泊松分布,计算得到元素个数>=8的概率是极小的,而元素个数等于7的概率,还是稍大的。那么取阈值为8,我们可以避免正常情况下链表的退化。