集合框架
1.List集合下的ArrayList、Lin用到kedList
2.Set集合下的HashSet、TreeSet
3.Map集合常用的是HashMap、TreeMap,要是涉及到线程安全问题的会用到ConcurrentHashMap
List、Set 和 Map集合的区别
List、Set 和 Map集合它们都是Collection 接口下的子接口,它们的区别是 :
1.List集合存储的是一组,有序的、可重复的对象
2.Set集合存储的是一组,无序的、不重复的对象
3.Map集合使用键值对( key-value ) 进行存储。是维护与Key有关联的值,key唯一不可重复
ArrayList & LinkedList 的选择和使用场景,它们的区别
相同点 :
1. ArrayList & LinkedList 都是 List 集合接口的实现类
2. 都遵从 List 接口存储数据的特点,有序且可重复
3. 都是不同步,不能保证线程安全的
不同点 : ArrayList 和 LinkedList 最大的区别是数据结构不同,其次还有随机访问效率,插入删除效率,扩容和内存占用率等区别
1. 数据结构方面:ArrayList 是基于动态数组实现的,而 LinkedList 是基于双向链表实现的。LinkedList 在实现 List 接口基础上,还实现了Deque接口,Deque接口代表一个双向队列,所以 LinkedList 是基于双向链表数据结构的实现
2. 随机访问效率方面:ArrayList 的随机访问效率 要比 LinkedList 快因为,由于ArrayList 是基于索引(index)的数据结构,所以查询根据索引下标直接找元素,性能非常的高,而 LinkedList 由于是基于双向链表存储的,所以随机查询数据的时候,LinkedList 会先计算链表总长度一半的值,然后判读是在这个值的左边还是右边,然后决定从头结点还是从尾结点开始遍历
3. 插入和删除效率方面:一般情况下 LinkedList 的插入和删除操作效率是要比 ArrayList 快的,不过如果 ArrayList 只是尾部操作效率也不慢,因为,ArrayList 插入数据的时候需要移动数据,更新索引,而 LinkedList 看源码会发现,它的存储单元包含了三个成员,存储的数据,以及前后节点指针 prev 和 next,所以 LinkedList 插入数据的时候,只需要引用前后的两个节点即可,删除数据也是同样原理,改变前后节点的指针地址,不需要像 ArrayList 去移动大量数据
4. 扩容方式方面:ArrayList 是基于动态数组实现的,内部初始化了数组长度,默认是10,当发现数据被填满时会进行扩容,会以原数组的1.5倍的长度进行扩容,LinkedList 没有初始化大小,也没有扩容的机制
5. 内存占用方面:一般情况下,LinkedList 的内存占用空间更大,因为 LinkedList 每个节点要维护前后指针,ArrayList 的每个索引的位置只是维护实际的数据,所以 LinkedList 每一个元素都需要消耗比 ArrayList 更多的空间, ArrayList的空间浪费主要体现在结尾会预留一定的容量空间
所以根据他们的特性 LinkedList 的增删效率更高,在平常工作中,如果需要处理的 list集合 需要经常进行增删并且随机访问较少使用 LinkedList ,其他情况一般都会选择ArrayList
List集合的线程安全问题
大概有三种方法可以解决 List 集合的线程安全问题,List 接口下的 Vector 实现类就可以解决,不过,Vector 因为在同步上消耗了大量的资源,导致性能变慢,本身也并没有能完全解决线程安全问题,基本上已经不再使用了。所以,我在工作中会根据情况选择使用 Collections.synchronizedList() 方法 或使用 COW 也就是 CopyOnWriteArrayList 来解决 List 集合的线程安全问题
synchronizedList() 方法
在synchronizedList()方法的源码里,大多数操作方法都是 synchronized 关键字修饰的,它只是在 List 进行操作前给加了同步锁,实际的操作还都是在原 List 对象上进行,SynchronizedList 可以将所有的 List 的子类转成线程安全的类,比如:linkedlist,扩展和兼容方面还是很好的,但是 SynchronizedList 的便利方法 iterator 没有添加锁,所以在多线程开发中如果要遍历,还是必须要在外面用 synchronized 加一层锁,来保证线程安全
COW
意思是:写入时复制, 就是在执行写入操作的时候,不在当前数组上面修改,而是复制一份新的数组,在新的数组上面修改,修改完毕以后,再去替换掉当前的数组,实现了读写分离,效率就提高了很多。COW 适合读多,写少的情况。它的整个过程中是使用 volatile 修饰数组来保证修改后的可见性,使用 ReentrantLock 保证多线程情况下修改的原子性
Vector
List 接口下面还有一个叫 Vector 的实现类,它 和 ArrayList 的实现几乎是一样的,区别在于
ArrayList 的扩容是原数组长度的1.5倍, Vector 是原数组长度的是2倍
并且 Vector 是线程同步的,因为 Vector 在每个方法上都加上 synchronized 关键字修饰,从而来保证线程安全
但是Vector也不是绝对安全的,对于复合操作,Vector 仍然需要开发人员自己进行同步处理。
因为 Vector 在同步上消耗了大量资源,导致性能变慢,而且本身并没有完全解决多线程问题,所以现在已经基本不再使用
HashMap 集合
HashMap的底层结构是哈希表的具体实现,通过相应的哈希运算,可以很快查询到目标元素在表中的位置,拥有很快的查询速度,我们现在常用的都是 JDK 1.8,底层的数据结构是由“数组+链表+红黑树”组成,而在 JDK 1.8 之前是由“数组+链表”组成的。
- 那数组是HashMap的主体
- 链表则是主要为了解决哈希冲突而存在的
- jdk1.8 添加红黑树,主要是为了提升在 hash 冲突严重时(链表过长)的查找性能
新建 HashMap 对象时, 扩容阈值 会先被用来存初始化时的容量,默认是16,直到第一次插入节点时,才会进行初始化,避免不必要的空间浪费
HashMap的整体插入流程大概是这样的, 在 put 元素时,会先检查数组是非为空,如果数组为空,会先初始化数组, 进行一次 resize 操作,初始化完毕后,定位索引位置,遍历链表,存在则覆盖,不存在则新增,当链表长度大于 8 ,并且数组长度大于 64 的时候,链表就会树化,转化为红黑树,而如果数组小于64,则不会转红黑树,而是会进行扩容,因为此时的数据量还比较小
而对于移除操作,当红黑树节点小于等于 6 个,会转为链表
没有将转回链表节点的阈值也设置为8,是因为当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗
HashMap 不是线程安全的,因为它在并发下存在数据丢失、覆盖、遍历的同时进行修改会出现异常,JDK 1.8 之前还存在死循环问题。导致死循环的根本原因是 JDK 1.7 扩容采用的是“头插法”,会导致同一索引位置的节点在扩容后顺序反掉,在多线程环境下,当两个线程同时检测到 hashmap 需要扩容,而造成死循环,jdk1.8 在扩容时插入方式从“头插法”改成“尾插法”,避免了并发下死循环的问题
HashMap的扩容 resize()
在HashMap中,如果HashMap在初始化数组或者添加元素个数超过阈值时都会触发扩容 resize() 方法 。HashMap的默认初始容量是16,负载因子是0.75,扩容阈值 就等于 容量 * 负载因子,扩容数组是原数组的2倍,扩容后,会重新计算每个元素在数组中的位置,将原数组的元素重新映射到新的数组中。
我们平时在工作中,新建 HashMap 时,最好是根据自己使用情况设置初始容量,避免经常扩容带来的性能消耗,HashMap 的容量必须是2的幂次方,HashMap 会根据传入的容量,计算一个大于等于该容量,最小的2的幂次方,例如当我传入初始容量为30,其实际容量为32
HashMap 的容量必须是 2 的幂次方
通过源码会发现,jdk1.8 对计算数组容量的方法进行了优化,公式为 (n-1) & hash(key),这个n就是容量,也就是说:当n为2的幂次方时,n-1的二进制值会全为1,此时任何值跟 n - 1 进行 & 运算会等于其本身,这样避免了不必要的哈希冲突。所以这也解释了为什么扩容是原数组的2倍,就是为了维持容量始终为2的幂次方
Map的分类
- Hashtable:最早的线程安全Map,通过对方法本身添加 synchronized 关键字,保证了线程安全,但是也大大的降低了执行效率,理论上是不会再使用了
- ConcurrentHashMap:也是一个线程安全的map,是通过 Synchronized+CAS 来实现线程安全的,线程安全问题常用ConcurrentHashMap的方式
- LinkedHashMap:是一个有序的双向链表,能记录访问顺序或插入顺序的Map,在需要记录访问顺序和插入顺序时使用
- TreeMap:是一个可以通过 Comparator 实现自定义排序的Map,默认是根据Key来比较来排序的,在需要自定义排序的情况下使用
红黑树
因为是红黑树嘛,所以每个节点非红即黑,但是根节点总是黑色的
- 每个叶子节点都是黑色的空节点(NIL节点)
- 从根节点到叶节点或空子节点的每条路径,黑色节点数目相同(即相同的黑色高度)
JDK 1.8 主要进行了优化
- 底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能。
- 计算 数组 初始容量的方式发生了改变
- 扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。
注意:在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数
线程安全推荐使用 ConcurrentHashMap
ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。而HashTable是使用一个全局的锁来同步不同线程间的并发访问,任何操作都会把整个表锁住,是阻塞的
ConcurrentHashMap
使用 数组+链表+红黑树 的数据结构来实现,并发控制使用 Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap
在jdk1.8中 ConcurrentHashMap put的流程是这样的。
- 如果没有初始化就先进行初始化过程
- 如果没有hash冲突就直接CAS插入
- 如果还在进行扩容操作就先进行扩容
- 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,其实就是看是链表还是红黑树
- 是链表形式就直接遍历到尾端插入,
- 是红黑树就按照红黑树结构插入,
- 如果添加成功就统计size,并且检查是否需要扩容
ConcurrentHashMap 在 jdk1.8 的优化
- 相对于JDK1.7版本,JDK1.8的实现降低锁的粒度,1.7版本锁的粒度是基于Segment的,包含多个HashEntry,1.8版本中的 synchronized 只锁定当前链表或红黑二叉树的首节点,也就是说 1.8 版本的粒度就是HashEntry,只要hash不冲突,就不会产生并发,效率又提升N倍。
- JDK1.8版本的数据结构变为数组+链表+红黑树,更加简单了,使得操作也更加清晰流畅
- JDK1.8使用红黑树来优化链表,提升了遍历效率
保证map的线程安全
在Collections接口下提供了synchronizedMap方法 可以把hashmap封装成一个synchronizedMap对象。不过其封装的本质 和 Hashtable 的实现是完全一致的,也是对原Map本身的方法进行加锁,所以这种同步方式的执行效率也是很低的。还是用ConcurrentHashMap更好
CAS
它的全称是 Compare-and-Swap,如字面意思,就是比较并替换,那在CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B
但是CAS 还是有缺点的
- 第一个就是,CAS在不加锁的情况下,只能保证一个变量的原子性
- 第二个是,在高并发量情况下,如果CAS长时间自旋不成功,循环往复,会给CPU带来非常大的执行开销,还有可能死循环
- 第三个是,典型的ABA问题,CAS是比较并替换,ABA问题是指在CAS操作时变量值A时,在这个期间,其他线程将变量值A改为了B,但是又被改回了A,等到CAS线程继续使用进行比较时,发现变量A没有变,于是CAS就将A值进行了交换操作,但是实际上该值已经被其他线程改变过
- ABA问题的解决思路:是使用版本号或时间戳,在每次变更更新的时候,把变量的版本号加1,这样就解决了ABA问题,而 Java 为了解决这个问题,在Atomic包下,提供了“AtomicStampedReference”,来保证CAS的正确性。
HashSet
HashSet是基于HashMap实现的,HashSet 底层使用HashMap来保存所有元素,相关HashSet 的操作,基本上都是直接调用底层HashMap的相关方法来完成,HashSet不允许有重复的值,并且元素是无序的。因此HashSet 的实现比较简单,我觉得其实就是外面包了一层 Set 的HashMap,需要注意的是在将对象存储在HashSet之前,要确保重写hashCode()方法和equals()方法,这样才能比较对象的值是否相等,确保集合中没有储存相同的对象