集合框架
- 1.集合概述
- 2.Collection接口
- 3.Map接口
- Java集合使用注意事项总结
- ArrayList 源码分析
- LinkedList 源码分析
- HashMap 源码分析
- ConcurrentHashMap 源码分析
大佬榜:请优先查看!!!
1.集合概述
Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection
接口,主要用于存放单一元素;另一个是 Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
和 Queue
。
注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了
AbstractList
,NavigableSet
等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。
2.Collection接口
Collection接口是List接口和Set接口的父接口,所以该接口中封装的方法,子接口可以照常使用。
2.1 List接口(特点:不唯一,有序的)
Java 的 List 是非常常用的数据类型。List 是有序的 Collection。
Java List 一共三个实现类:分别是 ArrayList、Vector 和 LinkedList。
因为List接口为有序的集合,所以在该集合中增加的方法都是一些与索引相关的方法。
增加:add(int index, E element)
删除:remove(int index) remove(Object o)
修改:set(int index, E element)
查看:get(int index)
List集合的遍历:
//方式1:普通for循环: System.out.println("---------------------"); for(int i = 0;i<list.size();i++){ System.out.println(list.get(i)); } //方式2:增强for循环: System.out.println("---------------------"); for(Object obj:list){ System.out.println(obj); } //方式3:迭代器: System.out.println("---------------------"); //Iterator为迭代器,后续会介绍 Iterator it = list.iterator(); while(it.hasNext()){ System.out.println(it.next());
2.1.1 ArrayList类(底层为数组存储)
ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。
JDK7版本:当在调用构造器时,数组初始化的长度为10,然后当数据超出初始化时,*会自动扩容为原来的1,5倍*,然后再将老数组指向新数组,在返回新数组。
JDK8版本:当在调用构造器时,底层数组为null,只有在调用add方法以后底层的数组才重新复制新的数组,长度为10,也是扩容1.5倍。优点:节省内存
- ArrayList的线程不安全,但是效率高。
- 数组的优点:查询速度快,但是可以重复。
- 数组的缺点:删除,增加元素效率低
2.1.2 Vector类(已淘汰,底层为数组存储,线程同步)
Vector 与 ArrayList 一样,也是通过数组实现的,但是该类扩容2倍。不同的是它支持线程的同步,即某一时刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问 ArrayList慢,线程安全,效率低。
2.1.3 LinkeList类(底层为双向链表存储)
LinkedList 是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另外,他还提供了 List 接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。
LinkedList的源码:
public class LinkedList<E>{//E是一个泛型,具体的类型要在实例化的时候才会最终确定 transient int size = 0;//集合中元素的数量 //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; } } transient Node<E> first;//链表的首节点 transient Node<E> last;//链表的尾节点 //空构造器: public LinkedList() { } //添加元素操作: public boolean add(E e) { linkLast(e); return true; } void linkLast(E e) {//添加的元素e final Node<E> l = last;//将链表中的last节点给l 如果是第一个元素的话 l为null //将元素封装为一个Node具体的对象: final Node<E> newNode = new Node<>(l, e, null); //将链表的last节点指向新的创建的对象: last = newNode; if (l == null)//如果添加的是第一个节点 first = newNode;//将链表的first节点指向为新节点 else//如果添加的不是第一个节点 l.next = newNode;//将l的下一个指向为新的节点 size++;//集合中元素数量加1操作 modCount++; } //获取集合中元素数量 public int size() { return size; } //通过索引得到元素: public E get(int index) { checkElementIndex(index);//健壮性考虑 return node(index).item; } Node<E> node(int index) { //如果index在链表的前半段,那么从前往后找 if (index < (size >> 1)) { Node<E> x = first; for (int i = 0; i < index; i++) x = x.next; return x; } else {//如果index在链表的后半段,那么从后往前找 Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } } }
ArrayList()和LinkedList的区别和联系
两者都实现了List接口,都具有List中元素有序、不唯一的特点。
ArrayList实现了长度可变的数组,在内存中分配连续空间。遍历元素和随机访问元素的效率比较高;
LinkedList采用链表存储方式。插入、删除元素时效率比较高
2.2 Set接口(特点:唯一,无序的)
Set 注重独一无二的性质,该体系集合用于存储无序(存入和取出的顺序不一定相同)元素,值不能重复。对象的相等性本质是对象 hashCode 值(java 是依据对象的内存地址计算出的此序号)判断的,如果想要让两个不同的对象视为相等的,就必须覆盖 Object 的 hashCode 方法和 equals 方法。
遍历的方法:使用Iterator迭代器迭代;增强for循环遍历。
2.2.1 HashSet类(底层为哈希表(数组+链表)存储)
哈希表里存放的是哈希值。HashSet 存储元素的顺序并不是按照存入时的顺序(和 List 显然不同) 而是按照哈希值来存的,所以取数据也是按照哈希值取得。元素的哈希值是通过元素的hashcode 方法来获取的,HashSet 首先判断两个元素的哈希值,如果哈希值一样,接着会比较equals 方法。如果 equls 结果为 true ,HashSet 就视为同一个元素。如果 equals 为 false 就不是同一个元素。
哈希值相同,equals为false的元素是怎么存储呢,就是在同样的哈希值下顺延(可以认为哈希值相同的元素放在一个哈希桶中)。也就是哈希一样的存一列。
如图 1 表示 hashCode 值不相同的情况;图 2 表示 hashCode 值相同,但 equals 不相同的情况。
HashSet 通过 hashCode 值来确定元素在内存中的位置。一个 hashCode 位置上可以存放多个元素。
2.2.2 LinkHashSet(HashSet+LinkedHashMap)
对于 LinkedHashSet 而言,它继承于 HashSet、又基于 LinkedHashMap 来实现的。LinkedHashSet 底层使用 LinkedHashMap 来保存所有元素,它继承与 HashSet,其所有的方法操作上又与 HashSet 相同,因此 LinkedHashSet 的实现上非常简单,只提供了四个构造方法,并通过传递一个标识参数,调用父类的构造器,底层构造一个 LinkedHashMap 来实现,在相关操作上与父类 HashSet 的操作相同,直接调用父类 HashSet 的方法即可。
2.2.3 TreeSet类(底层为二叉树存储)
TreeSet()是使用二叉树的原理对新 add()的对象按照指定的顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入的二叉树指定的位置。
Integer 和 String 对象都可以进行默认的 TreeSet 排序,而自定义类的对象是不可以的,自己定义的类必须实现 Comparable 接口,并且覆写相应的 compareTo()函数,才可以正常使用。
在覆写 compare()函数时,要返回相应的值才能使 TreeSet 按照一定的规则来排序
比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零或正整数。
当调用TreeSet的空构造器时,底层创建了一个TreeMap。
3.Map接口
3.1 HashMap类(底层为哈希表(数组+链表+红黑树)存储)
HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections 的 synchronizedMap 方法使HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。我们用下面这张图来介绍HashMap 的结构。
-
JDK7 实现
HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key、value、hash 值和用于单向链表的 next。
- capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
- loadFactor:负载因子,默认为 0.75。
- threshold:扩容的阈值,等于
capacity * loadFactor
所以当放入这个集合的数据对应的类时,必须重写HashCode方法(将key进行哈希运算)和equals方法(判断唯不唯一)。
-
JDK8 实现
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
HashMap 的底层实现
JDK1.8 之前 HashMap
底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashcode
经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap 的长度为什么是 2 的幂次方
- HashMap判断元素加入的位置,会先对key进行hash操作,然后将得到的hash值对数组的长度取模运算(hash数太大了,数组没那么大),得到的余数就是对应的数组下标。
- 取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作,并且 采用二进制位操作 &,相对于%能够提高运算效率
HashMap中的装填因子是0.75的原因
如果装填因子为1,那么就证明需要等到数组满了时候才能进行数组2倍扩容,这样可以做到最大的空间利用率,但是现实中元素不可能完全的均匀分布,那就很可能发生哈希碰撞,就会产生链表,产生链表就会使查询速度变慢。所以说当装填因子为1时,能做到最大的空间利用率,但是时间会变慢。
如果装填因子变小,变到0.5时,也就说当元素数量增加到数组容量的一半时就会进行2倍的数组扩容,这样做可以减少哈希碰撞,若不产生链表,那么查询的效率很高,但是会浪费空间。所以当装填因子为0.5时,查询时间会很快,但是空间会浪费。
所以我们会在空间和时间中取中间值0.75来平衡这个因素。
HashMap源码详解
//【1】继承的AbstractMap中,已经实现了Map接口,这个地方属于重复使用 public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { //哈希表主数组的默认长度 static final int DEFAULT_INITIAL_CAPACITY = 16; /* 定义了一个float类型的变量,以后作为:默认的加载因子,加载因子是表示Hsah表中元素的填满的程度, 太大容易引起哈西冲突,太小容易浪费 0.75是经过大量运算后得到的最好值 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; //主数组,每个元素为Entry类型 transient Entry<K,V>[] table; transient int size; //数组扩容的界限值,门槛值 16*0.75=12 int threshold; //用来接收装填因子的变量 final float loadFactor; //【4】查看构造器:内部相当于:this(16,0.75f);调用了当前类中的带参构造器 public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } //【5】本类中带参数构造器:-->作用给一些数值进行初始化的! public HashMap(int initialCapacity, float loadFactor) { //【6】给capacity赋值,capacity的值一定是大于你传进来的initialCapacity 的最小的2的倍数 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; //【7】给loadFactor赋值,将装填因子0.75赋值给loadFactor this.loadFactor = loadFactor; //【8】数组扩容的界限值,门槛值 threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); //【9】给table数组赋值,初始化数组长度为16 table = new Entry[capacity]; } //【10】调用put方法: public V put(K key, V value) { //【11】对空值的判断 if (key == null) return putForNullKey(value); //【12】调用hash方法,获取哈希码 int hash = hash(key); //【14】得到key对应在数组中的位置 int i = indexFor(hash, table.length); //【16】如果你放入的元素,在主数组那个位置上没有值,e==null 那么下面这个循环不走 //当在同一个位置上放入元素的时候 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; //哈希值一样 并且 equals相比一样 //(k = e.key) == key 如果是一个对象就不用比较equals了 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; //【17】走addEntry添加这个节点的方法: addEntry(hash, key, value, i); return null; } //【13】hash方法返回这个key对应的哈希值,内部进行二次散列,为了尽量保证不同的key得到不同的哈希码! final int hash(Object k) { int h = 0; if (useAltHashing) { if (k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h = hashSeed; } //k.hashCode()函数调用的是key键值类型自带的哈希函数, //由于不同的对象其hashCode()有可能相同,所以需对hashCode()再次哈希,以降低相同率。 h ^= k.hashCode(); // 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 ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } //【15】返回int类型数组的坐标 static int indexFor(int h, int length) { //其实这个算法就是取模运算:h%length,取模效率不如位运算 return h & (length-1); } //【18】调用addEntry void addEntry(int hash, K key, V value, int bucketIndex) { //【25】size的大小 大于 16*0.75=12的时候,比如你放入的是第13个,这第13个你打算放在没有元素的位置上的时候 if ((size >= threshold) && (null != table[bucketIndex])) { //【26】主数组扩容为2倍 resize(2 * table.length); //【30】重新调整当前元素的hash码 hash = (null != key) ? hash(key) : 0; //【31】重新计算元素位置 bucketIndex = indexFor(hash, table.length); } //【19】将hash,key,value,bucketIndex位置 封装为一个Entry对象: createEntry(hash, key, value, bucketIndex); } //【20】 void createEntry(int hash, K key, V value, int bucketIndex) { //【21】获取bucketIndex位置上的元素给e Entry<K,V> e = table[bucketIndex]; //【22】然后将hash, key, value封装为一个对象,然后将下一个元素的指向为e (链表的头插法) //【23】将新的Entry放在table[bucketIndex]的位置上 table[bucketIndex] = new Entry<>(hash, key, value, e); //【24】集合中加入一个元素 size+1 size++; } //【27】 void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } //【28】创建长度为newCapacity的数组 Entry[] newTable = new Entry[newCapacity]; boolean oldAltHashing = useAltHashing; useAltHashing |= sun.misc.VM.isBooted() && (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); boolean rehash = oldAltHashing ^ useAltHashing; //【28.5】转让方法:将老数组中的东西都重新放入新数组中 transfer(newTable, rehash); //【29】老数组替换为新数组 table = newTable; //【29.5】重新计算 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); } //【28.6】 void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } //【28.7】将哈希值,和新的数组容量传进去,重新计算key在新数组中的位置 int i = indexFor(e.hash, newCapacity); //【28.8】头插法 e.next = newTable[i];//获取链表上元素给e.next newTable[i] = e;//然后将e放在i位置 e = next;//e再指向下一个节点继续遍历 } } } }
3.2 TreeMap类(底层为二叉树存储)
TreeMap底层为二叉树,所以遵照二叉树的特点,TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap。
在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常
3.3 HashTable(线程安全)
Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。Hashtable 不建议在新代码中使用,不需要线程安全 的场合可以用 HashMap 替换,需要线程安全的场合可以用ConcurrentHashMap 替换。
Hashtable
(同一把锁) :使用 synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
3.4 LinkHashMap(记录插入顺序)
LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
3.5 ConcurrentHashMap
Segment 段
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个segment。
线程安全(Segment 继承 ReentrantLock 加锁)
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
- JDK7结构
-
在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 -
Java8 实现 (引入了红黑树)
Java8 对 ConcurrentHashMap 进行了比较大的改动,Java8 也引入了红黑树
- 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。
并行度(默认 16)
concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式 :JDK 1.7 采用
Segment
分段锁来保证安全,Segment
是继承自ReentrantLock
。JDK1.8 放弃了Segment
分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。 - Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度 :JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
HashMap 和 Hashtable 的区别
- 线程是否安全:
HashMap
是非线程安全的,Hashtable
是线程安全的,因为Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap
吧!); - 效率: 因为线程安全的问题,
HashMap
要比Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它; - 对 Null key 和 Null value 的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException
。 - 初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,
Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么Hashtable
会直接使用你给定的大小,而HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证,下面给出了源代码)。也就是说HashMap
总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。 - 底层数据结构: JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable
没有这样的机制。
HashMap 和 HashSet 区别
HashSet
底层就是基于 HashMap
实现的。
HashMap 和 TreeMap 区别
-
TreeMap是可排序的。
-
TreeMap
和HashMap
都继承自AbstractMap
,TreeMap
它还实现了NavigableMap
接口和SortedMap
接口。 -
实现
NavigableMap
接口让TreeMap
有了对集合内元素的搜索的能力。如NavigableMap定义了一些到导航方法,【Map.Entry<K,V> lowerEntry(K key)】返回第一个key小于参数的Entry -
实现
SortedMap
接口让TreeMap
有了对集合中的元素根据键排序的能力。 -
TreeMap多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
Java集合使用注意事项总结
集合判空
《阿里巴巴 Java 开发手册》的描述如下:
判断所有集合内部的元素是否为空,使用
isEmpty()
方法,而不是size()==0
的方式。
这是因为 isEmpty()
方法的可读性更好,并且时间复杂度为 O(1)。
绝大部分我们使用的集合的 size()
方法的时间复杂度也是 O(1),不过,也有很多复杂度不是 O(1) 的,比如 java.util.concurrent
包下的某些集合(ConcurrentLinkedQueue
、ConcurrentHashMap
...)。
下面是 ConcurrentHashMap
的 size()
方法和 isEmpty()
方法的源码。
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; } public boolean isEmpty() { return sumCount() <= 0L; // ignore transient negative values }
集合转 Map
《阿里巴巴 Java 开发手册》的描述如下:
在使用
java.util.stream.Collectors
类的toMap()
方法转为Map
集合时,一定要注意当 value 为 null 时会抛 NPE 异常。
class Person { private String name; private String phoneNumber; // getters and setters } List<Person> bookList = new ArrayList<>(); bookList.add(new Person("jack","18163138123")); bookList.add(new Person("martin",null)); // 空指针异常 bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber));
下面我们来解释一下原因。
首先,我们来看 java.util.stream.Collectors
类的 toMap()
方法 ,可以看到其内部调用了 Map
接口的 merge()
方法。
public static <T, K, U, M extends Map<K, U>> Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier) { BiConsumer<M, T> accumulator = (map, element) -> map.merge(keyMapper.apply(element), valueMapper.apply(element), mergeFunction); return new CollectorImpl<>(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID); }
Map
接口的 merge()
方法如下,这个方法是接口中的默认实现。
如果你还不了解 Java 8 新特性的话,请看这篇文章:《Java8 新特性总结》 。
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) { Objects.requireNonNull(remappingFunction); Objects.requireNonNull(value); V oldValue = get(key); V newValue = (oldValue == null) ? value : remappingFunction.apply(oldValue, value); if(newValue == null) { remove(key); } else { put(key, newValue); } return newValue; }
merge()
方法会先调用 Objects.requireNonNull()
方法判断 value 是否为空。
public static <T> T requireNonNull(T obj) { if (obj == null) throw new NullPointerException(); return obj; }
集合遍历
《阿里巴巴 Java 开发手册》的描述如下:
不要在 foreach 循环里进行元素的
remove/add
操作。remove 元素请使用Iterator
方式,如果并发操作,需要对Iterator
对象加锁。
通过反编译你会发现 foreach 语法底层其实还是依赖 Iterator
。不过, remove/add
操作直接调用的是集合自己的方法,而不是 Iterator
的 remove/add
方法
这就导致 Iterator
莫名其妙地发现自己有元素被 remove/add
,然后,它就会抛出一个 ConcurrentModificationException
来提示用户发生了并发修改异常。这就是单线程状态下产生的 fail-fast 机制。
fail-fast 机制:多个线程对 fail-fast 集合进行修改的时候,可能会抛出
ConcurrentModificationException
。 即使是单线程下也有可能会出现这种情况,上面已经提到过。相关阅读:什么是 fail-fast 。
Java8 开始,可以使用 Collection#removeIf()
方法删除满足特定条件的元素,如
List<Integer> list = new ArrayList<>(); for (int i = 1; i <= 10; ++i) { list.add(i); } list.removeIf(filter -> filter % 2 == 0); /* 删除list中的所有偶数 */ System.out.println(list); /* [1, 3, 5, 7, 9] */
除了上面介绍的直接使用 Iterator
进行遍历操作之外,你还可以:
- 使用普通的 for 循环
- 使用 fail-safe 的集合类。
java.util
包下面的所有的集合类都是 fail-fast 的,而java.util.concurrent
包下面的所有的类都是 fail-safe 的。 - ......
集合去重
《阿里巴巴 Java 开发手册》的描述如下:
可以利用
Set
元素唯一的特性,可以快速对一个集合进行去重操作,避免使用List
的contains()
进行遍历去重或者判断包含操作。
这里我们以 HashSet
和 ArrayList
为例说明。
// Set 去重代码示例 public static <T> Set<T> removeDuplicateBySet(List<T> data) { if (CollectionUtils.isEmpty(data)) { return new HashSet<>(); } return new HashSet<>(data); } // List 去重代码示例 public static <T> List<T> removeDuplicateByList(List<T> data) { if (CollectionUtils.isEmpty(data)) { return new ArrayList<>(); } List<T> result = new ArrayList<>(data.size()); for (T current : data) { if (!result.contains(current)) { result.add(current); } } return result; }
两者的核心差别在于 contains()
方法的实现。
HashSet
的 contains()
方法底部依赖的 HashMap
的 containsKey()
方法,时间复杂度接近于 O(1)(没有出现哈希冲突的时候为 O(1))。
private transient HashMap<E,Object> map; public boolean contains(Object o) { return map.containsKey(o); }
我们有 N 个元素插入进 Set 中,那时间复杂度就接近是 O (n)。
ArrayList
的 contains()
方法是通过遍历所有元素的方法来做的,时间复杂度接近是 O(n)。
public boolean contains(Object o) { return indexOf(o) >= 0; } public int indexOf(Object o) { if (o == null) { for (int i = 0; i < size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i < size; i++) if (o.equals(elementData[i])) return i; } return -1; }
我们的 List
有 N 个元素,那时间复杂度就接近是 O (n^2)。
集合转数组
《阿里巴巴 Java 开发手册》的描述如下:
使用集合转数组的方法,必须使用集合的
toArray(T[] array)
,传入的是类型完全一致、长度为 0 的空数组。
toArray(T[] array)
方法的参数是一个泛型数组,如果 toArray
方法中没有传递任何参数的话返回的是 Object
类 型数组。
String [] s= new String[]{ "dog", "lazy", "a", "over", "jumps", "fox", "brown", "quick", "A" }; List<String> list = Arrays.asList(s); Collections.reverse(list); //没有指定类型的话会报错 s=list.toArray(new String[0]);
由于 JVM 优化,new String[0]
作为Collection.toArray()
方法的参数现在使用更好,new String[0]
就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型。详见:https://shipilev.net/blog/2016/arrays-wisdom-ancients/
数组转集合
《阿里巴巴 Java 开发手册》的描述如下:
使用工具类
Arrays.asList()
把数组转换成集合时,不能使用其修改集合相关的方法, 它的add/remove/clear
方法会抛出UnsupportedOperationException
异常。
Arrays.asList()
在平时开发中还是比较常见的,我们可以使用它将一个数组转换为一个 List
集合。
String[] myArray = {"Apple", "Banana", "Orange"}; List<String> myList = Arrays.asList(myArray); //上面两个语句等价于下面一条语句 List<String> myList = Arrays.asList("Apple","Banana", "Orange");
JDK 源码对于这个方法的说明:
/** *返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的API之间的桥梁, * 与 Collection.toArray()结合使用。返回的List是可序列化并实现RandomAccess接口。 */ public static <T> List<T> asList(T... a) { return new ArrayList<>(a); }
下面我们来总结一下使用注意事项。
1、Arrays.asList()
是泛型方法,传递的数组必须是对象数组,而不是基本类型。
int[] myArray = {1, 2, 3}; List myList = Arrays.asList(myArray); System.out.println(myList.size());//1 System.out.println(myList.get(0));//数组地址值 System.out.println(myList.get(1));//报错:ArrayIndexOutOfBoundsException int[] array = (int[]) myList.get(0); System.out.println(array[0]);//1
当传入一个原生数据类型数组时,Arrays.asList()
的真正得到的参数就不是数组中的元素,而是数组对象本身!此时 List
的唯一元素就是这个数组,这也就解释了上面的代码。
我们使用包装类型数组就可以解决这个问题。
Integer[] myArray = {1, 2, 3};
2、使用集合的修改方法: add()
、remove()
、clear()
会抛出异常。
List myList = Arrays.asList(1, 2, 3); myList.add(4);//运行时报错:UnsupportedOperationException myList.remove(1);//运行时报错:UnsupportedOperationException myList.clear();//运行时报错:UnsupportedOperationException
Arrays.asList()
方法返回的并不是 java.util.ArrayList
,而是 java.util.Arrays
的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。
List myList = Arrays.asList(1, 2, 3); System.out.println(myList.getClass());//class java.util.Arrays$ArrayList
下图是 java.util.Arrays$ArrayList
的简易源码,我们可以看到这个类重写的方法有哪些。
private static class ArrayList<E> extends AbstractList<E> implements RandomAccess, java.io.Serializable { ... @Override public E get(int index) { ... } @Override public E set(int index, E element) { ... } @Override public int indexOf(Object o) { ... } @Override public boolean contains(Object o) { ... } @Override public void forEach(Consumer<? super E> action) { ... } @Override public void replaceAll(UnaryOperator<E> operator) { ... } @Override public void sort(Comparator<? super E> c) { ... } }
我们再看一下java.util.AbstractList
的 add/remove/clear
方法就知道为什么会抛出 UnsupportedOperationException
了。
public E remove(int index) { throw new UnsupportedOperationException(); } public boolean add(E e) { add(size(), e); return true; } public void add(int index, E element) { throw new UnsupportedOperationException(); } public void clear() { removeRange(0, size()); } protected void removeRange(int fromIndex, int toIndex) { ListIterator<E> it = listIterator(fromIndex); for (int i=0, n=toIndex-fromIndex; i<n; i++) { it.next(); it.remove(); } }
那我们如何正确的将数组转换为 ArrayList
?
1、手动实现工具类
//JDK1.5+ static <T> List<T> arrayToList(final T[] array) { final List<T> l = new ArrayList<T>(array.length); for (final T s : array) { l.add(s); } return l; } Integer [] myArray = { 1, 2, 3 }; System.out.println(arrayToList(myArray).getClass());//class java.util.ArrayList
2、最简便的方法
List list = new ArrayList<>(Arrays.asList("a", "b", "c"))
3、使用 Java8 的 Stream
(推荐)
Integer [] myArray = { 1, 2, 3 }; List myList = Arrays.stream(myArray).collect(Collectors.toList()); //基本类型也可以实现转换(依赖boxed的装箱操作) int [] myArray2 = { 1, 2, 3 }; List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList());
4、使用 Guava
对于不可变集合,你可以使用ImmutableList类及其of()与copyOf()工厂方法:(参数不能为空)
List<String> il = ImmutableList.of("string", "elements"); // from varargs List<String> il = ImmutableList.copyOf(aStringArray); // from array
对于可变集合,你可以使用Lists类及其newArrayList()工厂方法:
List<String> l1 = Lists.newArrayList(anotherListOrCollection); // from collection List<String> l2 = Lists.newArrayList(aStringArray); // from array List<String> l3 = Lists.newArrayList("or", "string", "elements"); // from varargs
5、使用 Apache Commons Collections
List<String> list = new ArrayList<String>(); CollectionUtils.addAll(list, str);
6、 使用 Java9 的 List.of()
方法
Integer[] array = {1, 2, 3}; List<Integer> list = List.of(array);
ArrayList 源码分析
ArrayList 简介
ArrayList
的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用ensureCapacity
操作来增加 ArrayList
实例的容量。这可以减少递增式再分配的数量。
ArrayList
继承于 AbstractList
,实现了 List
, RandomAccess
, Cloneable
, java.io.Serializable
这些接口。
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable{ }
List
: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。RandomAccess
:这是一个标志接口,表明实现这个接口的List
集合是支持 快速随机访问 的。在ArrayList
中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。Cloneable
:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable
: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。
ArrayList 可以添加 null 值吗?
ArrayList
中可以存储任何类型的对象,包括 null
值。不过,不建议向ArrayList
中添加 null
值, null
值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。
示例代码:
ArrayList<String> listOfStrings = new ArrayList<>(); listOfStrings.add(null); listOfStrings.add("java"); System.out.println(listOfStrings);
输出:
[null, java]
Arraylist 与 LinkedList 区别?
- 是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全; - 底层数据结构:
ArrayList
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!) - 插入和删除是否受元素位置的影响:
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)
方法的时候,ArrayList
会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)
),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。LinkedList
采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)
、addFirst(E e)
、addLast(E e)
、removeFirst()
、removeLast()
),时间复杂度为 O(1),如果是要在指定位置i
插入和删除元素的话(add(int index, E element)
,remove(Object o)
,remove(int index)
), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。
- 是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
(实现了RandomAccess
接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。 - 内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
ArrayList 核心源码解读
这里以 JDK1.8为例,分析一下 ArrayList
的底层源码。
package java.util; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.UnaryOperator; public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8683452581122892189L; /** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; /** * 空数组(用于空实例)。 */ private static final Object[] EMPTY_ELEMENTDATA = {}; //用于默认大小空实例的共享空数组实例。 //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。 private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** * 保存ArrayList数据的数组 */ transient Object[] elementData; // non-private to simplify nested class access /** * ArrayList 所包含的元素个数 */ private int size; /** * 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小) */ public ArrayList(int initialCapacity) { if (initialCapacity > 0) { //如果传入的参数大于0,创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { //如果传入的参数等于0,创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else { //其他情况,抛出异常 throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } /** *默认无参构造函数 *DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10 */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。 */ public ArrayList(Collection<? extends E> c) { //将指定集合转换为数组 elementData = c.toArray(); //如果elementData数组的长度不为0 if ((size = elementData.length) != 0) { // 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断) if (elementData.getClass() != Object[].class) //将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组 elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // 其他情况,用空数组代替 this.elementData = EMPTY_ELEMENTDATA; } } /** * 修改这个ArrayList实例的容量是列表的当前大小。 应用程序可以使用此操作来最小化ArrayList实例的存储。 */ public void trimToSize() { modCount++; if (size < elementData.length) { elementData = (size == 0) ? EMPTY_ELEMENTDATA : Arrays.copyOf(elementData, size); } } //下面是ArrayList的扩容机制 //ArrayList的扩容机制提高了性能,如果每次只扩充一个, //那么频繁的插入会导致频繁的拷贝,降低性能,而ArrayList的扩容机制避免了这种情况。 /** * 如有必要,增加此ArrayList实例的容量,以确保它至少能容纳元素的数量 * @param minCapacity 所需的最小容量 */ public void ensureCapacity(int minCapacity) { //如果是true,minExpand的值为0,如果是false,minExpand的值为10 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.得到最小扩容量 //2.通过最小容量扩容 private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取“默认的容量”和“传入参数”两者之间的最大值 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } //判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); } /** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity >> 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity < 0) newCapacity = minCapacity; //再检查新容量是否超出了ArrayList所定义的最大容量, //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE, //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。 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); } //比较minCapacity和 MAX_ARRAY_SIZE private static int hugeCapacity(int minCapacity) { if (minCapacity < 0) // overflow throw new OutOfMemoryError(); return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; } /** *返回此列表中的元素数。 */ public int size() { return size; } /** * 如果此列表不包含元素,则返回 true 。 */ public boolean isEmpty() { //注意=和==的区别 return size == 0; } /** * 如果此列表包含指定的元素,则返回true 。 */ public boolean contains(Object o) { //indexOf()方法:返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 return indexOf(o) >= 0; } /** *返回此列表中指定元素的首次出现的索引,如果此列表不包含此元素,则为-1 */ public int indexOf(Object o) { if (o == null) { for (int i = 0; i < size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i < size; i++) //equals()方法比较 if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此列表中指定元素的最后一次出现的索引,如果此列表不包含元素,则返回-1。. */ public int lastIndexOf(Object o) { if (o == null) { for (int i = size-1; i >= 0; i--) if (elementData[i]==null) return i; } else { for (int i = size-1; i >= 0; i--) if (o.equals(elementData[i])) return i; } return -1; } /** * 返回此ArrayList实例的浅拷贝。 (元素本身不被复制。) */ public Object clone() { try { ArrayList<?> v = (ArrayList<?>) super.clone(); //Arrays.copyOf功能是实现数组的复制,返回复制后的数组。参数是被复制的数组和复制的长度 v.elementData = Arrays.copyOf(elementData, size); v.modCount = 0; return v; } catch (CloneNotSupportedException e) { // 这不应该发生,因为我们是可以克隆的 throw new InternalError(e); } } /** *以正确的顺序(从第一个到最后一个元素)返回一个包含此列表中所有元素的数组。 *返回的数组将是“安全的”,因为该列表不保留对它的引用。 (换句话说,这个方法必须分配一个新的数组)。 *因此,调用者可以自由地修改返回的数组。 此方法充当基于阵列和基于集合的API之间的桥梁。 */ public Object[] toArray() { return Arrays.copyOf(elementData, size); } /** * 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); *返回的数组的运行时类型是指定数组的运行时类型。 如果列表适合指定的数组,则返回其中。 *否则,将为指定数组的运行时类型和此列表的大小分配一个新数组。 *如果列表适用于指定的数组,其余空间(即数组的列表数量多于此元素),则紧跟在集合结束后的数组中的元素设置为null 。 *(这仅在调用者知道列表不包含任何空元素的情况下才能确定列表的长度。) */ @SuppressWarnings("unchecked") public <T> T[] toArray(T[] a) { if (a.length < size) // 新建一个运行时类型的数组,但是ArrayList数组的内容 return (T[]) Arrays.copyOf(elementData, size, a.getClass()); //调用System提供的arraycopy()方法实现数组之间的复制 System.arraycopy(elementData, 0, a, 0, size); if (a.length > size) a[size] = null; return a; } // Positional Access Operations @SuppressWarnings("unchecked") E elementData(int index) { return (E) elementData[index]; } /** * 返回此列表中指定位置的元素。 */ public E get(int index) { rangeCheck(index); return elementData(index); } /** * 用指定的元素替换此列表中指定位置的元素。 */ public E set(int index, E element) { //对index进行界限检查 rangeCheck(index); E oldValue = elementData(index); elementData[index] = element; //返回原来在这个位置的元素 return oldValue; } /** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; } /** * 在此列表中的指定位置插入指定的元素。 *先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; *再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()这个实现数组之间复制的方法一定要看一下,下面就用到了arraycopy()方法实现数组自己复制自己 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; } /** * 删除该列表中指定位置的元素。 将任何后续元素移动到左侧(从其索引中减去一个元素)。 */ public E remove(int index) { rangeCheck(index); modCount++; E oldValue = elementData(index); 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 //从列表中删除的元素 return oldValue; } /** * 从列表中删除指定元素的第一个出现(如果存在)。 如果列表不包含该元素,则它不会更改。 *返回true,如果此列表包含指定的元素 */ 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; } /* * Private remove method that skips bounds checking and does not * return the value removed. */ 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 } /** * 从列表中删除所有元素。 */ public void clear() { modCount++; // 把数组中所有的元素的值设为null for (int i = 0; i < size; i++) elementData[i] = null; size = 0; } /** * 按指定集合的Iterator返回的顺序将指定集合中的所有元素追加到此列表的末尾。 */ public boolean addAll(Collection<? extends E> c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; } /** * 将指定集合中的所有元素插入到此列表中,从指定的位置开始。 */ public boolean addAll(int index, Collection<? extends E> c) { rangeCheckForAdd(index); Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); // Increments modCount int numMoved = size - index; if (numMoved > 0) System.arraycopy(elementData, index, elementData, index + numNew, numMoved); System.arraycopy(a, 0, elementData, index, numNew); size += numNew; return numNew != 0; } /** * 从此列表中删除所有索引为fromIndex (含)和toIndex之间的元素。 *将任何后续元素移动到左侧(减少其索引)。 */ protected void removeRange(int fromIndex, int toIndex) { modCount++; int numMoved = size - toIndex; System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved); // clear to let GC do its work int newSize = size - (toIndex-fromIndex); for (int i = newSize; i < size; i++) { elementData[i] = null; } size = newSize; } /** * 检查给定的索引是否在范围内。 */ private void rangeCheck(int index) { if (index >= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * add和addAll使用的rangeCheck的一个版本 */ private void rangeCheckForAdd(int index) { if (index > size || index < 0) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); } /** * 返回IndexOutOfBoundsException细节信息 */ private String outOfBoundsMsg(int index) { return "Index: "+index+", Size: "+size; } /** * 从此列表中删除指定集合中包含的所有元素。 */ public boolean removeAll(Collection<?> c) { Objects.requireNonNull(c); //如果此列表被修改则返回true return batchRemove(c, false); } /** * 仅保留此列表中包含在指定集合中的元素。 *换句话说,从此列表中删除其中不包含在指定集合中的所有元素。 */ public boolean retainAll(Collection<?> c) { Objects.requireNonNull(c); return batchRemove(c, true); } /** * 从列表中的指定位置开始,返回列表中的元素(按正确顺序)的列表迭代器。 *指定的索引表示初始调用将返回的第一个元素为next 。 初始调用previous将返回指定索引减1的元素。 *返回的列表迭代器是fail-fast 。 */ public ListIterator<E> listIterator(int index) { if (index < 0 || index > size) throw new IndexOutOfBoundsException("Index: "+index); return new ListItr(index); } /** *返回列表中的列表迭代器(按适当的顺序)。 *返回的列表迭代器是fail-fast 。 */ public ListIterator<E> listIterator() { return new ListItr(0); } /** *以正确的顺序返回该列表中的元素的迭代器。 *返回的迭代器是fail-fast 。 */ public Iterator<E> iterator() { return new Itr(); }
ArrayList 扩容机制分析
ArrayList 的构造函数说起
(JDK8)ArrayList 有三种方式来初始化,构造方法源码如下:
/** * 默认初始容量大小 */ private static final int DEFAULT_CAPACITY = 10; private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; /** *默认构造函数,使用初始容量10构造一个空列表(无参数构造) */ public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } /** * 带初始容量参数的构造函数。(用户自己指定容量) */ public ArrayList(int initialCapacity) { if (initialCapacity > 0) {//初始容量大于0 //创建initialCapacity大小的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) {//初始容量等于0 //创建空数组 this.elementData = EMPTY_ELEMENTDATA; } else {//初始容量小于0,抛出异常 throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } /** *构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回 *如果指定的集合为null,throws NullPointerException。 */ public ArrayList(Collection<? extends E> c) { elementData = c.toArray(); if ((size = elementData.length) != 0) { // c.toArray might (incorrectly) not return Object[] (see 6260652) if (elementData.getClass() != Object[].class) elementData = Arrays.copyOf(elementData, size, Object[].class); } else { // replace with empty array. this.elementData = EMPTY_ELEMENTDATA; } }
以无参数构造方法创建 ArrayList
时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。 下面在我们分析 ArrayList 扩容时会讲到这一点内容!
补充:JDK6 new 无参构造的
ArrayList
对象时,直接创建了长度是 10 的Object[]
数组 elementData 。
一步一步分析 ArrayList 扩容机制
这里以无参构造函数创建的 ArrayList 为例分析
先来看 add
方法
/** * 将指定的元素追加到此列表的末尾。 */ public boolean add(E e) { //添加元素之前,先调用ensureCapacityInternal方法 ensureCapacityInternal(size + 1); // Increments modCount!! //这里看到ArrayList添加元素的实质就相当于为数组赋值 elementData[size++] = e; return true; }
注意:JDK11 移除了
ensureCapacityInternal()
和ensureExplicitCapacity()
方法
再来看看 ensureCapacityInternal()
方法
(JDK7)可以看到 add
方法 首先调用了ensureCapacityInternal(size + 1)
//得到最小扩容量 private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 获取默认的容量和传入参数的较大值 minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); }
当 要 add 进第 1 个元素时,minCapacity 为 1,在 Math.max()方法比较后,minCapacity 为 10。
此处和后续 JDK8 代码格式化略有不同,核心代码基本一样。
ensureExplicitCapacity()
方法
如果调用 ensureCapacityInternal()
方法就一定会进入(执行)这个方法,下面我们来研究一下这个方法的源码!
//判断是否需要扩容 private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) //调用grow方法进行扩容,调用此方法代表已经开始扩容了 grow(minCapacity); }
我们来仔细分析一下:
- 当我们要 add 进第 1 个元素到 ArrayList 时,elementData.length 为 0 (因为还是一个空的 list),因为执行了
ensureCapacityInternal()
方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length > 0
成立,所以会进入grow(minCapacity)
方法。 - 当 add 第 2 个元素时,minCapacity 为 2,此时 elementData.length(容量)在添加第一个元素后扩容成 10 了。此时,
minCapacity - elementData.length > 0
不成立,所以不会进入 (执行)grow(minCapacity)
方法。 - 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。
直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法进行扩容。
grow()
方法
/** * 要分配的最大数组大小 */ private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; /** * ArrayList扩容的核心方法。 */ private void grow(int minCapacity) { // oldCapacity为旧容量,newCapacity为新容量 int oldCapacity = elementData.length; //将oldCapacity 右移一位,其效果相当于oldCapacity /2, //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍, int newCapacity = oldCapacity + (oldCapacity >> 1); //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量, if (newCapacity - minCapacity < 0) newCapacity = minCapacity; // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE, //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。 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); }
int newCapacity = oldCapacity + (oldCapacity >> 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.
">>"(移位运算符):>>1 右移一位相当于除 2,右移 n 位相当于除以 2 的 n 次方。这里 oldCapacity 明显右移了 1 位所以相当于 oldCapacity /2。对于大数据的 2 进制运算,位移运算符比那些普通运算符的运算要快很多,因为程序仅仅移动一下而已,不去计算,这样提高了效率,节省了资源
我们再来通过例子探究一下grow()
方法:
- 当 add 第 1 个元素时,oldCapacity 为 0,经比较后第一个 if 判断成立,newCapacity = minCapacity(为 10)。但是第二个 if 判断不会成立,即 newCapacity 不比 MAX_ARRAY_SIZE 大,则不会进入
hugeCapacity
方法。数组容量为 10,add 方法中 return true,size 增为 1。 - 当 add 第 11 个元素进入 grow 方法时,newCapacity 为 15,比 minCapacity(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 hugeCapacity 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。
- 以此类推······
这里补充一点比较重要,但是容易被忽视掉的知识点:
- java 中的
length
属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性. - java 中的
length()
方法是针对字符串说的,如果想看这个字符串的长度则用到length()
这个方法. - java 中的
size()
方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看!
hugeCapacity()
方法。
从上面 grow()
方法源码我们知道:如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity()
方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果 minCapacity 大于最大容量,则新容量则为Integer.MAX_VALUE
,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8
。
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; }
System.arraycopy()
和 Arrays.copyOf()
方法
阅读源码的话,我们就会发现 ArrayList 中大量调用了这两个方法。比如:我们上面讲的扩容操作以及add(int index, E element)
、toArray()
等方法中都用到了该方法!
System.arraycopy()
方法
源码:
// 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义 /** * 复制数组 * @param src 源数组 * @param srcPos 源数组中的起始位置 * @param dest 目标数组 * @param destPos 目标数组中的起始位置 * @param length 要复制的数组元素的数量 */ public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
场景:
/** * 在此列表中的指定位置插入指定的元素。 *先调用 rangeCheckForAdd 对index进行界限检查;然后调用 ensureCapacityInternal 方法保证capacity足够大; *再将从index开始之后的所有成员后移一个位置;将element插入index位置;最后size加1。 */ public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! //arraycopy()方法实现数组自己复制自己 //elementData:源数组;index:源数组中的起始位置;elementData:目标数组;index + 1:目标数组中的起始位置; size - index:要复制的数组元素的数量; System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; }
我们写一个简单的方法测试以下:
public class ArraycopyTest { public static void main(String[] args) { // TODO Auto-generated method stub int[] a = new int[10]; a[0] = 0; a[1] = 1; a[2] = 2; a[3] = 3; System.arraycopy(a, 2, a, 3, 3); a[2]=99; for (int i = 0; i < a.length; i++) { System.out.print(a[i] + " "); } } }
结果:
0 1 99 2 3 0 0 0 0 0
Arrays.copyOf()
方法
源码:
public static int[] copyOf(int[] original, int newLength) { // 申请一个新的数组 int[] copy = new int[newLength]; // 调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组 System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; }
场景:
/** 以正确的顺序返回一个包含此列表中所有元素的数组(从第一个到最后一个元素); 返回的数组的运行时类型是指定数组的运行时类型。 */ public Object[] toArray() { //elementData:要复制的数组;size:要复制的长度 return Arrays.copyOf(elementData, size); }
个人觉得使用 Arrays.copyOf()
方法主要是为了给原有数组扩容,测试代码如下:
public class ArrayscopyOfTest { public static void main(String[] args) { int[] a = new int[3]; a[0] = 0; a[1] = 1; a[2] = 2; int[] b = Arrays.copyOf(a, 10); System.out.println("b.length"+b.length); } }
结果:
10
两者联系和区别
联系:
看两者源代码可以发现 copyOf()
内部实际调用了 System.arraycopy()
方法
区别:
arraycopy()
需要目标数组,将原数组拷贝到你自己定义的数组里或者原数组,而且可以选择拷贝的起点和长度以及放入新数组中的位置 copyOf()
是系统自动在内部新建一个数组,并返回该数组。
ensureCapacity
方法
ArrayList
源码中有一个 ensureCapacity
方法不知道大家注意到没有,这个方法 ArrayList
内部没有被调用过,所以很显然是提供给用户调用的,那么这个方法有什么作用呢?
/** 如有必要,增加此 ArrayList 实例的容量,以确保它至少可以容纳由minimum capacity参数指定的元素数。 * * @param minCapacity 所需的最小容量 */ 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); } }
理论上来说,最好在向 ArrayList
添加大量元素之前用 ensureCapacity
方法,以减少增量重新分配的次数
我们通过下面的代码实际测试以下这个方法的效果:
public class EnsureCapacityTest { public static void main(String[] args) { ArrayList<Object> list = new ArrayList<Object>(); final int N = 10000000; long startTime = System.currentTimeMillis(); for (int i = 0; i < N; i++) { list.add(i); } long endTime = System.currentTimeMillis(); System.out.println("使用ensureCapacity方法前:"+(endTime - startTime)); } }
运行结果:
使用ensureCapacity方法前:2158
public class EnsureCapacityTest { public static void main(String[] args) { ArrayList<Object> list = new ArrayList<Object>(); final int N = 10000000; long startTime1 = System.currentTimeMillis(); list.ensureCapacity(N); for (int i = 0; i < N; i++) { list.add(i); } long endTime1 = System.currentTimeMillis(); System.out.println("使用ensureCapacity方法后:"+(endTime1 - startTime1)); } }
运行结果:
使用ensureCapacity方法后:1773
通过运行结果,我们可以看出向 ArrayList
添加大量元素之前使用ensureCapacity
方法可以提升性能。不过,这个性能差距几乎可以忽略不计。而且,实际项目根本也不可能往 ArrayList
里面添加这么多元素。
LinkedList 源码分析
LinkedList 简介
LinkedList
是一个基于双向链表实现的集合类,经常被拿来和 ArrayList
做比较。
不过,我们在项目中一般是不会使用到 LinkedList
的,需要用到 LinkedList
的场景几乎都可以使用 ArrayList
来代替,并且,性能通常会更好!就连 LinkedList
的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
。
另外,不要下意识地认为 LinkedList
作为链表就最适合元素增删的场景。我在上面也说了,LinkedList
仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的平均时间复杂度都是 O(n) 。
LinkedList 插入和删除元素的时间复杂度?
- 头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
- 指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
LinkedList 为什么不能实现 RandomAccess 接口?
RandomAccess
是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList
底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess
接口。
LinkedList 源码分析
这里以 JDK1.8 为例,分析一下 LinkedList
的底层核心源码。
LinkedList
的类定义如下:
public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, java.io.Serializable { //... }
LinkedList
继承了 AbstractSequentialList
,而 AbstractSequentialList
又继承于 AbstractList
。
阅读过 ArrayList
的源码我们就知道,ArrayList
同样继承了 AbstractList
, 所以 LinkedList
会有大部分方法和 ArrayList
相似。
LinkedList
实现了以下接口:
List
: 表明它是一个列表,支持添加、删除、查找等操作,并且可以通过下标进行访问。Deque
:继承自Queue
接口,具有双端队列的特性,支持从两端插入和删除元素,方便实现栈和队列等数据结构。需要注意,Deque
的发音为 "deck" [dɛk],这个大部分人都会读错。Cloneable
:表明它具有拷贝能力,可以进行深拷贝或浅拷贝操作。Serializable
: 表明它可以进行序列化操作,也就是可以将对象转换为字节流进行持久化存储或网络传输,非常方便。
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; } }
初始化
LinkedList
中有一个无参构造函数和一个有参构造函数。
// 创建一个空的链表对象 public LinkedList() { } // 接收一个集合类型作为参数,会创建一个与传入集合相同元素的链表对象 public LinkedList(Collection<? extends E> c) { this(); addAll(c); }
插入元素
LinkedList
除了实现了 List
接口相关方法,还实现了 Deque
接口的很多方法,所以我们有很多种方式插入元素。
我们这里以 List
接口中相关的插入方法为例进行源码讲解,对应的是add()
方法。
add()
方法有两个版本:
add(E e)
:用于在LinkedList
的尾部插入元素,即将新元素作为链表的最后一个元素,时间复杂度为 O(1)。add(int index, E element)
:用于在指定位置插入元素。这种插入方式需要先移动到指定位置,再修改指定节点的指针完成插入/删除,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
// 在链表尾部插入元素 public boolean add(E e) { linkLast(e); return true; } // 在链表指定位置插入元素 public void add(int index, E element) { // 下标越界检查 checkPositionIndex(index); // 判断 index 是不是链表尾部位置 if (index == size) // 如果是就直接调用 linkLast 方法将元素节点插入链表尾部即可 linkLast(element); else // 如果不是则调用 linkBefore 方法将其插入指定元素之前 linkBefore(element, node(index)); } // 将元素节点插入到链表尾部 void linkLast(E e) { // 将最后一个元素赋值(引用传递)给节点 l final Node<E> l = last; // 创建节点,并指定节点前驱为链表尾节点 last,后继引用为空 final Node<E> newNode = new Node<>(l, e, null); // 将 last 引用指向新节点 last = newNode; // 判断尾节点是否为空 // 如果 l 是null 意味着这是第一次添加元素 if (l == null) // 如果是第一次添加,将first赋值为新节点,此时链表只有一个元素 first = newNode; else // 如果不是第一次添加,将新节点赋值给l(添加前的最后一个元素)的next l.next = newNode; size++; modCount++; } // 在指定元素之前插入元素 void linkBefore(E e, Node<E> succ) { // assert succ != null;断言 succ不为 null // 定义一个节点元素保存 succ 的 prev 引用,也就是它的前一节点信息 final Node<E> pred = succ.prev; // 初始化节点,并指明前驱和后继节点 final Node<E> newNode = new Node<>(pred, e, succ); // 将 succ 节点前驱引用 prev 指向新节点 succ.prev = newNode; // 判断尾节点是否为空,为空表示当前链表还没有节点 if (pred == null) first = newNode; else // succ 节点前驱的后继引用指向新节点 pred.next = newNode; size++; modCount++; }
获取元素
LinkedList
获取元素相关的方法一共有 3 个:
getFirst()
:获取链表的第一个元素。getLast()
:获取链表的最后一个元素。get(int index)
:获取链表指定位置的元素。
// 获取链表的第一个元素 public E getFirst() { final Node<E> f = first; if (f == null) throw new NoSuchElementException(); return f.item; } // 获取链表的最后一个元素 public E getLast() { final Node<E> l = last; if (l == null) throw new NoSuchElementException(); return l.item; } // 获取链表指定位置的元素 public E get(int index) { // 下标越界检查,如果越界就抛异常 checkElementIndex(index); // 返回链表中对应下标的元素 return node(index).item; }
这里的核心在于 node(int index)
这个方法:
// 返回指定下标的非空节点 Node<E> node(int index) { // 断言下标未越界 // assert isElementIndex(index); // 如果index小于size的二分之一 从前开始查找(向后查找) 反之向前查找 if (index < (size >> 1)) { Node<E> x = first; // 遍历,循环向后查找,直至 i == index for (int i = 0; i < index; i++) x = x.next; return x; } else { Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } }
get(int index)
或 remove(int index)
等方法内部都调用了该方法来获取对应的节点。
从这个方法的源码可以看出,该方法通过比较索引值与链表 size 的一半大小来确定从链表头还是尾开始遍历。如果索引值小于 size 的一半,就从链表头开始遍历,反之从链表尾开始遍历。这样可以在较短的时间内找到目标节点,充分利用了双向链表的特性来提高效率。
删除元素
LinkedList
删除元素相关的方法一共有 5 个:
removeFirst()
:删除并返回链表的第一个元素。removeLast()
:删除并返回链表的最后一个元素。remove(E e)
:删除链表中首次出现的指定元素,如果不存在该元素则返回 false。remove(int index)
:删除指定索引处的元素,并返回该元素的值。void clear()
:移除此链表中的所有元素。
// 删除并返回链表的第一个元素 public E removeFirst() { final Node<E> f = first; if (f == null) throw new NoSuchElementException(); return unlinkFirst(f); } // 删除并返回链表的最后一个元素 public E removeLast() { final Node<E> l = last; if (l == null) throw new NoSuchElementException(); return unlinkLast(l); } // 删除链表中首次出现的指定元素,如果不存在该元素则返回 fals public boolean remove(Object o) { // 如果指定元素为 null,遍历链表找到第一个为 null 的元素进行删除 if (o == null) { for (Node<E> x = first; x != null; x = x.next) { if (x.item == null) { unlink(x); return true; } } } else { // 如果不为 null ,遍历链表找到要删除的节点 for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) { unlink(x); return true; } } } return false; } // 删除链表指定位置的元素 public E remove(int index) { // 下标越界检查,如果越界就抛异常 checkElementIndex(index); return unlink(node(index)); }
这里的核心在于 unlink(Node<E> x)
这个方法:
E unlink(Node<E> x) { // 断言 x 不为 null // assert x != null; // 获取当前节点(也就是待删除节点)的元素 final E element = x.item; // 获取当前节点的下一个节点 final Node<E> next = x.next; // 获取当前节点的前一个节点 final Node<E> prev = x.prev; // 如果前一个节点为空,则说明当前节点是头节点 if (prev == null) { // 直接让链表头指向当前节点的下一个节点 first = next; } else { // 如果前一个节点不为空 // 将前一个节点的 next 指针指向当前节点的下一个节点 prev.next = next; // 将当前节点的 prev 指针置为 null,,方便 GC 回收 x.prev = null; } // 如果下一个节点为空,则说明当前节点是尾节点 if (next == null) { // 直接让链表尾指向当前节点的前一个节点 last = prev; } else { // 如果下一个节点不为空 // 将下一个节点的 prev 指针指向当前节点的前一个节点 next.prev = prev; // 将当前节点的 next 指针置为 null,方便 GC 回收 x.next = null; } // 将当前节点元素置为 null,方便 GC 回收 x.item = null; size--; modCount++; return element; }
unlink()
方法的逻辑如下:
- 首先获取待删除节点 x 的前驱和后继节点;
- 判断待删除节点是否为头节点或尾节点:
- 如果 x 是头节点,则将 first 指向 x 的后继节点 next
- 如果 x 是尾节点,则将 last 指向 x 的前驱节点 prev
- 如果 x 不是头节点也不是尾节点,执行下一步操作
- 将待删除节点 x 的前驱的后继指向待删除节点的后继 next,断开 x 和 x.prev 之间的链接;
- 将待删除节点 x 的后继的前驱指向待删除节点的前驱 prev,断开 x 和 x.next 之间的链接;
- 将待删除节点 x 的元素置空,修改链表长度。
可以参考下图理解(图源:LinkedList 源码分析(JDK 1.8)):
遍历链表
推荐使用for-each
循环来遍历 LinkedList
中的元素, for-each
循环最终会转换成迭代器形式。
LinkedList<String> list = new LinkedList<>(); list.add("apple"); list.add("banana"); list.add("pear"); for (String fruit : list) { System.out.println(fruit); }
LinkedList
的遍历的核心就是它的迭代器的实现。
// 双向迭代器 private class ListItr implements ListIterator<E> { // 表示上一次调用 next() 或 previous() 方法时经过的节点; private Node<E> lastReturned; // 表示下一个要遍历的节点; private Node<E> next; // 表示下一个要遍历的节点的下标,也就是当前节点的后继节点的下标; private int nextIndex; // 表示当前遍历期望的修改计数值,用于和 LinkedList 的 modCount 比较,判断链表是否被其他线程修改过。 private int expectedModCount = modCount; ………… }
下面我们对迭代器 ListItr
中的核心方法进行详细介绍。
我们先来看下从头到尾方向的迭代:
// 判断还有没有下一个节点 public boolean hasNext() { // 判断下一个节点的下标是否小于链表的大小,如果是则表示还有下一个元素可以遍历 return nextIndex < size; } // 获取下一个节点 public E next() { // 检查在迭代过程中链表是否被修改过 checkForComodification(); // 判断是否还有下一个节点可以遍历,如果没有则抛出 NoSuchElementException 异常 if (!hasNext()) throw new NoSuchElementException(); // 将 lastReturned 指向当前节点 lastReturned = next; // 将 next 指向下一个节点 next = next.next; nextIndex++; return lastReturned.item; }
再来看一下从尾到头方向的迭代:
// 判断是否还有前一个节点 public boolean hasPrevious() { return nextIndex > 0; } // 获取前一个节点 public E previous() { // 检查是否在迭代过程中链表被修改 checkForComodification(); // 如果没有前一个节点,则抛出异常 if (!hasPrevious()) throw new NoSuchElementException(); // 将 lastReturned 和 next 指针指向上一个节点 lastReturned = next = (next == null) ? last : next.prev; nextIndex--; return lastReturned.item; }
如果需要删除或插入元素,也可以使用迭代器进行操作。
LinkedList<String> list = new LinkedList<>(); list.add("apple"); list.add(null); list.add("banana"); // Collection 接口的 removeIf 方法底层依然是基于迭代器 list.removeIf(Objects::isNull); for (String fruit : list) { System.out.println(fruit); }
迭代器对应的移除元素的方法如下:
// 从列表中删除上次被返回的元素 public void remove() { // 检查是否在迭代过程中链表被修改 checkForComodification(); // 如果上次返回的节点为空,则抛出异常 if (lastReturned == null) throw new IllegalStateException(); // 获取当前节点的下一个节点 Node<E> lastNext = lastReturned.next; // 从链表中删除上次返回的节点 unlink(lastReturned); // 修改指针 if (next == lastReturned) next = lastNext; else nextIndex--; // 将上次返回的节点引用置为 null,方便 GC 回收 lastReturned = null; expectedModCount++; }
LinkedList 常用方法测试
代码:
// 创建 LinkedList 对象 LinkedList<String> list = new LinkedList<>(); // 添加元素到链表末尾 list.add("apple"); list.add("banana"); list.add("pear"); System.out.println("链表内容:" + list); // 在指定位置插入元素 list.add(1, "orange"); System.out.println("链表内容:" + list); // 获取指定位置的元素 String fruit = list.get(2); System.out.println("索引为 2 的元素:" + fruit); // 修改指定位置的元素 list.set(3, "grape"); System.out.println("链表内容:" + list); // 删除指定位置的元素 list.remove(0); System.out.println("链表内容:" + list); // 删除第一个出现的指定元素 list.remove("banana"); System.out.println("链表内容:" + list); // 获取链表的长度 int size = list.size(); System.out.println("链表长度:" + size); // 清空链表 list.clear(); System.out.println("清空后的链表:" + list);
输出:
索引为 2 的元素:banana 链表内容:[apple, orange, banana, grape] 链表内容:[orange, banana, grape] 链表内容:[orange, grape] 链表长度:2 清空后的链表:[]
HashMap 源码分析
HashMap 简介
HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap
总是使用 2 的幂作为哈希表的大小。
底层数据结构分析
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 次。
所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8 之后
相比于之前的版本,JDK1.8 以后在解决哈希冲突时有了较大的变化。
当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()
方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize()
方法对数组扩容。相关源码这里就不贴了,重点关注 treeifyBin()
方法即可!
类的属性:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { // 序列号 private static final long serialVersionUID = 362498820763181265L; // 默认的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; // 默认的负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 当桶(bucket)上的结点数大于等于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8; // 当桶(bucket)上的结点数小于等于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; // 桶中结构转化为红黑树对应的table的最小容量 static final int MIN_TREEIFY_CAPACITY = 64; // 存储元素的数组,总是2的幂次倍 transient Node<k,v>[] table; // 存放具体元素的集 transient Set<map.entry<k,v>> entrySet; // 存放元素的个数,注意这个不等于数组的长度。 transient int size; // 每次扩容和更改map结构的计数器 transient int modCount; // 阈值(容量*负载因子) 当实际大小超过阈值时,会进行扩容 int threshold; // 负载因子 final float loadFactor; }
-
loadFactor 负载因子
loadFactor 负载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。
loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量超过了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
-
threshold
threshold = capacity * loadFactor,当 Size>threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。
Node 节点类源码:
// 继承自 Map.Entry<K,V> static class Node<K,V> implements Map.Entry<K,V> { final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较 final K key;//键 V value;//值 // 指向下一个节点 Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } // 重写hashCode()方法 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 重写 equals() 方法 public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
树节点类源码:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // 父 TreeNode<K,V> left; // 左 TreeNode<K,V> right; // 右 TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; // 判断颜色 TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } // 返回根节点 final TreeNode<K,V> root() { for (TreeNode<K,V> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; }
HashMap 源码分析
构造方法
HashMap 中有四个构造方法,它们分别如下:
// 默认构造函数。 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } // 包含另一个“Map”的构造函数 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false);//下面会分析到这个方法 } // 指定“容量大小”的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 指定“容量大小”和“负载因子”的构造函数 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; // 初始容量暂时存放到 threshold ,在resize中再赋值给 newCap 进行table初始化 this.threshold = tableSizeFor(initialCapacity); }
值得注意的是上述四个构造方法中,都初始化了负载因子 loadFactor,由于HashMap中没有 capacity 这样的字段,即使指定了初始化容量 initialCapacity ,也只是通过 tableSizeFor 将其扩容到与 initialCapacity 最接近的2的幂次方大小,然后暂时赋值给 threshold ,后续通过 resize 方法将 threshold 赋值给 newCap 进行 table 的初始化。
putMapEntries 方法:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { // 判断table是否已经初始化 if (table == null) { // pre-size /* * 未初始化,s为m的实际元素个数,ft=s/loadFactor => s=ft*loadFactor, 跟我们前面提到的 * 阈值=容量*负载因子 是不是很像,是的,ft指的是要添加s个元素所需的最小的容量 */ float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); /* * 根据构造函数可知,table未初始化,threshold实际上是存放的初始化容量,如果添加s个元素所 * 需的最小容量大于初始化容量,则将最小容量扩容为最接近的2的幂次方大小作为初始化。 * 注意这里不是初始化阈值 */ if (t > threshold) threshold = tableSizeFor(t); } // 已初始化,并且m元素个数大于阈值,进行扩容处理 else if (s > threshold) resize(); // 将m中的所有元素添加至HashMap中,如果table未初始化,putVal中会调用resize初始化或扩容 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } }
put 方法
HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。
对 putVal 方法添加元素的分析如下:
- 如果定位到的数组位置没有元素 就直接插入。
- 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)
将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。
说明:上图有两个小问题:
- 直接覆盖之后应该就会 return,不会有后续操作。参考 JDK8 HashMap.java 658 行(issue#608)。
- 当链表长度大于阈值(默认为 8)并且 HashMap 数组长度超过 64 的时候才会执行链表转红黑树的操作,否则就只是对数组扩容。参考 HashMap 的
treeifyBin()
方法([issue#1087)。
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // table未初始化或者长度为0,进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素(处理hash冲突) else { Node<K,V> e; K k; //快速判断第一个节点table[i]的key是否与插入的key一样,若相同就直接使用插入的值p替换掉旧的值e。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 判断插入的是否是红黑树节点 else if (p instanceof TreeNode) // 放入树中 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 不是红黑树节点则说明为链表结点 else { // 在链表最末插入结点 for (int binCount = 0; ; ++binCount) { // 到达链表的尾部 if ((e = p.next) == null) { // 在尾部插入新结点 p.next = newNode(hash, key, value, null); // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法 // 这个方法会根据 HashMap 数组来决定是否转换为红黑树。 // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 跳出循环 break; } // 判断链表中结点的key值与插入的元素的key值是否相等 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 相等,跳出循环 break; // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } // 表示在桶中找到key值、hash值与插入元素相等的结点 if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 结构性修改 ++modCount; // 实际大小大于阈值则扩容 if (++size > threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; }
我们再来对比一下 JDK1.7 put 方法的代码
对于 put 方法的分析如下:
- ① 如果定位到的数组位置没有元素 就直接插入。
- ② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。
public V put(K key, V value) if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { // 先遍历 Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); // 再插入 return null; }
get 方法
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 数组元素相等 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // 桶中不止一个节点 if ((e = first.next) != null) { // 在树中get if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 在链表中get do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
resize 方法
进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。resize方法实际上是将 table 初始化和 table 扩容 进行了整合,底层的行为都是给 table 赋值一个新的数组。
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; } // 没超过最大值,就扩充为原来的2倍 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 // 创建对象时初始化容量大小放在threshold中,此时只需要将其作为新的数组容量 newCap = oldThr; else { // signifies using defaults 无参构造函数创建的对象在这里计算容量和阈值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { // 创建时指定了初始化容量或者负载因子,在这里进行阈值初始化, // 或者扩容前的旧容量小于16,在这里计算新的resize上限 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) { // 把每个bucket都移动到新的buckets中 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) // 将红黑树拆分成2棵子树,拆分后的子树节点数小于等于6,则将树转化成链表 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { 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; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
HashMap 常用方法测试
package map; import java.util.Collection; import java.util.HashMap; import java.util.Set; public class HashMapDemo { public static void main(String[] args) { HashMap<String, String> map = new HashMap<String, String>(); // 键不能重复,值可以重复 map.put("san", "张三"); map.put("si", "李四"); map.put("wu", "王五"); map.put("wang", "老王"); map.put("wang", "老王2");// 老王被覆盖 map.put("lao", "老王"); System.out.println("-------直接输出hashmap:-------"); System.out.println(map); /** * 遍历HashMap */ // 1.获取Map中的所有键 System.out.println("-------foreach获取Map中所有的键:------"); Set<String> keys = map.keySet(); for (String key : keys) { System.out.print(key+" "); } System.out.println();//换行 // 2.获取Map中所有值 System.out.println("-------foreach获取Map中所有的值:------"); Collection<String> values = map.values(); for (String value : values) { System.out.print(value+" "); } System.out.println();//换行 // 3.得到key的值的同时得到key所对应的值 System.out.println("-------得到key的值的同时得到key所对应的值:-------"); Set<String> keys2 = map.keySet(); for (String key : keys2) { System.out.print(key + ":" + map.get(key)+" "); } /** * 如果既要遍历key又要value,那么建议这种方式,因为如果先获取keySet然后再执行map.get(key),map内部会执行两次遍历。 * 一次是在获取keySet的时候,一次是在遍历所有key的时候。 */ // 当我调用put(key,value)方法的时候,首先会把key和value封装到 // Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取 // map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来 // 调用Entry对象中的getKey()和getValue()方法就能获取键值对了 Set<java.util.Map.Entry<String, String>> entrys = map.entrySet(); for (java.util.Map.Entry<String, String> entry : entrys) { System.out.println(entry.getKey() + "--" + entry.getValue()); } /** * HashMap其他常用方法 */ System.out.println("after map.size():"+map.size()); System.out.println("after map.isEmpty():"+map.isEmpty()); System.out.println(map.remove("san")); System.out.println("after map.remove():"+map); System.out.println("after map.get(si):"+map.get("si")); System.out.println("after map.containsKey(si):"+map.containsKey("si")); System.out.println("after containsValue(李四):"+map.containsValue("李四")); System.out.println(map.replace("si", "李四2")); System.out.println("after map.replace(si, 李四2):"+map); } }
ConcurrentHashMap 源码分析
1. ConcurrentHashMap 1.7
1. 存储结构
Java 7 中 ConcurrentHashMap
的存储结构如上图,ConcurrnetHashMap
由很多个 Segment
组合,而每一个 Segment
是一个类似于 HashMap
的结构,所以每一个 HashMap
的内部可以进行扩容。但是 Segment
的个数一旦初始化就不能改变,默认 Segment
的个数是 16 个,你也可以认为 ConcurrentHashMap
默认支持最多 16 个线程并发。
2. 初始化
通过 ConcurrentHashMap
的无参构造探寻 ConcurrentHashMap
的初始化流程。
/** * Creates a new, empty map with a default initial capacity (16), * load factor (0.75) and concurrencyLevel (16). */ public ConcurrentHashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); }
无参构造中调用了有参构造,传入了三个参数的默认值,他们的值是。
/** * 默认初始化容量 */ static final int DEFAULT_INITIAL_CAPACITY = 16; /** * 默认负载因子 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 默认并发级别 */ static final int DEFAULT_CONCURRENCY_LEVEL = 16;
接着看下这个有参构造函数的内部实现逻辑。
@SuppressWarnings("unchecked") public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel) { // 参数校验 if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); // 校验并发级别大小,大于 1<<16,重置为 65536 if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS; // Find power-of-two sizes best matching arguments // 2的多少次方 int sshift = 0; int ssize = 1; // 这个循环可以找到 concurrencyLevel 之上最近的 2的次方值 while (ssize < concurrencyLevel) { ++sshift; ssize <<= 1; } // 记录段偏移量 this.segmentShift = 32 - sshift; // 记录段掩码 this.segmentMask = ssize - 1; // 设置容量 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; // c = 容量 / ssize ,默认 16 / 16 = 1,这里是计算每个 Segment 中的类似于 HashMap 的容量 int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; //Segment 中的类似于 HashMap 的容量至少是2或者2的倍数 while (cap < c) cap <<= 1; // create segments and segments[0] // 创建 Segment 数组,设置 segments[0] Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0] this.segments = ss; }
总结一下在 Java 7 中 ConcurrentHashMap 的初始化逻辑。
- 必要参数校验。
- 校验并发级别
concurrencyLevel
大小,如果大于最大值,重置为最大值。无参构造默认值是 16. - 寻找并发级别
concurrencyLevel
之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。 - 记录
segmentShift
偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. - 记录
segmentMask
,默认是 ssize - 1 = 16 -1 = 15. - 初始化
segments[0]
,默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。
3. put
接着上面的初始化参数继续查看 put 方法源码。
/** * Maps the specified key to the specified value in this table. * Neither the key nor the value can be null. * * <p> The value can be retrieved by calling the <tt>get</tt> method * with a key that is equal to the original key. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with <tt>key</tt>, or * <tt>null</tt> if there was no mapping for <tt>key</tt> * @throws NullPointerException if the specified key or value is null */ public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException(); int hash = hash(key); // hash 值无符号右移 28位(初始化时获得),然后与 segmentMask=15 做与运算 // 其实也就是把高4位与segmentMask(1111)做与运算 int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment // 如果查找到的 Segment 为空,初始化 s = ensureSegment(j); return s.put(key, hash, value, false); } /** * Returns the segment for the given index, creating it and * recording in segment table (via CAS) if not already present. * * @param k the index * @return the segment */ @SuppressWarnings("unchecked") private Segment<K,V> ensureSegment(int k) { final Segment<K,V>[] ss = this.segments; long u = (k << SSHIFT) + SBASE; // raw offset Segment<K,V> seg; // 判断 u 位置的 Segment 是否为null if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { Segment<K,V> proto = ss[0]; // use segment 0 as prototype // 获取0号 segment 里的 HashEntry<K,V> 初始化长度 int cap = proto.table.length; // 获取0号 segment 里的 hash 表里的扩容负载因子,所有的 segment 的 loadFactor 是相同的 float lf = proto.loadFactor; // 计算扩容阀值 int threshold = (int)(cap * lf); // 创建一个 cap 容量的 HashEntry 数组 HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap]; if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // recheck // 再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作 Segment<K,V> s = new Segment<K,V>(lf, threshold, tab); // 自旋检查 u 位置的 Segment 是否为null while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { // 使用CAS 赋值,只会成功一次 if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s)) break; } } } return seg; }
上面的源码分析了 ConcurrentHashMap
在 put 一个数据时的处理流程,下面梳理下具体流程。
-
计算要 put 的 key 的位置,获取指定位置的
Segment
。 -
如果指定位置的
Segment
为空,则初始化这个Segment
.初始化 Segment 流程:
- 检查计算得到的位置的
Segment
是否为 null. - 为 null 继续初始化,使用
Segment[0]
的容量和负载因子创建一个HashEntry
数组。 - 再次检查计算得到的指定位置的
Segment
是否为 null. - 使用创建的
HashEntry
数组初始化这个 Segment. - 自旋判断计算得到的指定位置的
Segment
是否为 null,使用 CAS 在这个位置赋值为Segment
.
- 检查计算得到的位置的
-
Segment.put
插入 key,value 值。
上面探究了获取 Segment
段和初始化 Segment
段的操作。最后一行的 Segment
的 put 方法还没有查看,继续分析。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { // 获取 ReentrantLock 独占锁,获取不到,scanAndLockForPut 获取。 HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { HashEntry<K,V>[] tab = table; // 计算要put的数据位置 int index = (tab.length - 1) & hash; // CAS 获取 index 坐标的值 HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) { // 检查是否 key 已经存在,如果存在,则遍历链表寻找位置,找到后替换 value K k; 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 { // first 有值没说明 index 位置已经有值了,有冲突,链表头插法。 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 // index 位置赋值 node,node 可能是一个元素,也可能是一个链表的表头 setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; }
由于 Segment
继承了 ReentrantLock
,所以 Segment
内部可以很方便的获取锁,put 流程就用到了这个功能。
-
tryLock()
获取锁,获取不到使用scanAndLockForPut
方法继续获取。 -
计算 put 的数据要放入的 index 位置,然后获取这个位置上的
HashEntry
。 -
遍历 put 新元素,为什么要遍历?因为这里获取的
HashEntry
可能是一个空元素,也可能是链表已存在,所以要区别对待。如果这个位置上的
HashEntry
不存在:- 如果当前容量大于扩容阀值,小于最大容量,进行扩容。
- 直接头插法插入。
如果这个位置上的
HashEntry
存在:- 判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
- 不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。
- 如果当前容量大于扩容阀值,小于最大容量,进行扩容。
- 直接链表头插法插入。
-
如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.
这里面的第一步中的 scanAndLockForPut
操作这里没有介绍,这个方法做的操作就是不断的自旋 tryLock()
获取锁。当自旋次数大于指定次数时,使用 lock()
阻塞获取锁。在自旋时顺表获取下 hash 位置的 HashEntry
。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) { HashEntry<K,V> first = entryForHash(this, hash); HashEntry<K,V> e = first; HashEntry<K,V> node = null; int retries = -1; // negative while locating node // 自旋获取锁 while (!tryLock()) { HashEntry<K,V> f; // to recheck first below if (retries < 0) { if (e == null) { if (node == null) // speculatively create node node = new HashEntry<K,V>(hash, key, value, null); retries = 0; } else if (key.equals(e.key)) retries = 0; else e = e.next; } else if (++retries > MAX_SCAN_RETRIES) { // 自旋达到指定次数后,阻塞等到只到获取到锁 lock(); break; } else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) { e = first = f; // re-traverse if entry changed retries = -1; } } return node; }
4. 扩容 rehash
ConcurrentHashMap
的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize
,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
private void rehash(HashEntry<K,V> node) { HashEntry<K,V>[] oldTable = table; // 老容量 int oldCapacity = oldTable.length; // 新容量,扩大两倍 int newCapacity = oldCapacity << 1; // 新的扩容阀值 threshold = (int)(newCapacity * loadFactor); // 创建新的数组 HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity]; // 新的掩码,默认2扩容后是4,-1是3,二进制就是11。 int sizeMask = newCapacity - 1; for (int i = 0; i < oldCapacity ; i++) { // 遍历老数组 HashEntry<K,V> e = oldTable[i]; if (e != null) { HashEntry<K,V> next = e.next; // 计算新的位置,新的位置只可能是不便或者是老的位置+老的容量。 int idx = e.hash & sizeMask; if (next == null) // Single node on list // 如果当前位置还不是链表,只是一个元素,直接赋值 newTable[idx] = e; else { // Reuse consecutive sequence at same slot // 如果是链表了 HashEntry<K,V> lastRun = e; int lastIdx = idx; // 新的位置只可能是不便或者是老的位置+老的容量。 // 遍历结束后,lastRun 后面的元素位置都是相同的 for (HashEntry<K,V> last = next; last != null; last = last.next) { int k = last.hash & sizeMask; if (k != lastIdx) { lastIdx = k; lastRun = last; } } // ,lastRun 后面的元素位置都是相同的,直接作为链表赋值到新位置。 newTable[lastIdx] = lastRun; // Clone remaining nodes for (HashEntry<K,V> p = e; p != lastRun; p = p.next) { // 遍历剩余元素,头插法到指定 k 位置。 V v = p.value; int h = p.hash; int k = h & sizeMask; HashEntry<K,V> n = newTable[k]; newTable[k] = new HashEntry<K,V>(h, p.key, v, n); } } } } // 头插法插入新的节点 int nodeIndex = node.hash & sizeMask; // add the new node node.setNext(newTable[nodeIndex]); newTable[nodeIndex] = node; table = newTable; }
第一个 for 是为了寻找这样一个节点,这个节点后面的所有 next 节点的新位置都是相同的。然后把这个作为一个链表赋值到新位置。第二个 for 循环是为了把剩余的元素通过头插法插入到指定位置链表。
5. get
到这里就很简单了,get 方法只需要两步即可。
- 计算得到 key 的存放位置。
- 遍历指定位置查找相同 key 的 value 值。
public V get(Object key) { Segment<K,V> s; // manually integrate access methods to reduce overhead HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; // 计算得到 key 的存放位置 if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { // 如果是链表,遍历查找到相同 key 的 value。 K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
2. ConcurrentHashMap 1.8
1. 存储结构
可以发现 Java8 的 ConcurrentHashMap 相对于 Java7 来说变化比较大,不再是之前的 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。
2. 初始化 initTable
/** * Initializes table, using the size recorded in sizeCtl. */ private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。 if ((sc = sizeCtl) < 0) // 让出 CPU 使用权 Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; sc = n - (n >>> 2); } } finally { sizeCtl = sc; } break; } } return tab; }
从源码中可以发现 ConcurrentHashMap
的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl
,它的值决定着当前的初始化状态。
- -1 说明正在初始化
- -N 说明有 N-1 个线程正在进行扩容
- 0 表示 table 初始化大小,如果 table 没有初始化
- >0 表示 table 扩容的阈值,如果 table 已经初始化。
3. put
直接过一遍 put 源码。
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) { // key 和 value 不能为空 if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { // f = 目标位置元素 Node<K,V> f; int n, i, fh;// fh 后面存放目标位置的元素 hash 值 if (tab == null || (n = tab.length) == 0) // 数组桶为空,初始化数组桶(自旋+CAS) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 桶内为空,CAS 放入,不加锁,成功了就直接 break 跳出 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 加锁加入节点 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; }
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要执行树化方法,在treeifyBin
中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
4. get
get 流程比较简单,直接过一遍源码。
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; // key 所在的 hash 位置 int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { // 如果指定位置元素存在,头结点hash值相同 if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) // key hash 值相等,key值相同,直接返回元素 value return e.val; } else if (eh < 0) // 头结点hash值小于0,说明正在扩容或者是红黑树,find查找 return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { // 是链表,遍历查找 if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }
总结一下 get 过程:
- 根据 hash 值计算位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
- 如果是链表,遍历查找之。
总结:
总的来说 ConcurrentHashMap
在 Java8 中相对于 Java7 来说变化还是挺大的,
3. 总结
Java7 中 ConcurrentHashMap
使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment
都是一个类似 HashMap
数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment
的个数一但初始化就不能改变。
Java8 中的 ConcurrentHashMap
使用的 Synchronized
锁加 CAS 的机制。结构也由 Java7 中的 Segment
数组 + HashEntry
数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
本文来自博客园,作者:Lz_蚂蚱,转载请注明原文链接:https://www.cnblogs.com/leizia/p/17489500.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步