ArrayList
ArrayList是一个列表,它的底层数据结构是数组,初始的默认容量是10,但是当你new了一个ArrayList,它也不会立刻初始化,它会等到第一次添加元素的时候才初始化,是一种懒加载的方式。
然后它的扩容机制是扩容后容量是原来数组的1.5倍,当以这种方式扩容后还是不够所需长度,则直接用所需长度大小来扩容。然后ArrayList也有缩容机制,虽然不是自动缩容,但是它为我们提供了一个方法可以按照数组里面元素个数的大小进行缩容。
因为它的底层是数组,所以就决定了它的查询速度是很快的,但是相对的,它的插入和删除操作就显得比较慢。所以ArrayList适合用于查询操作比较多的场景。
ArrayList不是线程安全的,如果要想在线程安全的情况下用列表,可以使用Vector,它是线程安全的,但是它里面主要方法都用了synchronized来修饰,比较笨重;还可以使用Collections.synchronizedList(list),Collections里面有很多内部类,SynchronizedList就是其中一个,这个内部类里面的方法都是由synchronized代码块来实现线程安全的的。当然也可以使用CopyOnWriteArrayList,它也是线程安全的,它的add、set方法底层实现都是重新复制一个新的容器来操作的(写时复制)。
CopyOnWriteArrayList
适用于读操作远远多于写操作的场景,由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
为了将读取的性能发挥到极致,CopyOnWriteArrayList读取是完全不用加锁的,并且更厉害的是:写入操作也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。
CopyOnWriteArrayList类的所有可变操作(add,set 等等)都是通过拷贝底层数组的新副本来实现的。当 List 需要被修改的时候,并不修改原有数据,而是对原有数据进行一次复制,对副本进行修改操作,写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。整个过程利用ReentrantLock进行同步。其实就是CopyOnWrite思想。
SynchronizedList和Vector有什么区别
-
SynchronizedList有很好的扩展和兼容功能。他可以将所有的List的子类转成线程安全的类。Vector做不到。
-
使用SynchronizedList的时候,进行迭代时要手动进行同步处理,因为迭代操作没有加锁。
-
SynchronizedList可以指定锁定的对象mutex。
LinkedList
LinkedList的底层是带有头结点和尾节点的双向链表,它提供了两种插入的方法,一个是头插法,一个是尾插法,不管是那种插入,插入的效率都是比ArrayList效率高的,但是既然是由链表实现的,那么相对的它的查询效率就不如ArrayList。所以它的特性非常适合经常有插入和删除操作的场景。
TreeMap
TreeMap 基于红黑树实现,增删改查的平均时间复杂度均为 O(logn) ,最大特点是 Key 有序。所有Key 必须实现 Comparable 接口或提供的 Comparator 比较器,所以 Key 不允许为 null。
HashMap 依靠 hashCode 和 equal 去重,而 TreeMap 依靠 Comparable 或 Comparator。 TreeMap 排序时,如果Comparator 不为空就会优先使用比较器的 compare 方法,否则使用 Key 实现的 Comparable 的compareTo方法,两者都不满足则抛出异常。
TreeMap 通过put和 deleteEntry实现增加和删除树节点。插入新节点的规则有三个:
- 需要调整的新节点总是红色的。
- 如果插入新节点的父节点是黑色的,不需要调整。
- 如果插入新节点的父节点是红色的,由于红黑树不能出现相邻红色,进入循环判断,通过重新着色或左右旋转来调整。
TreeMap 的插入操作就是按照 Key 的对比往下遍历,大于节点值向右查找,小于向左查找,先按照二叉查找树的特性操作,后续会重新着色和旋转,保持红黑树的特性。
HashMap
首先说一下它的底层数据结构,jdk1.8之前是数组+链表,而jdk1.8对HashMap做了些优化,底层数据结构变成了数组+链表/红黑树,因为加了红黑树,这就面临着单链表与红黑树之间的转化,当单链表的长度大于等于8,并且数组长度大于等于64的时候,它会转化成红黑树,而当红黑树的节点个数小于等于6的时候,会重新转化成单链表。
然后是HashMap的容量是16,负载因子是0.75,当数组里元素个数大于等于容量乘以负载因子的时候会进行扩容,并且扩容后的大小是原数组的两倍。然后把之前HashMap里面的元素重新进行一次hash运算,放入新的HashMap中。另外它也有一个容量规整化的一个方法,主要就是为了防止用户初始化的时候设置的容量不是2的整数幂,所以规整化的操作就是把容量变为大于该容量的最小的2的整数幂。
HashMap不是线程安全的,在多线程的插入操作的时候会造成数据覆盖的可能,另外在jdk1.7的时候Hashmap采用的是头插法,所以它在resize()的过程中会形成一个环形链表,导致死循环,而在jdk1.8的时候改成了尾插法,避免了这种情况的发生。尽管有了这些改变,但是它还是线程不安全的,若是我们需要在多线程的环境下使用map,我们可以使用HashTable或是ConcurrentHashMap,HashTable基本也是很粗暴的在方法上加synchronized关键字来实现线程安全,有点笨重。而ConcurrentHashMap相对而言,更常用一些。
put()操作:
- 先判断是否需要初始化,第一次put元素需要初始化,因为它采用的是懒加载的方式;
- 通过key的hash值定位到的数组下标位置是否为null,若为null,则直接初始化第一个节点,存入key和value;
- 否则,就说明发生了hash冲突,具体有下面三种情况:
- 如果要插入的key的hash值等于当前首节点的hash值,key也等于当前节点的key,说明指定key的值已存在,那么就用临时节点e保存这个节点。
- 如果当前节点是TreeNode的话,就调用TreeNode方法的put方法,把元素插入到红黑树中。
- 如果当前节点是链表节点的话,遍历链表,如果遇到的节点为空,即到达链表的尾部,就将节点插入,如果插入后的节点数量超过8,那么就会将这个链表进行树化。如果在循环链表过程中又遇到有相同的key值,又直接对旧节点返回。
- 最后如果临时节点e不为null的话,表示map中有这个key,即用新值替换旧值即可。
get()操作:
-
通过hash值找到数组索引的位置,如果链表的第一个节点的hash值等于要查找的这个key的hash值,以及key也相等,则直接返回这个节点;
-
若不是,则继续往下找。(如果首节点是TreeNode的话,那就以树的遍历方式去查找key,否则就直接按照链表的顺序遍历查找。)
HashMap容量为什么是2的幂?
加快哈希计算:计算key在哪个槽里面的时候使用&运算速度会更快,也就是hash(key) & (n- 1)。当 & 代替 %,为了保证 & 的计算结果等于 % 的结果需要使n-1的二进制全为1。
减少哈希冲突:在HashMap中是通过 hash(key) & (n- 1) 定位数组的索引,当HashMap的容量是2的整数幂时,(n-1)的2进制也就是全1的形式,这样与元素的hash值进行&运算时,能够充分的散列,使得添加的元素均匀散列在数组的每个位置上,可以减少hash冲突。假如n是奇数的话,很明显 n-1 为偶数,它的最后一位是 0,这样 hash(key) & (n-1) 的最后一位肯定为 0,即只能为偶数,这样任何 hash 值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间。
为什么HashMap使用红黑树而不是AVL树
AVL树(平衡二叉搜索树)和红黑树相比有以下的区别:
- AVL树适用于查找密集型任务。红黑树适合于插入、修改密集型任务。
- 虽然它们都是高度平衡的树结构,但是AVL树是很严格的平衡树,而红黑树对平衡要求没那么高,这也意味着AVL树在完成添加、删除操作时,需要更多的旋转操作次数才能重新的平衡树结构,而红黑树旋转次数比AVL树少,因此AVL树在插入和删除操作上效率比较低。(平衡二叉树可能需要 O(logN)旋转,而红黑树需要最多两次旋转使其达到平衡)。
ConcurrentHashMap
jdk 1.7之前使用分段锁保证线程安全。通过锁住一段数据来保证并发安全。而在1.8的时候,它底层改成了和HashMap一样的数据结构,并且放弃了分段锁,采用CAS+synchronized来保证并发时的线程安全。这样锁的粒度会更小一些。并发可以更高。
put()操作:
- 判空操作(key,value),ConcurrentHashMap中不允许key和value为null。
- 判断是否需要进行初始化,如果还没有初始化,则进行初始化操作。同样的也是懒加载的方式。
- 若已经初始化,则找到当前 key 的hashcode所在的数组位置,如果为null表示当前位置可以写入数据,利用 CAS 尝试写入,这保证了只有一个线程可以 CAS 成功,其它线程都会失败。
- 如果数组下标位置不为null,则判断节点的 hash 值是否为 MOVED(值是-1),若为-1,说明当前数组正在进行扩容,则需要当前线程帮忙迁移数据。
- 如果都不满足,则利用 synchronized 给数组下标位置中第一个节点加锁,来保证在该节点下操作的线程安全。
- 如果hash值大于等于0,说明是正常的链表结构,从头结点开始遍历,如果找到了和当前 key 相同的节点,则用新值替换旧值,若遍历到了尾节点,则把新节点尾插进去。
- 如果头结点是树节点的话,就按照树的插入方式插入。
- 要注意的是,如果节点数量大于
TREEIFY_THRESHOLD则要转换为红黑树。
get()操作:
- 根据计算出来的 hash值寻址,如果就在数组位置上那么直接返回值。否则就按下面方式来获取。
- 如果是节点为红黑树节点那就按照红黑树的方式获取key的值。
- 都不满足那就按照链表的方式遍历获取key的值。
为何ConcurrentHashMap在JDK8放弃分段锁
JDK 1.7:分段锁
- 通过分段锁的方式提高并发度。底层采用数组+链表的结构,但是为了提高并发度,把原来的整个table划分成n个Segment,然后,每个 Segment 里边是由 HashEntry 组成的数组,每个 HashEntry之间又可以形成链表。我们可以把每个 Segment 看成是一个小的 HashMap,其内部结构和 HashMap 是一模一样的。当我们对一个Segment进行加锁,并不会影响其它Segment中的读写。注意的是分段的数量是一开始就确定的了,后期不能再进行扩容的,虽然Segment的个数是不能扩容的,但是单个Segment里面的数组是可以扩容的。
JDK 1.8:CAS+synchronized
- JDK1.8摒弃了分段锁,采用了数组+链表/红黑树的数据结构,而加锁机制变成了CAS+synchronized,synchronized在JDK1.6之后有了很大的优化,所以在这里并不会影响并发效率。CAS+synchronized这种方式使得锁的粒度更小,并发度更高。虽然读操作不需要锁,但是需要保证能读到最新数据,所以必须加volatile。即数组的引用需要加volatile,同时一个Node节点中的val和next属性也必须要加volatile。
原因:
- 分成很多segment时会比较浪费内存空间(不连续,碎片化)
- 生产环境中,map在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待
- 为了提高GC的效率
fail-fast和fail-safe有什么区别?
fail-fast(快速失败):它是java集合中的一种错误检测机制,在使用迭代器对集合对象进行遍历的时候,如果集合的结构被改变了(其它线程或是自己线程在遍历的过程中更改了数据),就会抛出 ConcurrentModificationException异常来防止继续遍历。原理是HashMap中维护一个modCount 变量。每次对数据的更改都会使得modCount+1,集合在被遍历期间如果modCount没有与预期的modCount相等,那么为了安全考虑,就会抛出异常。迭代器的快速失败行为是不一定能够得到保证的,一般来说,存在非同步的并发修改时,不可能做出任何坚决的保证的。但是快速失败迭代器会做出最大的努力来抛出异常。 fail-safe(安全失败):采用安全失败机制的集合容器,在使用迭代器对集合对象进行遍历的时候,不是直接在集合原本内容上遍历的,而是先拷贝一份原有集合数据,在拷贝的集合数据上进行遍历。所以即使有在并发的情况下也不会抛出异常,但是也带来一些缺陷:1)复制集合内容需要额外的空间和时间的开销;2)不能保证遍历的内容是最新的。