概括:
数组,链表,散列表,二分查找树,红黑树是五种不同的数据结构,它们有各自的特点和用途。ArrayList,LinkedList,HashTable,LinkedHashMap,HashMap 是 Java 中的五个类,它们分别实现了 List 和 Map 接口,用来表示列表和映射。它们之间的对应关系如下:
1.ArrayList 是基于数组的列表,它使用一个动态扩展的数组来存储元素。
2.LinkedList 是基于双向链表的列表,它使用一个双向链表来存储元素。
3.HashTable 是基于散列表的映射,它使用一个数组和一个哈希函数来存储键值对。它是线程安全的,但是效率较低。
4.LinkedHashMap 是基于散列表和双向链表的映射,它使用一个数组和一个哈希函数来存储键值对,并且使用一个双向链表来维护键值对的插入顺序或者访问顺序。
5.HashMap 是基于散列表和红黑树的映射,它使用一个数组和一个哈希函数来存储键值对,并且当一个数组位置有多个键值对时,使用一个红黑树来存储这些键值对。
所以,你可以把这五个类分为两类:列表和映射。列表是一种有序的集合,它允许重复的元素,并且可以按照下标来访问或者修改元素。映射是一种无序的集合,它不允许重复的键,并且可以按照键来访问或者修改值。
List
list接口通常表示一个列表(数组、队列、链表、栈等),其中的元素可以重复,常用实现类为ArrayList和LinkedList及不常用的Vector。另外,LinkedList还是实现了Queue接口,因此也可作为队列使用。
同类型,可重复,有序性
list允许重复:指的是可以插入相同的名称 比如 两个张三,还有可以插入多个null值。
ArrayList
基于数组实现,可以通过下标索引直接查找到指定位置的元素,因此查找效率高,但每次插入或删除元素,就要大量地移动元素,插入删除元素的效率低。不是线程安全的。
批量处理数组 System.arraycopy(),对于ArrayList里面,add和remove等方法都用到了.
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
可以看到ArrayList类实现了Serializable接口,因此它支持序列化,能够通过序列化传输,实现了RandomAccess接口,支持快速随机访问,实现了Cloneable接口,能被克隆。实现类RandomAccess接口(标记接口),说明它支持快速访问。对于ArrayList来说,采用for循环遍历更快,对于LinkedList来说,采用迭代器遍历更快。
LinkedList
内部存储用的数据结构是双向链表,动态的插入和删除,访问遍历比较慢.
LinkedList是线程不安全的,若想使LinkedList变成线程安全的,可以使用如下方式:
List list=Collections.synchronizedList(new LinkedList(...));
LinkedList与ArrayList的区别
顺序插入的速度ArrayList快于LinkedList。因为ArrarList只是在指定的位置上赋值即可,而LinkedList则需要创建Node对象,并且需要建立前后关联,如果对象较大的话,速度会慢一些。
基于上面的理解LinkedList的占用的内存空间要大一些。
数组遍历的方式ArrayList推荐使用for循环,而LinkedList则推荐使用foreach,如果使用for循环,效率将会很慢。
一般我们这样认为:ArrarList查询和获取快,修改和删除慢;LinkedList修改和删除快,查询和获取慢。其实这样说不准确的。
LinkedList做插入、删除的时候,慢在寻址,快在只需要改变前后Entry的引用地址;
ArrayList做插入、删除的时候,慢在数组元素的批量copy,快在寻址。
Vector
它是非同步的,若涉及到多线程,用Vector会比较好一些,在非多线程环境中,Vector对于元素的查询、添加、删除和更新操作效果不是很好
Vector类中的capacity()/size()/isEmpty()/indexOf()/lastIndexOf()/removeElement()/addElement() 等方法
均是 sychronized 的,所以,对Vector的操作均是线程安全的,但也不完全原子性操作,中间有间隙。
Vector与ArrayList的区别
Vector 和 ArrayList 实现了同一接口 List, 但所有的 Vector 的方法都具有 synchronized 关键修饰。但对于复合操作,Vector 仍然需要进行同步处理
想要ArrayList安全 :使用同步包装器(Collections.synchronizedList),还可以使用J.U.C中的CopyOnWriteArrayList。
Set
接口通常表示一个集合,其中的元素不允许重复(通过hashcode和equals函数保证),常用实现类有HashSet和TreeSet,HashSet是通过Map中的HashMap实现的,而TreeSet是通过Map中的TreeMap实现的。另外,TreeSet还实现了SortedSet接口,因此是有序的集合(集合中的元素要实现Comparable接口,并覆写Compartor函数才行)。
set不允许重复,指的是,就算添加两个相同的值 也是只有一个出现。
HashSet 是基于散列表的集合
LinkedHashSet 是基于散列表和双向链表的集合
TreeSet 是基于红黑树的集合
Map
HashMap
底层基于哈希表,每一个元素是key-value对,采用数组存储数据,使用链表来解决哈希碰撞。在JDK1.8中引入了红黑树来解决链表长度过长导致的查询速度下降问题。HashMap是非线程安全的,只是用于单线程环境中,多线程环境中采用concurrent并发包下的concurrentHashMap,
1.HashMap是非线程安全的,允许key为null,value为null,遍历时无序。不允许重复
2.底层数据结构是数组称为哈希桶,每个桶里面放的是链表,链表中的每个节点,就是哈希表中的每个元素。
3.在JDK8中,当链表长度达到8,会转化成红黑树,以提升它的查询、插入效率.
数组链表转红黑树:
我可以尝试用更简单的语言来解释一下数组链表转红黑树的过程。你可以想象一个 HashMap 就像一个书架,它有很多格子,每个格子里可以放很多本书。每本书都有一个编号,这个编号就是它的哈希值,它决定了这本书应该放在哪个格子里。如果一个格子里只有一本书,那么我们很容易就能找到它。但是如果一个格子里有很多本书,那么我们就需要一个个地翻看,这样就很慢。为了加快查找的速度,我们可以把一个格子里的书按照编号的顺序排列起来,这样就形成了一个链表。这样我们就可以用二分法来查找,比如我们要找编号为 5 的书,我们先看中间的书是不是 5,如果不是,再看它是大于 5 还是小于 5,然后再在相应的一半里继续查找,直到找到或者没有为止。
但是有时候,一个格子里的书太多了,即使排成链表也还是很慢。比如说,如果一个格子里有 1000 本书,那么最坏的情况下,我们可能需要查找 10 次才能找到或者确定没有。为了进一步加快查找的速度,我们可以把一个格子里的书按照编号的高位和低位分成两堆,比如说,编号的前两位是 00 的放在一堆,编号的前两位是 01 的放在另一堆,以此类推。然后我们再把每一堆里的书按照编号的顺序排列起来,这样就形成了一个树形结构。这样我们就可以用树形搜索来查找,比如我们要找编号为 0101 的书,我们先看第一堆里有没有,如果没有,再看第二堆里有没有,如果有,再在第二堆里按照编号的顺序查找。这样最坏的情况下,我们只需要查找 4 次就能找到或者确定没有。
但是有时候,一个格子里的书分成两堆还是不够平均,比如说,编号的前两位是 00 的有 900 本书,编号的前两位是 01 的只有 100 本书。这样的话,我们查找编号为 00 开头的书还是很慢。为了让每一堆里的书更加平均分布,我们可以用一种特殊的树形结构来存储和查找书籍,这种树形结构叫做红黑树。红黑树有以下几个特点:
每个节点(即每本书)都有一个颜色属性,要么是红色要么是黑色。
根节点(即第一本书)必须是黑色。
每个叶子节点(即空节点)都是黑色。
如果一个结点是红的,则它两个子节点都是黑的。也就是说在一条路径上不能出现相邻的两个红色结点。
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。正是这些性质的限制,使得红黑树中任一节点到其子孙叶子节点的最长路径不会长于最短路径的2倍,因此它是一种接近平衡的二叉树
红黑树的引入,既是为了平衡寻找的次数,也是为了加速查找速率。因为平衡寻找的次数,就意味着减少了最坏情况下的查找时间。比如说,如果一个格子里有 1000 本书,用链表存储和查找,最坏的情况下,我们需要查找 1000 次;用普通的二叉搜索树存储和查找,最坏的情况下,我们可能需要查找 1000 次(如果树退化成链表);用红黑树存储和查找,最坏的情况下,我们只需要查找 10 次。所以,红黑树相比于链表和普通的二叉搜索树,都能提高查找速率。
HashTable
是基于key-value键值对,采用桶位+链表结构实现。不允许存null,如果put null,会 npe
HashTable与HashMap的区别
1.hashmap中key和value均可以为null,但是hashtable中key和value均不能为null。
2.hashmap采用的是数组(桶位)+链表+红黑树结构实现,而hashtable中采用的是数组(桶位)+链表实现。
3.hashmap中出现hash冲突时,如果链表节点数小于8时是将新元素加入到链表的末尾,而hashtable中出现hash冲突时采用的是将新元素加入到链表的开头。
4.hashmap中数组容量的大小要求是2的n次方,如果初始化时不符合要求会进行调整,而hashtable中数组容量的大小可以为任意正整数。
5.hashmap中的寻址方法采用的是位运算按位与,而hashtable中寻址方式采用的是求余数。
6.hashmap不是线程安全的,而hashtable是线程安全的,hashtable中的get和put方法均采用了synchronized关键字进行了方法同步。
7.hashmap中默认容量的大小是16,而hashtable中默认数组容量是11。
LinkedHashMap
1.LinkedHashMap是非线程安全的。
2.LinkedHashMap中加入了一个head头结点,将所有插入到该LinkedHashMap中的Entry按照插入的先后顺序依次加入到以head为头结点的双向循环链表的尾部
3.LinkedHashMap是HashMap和LinkedList两个集合类存储结构的结合,所有put进来的Entry都保存在一个哈希表中,但它又额外定义了一个以head为头结点的空的双向循环链表,每次put进来Entry,除了将其保存到对哈希表中对应的位置上外,还要将其插入到双向循环链表的尾部。
4.LinkedHashMap继承自HashMap,同样允许key和value为null。
5.accessOrder标志属性,当为false时,表示双向链表中的元素按照Entry插入LinkedHashMap到中的先后顺序排序,当它为true时,表示双向链表中的元素按照访问的先后顺序排列。
6.LinkedHashMap如何实现LRU。首先,当accessOrder为true时,才会开启按访问顺序排序的模式,才能用来实现LRU算法。我们可以看到,无论是put方法还是get方法,都会导致目标Entry成为最近访问的Entry,因此便把该Entry加入到了双向链表的末尾,这样便把最近使用了的Entry放入到了双向链表的后面,多次操作后,双向链表前面的Entry便是最近没有使用的,这样当节点个数满的时候,删除的最前面的Entry(head后面的那个Entry)便是最近最少使用的Entry。
TreeMap
1.TreeMap是根据key进行排序的,它的排序和定位需要依赖比较器或覆写Comparable接口,也因此不需要key覆写hashCode方法和equals方法,就可以排除掉重复的key,而HashMap的key则需要通过覆写hashCode方法和equals方法来确保没有重复的key。
2.TreeMap的查询、插入、删除效率均没有HashMap高,一般只有要对key排序时才使用TreeMap。
3.TreeMap的key不能为null,而HashMap的key可以为null。
ConcurrentHashMap
总结1.7和1.8区别
结构区别:JDK7的是由一个Segment数组组成,每个Segment是一个类似于HashMap的结构,它包含一个Node数组和一个链表。JDK8的是由一个Node数组组成,每个Node是一个键值对,它可以连接成一个链表或者一个红黑树。
锁区别:JDK7的使用了分段锁的机制,每个Segment继承了ReentrantLock,对Segment进行读写操作时需要加锁。JDK8的取消了分段锁,而是使用了synchronized和CAS来实现对每个节点的细粒度锁控制。
扩容区别:JDK7的在扩容时,需要对整个Segment加锁,然后将原来的Node数组复制到新的数组中,并重新计算哈希值和索引。JDK8的ConcurrentHashMap在扩容时,不需要加锁,而是使用CAS来保证原子性,然后将原来的Node数组分成两个部分,一部分保持不变,另一部分迁移到新的数组中,并使用高位运算来计算索引。
插入区别:JDK7的在插入节点时,使用了头插法,也就是说新插入的节点会放在链表的头部。这样在扩容时,原来的链表顺序会被反转。JDK8的在插入节点时,使用了尾插法,也就是说新插入的节点会放在链表的尾部。这样在扩容时,原来的链表顺序不会改变。
优化区别:JDK8的在处理哈希冲突时,有一个优化措施,就是当链表长度达到一定阈值(默认是8)时,会将链表转换为红黑树,这样可以提高查找效率。JDK7的ConcurrentHashMap没有这个优化措施。
Java 7
初始化
1.必要参数校验。
2.校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无参构造默认值是 16.
3.寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。
4.记录 segmentShift 偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28.
5.记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15.
6.初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。
segmentShift和segmentMask是ConcurrentHashMap在JDK1.7中使用的两个变量,
它们用于定位Segment对象在segments数组中的位置。segmentShift是一个位移量,
它表示hash值右移多少位后,再与segmentMask进行与运算,得到Segment对象的索引。segmentMask是一个掩码,它是一个二进制数,所有位都是1,它的大小等于segments数组的
长度减1。例如,如果segments数组的长度是16,那么segmentShift是28,segmentMask
是15。32是hash值的位数,4是segments数组的长度的以2为底的对数。也就是说,
segments数组的长度是2的4次方,也就是16。为了从hash值中提取出Segment对象的索引,
需要将hash值右移32-4=28位,这样就只剩下高4位,然后与segmentMask进行与运算,
得到0到15之间的一个整数,作为Segment对象的索引。这样可以保证hash值在segments
数组中均匀分布,减少锁竞争。
put
简单总结;
1.Segment 段和初始化 Segment 段的操作
2.开始put,tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取
3.获取的 HashEntry,不存在(是否大于阈值,大于就扩容,不大于就直接插入)
4.获取的 HashEntry,存在,当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
不一致继续向下获取链表,直到找到为止,大于阈值扩容,否则插入
扩容
ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为
index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置
get
1.计算得到 key 的存放位置。
20遍历指定位置查找相同 key 的 value 值。
Java 8
初始化initTable
从源码中可以发现 ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。
1.-1 说明正在初始化
2.-N 说明有 N-1 个线程正在进行扩容
3.0 表示 table 初始化大小,如果 table 没有初始化
4.>0 表示 table 扩容的阈值,如果 table 已经初始化。
put
1.根据 key 计算出 hashcode 。
2.判断是否需要进行初始化。
3.即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
4.如果当前位置的hashcode == MOVED == -1 则需要进行扩容。
5.如果都不满足,则利用 synchronized 锁写入数据。
6.如果数量大于TREEIFY_THRESHOLD则要执行树化方法,在treeifyBin中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
get
1.根据 hash 值计算位置。
2.查找到指定位置,如果头节点就是要找的,直接返回它的 value.
3.如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
4.如果是链表,遍历查找之。
各种树,链表,数组结构
链接: https://juejin.cn/post/6844903625236430862
参考地址:
JavaGuide/docs/java/collection/concurrent-hash-map-source-code.md
jdk-sourcecode-analysis/note/ConcurrentHashMap/concurrenthashmap.md