文章目录
学习地址: 韩顺平集合
0、体系架构
0.1、Collection接口常用方法
0.2、Map接口常用方法
1、List接口
- List接口是Collection接口的子接口。
- List的实现类中的元素是有序的,即添加顺序和取出元素的顺序是一样的,且可以重复。
- List集合中的每个元素都有其对应的顺序索引,即支持索引。
- List容器的每个元素对应一个整数型的序号记载其在容器中的位置们可以根据序号存取容器中的元素(linkedList也是支持索引的)。
1.1、ArrayList
- ArrayList底层是数组,
transient Object[] elementData;
这个数组是不能被序列化的。 - 在初始化的时候,如果使用的是无参构造器,则初始elementData容量为0(懒加载),默认大小为10。
- 当添加元素时:先判断是否需要扩容,如果需要扩容,则调用grow方法,否则直接添加元素到合适位置。
- 扩容到原来的1.5倍,同时将原数组中的元素复制到新的数组中。
- 支持快速随机访问,支持元素重复。
- 不适合插入和删除。
- 是线程不安全的。
1.2、LinkedList
- 底层是一个双向链表。
- 维护了两个属性first和last(是Node类型),分别指向首节点和尾结点。
- Node是LinkedList中的静态内部类,里面还有三个属性(item,pre,next),因此没有扩容,一个节点链下一个节点。
- item:元素
- pre:之前前一个节点。
- next:指向下一个节点。
- 是个数据的动态插入和删除。
- 是线程不安全的。
1.3、Vector
- 是JDK1.0就有的。底层是一个数组,
protected Object[] elementData;
- 默认大小是10,如果数组满了,就按照2倍进行扩容;如果指定大小之后,每次扩容是按照指定大小的两倍进行的。
- 是线程安全的。因为所有的方法都是同步方法(
synchornized
),但也因此效率是很低的,现在很少用。
1.4、线程安全的List
- Vector
- Collections.synchronized():即采用Collections集合工具类,在ArrayList·外面包装一层同步机制。
List<String> list = Collections.synchronizedList(new ArrayList<>());
- CopyOnWriteArrayList:写时复制,主要是一种读写分离的思想。尚硅谷-写时复制
- 写时复制,CopyOnWrite容器即写时复制的容器,往一个容器中添加元素的时候,不直接往当前容器Object[]添加,而是先将Object[]进行copy,复制出一个新的容器object[] newElements,然后新的容器Object[] newElements里添加原始,添加元素完后,在将原容器的引用指向新的容器 setArray(newElements);这样做的好处是可以对copyOnWrite容器进行并发的读 ,而不需要加锁,因为当前容器不需要添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
- 简单理解就是:写的时候,把ArrayList扩容出来一个,然后把值填回去,再通知其他线程,ArrayLis的引用此时指向扩容之后的。
2、Set接口
- set接口无序(添加和取出元素的顺序不一样),且没有索引,不支持随机访问。
- 不允许有重复元素,所以最多有一个null。
- 和List接口一样,Set接口也是Collection接口的子接口,因此,常用方法和Collection接口一样。
- 同Collection接口遍历方式一样,因为Set接口是Collection接口的子接口,所以Set接口的遍历可以使用迭代器和增强for循环。
- 不能使用索引来获取。
2.1、HashSet
- 底层就是一个HashMap,此时的value就是一个常量
PRESENT
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
- 可以存放null,但也只能存放一个null(因为不能有重复的)。
- 不保证元素是有序的,元素存放的位置是取决于Hash函数的。
- 在存储对象计算hash的时候,一定要重写hashCode和equals方法。
- 扩容:既然底层是HashMap,那么扩容自然就是HashMap的方式进行扩容的
3、Map接口
- Map和Collection并列存在。用于保存具有映射关系的数据:key-value。
- key和value可以是任何引用类型的数据。
- key不允许重复,value是可以重复的。
- key和value之间存在单向一对一关系,即:通过指定的key总能找到对应的value。
3.1、HashMap
- 是Map接口的一个实现子类。
- key可以为null,但只能有一个,value也可以为null,数量不限。
3.1.1、底层
- HashMap底层是数组(Node类型)+链表+红黑树。
- 每个节点的类型就是
HashMap$Node
。Node又实现了Map$entry
。一个key-value就组成一个一个entry。同时Node还有一个指向下一个节点的指针next,因此可以将HashMap的数组中的单个元素当做是一个链表。 - 添加元素的时候,首先计算key所在类的hash值,然后通过
(n-1)&hash
得到应当存放在数组中下标的idx,如果当前数组位置不存在位置,则插入成功;如果当前位置存在元素,那么存在碰撞,此时调用equals方法,比较两个元素是否相等,如果相等,那么进行更新,如果不相等,那么进行一个尾插,旧元素指向新元素。 - 关于扰动函数:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
尽可能的降低hash碰撞,使元素越分散越好;算法一定要尽可能高效,因为是高频操作,因此采用位运算。
3.1.2、扩容
- HashMap的默认大小是16,加载因子是0.75,即:当数组元素个数达到12的时候就要进行扩容。
- 每次扩容为原来的两倍。
- 当数组长度小于64,且某一个链表元素个数大于8,那么进行扩容;如果数组长度大于64,且某一个链表元素个数大于8,那么此时链表转为红黑树(因为红黑树的查找效率更高)。
3.1.3、哈希冲突处理
- 开放地址法:所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。简单理解:一旦发生冲突,就向下查找,只要散列表足够大,总能找到一个空位置存放元素。
- 再哈希法:再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,….,等哈希函数计算地址,直到无冲突。虽然不易发生聚集,但是增加了计算时间。简单理解:Hash函数很多,发生冲突了就选用其他的进行rehash。
- 链地址法:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表。(HashMap底层采用)。
- 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
3.2、HashTable
- 底层:采用数组+链表的方式,数组依旧采用HashMap的主题,hash冲突采用了链地址法(链表)。
- 线程安全的原因:大量使用同步方法,使用synchronized来保证线程安全,因此效率低下。
3.3、ConcurrentHashMap
- JDK1.8采用数组+链表+红黑树的方式实现。
- 为了保证线程安全,采用了synchronized+CAS的方式实现。
- 当链表的长度超过8的时候,将链表转为红黑树。synchronized只锁定当前链表或者红黑树的首节点,只要哈希不冲突,就不会产生并发。
CAS底层原理
4、JDK1.7和JDK1.8的HashMap
4.1、JDK1.7
- HashMap的结构为:数组+链表
- 采用头插法,发生碰撞的时候,
新元素指向旧元素
。 - 存在的问题:链表循环。
4.1.1、HashMap源码
-
put()方法:
public V put(K key, V value) { //第一次向map中添加元素 if (table == EMPTY_TABLE) { //初始化 inflateTable(threshold); } //如果key为null,存储位置为table[0]或table[0]的冲突链上 if (key == null) //保存null值,放入链表首位 return putForNullKey(value); //将key计算hash值,尽量散列均匀分布 int hash = hash(key); //计算key的位置,保证下标不会越界 int i = indexFor(hash, table.length); //hash冲突解决 for (Entry<K, V> e = table[i]; e != null; e = e.next) { Object k; //判断hash和equals if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; ;//调用value的回调函数,其实这个函数也为空实现 e.recordAccess(this); return oldValue; } } //保证并发访问时,若HashMap内部结构发生变化,快速响应失败 modCount++; addEntry(hash, key, value, i); return null; }
-
addEntry():
void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize if (size++ >= threshold) resize(2 * table.length);//扩容都是2倍2倍的来的, }
-
resize():
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; //将旧的集合放入新的集合,会重新计算hash值 transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
-
transfer():
void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; //下面这段代码的意思是: // 从OldTable里摘一个元素出来,然后放到NewTable中 for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } }
- 负载因子是0.75,初始容量是16,扩容的阈值就是12。
4.1.2、链表循环的原因
- 在上面已经知道了HashMap在放入元素会经过put()、addEntry()、以及扩容的时候resize()、transfer()方法。问题其实就是处在transfer()方法上。
- 假设现在HashMap的大小为2,放入的元素为3,5,7,都冲突在了数组下标1的位置,然后Hash表扩容。这是单线程的情况。
- 假设有两个线程呢?
当线程1执行到do-while语句
第一句的时候就被调度挂起,那么情况如图:
那么这里意思就是:线程1还没有完全扩容,但是e和next都已经指向了线程2正常扩容的了。
当线程1重新被调度回来执行的时候:newTalbe[i] = e;
,此时线程1的HashMap数组下标3的位置指向了Key(3)。e=next;
,此时e指向了Key(7)- 而下次循环的
next=e.next;
,就导致了next指向了Key(3)
- 继续执行
newTable[i] = e;
,那么情况如下图,接着e和next接着下移 - 此时Key(7).next=Key(3),而此时e指向了Key(3),继续执行
newTable[i] = e;
,那么Key(3).next=Key(7),循环出现了。 - 线程2生成的e和next的关系影响到了线程1的情况。从而打乱了正常的e和next的链。于是,当我们的线程一调用到,HashTable.get(11)时,即又到了3这个位置,需要插入新的,那这会就e 和next就乱了。
4.2、JDK1.8
4.2.1、结构变化
- 结构变为了数组+链表+
红黑树
。 - 具体触发条件为:某个
链表
的个数大于8
,并且数组的大小
大于64
的时候,那么会把原来的链表转换成红黑树。 - 为什么会是红黑树?
红黑树
的查询、删除和添加时间复杂度是 O ( l o g 2 n ) O(log_2n) O(log2n)链表
的查询和删除的时间复杂度为 O ( n ) O(n) O(n),插入为: O ( 1 ) O(1) O(1)
- 为什么是红黑树而不是AVL平衡树?
- 红黑树和AVL树都是常见的平衡二叉树,它们的查找,删除,修改的时间复杂度都是 O ( l o g n ) O(log n) O(logn)
- AVL树是更加严格的平衡,因此可以提供更快的查找速度,一般读取查找密集型任务,适用AVL树
- 红黑树更适合插入修改密集型任务
- 两个都是O(logN)查找,但是平衡二叉树可能需要
O
(
l
o
g
N
O(logN
O(logN)旋转,而红黑树需要
最多两次
旋转使其达到平衡(尽可能需要检查O(logN)节点以确定旋转的位置),旋转本身是O(1)操作,因为只需要移动指针。
- 由头插法改为了
尾插法
。