并发List、Set、ConcurrentHashMap底层原理

并发List、Set、ConcurrentHashMap底层原理

ArrayList:

List特点:元素有放入顺序,元素可重复

存储结构:底层采用数组来实现

复制代码
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
  • Cloneable:

支持拷贝:实现Cloneable接口,重写clone方法、方法内容默认调用父类的clone方法

浅拷贝

​ 基础类型的变量拷贝之后是独立的,不会随着原变量变动而改变

String类型拷贝之后也是独立的

引用类型拷贝的是引用地址,拷贝前后的变量引用同一个堆中的对象

复制代码
public Object clone() throws CloneNotSupportedException {
    Study s = (Study) super.clone();
    return s;
}

深拷贝

  • 深拷贝是创建一个新的对象,并将当前对象的非静态字段复制到该新对象,如果字段是值类型的,那么对该字段执行逐位复制,如果字段是引用类型的,那么复制引用并指向一个新的对象,而不再是原有的对象。
  • 简单来说,深拷贝就是两个对象不共享内部状态,一个对象的修改不会影响到另一个对象。

ArrayList实现了深拷贝

复制代码
    public Object clone() {
        try {
            ArrayList<?> v = (ArrayList<?>) super.clone();
            v.elementData = Arrays.copyOf(elementData, size);
            v.modCount = 0;
            return v;
        } catch (CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError(e);
        }
    }
  • Serialilzable

    • 序列化:将对象状态转换为可保持或传输的个数的过程
  • AbstractList

    • 继承了AbstractList,说明它是一个列表,有用相应的增、删、查、改等功能
  • List

    • 为什么继承了AbstractList还需要实现List接口
      • 在StackOverFlow 中:传送门 得票最高的答案的回答者说他问了当初写这段代码的 Josh Bloch,得知这就是一个写法错误。

基本属性

复制代码
//序列化版本号(类文件签名),如果不写会默认生成,类内容的改变会影响签名变化,导致反序列化失败
private static final long serialVersionUID = 8683452581122892189L;

//如果实例化时未指定容量,则在初次添加元素时会进行扩容使用此容量作为数组长度
private static final int DEFAULT_CAPACITY = 10;

//static修饰,所有的未指定容量的实例(也未添加元素)共享此数组,两个空的数组有什么区别呢? 就是第一次添加元素时知道该 elementData 从空的构造函数还是有参构造函数被初始化的。以便确认如何扩容。空的构造器则初始化为10,有参构造器则按照扩容因子扩容
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData; // arrayList真正存放元素的地方,长度大于等于size

总结之EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA的区别:EMPTY_ELEMENTDATA是为了优化创建ArrayList空实例时产生不必要的空数组,使得所有ArrayList空实例都指向同一个空数组。DEFAULTCAPACITY_EMPTY_ELEMENTDATA是为了确保无参构成函数创建的实例在添加第一个元素时,*最小的容量*是默认大小10。

添加元素 - 默认尾部添加

效率比较高

指定下标添加元素

复制代码
public void add(int index, E element) {
    rangeCheckForAdd(index);//下标越界检查
    ensureCapacityInternal(size + 1);  //同上  判断扩容,记录操作数
    //依次复制插入位置及后面的数组元素,到后面一格,不是移动,因此复制完后,添加的下标位置和下一个位置指向对同一个对象
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;//再将元素赋值给该下标
    size++;

时间复杂度为O(n),与移动的元素个数正相关

扩容:

复制代码
private void grow(int minCapacity) {
    int oldCapacity = elementData.length;//获取当前数组长度
    int newCapacity = oldCapacity + (oldCapacity >> 1);//默认将扩容至原来容量的 1.5 倍
    if (newCapacity - minCapacity < 0)//如果1.5倍太小的话,则将我们所需的容量大小赋值给newCapacity
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)//如果1.5倍太大或者我们需要的容量太大,那就直接拿 newCapacity = (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE 来扩容
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);//然后将原数组中的数据复制到大小为 newCapacity 的新数组中,并将新数组赋值给 elementData。

迭代器 iterator

复制代码
public Iterator<E> iterator() {
    return new Itr();
}
private class Itr implements Iterator<E> {
    int cursor;       // 代表下一个要访问的元素下标
    int lastRet = -1; // 代表上一个要访问的元素下标
    int expectedModCount = modCount;//代表对 ArrayList 修改次数的期望值,初始值为 modCount
    //如果下一个元素的下标等于集合的大小 ,就证明到最后了
    public boolean hasNext() {
        return cursor != size;
    }
    @SuppressWarnings("unchecked")
    public E next() {
        checkForComodification();//判断expectedModCount和modCount是否相等,ConcurrentModificationException
        int i = cursor;
        if (i >= size)//对 cursor 进行判断,看是否超过集合大小和数组长度
            throw new NoSuchElementException();
        Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        cursor = i + 1;//自增 1。开始时,cursor = 0,lastRet = -1;每调用一次next方法,cursor和lastRet都会自增1。
        return (E) elementData[lastRet = i];//将cursor赋值给lastRet,并返回下标为 lastRet 的元素
    }
    public void remove() {
        if (lastRet < 0)//判断 lastRet 的值是否小于 0
            throw new IllegalStateException();
        checkForComodification();//判断expectedModCount和modCount是否相等,ConcurrentModificationException
        try {
            ArrayList.this.remove(lastRet);//直接调用 ArrayList 的 remove 方法删除下标为 lastRet 的元素
            cursor = lastRet;//将 lastRet 赋值给 curso
            lastRet = -1;//将 lastRet 重新赋值为 -1,并将 modCount 重新赋值给 expectedModCount。
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }

remove方法的弊端

  • 只能进行remove操作,add、clear等Iterator中没有
  • 调用remove之前必须调用next。因为remove开始就对lastRet做了校验。而lastRet初始化时为-1
  • next之后只可以调用一次remove。因为remove会将lastRet重新初始化为-1

什么是fail-fast

fail-fast机制是java集合中的一种错误机制。

当使用迭代器迭代时,如果发现集合有修改,则快速失败做出响应,抛出ConcurrentModificationException异常。

这种修改有可能是其它线程的修改,也有可能是当前线程自己的修改导致的,比如迭代的过程中直接调用remove()删除元素等。

另外,并不是java中所有的集合都有fail-fast的机制。比如,像最终一致性的ConcurrentHashMap、CopyOnWriterArrayList等都是没有fast-fail的。

fail-fast是怎么实现的:

ArrayList、HashMap中都有一个属性modcount,每次对集合的修改这个值都会加1,在遍历前记录这个值expert*count中,遍历中检查两者是否一致,如果出现不一致就说明有修改,则抛出ConcurrentModificationException异常。

底层数组存/取元素效率非常的高(get/set),时间复杂度是O(1),而查找(比如:indexOf,contain),插入和删除元素效率不太高,时间复杂度为O(n)。

插入/删除元素会触发底层数组频繁拷贝,效率不高,还会造成内存空间的浪费,解决方案:linkedList

查找元素效率不高,解决方案:HashMap(红黑树)

LinkedList

存储结构:底层采用链表来实现

HashSet(Set):

特点:元素无放入顺序,元素不可重复(注意:元素虽然无放入顺序,但是元素在set中的位置是有该元素的HashCode决定的,其位置其实是固定的)

存储结构

底层采用HashMap来实现

HashMap(Map):

特点:key,value存储,key可以为null,同样的key会被覆盖掉

存储结构:底层采用数组、链表、红黑树来实现

原理讲解:

哈希算法(也叫散列),就是把任意长度值(Key)通过散列算法变换成固定长度的key(地址)通过这个地址进行访问的数据结构它通过把关键码值映射到表中一个。位置来访问记录,以加快查找的速度。

链表查询的时候链表过长查询效率非常低,所以需要红黑树

JDK8中的HashMap与JDK7中的HashMap有什么不同
  • JDK8中新增了红黑树,JDK8是通过数组 + 链表 + 红黑树来实现的
  • JDK7中链表的插入是用的头插法,而JDK8中则改为了尾插法
  • JDK8中因为使用了红黑树保证了插入和查询的效率,所以实际上JDK8中的Hash算法实现的复杂度降低了
  • JDK8中数组扩容的条件也发生了变化,只会判断是否当前元素的个数是否查过了阈值,而不再判断当前put进来的元素对应的数组小标位置是否有值
  • JDK7是先扩容再添加新的元素、JDK8是先添加新元素然后再扩容
HashMap中put方法的流程
  • 通过key计算出一个hashcode
  • 通过hashcode与"与操作"计算出一个数组下标
  • 在把put进来的key、value封装成一个entry对象
  • 判断数组下标对应的位置,是不是为空,如果是空则把entry直接存在改数组位置
  • 如果改下标对应的位置不为空,则需要把entry插入到链表中
  • 并且还需要判断改链表中是否存在相同的key,如果存在,则更新value
  • 如果是JDK7使用头插法
  • 如果是JDK8,则会遍历链表,遍历链表的过程中,统计当前链表的元素个数,如果超过8个,则先把链表转变为红黑树、并且把元素插入到红黑树中
JDK中链表转变为红黑树的条件
  • 链表中的元素的个数为8个或超过8个
  • 同时,还需要满足当前数组的长度大于或等于64才会把链表转变为红黑树
    • 因为链表转变为红黑树的目的是为了解决链表过长,导致查询和插入效
      率慢的问题,而如果要解决这个问题,也可以通过数组扩容,把链表缩短也可
      以解决这个问题。所以在数组长度还不太长的情况,可以先通过数组扩容来解
      决链表过长的问题
HashMap扩容流程是怎样的?
  • HashMap的扩容指的就是数组的扩容, 因为数组占用的是连续内存空间,所以数组的扩容其实只能新开一个新的数组,然后把老数组上的元素转移到新
    数组上来,这样才是数组的扩容
  • 在HashMap中也是一样,先新建一个2被数组大小的数组
  • 然后遍历老数组上的没一个位置,如果这个位置上是一个链表,就把这个链表上的元素转移到新数组上去
  • 在这个过程中就需要遍历链表,当然jdk7,和jdk8在这个实现时是有不一样的,jdk7就是简单的遍历链表上的没一个元素,然后按每个元素的hashcode结合新数组的长度重新计算得出一个下标,而重新得到的这个数组下标很可能和之前的数组下标是不一样的,这样子就达到了一种效果,就是扩容之后,某个链表会变短,这也就达到了扩容的目的,缩短链表长度,提高了查询效率
  • 而在jdk8中,因为涉及到红黑树,这个其实比较复杂,jdk8中其实还会用到一个双向链表来维护红黑树中的元素,所以jdk8中在转移某个位置上的元素
    时,会去判断如果这个位置是一个红黑树,那么会遍历该位置的双向链表,遍历双向链表统计哪些元素在扩容完之后还是原位置,哪些元素在扩容之后在新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果超过八,则转成红黑树放到对应的位置,否则把单向链表放到对应的位置
  • 元素转移完了之后,在把新数组对象赋值给HashMap的table属性,老数组会被回收到
为什么HashMap的数组的大小是2的幂次方

JDK7的HashMap是数组+链表实现的,JDK8的HashMap是数组+链表+红黑树实现的

当某个key-value对需要存储到数组中时,需要先生成一个数组下标index,并且这个index不能越界。

在HashMap中,先得到key的hashcode,hashcode是一个数字,然后通过hashcode & (table.length - 1) 运算得到一个数组下标index,是通过与运算计算出来一个数组下标的,而不是通过取余,与运算相比于取余运算速度更快,但是也有一个前提条件,就是数组的长度得是一个2的幂次方数。

ConcurrentHashMap

特点:并发安全的HashMap,比HashTable效率更高

存储结构:底层采用数组、链表、红黑树、内部大量采用CAS操作。并发控制使用synchronized和CAS来操作来实现的。

相关推荐
落魄江湖行7 小时前
孤舟笔记 并发篇二十九 volatile关键字有什么用?它的实现原理是什么?面试必问的轻量级同步机制
java并发·春招·孤舟笔记·volatile关键字
落魄江湖行13 小时前
孤舟笔记 并发篇二十八 wait和sleep是否会触发锁的释放及CPU资源的释放?这个区别面试必考
java并发·春招·孤舟笔记·wait和sleep
落魄江湖行1 天前
孤舟笔记 并发篇二十二 线程池是如何回收线程的?核心线程和非核心线程的回收逻辑大不相同
java并发·春招·孤舟笔记·线程池是如何回收线程的
落魄江湖行2 天前
孤舟笔记 并发篇二十五 当任务数超过核心线程数时,如何让任务不进入队列?线程池调优的经典问题
java并发·春招·孤舟笔记·当任务数超过核心线程数时
落魄江湖行2 天前
孤舟笔记 并发篇二十三 线程池是如何实现线程复用的?Worker循环取任务的秘密远比你想象的精巧
java并发·春招·孤舟笔记
落魄江湖行3 天前
孤舟笔记 并发篇十一 行锁、间隙锁、临键锁傻傻分不清?MySQL InnoDB的锁其实就这三板斧
mysql·java并发·春招·孤舟笔记
落魄江湖行4 天前
孤舟笔记 并发篇十 ReentrantLock的公平锁和非公平锁是怎么实现的?这个设计藏着大智慧
java并发·春招·孤舟笔记
Javatutouhouduan10 天前
阿里2026最新Java面试核心讲(终极版)
java·java面试·java并发·后端开发·java程序员·java八股文·java性能优化
予枫的编程笔记2 个月前
【面试专栏|Java并发编程】CAS 核心原理,优缺点,ABA问题与解决方案
java·并发编程·java面试·java并发·aba问题·cas原理·面试干货
予枫的编程笔记2 个月前
【面试专栏|Java并发编程】Java 原子类全解:AtomicInteger、LongAdder 原理与适用场景
java·并发编程·java并发·面试干货·java原子类·atomicinteger·longadder