字节面试杂谈——JAVA集合

 

目录

一、常用容器 Collection 和 Map

二、ArrayList 与 LinkedList 及其区别,ArrayList的扩容机制

ArrayList的成员属性

ArrayList构造方法

ArrayList的add方法

ArrayList的remove方法

ArrayList的其他方法

三、Array与ArrayList的区别

四、HashMap 底层数据结构,put、get、resize、size、多线程死循环

五、HashMap与HashTable

六、HashMap与ConcurrentHashMap

七、HashTable与ConcurrentHashMap

八、ConcurrentHashMap实现原理

九、HashSet实现原理

十、LinkedHashMap、LinkedHashSet

十一、Iterator、ListIterator、Enumeration

十二、fail-fast与fail-safe

十三、Collection与Collections

十四、TreeSet、TreeMap

十五、PriorityQueue

十六、List、Set、Map

十七、HashMap:为什么HashMap头插会形成环、HashMap先Resize还是先ReHash、如果HashMap链表加了锁,能不能线程安全。

1 概述

2 HashMap的数据结构


一、常用容器 Collection 和 Map

        常见容器主要包括 Collection Map 两种, Collection 存储着对象的集合,而  Map 存储着键值对(两个对象)的 映射表

Collection
Set
        1. TreeSet:基于红黑树实现,支持有序性操作,例如:根据⼀个范围查找元素的操作。但是查找效率不如HashSet HashSet 查找的时间复杂度为 O(1) TreeSet 则为 O(logN)
        2. HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用  Iterator 遍历 HashSet 得到的结果是不确定的。
        3. LinkedHashSet:具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。
List
        1. ArrayList:基于动态数组实现,⽀持随机访问。
        2. Vector:和 ArrayList 类似,但它是线程安全的。
        3. LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插⼊和删除元素。不仅如此, LinkedList 还可以用作栈、队列和双向队列。
Queue
        1. LinkedList:可以用它来实现双向队列。
        2. PriorityQueue:基于堆结构实现,可用它来实现优先队列。
Map
        1. TreeMap:基于红黑树实现。
        
        2. HashMap:基于哈希表实现。
        3. HashTable:和 HashMap 类似,但它是线程安全的,这意味着同⼀时刻多个线程可以同时写入 HashTable 并且不会导致数据不⼀致。它是遗留类,不应该去使用它。现在可以使用  ConcurrentHashMap 来支持线程 安全,并且 ConcurrentHashMap 的效率会更高,因为C oncurrentHashMap 引入了分段锁。
        4. LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用( LRU )顺序。

集合框架底层数据结构总结
        先来看⼀下 Collection 接口下面的集合。
1. List
        Arraylist : Object[] 数组
        Vector : Object[] 数组
        LinkedList : 双向链表(JDK1.6 之前为循环链表, JDK1.7 取消了循环 )
2. Set
        HashSet (⽆序,唯⼀): 基于 HashMap 实现的,底层采用HashMap 来保存元素
        LinkedHashSet : LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现⼀样, 不过还是有⼀点点区别的
        TreeSet (有序,唯⼀): 红⿊树 ( ⾃平衡的排序⼆叉树 )
        再来看看 Map 接口下面的集合。
3. Map
        HashMap : JDK1.8 之前 HashMap 由数组+ 链表组成的,数组是 HashMap 的主体,链
表则是主要为了解决哈希冲突而存在的( 拉链法 解决冲突)。 JDK1.8 以后在解决哈希冲突时有了重大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红⿊树前会判断,如 果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红⿊树)时,将链 表转化为红⿊树,以减少搜索时间
        LinkedHashMap : LinkedHashMap 继承自 HashMap ,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外, LinkedHashMap 在上面结构的基础上,增加了⼀条双向链表,使得上面的结构可以保持键值对的插⼊顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
        Hashtable : 数组 + 链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲 突而存在的
        TreeMap : 红⿊树(⾃平衡的排序⼆叉树)
如何选用集合 ?
        主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用Map 接口下的集合,需要排序时选择 TreeMap , 不需要排序时就选择 HashMap ,需要保证线程安全就选用ConcurrentHashMap
        当我们只需要存放元素值时,就选择实现 Collection 接⼝的集合,需要保证元素唯⼀时选择实现 Set 接⼝的集合比如 TreeSet HashSet ,不需要就选择实现 List 接⼝的比如 ArrayList 或LinkedList ,然后再根据实现这些接⼝的集合的特点来选用。

二、ArrayList 与 LinkedList 及其区别,ArrayList的扩容机制

ArrayList Vector 区别呢 ? 为什么要用 Arraylist 取代 Vector 呢?
        ArrayList 是 List 的主要实现类,底层使用  Object[ ] 存储,适用于频繁的查找工作,线程不安全 ;
        Vector 是 List 的古老实现类,底层使用 Object[ ] 存储,线程安全的。
Arraylist LinkedList 区别 ?
        1. 是否保证线程安全: ArrayList LinkedList 都是不同步的,也就是不保证线程安全;
        2. 底层数据结构: Arraylist 底层使⽤的是 Object 数组 LinkedList 底层使⽤的是 双向链
数据结构( JDK1.6 之前为循环链表, JDK1.7 取消了循环。注意双向链表和双向循环链
表的区别,下⾯有介绍到!)
        3. 插入和删除是否受元素位置的影响:
                ArrayList 采用数组存储,所以插入和删除元素的 时间复杂度受元素位置的影响。 比如:执行  add(E e) 放 法的时候, ArrayList 会默认在将 指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1) 。但是如果要在指定位置 i 插⼊和删除元素的话( add(int index, E element) )时间复杂度就为 O(n-i)。因为在进行上述操 作的时候集合中第 i 和第 i 个元素之后的 (n-i) 个元素都要执行向后位 / 向前移⼀位的操作。
                 ② LinkedList 采用链表存储,所以对于 add(E e) 放 法的插入,删除元素时间复杂度不受元素 位置的影响,近似 O(1) ,如果是要在指定位置 i 插入和删除元素的话( (add(int index, E  element) ) 时间复杂度近似为 o(n)) 因为需要先移动到指定位置再插入。
        4. 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象 ( 对应于 get(int index) 方法 )
        5. 内存空间占⽤: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留⼀定的容量空间,而  LinkedList 的空间花费则体现在它的每⼀个元素都需要消耗比  ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
双向链表: 包含两个指针,⼀个 prev 指向前⼀个节点,⼀个 next 指向后⼀个节点。

双向循环链表: 最后⼀个节点的 next 指向 head ,而  head prev 指向最后⼀个节点,构成⼀
个环

RandomAccess 接口
public interface RandomAccess {

}

        查看源码我们发现实际上 RandomAccess 接⼝中什么都没有定义。所以,在我看来RandomAccess 接口不过是⼀个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。

        在 binarySearch()方 法中,它要判断传入的 list 是否 RamdomAccess 的实例,如果是,调用indexedBinarySearch() 方 法,如果不是,那么调用  iteratorBinarySearch() 方
public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key) {
    if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
        return Collections.indexedBinarySearch(list, key);
    else
        return Collections.iteratorBinarySearch(list, key);
}
ArrayList 实现了 RandomAccess 接口, 而  LinkedList 没有实现。为什么呢?我觉得还是和底
层数据结构有关!
        ArrayList 底层是数组,而  LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n) ,所以不支持快速随机访问。, ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandmAccess 接口只是标识,并不是说 ArrayList实现 RandomAccess 接⼝才具有快速随机访问功能的!
        instanceof 严格来说是Java中的一个双目运算符,用来测试一个对象是否为一个类的实例,
ArrayList LinkedList 的区别?
        ArrayList: 底层是基于数组实现的,查找快,增删较慢;支持随机访问,要求内存连续。
        LinkedList: 底层是基于链表实现的。确切的说是循环双向链表( JDK1.6 之前是双向循环链表、 JDK1.7 之后取消 了循环),查找慢、增删快。 LinkedList 链表由⼀系列表项连接而成,⼀个表项包含 3 个部分:元素内容、前驱表和后驱表。链表内部有⼀个 header 表项,既是链表的开始也是链表的结尾。 header 的后继表项是链表中的第⼀ 个元素, header 的前驱表项是链表中的最后⼀个元素。不支持随机访问,存储内存可以不连续。
        若增删元素的位置是在链表头,则LinkedList较快。
ArrayList 的增删未必就是比  LinkedList 要慢:
        1. 如果增删都是在末尾来操作【每次调用的都是 remove() add() 】,此时 ArrayList 就不需要移动和复制数组 来进行操作了。如果数据量有百万级的时,速度是会比  LinkedList 要快的。
        2. 如果删除操作的位置是在中间。由于 LinkedList 的消耗主要是在遍历上, ArrayList 的消耗主要是在移动和复 制上(底层调用的是 arrayCopy() 方 法,是 native 方 法)。 LinkedList 的遍历速度是要慢于 ArrayList 的复制 移动速度的如果数据量有百万级的时,还是 ArrayList 要快。
ArrayList 实现 RandomAccess 接口有何作用?为何 LinkedList 没实现这个接口?
        1. RandomAccess 接口只是⼀个标志接口,只要 List 集合实现这个接口,就能支持快速随机访问。通过查看 Collections 类中的 binarySearch() ⽅法,可以看出,判断 List 是否实现 RandomAccess 接口来执行indexedBinarySerach(list, key) iteratorBinarySerach(list, key)方 法。
        再通过查看这两个方法的源码发现:实现 RandomAccess 接口的 List 集合采⽤⼀般的 for 循环遍历,而未实现这接口则采用迭代器,即 ArrayList ⼀般采用  for 循环遍历,而 LinkedList ⼀般采用迭代器遍历;
        2. ArrayList 用  for 循环遍历比  iterator 迭代器遍历快, LinkedList 用  iterator 迭代器遍历比  for 循环遍历快。 所以说,当我们在做项目时,应该考虑到 List 集合的不同子类采用不同的遍历方式,能够提⾼性能。

ArrayList 的扩容机制?

        (1)ArrayList 是一种变长的集合类,基于定长数组实现,使用默认构造方法初始化出来的容量是10(1.7之后都是延迟初始化,即第一次调用add方法添加元素的时候才将elementData容量初始化为10)。

        (2)ArrayList 允许空值和重复元素,当往 ArrayList 中添加的元素数量大于其底层数组容量时,其会通过扩容机制重新生成一个更大的数组。ArrayList扩容的长度是原长度的1.5倍

        (3)由于 ArrayList 底层基于数组实现,所以其可以保证在 O(1) 复杂度下完成随机查找操作。

        (4)ArrayList 是非线程安全类,并发环境下,多个线程同时操作 ArrayList,会引发不可预知的异常或错误。

        (5)顺序添加很方便

        (6)删除和插入需要复制数组,性能差(可以使用LinkindList)

        (7)Integer.MAX_VALUE - 8 :主要是考虑到不同的JVM,有的JVM会在加入一些数据头,当扩容后的容量大于MAX_ARRAY_SIZE,我们会去比较最小需要容量和MAX_ARRAY_SIZE做比较,如果比它大, 只能取Integer.MAX_VALUE,否则是Integer.MAX_VALUE -8。 这个是从jdk1.7开始才有的

        (1)ArrayList 是一种变长的集合类,基于定长数组实现。

        (2)ArrayList 允许空值和重复元素,当往 ArrayList 中添加的元素数量大于其底层数组容量时,其会通过扩容机制重新生成一个更大的数组。

        (3)由于 ArrayList 底层基于数组实现,所以其可以保证在 O(1) 复杂度下完成随机查找操作。

        (4)ArrayList 是非线程安全类,并发环境下,多个线程同时操作 ArrayList,会引发不可预知的异常或错误。

        1. 当使用  add 方 法的时候首先调用  ensureCapacityInternal 方 法,传⼊ size+1 进去,检查是否需要扩充 elementData 数组的大小;
        2. newCapacity = 扩充数组为原来的 1.5 ( 不能⾃定义 ),如果还不够,就使用它指定要扩充的大小minCapacity ,然后判断 minCapacity 是否⼤于 MAX_ARRAY_SIZE(Integer.MAX_VALUE - 8) ,如果大于,就 取 Integer.MAX_VALUE
        3. 扩容的主要方法: grow
        4. ArrayList 中 copy 数组的核心就是 System.arraycopy 方 法,将 original 数组的所有数据复制到 copy 数组中,这是⼀个本地方法。

ArrayList的成员属性

在介绍关于ArrayList的各种方法之前先看一下基础属性成员。其中                ​​

        DEFAULTCAPACITY_EMPTY_ELEMENTDATA与EMPTY_ELEMENTDATA的区别是:当我们向数组中添加第一个元素时,DEFAULTCAPACITY_EMPTY_ELEMENTDATA将会知道数组该扩充多少

//默认初始化容量
private static final int DEFAULT_CAPACITY = 10;

//默认的空的数组,这个主要是在构造方法初始化一个空数组的时候使用
private static final Object[] EMPTY_ELEMENTDATA = {};

//使用默认size大小的空数组实例,和EMPTY_ELEMENTDATA区分开来,
//这样可以知道当第一个元素添加的时候进行扩容至多少
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

//ArrayList底层存储数据就是通过数组的形式,ArrayList长度就是数组的长度。
//一个空的实例elementData为上面的DEFAULTCAPACITY_EMPTY_ELEMENTDATA,当添加第一个元素的时候
//会进行扩容,扩容大小就是上面的默认容量DEFAULT_CAPACITY
transient Object[] elementData; // non-private to simplify nested class access

//arrayList的大小
private int size;

static修饰的EMPTY_ELEMENTDATADEFAULTCAPACITY_EMPTY_ELEMENTDATA

 

ArrayList构造方法

(1)带有初始化容量的构造方法

  • 参数大于0,elementData初始化为initialCapacity大小的数组
  • 参数等于0,elementData初始化为空数组
  • 参数小于0,抛出异常
//参数为初始化容量
public ArrayList(int initialCapacity) {
    //判断容量的合法性
    if (initialCapacity > 0) {
        //elementData才是实际存放元素的数组
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        //如果传递的长度为0,就是直接使用自己已经定义的成员变量(一个空数组)
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

(2)无参构造

  • 构造方法中将elementData初始化为空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA
  • 当调用add方法添加第一个元素的时候,会进行扩容
  • 扩容至大小为DEFAULT_CAPACITY=10
//无参构造,使用默认的size为10的空数组,在构造方法中没有对数组长度进行设置,会在后续调用add方法的时候进行扩容
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

(3)参数为Collection类型的构造器

//将一个参数为Collection的集合转变为ArrayList(实际上就是将集合中的元素换为了数组的形式)。如果
//传入的集合为null会抛出空指针异常(调用c.toArray()方法的时候)
public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        //c.toArray()可能不会正确地返回一个 Object[]数组,那么使用Arrays.copyOf()方法
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        //如果集合转换为数组之后数组长度为0,就直接使用自己的空成员变量初始化elementData
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

​        上面的这些构造方法理解起来比较简单,关注前两个构造方法做的事情,目的都是初始化底层数组 elementData(this.elementData=XXX)。区别在于无参构造方法会将 elementData 初始化一个空数组,插入元素时,扩容将会按默认值重新初始化数组。而有参的构造方法则会将elementData 初始化为参数值大小(>= 0)的数组。一般情况下,我们用默认的构造方法即可。倘若在可知道将会向 ArrayList 插入多少元素的情况下,可以使用有参构造方法。

​        上面说到了使用无参构造的时候,在调用add方法的时候会进行扩容,所以下面我们就看看add方法以及扩容的细节

ArrayList的add方法

add方法大致流程

//将指定元素添加到list的末尾
public boolean add(E e) {
    //因为要添加元素,所以添加之后可能导致容量不够,所以需要在添加之前进行判断(扩容)
    ensureCapacityInternal(size + 1);  // Increments modCount!!(待会会介绍到fast-fail)
    elementData[size++] = e;
    return true;
}

        我们看到add方法中在添加元素之前,会先判断size的大小,所以我们来看看ensureCapacityInternal方法的细节

ensureCapacityInternal方法分析

private void ensureCapacityInternal(int minCapacity) {
    //这里就是判断elementData数组是不是为空数组
    //(使用的无参构造的时候,elementData=DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
    //如果是,那么比较size+1(第一次调用add的时候size+1=1)和DEFAULT_CAPACITY,
    //那么显然容量为10
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

​         当 要 add 进第1个元素时,minCapacity为(size+1=0+1=)1,在Math.max()方法比较后,minCapacity 为10。然后紧接着调用ensureExplicitCapacity更新modCount的值,并判断是否需要扩容

ensureExplicitCapacity方法分析

private void ensureExplicitCapacity(int minCapacity) {
    modCount++; //这里就是add方法中注释的Increments modCount
    //溢出
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);//这里就是执行扩容的方法
}

​下面来看一下扩容的主要方法grow。

grow方法分析

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
    // oldCapacity为旧数组的容量
    int oldCapacity = elementData.length;
    // newCapacity为新数组的容量(oldCap+oldCap/2:即更新为旧容量的1.5倍)
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 检查新容量的大小是否小于最小需要容量,如果小于那旧将最小容量最为数组的新容量
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    //如果新容量大于MAX_ARRAY_SIZE,使用hugeCapacity比较二者
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    // 将原数组中的元素拷贝
    elementData = Arrays.copyOf(elementData, newCapacity);
}

hugeCapacity方法

这里简单看一下hugeCapacity方法

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    //对minCapacity和MAX_ARRAY_SIZE进行比较
    //若minCapacity大,将Integer.MAX_VALUE作为新数组的大小
    //若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小
    //MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}

add方法执行流程总结

​ 我们用一幅图来简单梳理一下,当使用无参构造的时候,在第一次调用add方法之后的执行流程

 

​ 这是第一次调用add方法的过程,当扩容值capacity为10之后,

  • 继续添加第2个元素(先注意调用ensureCapacityInternal方法传递的参数为size+1=1+1=2)

  • 在ensureCapacityInternal方法中,elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA不成立,所以直接执行ensureExplicitCapacity方法

  • ensureExplicitCapacity方法中minCapacity为刚刚传递的2,所以第二个if判断(2-10=-8)不会成立,即newCapacity 不比 MAX_ARRAY_SIZE大,则不会进入 grow 方法。数组容量为10,add方法中 return true,size增为1。

  • 假设又添加3、4......10个元素(其中过程类似,但是不会执行grow扩容方法)

  • 当add第11个元素时候,会进入grow方法时,计算newCapacity为15,比minCapacity(为10+1=11)大,第一个if判断不成立。新容量没有大于数组最大size,不会进入hugeCapacity方法。数组容量扩为15,add方法中return true,size增为11。

add(int index,E element)方法

//在元素序列 index 位置处插入
public void add(int index, E element) {
    rangeCheckForAdd(index); //校验传递的index参数是不是合法
    // 1. 检测是否需要扩容
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 2. 将 index 及其之后的所有元素都向后移一位
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 3. 将新元素插入至 index 处
    elementData[index] = element;
    size++;
}
private void rangeCheckForAdd(int index) {
    if (index > size || index < 0) //这里判断的index>size(保证数组的连续性),index小于0
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

add(int index, E element)方法(在元素序列指定位置(假设该位置合理)插入)的过程大概是下面这些

  1. 检测数组是否有足够的空间(这里的实现和上面的)
  2. 将 index 及其之后的所有元素向后移一位
  3. 将新元素插入至 index 处.

将新元素插入至序列指定位置,需要先将该位置及其之后的元素都向后移动一位,为新元素腾出位置。这个操作的时间复杂度为O(N),频繁移动元素可能会导致效率问题,特别是集合中元素数量较多时。在日常开发中,若非所需,我们应当尽量避免在大集合中调用第二个插入方法。

ArrayList的remove方法

ArrayList支持两种删除元素的方式

1、remove(int index) 按照下标删除

public E remove(int index) {
    rangeCheck(index); //校验下标是否合法(如果index>size,旧抛出IndexOutOfBoundsException异常)
    modCount++;//修改list结构,就需要更新这个值
    E oldValue = elementData(index); //直接在数组中查找这个值

    int numMoved = size - index - 1;//这里计算所需要移动的数目
    //如果这个值大于0 说明后续有元素需要左移(size=index+1)
    //如果是0说明被移除的对象就是最后一位元素(不需要移动别的元素)
    if (numMoved > 0)
        //索引index只有的所有元素左移一位  覆盖掉index位置上的元素
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    //移动之后,原数组中size位置null
    elementData[--size] = null; // clear to let GC do its work
    //返回旧值
    return oldValue;
}
//src:源数组   
//srcPos:从源数组的srcPos位置处开始移动
//dest:目标数组
//desPos:源数组的srcPos位置处开始移动的元素,这些元素从目标数组的desPos处开始填充
//length:移动源数组的长度
public static native void arraycopy(Object src,  int  srcPos,
                                    Object dest, int destPos,
                                    int length);

​ 删除过程如下图所示

 

2、remove(Object o) 按照元素删除,会删除和参数匹配的第一个元素

public boolean remove(Object o) {
    //如果元素是null 遍历数组移除第一个null
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                //遍历找到第一个null元素的下标 调用下标移除元素的方法
                fastRemove(index);
                return true;
            }
    } else {
        //找到元素对应的下标 调用下标移除元素的方法
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}
//按照下标移除元素(通过数组元素的位置移动来达到删除的效果)
private void fastRemove(int index) {
  modCount++;
  int numMoved = size - index - 1;
  if (numMoved > 0)
    System.arraycopy(elementData, index+1, elementData, index,
                     numMoved);
  elementData[--size] = null; // clear to let GC do its work
}

ArrayList的其他方法

ensureCapacity方法

最好在 add 大量元素之前用 ensureCapacity 方法,以减少增量从新分配的次数

public void ensureCapacity(int minCapacity) {
    int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
        // any size if not default element table
        ? 0
        // larger than default for default empty table. It's already
        // supposed to be at default size.
        : DEFAULT_CAPACITY;

    if (minCapacity > minExpand) {
        ensureExplicitCapacity(minCapacity);
    }
}

为什么扩容因子选为1.5
k=1.5时,就能充分利用前面已经释放的空间。如果k >= 2,新容量刚刚好永远大于过去所有废弃的数组容量。
  • 为什么不取扩容固定容量呢?
    扩容的目的需要综合考虑这两种情况:
  1. 扩容容量不能太小,防止频繁扩容,频繁申请内存空间 + 数组频繁复制
  2. 扩容容量不能太大,需要充分利用空间,避免浪费过多空间;

        而扩容固定容量,很难决定到底取多少值合适,取任何具体值都不太合适,因为所需数据量往往由数组的客户端在具体应用场景决定。依赖于当前已经使用的量 * 系数, 比较符合实际应用场景。
        比如,我现在已经用到一个数组100的容量,接下来很可能会有这个数量级的数据需要插入。

  • 为什么是1.5,而不是1.2,1.25,1.8或者1.75?
    因为1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数。
// 新容量计算
int newCapacity = oldCapacity + (oldCapacity >> 1);

三、Array与ArrayList的区别

Array ArrayList 有何区别?什么时候更适合用  Array
        1. Array 可以容纳基本类型和对象,而  ArrayList 只能容纳对象;
        2. Array 是指定大小的,而  ArrayList 大小 是不固定的。
什么时候更适合使用  Array
        1. 如果列表的大小已经指定,⼤部分情况下是存储和遍历它们;
        2. 对于遍历基本数据类型,尽管 Collections 使用自动装箱来减轻编码任务,在指定大小的基本类型的列表上工作也会变得很慢;
        3. 如果你要使用多维数组,使⽤ [ ][ ] 比  List  更容易

四、HashMap 底层数据结构,put、get、resize、size、多线程死循环

JDK1.7 Entry 数组 + 链表
JDK1.8 Node 数组 + 链表 / 红⿊树,当链表上的元素个数超过 8 个并且数组⻓度 >= 64 时⾃动转化成红⿊树,节点变成树节点,以提高搜索效率和插⼊效率到 O(logN)
Entry Node 都包含 key value hash next 属性。
HashMap put 方 法的执行过程?
        当我们想往⼀个 HashMap 中添加⼀对 key-value 时,系统首先会计算 key hash 值,然后根据 hash 值确认在 table 中存储的位置。若该位置没有元素,则直接插入。否则迭代该处元素链表并依次比较其 key hash 值。如果两个 hash 值相等且 key 值相等 (e.hash == hash && ((k = e.key) == key || key.equals(k))) ,则用新的 Entry 的 value 覆盖原来节点的 value 。如果两个 hash 值相等但 key 值不等 ,则将该节点插⼊该链表的链头。
HashMap get 方 法的执行过程?
        通过 key hash 值找到在 table 数组中的索引处的 Entry ,然后返回该 key 对应的 value 即可。
        在这里能够根据 key 快速的取到 value 除了和 HashMap 的数据结构密不可分外,还和 Entry 有莫⼤的关系。
        HashMap 在存储过程中并没有将 key value 分开来存储,而是当做⼀个整体 key-value 来处理的,这个整体就是 Entry 对象。同时 value 也只相当于 key 的附属而已。在存储的过程中,系统根据 key HashCode 来决定 Entry 在 table 数组中的存储位置,在取的过程中同样根据 key HashCode 取出相对应的 Entry 对象( value 就包含在 里面)。
HashMap resize 方 法的执行过程?
有两种情况会调用  resize 方 法:
        1. 第⼀次调用  HashMap put 方 法时,会调用  resize 方 法对 table 数组进行初始化,如果不传⼊指定值,默认大小为 16
        2. 扩容时会调⽤ resize ,即 size > threshold 时, table 数组大小翻倍。
        每次扩容之后容量都是翻倍。扩容后要将原数组中的所有元素找到在新数组中合适的位置。当我们把 table[i] 位置的所有 Node 迁移到 newtab 中去的时候:这里面的 node 要么在 newtab 的  i 位置(不变),要么在 newtab i + n 位置。也就是我们可以这样处理:把 table[i] 这个桶中的 node 拆分为两个链表 l1 和 l2 :如果 hash & n == 0 ,那么当前这个 node 被连接到 l1 链表;否则连接到 l2 链表。这样下来,当遍历完 table[i] 处的所有 node 的时候,我们得到两个链表 l1 和  l2 ,这时我们令 newtab[i] = l1 newtab[i + n] = l2,这 就完成了 table[i] 位置所有 node 的迁移( rehash ),这也是 HashMap 中容量⼀定的是 2 的整数次幂带来的方便 之处。
HashMap size 为什么必须是 2 的整数次方?
        1. 这样做总是能够保证 HashMap 的底层数组长度为 2 n 次方。当 length 2 n 次方时,h & (length - 1) 就相当于对 length 取模,而且速度比直接取模快得多,这是 HashMap 在速度上的⼀个优化。而且每次扩容 时都是翻倍。
        2. 如果 length 2 的次幂,则 length - 1 转化为⼆进制必定是 11111…… 的形式,在与 h 的⼆进制进行与操作 时效率会非常的快,而且空间不浪费。但是,如果 length 不是 2 的次幂,比如: length 15 ,则 length - 1 为 14 ,对应的⼆进制为 1110 ,在于 h 与操作,最后⼀位都为 0 ,而  0001 0011 0101 1001 1011 , 0111, 1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位 置比数组长度小了很多,这意味着进⼀步增加了碰撞的几率,减慢了查询的效率,这样就会造成空间的浪费。
        3.扩容时方便:扩容后要将原数组中的所有元素找到在新数组中合适的位置。当我们把 table[i] 位置的所有 Node 迁移到 newtab 中去的时候:这里面的 node 要么在 newtab 的  i 位置(不变),要么在 newtab i + n 位置。
多线程操作HashMap,头插操作造成的死循环
        主要是多线程同时 put 时,如果同时触发了 rehash 操作,会导致 HashMap 中的链表中出现循环节点,进而使得 后面  get 的时候,会死循环。

正常的ReHash的过程

画了个图做了个演示。

  • 我假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
  • 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
  • 接下来的三个步骤是Hash表 resize成4,然后所有的<key,value> 重新rehash的过程

并发下的Rehash

1)假设我们有两个线程。我用红色和浅蓝色标注了一下。

我们再回头看一下我们的 transfer代码中的这个细节:

这个哈希链表没有头节点!!


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);

假设线程一执行到这里就被调度挂起了,而我们的线程二执行完成了。于是我们有下面的这个样子。

        注意,因为Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。

2)线程一被调度回来执行。

  • 先是执行 newTalbe[i] = e;
  • 然后是e = next,导致了e指向了key(7),
  • 而下一次循环的next = e.next导致了next指向了key(3)

3)一切安好。

线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移

4)环形链接出现。

e.next = newTable[i] 导致  key(3).next 指向了 key(7)

注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

于是,当我们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop。

        主要原因在于 并发下的Rehash 会造成元素之间会形成⼀个循环链表。不过, jdk 1.8 后解决了这 个问题,jdk1.8用尾插法,使得不会形成循环链表,但是在链表和红黑树互转的过程中可能会出现环,但是还是不建议在多线程下使用  HashMap, 因为多线程下使用  HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用  ConcurrentHashMap
HashMap get 方 法能否判断某个元素是否在 map 中?
        HashMap 的 get 函数的返回值不能判断⼀个 key 是否包含在 map 中,因为 get 返回 null 有可能是不包含该 key ,也有可能该 key 对应的 value null 。因为 HashMap 中允许 key null ,也允许 value null
        

五、HashMap与HashTable

HashMap HashTable 的区别是什么?
        1. HashTable 基于 Dictionary 类,而  HashMap 是基于 AbstractMap Dictionary 是任何可将键映射到相应值 的类的抽象父类,而  AbstractMap 是基于 Map 接⼝的实现,它以最大限度地减少实现此接⼝所需的⼯作。
        2. HashMap 的 key value 都允许为 null ,而  Hashtable key value 都不允许为 null HashMap 遇到 key null 的时候,调用  putForNullKey 方 法进⾏处理,而对 value 没有处理; Hashtable 遇到 null,直接 返回 NullPointerException
        3. Hashtable 是线程安全的,而  HashMap 不是线程安全的,但是我们也可以通过
Collections.synchronizedMap(hashMap) ,使其实现同步。
HashTable 的补充:
        HashTable 和 HashMap 的实现原理几乎⼀样,差别无非是
                1. HashTable 不允许 key value null
                2. HashTable 是线程安全的。但是 HashTable 线程安全的策略实现代价却太大了,简单粗暴, get/put 所有相 关操作都是 synchronized 的,这相当于给整个哈希表加了⼀把大锁,多线程访问时候,只要有⼀个线程访问 或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常 差
HashMap Hashtable 的区别
        1. 线程是否安全: HashMap 是非线程安全的, HashTable 是线程安全的 ,因为 HashTable
内 部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap 吧!);
        2. 效率: 因为线程安全的问题, HashMap 要比  HashTable 效率高一点。另外, HashTable基本被淘汰,不要在代码中使用它;
        3. 对 Null key Null value 的⽀持: HashMap 可以存储 null key value ,但 null 作为
键只能有⼀个, null 作为值可以有多个; HashTable 不允许有 null 键和 null 值,否则会抛出
NullPointerException
        4. 初始容量大小和每次扩充容量大小的不同 :
                ① 创建时如果不指定容量初始值, Hashtable 默认的初始大小为 11 ,之后每次扩充,容量变为原来的 2n+1 HashMap 默认的初始化大小为 16 。之后每次扩充,容量变为原来的 2 倍。
                ② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小( HashMap 中的 tableSizeFor() 方 法保证,下面给出了源代码)。也就是说 HashMap 总 是使用  2 的幂作为哈希表的大小 , 后⾯会介绍到为什么是 2 的幂次⽅。
        5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8 )(将链表转换成红⿊树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红⿊树)时,将链表转化为红⿊树,以减少搜索时间。H ashtable 没有这样的机制
HashMap 中带有初始容量的构造函数:
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
 
    if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " +loadFactor);

    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
下⾯这个⽅法保证了 HashMap 总是使⽤ 2 的幂作为哈希表的大小。
static final int tableSizeFor(int cap) {
 int n = cap - 1;
 n |= n >>> 1;
 n |= n >>> 2;
 n |= n >>> 4;
 n |= n >>> 8;
 n |= n >>> 16;
 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
 }

        取模运算中如果除数是2的幂次方则等价于 其与除数减一的&操作,就是:hash % length == hash & (length - 1)
        采用二进制位操作 & 相对于 % 能够提高运算效率,这也就解释了为啥HashMap的长度需要为2的幂次方

HashMap的扩容因子是0.75:提高空间利用率和 减少查询成本的折中,主要是泊松分布,0.75的话碰撞最小,
        数组的扩容门槛threshold = capacity * loadFactorloadFactor。

        

        加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;

        加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数

        在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数,所以,一般在使用HashMap时建议根据预估值设置初始容量,减少扩容操作。

        选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择, 

        在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。

        从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

  • 0: 0.60653066
  • 1: 0.30326533
  • 2: 0.07581633
  • 3: 0.01263606
  • 4: 0.00157952
  • 5: 0.00015795
  • 6: 0.00001316
  • 7: 0.00000094
  • 8: 0.00000006
  • more: less than 1 in ten million

泊松分布就是描述某段时间内,事件具体的发生概率。

        泊松分布的公式。等号的左边,P 表示概率,N表示某种函数关系,t 表示时间,n 表示数量 。等号的右边,λ 表示事件的频率。

六、HashMap与ConcurrentHashMap

(1)JDK1.8 之前
        JDK1.8 之前 HashMap 底层是 数组和链表 结合在⼀起使用也就是 链表散列 HashMap 通过 key hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素 存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存 ⼊的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲 突。
        所谓扰动函数指的就是 HashMap hash 方 法。使用  hash 方 法也就是扰动函数是为了防止⼀ 些实现比较差的 hashCode() 方 法 换句话说使用扰动函数之后可以减少碰撞。
JDK 1.8 HashMap hash 方 法源码 :
JDK 1.8 hash 方 法 相比于 JDK 1.7 hash 方 法更加简化,但是原理不变
 static final int hash(Object key) {
 int h;
 // key.hashCode():返回散列值也就是hashcode
 // ^ :按位异或
 // >>>:⽆符号右移,忽略符号位,空位都以0补⻬
 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }
对比⼀下 JDK1.7 HashMap hash 方 法源码 .
static int hash(int h) {
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
        相比于 JDK1.8 hash 方 法 , JDK 1.7 hash 方 法的性能会稍差⼀点点,因为毕竟扰动了 4 次。
        所谓 拉链法 就是:将链表和数组相结合。也就是说创建⼀个链表数组,数组中每⼀格就是⼀ 个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
(2) JDK1.8 之后
        相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默 认为 8 )(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红⿊树)时,将链表转化为红⿊树,以减少搜索时间。
        TreeMap、 TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红⿊树。红⿊树就是为了解决⼆叉查找树的缺陷,因为⼆叉查找树在某些情况下会退化成⼀个线性结构。
(3)HashMap 的长度为什么是 2 的幂次方
        为了能让 HashMap 存取高效,尽量减少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了, Hash 值的范围值 -2147483648 2147483647 ,前后加起来⼤概 40亿的映射空间,只要 哈希函数映射得比较均匀松散,⼀般应用是很难出现碰撞的。但问题是⼀个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算⽅法是 (n - 1) & hash ” 。( n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。
        我们⾸先可能会想到采⽤% 取余的操作来实现。但是,重点来了: 取余 (%) 操作中如果除数是 2 的幂次则等价于与其除数减⼀的与 (&) 操作(也就是说 hash%length==hash&(length-1) 的前提 length 2 n 次方;)。 并且 采⽤⼆进制位操作 & ,相对于 % 能够提高运算效率,这就解 释了 HashMap 的长度为什么是 2 的幂次方
(4)HashMap 不是线程安全的,⽽ ConcurrentHashMap 是线程安全的。
        ConcurrentHashMap 采⽤锁分段技术,将整个 Hash 桶进行了分段 segment,也就是将这个大的数组分成了几个小的片段 segment ,而且每个小的片段 segment 上面都有锁存在,那么在插入元素的时候就需要先找到应该插入到哪⼀个片段 segment ,然后再在这个片段上面进行插⼊,而且这里还需要获取 segment 锁,这样做明显减小了锁的粒度

七、HashTable与ConcurrentHashMap

        HashTable 和 ConcurrentHashMap 相比,效率低。 Hashtable 之所以效率低主要是使用了 synchronized 关键 字对 put 等操作进行加锁,而  synchronized 关键字加锁是对整张 Hash 表的,即每次锁住整张表让线程独占,致使效率低下,而  ConcurrentHashMap 在对象中保存了⼀个 Segment 数组,即将整个 Hash 表划分为多个分段; 而每个 Segment 元素,即每个分段则类似于⼀个 Hashtable ;这样,在执行  put 操作时首先根据 hash 算法定位到 元素属于哪个 Segment ,然后对该 Segment 加锁即可,因此, ConcurrentHashMap 在多线程并发编程中可是实现多线程 put 操作。
ConcurrentHashMap Hashtable 的区别
        ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
        底层数据结构: JDK1.7 ConcurrentHashMap 底层采用  分段的数组 + 链表 实现, JDK1.8
采用的数据结构跟 HashMap1.8 的结构⼀样,数组 + 链表 / 红⿊⼆叉树。 Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用  数组 + 链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
        实现线程安全的方式(重要):
                在 JDK1.7 的时候, ConcurrentHashMap (分段锁) 对整个桶数组进行了分割分段( Segment ),每⼀把锁只锁容器其中⼀部分数据,多线程访问 容器里不同数据段的数据,就不会存在锁竞争,提⾼并发访问率。 到了 JDK1.8 的时候已经 摒弃了 Segment 的概念,而是直接用  Node 数组 + 链表 + 红黑树的数据结构来实现,并发 控制使用  synchronized CAS 来操作。( JDK1.6 以后 对 synchronized 锁做了很多优 化) 整个看起来就像是优化过且线程安全的 HashMap ,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
                ② Hashtable ( 同⼀把 ) : 使用  synchronized 来保证线程安全,效率非常低下。当⼀个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使⽤ put 添加元素,另⼀个线程不能使用  put 添加元素,也不能使用  get ,竞争会越来越激烈效率越低。

 JDK1.7 ConcurrentHashMap

 JDK1.8 ConcurrentHashMap

        JDK1.8 的 ConcurrentHashMap 不在是 Segment 数组 + HashEntry 数组 + 链表 ,而是 Node + 链表 / 红⿊树 。不过, Node 只能⽤于链表的情况,红⿊树的情况需要使用 TreeNode 。当冲突链表达到⼀定长度时,链表会转换成红⿊树

八、ConcurrentHashMap实现原理

数据结构
        JDK 7:中 ConcurrentHashMap 采用了数组 + Segment + 分段锁的方式实现。
        JDK 8:中 ConcurrentHashMap 参考了 JDK 8 HashMap 的实现,采用了数组 + 链表 + 红黑树的实现方式来设计,内部大量采用  CAS 操作。
ConcurrentHashMap 采用了非常精妙的 " 分段锁 " 策略, ConcurrentHashMap 的主干是个 Segment 数组。
final Segment<K,V>[] segments;
        Segment 继承了 ReentrantLock, 所以它就是⼀种可重入锁(ReentrantLock) 。在 ConcurrentHashMap,⼀个Segment 就是⼀个⼦哈希表, Segment 里 维护了⼀个 HashEntry 数组,并发环境下,对于不同 Segment 的数据 进行操作是不用考虑锁竞争的。就按默认的 ConcurrentLevel 16 来讲,理论上就允许 16 个线程并发执行。
        所以,对于同⼀个 Segment 的操作才需考虑线程同步,不同的 Segment 则无需考虑。S egment 类似于 HashMap ,⼀个 Segment 维护着⼀个 HashEntry 数组:
transient volatile HashEntry<K,V>[] table;

JDK1.7 (上面有示意图)
        首先将数据分为⼀段⼀段的存储,然后给每⼀段数据配⼀把锁,当⼀个线程占⽤锁访问其中⼀个 段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成
Segment 实现了 ReentrantLock , 所以 Segment 是⼀种可重入锁,扮演锁的角色。 HashEntry 用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
        ⼀个 ConcurrentHashMap 里 包含⼀个 Segment 数组。 Segment 的结构和 HashMap 类似,是 ⼀种数组和链表结构,⼀个 Segment 包含⼀个 HashEntry 数组,每个 HashEntry 是⼀个链表
结构的元素,每个 Segment 守护着⼀个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。

JDK1.8
        ConcurrentHashMap 取消了 Segment 分段锁,采用CAS 和 synchronized 来保证并发安全。数 据结构跟 HashMap1.8 的结构类似,数组 + 链表 / 红⿊⼆叉树。 Java 8 在链表长度超过⼀定阈值(8 )时将链表(寻址时间复杂度为 O(N) )转换为红⿊树(寻址时间复杂度为 O(log(N))) synchronized 只锁定当前链表或红⿊⼆叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

九、HashSet实现原理

        HashSet 的实现是依赖于 HashMap 的, HashSet 的值都是存储在 HashMap 中的。在 HashSet 的构造法中会初 始化⼀个 HashMap 对象, HashSet 不允许值重复。因此, HashSet 的值是作为 HashMap key 存储在 HashMap 中的,当存储的值已经存在时返回 false
HashSet 怎么保证元素不重复的?
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

        元素值作为的是 map keymap value 则是 PRESENT 变量,这个变量只作为放⼊map 时的⼀个占位符而存在,所以没什么实际用处。其实,这时候答案已经出来了:HashMap key是不能重复的,而这⾥HashSet 的元素⼜是作为了 map key,当然也不能重复了。

HashMap HashSet 区别
如果你看过 HashSet 源码的话就应该知道: HashSet 底层就是基于 HashMap 实现的。
HashSet 的源码非常非常少,因为除了 clone() 、 writeObject() 、 readObject() HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。)
HashMapHashSet
实现了 Map 接⼝实现 Set 接⼝
存储键值对仅存储对象
调用 put() map 中添加元素调用 add() 方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcode
HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以 equals()
方法用来判断对象的相等性

HashSet 如何检查重复
        当你把对象加入 HashSet 时, HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他加⼊的对象的 hashcode 值作比较,如果没有相符的 hashcode , HashSet会假设 对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals() 方法来检查 hashcode 相等的对象是否真的相同。如果两者相同, HashSet 就不会让加⼊操作成功。
实际上HashSet内部是用HashMap实现的,HashMap键值不允许重复,检查重复也是HashMap完成的。
hashCode() equals() 的相关规定:
        1. 如果两个对象相等,则 hashcode ⼀定也是相同的
        2. 两个对象相等 , 对两个 equals() 方 法返回 true
        3. 两个对象有相同的 hashcode 值,它们也不⼀定是相等的
        4. 综上, equals() 方 法被覆盖过,则 hashCode() 方 法也必须被覆盖
        5. hashCode() 的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode() ,则该class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。
== equals 的区别
        对于基本类型来说,== 比较 的是值是否相等;
        对于引用类型来说,== 比较的是两个引用是否指向同⼀个对象地址(两者在内存中存放的地址 (堆内存地址)是否指向同⼀个地⽅);
        对于引用类型(包括包装类型)来说,equals 如果没有被重写,对比它们的地址是否相等;如果 equals()方 法被重写(例如 String ),则比较的是地址里的内容。

十、LinkedHashMap、LinkedHashSet

LinkedHashMap 的实现原理 ?
        LinkedHashMap 也是基于 HashMap 实现的,不同的是它定义了⼀个 Entry header ,这个 header 不是放在 Table 里 ,它是额外独立出来的。 LinkedHashMap 通过继承 hashMap 中的 Entry ,并添加两个属性 Entry before after header 结合起来组成⼀个双向链表,来实现按插入顺序或访问顺序排序。
        LinkedHashMap 定义了排序模式 accessOrder ,该属性为 boolean 型变量,对于访问顺序,为 true;对于插入顺序,则为 false 。⼀般情况下,不必指定排序模式,其迭代顺序即为默认为插⼊顺序。
比较 HashSet LinkedHashSet TreeSet 三者的异同
        HashSet 是 Set 接口的主要实现类 , HashSet 的底层是 HashMap ,线程不安全的,可以存
null 值;
        LinkedHashSet 是 HashSet 的子类,能够按照添加的顺序遍历;
        TreeSet 底层使用红⿊树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序。

十一、Iterator、ListIterator、Enumeration

Iterator 怎么使用?有什么特点?
        迭代器是⼀种设计模式,它是⼀个对象,它可以遍历并选择序列中的对象,而开发⼈员不需要了解该序列的底层结构。迭代器通常被称为 轻量级 对象,因为创建它的代价小。 Java 中的 Iterator 功能比较简单,并且只能单向移 动:  
        1. 使用方法 iterator() 要求容器返回⼀个 Iterator 。第⼀次调用  Iterator next() 方法时,它返回序列的第⼀个元素。注意: iterator() 方 法是 java.lang.Iterable 接⼝,被 Collection 继承。  
        2. 使用  next() 获得序列中的下⼀个元素。 
        
        3. 使用 hasNext() 检查序列中是否还有元素。  
        4. 使用  remove() 将迭代器新返回的元素删除。 
Iterator ListIterator 有什么区别?
        Iterator 可用来遍历 Set List 集合,但是 ListIterator 只能⽤来遍历 List Iterator 对集合只能是前向遍历, ListIterator 既可以前向也可以后向。 ListIterator 实现了 Iterator 接⼝,并包含其他的功能,比如:增加元素,替换元素,获取前⼀个和后⼀个元素的索引等等。
Iterator Enumeration 接口的区别?
        与 Enumeration 相比, Iterator 更加安全,因为当⼀个集合正在被遍历的时候,它会阻止其它线程去修改集合。
        否则会抛出 ConcurrentModificationException 异常。这其实就是 fail-fast 机制。具体区别有三点:
        1. Iterator 的方法名比  Enumeration 更科学;
        2. Iterator 有 fail-fast 机制,比  Enumeration 更安全;
        3. Iterator 能够删除元素, Enumeration 并不能删除元素。

十二、fail-fast与fail-safe

        Iterator 的 fail-fast 属性与当前的集合共同起作⽤,因此它不会受到集合中任何改动的影响。 Java.util 包中的所有集合类都被设计为 fail-fast 的,而  java.util.concurrent 中的集合类都为 fail-safe 的。当检测到正在遍历的集合的结构被改变时, fail-fast 迭代器抛出C oncurrentModificationException ,而  fail-safe 迭代器从不抛出ConcurrentModificationException

fail-fast和fail-safe的区别: 
        fail-safe允许在遍历的过程中对容器中的数据进行修改,而fail-fast则不允许。

fail-fast ( 快速失败 )
        fail-fast:直接在容器上进行遍历,在遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出ConcurrentModificationException异常导致遍历失败。java.util包下的集合类都是快速失败机制的, 常见的的使用fail-fast方式遍历的容器有HashMap和ArrayList等。

        在使用迭代器遍历一个集合对象时,比如增强for,如果遍历过程中对集合对象的内容进行了修改(增删改),会抛出ConcurrentModificationException 异常.

fail-safe ( 安全失败 )
        fail-safe:这种遍历基于容器的一个克隆。因此,对容器内容的修改不影响遍历。java.util.concurrent包下的容器都是安全失败的,可以在多线程下并发使用,并发修改。常见的的使用fail-safe方式遍历的容器有ConcerrentHashMap和CopyOnWriteArrayList等。

        原理:采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。

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

十三、Collection与Collections

        Collection:是最基本的集合接口,⼀个 Collection 代表⼀组 Object ,即 Collection 的元素。它的直接继承接口有 List Set Queue
        Collections:是不属于 Java 的集合框架的,它是集合类的⼀个⼯具类 / 帮助类。此类不能被实例化, 服务于 Java 的 Collection 框架。它包含有关集合操作的静态多态方法,实现对各种集合的搜索、排序、线程安全等操作

十四、TreeSet、TreeMap

        在Map集合框架中,除了HashMap以外,TreeMap也是常用到的集合对象之一。
        与HashMap相比,TreeMap是一个能比较元素大小的Map集合,会对传入的key进行了大小排序。其中,可以使用元素的自然顺序,也可以使用集合中自定义的比较器来进行排序;
        不同于HashMap的哈希映射,TreeMap实现了红黑树的结构,形成了一颗二叉树。

TreeMap继承于AbstractMap,实现了Map, Cloneable, NavigableMap, Serializable接口。
        (1)TreeMap 继承于AbstractMap,而AbstractMap实现了Map接口,并实现了Map接口中定义的方法,减少了其子类继承的复杂度;
        (2)TreeMap 实现了Map接口,成为Map框架中的一员,可以包含着key-value形式的元素;
        (3)TreeMap 实现了NavigableMap接口,意味着拥有了更强的元素搜索能力;
        (4)TreeMap 实现了Cloneable接口,实现了clone()方法,可以被克隆;
        (5)TreeMap 实现了Java.io.Serializable接口,支持序列化操作;

TreeMap具有如下特点:
        不允许出现重复的key;
        可以插入null键,null值;
        可以对元素进行排序;
        不记录插入顺序

TreeSet 内部用TreeMap

十五、PriorityQueue

        优先队列PriorityQueue是Queue接口的实现,可以对其中元素进行排序,

        可以放基本数据类型的包装类(如:Integer,Long等)或自定义的类

        对于基本数据类型的包装器类,优先队列中元素默认排列顺序是升序排列

        但对于自己定义的类来说,需要自己定义比较器

peek()//返回队首元素
poll()//返回队首元素,队首元素出队列
add()//添加元素
size()//返回队列元素个数
isEmpty()//判断队列是否为空,为空返回true,不空返回false

十六、List、Set、Map

        List ( 对付顺序的好帮⼿ ) : 存储的元素是有序的、可重复的。
        Set ( 注重独⼀⽆⼆的性质 ): 存储的元素是无序的、不可重复的。
        Map (用  Key 来搜索的专家 ): 使用键值对( kye-value )存储,类似于数学上的函数
y=f(x) “x” 代表 key "y" 代表 value Key 是无序的、不可重复的, value 是无序的、可重复
的,每个键最多映射到⼀个值。
以上的有序无序指的是插入顺序。

十七、HashMap:为什么HashMap头插会形成环、HashMap先Resize还是先ReHash、如果HashMap链表加了锁,能不能线程安全。

1 概述

        HashMap是基于哈希表实现的,每一个元素是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长.

        HashMap是非线程安全的,只适用于单线程环境,多线程环境可以采用并发包下的concurrentHashMap

        HashMap 实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆

        HashMap是基于哈希表的Map接口的非同步实现.此实现提供所有可选的映射操作,并允许使用null值和null键.此类不保证映射的顺序,特别是它不保证该顺序恒久不变.

        Java8中又对此类底层实现进行了优化,比如引入了红黑树的结构以解决哈希碰撞

2 HashMap的数据结构

        在Java中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造,HashMap也不例外. HashMap实际上是一个"链表散列"的数据结构,即数组和链表的结合体.799ad398242fa51473f09c243a747e6f.png

        HashMap的主结构类似于一个数组,添加值时通过key确定储存位置.
        每个位置是一个Entry的数据结构,该结构可组成链表.
        当发生冲突时,相同hash值的键值对会组成链表.
        这种数组+链表的组合形式大部分情况下都能有不错的性能效果,Java6、7就是这样设计的. 然而,在极端情况下,一组(比如经过精心设计的)键值对都发生了冲突,这时的哈希结构就会退化成一个链表,使HashMap性能急剧下降.

所以在Java8中,HashMap的结构实现变为数组+链表+红黑树74b899b7149b39f4fa2f64b8a7c1f7de.png

        可以看出,HashMap底层就是一个数组结构
        数组中的每一项又是一个链表
        当新建一个HashMap时,就会初始化一个数组.

        因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。

        链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短

        还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换
        假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

(1)为什么cap要保持为2的幂次方?

取余(%)操作 : 如果除数是2的幂次则等价于与其除数减一的与(&)操作.

采用二进制位操作&,相对于%,能够提高运算效率,这就是cap的值被要求为2幂次的原因

(2)加载因子:

20c0f5946f844c83ab20960c46fedeeb.pnge9c1725192a95a39f32116c26921b987.png

(3)Put方法

8e1b8f73837eb11c473ca51522cb6e0c.png6839cec8b8f6d0686b058e93a50b3dcf.pnge8a6a1b0ed9dba97c344d42565b1b9e3.png

        ①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容

        ②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③

        ③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals

        ④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤

        ⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可

        ⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,执行resize()扩容

(4)resize

1c27f3626aab56a9a4d78982537e66f0.png
        扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,内部的数组无法装载更多的元素时,就需要扩大数组的长度.
        当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组

b1397f700a626fd9777cf07c2bd0fe95.png

(5)remove方法

remove(key) 方法 和 remove(key, value) 方法都是通过调用removeNode的方法来实现删除元素的

(6)get

        在JDK1.7及以前的版本中,HashMap里是没有红黑树的实现的,在JDK1.8中加入了红黑树是为了防止哈希表碰撞攻击,当链表链长度为8时,及时转成红黑树,提高map的效率

        如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。它是如何工作的?前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最好。如果没有实现这个接口,在出现严重的哈希碰撞的时候,你就并别指望能获得性能提升了。

        这个性能提升有什么用处?比方说恶意的程序,如果它知道我们用的是哈希算法,它可能会发送大量的请求,导致产生严重的哈希碰撞。然后不停的访问这些key就能显著的影响服务器的性能,这样就形成了一次拒绝服务攻击(DoS)。JDK 8中从O(n)到O(logn)的飞跃,可以有效地防止类似的攻击,同时也让HashMap性能的可预测性稍微增强了一些

        

(7)头插形成链表循环

        从上面可看出:在扩容resize()过程中,在将旧数组上的数据 转移到 新数组上时,转移数据操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况

        设重新计算存储位置后不变,即扩容前 = 1->2->3,扩容后 = 3->2->1

        此时若并发执行 put 操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即死锁

(8)单线程rehash

单线程情况下,rehash无问题f4f08be64709c30d34fc784a9d230bb2.png

(9)多线程并发下的rehash

        这里假设有两个线程同时执行了put操作并引发了rehash,执行了transfer方法,并假设线程一进入transfer方法并执行完next = e.next后,因为线程调度所分配时间片用完而“暂停”,此时线程二完成了transfer方法的执行。此时状态如下。

接着线程1被唤醒,继续执行第一轮循环的剩余部分

e.next = newTable[1] = null
newTable[1] = e = key(5)
e = next = key(9)

结果如下图所示04792ba0aa75ee703874b0a3ea887219.png

接着执行下一轮循环,结果状态图如下所示ab4e4d72cc284dc63ce1f392ca72fab4.png

继续下一轮循环,结果状态图如下所示21c1b69e47b95e7ebe2917d72ba5e93a.png

        此时循环链表形成,并且key(11)无法加入到线程1的新数组。在下一次访问该链表时会出现死循环。

(10)Fast-fail

        产生原因

        在使用迭代器的过程中如果HashMap被修改,那么ConcurrentModificationException将被抛出,也即Fast-fail策略。

        当HashMap的iterator()方法被调用时,会构造并返回一个新的EntryIterator对象,并将EntryIterator的expectedModCount设置为HashMap的modCount(该变量记录了HashMap被修改的次数)。

        在通过该Iterator的next方法访问下一个Entry时,它会先检查自己的expectedModCount与HashMap的modCount是否相等,如果不相等,说明HashMap被修改,直接抛出ConcurrentModificationException。该Iterator的remove方法也会做类似的检查。该异常的抛出意在提醒用户及早意识到线程安全问题。

(11)线程安全解决方案

        单线程条件下,为避免出现ConcurrentModificationException,需要保证只通过HashMap本身或者只通过Iterator去修改数据,不能在Iterator使用结束之前使用HashMap本身的方法修改数据。因为通过Iterator删除数据时,HashMap的modCount和Iterator的expectedModCount都会自增,不影响二者的相等性。如果是增加数据,只能通过HashMap本身的方法完成,此时如果要继续遍历数据,需要重新调用iterator()方法从而重新构造出一个新的Iterator,使得新Iterator的expectedModCount与更新后的HashMap的modCount相等。

多线程条件下,可使用Collections.synchronizedMap方法构造出一个同步Map,或者直接使用线程安全的ConcurrentHashMap

(11)HashMap数据结构 

        如上图为JDK1.8版本的数据结构,其实HashMap在JDK1.7及以前是一个“链表散列”的数据结构,即数组 + 链表的结合体。JDK8优化为:数组+链表+红黑树。

        我们常把数组中的每一个节点称为一个桶。当向桶中添加一个键值对时,首先计算键值对中key的hash值(hash(key)),以此确定插入数组中的位置(即哪个桶),但是可能存在同一hash值的元素已经被放在数组同一位置了,这种现象称为碰撞,这时按照尾插法(jdk1.7及以前为头插法)的方式添加key-value到同一hash值的元素的最后面,链表就这样形成了。

        当链表长度超过8(TREEIFY_THRESHOLD - 阈值)时,链表就自行转为红黑树。

注意:同一hash值的元素指的是key内容一样么?不是。根据hash算法的计算方式,是将key值转为一个32位的int值(近似取值),key值不同但key值相近的很可能hash值相同,如key=“a”和key=“aa”等

        通过上述回答的内容,我们明显给了面试官往深入问的多个诱饵,根据我们的回答,下一步他多可能会追问这些问题:

1、如何实现HashMap的有序?

4、put方法原理是怎么实现的?

6、扩容机制原理 → 初始容量、加载因子 → 扩容后的rehash(元素迁移)

2、插入后的数据顺序会变的原因是什么?

3、HashMap在JDK1.7-JDK1.8都做了哪些优化?

5、链表红黑树如何互相转换?阈值多少?

7、头插法改成尾插法为了解决什么问题?

(12)追问1:如何实现HashMap的有序?

使用LinkedHashMap 或 TreeMap。

        LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。

(13)追问2:那TreeMap怎么实现有序的?

TreeMap是按照Key的自然顺序或者Comprator的顺序进行排序,内部是通过红黑树来实现。

  • TreeMap实现了SortedMap接口,它是一个key有序的Map类。

  • 要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用于key的比较。

(14)追问3:put方法原理是怎么实现的?

从源码中可以看到,put(K key, V value)可以分为三个步骤:

  • 通过hash(Object key)方法计算key的哈希值。

  • 通过putVal(hash(key), key, value, false, true)方法实现功能。

  • 返回putVal方法返回的结果。

(15)追问4:HashMap扩容机制原理

capacity 即容量,默认16。

loadFactor 加载因子,默认是0.75threshold 阈值。阈值=容量*加载因子。默认12。当元素数量超过阈值时便会触发扩容。

  • 一般情况下,当元素数量超过阈值时便会触发扩容(调用resize()方法)。

  • 每次扩容的容量都是之前容量的2倍。

  • 扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。

这里我们以JDK1.8的扩容为例:

HashMap的容量变化通常存在以下几种情况:

  • 空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。

  • 有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的2的幂数,将这个数设置赋值给阈值(threshold)。第一次调用put方法时,会将阈值赋值给容量,然后让 阈值 = 容量 x 加载因子 。(因此并不是我们手动指定了容量就一定不会触发扩容,超过阈值后一样会扩容!!)

  • 如果不是第一次扩容,则容量变为原来的2倍,阈值也变为原来的2倍。(容量和阈值都变为原来的2倍时,加载因子0.75不变)

此外还有几个点需要注意:

  • 首次put时,先会触发扩容(算是初始化),然后存入数据,然后判断是否需要扩容;可见首次扩容可能会调用两次resize()方法。

  • 不是首次put,则不再初始化,直接存入数据,然后判断是否需要扩容;

扩容时,要扩大空间,为了使hash散列均匀分布,原有部分元素的位置会发生移位。

JDK7的元素迁移

        JDK7中,HashMap的内部数据保存的都是链表。因此逻辑相对简单:在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。

这里有几个注意点:

  • 是否要重新计算hash值的条件这里不深入讨论,读者可自行查阅源码。

  • 因为是头插法,因此新旧链表的元素位置会发生转置现象。

  • 元素迁移的过程中在多线程情境下有可能会触发死循环(无限进行链表反转)。

JDK1.8的元素迁移

        JDK1.8则因为巧妙的设计,性能有了大大的提升:由于数组的容量是以2的幂次方扩容的,那么一个Entity在扩容时,新的位置要么在原位置,要么在原长度+原位置的位置。原因如下图:

        数组长度变为原来的2倍,表现在二进制上就是多了一个高位参与数组下标确定。此时,一个元素通过hash转换坐标的方法计算后,恰好出现一个现象:最高位是0则坐标不变,最高位是1则坐标变为“10000+原坐标”,即“原长度+原坐标”。如下图:

因此,在扩容时,不需要重新计算元素的hash了,只需要判断最高位是1还是0就好了。

JDK8的HashMap还有以下细节需要注意:

  • JDK8在迁移元素时是正序的,不会出现链表转置的发生。

  • 如果某个桶内的元素超过8个,则会将链表转化成红黑树,加快数据查询效率。

(16)追问5:HashMap在JDK1.8都做了哪些优化?

  • 数组+链表改成了数组+链表或红黑树

                防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);

  • 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,新节点插入到链表头部,原始节点后移;而JDK1.8会遍历链表,将元素放置到链表的最后;

        因为1.7头插法扩容时,头插法可能会导致链表发生反转,多线程环境下会产生环(死循环);

        这个过程为,先将A复制到新的hash表中,然后接着复制B到链头(A的前边:B.next=A),本来B.next=null,到此也就结束了(跟线程二一样的过程),但是,由于线程二扩容的原因,将B.next=A,所以,这里继续复制A,让A.next=B,由此,环形链表出现:B.next=A; A.next=B

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

就是说原本是A->B,在扩容后那个链表还是A->B。

  • 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;

  • 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

(17)追问6:链表红黑树如何互相转换?阈值多少?

  • 链表转红黑树的阈值为:8

  • 红黑树转链表的阈值为:6

        经过计算,在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,从概率上讲,阈值为8足够用;至于为什么红黑树转回来链表的条件阈值是6而不是7或9?因为如果hash碰撞次数在8附近徘徊,可能会频繁发生链表和红黑树的互相转化操作,为了预防这种情况的发生。

(18)面试题2:HashMap是线程安全的吗?

不是线程安全的,在多线程环境下,

  • JDK1.7:会产生死循环、数据丢失、数据覆盖的问题;

  • JDK1.8:中会有数据覆盖的问题。

        以1.8为例,当A线程判断index位置为空后正好挂起,B线程开始往index位置写入数据时,这时A线程恢复,执行写入操作,这样A或B数据就被覆盖了。

(19)追问1:你是如何解决这个线程不安全问题的?

在Java中有HashTable、SynchronizedMap、ConcurrentHashMap这三种是实现线程安全的Map。

  • HashTable:是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大;

  • SynchronizedMap:是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现;

  • ConcurrentHashMap:使用分段锁(CAS + synchronized相结合),降低了锁粒度,大大提高并发度

(20)追问2:说一下大家为什么要选择ConcurrentHashMap?

        在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会

1)线程不安全的HashMap

        在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。HashMap在并发执行put操作时会引起死循环,是因为多线程环境下会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,调用.next()时就会产生死循环获取Entry。

2)效率低下的HashTable

        HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下(类似于数据库中的串行化隔离级别)。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,读写操作均需要获取锁,竞争越激烈效率越低。

        因此,若未明确严格要求业务遵循串行化时(如转账、支付类业务),建议不启用HashTable。

3)ConcurrentHashMap的分段锁技术可有效提升并发访问率

        HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在严重锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的分段锁技术。首先将数据分成一段一段地存储(一堆Segment),然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

对于 ConcurrentHashMap 你至少要知道的几个点:

  • 默认数组大小为16

  • 扩容因子为0.75,扩容后数组大小翻倍

  • 当存储的node总数量 >= 数组长度*扩容因子时,会进行扩容(数组中的元素、链表元素、红黑树元素都是内部类Node的实例或子类实例,这里的node总数量是指所有put进map的node数量)

  • 当链表长度>=8且数组长度<64时会进行扩容

  • 当数组下是链表时,在扩容的时候会从链表的尾部开始rehash

  • 当链表长度>=8且数组长度>=64时链表会变成红黑树

  • 树节点减少直至为空时会将对应的数组下标置空,下次存储操作再定位在这个下标t时会按照链表存储

  • 扩容时树节点数量<=6时会变成链表

  • 当一个事 物 操作发现map正在扩容时,会帮助扩容

  • map正在扩容时获取(get等类似操作)操作还没进行扩容的下标会从原来的table获取,扩容完毕的下标会从新的table中获取

(21)追问3:ConcurrentHashMap在JDK1.7、1.8中都有哪些优化?

        其实,JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发。

  • JDK1.7:ReentrantLock+Segment+HashEntry

  • JDK1.8:Synchronized+CAS+Node(HashEntry)+红黑树

        从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

        数据结构上跟HashMap很像,从1.7到1.8版本,由于HashEntry从链表 → 红黑树所以 concurrentHashMap的时间复杂度从O(n)到O(log(n)) ↓↓↓;

        同时,也把之前的HashEntry改成了Node,作用不变,当Node链表的节点数大于8时Node会自动转化为TreeNode,会转换成红黑树的结构。把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

归纳一下:

  • JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)

  • JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念(jdk1.8),也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了

  • JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,成功代替了一定阈值的链表。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
回答: Java集合框架是一组设计良好的接口和类,用于支持对一组对象进行操作。它包括了Collection、Set、List和Map等基本接口和类。Collection接口代表一组对象,每个对象都是它的子元素。Set是不包含重复元素的Collection,而List是有顺序的Collection,可以包含重复元素。Map是可以将键映射到值的对象,键不能重复。\[1\] 在Java集合框架中,有一些线程安全的类和接口,如Vector和Hashtable,它们是旧的,从Java一诞生就提供的,因此是线程安全的。而ArrayList和HashMap是在Java2时才提供的,它们是线程不安全的。因此,在使用这些类时需要注意线程安全的问题。\[2\] Java1.5引入了泛型,它允许我们为集合提供一个可以容纳的对象类型。这样,如果我们尝试添加其他类型的元素,编译时会报错,避免了在运行时出现ClassCastException的情况。泛型还使代码更整洁,不需要使用显式转换和instanceOf操作符。此外,泛型还带来了运行时的好处,因为它不会产生类型检查的字节码指令。\[3\] Iterator是Java集合框架中的一个接口,它用于遍历集合中的元素。它提供了一种统一的方式来访问集合中的元素,无论集合的具体实现是什么。Enumeration是Iterator的前身,它是在Java集合框架之前引入的。它们的主要区别在于Iterator支持删除操作,而Enumeration不支持。此外,Iterator还提供了更多的方法,如hasNext()和next(),使得遍历集合更加方便。\[3\] #### 引用[.reference_title] - *1* *2* [Java面试常问集合框架22道面试真题(详解)](https://blog.csdn.net/NObug_369/article/details/106099293)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insert_down28v1,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [10道集合框架面试题(含解析),来看看你会多少](https://blog.csdn.net/weixin_62421895/article/details/126196672)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insert_down28v1,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值