Java集合

一、Java集合概述

1、集合整体框架

单例集合
在这里插入图片描述
双列集合
在这里插入图片描述

2、Collection接口和常用方法

在这里插入图片描述

二、List

1、迭代器遍历

迭代器(Iterator)
  迭代器是一种设计模式,它是一个对象,它可以遍历并选择序列中的对象,而开发人员不需要了解该序列的底层结构。迭代器通常被称为“轻量级”对象,因为创建它的代价小。
  Java中的Iterator功能比较简单,并且只能单向移动:
  (1) 使用方法iterator()要求容器返回一个Iterator。第一次调用Iterator的next()方法时,它返回序列的第一个元素。注意:iterator()方法是java.lang.Iterable接口,被Collection继承。
  (2) 使用next()获得序列中的下一个元素。
  (3) 使用hasNext()检查序列中是否还有元素。
  (4) 使用remove()将迭代器新返回的元素删除.
  Iterator是Java迭代器最简单的实现,为List设计的ListIterator具有更多的功能,它可以从两个方向遍历List,也可以从List中插入和删除元素。
迭代器应用:
复制代码

 list l = new ArrayList();
 l.add("aa");
 l.add("bb");
 l.add("cc");
 for (Iterator iter = l.iterator(); iter.hasNext();) {
     String str = (String)iter.next();
     System.out.println(str);
 }
 /*迭代器用于while循环
 Iterator iter = l.iterator();
 while(iter.hasNext()){
     String str = (String) iter.next();
     System.out.println(str);
 }
 */
复制代码
Iterator的接口定义:
public interface Iterator {  
  boolean hasNext();  
  Object next();  
  void remove();  
}  

使用: Object next():返回迭代器刚越过的元素的引用,返回值是Object,需要强制转换成自己需要的类型

boolean hasNext():判断容器内是否还有可供访问的元素

void remove():删除迭代器刚越过的元素

迭代使用方法:(迭代其实可以简单地理解为遍历,是一个标准化遍历各类容器里面的所有对象的方法类)

for(Iterator it = c.iterator(); it.hasNext(); ) {  
  Object o = it.next();  
   //do something  
}  

2、增强for循环

int[] num = {1,2,3,4,5,6};
for(int i = 0; i < num.length; i++){
    System.out.print(num[i]);
}

3、Iterator 和 ListIterator 有什么区别?

Iterator 可以遍历 Set 和 List 集合,而 ListIterator 只能遍历 List。
Iterator 只能单向遍历,而 ListIterator 可以双向遍历(向前/后遍历)。
ListIterator 实现 Iterator 接口,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。

4、List接口和常用方法

(1)List接口基本介绍

(1)List集合类中元素有序(即添加顺序和去除顺序一致),且可重复
(2)List集合中的每个元素都有其对应的顺序索引,支持索引
(3)List容器中的元素都对应一个整数型的序号记载其在容器中的位置

(2)遍历List的方式

1.for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后停止。

2.迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏蔽不同数据集合的特点,统一遍历集合的接口。Java 在 Collections 中支持了 Iterator 模式。

3.foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能在遍历过程中操作数据集合,例如删除、替换。

5、ArrayList扩容机制

  1. ArrayList中维护了一个Object类型的数组elementData. transient Object[] elementData;( transien表示短暂的,瞬间的,表示该属性不会序列化)
    2)当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData为1.5倍。
    3)如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容,·则直接扩容elementData为1.5倍。
  2. 扩容之后是通过数组的拷贝来确保元素的准确性的,所以尽可能减少扩容操作。 ArrayList 的最大存储能力:Integer.MAX_VALUE。 size 为集合中存储的元素的个数。elementData.length 为数组长度,表示最多可以存储多少个元素。
    5)如果需要边遍历边 remove ,必须使用 iterator。且 remove 之前必须先 next,next 之后只能用一次 remove。

6、ArrayList的方法

(1)ArrayList add(E E)方法源码原理

1) 确保数组已使用长度(size) 加1之后足够存下 下一个数据
2) 修改次数modCount 标识自增1, 如果当前数组已使用长度(size) 加1后的大于当前的数组长度, 则调用grow方法, 增长数组, grow方法会将当前数组的长度变为原来容量的1.5倍。
3) 确保新增的数据有地方存储之后, 则将新元素添加到位于size的位置上。
4) 返回添加成功布尔值。

(2)ArrayList的优缺点

优点:ArrayList 底层以数组实现,是一种随机访问模式。ArrayList实现了RandomAccess 接口,因此查找的时候非常快。ArrayList 在顺序添加一个元素的时候非常方便。

缺点:删除元素的时候,需要做一次元素复制操作。如果要复制的元素很多,那么就会比较耗费性能。插入元素的时候,也需要做一次元素复制操作,缺点同上。ArrayList 比较适合顺序添加、随机访问的场景。

利弊:

  • 确保数插入的位置小于等于当前数组长度, 并且不小于0, 否则抛出异常
  • 确保数组已使用长度(size) 加1之后足够存下下一个数据
  • 修改次数(modCount) 标识自增1, 如果当前数组已使用长度(size) 加1后的大于当前的数组长度, 则调用grow方法, 增长数组
  • grow方法会将当前数组的长度变为原来容量的1.5倍。
  • 确保有足够的容量之后, 使用System.arraycopy将需要插入的位置(index) 后面的元素统统往后移动一位。
  • 将新的数据内容存放到数组的指定位置(index)上

7、jdk1.8的Arraylist的特点

(1)ArrayList是一个动态数组,实现List,RandomAccess, Cloneable, java.io.Serializable,并允许包括null在内的所有元素。 实现了RandomAccess接口标识着其支持随机快速访问,实际上,我们查看RandomAccess源码可以看到,其实里面什么都没有定义.因为ArrayList底层是数组,那么随机快速访问是理所当然的,访问速度O(1).实现了Cloneable接口,标识着可以它可以被复制.注意,ArrayList里面的clone()复制其实是浅复制。实现了Serializable, 支持序列化传输
(2)底层使用数组实现,默认初始容量为10. 当超出后, 会动扩容为原来的1.5倍, 即自动扩容机制。 数组的扩容是新建一个大容量(原始数组大小+扩充容量) 的数组, 然后将原始数组数据拷贝到新数组, 然后将新数组作为扩容之后的数组。数组扩容的操作代价很高, 我们应该尽量减少这种操作。
(3)该集合是可变长度数组, 数组扩容时, 会将老数组中的元素重新拷贝一份到新的数组中, 每次数组容量增长大约是其容量的1.5倍, 如果扩容一半不够,就将目标size作为扩容后容量.这种操作的代价很高。 采用的是 Arrays.copyOf浅复制
(4)采用了Fail-Fast机制, 面对并发的修改时, 迭代器很快就会完全失败, 报异常concurrentModificationException(并发修改一次),而不是冒着在将来某个不确定时间发生任意不确定行为的风险。
(5)remove方法会让下标到数组末尾的元素向前移动一个单位, 并把最后一位的值置空, 方便GC
(6)数组扩容代价是很高的, 因此在实际使用时, 我们应该尽量避免数组容量的扩张。 当我们可预知要保存的元素的多少时, 要在构造ArrayList实例时, 就指定其容量, 以避免数扩容的发生。 或者根据实际需求, 通过调用ensureCapacity方法来手动增加ArrayList实例的容量
(7)ArrayList不是线程安全的, 只能用在单线程环境下, 多线程环境下可以考虑用Collections.synchronizedList(List l)函数返回一个线程安全的ArrayList类, 也可以使用concurrent并发包下的CopyOnWriteArrayList类。
(8)如果是删除数组指定位置的元素,那么可能会挪动大量的数组元素;如果是删除末尾元素的话,那么代价是最小的

8、ArrayList的常见面试题

(1)序列化是什么?

我们知道对象是不能直接进行网络传输的, 必须要转化为二进制字节流进行传输。 序列化就是将对象转化为字节流的过程。 同理, 反序列化就是从字节流构建对象的过程。

  • 对于 Java 对象来说, 如果使用 JDK 的序列化实现。 对象只需要实现 java.io.Serializable 接口 可以使用ObjectOutputStream() 和 ObjectInputStream() 对对象进行手动序列化和反序列化。 序列化的时候会调用writeObject() 方法, 把对象转换为字节流。 反序列化的时候会调readObject() 方法, 把字节流转换为对象
  • Java 在反序列化的时候会校验字节流中的 serialVersionUID 与对象的 serialVersionUID 时候一致。 如果不一致就会抛出 InvalidClassException 异常。
  • 官方强烈推荐为序列化的对象指定一个固定的 serialVersionUID。 否则虚拟机会根据类的相关信息通过一个摘要算法生成, 所以当我们改变类的参数的时候虚拟机生成的 serialVersionUID 是会变化的。 transient 关键字修饰的变量 不会被序列化为字节流
(2)Transient关键词是什么意思?
  • 一旦变量被transient修饰, 变量将不再是对象持久化的一部分, 该变量内容在序列化后无法获得访问
  • transient关键字只能修饰变量, 而不能修饰方法和类。 注意, 本地变量是不能被transient关键字修饰的。 变量如果是用户自定义类变量, 则该类需要实现Serializable接口
  • 被transient关键字修饰的变量不再能被序列化, 一个静态变量不管是否被transient修饰, 均不能被序列化
  • 使用场景举例 , 在实际开发过程中, 我们常常会遇到这样的问题, 这个类的有些属性需要序列化, 而其他属性不需要被序列化, 打个比方, 如果一个用户有一些敏感信息(如密码, 银行卡号等) , 为了安全起见, 不希望在网络操作(主要涉及到序列化操作, 本地序列化缓存也适用) 中被传输, 这些信息对应的变量就可以加上transient关键字。 换句话说, 这个
    字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化

在ArrayList中是支持序列化的,所有的用户数据都存在elementData中,序列化后如果数据全部丢失arrayList就没有用了,为了即防止了elementData的序列又保证元素不能丢失,所以就不对elementData这个Object数组对象序列化, 对elementData中的元素进行循环, 取出来单独进行序列化化, 序列化使用writeObject()方法,反序列化调用readObject方法。直接序列化elementData将会浪费很大的空间,因为绝大多数情况下会没有存储任何元素的容量空间。

8、ArrayList 和 LinkedList 的区别

数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结构实现。
随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。
增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率高,因为 ArrayList 增删操作要影响数组内的其他数据的下标。
内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;综合来说,在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推荐使用 LinkedList。

补充:数据结构基础之双向链表

双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。

9、ArrayList 和 Vector 的区别是什么?

这两个类都实现了 List 接口(List 接口继承了 Collection 接口),他们都是有序集合

线程安全:Vector 使用了 Synchronized 来实现线程同步,是线程安全的,而 ArrayList 是非线程安全的。
性能:ArrayList 在性能方面要优于 Vector。
扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍,而 ArrayList 只会增加 50%。
Vector类的所有方法都是同步的。可以由两个线程安全地访问一个Vector对象、但是一个线程访问Vector的话代码要在同步操作上耗费大量的时间。

Arraylist不是同步的,所以在不需要保证线程安全时时建议使用Arraylist。

10、插入数据时,ArrayList、LinkedList、Vector谁速度较快?阐述 ArrayList、Vector、LinkedList 的存储性能和特性?

ArrayList、LinkedList、Vector 底层的实现都是使用数组方式存储数据。数组元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉及数组元素移动等内存操作,所以索引数据快而插入数据慢。

Vector 中的方法由于加了 synchronized 修饰,因此 Vector 是线程安全容器,但性能上较ArrayList差。

LinkedList 使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但插入数据时只需要记录当前项的前后项即可,所以 LinkedList 插入速度较快。

三、Set

1、Set基本介绍

(1)无序(添加和取出的顺序不一致),没有索引
(2)不允许有重复元素,最多包含一个null
(3)实现类有HashSet、TreeSet、LinkedHashSet

2、HashSet的实现方法

HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。

元素无序且唯一,线程不安全,效率高,可以存储null元素,元素的唯一性是靠所存储元素类型是否重写hashCode()和equals()方法来保证的,如果没有重写这两个方法,则无法保证元素的唯一性。
具体实现唯一性的比较过程:存储元素首先会使用hash()算法函数生成一个int类型hashCode散列值,然后已经的所存储的元素的hashCode值比较,如果hashCode不相等,则所存储的两个对象一定不相等,此时存储当前的新的hashCode值处的元素对象;如果hashCode相等,存储元素的对象还是不一定相等,此时会调用equals()方法判断两个对象的内容是否相等,如果内容相等,那么就是同一个对象,无需存储;如果比较的内容不相等,那么就是不同的对象,就该存储了,此时就要采用哈希的解决地址冲突算法,在当前hashCode值处类似一个新的链表, 在同hashCode值的后面存储存储不同的对象,这样就保证了元素的唯一性。Set的实现类的集合对象中不能够有重复元素,HashSet也一样他是使用了一种标识来确定元素的不重复,HashSet用一种算法来保证HashSet中的元素是不重复的, HashSet采用哈希算法,底层用数组存储数据。默认初始化容量16,加载因子0.75。Object类中的hashCode()的方法是所有子类都会继承这个方法,这个方法会用Hash算法算出一个Hash(哈希)码值返回,HashSet会用Hash码值去和数组长度取模, 模(这个模就是对象要存放在数组中的位置)相同时才会判断数组中的元素和要加入的对象的内容是否相同,如果不同才会添加进去。

HashCode()$equals()区别
如果两个对象相等,则hashcode一定也是相同的
两个对象相等,对两个equals方法返回true
两个对象有相同的hashcode值,它们也不一定是相等的
综上,equals方法被覆盖过,则hashCode方法也必须被覆盖
hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

3、LinkedHashSet

LinkedHashSet底层数据结构采用链表和哈希表共同实现,链表保证了元素的顺序与存储顺序一致,哈希表保证了元素的唯一性。线程不安全,效率高。LinkedHashSet集合同样是根据元素的hashCode(归根结底, 还是hashMap)值来决定元素的存储位置, 但是它同时使用链表维护元素的次序(次序出自LinkedHashMap) 。 这样使得元素看起 来像是以插入顺 序保存的, 也就是说, 当遍历该集合时候, LinkedHashSet将会以元素的添加顺序访问集合的元素。

4、TreeSet

TreeSet底层数据结构采用二叉树(红黑树)来实现,元素唯一且已经排好序;唯一性同样需要重写hashCode和equals()方法,二叉树结构保证了元素的有序性。根据构造方法不同,分为自然排序(无参构造)和比较器排序(有参构造),自然排序要求元素必须实现Compareable接口,并重写里面的compareTo()方法,元素通过比较返回的int值来判断排序序列,返回0说明两个对象相同,不需要存储;比较器排需要在TreeSet初始化是时候传入一个实现Comparator接口的比较器对象,或者采用匿名内部类的方式new一个Comparator对象,重写里面的compare()方法;

四、Map

1、HashMap原理

HashMap本质是一个一定长度的数组,数组中存放的是链表。它是一个Entry类型的数组.在这里插入图片描述
在这里插入图片描述
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
(首先判断当前的table是否为空或者数组是否为0,如果是就要进行扩容,不是根据hash计算下标,下标要是没有重复就直接插入,要是有重复就会对比key,如果key相同就会之际而覆盖掉,key不相同就判断当前节点是否是树节点,如果是树节点就按照红黑树的方式插入,如果不是树节点就就按照链表顺序插入)

2、Hash冲突问题

(1)Hash冲突的概念

Hash冲突就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
相同的数值算出的Hash值一定相同,但是Hash值相同的的话输入的数值不一定相同。这就导致了Hash冲突问题

(2)Hash冲突解决办法
  • 拉链法:jdk 1.7使用了数组+链表,当产生冲突时头插到链表中 jdk 1.8 使用了数组+链表+红黑树产生冲突时尾插到链表中;还使用Hash扰动处理,jdk 1.7时四次位运算,五次疑惑运算,jdk 1.8使用一次位运算,一次异或运算()static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或) }
  • 开放定址法就是解决hash冲突的一种方式。 它是使用一种 探测方式在整个数组中找到另一个可以存
    储值的地方。
  • 再散列法再散列法其实很简单, 就是再使用哈希函数去散列一个输入的时候, 输出是同一个位置就
    再次散列, 直至不发生冲突位置

3、HashMap扩容机制

(1)jdk 1.7

1.7 中整个扩容过程就是一个取出数组元素(实际数组索引位置上的每个元素是每个独立单向链表的头部, 也就是发生 Hash 冲突后最后放入的冲突元素) 然后遍历以该元素为头的单向链表元素, 依据每个被遍历元素的 hash 值计算其在新数组中的下标然后进行交换(即原来 hash 冲突的单向链表尾部变成了扩容后单向链表的头部)

(2)jdk 1.8

由于扩容数组的长度是 2 倍关系, 所以对于假设初始tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变(左移一位就是 2 倍) , 在扩容中只用判断原来的 hash 值与左移动的一位(newtable 的值) 按位与操作是 0 或 1 就行, 0 的话索引就不变, 1 的话索引变成原索引加上扩容前数组

4、Hashmap1.7 &HashMap 1.8

JDK1.8主要解决或优化了一下问题:

(1)resize 扩容优化
(2)引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
(3)解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
在这里插入图片描述

5、HashMap常见面试题

(1)能否使用任何类作为 Map 的 key?
可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:

如果类重写了 equals() 方法,也应该重写 hashCode() 方法。

类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。

如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。

用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。

(2)为什么HashMap中String、Integer这样的包装类适合作为K?
答:String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率

都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况,内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范(不清楚可以去上面看看putValue的过程),不容易出现Hash值计算错误的情况;

①天生复写了hashCode方法,根据String对象的内容来计算的hashCode
②因为字符串是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快
③equals方法 string自己就有

(3)如果使用Object作为HashMap的Key,应该怎么办呢?
答:重写hashCode()和equals()方法

重写hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞;重写equals()方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性

(4)HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?
hashCode()方法返回的是int整数类型, 其范围为-(2 ^ 31)~(2 ^ 31 - 1), 约有40亿个映射空间, 而HashMap的容量范围是在16(初始化默认值) ~2 ^ 30, HashMap通常情况下是取不到最大值的, 并且设备上也难以提供这么多的存储空间, 从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内, 进而无法匹配存储位置
解决办法:哈希码按位与哈希码右移16的二进制由于和最终和(length-1)与 运算, length 绝大多数情况小于2的16次方。 所以始终是hashcode 的低16位(甚至更低) 参与运算。 要是高16位也参与运算, 会让得到的下标更加散列。 所以这样高16位是用不到的, 如何让高16也参与运算呢。 所以才有hash(Object key)方法。 让他的hashCode()和自己的高16位^运算。 所以(h >>> 16)得到他的高16位与hashCode()进行^运算。

(5)为什么hashmap的数组长度要保证为2的幂次方呢?
①只有当数组长度为2的幂次方时, h&(length-1)才等价于h%length, 即实现了key的定位,2的幂次方也可以减少冲突次数, 提HashMap的查询效率;
②如果 length 为 2 的次幂 则 length-1 转化为二进制必定是 11111……的形式, 在与 h 的二进制与操作效率会非常的快, 而且空间不浪费; 如果 length 不是 2 的次幂, 比如length 为 15, 则 length - 1 为 14, 对应的二进制为 1110, 在与 h 与操作, 最后一位都为 0 , 而0001, 0011, 0101, 1001, 1011, 0111, 1101 这几个位置永远都不能存放元素了, 空间浪费相当大, 更糟的是这种情况中, 数组可以使用的位置比数组长度小了很多, 这意味着进一步增加了碰撞的几率, 减慢了查询的效率! 这样就会造成空间的浪费。

(6)链表的节点数大于8就一定会转为红黑树嘛?

可以看到在treeifyBin中并不是简单地将链表转换为红黑树, 而是先判断table的长度是否大于64, 如果小于64, 就通过扩容的方式来解决, 避免红黑树结构化。

6、HashTable

(1)结构

数组+链表+二叉树
在这里插入图片描述

(2)HashMap和HashTable的区别

① 继承的父类不同:
Hashtable继承自Dictionary类, 而HashMap继承自AbstractMap类。 但二者都实现了Map接口
②线程安全性不同
HashTable使用了Synchronized线程安全,HashMap线程不安全
③key/value是否允许null值
Hashtable中, key和value都不允许出现null值。HashMap中, null可以作为键, 这样的键只有一个;
④两个遍历方式的内部实现上不同
Hashtable、 HashMap都使用了 Iterator。 而由于历史原因, Hashtable还使用了Enumeration的方式
⑤hash值不同
哈希值的使用不同, HashTable直接使用对象的hashCode。 而HashMap重新计算hash值。
⑥内部实现使用的数组初始化和扩容方式不同
HashTable在不指定容量的情况下的默认容量为11, 而HashMap为16, Hashtable不要求底层数组的容量一定要为2的整数次幂, 而HashMap则要求一定为2的整数次幂。Hashtable扩容时, 将容量变为原来的2倍加1, 而HashMap扩容时, 将容量变为原来的2倍。 Hashtable和HashMap它们两个内部实现方式的数组的初始大小和扩容的方式。 HashTable中hash数组默认大小是11, 增加的方式是 old*2+1

7、ConcurrentHashmap

(1)jdk 1.7ConcurrentHashmap

①结构
JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成,继承reeentrantLock锁
如下图所示,首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。
在这里插入图片描述
②put方法

  • 首先是通过 key 定位到 Segment,之后在对应的 Segment 中进行具体的 put。(虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。)
  • 首先会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。尝试自旋获取锁。如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。
  • 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value,为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容
    在这里插入图片描述

③get()方法
首先,根据 key 计算出 hash 值定位到具体的 Segment ,再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。
由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。
在这里插入图片描述

(2)jdk 1.8ConcurrentHashmap

①结构
DK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁。

将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。

在这里插入图片描述
②put()方法

  • 根据 key 计算出 hash 值

  • 判断是否需要进行初始化;

  • 定位到 Node,拿到首节点 f,判断首节点 f:
    ①如果为 null ,则通过 CAS 的方式尝试添加;
    ②如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
    ③如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;

  • 当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树。
    在这里插入图片描述
    ③get()方法

  • 根据 key 计算出 hash 值,判断数组是否为空;

  • 如果是首节点,就直接返回;

  • 如果是红黑树结构,就从红黑树里面查询

  • 如果是链表结构,循环遍历判断。
    在这里插入图片描述

(3)常见面试题

①ConcurrentHashMap 和 Hashtable 的区别?
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

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

②ConcurrentHashMap 迭代器是强一致性还是弱一致性?

与 HashMap 迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。

③JDK1.8的currenthashmap和jdk1.7的区别(或者说1.8的改进)
其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap, 相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发, 从JDK1.7版本的
ReentrantLock+Segment+HashEntry, 到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。
1.数据结构: 取消了Segment分段锁的数据结构, 取而代之的是数组+链表+红黑树的结构。
2.保证线程安全机制: JDK1.7采用segment的分段锁机制实现线程安全, 其中segment继承自ReentrantLock。 JDK1.8采用CAS+Synchronized保证线程安全。
3.锁的粒度: 原来是对需要进行数据操作的Segment加锁, 现调整为对每个数组元素加锁(Node) 。
4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时, 会将链表转化为红黑树进行存储。
5.查询时间复杂度: 从原来的遍历链表O(n), 变成遍历红黑树O(logN)

8、LinkedHashMap

(1)结构

本质上,LinkedHashMap = HashMap(数组+单向链表) + 双向链表,也就是说,HashMap和双向链表合二为一即是LinkedHashMap。也可以这样理解,LinkedHashMap 在不对HashMap做任何改变的基础上,给HashMap的任意两个节点间加了两条连线(before指针和after指针),使这些节点形成一个双向链表。在LinkedHashMapMap中,所有put进来的Entry都保存在HashMap中,但由于它又额外定义了一个以head为头结点的空的双向链表,因此对于每次put进来Entry还会将其插入到双向链表的尾部。
在这里插入图片描述

(2)LinkedeHashMap存取

LinkedHashMap 的存取过程基本与HashMap基本类似,只是在细节实现上稍有不同,这是由LinkedHashMap本身的特性所决定的,因为它要额外维护一个双向链表用于保持迭代顺序。在put操作上,虽然LinkedHashMap完全继承了HashMap的put操作,但是在细节上还是做了一定的调整,比如,在LinkedHashMap中向哈希表中插入新Entry的同时,还会通过Entry的addBefore方法将其链入到双向链表中。在扩容操作上,虽然LinkedHashMap完全继承了HashMap的resize操作,但是鉴于性能和LinkedHashMap自身特点的考量,LinkedHashMap对其中的重哈希过程(transfer方法)进行了重写。在读取操作上,LinkedHashMap中重写了HashMap中的get方法,通过HashMap中的getEntry方法获取Entry对象。在此基础上,进一步获取指定键对应的值

①put()方法原理
与hashMap不同的地方

  • newNode方法的复写如果进行put的方法调用, 肯定是要进行新节点的添加的, linkedHashMap在 newNode方法中进行了改善, 进行了复写, 源码如下:
    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
        LinkedHashMap.Entry<K,V> p =
        //创建一个普通的entry,蒋entry插入到双向链表的末尾
            new LinkedHashMap.Entry<K,V>(hash, key, value, e);
            
        linkNodeLast(p);
        return p;
    }
  • AfterNodeAccess方法的复写, 将新增的节点添加到链表的尾部
   void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        if (accessOrder && (last = tail) != e) {
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            p.after = null;
            if (b == null)
                head = a;
            else
                b.after = a;
            if (a != null)
                a.before = b;
            else
                last = b;
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
    }

②get()方法

  • 计算数据在桶中的位置 (tab.length- 1) & hash(key)
  • 通过hash值和key值判断待查找的数据是否在对应桶的首节点,
    如果在, 则返回对应节点数据; 否则判断桶首节点的类型。 如果节点
    为红黑树, 从红黑树中获取对应数据; 如果节点为链表节点, 则遍历
    链表, 从中获取对应数据
(3)LinkedHashMap实现一个缓存清理(LRU算法)
class LRULinkedHashMap<k,v> extends LinkedHashMap<k,v>{
    //定义缓存的容量
    private int capacity;
    private static final long serialVersionUID = 1L;
    //带参数的构造器

    public LRULinkedHashMap(int capacity) {
        //调用LinkedHashMao的构造器,传入以下参数
        super(16,0.75f,true);
        //传入指定的缓存最大容量
        this.capacity = capacity;
    }
    //实现LRU的关键方法,如果map里面的元素个数大于了缓存最大容量,则消除链表的顶端元素

    @Override
    protected boolean removeEldestEntry(Map.Entry<k, v> eldest) {
        System.out.println(eldest.getKey()+"="+eldest.getValue());
        return size()>capacity;
    }
(4)插入顺序和访问顺序

①插入顺序: 顾名思义, 插入的时候能够通过双向链表的关联维护插入的顺序(详见41题的代码举例) 。 新创建的linkedhashmap存放的元素, 在进行遍历输出打印时, 按照put的顺序进行的打印, 这也是与hashmap不同的地方, 因为hashmap中没有维护插入的元素与元素之间的顺序的逻辑
②我们发现之前的 123 变成了231 , First这个值最后打印了, 原因是accessOrder为true代表开启访问顺序 开启访问顺序后, 只要调用get方法, 就会把被调用过get方法的元素放到链表的最末端这就是传说中的访问顺序。

(5)LinkedHashMap与hashMap的区别
  • LinkedHashMap是继承于HashMap, 是基于HashMap和向链表来实现的。
  • HashMap无序; LinkedHashMap有序, 可分为插入顺序和访问顺序两种。 如果是访问顺序, 那put和get操作已存在Entry时, 都会把Entry移动到双向链表的表尾(其实是先删除再插入)。
  • LinkedHashMap存取数据, 还是跟HashMap一样使用的Entry[]的方式, 双向链表只是为了保证顺序。
  • LinkedHashMap是线程不安全的。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值