飞道的博客

【Java集合类】 ---- Java三大集合类(List、Map、Set)分析

500人阅读  评论(0)

一、List集合

1、ArrayList

1.1 数据结构

ArrayList 底层是一个数组结构

图中展示是长度为 10 的数组,从 1 开始计数,index 表示数组的下标,从 0 开始计数,elementData 表示数组本身,还有以下三个基本概念:

  • DEFAULT_CAPACITY 表示数组的初始大小,默认是 10;
  • size 表示当前数组的大小,类型 int,没有使用 volatile 修饰,非线程安全的;
  • modCount 统计当前数组被修改的版本次数,数组结构有变动,就会 +1

1.2 初始化

private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {
   };
 
//无参数直接初始化,数组大小为空
public ArrayList() {
   
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
 
//指定初始数据初始化
public ArrayList(Collection<? extends E> c) {
   
    //elementData 是保存数组的容器,默认为 null
    elementData = c.toArray();
    //如果给定的集合(c)数据有值
    if ((size = elementData.length) != 0) {
   
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        //如果集合元素类型不是 Object 类型,我们会转成 Object
        if (elementData.getClass() != Object[].class) {
   
            elementData = Arrays.copyOf(elementData, size, Object[].class);
        }
    } else {
   
        // 给定集合(c)无值,则默认空数组
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

1.3 新增

新增就是往数组中添加元素,主要分成两步:

  • 判断是否需要扩容,如果需要执行扩容操作;
  • 直接赋值
public boolean add(E e) {
   
  //确保数组大小是否足够,不够执行扩容,size 为当前数组的大小
  ensureCapacityInternal(size + 1);  // Increments modCount!!
  //直接赋值,线程不安全的
  elementData[size++] = e;
  return true;
}
private void ensureCapacityInternal(int minCapacity) {
   
  //如果初始化数组大小时,有给定初始值,以给定的大小为准,不走 if 逻辑
  if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
   
    minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
  }
  //确保容积足够
  ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
   
  //记录数组被修改
  modCount++;
  // 如果我们期望的最小容量大于目前数组的长度,那么就扩容
  if (minCapacity - elementData.length > 0)
    grow(minCapacity);
}
//扩容,并把现有数据拷贝到新的数组里面去
private void grow(int minCapacity) {
   
  int oldCapacity = elementData.length;
  // oldCapacity >> 1 是把 oldCapacity 除以 2 的意思
  int newCapacity = oldCapacity + (oldCapacity >> 1);
 
  // 如果扩容后的值 < 我们的期望值,扩容后的值就等于我们的期望值
  if (newCapacity - minCapacity < 0)
    newCapacity = minCapacity;
 
  // 如果扩容后的值 > jvm 所能分配的数组的最大值,那么就用 Integer 的最大值
  if (newCapacity - MAX_ARRAY_SIZE > 0)
    newCapacity = hugeCapacity(minCapacity);
 
  // 通过复制进行扩容
  elementData = Arrays.copyOf(elementData, newCapacity);
}
  • 扩容的规则并不是翻倍,是原来容量大小 + 容量大小的一半,直白来说,扩容后的大小是原来容量的 1.5 倍;
  • ArrayList 中的数组的最大值是Integer.MAX_VALUE,超过这个值,JVM 就不会给数组分配内存空间了。
  • 新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的

elementData [size++] = e,这种简单赋值,没有任何锁控制,是线程不安全的

1.4 扩容

扩容是通过这行代码来实现的:Arrays.copyOf(elementData, newCapacity);,这行代码描述的本质是数组之间的拷贝,扩容是会先新建一个符合我们预期容量的新数组,然后把老数组的数据拷贝过去,我们通过 System.arraycopy 方法进行拷贝,此方法是 native 的方法

/**
 * @param src     被拷贝的数组
 * @param srcPos  从数组那里开始
 * @param dest    目标数组
 * @param destPos 从目标数组那个索引位置开始拷贝
 * @param length  拷贝的长度 
 * 此方法是没有返回值的,通过 dest 的引用进行传值
 */
public static native void arraycopy(Object src, int srcPos,
                                    Object dest, int destPos,
                                    int length);

1.5 线程不安全

只有当 ArrayList 作为共享变量时,才会有线程安全问题,当 ArrayList 是方法内的局部变量时,是没有线程安全的问题的。

ArrayList 有线程安全问题的本质,是因为 ArrayList 自身的 elementData、size、modConut 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况

1.6 总结

  • ArrayList是List接口的一个可变大小的数组的实现
  • ArrayList的内部是使用一个Object对象数组来存储元素的
  • 初始化ArrayList的时候,可以指定初始化容量的大小,如果不指定,就会使用默认大小,为10
  • 当添加一个新元素的时候,首先会检查容量是否足够添加这个元素,如果够就直接添加,如果不够就进行扩容,扩容为原数组容量的1.5倍
  • 当删除一个元素的时候,会将数组右边的元素全部左移

2、LinkedList

2.1 数据结构

LinkedList 适用于集合元素先入先出和先入后出的场景

LinkedList 底层数据结构是一个双向链表

LinkedList类实现了List接口和Deque接口,是一种链表类型的数据结构,支持高效的插入和删除操作,同时也实现了Deque接口,使得LinkedList类也具有队列的特性。

链表中的元素叫做 Node

private static class Node<E> {
   
    E item;// 节点值
    Node<E> next; // 指向的下一个节点
    Node<E> prev; // 指向的前一个节点
 
    // 初始化参数顺序分别是:前一个节点、本身节点值、后一个节点
    Node(Node<E> prev, E element, Node<E> next) {
   
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

2.2 查询

链表查询某一个节点是比较慢的,需要挨个循环查找才行

LinkedList 并没有采用从头循环到尾的做法,而是采取了简单二分法,首先看看 index 是在链表的前半部分,还是后半部分。如果是前半部分,就从头开始寻找,反之亦然。通过这种方式,使循环的次数至少降低了一半,提高了查找的性能

// 根据链表索引位置查询节点
Node<E> node(int index) {
   
    // 如果 index 处于队列的前半部分,从头开始找,size >> 1 是 size 除以 2 的意思。
    if (index < (size >> 1)) {
   
        Node<E> x = first;
        // 直到 for 循环到 index 的前一个 node 停止
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
   // 如果 index 处于队列的后半部分,从尾开始找
        Node<E> x = last;
        // 直到 for 循环到 index 的后一个 node 停止
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

2.3 新增

追加节点时,我们可以选择追加到链表头部,还是追加到链表尾部,add 方法默认是从尾部开始追加,addFirst 方法是从头部开始追加

// 从尾部开始追加节点
void linkLast(E e) {
   
    // 把尾节点数据暂存
    final Node<E> l = last;
    // 新建新的节点,初始化入参含义:
    // l 是新节点的前一个节点,当前值是尾节点值
    // e 表示当前新增节点,当前新增节点后一个节点是 null
    final Node<E> newNode = new Node<>(l, e, null);
    // 新建节点追加到尾部
    last = newNode;
    //如果链表为空(l 是尾节点,尾节点为空,链表即空),头部和尾部是同一个节点,都是新建的节点
    if (l == null)
        first = newNode;
    //否则把前尾节点的下一个节点,指向当前尾节点。
    else
        l.next = newNode;
    //大小和版本更改
    size++;
    modCount++;
}
// 从头部追加
private void linkFirst(E e) {
   
    // 头节点赋值给临时变量
    final Node<E> f = first;
    // 新建节点,前一个节点指向null,e 是新建节点,f 是新建节点的下一个节点,目前值是头节点的值
    final Node<E> newNode = new Node<>(null, e, f);
    // 新建节点成为头节点
    first = newNode;
    // 头节点为空,就是链表为空,头尾节点是一个节点
    if (f == null)
        last = newNode;
    //上一个头节点的前一个节点指向当前节点
    else
        f.prev = newNode;
    size++;
    modCount++;
}

2.4 删除

节点删除的方式和追加类似,我们可以选择从头部删除,也可以选择从尾部删除,删除操作会把节点的值,前后指向节点都置为 null,帮助 GC 进行回收

3、Vector

​ 其是ArrayList的改进版本,其采用Synchronized加锁的方式保证了线程安全

二、Map集合

1、HashMap

1.1 数据结构

1.8 : 数组 + 单链表(尾插) + 红黑树(链表元素大于等于8,并且数组长度大于64的时候,链表就会转化为红黑树, 当红黑树的大小小于等于 6 时,红黑树会转化成链表)

1.7: 数组 + 单链表(头插)

如图是JDK1.8 - HashMap的图

1.2 类注释

  • 允许 null 值,不同于 HashTable ,是线程不安全的;
  • load factor(影响因子) 默认值是 0.75, 是均衡了时间和空间损耗算出来的值
    • 较高的值会减少空间开销(扩容减少,数组大小增长速度变慢),但增加了查找成本(hash 冲突增加,链表长度变长)
    • 较小的值会增大空间开销(扩容频繁,数组大小增长速度变快),但hash冲突减小
  • 如果有很多数据需要储存到 HashMap 中,建议 HashMap 的容量一开始就设置成足够的大小,这样可以防止在其过程中不断的扩容,影响性能;
  • HashMap 是非线程安全的,我们可以自己在外部加锁,或者通过 Collections#synchronizedMap 来实现线程安全,Collections#synchronizedMap 的实现是在每个方法上加上了 synchronized 锁;
  • 在迭代过程中,如果 HashMap 的结构被修改,会快速失败。
//初始容量为 16
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
 
 //最大容量
 static final int MAXIMUM_CAPACITY = 1 << 30;
 
 //负载因子默认值
 static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
 //桶上的链表长度大于等于8时,链表转化成红黑树
 static final int TREEIFY_THRESHOLD = 8;
 
 //桶上的红黑树大小小于等于6时,红黑树转化成链表
 static final int UNTREEIFY_THRESHOLD = 6;
 
 //当数组容量大于 64 时,链表才会转化成红黑树
 static final int MIN_TREEIFY_CAPACITY = 64;
 
 //记录迭代过程中 HashMap 结构是否发生变化,如果有变化,迭代时会 fail-fast
 transient int modCount;
 
 //HashMap 的实际大小,可能不准(因为当你拿到这个值的时候,可能又发生了变化)
 transient int size;
 
 //存放数据的数组
 transient Node<K,V>[] table;
 
 // 扩容的门槛,有两种情况
 // 如果初始化时,给定数组大小的话,通过 tableSizeFor 方法计算,数组大小永远接近于 2 的幂次方,比如你给定初始化大小 19,实际上初始化大小为 32,为 2 的 5 次方。
 // 如果是通过 resize 方法进行扩容,大小 = 数组容量 * 0.75
 int threshold;
 
 //链表的节点
 static class Node<K,V> implements Map.Entry<K,V> {
   
 
 //红黑树的节点
 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
   

1.3 新增

  1. 空数组有无初始化,没有的话初始化;
  2. 如果通过key的hash计算出下标
    如果key值为null, 则将节点插入到第一个桶,
    否则看当前下标节点为空 或者 key值相同,则跳转第6步,否则到第3步
  3. 如果 hash 冲突,两种解决方案:链表 or 红黑树;
  4. 如果是链表,循环,把新元素追加到队尾;
  5. 如果是红黑树,调用红黑树新增的方法;
  6. 通过 2、4、5 将新元素追加成功,再根据 onlyIfAbsent 判断是否需要覆盖;
  7. 判断是否需要扩容,需要扩容进行扩容,结束。

为什么链表大小长度为8时会转化为红黑树?

链表查询的时间复杂度是 `O (n)`,红黑树的查询复杂度是 `O (log (n))`。在链表数据不多的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗,所以我们需要定义出转化的边界值。

在考虑设计 8 这个值的时候,我们参考了泊松分布概率函数,由泊松分布中得出结论,链表各个长度的命中概率为:

当链表的长度是 8 的时候,出现的概率是 0.00000006,不到千万分之一,所以说正常情况下,链表的长度不可能到达 8 ,而一旦到达 8 时,肯定是 hash 算法出了问题,所以在这种情况下,为了让`HashMap`仍然有较高的查询性能,所以让链表转化成红黑树,我们正常写代码,使用 HashMap 时,几乎不会碰到链表转化成红黑树的情况,毕竟概率只有千万分之一。

1.4 查找

HashMap 的查找主要分为以下三步:

  • 根据 hash 算法定位数组的索引位置,equals 判断当前节点是否是我们需要寻找的 key,是的话直接返回,不是的话往下。
  • 判断当前节点有无 next节点,有的话判断是链表类型,还是红黑树类型。
  • 分别走链表和红黑树不同类型的查找方法。

1.5 扩容

什么时候进行扩容

1.当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能

2.数组长度小于64,且某个桶中的元素大于8

为什么扩容是2的次幂

HashMap的初始容量是2的n次幂,扩容也是2倍的形式进行扩容,是因为容量是2的n次幂,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞,避免形成链表的结构,使得查询效率降低

根据数学规律,对n取模,就是和n-1进行与运算。与运算的效率远远高于求模运算

当数组长度是2的幂次时,hash值对数组长度取模(计算出key值的下标)的效果和hash&(n-1) (hash(key) & (n-1))是一样的

扩容步骤

一个hash值在扩容前后的table下标是这么计算的:

判断条件(e.hash & oldCap) == 0 计算出来的值,如果等于0,就放入低位节点,低位节点在新数组的位置跟原数组一样。 等于1,就放入高位节点,高位放在新数组的位置等于原来位置在加上原数组的长度

// 原索引
if ((e.hash & oldCap) == 0) {
   
    if (loTail == null)
    	loHead = e;
    else
    	loTail.next = e;
    loTail = e;
}
// 原索引 + oldCap
else {
   
    if (hiTail == null)
   	 	hiHead = e;
    else
    	hiTail.next = e;
    hiTail = e;
}

// 将原索引放到哈希桶中
if (loTail != null) {
   
    loTail.next = null;
    newTab[j] = loHead;
}
// 将原索引 + oldCap 放到哈希桶中
if (hiTail != null) {
   
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

其它问题:

JDK1.8中对hash算法优化

JDK1.7中通过多次位移异或计算得到关键字的hash值

hash算法的优化:

//JDK1.8以后的HashMap部分源码
static final int hash(Object key){
   
 int h;
 return (key == null)?0(h=key.hashCode())^(h>>>16);
}

将32位的hash值右移16位,结果是将高16位推到了低16位上,高16位用0来补齐,优化的实质是将高16位与低16位进行异或运算,对于两个hash值的低16位相等,高16位不等的情况,尽量减少了hash冲突 (将高低位的数据特性混合,使hashCode更加离散)

寻址算法的优化:

寻址算法就是对长度为n的数组取模,得到在数组中的位置。根据数学规律,对n取模,就是和n-1进行与运算。与运算的效率远远高于求模运算,所以采用与运算。

当数组长度是2的幂次时,hash值对数组长度取模的效果和hash&(n-1)是一样的

加载因子为什么是 0.75

加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。

这其实是出于容量和性能之间平衡的结果:

  • 当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
  • 而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。

所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。

为什么线程不安全

HashMap 在并发时可能出现的问题主要是两方面:

  • 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞,那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖
  • 如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失

一般用什么作为HashMap的key

一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。

  • 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
  • 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。

用可变类当HashMap的key有什么问题

hashcode可能发生改变,导致put进去的值,无法get出,如下所示

HashMap<List<String>, Object> changeMap = new HashMap<>();
List<String> list = new ArrayList<>();
list.add("hello");
Object objectValue = new Object();
changeMap.put(list, objectValue);
System.out.println(changeMap.get(list));
list.add("hello world");//hashcode发生了改变
System.out.println(changeMap.get(list));
java.lang.Object@74a14482
null

实现一个自定义的class作为HashMap的key该如何实现

记住下面四个原则

  • 两个对象相等,hashcode一定相等
  • 两个对象不等,hashcode不一定不等
  • hashcode相等,两个对象不一定相等
  • hashcode不等,两个对象一定不等

和HashTable的区别

1.Hashtable 是不允许键或值为 null 的,HashMap 的键值则都可以为 null

2.Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类,Dictionary的子类同时也实现了Map接口

3.HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75,当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1,int newCapacity = (oldCapacity << 1) + 1;

5.HashTable是直接使用key的hashCode(key.hashCode())作为hash值,不像HashMap内部使用static final int hash(Object key)扰动函数对key的hashCode进行扰动后作为hash值

6.HashTable取哈希桶下标是直接用模运算%(因为其默认容量也不是2的n次方。所以也无法用位运算替代模运算)

7.Hashtable是线程安全的,方法是Synchronized的

尾插法好处

尾插法主要是为了安全,防止环化,因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上

使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了

死循环造成 CPU 100%

HashMap 有可能会发生死循环并且造成 CPU 100% ,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。

2、Hashtable

2.1 数据结构

Entry数组 (哈希表)

Hashtable是HashMap的线程安全版本,采用Synchronized的方式进行上锁

注意Hashtable和ConcurrentHashMap都不允许插入空键和空值,而Hashmap则允许插入

2.2 初始化

初始容量为11,扩容时容量翻倍 + 1

2.3 key的hash值

直接使用key的hashCode作为hash值

插入节点的下标为取模运算

3、ConcurrentHashMap

3.1 数据结构

  • JDK1.7

    基本结构为Segment数组+HashEntry数组+链表的形式,通过对每个Segment进行上锁来保证高并发。

  • JDK1.8

    Node数组 + 链表 + 红黑树

ConcurrentHashMap和HashMap两者的相同之处:

  1. 数组、链表结构几乎相同,所以底层对数据结构的操作思路是相同的(只是思路相同,底层实现不同)
  2. 都实现了 Map 接口,继承了 AbstractMap 抽象类,所以大多数的方法也都是相同的,HashMap 有的方法,ConcurrentHashMap 几乎都有,所以当我们需要从 HashMap 切换到 ConcurrentHashMap 时,无需关心两者之间的兼容问题

不同之处:

  1. 红黑树结构略有不同,HashMap 的红黑树中的节点叫做 TreeNode,TreeNode 不仅仅有属性,还维护着红黑树的结构,比如说查找,新增等等;ConcurrentHashMap 中红黑树被拆分成两块,TreeNode 仅仅维护的属性和查找功能,新增了 TreeBin,来维护红黑树结构,并负责根节点的加锁和解锁
  2. 新增 ForwardingNode (转移)节点,扩容的时候会使用到,通过使用该节点,来保证扩容时的线程安全

3.2 新增

步骤:

  1. 如果数组为空,初始化,初始化完成之后,走 2;
  2. 计算当前槽点有没有值,没有值的话,cas 创建,失败继续自旋(for 死循环),直到成功,槽点有值的话,走 3;
  3. 如果槽点是转移节点(正在扩容),就会一直自旋等待扩容完成之后再新增,不是转移节点走 4;
  4. 槽点有值的,先锁定当前槽点(synchronized),保证其余线程不能对该槽点操作,如果是链表,新增值到链表的尾部,如果是红黑树,使用红黑树新增的方法新增;
  5. 新增完成之后 check 需不需要扩容,需要的话去扩容。

3.3 扩容

ConcurrentHashMap 扩容的方法叫做 transfer,从 put 方法的 addCount 方法进去,就能找到 transfer 方法

transfer方法的主要思路是:

  1. 首先需要把老数组的值全部拷贝到扩容之后的新数组上,先从数组的队尾开始拷贝;
  2. 拷贝数组的槽点时,先把原数组槽点锁住,保证原数组槽点不能操作,成功拷贝到新数组时,把原数组槽点赋值为转移节点;
  3. 这时如果有新数据正好需要 put 到此槽点时,发现槽点为转移节点,就会一直等待,所以在扩容完成之前,该槽点对应的数据是不会发生变化的;
  4. 从数组的尾部拷贝到头部,每拷贝成功一次,就把原数组中的节点设置成转移节点;
  5. 直到所有数组数据都拷贝到新数组时,直接把新数组整个赋值给数组容器,拷贝完成。

扩容中的关键点,就是如何保证是线程安全的:

  1. 拷贝槽点时,会把原数组的槽点锁住;
  2. 拷贝成功之后,会把原数组的槽点设置成转移节点,这样如果有数据需要 put 到该节点时,发现该槽点是转移节点,会一直等待,直到扩容成功之后,才能继续 put,可以参考 put 方法中的 helpTransfer 方法;
  3. 从尾到头进行拷贝,拷贝成功就把原数组的槽点设置成转移节点。
  4. 等扩容拷贝都完成之后,直接把新数组的值赋值给数组容器,之前等待 put 的数据才能继续 put。

扩容方法还是很有意思的,通过在原数组上设置转移节点,put 时碰到转移节点时会等待扩容成功之后才能 put 的策略,来保证了整个扩容过程中肯定是线程安全的,因为数组的槽点一旦被设置成转移节点,在没有扩容完成之前,是无法进行操作的

3.4 查找

ConcurrentHashMap 读的话,就比较简单,先获取数组的下标,然后通过判断数组下标的 key 是否和我们的 key 相等,相等的话直接返回,如果下标的槽点是链表或红黑树的话,分别调用相应的查找数据的方法,整体思路和 HashMap 很像

3.5 Hash算法

与HashMap类似

通过高低16位进行异或运算,减少碰撞的概率

3.6 JDK1.7和1.8的不同

数据结构

Java 7 采用 Segment 分段锁来实现,而 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树

并发度

Java 7 中,每个 Segment 独立加锁,最大并发个数就是 Segment 的个数,默认是 16。

但是到了 Java 8 中,锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高。

保证并发安全的原理

Java 7 采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock。

Java 8 中放弃了 Segment 的设计,采用 Node + CAS + synchronized保证线程安全。

遇到 Hash 碰撞

Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。

Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率。

查询时间复杂度

Java 7 遍历链表的时间复杂度是 O(n),n 为链表长度。

Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(log(n)),n 为树的节点个数。

3.7 线程安全

1.使用volatile保证当Node中的值变化时对于其他线程是可见的

2.使用table数组的头结点作为synchronized的锁来保证写操作的安全

3.当头结点为null时,使用CAS操作来保证数据能正确的写入

4、TreeMap

4.1 数据结构

红黑树

TreeMap利用红黑树的左节点小,右节点大的性质,根据key进行排序,使每个元素能够插入到红黑树适当的位置,维护了key的大小关系,适用于key需要排序的场景。

因为底层使用的是平衡红黑树的结构,所以 containsKey、get、put、remove 等方法的时间复杂度都是log(n)

5、LinkedHashMap

5.1 数据结构

继承自HashMap,提供了两大特性:

  • 按照插入顺序进行访问
  • 实现了访问最少最先删除功能
// 链表头
transient LinkedHashMap.Entry<K,V> head;
 
// 链表尾
transient LinkedHashMap.Entry<K,V> tail;
 
// 继承 Node,为数组的每个元素增加了 before 和 after 属性
static class Entry<K,V> extends HashMap.Node<K,V> {
   
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
   
        super(hash, key, value, next);
    }
}
 
// 控制两种访问模式的字段,默认 false
// true 按照访问顺序,会把经常访问的 key 放到队尾
// false 按照插入顺序提供访问
final boolean accessOrder;

5.2 新增

LinkedHashMap 初始化时,默认 accessOrder 为 false,就是会按照插入顺序提供访问,插入方法使用的是父类 HashMap 的 put 方法,不过覆写了 put 方法执行中调用的 newNode/newTreeNode 和 afterNodeAccess 方法。

// 新增节点,并追加到链表的尾部
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
   
    // 新增节点
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    // 追加到链表的尾部
    linkNodeLast(p);
    return p;
}
// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
   
    LinkedHashMap.Entry<K,V> last = tail;
    // 新增节点等于位节点
    tail = p;
    // last 为空,说明链表为空,首尾节点相等
    if (last == null)
        head = p;
    // 链表有数据,直接建立新增节点和上个尾节点之间的前后关系即可
    else {
   
        p.before = last;
        last.after = p;
    }
}

LinkedHashMap 通过新增头节点、尾节点,给每个节点增加 before、after 属性,每次新增时,都把节点追加到尾节点等手段,在新增的时候,就已经维护了按照插入顺序的链表结构了

5.3 访问最少删除策略

叫做 LRU(Least recently used,最近最少使用),大概的意思就是经常访问的元素会被追加到队尾,这样不经常访问的数据自然就靠近队头,然后我们可以通过设置删除策略,比如当 Map 元素个数大于多少时,把头节点删除

// 新建 LinkedHashMap
  LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>(4,0.75f,true) {
   
    {
   
      put(10, 10);
      put(9, 9);
      put(20, 20);
      put(1, 1);
    }
 
    @Override
    // 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
   
      return size() > 3;
    }
  };

三、Set集合

1、HashSet

底层实现基于 HashMap

// 把 HashMap 组合进来,key 是 Hashset 的 key,value 是下面的 PRESENT
private transient HashMap<E,Object> map;
// HashMap 中的 value
private static final Object PRESENT = new Object();

2、TreeSet

底层实现基于 TreeMap(红黑树)

3、LinkedHashSet

底层实现基于 LinkedHashMap(双向链表)

按放入顺序有序不重复。

是经常访问的元素会被追加到队尾,这样不经常访问的数据自然就靠近队头,然后我们可以通过设置删除策略,比如当 Map 元素个数大于多少时,把头节点删除

// 新建 LinkedHashMap
  LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>(4,0.75f,true) {
   
    {
   
      put(10, 10);
      put(9, 9);
      put(20, 20);
      put(1, 1);
    }
 
    @Override
    // 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
   
      return size() > 3;
    }
  };

四、快速失败、安全失败机制

1、快速失败机制(fail-fast)

在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出 Concurrent Modification Exception。

1.1 原理

迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

1.2 注意

这里异常的抛出条件是检测到 modCount != expectedmodCount 这个条件。如果集合发生变化时修改 modCount 值刚好又设置为了 expectedmodCount 值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的 bug。

1.3 场景

java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。

2、安全失败机制(fail—safe)

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

2.1 原理

由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 Concurrent Modification Exception。

2.2 缺点

基于拷贝内容的优点是避免了 Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

2.3 场景

java.util.concurrent 包下的容器都是安全失败,可以在多线程下并发使用,并发修改。


转载:https://blog.csdn.net/whc__/article/details/116614602
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场