【集合】吐血整理-集合框架库(含源码)

集合框架库

在这里插入图片描述
在这里插入图片描述

  • Collection接口:是存放一组单值的最大接口。 所谓的单值是指集合中的每个元素都是一个对象。一般很少直接使用此接口直接操作。

  • List接口: 是Collection接口的子接口 ,也是最常用的接口。此接口对Collection接口进行了
    大量的扩充,里面的内容是允许重复允许为NULL的并且有序(插入)。

  • Set接口: 是Collection接口的子类, 没有对Collection接口进行扩充,里面不允许存放重复内容!

  • Map接口: 是存放键值对的最大接口!即接口中的每个元素都是一对, 以key --> value的形式保存并且Key是不重复的,元素的存储位置由key决定。也就是可以通过key去寻找key-value的位置。从而得到value的 值。

  • Iterator接口:集合的输出接口,用于输出集合中的内容,只能进行从前到后的单向输出!(这里会举一个例子帮助同学们理解什么是迭代器)

  • ListIterator接口: 是Iterator接口的子接口,可以进行双向输出

  • Enumeration接口:是最早的输出接口,用于输出指定集合中的内容

  • SortedSet接口:单值的排序接口。实现此接口的集合类,里面的内容可以使用比较器排序(Comparable和Comparator的区别)

  • SortedMap接口:存放一对值得排序接口。实现此接口的集合类,里面的内容按照key排序,使用比较器排序

  • Queue接口: 队列接口,具有队列先入先出的特点。此接口的子类可以实现队列操作

  • Map.Entry接口:Map.Entry的内容接口,每一个Map.Entry对象都保存着一对 key -->value的内容,每个Map接口中都保存有多个Map.Entry接口实例。(具体HashMap的Entry类中有实现)

  • Queue :先入先出 一端添加 另一端删除*

  • Deque:可以在两端进行添加与删除*

学习目标:

  1. 明确每个接口和类的对应关系;
  2. 对每个接口和类,熟悉常用的API;
  3. 对不同的场景,能够选择合适的数据结构并分析优缺点;
  4. 学习源码的设计
什么是集合?

答:存储数据的容器;

集合、数组和数据库的区别?

答:(1)和数组的区别:a、数组是静态的,集合是动态的(可扩容);b、集合功能更全面;c、数组需要指定类型,存储数据必须是这个类型,集合创建时可有可无,如果没有可以存储任意;d、数租既可以使用普通类型,也可以使用引用类型,集合只能使用引用类型;e、数租是java内置类型,效率更高;

(2)和数据库的区别:使用场景的分析;

数组、集合、数据库的使用场景:

数组和集合都是根据需求来使用,数据库需要数据持久化

Collection

–》(list、Queue、Set)

Collection里面还定义了很多方法,这些方法都会继承到各个子接口和实现类中


操作集合无非就是四大类:【增删改查】CRUD

功能方法
add()/addAll()
remove()/removeAll()
Collection Interface里没有
contains()/containsAll()
其他isEmpty()/size()/toArray()

增:

boolean add(E e);

Boolean addAll(Collection<? extends E> c);

传入是object类型,所以当写入基本数据类型的时候,会自动装箱和拆箱

删:

boolean remove(object o);

boolean removeAll(Collection<?> c);

改:

没有直接改元素的操作,增和删可以实现;

查:

Boolean contains(Object o);

boolean containsAll(Collection<?> c);

查集合中有没有某个特定的元素;

List

ArrayList、LinkedList、Vector、Stack……

List接口: 是Collection接口的子接口 ,也是最常用的接口。此接口对Collection接口进行了
大量的扩充,里面的内容是允许重复允许为NULL的并且有序(插入)。

最大的特点就是有序,可重复

List的实现方式常用的有LinkedList和ArrayList两种,对于这两个数据结构的选择应该从

(1)数据结构是否能完成需要的功能;

(2)考虑那种更高效

功能方法ArrayListLinkedList
add(E e)(尾插)O(1)O(1)
add(int index,E e)O(n)O(n)
remove(int index)O(n)O(n)
remove(E e)(删除见到的第一个元素)O(n)O(n)
set(int index,E e)O(1)O(n)
get(int index)O(1)O(n)

由上图可见,ArrayList更适合查询;

因为ArrayList底层是数组,数组和链表的最大的区别就是可以随机访问

而增删呢?

(1)改查选择ArrayList;

(2)增删在尾部使用ArrayList;

(3)其他情况下,如果时间复杂度一样,推荐使用ArrayList;

ArrayList

特点:继承的接口

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
  • List接口允许存放null元素并且可以重复有序(插入顺序有序)
  • RandomAccess:可以随机访问
  • Cloneable:可以实现克隆
  • Serializable:可以实现序列化
  • Liertor:可以使用迭代器遍历

构造函数:

默认空数组,指定容量则按指定容量初始化;

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

在增加的时候才开始初始化数组,使用尾插

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

删除调用fastRemove(index)函数,采用尾插法删除;

public boolean remove(Object o) {
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

扩容函数,容量为int newCapacity = oldCapacity + (oldCapacity >> 1);相当于1.5倍扩容;然后让其与最大和最小数组比较,防止越界,然后将原数组拷贝到新数组中;

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    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);
}
Vector
public class Vector<E>
    extends AbstractList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable

线程安全的ArrayList;(其他实现几乎和ArrayList一样,只是在方法上加synchronized关键字修饰)

public synchronized void insertElementAt(E obj, int index) 
public synchronized void addElement(E obj)
public synchronized boolean removeElement(Object obj)

(1)和ArrayList一样,继承自java.util.AbstractList,底层也是由数组实现的;实现线程安全的方法是加了大量的synchronized关键字,效率低;(2)和ArrayList还有一个区别,就是扩容时的区别:

ArrayList的扩容是默认1.5倍扩容;而Vector的扩容是

int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                 capacityIncrement : oldCapacity);

默认二倍扩容,如果指定了扩容因子,则按原数组+扩容因子扩容。

Stack

继承自Vector,所以也是线程安全的

``publicclass Stack<E> extends Vector<E>

LinkedList

特点:(实现的接口)

  • List:允许重复null值并且插入有序

  • 继承了Deque接口具有如下特点(是双端队列,有针对first端和last端两组操作)所以赋予了LinkedList丰富的特性;

    • 当做普通链表使用增加:
      add(E e):在链表后添加一个元素 尾插
      add(int index, E element) 在指定下标添加元素 随机插入
      删除
      remove(E o); 删除一个指定元素
      remove(int index) 删除指定下标的元素
      获取:
      get(int index);获取指定下标的元素
      修改指定的下标的元素
      set(int index, E element);

    • 当做双端队列使用:

      增加:
      addFirst(E e):在链表头部插入一个元素; 特有方法
      addLast(E e):在链表尾部添加一个元素; 特有方法 add(int index, E element):在指定位置插入一个元素。
      offerFirst(E e):JDK1.6版本之后,在头部添加; 并返回是否添加成功
      offerLast(E e) 在数组后天添加元素, 并返回是否添加成功 offerLast(E e):JDK1.6版本之后,在尾部添加; 特有方法

      ​ 删除:
      ​ removeFirst(E e): 删除头, 获取元素并删除;
      ​ removeLast(E e): 删除尾;
      ​ pollFirst(): 删除头;
      ​ pollLast(): 删除尾;
      ​ removeFirstOccurrence(Object o) 删除第一次出现的指定元素
      ​ removeLastOccurrence(Object o) 删除最后一次出现的指定元素

      获取元素
      getFirst(): 获取第一个元素;
      getLast(): 获取最后一个元素
      peekFirst(): 获取第一个元素,但是不移除;
      peekLast(): 获取最后一个元素,但是不移除;

      • 当做普通队列:尾部添加 头部删除 获取元素也是获取头部第一个元素 返回值 关心
        add(E e) 在队列尾部添加一个元素
        offer(E e) 在队列尾部添加一个元素,并返回是否成功
        remove() 删除队列中第一个元素, 并返回该元素的值,如果元素为null,将抛出异常
        poll() 删除队列中第一个元素, 并返回该元素的值,如果元素为null,将返回null
        element() 获取第一个元素,如果没有将抛出异常
        peek() 获取第一个元素,如果没有返回null
      • 当做栈:头部添加尾部删除
public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{

增:

public boolean add(E e) {
    linkLast(e);
    return true;
}

尾插法

void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}
private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    final E element = f.item;
    final Node<E> next = f.next;
    f.item = null;
    f.next = null; // help GC
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;
    return element;
}

ArrayList和LinkedList都维护modCount有什么用?

答:在用迭代器遍历的时候,iterator.hasNext()方法中的checkForComodification()方法会检查modCount和expectedModCount是否相等,从而判断迭代器遍历的过程中,是否会发生结构性变化,否则可能抛出ConcurrentModificationException异常;

Queue/Deque

Queue和Deque的实现类大约有三个:LinkedList、ArrayDeque、PriorityQueue

  • 如果想实现「普通队列 - 先进先出」的语义,就使用 LinkedList 或者 ArrayDeque 来实现;
  • 如果想实现「优先队列」的语义,就使用 PriorityQueue;
  • 如果想实现「栈」的语义,就使用 ArrayDeque。

ArrayDeque和LinkedList有哪些区别呢?

答:

  1. ArrayDeque 是一个可扩容的数组,LinkedList 是链表结构;
  2. ArrayDeque 里不可以存 null 值,但是 LinkedList 可以;
  3. ArrayDeque 在操作头尾端的增删操作时更高效,但是 LinkedList 只有在当要移除中间某个元素且已经找到了这个元素后的移除才是 O(1) 的;
  4. ArrayDeque 在内存使用方面更高效。

所以,只要不是必须要存 null 值,就选择 ArrayDeque 吧!

java6之后才有的ArrayDeque

Set

之前提到set和List正好相反,是无序的不可重复的;

set的常用实现类有三种:HashSet、LinkedHashSet、TreeSet;而每个set的底层实现其实是对应的map,数值放在map的key上,value上放了present,是一个静态的Object,每个key都指向这个Object

HashSet:

采用hashmap的key来存放元素,主要特点是无序唯一,基本操作都是O(1)的复杂度,很快;

LinkedHashSet:这个是一个HashSet+LinkedList结构,特点是即拥有了O(1)的时间复杂度,有保证了插入的顺序;

TreeSet:

采用红黑树结构,特点是有序,可以用自然排序或者自定义比较器来排序,缺点是查询速度没有hashset快。

Map

Hashmap
特点:
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mUAQvUO7-1615172648114)(C:\Users\Administrator\Desktop\hashmap.png)]

数据结构:

1.7版本中使用数组加链表实现;1.8版本使用数组加链表加红黑树实现;

引入红黑树的原因:提高性能(1)解决发送哈希碰撞后链表过长导致的索引效率慢的问题(2)利用红黑树增删改查快的特点,将时间复杂度从O(n)降到O(logn);

条件:

  • 无冲突时:存放在数组中;
  • 有冲突&链表长度《8时:存放在单链表;
  • 有冲突&链表长度》8时:存放在红黑树;

重要参数:

hashmap中的主要参数和1.7相同:容量、加载因子、扩容阈值;

由于引入了红黑树,故加入了与红黑树相关参数;

  • $$
    /**

    • 主要参数 同 JDK 1.7
    • 即:容量、加载因子、扩容阈值(要求、范围均相同)
      */

    // 1. 容量(capacity): 必须是2的幂 & <最大容量(2的30次方)
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量 = 16 = 1<<4 = 00001中的1向左移4位 = 10000 = 十进制的2^4=16

static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量 = 2的30次方(若传入的容量过大,将被最大值替换)

 // 2. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度 
 final float loadFactor; // 实际加载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认加载因子 = 0.75

 // 3. 扩容阈值(threshold):当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表(即扩充HashMap的容量) 
 // a. 扩容 = 对哈希表进行resize操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数
 // b. 扩容阈值 = 容量 x 加载因子

int threshold;

 // 4. 其他
 transient Node<K,V>[] table;  // 存储数据的Node类型 数组,长度 = 2的幂;数组的每个元素 = 1个单链表
 transient int size;// HashMap的大小,即 HashMap中存储的键值对的数量


 /** 
   
  * 与红黑树相关的参数
    */
       // 1. 桶的树化阈值:即 链表转成红黑树的阈值,在存储数据时,当链表长度 > 该值时,则将链表转换成红黑树
       static final int TREEIFY_THRESHOLD = 8; 
       // 2. 桶的链表还原阈值:即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表
       static final int UNTREEIFY_THRESHOLD = 6;
       // 3. 最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
       // 否则,若桶内元素太多时,则直接扩容,而不是树形化
       // 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
       static final int MIN_TREEIFY_CAPACITY = 64;

$$

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a4m6wFse-1615172648115)(C:\Users\Administrator\Desktop\hashmap.参数.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-orMa0s3Q-1615172648117)(C:\Users\Administrator\Desktop\hashmap.node.png)]

源码分析:
构造函数:

(4个)

  1. 默认构造函数;

  2. 指定容量大小的构造函数;

  3. 指定容量大小和加载因子的构造函数

  4. 包含子map的构造函数

    注:当指定容量大小时,系统会调用tableSizeFor函数,将其转为传入容量大小最小的2的幂

增:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NK4y6XB3-1615172648118)(C:\Users\Administrator\Desktop\hashmap.put对比.png)]

hash函数:

   /**
     * 分析1:hash(key)
     * 作用:计算传入数据的哈希码(哈希值、Hash值)
     * 该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)
     * JDK 1.7 做了9次扰动处理 = 4次位运算 + 5次异或运算
     * JDK 1.8 简化了扰动函数 = 只做了2次扰动 = 1次位运算 + 1次异或运算
     */

      // JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作  = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
      static final int hash(int h) {
        h ^= k.hashCode(); 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
     }

      // JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
      // 1. 取hashCode值: h = key.hashCode() 
      // 2. 高位参与低位的运算:h ^ (h >>> 16)  
      static final int hash(Object key) {
           int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
            // a. 当key = null时,hash值 = 0,所以HashMap的key 可为null      
            // 注:对比HashTable,HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null
            // b. 当key ≠ null时,则通过先计算出 key的 hashCode()(记为h),然后 对哈希码进行 扰动处理: 按位 异或(^) 哈希码自身右移16位后的二进制
     }

   /**
     * 计算存储位置的函数分析:indexFor(hash, table.length)
     * 注:该函数仅存在于JDK 1.7 ,JDK 1.8中实际上无该函数(直接用1条语句判断写出),但原理相同
     * 为了方便讲解,故提前到此讲解
     */
     static int indexFor(int h, int length) {  
          return h & (length-1); 
          // 将对哈希码扰动处理后的结果 与运算(&) (数组长度-1),最终得到存储在数组table的位置(即数组下标、索引)
          }

这里就有疑问了,为什么要这么麻烦的计算呢?

设计的核心思想:为了提高存储key-value的数组下标位置的随机性和分布均匀性,尽量避免出现hash冲突。即对于不同key,存储的数组下标位置要尽可能不一样。

  • 为什么不直接采用经过hashcode()处理后的哈希码作为数组下标呢?

    答:因为哈希码一般是整型,计算出来的很可能不在hashmap数组容量范围中,从而导致无法匹配位置。解决(采用哈希码&(数组长度-1)

  • 为什么采用哈希码&运算(数组长度-1)计算下标呢?

    答:数组长度要求为2的幂,使得(数组-1)的结果=适合数组长度的低位掩码,数组长度=2的幂=(二进制表示)100……00的形式,首位为1其它位为0

    (数组长度-1)=(二进制表示)0111……111的形式,首位为0其余为1

    这个运算实际上是为了将hash值对数组长度取模,即h%length

    但直接采用取余效率很低,采用位运算符&,而只有当数组长度=2的次幂时,h&(length-1)才等价于h%length

    也保证了hash码的均匀性,如果数组长度为2的幂(偶数)则(数组长度-1)的结果是1,与哈希码&的时候最后一位可能是0也可能是1(取决于哈希码),如果是奇数则(length-1)结果是0,那么存储位置一定是偶数,则会更容易出现hash冲突且浪费空间。

    而&操作主要解决了(1)哈希码与数组大小范围不匹配问题(2)保证计算后的存储数组位置的均匀性,降低哈希冲突。

  • 为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?

    答:加大哈希码低位的随机性,使得分布均匀,从而提高对数组存储下标位置的随机性&均匀性,最终减少哈希冲突

增:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oPAGbvHi-1615172648118)(C:\Users\Administrator\Desktop\hashmap.put.png)]

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);//根据key值计算的hash值
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;//初始化哈希表
    if ((p = tab[i = (n - 1) & hash]) == null)//计算下标,判断是否发生哈希冲突
        tab[i] = newNode(hash, key, value, null);//如果没有发生,直接插入
    else {//存在哈希冲突
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))//判断key是否也相同,如果相同,新值覆盖旧值;判断原则使用equals
            e = p;
        else if (p instanceof TreeNode)//如果是红黑树在树中操作
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {//链表
            for (int binCount = 0; ; ++binCount) {//假如没有找到key值相同节点,则新建采用尾插法;
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st若链表节点大于树阈值,则将链表转换为红黑树
                        treeifyBin(tab, hash);//树化
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))//遍历链表看key是否已经存在
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key如果key已经存在,直接新值覆盖旧值
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);//替换旧值时会实现的方法,默认为null
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)//插入成功后,判断是否需要扩容
        resize();
    afterNodeInsertion(evict);//插入成功会调用的方法,默认实现为null
    return null;
}

扩容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dvgcmsVP-1615172648119)(C:\Users\Administrator\Desktop\hahsmap.resize.png)]

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;//扩容前数组的阈值
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {//如果扩容前的数组容量超过最大值,则不再扩容
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //如果没有超过最大值,则扩容为原来的二倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //重新哈希
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

扩容方面与1.7的区别:

1.71.8
扩容后存储位置的计算方式扩容后的位置=原位置or原位置+旧容量全部按照原来的方法计算
转移数据方式尾插法头插法
需插入数据的插入时机&位置重新计算时机扩容前插入,转移数据时统一计算扩容后插入,单独计算
线程安全问题:

1.7使用头插法进行扩容,写一个demo可以看出来,多线程环境下一直进行put会发生死循环,造成这一原因的关键就是扩容函数transfer,在对table就行扩容到newTable后,将原来数据转移到newTable中,转移元素的过程采用头插法,也就是链表的顺序会翻转,这就是造成死循环的关键

而1.8中虽然改成了尾插法,但是在一些put/get方法下,没有使用同步锁,所以是不安全的;

ConcurrentHashMap

之前提到的线程安全问题,一般情况下怎么处理呢?

  • 使用Collections.synchronizedMap(Map)创建线程安全的map集合;

  • Hashtable

  • ConcurrentHashMap

不过出于线程并发度的原因,会舍弃前两者使用ConcurrentHashMap,性能和效率高于前两者;

SynchronizedMap是怎么实现的呢?

SynchronizedMap内部维护了一个普通对象Map,还有排斥锁mutex;在调用的时候传入一个map,可以看到有两个构造器,如果传入了mutex参数,则将对象排斥锁赋值为传入的对象;如果没有将对象排斥锁赋值为this,创建出synchronizedMap之后,再操作map时,就会在方法上上锁;

HashTable是怎么实现的呢?

hashtable在方法上加synchronized关键字,所以效率比较低;

HashTable和HashMap的区别?

  1. 线程安全问题;

  2. 实现方式不同:hashtable继承了Dictionary类,而hashmap继承的是AbstractMap类;

  3. 初始容量不同:HashMap的初始容量16;hashtable初始容量11;负载因子都是0.75‘

  4. 迭代器不同:hashmap中iterator迭代器时fail-fast的,而hashtable不是

  5. HashTable不允许键或值为空,HashMap的键值则都可以为null。

    因为hashtable在我们put空值的时候会直接抛出空指针异常,但是hashmap却做了特殊处理,如果key==null时hashmap计算hash等于0;

为什么允许为null吗?

答:这是因为hashtable使用的是安全失败机制,这种机制会使你此次读到的数据不一定是最新的数据。入股使用null值,就会使其无法判断对应的key是不存在还是为null,因为你无法再调用一次contains(key)来对key是否存在进行判断,Concurrenthashmap同理。

快速失败机制是什么?

答:是java集合中的一种机制,在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改,则会抛出ConcurrentModificationException。你们在多线程下发生并发修改。

快速失败 原理?

答:迭代器在遍历的时候直接访问集合中的内容,并且在遍历中会使用一个modCount变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值,在迭代器遍历的时候,会检查modCount变量是否为expectedmodCount值,是的话就返回,不是就抛出异常终止。

ConcurrentHashMap

JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。

实现线程安全的方式(重要): ① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用
Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使 用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

Segment是ConcurrentHashMap的一个内部类

static final class Segment<K,V> extends ReentrantLock implements Serializable {

  private static final long serialVersionUID = 2249069246763182397L;

  // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
  transient volatile HashEntry<K,V>[] table;

  transient int count;
    // 记得快速失败(fail—fast)么?
  transient int modCount;
    // 大小
  transient int threshold;
    // 负载因子
  final float loadFactor;

}

HashEntry和hashMap的结构差不多,但是它使用volatile去修饰了他的数据域value还有其下一个节点next,volatile保证了其可见性有序性原子性。原理上说,ConcurrenthashMap采用分段锁技术,每当一个线程占用锁访问一个Segment时,不会影响到其他的Segment。就是说如果容量大小是16的并发度就是16,可以同时允许16个线程操作16个Segment是线程安全的

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();//这就是为啥他不可以put null值的原因
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          
         (segments, (j << SSHIFT) + SBASE)) == null) 
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

put操作时先定位到Segment,然后再进行put操作

    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
          // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
 // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                 // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
               //释放锁
                unlock();
            }
            return oldValue;
        }

首先第一步尝试获取锁,如果获取失败肯定就是有其他线程存在竞争,则利用scanAndLockForPut()自旋获取锁。

  1. 尝试自旋获取锁;
  2. 如果重试的次数到了最大值,则改为阻塞锁获取,保证能获取成功;

get逻辑只需要将key通过hash之后定位到具体的Segment,再通过一次Hash定位到具体的元素上,由于value是用volatile关键字修饰的,保证了内存可见性,所以每次获取都是新值,但我们在查询的时候还是遍历链表,会导致效率低

1.8抛弃了分段锁,采用CAS+synchronized来保证安全性;将之前的hashEntry改成了node,但是作用不变,把值和next采用volatile修饰,保证了可见性,也引入了红黑树;

put操作:

  1. 根据key计算出hashcode

  2. 判断是否需要进行初始化

  3. 即为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功。

  4. 如果当前位置hashcodeMOVED-1,则需要进行扩容;

  5. 如果都不满足,则利用synchronized锁写入数据

  6. 如果数量大于tree,则需要转换为红黑树

    public V put(K key, V value) {
        return putVal(key, value, false);
    }
    
    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }
    

CAS操作,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。(但也有ABA问题)

CAS性能很高,但synchronized性能不好,为什么jdk1.8升级之后反而多了synchronized

synchronized之前一直是重量级锁,但是后来java官方对其进行升级,他采用锁升级的方式去做的

针对synchronized获取锁的方式,jvm使用锁升级优化方式,先使用偏向锁优先统一线程然后再次获取锁,如果失败,就失败为CAS轻量级锁,如果失败就会短暂自旋,防止线程被挂起。最后如果都失败就升级为重量级锁。

ConcurrentHashMap的get操作是怎么样的?

  • 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
  • 如果是红黑树那就按照树的方式获取值。
  • 就不满足那就按照链表的方式遍历获取值。

resize

当需要扩容的时候,调用的时候tryPresize方法。在tryPresize方法中,并没有加锁,允许多个线程进入,如果数组正在扩张,则当前线程也去帮助扩容。值得注意的是,复制之后的新链表不是旧链表的绝对倒序;在扩容的时候每个线程都有处理的步长,最少为16,在这个步长范围内的数组节点只有自己一个线程来处理。整个操作是在持有段锁的情况下执行。

remove

remove 操作也是确定需要删除的元素的位置,不过这里删除元素的方法不是简单地把待删除元素的前面的一个元素的 next 域指向后面的节点。HashEntry 中的 next 是 final 修饰的,一经赋值以后就不可修改。在定位到待删除元素的位置以后,程序就将待删除元素前面的那一些元素全部复制一遍,然后再一个一个重新接到链表上去。从源码来看,就是将定位之后的所有entry克隆并拼回前面去。这意味着每次删除一个元素就要将那之前的元素克隆一遍。这其实是由entry的不变性来决定的,仔细观察entry定义,发现除了value,其他所有属性都是用 final 来修饰的,这意味着在第一次设置了 next 域之后便不能再改变它,取而代之的是将它之前的节点全都克隆一次。至于 entry 为什么要设置为不变性,这跟不变性的访问不需要同步从而节省时间有关。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R88gOCaG-1615172648120)(C:\Users\15079\AppData\Roaming\Typora\typora-user-images\image-20201218201156245.png)]

计算ConcurentHashMap的size大小

计算ConcurrentHashMap的元素大小是一个有趣的问题,因为可能存在并发操作。当计算 size 的时候,并发插入的数据可能会导致计算出来的 size 和实际的 size 有偏差,JDK1.7版本用两种方案:

  • 第一种方案使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的
  • 第二种方案是如果第一种方案不符合,就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回
  • 当要删除的结点存在时,删除的最后一步操作要将 count 的值减一。这必须是最后一步操作,否则读取操作可能看不到之前对段所做的结构性修改
  • remove 执行的开始就将 table 赋给一个局部变量 tab。这是因为 table 是 volatile 变量,读写volatile 变量的开销很大。编译器也不能对 volatile 变量的读写做任何优化,直接多次访问非 volatile 实例变量没有多大影响,编译器会做相应优化

同步容器

  1. Vector、Stack、HashTable
  2. Collections类中提供 的静态工厂方法创建的类

比如Vector单独使用的时候是没问题的,但是例如deleteLast方法是一个复合方法,多线程中可能会出现ArrayIndexOutOfBoundsException异常

则需要通过主动加锁解决,但是大大降低了效率,get和add要排队执行

并发容器

juc包下提供了大量支持高校并发的访问的集合类,有很好的并发能力并且保证了线程安全的复合操作

建议直接使用juc提供的容器类

LinkedHashMap:(有序的hashmap)

​ 1、key和value都允许为null
​ 2、key重复会覆盖、value允许重复
​ 3、有序
​ 4、非线程安全

底层数据结构:
(1)可以认为是hashmap+linkedlist,即使用hashmap操作数据结构
又使用linkedlist维护插入元素的先后顺序
(2)基本实现思想–多态。

Entry: K key ,V value , Entry<K,V> next ,int hash ,
        Entry<K,V>before(独有) ,Entry<K,V> after(独有)

因为维护了顺序,所以可以做缓存

set:1、先计算hashcode值判断对象加入的位置 2、如果相同调用equals
检查hashcode相等的对象是否真的相同

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值