集合类面试题
集合概述
集合的概念
用于存储对象的容器(不能存储基本数据类型数据)
集合中存入的都是对象的地址
在java.util包下
集合类的作用
可以方便的持有对象操作对象
集合类使用泛型的好处
可以明确要存储的元素类型,又避免了手动类型转换及类型转换错误的问题
集合类的分类
常用的集合类有
Collection接口下的:
List和Set
(接口)List集合的特点是元素有序、允许有重复元素
(接口)Set集合的特点是元素无序、不允许有重复元素(通过equals进行比较的)
list接口下常用的:ArrayList,Vector,LinkedList
set接口下常用的:HashSet,TreeSet
Map接口下的:键值对、键唯一、值不唯一
HashMap和TreeMap
数组与集合的区别
(元素和大小)
1数组中即可以存放引用类型和基本类型数据。集合中只能存放引用类型数据。
2数组中只能存放相同类型的数据,而集合可以存放不同类型的数据。
3数组的长度具有固定大小,而集合的长度不固定(容量不够就会扩容)。
常见集合类
ArrayList、Vector、LinkedList
三者元素有序(从左到右的插入顺序,插入的是abc遍历输出的顺序还是abc),允许有重复元素
ArrayList、Vector:
底层数组实现,查找快(数组的每个元素的空间大小的一样的(4个字节),根据头元素的位置及要查找的角标能计算出要操作的元素的位置)、增删慢(由于要移动很多元素,添加后面的元素后移,删除后面的元素前移,如果是在末端增删会快(增删先根据角标找到位置再增删))。(为什么要移动元素,因为基于数组实现的,数组的特性要求元素在内存中是连续存储的)
ArrayList线程不安全,Vector线程安全(通过在方法前面加上synchronized实现的)
扩容是有区别Vector增长原来的一倍,ArrayList增加原来的0.5倍。
LinkedList :
底层双向链表(每一个元素记住前驱和后驱)实现,增删快(只需修改两边前驱后驱信息就可以(增删要先遍历到指定位置再增删),如果是末尾增删跟arrayList比效率低),查找慢(get(index))(比较index与集合长度size/2,如果是index小,那么从第一个顺序循环,直到找到为止;如果index大,那么从最后一个倒序循环,直到找到为止。)
LinkedList把双向链表的长度,头结点,尾结点作为类属性进行记录,所以头和尾都可以作为遍历的开始
为什么数组可以根据角标查询到元素,链表不可以:
数组在内存上是一块连续的内存空间,内存地址的顺序和元素的顺序一致(数组实现的,数组可以根据角标获取元素)
链表在内存上是非连续的内存空间,需要根据前驱或后驱来找到前面后面的地址
时间复杂度:
ArrayList:
get(index) 直接读取指定下标的值,复杂度 O(1)
add(E) 添加元素,直接在后面添加,复杂度O(1)
add(index, E) 添加元素,在指定下标的位置添加元素,后面的元素需要向后移动,复杂度O(n)
remove(index),remove(E)删除元素,后面的元素需要逐个移动,复杂度O(n)
LinkedList 是链表的操作
get(int index) 获取指定下标的值,依次遍历,复杂度O(n)
add(E) 添加到末尾,复杂度O(1)
add(index, E) 在指定下标的位置添加元素,需要先查找到第几个元素,直接指针指向操作,复杂度O(n)
remove(Object o)/remove(int index)删除元素,找到元素,然后指针操作,复杂度O(n)
ArrayList
调用无参的构造函数new ArrayList(),初始化完成后并没有分配空间
当第一次调用add方法时进行初始化为长度为10(默认值)的数组空间
如果调用的是有初始化长度的有参构造函数ArrayList(int initialCapacity),初始化时就分配了指定值的容量。
add(E) 添加元素,直接在后面添加,当添加后的长度大于了原来的数组容量就会先扩容再添加(构造函数指定的长度也不能约束用add添加后的实际长度)
add(index, E) 添加元素,在指定下标位置插入,原来位置元素和后面的元素需要向后移动(index不能超过当前数组实际容量长度的值-1的值,否则会报ArrayIndexOutOfBoundsException)
(构造函数的主要作用就是对象的初始化,只有数组需要扩容的时候才会新建数组,移动数组元素没有涉及到扩容不会新建数组,通过操作系统的底层内存复制操作来移动元素的存储位置)
remove方法,如果删除的非最后的元素,后面的元素会向前移动,并不会触发缩容,可以使用trimToSize()方法对数组空间进行缩容,这个方法会将 ArrayList 的容量调整为当前元素的数量。
可变长度数组的原理
调用无参的构造函数时,默认初始化的是长度为10的数组(初始化的长度为0当添加第一个长度时分配默认空间10)
调用指定长度的构造函数时,初始化长度为指定长度的数组(初始化是分配指定的长度空间)
当要add元素时判断添加后的长度跟现在数组的长度大小,不够就会进行扩容,扩容到现在的1.5倍(就是新建一个1.5倍的数组),把原来的元素拷贝到新数组里面,然后再添加元素
(如果指定长度为2,add元素后长度超过了2也不会报错,会进行扩容,也能够取到)
Vector每次扩容是原来的2倍
对list中的内容自定义规则排序
可以调用工具类Collections(元素自带比较方法或传入比较器)
Collections.sort(l);//list中的元素必须实现Comparable接口实现compareTo方法,将根据元素的比较方法进行排序
Collections.sort(List<T> list, Comparator<? super T> c)//如果元素没有实现Comparable接口可以向sort方法中传递一个比较器Comparator的实现类,实现compare方法,如果元素已经实现了Comparable接口,还使用这个方法,以这个比较器的比较规则为准
如:
Collections.sort(l, new Comparator<Integer>(){
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
});
LinkedList
数据结构:双向链表
元素在Node中存储,Node是定义来LinkedList类文件中的一个内部类
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;
}
}
常用方法:
void addFirst(E e):头部插入,调用的linkFirst(e)方法
void addLast(E e):尾部插入,调用的linkLast(e)方法
void push(E e):头部插入,调用的addFirst方法
boolean add(E e):尾部插入,调用的linkLast(e)方法
boolean offer(E e):尾部插入,调用的linkLast(e)方法
E pollFirst():查询并移除头元素,如果没有元素返回null
E pollLast():查询并移除尾元素,如果没有元素返回null
E poll():查询并移除头元素,如果没有元素返回null
E removeFirst():查询并移除头元素,如果没有元素抛异常
E removeLast():查询并移除尾元素,如果没有元素抛异常
E remove():调用的removeFirst方法
E peekFirst():查询头元素,如果没有返回null
E peekLast():查询尾元素,如果没有返回null
E peek():查询头元素,如果没有返回null
E getFirst():查询头元素,如果没有元素抛异常
E getLast():查询尾元素,如果没有元素抛异常
E get(int index):按照下标获取元素
遍历方法:增强for循环
LinkedList<String> ll=new LinkedList<String>();
ll.push("1");ll.push("2");
for(String s:ll){
System.out.println(s);
}
hashSet和treeSet
两者:不包含重复元素
hashSet特点:元素无序,元素不重复,允许使用null,有且仅有一个元素为null,线程不安全
hashSet原理:实现本质其实就是HashMap(所以数据结构也是数组+链表/红黑树),hashSet 底层是创建一个HashMap,调用add方法实际上是向HashMap中添加以添加的值为key,空的Object对象(new Object())为value。
TreeSet特点:元素有序,元素不重复,线程不安全
TreeSet原理:实现本质其实就是treeMap,创建一个TreeMap,add方法实际上就是向TreeMap中添加值为key,空的Object对象为value。
向TreeSet中添加的元素,要实现Comparable接口,如果没有实现,也可以给集合传入一个比较器,如果都没有添加元素会报错:
ClassCastException 不能转换为Comparable的转换异常
为什么使用空对象,不使用null:
因为set的remove方法要返回一个boolean值,是是调用的HashMap或treeMap的remove方法,他们remove方法会返回原值,set的remove方法在此基础上判断返回值是否为null来确定是否删除成功,所以如果设置为null就没办法判读了
空对象占用的内存
一个new Object();
实例对象的组成:对象头+对象实际数据+对齐填充
64位操作系统jvm默认是开启指针压缩的
对象头:markword(存储hashcode、锁状态、分代年龄等信息)8字节+类元数据指针(指向方法区类元数据)4字节
对象实际数据:因为没有成员变量0字节
对齐填充:8+4=12,非8字节倍数,填充为8字节倍数
所以空Object占用的堆内存为16字节;
在集合类中放入的是指向堆内存的引用变量,在64位操作系统开启指针压缩的情况下为4字节;
(java基础中的java对象存储结构有详细描述)
hashMap、LinkedHashMap、treeMap、Hashtable、ConcurrentHashMap
hashMap、treeMap、Hashtable、ConcurrentHashMap:键不能重复
HashMap特点:不允许有重复键(否则会覆盖原值),元素无序,可以为null键(只能有一个key为null,再添加就会覆盖原来的)也可以有null值,线程不安全
hashMap原理:hashMap在jdk8以前是数组+链表的数据结构(数组元素是hashEntry),jdk8以后是数组+链表/红黑树的数据结构(数组元素是Node节点)。
多线程put操作会怎么样:1、同时尝试往同一个位置插入数据,其中一个线程的数据会覆盖另一个线程的数据;2、如果同时插入数据时正好要进行扩容数组进行重新分配元素位置,可能会导致数组下的链表形成环,查询时进入循环
LinkedHashMap特点:有序的:插入顺序是就是访问顺序,key重复会覆盖,线程不安全
LinkedHashMap原理:双向链表+hashMap实现
treeMap特点:不允许有重复键,根据key比较规则对元素排序,线程不安全,如果构造函数传入了比较器元素key可以为null,如果没有传比较器,因为要调用compareTo方法会报错
treeMap原理:数据结构是红黑树(红黑树它是复杂而高效的,TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度最差是O(log n))
hashTable:线程安全的,不允许有null键null值(null为键或为值都会报空指针异常)
ConcurrentHashMap特点:线程安全的,在多线程下比Hashtable效率高
ConcurrentHashMap原理:jdk8以前:数据结构为一个 Segment 数组,Segment 的数据结构为 HashEntry 的数组,而 HashEntry 存的是我们的键值对,可以构成链表。
jdk8以后摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。新的实现方式在性能上比之前的Segment机制有所提升。
(JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
之所以不一开始就直接使用红黑树来存储,是因为红黑树每个节点占用的地址空间是链表的两倍,为了节省内存空间,一开始采用的是链表存储
JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点:
因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据)
synchronized和case的使用:在put、remove等方法中
1. put方法:
- 首先,put方法会尝试使用CAS操作来插入新的键值对。
- 如果CAS操作失败(即有其他线程已经修改了相同位置的数据),则会进入synchronized块中进行操作。
- 在synchronized块中,会再次检查是否需要进行插入操作,需要的话执行插入操作。
2. remove方法:
- 类似于put方法,remove方法也会先尝试使用CAS操作来删除指定的键值对。
- 如果CAS操作失败,则会进入synchronized块中进行删除操作。
- 在synchronized块中,会再次检查是否需要进行删除操作,需要的话执行删除操作。
Node类说明:
是ConcurrentHashMap的一个静态内部类,实现了Map.Entry接口,定义的属性有如下几个
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
两个线程同时向同一个位置插入数据会怎么样:只有一个线程能够成功插入数据,而另一个线程可能会失败或重试。
Segment说明:
es中和lucene中也有Segment,es中index会分片而且会分段,一个段有多个document
HashMap
hashMap原理:hashMap在jdk8以前是数组+链表的数据结构(数组元素是hashEntry),jdk8以后是数组+链表/红黑树的数据结构(数组元素是Node节点)。
hashMap中常用的方法:
V put(K key, V value)
V get(Object key)
Set<K> keySet()
Set<Map.Entry<K,V>> entrySet()
hashmap数据结构
jdk8以前:
数组+链表,数组元素是Entry
jdk8及以后:
数组+链表+红黑树,数组元素是Node或TreeNode
Entry和Node和TreeNode
它们都是hashMap的静态内部类,用来存储键值对信息,Node应用于链表,TreeNode应用与红黑树(占用的空间比它们大),
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
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);
}
hashMap快的原因
是因为它的数据结构是数组+链表(单向)+红黑树
查找的时候先根据key经过hash算法找到在数组上的位置,然后对这个位置上的元素进行查找就可以了
hashMap实例创建
使用new HashMap()创建实例,数组使用默认长度16,负载因子0.75
使用new HashMap(int)创建实例,数组使用指定长度,负载因子0.75
使用new HashMap(int,float)创建实例,数组使用指定长度,使用指定的负载因子
第一次put的时候再去创建数组,实际创建的数组长度为大于等于指定值的2的次方数
HashMap的put过程
put方法存数据时,先根据判断map是不是空的(空数组[]),是的话进行初始化容量(数组长度16),然后根据key的值计算出这个元素要存储在数组的位置(即下标),如果数组该位置上没有元素,就直接将该元素插入到此数组中的该位置上,如果数组该位置上已经存放有其他元素了,那么就判断key是不是一样,一样的话就替换原来的值,不一样就判断这个元素是不是红黑树节点,是红黑树节点就遍历红黑树有相同key的就替换,没有就插入,如果不是红黑树就是一个链表,遍历链表有相同的key就覆盖,没有就插入(尾部插入),插入链表成功后,判断链表现在的长度,如果到达了要转化为红黑树的长度(阀值,默认是8),达到了就要转换为红黑树,插入就结束了。然后判断现在hashMap的size(元素总数)是否大于threshold(阈值:数组长度*负载因子(默认0.75)),是的话就要调用resize方法对hashMap进行扩容。最后返回覆盖的旧值或null(没有旧值时);
(jdk8及以后版本数组的元素可以是链表也可以是红黑树,红黑树的引入是为了提高效率。)
(数组:存储区间连续,占用内存严重,寻址容易,插入删除困难;
链表:存储区间离散,占用内存比较宽松,寻址困难,插入删除容易;
Hashmap综合应用了这两种数据结构,实现了寻址容易,插入删除也容易。
hashmap使用的是单链表,每个链表元素指向下一个链表元素
)
链表如何转为红黑树:
新建treeNode遍历链表的每个node,把node元素的内容放入到TreeNode中。插入完毕把数组中元素设置为树的根节点
默认链表中元素个数>=8的时候转为红黑树
链表头插改为尾插
jdk8以前元素是插入到链表的头部,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。
8及以后是插入到尾部,因为如果使用头插法如果多个线程同时扩容会出现循环链表。使用头插扩容时如果计算的数组位置一致会改变链表的上的顺序会倒置,但是如果使用尾插,在扩容时会保持链表元素原本的顺序
一般我们不把hashMap使用在多线程情况下,毕竟不是线程安全的所以很少遇到jdk1.7时出现循环链表的情况
HashMap的get过程
根据元素的key计算出hash值,根据hash值在计算找到数组的位置((n - 1) & hash n是数组长度),然后遍历数组位置上的元素找到key相同的就返回value,没有就null;
hashMap的hash函数
是通过 hashCode() 的高 16 位异或低 16 位实现的
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
不会造成因为高位没有参与下标的计算,从而引起的碰撞。
使用异或是为了保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。
为什么链表阀值设置为8
主要是为了寻找一种时间和空间的平衡。在负载因子0.75(HashMap默认)的情况下,单个hash槽内元素个数为8的概率小于百万分之一;链表中元素个数为8时的概率已经非常小,再多的就更少了作者在选择链表元素个数时选择了8,是根据概率统计而选择的。
红黑树中的TreeNode是链表中的Node所占空间的2倍,虽然红黑树的查找效率为o(logN),要优于链表的o(N),但是当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。所以,要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。
HashMap为什么要引入红黑树
二叉树就是每个父节点下面有零个一个或两个子节点
红黑树本质也是一种二叉查找平衡树
每次插入和删除之后它都要进行额外的调整,以恢复自身的平衡,这是它与普通二叉查找树不同的地方
也正因为如此,红黑树的查找,插入和删除操作在最坏情况下的时间复杂度也能保证为O(logN),其中N为树中元素个数。链表查找的时间复杂度是O(N)。
利用红黑树快速增删改查的特点提高HashMap的性能。
红黑树特点
每个节点非红即黑
根节点总是黑色的
如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
每个叶子节点都是黑色的空节点(NIL节点)
从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)
为什么使用红黑树不使用B树
二叉树和二叉平衡树的时间复杂度不够稳定,红黑树能够保证时间复杂度最坏为O(logN)
二叉树最坏的情况会是O(N),二叉平衡树为了调整平衡插入数据后调整的次数不固定,红黑树可以保证在3次以内
B树和二叉树、红黑树相比较,子树更多也就是路数越多,子树越多表示数的高度越低,搜索效率越高
文件系统和数据库一般都是存在电脑硬盘上的,如果数据量太大的话不一定能一次性加载到内存中。一次io可以加载B树的一个节点到内存中,
应用场景:
红黑树多用于内存中排序,也就是内部排序
B树多用于做文件系统的索引。
B+树多用于数据库中的索引。方便查询多条记录
HashMap的初始化及扩容
jdk1.8之后HashMap使用的是懒加载,构造完HashMap对象后,只要不进行put 方法插入元素之前,HashMap并不会去初始化或者扩容table,刚new完的HashMap是一个空数组[]
默认HashMap的初始化是16(数组的长度),负载因子是0.75
16x0.75=12默认是元素12个就会扩容
扩容:
调用resize()方法,扩容为原来的2倍
为什么要扩容:
减少插入时的冲突,及提高获取数据时效率
什么时候扩容:当向容器添加完元素的时候,会判断当前容器的元素个数,如果大于等于阈值---当即达到加载因子的比例的时候(长度乘以加载因子的值),就要自动扩容啦。默认每次容量翻倍
Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组
数据迁移:
jdk8元素数组下标的计算:
hash值=(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
数组下标=i = (n - 1) & hash]),n代表数组长度,jdk8中数组长度都是2的次幂
总结:扩容元素计算的hash值是不变的,数组下标是key的hash值与数组长度-1的值进行位与计算,扩容以后数组长度翻倍,数组长度的二进制进行左移一位,此时位的高位会由0变为1,这样的话原数组位置的元素如果hash值的该高位也是1位置就要变为原位置+原数组长度,为0位置不变。
数据迁移总结:不需要重新计算hash值,直接计算数组下标,根据算法最后元素的下标在原位置或者在原位+原数组长度的位置。这样就比jdk1.7重新计算hash值和索引位置更加高效,元素移动相对较少。
1.7扩容时需要重新计算哈希值和索引位置,1.8并不重新计算哈希值,巧妙地采用和扩容后容量进行&操作来计算新的索引位置。
自定义HashMap的初始容量
没有指定容量,默认是16
通过调用构造函数HashMap(int initialCapacity)指定初始容量,过程:先判断传入的值是否小于0是的话就抛出IllegalArgumentException,不是的话就跟2的30次方比较,大于就用2的30次方作为初始话长度,否则调用tableSizeFor方法通过位运算把初始值修改为大于等于指定值的2的次方数比如输入13,小于2的4次方,那面计算出来桶的初始容量就是16.
数组长度取2的次幂原因:
这样数据比较均匀碰撞几率小;如果是非2的次幂可能有的位置永远都不会有元素插入
元素位置计算方法:
hash & (length - 1)
jdk7是小于且最靠近指定值的的 2 的次幂
负载因子
加载因子是表中元素的填满的程度。
加载因子越大,填满的元素越多,好处是,空间利用率越高,但:冲突的机会加大
加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了
冲突的机会越大,则查找的成本越高.反之,查找的成本越小.因而,查找时间就越小.
因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的"时-空"矛盾的平衡与折衷.
默认的加载因子是0.75,最大容量是16,因此可以得出HashMap的默认容量是:0.75*16=12。
hashMap使用优化
如果可以预料到元素的数量,可以指定hashMap的长度,比如预料到有30个元素,
30/0.75=40,去2的次幂那就取到64,指定hashMap的长度为64,这样可以避免添加元素过程中进行扩容浪费性能
多线程访问hashMap会有什么问题
元素丢失:
如一个线程判断数组上一个位置没有元素,这个时候另一个线程也进行判断没有元素,都去设置元素会出现有一个元素被覆盖掉
jdk1.7多线程进行扩容时容易形成循环链表,这样的话获取元素就成了死循环一直取不完
HashMap和Hashtable的区别
(条理上还需要整理,也是先说相同点,再说不同点)
他们都实现了Map接口
就HashMap与HashTable主要从三方面来说。
一.历史原因:Hashtable是基于陈旧的Dictionary类的子类,HashMap是Java 1.2引进的Map接口的一个实现
二.同步性:Hashtable是线程安全的,也就是说是同步的,而HashMap是线程序不安全的,不是同步的
三.值:HashMap允许空(null)键值(key),HashTable不可以。
LinkedHashMap
LinkedHashMap继承与HashMap,数据结构是双向链表+hashMap(数组+单链表+红黑树);hashMap用于存储数据,双向链表用于记录每个键值对的先后顺序。
有序的:插入顺序是就是访问顺序
jdk1.7存入的元素是entry:
private static class Entry<K,V> extends HashMap.Entry<K,V> {
// These fields comprise the doubly linked list used for iteration.
Entry<K,V> before, after;
Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
super(hash, key, value, next);
}
jdk1.8
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
相比hashMap的Entry或Node类多了两个属性before和after来记录关系
在LinkedHashMap中同一个键值对在双向链表和哈希表都会存一套数据,双向链表中的数据用于保证有序性,哈希表中的数据用于根据键的哈希值快速查找对应的键值对
treeMap
数据结构:红黑树,基本元素是Entry,定义了红黑树节点需要的属性
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
特点:元素有序的,排序规则是元素自己的比较性或比较器定义的比较规则
比较规则
有两种方法都可以
1:让元素自身具备比较性,需要元素对象实现Comparable接口,覆盖compareTo方法。
2:让集合自身具备比较性,需要定义一个实现了Comparator接口的比较器,并覆盖compare方法,并将该类对象作为实际参数传递给treeMap/TreeSet集合的构造函数。
如果元素没有实现Comparable接口,也没有传入比较器,那样在运行时会发生ClassCastException异常
对于null键,看比较方法中有没有对null的特殊处理,如果没有就会报空指针异常(String没有对null处理,会报空指针)
String类实现了comparable接口,字符串按字典顺序排序
ConcurrentHashMap
线程安全的,在多线程下比Hashtable效率高
数据结构
jdk1.8以前:
segment数组,(segment是类似hashMap的元素,用数组+链表实现)用到的数据结构:数组+链表
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
jek1.8及以后:
Node数组,采用数组+链表+红黑树
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
也有TreeNode
线程安全
JDK1.7中,SegMent继承了ReentrantLock可重入锁,在ConcurrentHashMap的方法中都会先调用tryLock,执行完方法finally方法中进行unlock();所以它是使用可重入锁锁住segment元素来进行操作保证线程安全
JDK1.8中
使用了CAS的方式和Synchronized加锁来保证线程的安全,加锁对象是数组中的链表或树的头节点
jdk1.8相比jdk1.7加锁的方式有变化,锁住的数据更少
读操作不加锁,写操作加锁
读写扩容
jdk1.7中put数据的方式是通过hash的方式先找到插入entry在Segment数组中的位置,然后再通过Hash的方式找到entry在Segment中HashEntry数组中的位置,然后再执行插入到链表中,其中在对segment操作的时候会进行trylock如果获取到锁则执行插入,如果没有获取到锁则会重试3次还没获取到则用阻塞锁的方式获取锁,同时对链表中的节点进行删除操作时,需要将删除节点的前面所有节点复制一遍然后用头插法插入链表(原因是每个节点的next是final的不可修改)。
get数据不需要加锁,如果读到的数据为空则会加锁后再读一遍,因为可能由于某个线程在删除某个节点导致读到的数据为空。(删除某个节点需要把前面所有节点复制一遍重新插入)。
扩容:不会对整个ConcurrentHashMap扩容只会针对某个segment扩容。
jdk1.8中ConcurrentHashMap的节点put流程:
(1)、如果数据没初始化则初始化,通过cas保证只有一个线程再执行初始化
(2)、通过hash方式(与hashMap的hash方式类似只不过将hash值转化为正数)找到要put节点在数组中的位置,如果该位置为空,则通过CAS的方式插入
(3)、如果当前节点正在扩容则该线程参与扩容完成
(4)、如果该位置有节点则通过synchronized加锁判断是如果该节点是链表则查找PUT,如果是红黑树则执行红黑树的PUT,之后判断是否要将链表转化成红黑树。
(5)最后更新size值并且判断是否需要扩容。
总结jdk7与jdk8比较:
数据结构的变化:
jdk7是 由Segment数组组成,默认16个,每个Segment类似hashmap由数组+链表组成,segment的数量不会进行扩容,会对segment中的数组进行扩容;
jdk8是由node数组+链表+红黑树组成,默认有16个数组元素,node数组可以进行扩容
加锁方式的变化:
jdk7是对ConcurrentHashMap继承了ReentrantLock(可重入锁),是对Segment进行加锁(一个segment可能有多个node)
jdk8是通过CAS和synchronized是针对Node对象的(每个node记录了元素的hash,元素的key,元素的value和下一个node,加锁的是数组中的链表或树的头节点)
为什么Hashtable、ConcurrentHashMap不允许有null键或null值
多线程情况下无法判断是key为null还是这个key不存在
因为hashtable,concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。
一个线程先get(key)再containKey(key),这两个方法的中间时刻,其他线程怎么操作这个key都会可能发生,例如删掉这个key
与hashTable区别
hashTable:
jdk7和jdk8都是使用的数组+链表的数据结构,基本元素是Entry,使用synchronized来保证线程安全,用synchronized来修饰整个添加或删除或查询方法,这样的话就会锁定所有了所有内容,效率低下
ConcurrentHashMap:
jdk7使用的数组+数组+链表的数据结构,基本元素是Segment;jdk1.8使用的数组+链表+红黑树的数据结构,基本元素是Node;
对比hashTable只会锁定部分数据,而且jdk8之后的数据结构上有所优化,效率比hashTable高
时间复杂度
hashMap时间复杂度:
get()方法最好情况O(1),最差情况O(N),平均O(1)
put()方法最好情况O(1),最差情况比较复杂,其中底层实现还涉及到map扩容
最好情况 没出现hash碰撞
最坏情况 所有key的hash值都一样
treemap时间复杂度:
因为是红黑树实现,所以平均复杂度都是O(log(n))
hashCode方法的作用?
hashCode方法的作用主要是为了配合基于散列的集合一起正常运行,这样的散列集合包括HashSet,HashMap,HashTable.
为了处理添加的元素不重复,如果只用equals方法就要把集合中所有的元素遍历比较,这样效率不高。
因此就可以先通过对象的hash值进行比较,不同就不用equals进行比较了,相同再通过equals进行比较,就可以明确是否重复了。
List 和 Map 区别?
一个是存储单列数据的集合,另一个是存储键和值这样的双列数据的集合,List中存储的数据是有顺序,并且允许重复;Map中存储的数据是没有顺序的,其键是不能重复的,它的值是可以有重复的。
List、Map、Set三个接口,存取元素时,各有什么特点?
首先,List与Set,它们都是单列元素的集合,所以,它们有一个功共同的父接口,叫Collection。
Set里面不允许有重复的元素。Set取元素时只能遍历,不能通过索引取得单个元素。
List表示有先后顺序的集合, 默认是按先来后到的顺序排序。可以通过带索引值的add方法指定存放位置添加元素,允许有重复元素,其实,并不是把这个对象本身存储进了集合中,而是在集合中用一个索引变量指向这个对象,当这个对象被add多次时,即相当于集合中有多个索引指向了这个对象。List除了可以遍历元素,还可以调用get(index i)来明确说明取第几个。
Map与List和Set不同,它是双列的集合,其中有put方法,定义如下:put(obj key,obj value),每次存储时,要存储一对key/value,不能存储重复的key,这个重复的规则也是按equals比较相等。取则可以根据key获得相应的value,即get(Object key)返回值为key 所对应的value。另外,也可以获得所有的key的集合,还可以获得所有的value的集合,还可以获得key和value组合成的Map.Entry对象的集合。
说一下Properties类
Properties类是HashTable的子类,是线程安全的
主要用于读写.properties类型的配置文件
load(new FileInputStream("src/a.properties"))//加载配置文件,路径以项目文件为基准,然后直接用getProperty(String key)来获得配置文件所对应的值
setProperty(String key, String value)存入
Collection 和 Collections的区别。
Collection是集合类的上级接口,继承他的接口主要有Set 和List.
Collections是针对集合类的一个工具类,他提供一系列静态方法实现对各种集合的搜索、排序、线程安全化等操作。
Collections.synchronizedMap
它可以把非线程安全的map转为线程安全的map
它的方法里面都使用synchronized来修饰逻辑
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
public void putAll(Map<? extends K, ? extends V> map) {
synchronized (mutex) {m.putAll(map);}
}
public void clear() {
synchronized (mutex) {m.clear();}
}
使用实例:
Map<String, Integer> map = new HashMap<>();
//非线程安全操作
map.put("one", 1);
Integer one = map.get("one");
Map<String, Integer> synchronizedMap = Collections.synchronizedMap(map);
//线程安全操作
one = synchronizedMap.get("one");
synchronizedMap.put("two", 2);
Integer two = synchronizedMap.get("two");
两个对象值相同(x.equals(y) == true),但却可有不同的hash code,这句话对不对?
对。
如果对象要保存在HashSet或HashMap中,它们的equals相等,那么,它们的hashcode值就必须相等。
如果不是要保存在HashSet或HashMap,则与hashcode没有什么关系了,这时候hashcode不等是可以的(看你怎么实现hashcode方法),例如arrayList存储的对象就不用实现hashcode,当然,我们没有理由不实现,通常都会去实现的。
对于HashSet,HashMap,HashTable
equals为false的两个对象可能会生成相同的hashcode,所以相同的hash值不能判断是不是相同的对象
equals为ture,则两个对象的hash值必定相等。所以hash值不等则equals方法必定为false。
Stack类
它实现了一个标准的后进先出的栈
Stack继承Vector类,在Vector类的基础上扩展5个方法而来
基本方法:
boolean empty() 判断栈是否为空;
E pop() 移除堆栈顶部的对象,并作为此函数的值返回该对象。
E push(E item) 把项压入栈顶部
E peek() 查看堆栈顶部的对象,但不从堆栈中移除它。
int search(Object o) 返回对象在栈中的位置,以 1 为基数。栈顶的基数为1
pop,peek,search是线程安全的方法
Queue接口
Queue接口是继承了Collection的接口
Queue用来模拟队列这种数据结构,遵循先进先出原则
LinkedList实现了Queue接口,因此我们可以把LinkedList当成Queue来用
使用实例:(offer:供应,poll:选举表决)
public static void main(String[] args) {
//add()和remove()方法在失败的时候会抛出异常(不推荐)
Queue<String> queue = new LinkedList<String>();
//添加元素
queue.offer("a");
queue.offer("b");
queue.offer("c");
queue.offer("d");
queue.offer("e");
for(String q : queue){
System.out.println(q);
}
System.out.println("===");
System.out.println("poll="+queue.poll()); //返回第一个元素,并在队列中删除
for(String q : queue){
System.out.println(q);
}
System.out.println("===");
System.out.println("element="+queue.element()); //返回第一个元素
for(String q : queue){
System.out.println(q);
}
System.out.println("===");
System.out.println("peek="+queue.peek()); //返回第一个元素
for(String q : queue){
System.out.println(q);
}
}
ArrayDeque不是线程安全的。不允许添加Null元素。当ArrayDeque 作为一个栈来使用的时候,ArrayDeque 可能会比Stack 快。当ArrayDeque 作为 队列使用的时候,可能会比 LinkedList 速度要快。

浙公网安备 33010602011771号