集合分类
集合主要可分为三类
- 单列结合:List
- 双列集合:Map
- 迭代器:Iterator
Iterator
迭代器主要用来操作集合
int cursor; // 代表下一个要访问的元素下标 int lastRet = ‐1; // 代表上一个要访问的元素下标 int expectedModCount = modCount;//代表对 ArrayList 修改次数的期望值,初始值为 modCount //如果下一个元素的下标等于集合的大小 ,就证明到最后了 public boolean hasNext() { return cursor != size; } // 获取下一个元素,在最开始时,下一个元素指针cursor=0,上一个元素指针lastRet =-1; // 每调用一次next方法,将lastRet改为=cursor,然后cursor+1。返回当前下标元素。 public E next() // 删除上一次遍历的元素,每调用一次,lastRet的值都改为-1,同时cursor-1; // 所以每删除上一个元素,就得遍历下一个元素,不然会报错。即只能从头删到尾。 public void remove() @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 重新赋值给 expectedMo dCount。 expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); } }
remove 方法的弊端。
1、只能进行remove操作,add、clear 等 Itr 中没有。
2、调用 remove 之前必须先调用 next。因为 remove 开始就对 lastRet 做了校验。而lastRet 初始化时为 -1。
3、next 之后只可以调用一次 remove。因为 remove 会将 lastRet 重新初始化为 -1
常见List对象
ArrayList:
特点:
- 元素有放入顺序,元素可重复 。
- 底层采用数组来实现的。即使用连续的内存。
- 实现了Cloneable接口,重写了clone方法、方法内容默认调用父类的clone方法。
- 实现了Serializable接口,可以对象流化传输。
- 继承AbstractList且实现了List接口。其实这是作者一个错误的写法。
属性:
// 序列化版 本号(类文件签名),如果不写会默认生成,类内容的改变会影响签名变化, // 导致反序列化失败 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 = {}; // arrayList真正存放元素的地方,长度大于等于size transient Object[] elementData; //arrayList中的元素个数 private int size;
构造器:
ArrayList的构造方法有三个,无参构造、指定容量构造、传入集合构造。
// 无参构造器,构造一个容量大小为 10 的空的 list 集合,但构造函数只是给 elementData // 赋值了一个空的数组,其实是在第一次添加元素时容量扩大至 10 的。 public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; } // 当使用无参构造函数时是把 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 赋值给 elementDat a。 // 当 initialCapacity 为零时则是把 EMPTY_ELEMENTDATA 赋值给 elementData。 // 当 initialCapacity 大于零时初始化一个大小为 initialCapacity 的 object数组并赋值给 ele mentData。 public ArrayList(int initialCapacity) { if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } else { throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } //将 Collection 转化为数组,数组长度赋值给 size。 如果 size 不为零,则判断 elem entData 的 class 类型是否为 ArrayList,不是的话则做一次转换。 如果 size 为零,则把 EMPTY_ELEMENTDATA 赋值给 elementData,相当于new ArrayList(0)。 public ArrayList(Collection<? extends E> c) { Object[] a = c.toArray(); if ((size = a.length) != 0) { if (c.getClass() == ArrayList.class) { elementData = a; } else { elementData = Arrays.copyOf(a, size, Object[].class); } } else { // 指向空数组 elementData = EMPTY_ELEMENTDATA; } }
添加元素
- 尾部添加,效率较高
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }
- 插入,后面的元素均要移动,效率相对较差
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++; }
扩容
先直接扩容1.5倍,如果扩容后的容量小于指定要扩容的容量,则以指定的扩容扩容。如果指定容量非常大,则以Integer的最大值或其减8来扩容。
private void grow(int minCapacity) { int oldCapacity = elementData.length;//获取当前数组长度 int newCapacity = oldCapacity + (oldCapacity >> 1);//默认将扩容至原来容量的 1.5 倍 // 如果1.5倍太小的话,则将我们所需的容量大小 赋值给newCapacity if (newCapacity ‐ minCapacity < 0) newCapacity = minCapacity; // 如果1.5倍太大或者我们需要的容量太大,那就直接 // 拿newCapacity = (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MA X_ARRAY_SIZE 来扩容 if (newCapacity ‐ MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // 然后将原数组中的数据 复制到大小为 newCapacity 的新数组中,并将新数组赋值给 elementData。 elementData = Arrays.copyOf(elementData, newCapacity); }
删除:
删除指定位置元素后,后面的元素都会往前移动,然后把最后一个元素置空
public E remove(int index) { // 删除是否越界 rangeCheck(index); // 操作数加1 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; }
清空ArrayList的三种方法
List aa= new ArrayList<>(); aa.add("111"); aa.add("112"); aa.add("113"); System.out.println(aa); // [111, 112, 113] // 直接调内部方法清空 aa.clear(); System.out.println(aa); // [] // for循环清空,遍历list总数次,每次删除第一个元素 int b =aa.size(); for (int i = 0; i < b; i++) { aa.remove(0); } System.out.println(aa); // [] // 迭代器清空,每次判断下一个元素是否存在,存在先遍历该元素,然后删除该元素 Iterator it =aa.iterator(); while (it.hasNext()){ it.next(); it.remove(); }
其他重要API
boolean remove(Object o) | 删除指值元素,可去除null |
void fastmove(int index) | 快速删除,不做越界校验 |
void clear() | 清空集合 |
boolean addAll(Collection e) | 将另一个集合添加原集合末尾 |
LinkedList:
底层是双向循环链表实现,增删效率搞,查找效率低。
构造函数
LinkedList() // 用于创建一个空的链表。 LinkedList(Collection<? extends E> collection) // 创建一个LinkedList,保护Collection中的全部元素。
常用方法
添加元素
boolean add(Object element)// 它将元素附加到列表的末尾。 void addFirst(E element) // 元素附加到列表的头部 void addLast(Eelement) // 元素附加到列表的尾部
查询头尾元素
Object getFirst() // 它返回链表的第一个元素。 Object getLast() // 它返回链接列表的最后一个元素。
查找指定元素索引
int indexOf(Object element) // 如果找到元素,它将返回元素第一次出现的索引。否则,它返回-1。 int lastIndexOf(Object element) // 如果找到元素,它将返回元素最后一次出现的索引。否则,它返回-1。
删除元素
E remove(int location) E removeFirst() // 删除并返回链接列表的头部一个元素 E removeLast() // 删除并返回链接列表的尾部一个元素
其他API
void clear() // 它删除列表中的所有元素。 Object clone() // 它用于制作现有链接列表的副本。 Object set(int index,Object element) // 它用于用新元素替换列表中的现有元素。 boolean contains(Object element) // 如果元素存在于列表中,则返回true。
遍历方法
// LinkedList内部有迭代器类,可以使用迭代器遍历 Iterator<String> it = li.iterator(); while (it.hasNext()) { System.out.println(it.next()); } // for循环遍历 for(int i=0;i<lList.size();i++){ System.out.println(lList.get(i)); }
常见Map对象
HashMap(Map):
特点:
key , value 存储, key 可以为 null ,同样的 key 会被覆盖掉(键相同,值覆盖)。
存储结构:
底层采用数组、链表、红黑树来实现的。
原理讲解:
jdk1.8以前采用数组+链表的方式实现,jdk1.8(含1.8)以后采用数组+链表+红黑树的方式实现(当链表长度大于8并且hashmap里面的元素大于等于64就会将链表转为红黑树,若只是链表长度大于8元素总数小于64则只进行扩容操作,当删除元素链表长度小于6个时,又会将红黑树转为链表的形式)。
HashMap采用延时加载机制,当创建一个Hashmap对象,只是对一个成员变量赋值,并没有初始化table对象,在首次使用(即第一次调用put方法时)才对table数组对象初始化默认长度16.调用put方法时,第一步检查table数组是否存在,若不存在则调用resize()方法初始化table数组,第二步,用hash算法算出键值对在table中的位置(返回的hash值并不是key的hash值,而是key的哈希值与key的哈希值右位移16位后进行按位异或得到的结果(这是jdk1.8的hash算法,以前的jdk版本和在这也不一样),再以这个结果和table的长度-1的结果进行与运算得到table的节点下标)。如果这个位置没有元素在里面则新建一个节点对象保存(保存前会判断新的size是否大于数组容量,如果大于则调用resize()方法扩容),如果该位置有对象存在则调用对象的equals方法判断对象值是否相同,若相同则覆盖,并返回旧的值。若不相同,则判断当前存储位置是否已经树化了之后的节点,如果是则按树的方式存储键值对,如果不是,则按链表的方式存储键值对,同时判断链表的长度是否超过阈值(8),若超过并且数组长度已经大于64,则将链表树化后重新保存,若链表长度超过8数组长度不大于64,则调用resize()方法将table数组的长度扩容2倍后将键值对重排,键值对依旧按链表方式保存。若链表长度不超过8,则直接按链表方式保存对象。
各个版本之间hashmap的区别
- 数据结构不同:
jdk1.8之前采用数组+链表的数据结构,jdk1.8(含jdk1.8)以后采用数组+链表+红黑树的数据结构。
- 插入方式不一样:
jdk1.8之前采用头插入(因为后插入的数据被使用的概率较大,头插入避免在获取的时候对整个链表进行遍历,提高效率),jdk1.8(含jdk1.8)以后采用尾插入(避免头插入在高并发的场景下引起链表成环的问题,当链表成环再调用get方法获取元素时会陷入死循环)。
- 扩容方式不一样:
二者均是扩容两倍,但jdk1.8之前扩容时会创建一个新的Entry数组,然后重新计算hash值将原来的数组元素,拷贝到新的Entry数组中,而jdk1.8(含jdk1.8)以后重新拷贝数组元素时,key<oldTable.length,元素位置不变,如果key > oldTable.length, 元素位置为原来的索引+oldTable.length;即如果原来的数组长度为16,元素a,key=5,元素b,key=21,那么在原来的数组中应该在同一个链表中;扩容为32时,元素a仍在位置5,而元素b,应在5+16=21的位置上,节省了重新计算hash的时间,提高了效率.
为什么扩容是2的倍数
- 操作系统申请内存一般都是2的倍数,可以避免内存浪费。
- 计算器擅长移位操作,不善于加减乘除,以左位移1位的方式扩容效率更高。
- 减少哈希碰撞,hashMap中散列桶下标的获取方法是以数组长度-1运算后与hash值进行与运算(即用hash值对数组长度取模运算)。当数组长度-1等于偶数时,其二进制最低为0,0和1或0进行与运行都得0,当数组长度-1等于奇数时,则相反。
如何减少碰撞
- 使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。
- 使用优秀的hash算法,尽量避免不同的key得到相同哈希值。
- 尽量使用优秀的扰动函数对key的哈希值做处理,使table序列更均匀的分布
- 尽可能的减小扩容因子。
拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。
hashtable、hashMap、ConccurentHashMap的区别
hashtable在public的方法里面都加入了synchronizedd 修饰,它是线程安全的,只能串联执行,效率较低;hashMap是线程不安全的,效率较高。要想将hasMap转为线程安全可用Collections. synchronizedMap(K,V)方法处理hashMap,将其转为线程安全的,它与hashTable的区别仅在于锁对象不同,Collections. synchronizedMap是用的一把内部对象锁,而hashtable的锁是调用对象(this)的锁,使用Collections. synchronizedMap处理hashMap使得hashMap是线程安全的,同时也只能串联执行,效率下降。ConccurentHashMap也是线程安全的,但是它使用的锁更加细化,早期的ConccurentHashMap采用的16把分段锁(hashMap的数组默认默认长度),在jdk1.8后做了更细的优化,每个桶配一把锁,使得只有在多线程且发生哈希碰撞的情况下才会产生并发竞争锁资源,这使效率得到更好的提升。
区别:
hashMap线程不安全,数组+链表+红黑树实现
hashtable线程安全,锁住整个对象,数组+链表实现
ConccurentHashMap线程安全,CAS+同步锁,锁住一个桶,内部实现逻辑与hashMap相同,也是数组+链表+红黑树实现
hashMap可以以null为键值对,其他两个不行。
ConccurentHashMap的put原理:
- 判断Node[]数组是否已经初始化,没有则初始化。
- 通过hash定位数组的索引下标,是否有节点,没有则通过CAS添加节点。
- 检查内部是否在扩容,是就帮助一块扩容。
- 如果头节点不为空,则锁住头节点,剩下的操作和hashMap一样。
JDK7 中 HashMap 成环原因
成环的时机
- HashMap 扩容时。
- 多线程环境下。
成环的具体代码位置
在扩容的 transfer 方法里面,有三行关键的代码,如下:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { //e为空时循环结束 while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); // 成环的代码主要是在这三行代码 // 首先插入是从头开始插入的 e.next = newTable[i]; newTable[i] = e; e = next; } } }
1. 假如说现在有一个链表a>b>null。
2. 有两个线程同时扩容,第一个线程刚进入while循环,执行完next=e.next,即e=a,next=b,此时cpu时间片耗尽
3. 开始执行第二个线程。第二个线程扩容执行结束后,新链表为b>a>null,即扩容颠倒了原有链表的顺序。
4. 此时第一个线程开始恢复执行,第一次循环完毕时a.next=null,newTable头=a,e=b,即新链表为a>bull,此时e=b,b.next=a;第二次循环时,e=b,next=b.next,即a。e.next=a,newTable头=b,e=a。此时原有链表的结构已经被改成了a->b->a,就已经开始死循环了。。
总结链表成环的原因有两个:
1. 扩容颠倒了原有链表的顺序
2. 在扩容的过程中改变了原有链表的next引用关系