一、前言

集合是java的基础。
我们有了集合,在我们开发过程中,事半功倍。我们常用的集合有这几类:Array,Map,Set,Queue等,他们每一类在java迭代升级的过程中,也是有不同的升级优化。

二、集合全局观

这个是小编画的一个集合全家福,整体上是 Map和Collection;

collection 包含我们常用的Set + Queue + List
Map 包含常用的HashMap + Hashtable + treeMap
一篇带你搞懂 java 集合_数据结构

三、依次看一下

List类

我们依次梳理一下,数组中常用的几种:

ArrayList

ArrayList可以说是我们最常用的了,基本写代码都会用到。有几个特点

  • 线程不安全
  • 基于数组,需要连续内存
  • 随机访问快,(根据下标直接访问)
  • 尾部插入、尾部删除性能可以;其他部分插入、删除都会移动数据,因此性能低
  • 可以利用cpu缓存, 局部性原理

一篇带你搞懂 java 集合_数组_02

扩容机制是什么样的?

数组扩容均是建立一个新的数组,大小会计算好的, 然后把旧数组中的数据copy过去。

一篇带你搞懂 java 集合_开发语言_03

  • ArrayList() 会初始化 长度为0的数组

  • ArrayList(int initialCapacity) 会初始化指定容量的数组

  • public ArrayList(Collection<? extends E> c) 会使用c的大小做为容量

  • add方法,默认是尾插法,首次扩容为10,再次扩容为上次的1.5倍,底层是通过x + x>>1 ,(向右移1位,等于x/2)。

比如,当前数组大小是15,再次扩容的时候,会计算增量 15>>1=7,最终扩容到 15+7=22

   /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return <tt>true</tt> (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
 
  • addAll方法,比较 下一次扩容容量 和元素个数较大的。

初始空时,扩容为Math.max(10,实际元素个数);
有元素时,Math.max(原容量1.5倍,实际元素个数);

比如:

当前数组为空,addall了一个6个元素的list,那么此时元素是6个,6<10,数组容量是10个。
一篇带你搞懂 java 集合_数据结构_04
再addall 一个6个元素的list, 现在10个装不下,要扩容,下次扩容量为 10+10>>1 = 15,12<15,所以扩容到15.

一篇带你搞懂 java 集合_开发语言_05

如果我们插入6个后,我们再插入10个,那么我们就有16个元素,16>15,所以会扩容到16.

一篇带你搞懂 java 集合_链表_06

fail-fast 和 fail-safe

  • fail-fast
    ArrayList是典型代表,多线程操作,遍历的同时修改数组,立即抛出异常。

过程:在遍历之前,会把当前list的元素个数modCount记录下来,迭代的时候会记录迭代器修改的次数expectedModCount,两者会比较,如果不相等,证明被修改了,抛出ConcurrentModificationException异常。

优化方案:使用juc下的包代替java.util,如CopyOnWriteArrayList。
一篇带你搞懂 java 集合_开发语言_07

  • fail-safe
    CopyOnWriteArrayList是典型代表,遍历的同时可以修改,原理是读写分离。
    会copy一份数据,牺牲一致性,让遍历完成。
    一篇带你搞懂 java 集合_数组_08

LinkedList

特点:

  • 基于双向链表,无需连续内存
  • 随机访问慢,(要沿着链表遍历)
  • 头部插入删除性能高
  • 占内存多 (双向链表有更多的成员变量)

一篇带你搞懂 java 集合_java_09

Vector

特点:

  • 数组实现,内存连续
  • 线程安全,Synchronized修饰
  • 扩容方式通过扩容因子判断
  • fail-fast

说一说面试题:

1.ArrayList 和 LinkedList 区别
2.ArrayList 和 Vector 区别
3.ArrayList如何扩容的?
。。。。

Map

map也是我们常用的数据结构,也是面试最经常问的,小编理解了Map的设计后,其实也是很佩服这个设计的。而且我们jdk1.7和jdk1.8结构是有所不同的,其中优化的理念,还是值得借鉴的。比如二次hash,链表转红黑树,以及为什么初始大小是16,扩容大小是2^n?

map这里呢,我们也挑选几个典型:

HashMap

HashMap,在1.7 和1.8 结构有所升级。

1.7 中的HashMap:

  • 结构:数组 + 链表
  • 线程不安全
  • 允许 null 作为 key和value

一篇带你搞懂 java 集合_java_10

1.8 中的HashMap

  • 结构:数组 + 链表 or 红黑树
  • 线程不安全
  • 允许 null 作为 key和value

一篇带你搞懂 java 集合_java_11

提问环节:

  • 1.8 和1.7 的Hashmap有什么区别?

数据结构不一样,1.7 是数组+链表,1.8 是数组+链表 or 红黑树;
链表插入方式:1.7是头插法,1.8是尾插法

  • 什么是红黑树??为什么用红黑树?

红黑树是特殊的平衡二叉树,她比平衡二叉树性能好一些,查询时间复杂度为 O(log2^n)。而链表的查询复杂度是O(1),当链表过长的时候,查询会很慢。
1.8 中转换红黑树的条件是 1.数组长度大于等于64,2.链表长度大于8;
使用红黑树就是为了优化长链表查询慢的问题。

  • 为什么不一上来就用红黑树?

链表短的时候,查询性能很快,没有必要直接用红黑树
存储方面:链表节点是Node,红黑树的节点是treeNode,treeNode成员变量比node多,所以内存会占用多,也没有必要。

  • 1.8转红黑树的阈值为什么是8 ?

阈值为8 ,也是综合考虑的。是为了尽量不要转成红黑树,除非链表真的很长了。
官方的一个demo:如果hash值够随机,在hash表内按泊松分布,在负载因子为0.75的情况下,统计了长度超过8的链表出现的概率是0.00000006 (亿分之6),选8,就是为了让树化 的概率足够小。链表转树,树转链表的开销也很大。

  • 红黑树如何退化为链表?

1.当链表长度小于等于6
2.红黑树的节点,如果 删除节点前,红黑树的 根节点,根的左节点,根的右节点,根的左孙子,有一个为null,就会转为链表。

一篇带你搞懂 java 集合_数据结构_12

如何防止hash碰撞?hash如何计算的?

hashMap 做了二次hash操作
第一次,根据key获取到对应的hashCode值;
第二次,根据hashCode 进行二次hash;
最后,用二次hash的值与数组容量进行取余运算。

根据key计算了hashcode,为什么还要进行二次hash?

保证hash结果更加的均匀,防止长链表产生。
二次hash是通过 hashcode ^ (hashcode>>>16) 计算的,目的是为了分配更加的均匀,防止链表过长

从这个图可以看到,二次hash结果分布更加均匀。
一篇带你搞懂 java 集合_数组_13

数组容量为什么是2^n?

为了提高整体性能,两个地方用到了这个数据
1.取余计算桶的时候,如果数组长度是2^n,那么可以直接用位运算取代取模
eg:
97 % 16 =1
97 & (16-1) = 1
2.方便扩容移动数据
扩容移动数据的时候,根据扩容后桶的长度,计算每个key对应的新桶的位置 A,
如果 A & oldCap == 0, 那就留在原位,否则,新位置 = 旧位置 + oldCap;可以直接移动。

不用2^n 可以吗?

可以,但是综合考虑性能
hashtable 就不是用的2^n

get的流程是什么样的?

首先根据 key 获取hashcode
再 根据hashcode进行二次hash
最后 按位与得到桶的位置
如果桶的位置没有数据,就直接返回null。
如果桶有数据,就依次遍历链表,通过equals()判断key是否相等,相等的话返回对应的value,没有的话返回null。

put流程是什么样的?1.7和1.8 区别?

首先 根据key 获取hashcode
在根据hashcode 进行二次hash
最后 按位与得到桶的位置
如果桶没有数据,就做成node节点,插入
如果桶有数据,判断数据是否存在?通过equals判断存在
存在,更新数据
不存在,插入数据
====如果是treeNode,走红黑树添加逻辑
====如果是普通node,走链表添加逻辑,1.7 头部插入node节点,1.8尾部插入Node节点
添加完后判断是否转红黑树
返回前检查容量是否超过阈值,一旦超过,进行扩容。(先插入,再判断树化,再判断扩容)

1.7并发扩容死链问题?死循环问题?

首先,hashmap是线程不安全的,所以在高并发的时候,会有问题。
其次,1.7是通过头插法完成的,当扩容的时候,a–b,会变为 b–a。node节点还是node节点,只是改变了前后的链接。

一篇带你搞懂 java 集合_数据结构_14

比如当前有两个线程来操作
一篇带你搞懂 java 集合_数据结构_15
其中一个线程2,已经完成了上面的扩容。现在链表的顺序是b–a。
一篇带你搞懂 java 集合_数据结构_16
线程1开始迁移数据,第一轮循环,把a先迁移,然后e的指针指到b,next的指针指到null。
一篇带你搞懂 java 集合_java_17
第二轮循环,next指针,指向b的下一个,是a。e把b迁移走,e指向next,指到a。
一篇带你搞懂 java 集合_java_18
一篇带你搞懂 java 集合_链表_19
第三次循环,next指向null,a要用头插法插到头部,就形成了 第一个node是a,a的next是b,b的next又是a,这样就出现了死链。
一篇带你搞懂 java 集合_链表_20
一篇带你搞懂 java 集合_开发语言_21
一篇带你搞懂 java 集合_java_22

负载因子为什么是0.75?

综合条件考虑的,从空间和时间考虑
大于这个值,空间节省了,但是链表会变长,影响效率。
小于这个值,扩容次数多了,hash冲突少了,空间多了。

hashMap出现HashDos问题,如何解决的?

通过红黑树解决,防止链表太长,性能急剧下降。

ConcurrentHashMap

ConcurrentHashMap ,在1.7 和1.8 结构有所升级

1.7的ConcurrentHashMap

  • 结构 : segment + 数组 + 链表
  • 线程安全,使用ReentrantLock ,用自旋锁来保证线程安全

一篇带你搞懂 java 集合_数据结构_23

1.8的ConcurrentHashMap

  • 结构 : 数组 + 链表 or 红黑树
  • 线程安全,使用 CAS + Synchronized保证线程安全

一篇带你搞懂 java 集合_开发语言_24

提问时间:
1.7和1.8 ConcurrentHashMap 有什么区别?

1.数据结构不一样
====1.7 segment + 数组 + 链表
====1.8 数组 + 链表or红黑树
2.初始化时机不一样
====1.7,饿汉式初始化,初始化的时候,就初始化好segment,以及segment0的数组,数组大小根据容量和并发度来计算。
一篇带你搞懂 java 集合_java_25
====1.8,懒汉式初始化,真正put数据的时候创建
一篇带你搞懂 java 集合_java_26
3. 插入方式不同,1.7头插法,1.8尾插法。
4.扩容时机不同
====1.7 当超过 容量负载因子大小,才扩容
====1.8 当 >= 容量
负载因子,就扩容,eg 12 个就扩容了
5.锁的对象不一样
====1.7锁的是segment
一篇带你搞懂 java 集合_开发语言_27
1.8 锁的是链表的第一个Node
一篇带你搞懂 java 集合_数组_28

ConcurrentHashMap如何保证线程安全的?
ConcurrentHashMap 和 hashMap的区别?
ConcurrentHashMap 和 hashtable的区别?

HashTable

  • 结构 : 数组 + 链表 or 红黑树
  • 线程安全,所有方法通过 Synchronized修饰
  • 不允许 null 作为 key和value,否则报空指针错误
四、小结

集合这个还是很值得研究的。包括里的设计思想。