java高级知识点[面试宝典]
持续更新中…
Java集合22题
- ArrayList 和 Vector 的区别。
- 说说 ArrayList,Vector, LinkedList 的存储性能和特性。
- 快速失败 (fail-fast) 和安全失败 (fail-safe) 的区别是什么?
- hashmap 的数据结构。
- HashMap 的工作原理是什么?
- Hashmap 什么时候进行扩容呢?
- List、Map、Set 三个接口,存取元素时,各有什么特点?
- Set 里的元素是不能重复的,那么用什么方法来区分重复与否呢? 是用 == 还是 equals()? 它们有何区别?
- 两个对象值相同 (x.equals(y) == true),但却可有不同的 hash code,这句话对不对?
- heap 和 stack 有什么区别。
- Java 集合类框架的基本接口有哪些?
- HashSet 和 TreeSet 有什么区别?
- HashSet 的底层实现是什么?
- LinkedHashMap 的实现原理?
- 为什么集合类没有实现 Cloneable 和 Serializable 接口?
- 什么是迭代器 (Iterator)?
- Iterator 和 ListIterator 的区别是什么?
- 数组 (Array) 和列表 (ArrayList) 有什么区别?什么时候应该使用 Array 而不是 ArrayList?
- Java 集合类框架的最佳实践有哪些?
- Set 里的元素是不能重复的,那么用什么方法来区分重复与否呢?是用 == 还是 equals()?它们有何区别?
- Comparable 和 Comparator 接口是干什么的?列出它们的区别
- Collection 和 Collections 的区别。
1. ArrayList 和 Vector 的区别。
1). Vector的方法都是同步的(Synchronized),是线程安全的(thread-safe),而ArrayList的方法不是,由于线程的同步必然要影响性能,因此,ArrayList的性能比Vector好。
2). 当Vector或ArrayList中的元素超过它的初始大小时,Vector会将它的容量翻倍,而ArrayList只增加50%的大小,这样,ArrayList就有利于节约内存空间。
2. 说说 ArrayList,Vector, LinkedList 的存储性能和特性。
ArrayList和Vector都是使用数组方式存储数据,此数组元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉及数组元素移动等内存操作,所以索引数据快而插入数据效率较低,
Vector由于使用了synchronized(同步)方法(线程安全),通常性能上较ArrayList差。LinkedList使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但是插入数据时只需要记录本项的前后项即可,所以插入速度较快。
LinkedList也是线程不安全的,LinkedList提供了一些方法,使得LinkedList可以被当作堆栈和队列来使用。
3. 快速失败 (fail-fast) 和安全失败 (fail-safe) 的区别是什么?
Iterator的安全失败是基于对底层集合做拷贝,因此,它不受源集合上修改的影响。
java.util包下面的所有的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。而java.util.concurrent包下面的所有的类都是安全失败的。
快速失败的迭代器会抛出ConcurrentModificationException异常,而安全失败的迭代器永远不会抛出这样的异常。
一:快速失败(fail—fast)
在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。 原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。 每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。 注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount这个条件。 如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。 场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。
二:安全失败(fail—safe)
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。 原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。 缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。 场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
名词解释
1.什么是同步修改?
当一个或多个线程正在遍历一个集合Collection,此时另一个线程修改了这个集合的内容(添加,删除或者修改)。这就是并发修改
2.什么是 fail-fast 机制?
fail-fast机制在遍历一个集合时,当集合结构被修改,会抛出Concurrent Modification Exception。
fail-fast会在以下两种情况下抛出ConcurrentModificationException
3.单线程环境
集合被创建后,在遍历它的过程中修改了结构。
注意 remove()方法会让expectModcount和modcount 相等,所以是不会抛出这个异常。
4.多线程环境
当一个线程在遍历这个集合,而另一个线程对这个集合的结构进行了修改。
注意,迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出任何硬性保证。快速失败迭代器会尽最大努力抛出 ConcurrentModificationException。因此,为提高这类迭代器的正确性而编写一个依赖于此异常的程序是错误的做法:迭代器的快速失败行为应该仅用于检测 bug。
fail-fast机制,是一种错误检测机制。它只能被用来检测错误,因为JDK并不保证fail-fast机制一定会发生。若在多线程环境下使用fail-fast机制的集合,建议使用“java.util.concurrent包下的类”去取代“java.util包下的类”。
fail-fast机制是如何检测的?
迭代器在遍历过程中是直接访问内部数据的,因此内部的数据在遍历的过程中无法被修改。为了保证不被修改,迭代器内部维护了一个标记 “mode” ,当集合结构改变(添加删除或者修改),标记"mode"会被修改,而迭代器每次的hasNext()和next()方法都会检查该"mode"是否被改变,当检测到被修改时,抛出Concurrent Modification Exception
fail-safe机制
fail-safe任何对集合结构的修改都会在一个复制的集合上进行修改,因此不会抛出ConcurrentModificationException
fail-safe机制有两个问题
(1)需要复制集合,产生大量的无效对象,开销大
(2)无法保证读取的数据是目前原始数据结构中的数据。
4. HashMap、HashTable、ConcurrentHashMap 的数据结构。
HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即++数组和链表的结合体++。
在同一个版本的Jdk中,HashMap、HashTable以及ConcurrentHashMap里面的hash方法的实现是不同的。再不同的版本的JDK中(Java7 和 Java8)中也是有区别的。
其实简单,我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了。没错,其实基本原理就是这个,只不过,在具体实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。但是考虑到效率等问题,HashMap的实现会稍微复杂一点。
hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。
1). HashMap In Java 7
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length-1);
}
indexFor方法其实主要是将hash生成的整型转换成链表数组中的下标。++那么return++ ++h &++ ++(length-1)++;是什么意思呢?
其实,他就是取模。Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
那么,为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:
X % 2^n = X & (2^n – 1)
2n表示2的n次方,也就是说,一个数对2n取模 == 一个数和(2^n – 1)做按位与运算 。
假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 = 7 ,即0111。
此时X & (2^3 – 1) 就相当于取X的2进制的最后三位数。
从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。
比如 :
6 % 8 = 6 ,6 & 7 = 6
10 & 8 = 2 ,10 & 7 = 2
return h & (length-1);只要保证length的长度是2^n的话,就可以实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,之后每次扩充为原来的2倍。
分析完indexFor方法后,我们接下来准备分析hash方法的具体原理和实现。在深入分析之前,至此,先做个总结。
HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操作时,都需要根据K-V对的键值定位到他应该保存在数组的哪个下标中。而这个通过键值求取下标的操作就叫做哈希。HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。简单的做法是先求取出键值的hashcode,然后在将hashcode得到的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操作实现取模操作。
接下来我们会发现,无论是用取模运算还是位运算都无法直接解决冲突较大的问题。比如:CA11 0000和0001 0000在对0000 1111进行按位与运算后的值是相等的。
两个不同的键值,在对数组长度进行按位与运算后得到的结果相同,这不就发生了冲突吗。那么如何解决这种冲突呢,来看下Java是如何做的。其中的主要代码部分如下:
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
举个例子来说,我们现在想向一个HashMap中put一个K-V对,Key的值为“hollischuang”,经过简单的获取hashcode后,得到的值为“1011000110101110011111010011011”,如果当前HashTable的大小为16,即在不进行扰动计算的情况下,他最终得到的index结果值为11。由于15的二进制扩展到32位为“00000000000000000000000000001111”,所以,一个数字在和他进行按位与操作的时候,前28位无论是什么,计算结果都一样(因为0和任何数做与,结果都为0)。如下图所示。
可以看到,后面的两个hashcode经过位运算之后得到的值也是11 ,虽然我们不知道哪个key的hashcode是上面例子中的那两个,但是肯定存在这样的key,这就产生了冲突。
那么,接下来,我看看一下经过扰动的算法最终的计算结果会如何。
从上面图中可以看到,之前会产生冲突的两个hashcode,经过扰动计算之后,最终得到的index的值不一样了,这就很好的避免了冲突。其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 – 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。
2).HashTable In Java 7
以下是Java 7中HashTable的hash方法的实现。
private int hash(Object k) {
// hashSeed will be zero if alternative hashing is disabled.
return hashSeed ^ k.hashCode();
}
我们可以发现,很简单,相当于只是对k做了个简单的hash,取了一下其hashCode。而HashTable中也没有indexOf方法,取而代之的是这段代码:int index = (hash & 0x7FFFFFFF) % tab.length;。也就是说,HashMap和HashTable对于计算数组下标这件事,采用了两种方法。HashMap采用的是位运算,而HashTable采用的是直接取模。
为啥要把hash值和0x7FFFFFFF做一次按位与操作呢,主要是为了保证得到的index的第一位为0,也就是为了得到一个正数。因为有符号数第一位0代表正数,1代表负数。
我们前面说过,HashMap之所以不用取模的原因是为了提高效率。有人认为,因为HashTable是个线程安全的类,本来就慢,所以Java并没有考虑效率问题,就直接使用取模算法了呢?但是其实并不完全是,Java这样设计还是有一定的考虑在的,虽然这样效率确实是会比HashMap慢一些。
其实,HashTable采用简单的取模是有一定的考虑在的。这就要涉及到HashTable的构造函数和扩容函数了。由于篇幅有限,这里就不贴代码了,直接给出结论:
++HashTable默认的初始大小为11,之后每次扩充为原来的2n+1。++
++也就是说,HashTable的链表数组的默认大小是一个素数、奇数。之后的每次扩充结果也都是奇数。++
++由于HashTable会尽量使用素数、奇数作为容量的大小。当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀。++(这个是可以证明出来的,由于不是本文重点,暂不详细介绍,可参考:http://zhaox.github.io/algorithm/2015/06/29/hash)
至此,我们看完了Java 7中HashMap和HashTable中对于hash的实现,我们来做个简单的总结。
- HashMap默认的初始化大小为16,之后每次扩充为原来的2倍。
- HashTable默认的初始大小为11,之后每次扩充为原来的2n+1。
- 当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀,所以单从这一点上看,HashTable的哈希表大小选择,似乎更高明些。因为hash结果越分散效果越好。
- 在取模计算时,如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要大大高于做除法。所以从hash计算的效率上,又是HashMap更胜一筹。
- 但是,HashMap为了提高效率使用位运算代替哈希,这又引入了哈希分布不均匀的问题,所以HashMap为解决这问题,又对hash算法做了一些改进,进行了扰动计算。
3).ConcurrentHashMap In Java 7
private int hash(Object k) {
int h = hashSeed;
if ((0 != h) && (k instanceof String)) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
int j = (hash >>> segmentShift) & segmentMask;
上面这段关于ConcurrentHashMap的hash实现其实和HashMap如出一辙。都是通过位运算代替取模,然后再对hashcode进行扰动。区别在于,ConcurrentHashMap 使用了一种变种的Wang/Jenkins 哈希算法,其主要目的也是为了把高位和低位组合在一起,避免发生冲突。至于为啥不和HashMap采用同样的算法进行扰动,我猜这只是程序员自由意志的选择吧。至少我目前没有办法证明哪个更优。
4).HashMap In Java 8
在Java 8 之前,HashMap和其他基于map的类都是通过链地址法解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)降低到O(n)。为了解决在频繁冲突时hashmap性能降低的问题,Java 8中使用平衡树来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)。
如果恶意程序知道我们用的是Hash算法,则在纯链表情况下,它能够发送大量请求导致哈希碰撞,然后不停访问这些key导致HashMap忙于进行线性查找,最终陷入瘫痪,即形成了拒绝服务攻击(DoS)。
关于Java 8中的hash函数,原理和Java 7中基本类似。Java 8中这一步做了优化,只做一次16位右位移异或混合,而不是四次,但原理是不变的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h>>>16),主要是从速度、功效、质量来考虑的。以上方法得到的int的hash值,然后再通过 h & (table.length -1) 来得到该对象在数据中保存的位置。
5).HashTable In Java 8
在Java 8的HashTable中,已经不在有hash方法了。但是哈希的操作还是在的,比如在put方法中就有如下实现:
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
这其实和Java 7中的实现几乎无差别
6).ConcurrentHashMap In Java 8
Java 8 里面的求hash的方法从hash改为了spread。实现方式如下:
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java 8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位作异或并保证最高位为0(从而保证最终结果为正整数)。
总结
至此,我们已经分析完了HashMap、HashTable以及ConcurrentHashMap分别在Jdk 1.7 和 Jdk 1.8中的实现。我们可以发现,为了保证哈希的结果可以分散、为了提高哈希的效率,JDK在一个小小的hash方法上就有很多考虑,做了很多事情。当然,我希望我们不仅可以深入了解背后的原理,还要学会这种对代码精益求精的态度。
原文出处: 李大辉的博客
https://blog.csdn.net/skiof007/article/details/80253587
5.HashMap 的工作原理是什么?
HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的 hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键 值对,然后返回值对象。HashMap使用LinkedList来解决碰撞问题,当发生碰撞了,对象将会储存在LinkedList的下一个节点中。 HashMap在每个LinkedList节点中储存键值对对象。
“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”
一些面试者可能可以给出答案,“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用 hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。”这里关键点在于指出,HashMap是在 bucket中储存键对象和值对象,作为Map.Entry。这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在 bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及 HashMap的工作原理。
如果两个键的hashcode相同,你如何获取值对象?
面试者会回答:当我们调用get()方 法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。面试官提醒他如果有两个值对象储存在同一个bucket,他给 出答案:将会遍历LinkedList直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者直到 HashMap在LinkedList中存储的是键值对,否则他们不可能回答出这一题。
其中一些记得这个重要知识点的面试者会说,找到bucket位置之后,会调用keys.equals()方法去找到LinkedList中正确的节点,最终找到要找的值对象。完美的答案!
许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而 equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和 hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用 String,Interger这样的wrapper类作为键是非常好的选择。
如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
除 非你真正知道HashMap的工作原理,否则你将回答不出这道题。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时 候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放 入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。
你了解重新调整HashMap大小存在什么问题吗?
++当多线程的情况下,可能产生条件竞争(race condition)。++
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整 大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在 LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问了,++为什么这么奇怪,要在多线程的环境下使用HashMap 呢?++)
热心的读者贡献了更多的关于HashMap的问题:
- 为什么String, Interger这样的wrapper类适合作为键?
String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final 的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不 可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象 的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的 hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
- 我们可以使用自定义的对象作为键吗?
这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
- 我们可以使用CocurrentHashMap来代替HashTable吗?
这是另外一个很热门的面试题,因为 ConcurrentHashMap越来越多人用了。我们知道HashTable是synchronized的,但是ConcurrentHashMap 同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是 HashTable提供更强的线程安全性。可以去查看Hashtable和ConcurrentHashMap的区别。
让我们再来看看这些问题设计哪些知识点:
- hashing的概念
- HashMap中解决碰撞的方法
- equals()和hashCode()的应用,以及它们在HashMap中的重要性
- 不可变对象的好处
- HashMap多线程的条件竞争
- 重新调整HashMap的大小
6. Hashmap 什么时候进行扩容呢?
当元素向HashMap容器中添加元素的时候,会判断当前元素的个数,如果当前元素的个数大于等于阈值时,即当前数组table的长度*加载因子就要进行自动扩容。
由于HashMap的底层数据结构是“链表散列”,即数组和链表的组合,而数组是无法自动扩容的,所以只能是换一个更大的数组去装填以前的元素和将要添加的新元素
分析resize()方法的源代码可以发现:在jdk1.7以前,resize()方法传入的参数是新数组的容量,HashMap也不是无限能扩容的,
- 方法中会首先判断扩容前的旧数组容量是否已经达到最大即2^30了,如果达到了就修改阈值为int的最大取值,这样以后就不会扩容了。
- 初始化一个新Entry数组;
- 计算rehash,判断扩容的时候是否需要重新计算hash值,将此值作为参数传入到transfer方法中;这个是和jdk1.8不同的一点,待会看1.8
- 通过transfer方法将旧数组中的元素复制到新数组,在这个方法中进行了包括释放就的Entry中的对象引用,该过程中如果需要重新计算hash值就重新计算,然后根据indexfor()方法计算索引值。而索引值的计算方法为{ return h & (length-1) ;}即hashcode计算出的hash值和数组长度进行与运算。。。。。jdk1.7中重新插入到新数组的元素,如果原来一条链上的元素又被分配到同一条链上那么他们的顺序会发生倒置这个和1.8也不一样
jdk1.8的优化
- resize方法源码融入了红黑树,本质和1.7区别不大,但是在插入元素的时候循环旧数组内的元素时会进行判断,如果是普通节点直接和1.7一样放置;如果是红黑树结构,就调用split()方法进行拆分放置;如果是链表,则采用下面2中要分析的方式!!!!
- 在经过一次容量判断是否大于最大值之后在进行扩容,使用的扩容方法是2次幂的扩展,**所以元素要么在原来的位置,要么在原位置在移动2次幂的位置,**如下图:图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,其实我们在扩容的时候不需要像jdk1.7那样重新计算hash,只要看看原hash值新增的那个bit位是1还是0就好了,是0的话索引没有变,是1的话索引变成“原索引+oldCap(旧数组大小)”,下图位resize()方法示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,也就是说1.8不用重新计算hash值而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,因为他采用的是头插法,先拿出旧链表头元素。但是从上图可以看出**,JDK1.8不会倒置,采用的尾插法。**看了网上很多文章,说HashMap在元素达到负载因子对应数的时候就发生扩容。如果你看过源码就会发现,其实还有一种情况也可能会发生扩容:树形化的时候。
HashMap只有容量达到阀值才发生扩容吗?大错特错!
- 对象最终是如何放入HashMap中的? (Java1.8)
上面已经讲过:先拿到要add进HashMap中的对象的hashCode,再将这个hashCode异或上对象自身hashCode右移16位,这个步骤叫扰乱,这样做的目的是为了让hashCode每一位都尽可能用到,hashCode经过上述步骤之后再&(数组长度-1),计算的结果就是这个对象在数组中的位置了
现在假设一个极端情况(不可能发生,但是我用这个举例子):
假设数组长度为1,根据源码:
数组位置=h&(数组长度-1)
那么有:
数组位置=h&(1-1)=0 ,无论什么对象,都定位到数组的第0个位置。
这个很好理解吧。无论元素是否一样,由于数组长度为1,所以元素通通定位到数组中第0个位置。大家都知道一个数组只能放一个元素啊?那怎么办呢?我们用链表来解决这个问题,把定位到这个位置的元素通过链表连接。这就是我一开始说的:hashMap是数组+链表。那树形化又是什么东东呢?
想一下我们为什么要用HashMap,是因为通过Hash算法在理想情况下时间复杂度O(1)就能找到元素,特别快,但是我都说了是理想情况,如果遇到上述发生hash碰撞(哪个取的名字?,就是上面我才说的,两个元素定位到数组中同一个位置),且hash碰撞比较频繁的话,那么当我们get一个元素的时候,定位到了这个数组,还需要在数组中遍历一次链表最终才能找到要get的元素,是不是已经失去一部分使用HashMap的初心了?(因为需要遍历链表,所以时间复杂度就比之前高了)
所以JDK1.8使用红黑树这种数据结构来解决链表过长的问题(可以简单理解为用红黑树遍历比链表遍历速度快,时间复杂度低,不懂红黑树的可以去搜搜看),默认链表长度达到8就将链表树形化(变为红黑树)。回到最最开始我提到的,那为什么树形化的时候可能会发生扩容呢?
想想刚刚的例子数组长度为1,所有元素全部在数组的第0个位置形成一条链表,这例子是一种极端情况,数组长度过小,那自然就会经常发生hash碰撞,那形成长链表是肯定的,这个时候树形化其实是治标不治本,因为引起链表过长的根本原因是数组过短,所以在JDK1.8源码中,执行树形化之前,会先检查数组长度,如果长度小于64,则对数组进行扩容,而不是进行树形化。
++所以发生扩容的时候有两种情况,一种是元素达到阀值了,一种是HashMap准备树形化但又发现数组太短,这两种情况均可能发生扩容。++
7. List、Map、Set 三个接口,存取元素时,各有什么特点?
List与Set都是单列元素的集合,它们有一个功共同的父接口Collection。
Set里面不允许有重复的元素,没有放入顺序元素无放入顺序,元素不可重复(注意:元素虽然无放入顺序,但是元素在set中的位置是有该元素的HashCode决定的,其位置其实是固定的
存元素:add方法有一个boolean的返回值,当集合中没有某个元素,此时add方法可成功加入该元素时,则返回true;当集合含有与某个元素equals相等的元素时,此时add方法无法加入该元素,返回结果为false。
取元素:没法说取第几个,只能以Iterator接口取得所有的元素,再逐一遍历各个元素。
List表示有先后顺序的集合,可重复
存元素:多次调用add(Object)方法时,每次加入的对象按先来后到的顺序排序,也可以插队,即调用add(int index,Object)方法,就可以指定当前对象在集合中的存放位置。
取元素:
方法1:Iterator接口取得所有,逐一遍历各个元素
方法2:调用get(index i)来明确说明取第几个。
Map是双列的集合,无放入顺序
存放用put方法:put(obj key,obj value),每次存储时,要存储一对key/value,不能存储重复的key,这个重复的规则也是按equals比较相等。
取元素:
用get(Object key)方法根据key获得相应的value。
也可以获得所有的key的集合,还可以获得所有的value的集合。
还可以获得key和value组合成的Map.Entry对象的集合。
List以特定次序来持有元素,可有重复元素。Set 无法拥有重复元素,内部排序。Map 保存key-value值,value可多值。
Set和Map容器都有基于 ++哈希存储++ 和 ++排序树++ 的两种实现版本,基于哈希存储的版本理论存取时间复杂度为O(1),而基于排序树版本的实现在插入或删除元素时会按照元素或元素的键(key)构成排序树从而达到排序和去重的效果。
1.List接口有三个实现类:LinkedList,ArrayList,Vector
LinkedList:底层基于链表实现,链表内存是散乱的,每一个元素存储本身内存地址的同时还存储下一个元素的地址。链表增删快,查找慢.
ArrayList和Vector的区别:ArrayList是非线程安全的,效率高;Vector是基于线程安全的,效率低.
2.Set接口有两个实现类:HashSet(底层由HashMap实现),LinkedHashSet
3.SortedSet接口有一个实现类:TreeSet(底层由平衡二叉树实现)
4.Query接口有一个实现类:LinkList
5.Map接口有三个实现类:HashMap,HashTable,LinkeHashMap
HashMap非线程安全,高效,支持null;
HashTable线程安全,低效,不支持null
6.SortedMap有一个实现类:TreeMap
list是用来处理序列的,而set是用来处理集的。Map是知道的,存储的是键值对
set 一般无序不重复.map kv 结构 list 有序这样的题属于随意发挥题:这样的题比较考水平,两个方面的水平:
1.是要真正明白这些内容。
2.是要有较强的总结和表述能力。
如果你明白,但表述不清楚,在别人那里则等同于不明白。
首先,List与Set具有相似性,它们都是单列元素的集合,所以,它们有一个功共同的父接口,叫Collection。Set里面不允许有重复的元素,所谓重复,即不能有两个相等(注意,不是仅仅是相同)的对象 ,即假设Set集合中有了一个A对象,现在我要向Set集合再存入一个B对象,但B对象与A对象equals相等,则B对象存储不进去,所以,Set集合的add方法有一个boolean的返回值,当集合中没有某个元素,此时add方法可成功加入该元素时,则返回true,当集合含有与某个元素equals相等的元素时,此时add方法无法加入该元素,返回结果为false。Set取元素时,没法说取第几个,只能以Iterator接口取得所有的元素,再逐一遍历各个元素。
List表示有先后顺序的集合, 注意,不是那种按年龄、按大小、按价格之类的排序。当我们多次调用add(Obj e)方法时,每次加入的对象就像火车站买票有排队顺序一样,按先来后到的顺序排序。有时候,也可以插队,即调用add(int index,Obj e)方法,就可以指定当前对象在集合中的存放位置。一个对象可以被反复存储进List中,每调用一次add方法,这个对象就被插入进集合中一次,其实,并不是把这个对象本身存储进了集合中,而是在集合中用一个索引变量指向这个对象,当这个对象被add多次时,即相当于集合中有多个索引指向了这个对象,如图x所示。List除了可以以Iterator接口取得所有的元素,再逐一遍历各个元素之外,还可以调用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对象的集合。
List以特定次序来持有元素,可有重复元素。Set 无法拥有重复元素,内部排序。Map 保存key-value值,value可多值。
HashSet按照hashcode值的某种运算方式进行存储,而不是直接按hashCode值的大小进行存储。例如,“abc” —> 78,“def” —> 62,“xyz” —> 65在hashSet中的存储顺序不是62,65,78。
LinkedHashSet按插入的顺序存储,那被存储对象的hashcode方法还有什么作用呢?HashSet集合比较两个对象是否相等,首先看hashcode方法是否相等,然后看equals方法是否相等。new 两个Student插入到HashSet中,看HashSet的size,实现hashcode和equals方法后再看size。
同一个对象可以在Vector中加入多次。往集合里面加元素,相当于集合里用一根绳子连接到了目标对象。往HashSet中却加不了多次的(不支持重复元素)。
8. Set 里的元素是不能重复的,那么用什么方法来区分重复与否呢? 是用 == 还是 equals()? 它们有何区别?
Set是Collection容器的一个子接口,它不允许出现重复元素,当然也只允许有一个null对象。
那如何来区分重复与否呢?(how)
“ 用 iterator() 方法来区分重复与否 ”,这是在网上流传的答案,个人认为这是个错误的答案。API中写的很明白:“set 不包含满足
e1.equals(e2) 的元素对 e1 和 e2 ”,由此可见回答使用equals()区分更合适。为什么用equals()而不用==来区分?(why)
==是用来判断两者是否是同一对象(同一事物),而equals是用来判断是否引用同一个对象。再看一下Set里面存的是
对象,还是对象的引用。根据java的存储机制可知,set里面存放的是对象的引用,所以当两个元素只要满足了equals()时就已经指向同一个对象,
也就出现了重复元素。所以应该用equals()来判断。字符串的比较基本上都是使用 equals 方法。
==操作符专门用来比较两个变量的值是否相等,也就是用于比较变量所对应的内存中所存
储的数值是否相同,要比较两个基本类型的数据或两个引用变量是否相等,只能用操作符==。
如果一个类没有自己定义 equals 方法,它默认的 equals 方法(从 Object 类继承
的)就是使用==操作符,也是在比较两个变量指向的对象是否是同一对象,这时候使用
equals 和使用.==会得到同样的结果,如果比较的是两个独立的对象则总返回 false。如果你
编写的类希望能够比较该类创建的两个实例对象的内容是否相同,那么你必须覆盖 equals
方法,由你自己写代码来决定在什么情况即可认为两个对象的内容是相同的==:
基本类型:比较的是值是否相同
引用类型:比较的是地址值是否相同
equals():
引用类型:默认情况下,比较的是地址值,可进行重写,比较的是对象的成员变量值是否相同
9.两个对象值相同 (x.equals(y) == true),但却可有不同的 hash code,这句话对不对?
答:不对,有相同的 hash code
这是java语言的定义:
1). 对象相等则hashCode一定相等;
2). hashCode相等对象未必相等
1.++如果是基本变量,没有hashcode和equals方法,基本变量的比较方式就只有==;++
2.++如果是变量,由于在java中所有变量定义都是一个指向实际存储的一个句柄(你可以理解为c++中的指针),在这里==是比较句柄的地址(你可以理解为指针的存储地址),而不是句柄指向的实际内存中的内容,如果要比较实际内存中的内容,那就要用equals方法++,但是!!!
如果是你自己定义的一个类,比较自定义类用equals和==是一样的,都是比较句柄地址
因为自定义的类是继承于object,而object中的equals就是用==来实现的,你可以看源码。
那为什么我们用的String等等类型equals是比较实际内容呢,是因为String等常用类已经重写了object中的equals方法,让equals来比较实际内容
首先equals方法必须满足自反性(x.equals(x)必须返回true)、对称性(x.equals(y)返回true时,y.equals(x)也必须返回true)、传递性(x.equals(y)和y.equals(z)都返回true时,x.equals(z)也必须返回true)和一致性(当x和y引用的对象信息没有被修改时,多次调用x.equals(y)应该得到同样的返回值),而且对于任何非null值的引用x,x.equals(null)必须返回false。
实现高质量的equals方法的诀窍包括:
- 使用==操作符检查”参数是否为这个对象的引用”;
- 使用instanceof操作符检查”参数是否为正确的类型”;
- 对于类中的关键属性,检查参数传入对象的属性是否与之相匹配;
- 编写完equals方法后,问自己它是否满足对称性、传递性、一致性;
- 重写equals时总是要重写hashCode;
- 不要将equals方法参数中的Object对象替换为其他的类型,在重写时不要忘掉@Override注解。
10.heap 和 stack 有什么区别。
java 的内存分为两类,一类是栈内存,一类是堆内存。栈内存是指程序进入一个方法时,
会为这个方法单独分配一块私属存储空间,用于存储这个方法内部的局部变量,当这个方法
结束时,分配给这个方法的栈会释放,这个栈中的变量也将随之释放。
堆是与栈作用不同的内存,一般用于存放不放在当前方法栈中的那些数据,例如,使用 new
创建的对象都放在堆里,所以,它不会随方法的结束而消失。 ++方法中的局部变量使用 final
修饰后,放在堆中,而不是栈中。++区别:
- heap是堆,stack是栈。
- stack的空间由操作系统自动分配和释放,heap的空间是手动申请和释放的,heap常用new关键字来分配。
- stack空间有限,heap的空间是很大的自由区。
- 在Java中,若只是声明一个对象,则先在栈内存中为其分配地址空间,
- 若再new一下,实例化它,则在堆内存中为其分配地址。
举例:
++数据类型 变量名;这样定义的东西在栈区。++
如:Object a =null; 只在栈内存中分配空间
++new 数据类型();或者malloc(长度); 这样定义的东西就在堆区++
如:Object b =new Object(); 则在堆内存中分配空间
11.Java 集合类框架的基本接口有哪些?
总共有两大接口:Collection 和Map ,一个元素集合,一个是键值对集合;
其中List和Set接口继承了Collection接口,一个是有序元素集合,一个是无序元素集合;
而ArrayList和 LinkedList 实现了List接口,HashSet实现了Set接口,这几个都比较常用;
HashMap 和HashTable实现了Map接口,并且HashTable是线程安全的,但是HashMap性能更好;
Java集合类里最基本的接口有:
- Collection:单列集合的根接口
- List:元素有序 可重复
- ArrayList:类似一个长度可变的数组 。适合查询,不适合增删
- LinkedList:底层是双向循环链表。适合增删,不适合查询。
- Set:元素无序,不可重复
- HashSet:根据对象的哈希值确定元素在集合中的位置
- TreeSet: 以二叉树的方式存储元素,实现了对集合中的元素排序
- Map:双列集合的根接口,用于存储具有键(key)、值(value)映射关系的元素。
- HashMap:用于存储键值映射关系,不能出现重复的键key
- TreeMap:用来存储键值映射关系,不能出现重复的键key,所有的键按照二叉树的方式排列
12.HashSet 和 TreeSet 有什么区别?
相同点:单例集合,数据不可重复
不同点1:底层使用的储存数据结构不同:
- 1.Hashset底层使用的是HashMap哈希表结构储存
- 2.而Treeset底层用的是TreeMap树结构储存。
不同点2:储存的数据保存唯一方式不用。
- 1.Hashset是通过复写hashCode()方法和equals()方法来保证的。
- 2.而Treeset是通过Compareable接口的compareto方法来保证的。
不同点3:
- hashset无序 Treeset有序
储存原理:
++hashset++ :底层数据结构是哈希表,本质就是哈希值储存。通过判断元素的hashcode方法和equals方法来保证元素的唯一性。当哈希值不同时就直接进行储存。如果相同,会判断一次equals方式是否返回为true ,如果是true 则视为用的同一个元素,不用再储存。 如果是false,这俄格相同哈希值不同内容的元素会放在同一个桶里(当哈希表中有一个桶结构,每一个捅都有一个哈希值)
如果是HashSet子类,那么其判断重复数据的方式不是依靠的comparable接口而是Object类之中的两个方法:(1)取得对象的哈希码 hashCode();(2)对象比较:equals();
如果数组中的元素和要加入的对象的hashCode()返回了相同的Hash值(相同对象),才会用equals()方法来判断两个对象的内容是否相同。
++Treeset++ :底层数据结构式一个二叉树,可以对set集合中的元素进行排序,这种结构,可以提高排序性能。根据比较方法的返回值决定的,只要返回的是0,就代表元素重复。
利用TreeSet保存自定义类对象的时候,自定义所在的类一定要实现Comparable接口,如果没有实现这个接口那么就无法区分大小关系,而且在TreeSet中如果要进行排序,那么就要将所有的字段都进行比较,就是说在TreeSet中是依靠comparato()方法返回的是不是0来判断是不是重复元素的。
13.HashSet 的底层实现是什么?
HashSet实现Set接口,由哈希表(实际上是一个HashMap实例)支持。它不保证 set 的迭代顺序;特别是它不保证该顺序恒久不变。此类允许使用null元素。HashSet中不允许有重复元素,这是因为HashSet是基于HashMap实现的,HashSet中的元素都存放在HashMap的key上面,而value中的值都是统一的一个private static final Object PRESENT = new Object();。++HashSet跟HashMap一样,都是一个存放链表的数组++。
HashSet中add方法调用的是底层HashMap中的put()方法,而如果是在HashMap中调用put,首先会判断key是否存在,如果key存在则修改value值,如果key不存在这插入这个key-value。而在set中,因为value值没有用,也就不存在修改value值的说法,因此往HashSet中添加元素,首先判断元素(也就是key)是否存在,如果不存在这插入,如果存在着不插入,这样HashSet中就不存在重复值。
HashSet源码如下:
public class HashSet<E>
extends AbstractSet<E>
implements Set<E>, Cloneable, java.io.Serializable
{
static final long serialVersionUID = -5024744406713321676L;
// 底层使用HashMap 来保存HashSet 中所有元素。
private transient HashMap<E,Object> map;
// 定义一个虚拟的Object 对象作为HashMap 的value,将此对象定义为static final。
private static final Object PRESENT = new Object();
/*
默认的无参构造器,构造一个空的HashSet。
实际底层会初始化一个空的HashMap,并使用默认初始容量为16 和加载因子0.75。
*/
public HashSet() {
map = new HashMap<>();
}
/*
构造一个包含指定collection 中的元素的新set。
实际底层使用默认的加载因子0.75 和足以包含指定
collection 中所有元素的初始容量来创建一个HashMap。
@param c 其中的元素将存放在此set 中的collection。
*/
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
/*
以指定的initialCapacity 和loadFactor 构造一个空的HashSet。
实际底层以相应的参数构造一个空的HashMap。
*/
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
/*
以指定的initialCapacity 构造一个空的HashSet。
实际底层以相应的参数及加载因子loadFactor 为0.75 构造一个空的HashMap。
*/
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
/*
以指定的initialCapacity 和loadFactor 构造一个新的空链接哈希集合。
此构造函数为包访问权限,不对外公开,实际只是是对LinkedHashSet 的支持。
实际底层会以指定的参数构造一个空LinkedHashMap 实例来实现。
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
/*
返回对此set 中元素进行迭代的迭代器。返回元素的顺序并不是特定的。
底层实际调用底层HashMap 的keySet 来返回所有的key。
可见HashSet 中的元素,只是存放在了底层HashMap 的key 上,
value 使用一个static final 的Object 对象标识。
@return 对此set 中元素进行迭代的Iterator
*/
public Iterator<E> iterator() {
return map.keySet().iterator();
}
/*
返回此set 中的元素的数量(set 的容量)。
底层实际调用HashMap 的size()方法返回Entry 的数量,就得到该Set 中元素的个数。
*/
public int size() {
return map.size();
}
/*
如果此set 不包含任何元素,则返回true。
底层实际调用HashMap 的isEmpty()判断该HashSet 是否为空。
*/
public boolean isEmpty() {
return map.isEmpty();
}
/*
如果此set 包含指定元素,则返回true。
更确切地讲,当且仅当此set 包含一个满足(o==null ? e==null : o.equals(e))的
e 元素时,返回true。
底层实际调用HashMap 的containsKey 判断是否包含指定key。
*/
public boolean contains(Object o) {
return map.containsKey(o);
}
/*
如果此set 中尚未包含指定元素,则添加指定元素。
更确切地讲,如果此 set 没有包含满足(e==null ? e2==null : e.equals(e2))
的元素e2,则向此set 添加指定的元素e。
如果此set 已包含该元素,则该调用不更改set 并返回false。
底层实际将将该元素作为key 放入HashMap。
由于HashMap 的put()方法添加key-value 对时,当新放入HashMap 的Entry 中key
与集合中原有Entry 的key 相同(hashCode()返回值相等,通过equals 比较也返回true)
新添加的Entry 的value 会将覆盖原来Entry 的value,但key 不会有任何改变,
因此如果向HashSet 中添加一个已经存在的元素时,新添加的集合元素将不会被放入HashMap 中,
原来的元素也不会有任何改变,这也就满足了Set 中元素不重复的特性。
*/
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
/*
如果指定元素存在于此set 中,则将其移除。
更确切地讲,如果此set 包含一个满足(o==null ? e==null : o.equals(e))的元素e, 则将其移除。
如果此set 已包含该元素,则返回true。
(或者:如果此set 因调用而发生更改,则返回true。一旦调用返回,则此set 不再
包含该元素)。
底层实际调用HashMap 的remove 方法删除指定Entry
*/
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
/*
从此set 中移除所有元素。此调用返回后,该set 将为空。
底层实际调用HashMap 的clear 方法清空Entry 中所有元素。
*/
public void clear() {
map.clear();
}
/*
返回此HashSet 实例的浅表副本:并没有复制这些元素本身。
底层实际调用HashMap 的clone()方法,获取HashMap 的浅表副本,并设置到HashSet 中。
*/
@SuppressWarnings("unchecked")
public Object clone() {
try {
HashSet<E> newSet = (HashSet<E>) super.clone();
newSet.map = (HashMap<E, Object>) map.clone();
return newSet;
} catch (CloneNotSupportedException e) {
throw new InternalError(e);
}
}
/*
序列化,
首先是map.capacity,
然后是map.loadFactor,
接着是map.size,
最后是set中的每个元素
*/
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
// Write out any hidden serialization magic
s.defaultWriteObject();
// Write out HashMap capacity and load factor
s.writeInt(map.capacity());
s.writeFloat(map.loadFactor());
// Write out size
s.writeInt(map.size());
// Write out all elements in the proper order.
for (E e : map.keySet())
s.writeObject(e);
}
/*
反序列化
capacity
loadFactor
size
判断创建linkedHashmap还是HashMap
每个元素
*/
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// Read in any hidden serialization magic
s.defaultReadObject();
// Read capacity and verify non-negative.
int capacity = s.readInt();
if (capacity < 0) {
throw new InvalidObjectException("Illegal capacity: " +
capacity);
}
// Read load factor and verify positive and non NaN.
float loadFactor = s.readFloat();
if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
}
// Read size and verify non-negative.
int size = s.readInt();
if (size < 0) {
throw new InvalidObjectException("Illegal size: " +
size);
}
// Set the capacity according to the size and load factor ensuring that
// the HashMap is at least 25% full but clamping to maximum capacity.
capacity = (int) Math.min(size * Math.min(1 / loadFactor, 4.0f),
HashMap.MAXIMUM_CAPACITY);
// Create backing HashMap
map = (((HashSet<?>)this) instanceof LinkedHashSet ?
new LinkedHashMap<E,Object>(capacity, loadFactor) :
new HashMap<E,Object>(capacity, loadFactor));
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
@SuppressWarnings("unchecked")
E e = (E) s.readObject();
map.put(e, PRESENT);
}
}
/**
* Creates a <em><a href="Spliterator.html#binding">late-binding</a></em>
* and <em>fail-fast</em> {@link Spliterator} over the elements in this
* set.
*
* <p>The {@code Spliterator} reports {@link Spliterator#SIZED} and
* {@link Spliterator#DISTINCT}. Overriding implementations should document
* the reporting of additional characteristic values.
*
* @return a {@code Spliterator} over the elements in this set
* @since 1.8
*/
public Spliterator<E> spliterator() {
return new HashMap.KeySpliterator<E,Object>(map, 0, -1, 0, 0);
}
}
对于HashSet 中保存的对象,请注意正确重写其equals 和hashCode 方法,以保证放入的对象的唯一性。
覆盖hashCode()方法的原则:
- 一定要让那些我们认为相同的对象返回相同的hashCode值
- 尽量让那些我们认为不同的对象返回不同的hashCode值,否则,就会增加冲突的概率。
- 尽量的让hashCode值散列开(两值用异或运算可使结果的范围更广)
如果数组中的元素和要加入的对象的hashCode()返回了相同的Hash值(相同对象),才会用equals()方法来判断两个对象的内容是否相同。
14.HashTable 的底层实现是什么?
首先,我们来了解一下HashMap和HashTable的区别:
- HashMap是非同步的,没有对读写等操作进行锁保护,所以是线程不安全的,在多线程场景下会出现数据不一致的问题。而HashTable是同步的,所有的读写等操作都进行了锁(synchronized)保护,在多线程环境下没有安全问题。但是锁保护也是有代价的,会对读写的效率产生较大影响。
- ++HashMap结构中++,是允许保存null的,Entry.key和Entry.value均可以为null。但是++HashTable中++是不允许保存null的。
- HashMap的迭代器(Iterator)是fail-fast迭代器,但是Hashtable的迭代器(enumerator)不是fail-fast的。如果有其它线程对HashMap进行的添加/删除元素,将会抛出ConcurrentModificationException,但迭代器本身的remove方法移除元素则不会抛出异常。这条同样也是Enumeration和Iterator的区别。
原理:
HashTable类中,保存实际数据的,依然是Entry对象。其数据结构与HashMap是相同的。
HashTable类继承自Dictionary类,实现了三个接口,分别是++Map,Cloneable和java.io.Serializable++,如下图所示。
HashTable中的主要方法,如put,get,remove和rehash等,与HashMap中的功能相同,这里不作赘述
HashTable的主要方法的源码实现逻辑,与HashMap中非常相似,有一点重大区别就是所有的操作都是通过synchronized锁保护的。只有获得了对应的锁,才能进行后续的读写等操作。
1. put方法
put方法的主要逻辑如下:
- 先获取synchronized锁。
- put方法不允许null值,如果发现是null,则直接抛出异常。
- 计算key的哈希值和index
- 遍历对应位置的链表,如果发现已经存在相同的hash和key,则更新value,并返回旧值。
- 如果不存在相同的key的Entry节点,则调用addEntry方法增加节点。
- addEntry方法中,如果需要则进行扩容,之后添加新节点到链表头部。
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
private void addEntry(int hash, K key, V value, int index) {
modCount++;
Entry<?,?> tab[] = table;
if (count >= threshold) {
// Rehash the table if the threshold is exceeded
rehash();
tab = table;
hash = key.hashCode();
index = (hash & 0x7FFFFFFF) % tab.length;
}
// Creates the new entry.
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>) tab[index];
tab[index] = new Entry<>(hash, key, value, e);
count++;
}
2. get方法
get方法的主要逻辑如下
- 先获取synchronized锁。
- 计算key的哈希值和index。
- 在对应位置的链表中寻找具有相同hash和key的节点,返回节点的value。
- 如果遍历结束都没有找到节点,则返回null。
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
3.rehash扩容方法
rehash扩容方法主要逻辑如下:
- 数组长度增加一倍(如果超过上限,则设置成上限值)。
- 更新哈希表的扩容门限值。
- 遍历旧表中的节点,计算在新表中的index,插入到对应位置链表的头部。
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
// overflow-conscious code
int newCapacity = (oldCapacity << 1) + 1;
if (newCapacity - MAX_ARRAY_SIZE > 0) {
if (oldCapacity == MAX_ARRAY_SIZE)
// Keep running with MAX_ARRAY_SIZE buckets
return;
newCapacity = MAX_ARRAY_SIZE;
}
Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
modCount++;
threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
table = newMap;
for (int i = oldCapacity ; i-- > 0 ;) {
for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
Entry<K,V> e = old;
old = old.next;
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
e.next = (Entry<K,V>)newMap[index];
newMap[index] = e;
}
}
}
4.remove方法
remove方法主要逻辑如下:
- 先获取synchronized锁。
- 计算key的哈希值和index。
- 遍历对应位置的链表,寻找待删除节点,如果存在,用e表示待删除节点,pre表示前驱节点。如果不存在,返回null。
- 更新前驱节点的next,指向e的next。返回待删除节点的value值。
总结:
HashTable相对于HashMap的最大特点就是线程安全,所有的操作都是被synchronized锁保护的
15.LinkedHashMap 的实现原理?
LinkedHashMap继承了HashMap,所以大体上是仍然是HashMap的结构,但是在内部实现增加两个指针来构成一个双向链表来维持顺序,同时提供了两种可选择的顺序。LinkedHashMap与HashMap的面向对象设计思想很值得学习,其次LinkedHashMap在需要有序Map时候是一种选择,也为缓存的实现提供一种基础组件。
LinkedHashMap相比于HashMap主要重写了newNode(), afterNodeRemoval(), afterNodeInsertion(), afterNodeAccess()几个重要方法,增加了head,tail,accessOrder三个内部字段。
1.新增基本字段
transient LinkedHashMap.Entry<K,V> head; //双链表的头指针
transient LinkedHashMap.Entry<K,V> tail; //双链表的尾指针
/**
* accesssOrder为true表示linkedHashMap的顺序是按照访问顺序
* 就是会将最新被访问过的节点移到链表末尾
* 比如1,2,3三个结点,如果访问了一次2,就会变成1,3,2
* 为false表示顺序是按照插入顺序来保持,后续不会更改
* 默认值为false
*/
final boolean accessOrder;
2.构造函数
public LinkedHashMap(int initialCapacity, float loadFactor)
public LinkedHashMap(int initialCapacity)
public LinkedHashMap()
public LinkedHashMap(Map<? extends K, ? extends V> m)
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
基本实现是调用父类的构造函数,然后给accessOrder赋值
3.class Entry<K,V> (LinkedHashMap如何维持顺序的)
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);
}
}
LinkedHashMap的Entry类继承自HashMap的Node类,但是增加了before,after两个字段来构成一个双向链表
通过这两个字段来维持结点的顺序,在put,remove,get之后都会对链表进行相应的调整。
4.newNode()函数
LinkedHashMap没有重写HashMap的put和putVal方法,如何将节点连到链表上的,就是通过newNode()函数,因为putVal的时候,一定会newNode,在newNode的时候就将节点连接到到链表上,这样就避免了putVal方法的重写,大量代码重复。
/**
* 只重写了这个方法,没有重写put方法,因为put的时候会调用这个方法
* 在这里把最新的结点插入到链表的末尾来维持顺序
*/
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
/**
* 双向链表的基本操作
*/
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
5.afterNodeRemoval函数
/**
* 删除结点的时候,同时在链表中删除
* 链表的基本操作应该都能看懂
*/
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null;
// 将e.before和e.after连接起来
if (b == null)// e是头结点时
head = a;
else
b.after = a;
if (a == null)// e是尾结点时
tail = b;
else
a.before = b;
}
6.afterNodeAccess函数
/**
* 将一个结点移动到链表的末尾,配合accessOrder使用
*/
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
// 如果accesOrder指定为true,访问节点后会将双链表中该节点移动到最后
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;
}
}
7.afterNodeInsertion函数
这个函数比较有趣,在HashMap的putVal中就调用了他,但是是一个空方法;在LinkedHashMap中又调用了他,但是基本还是个空方法;这个方法在ExpiringCache这个类中才发挥了作用。
/**
* 这个方法在正常的 LinkedHashMap方法中是不会被调用的
* 因为removeEldestEntry()返回始终为false
*/
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
看 removeEldestEntry函数
/**
* 在LinkedHashMap中始终返回的是false
* 作用是为LinkedHashMap做缓存提供一个实现
* 比如ExpiringCache,只需要重写这个方法
* 在进行缓存淘汰的时候可以发挥作用
* 比如用LinkedHash实现一个LRU算法
*/
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
这个方法在ExpiringCache被重写了,是这样的:
ExpiringCache(long millisUntilExpiration) {
this.millisUntilExpiration = millisUntilExpiration;
map = new LinkedHashMap<String,Entry>() {
protected boolean removeEldestEntry(Map.Entry<String,Entry> eldest) {
return size() > MAX_ENTRIES; //当缓存满的时候,淘汰一个最老的缓存
}
};
}
所以jdk是将LinkedHashMap作为了一种缓存的实现组件来设计,然后空出缓存具体部分由用户实现,我想通过读LinkedHashMap源码,应该很容易实现一个LRU算法了吧。
16.为什么集合类没有实现 Cloneable 和 Serializable 接口?
答:++克隆(cloning)或者序列化(serialization)的语义和含义是跟具体的实现相关的。因此应该由集合类的具体实现类来决定如何被克隆或者序列化++
Collection表示一个集合,包含了一组对象。如何存储和维护这些对象是由具体实现来决定的。因为集合的具体形式多种多样,例如list允许重复,set则不允许。而克隆(clone)和序列化(serializable)只对于具体的实体,对象有意义,你不能说去把一个接口,抽象类克隆,序列化甚至反序列化。所以具体的collection实现类是否可以克隆,是否可以序列化应该由其自身决定,而不能由其超类强行赋予。
如果collection继承了clone和serializable,那么所有的集合实现都会实现这两个接口,而如果某个实现它不需要被克隆,甚至不允许它序列化(序列化有风险),那么就与collection矛盾了。
克隆(cloning)或者是序列化(serialization)的语义和含义是跟具体的实现相关的。因此,应该由 集合类的具体实现来决定如何被克隆或者是序列化。
17.什么是迭代器 (Iterator)?
迭代器—不必知道序列底层是怎么实现的,就可以利用迭代器来访问一个序列。