JAVA面试整理之——JAVA基础
1. HashMap的源码,实现原理,JDK8中对HashMap做了怎样的优化。
在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。HashMap由链表+数组组成,他的底层结构是一个数组,而数组的元素是一个单向链表。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。
简单说下HashMap的实现原理:
首先有一个每个元素都是链表(可能表述不准确)的数组,当添加一个元素(key-value)时,就首先计算元素key的hash值,以此确定插入数组中的位置,但是可能存在同一hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,他们在数组的同一位置,但是形成了链表,同一各链表上的Hash值是相同的,所以说数组存放的是链表。而当链表长度太长时,链表就转换为红黑树,这样大大提高了查找的效率。
当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的搬移到新的数组中
存取机制:
HashMap如何getValue值,看源码:
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } /** * Implements Map.get and related methods * * @param hash hash for key * @param key the key * @return the node, or null if none */ final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab;//Entry对象数组 Node<K,V> first,e; //在tab数组中经过散列的第一个位置 int n; K k; /*找到插入的第一个Node,方法是hash值和n-1相与,tab[(n - 1) & hash]*/ //也就是说在一条链上的hash值相同的 if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) { /*检查第一个Node是不是要找的Node*/ if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k))))//判断条件是hash值要相同,key值要相同 return first; /*检查first后面的node*/ if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); /*遍历后面的链表,找到key值和hash值都相同的Node*/ do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
get(key)方法时获取key的hash值,计算hash&(n-1)得到在链表数组中的位置first=tab[hash&(n-1)],先判断first的key是否与参数key相等,不等就遍历后面的链表找到相同的key值返回对应的Value值即可
HashMap如何put(key,value);看源码
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * Implements Map.put and related methods * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /*如果table的在(n-1)&hash的值是空,就新建一个节点插入在该位置*/ if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); /*表示有冲突,开始处理冲突*/ else { Node<K,V> e; K k; /*检查第一个Node,p是不是要找的值*/ 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,只进行 //resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } /*如果有相同的key值就结束遍历*/ if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } /*就是链表上有相同的key值*/ if (e != null) { // existing mapping for key,就是key的Value存在 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue;//返回存在的Value值 } } ++modCount; /*如果当前大小大于门限,门限原本是初始容量*0.75*/ if (++size > threshold) resize();//扩容两倍 afterNodeInsertion(evict); return null; }
下面简单说下添加键值对put(key,value)的过程:
1,判断键值对数组tab[]是否为空或为null,否则以默认大小resize();
2,根据键值key计算hash值得到插入的数组索引i,如果tab[i]==null,直接新建节点添加,否则转入3
3,判断当前数组中处理hash冲突的方式为链表还是红黑树(check第一个节点类型即可),分别处理
2. HaspMap扩容是怎样扩容的,为什么都是2的N次幂的大小。
HashMap使用的是懒加载,构造完HashMap对象后,只要不进行put 方法插入元素之前,HashMap并不会去初始化或者扩容table:
若threshold(阈值)不为空,table的首次初始化大小为阈值,否则初始化为缺省值大小16
当table需要扩容时,扩容后的table大小变为原来的两倍,接下来就是进行扩容后table的调整:假设扩容前的table大小为2的N次方,有put方法解析可知,元素的table索引为其hash值的后N位确定,那么扩容后的table大小即为2的N+1次方,则其中元素的table索引为其hash值的后N+1位确定,比原来多了一位
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //创建一个oldTab数组用于保存之前的数组 int oldCap = (oldTab == null) ? 0 : oldTab.length; //获取原来数组的长度 int oldThr = threshold; //原来数组扩容的临界值 int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { //如果原来的数组长度大于最大值(2^30) threshold = Integer.MAX_VALUE; //扩容临界值提高到正无穷 return oldTab; //返回原来的数组,也就是系统已经管不了了,随便你怎么玩吧 } //else if((新数组newCap)长度乘2) < 最大值(2^30) && (原来的数组长度)>= 初始长度(2^4)) //这个else if 中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,同时交代了, //我们扩容是以2^1为单位扩容的。下面的newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) newCap = oldThr; //新数组的初始容量设置为老数组扩容的临界值 else { // 否则 oldThr == 0,零初始阈值表示使用默认值 newCap = DEFAULT_INITIAL_CAPACITY; //新数组初始容量设置为默认值 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //计算默认容量下的阈值 } if (newThr == 0) { //如果newThr == 0,说明为上面 else if (oldThr > 0) //的情况(其他两种情况都对newThr的值做了改变),此时newCap = oldThr; float ft = (float)newCap * loadFactor; //ft为临时变量,用于判断阈值的合法性 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); //计算新的阈值 } threshold = newThr; //改变threshold值为新的阈值 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //改变table全局变量为,扩容后的newTable if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { //遍历数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中 Node<K,V> e; if ((e = oldTab[j]) != null) { //新建一个Node<K,V>类对象,用它来遍历整个数组 oldTab[j] = null; if (e.next == null) //将e也就是oldTab[j]放入newTab中e.hash & (newCap - 1)的位置, newTab[e.hash & (newCap - 1)] = e; //这个我们之前讲过,是一个取模操作 else if (e instanceof TreeNode) //如果e已经是一个红黑树的元素,这个我们不展开讲 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // 链表重排,这一段是最难理解的,也是ldk1.8做的一系列优化,我们在下面详细讲解 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; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
因此,table中的元素只有两种情况:
元素hash值第N+1位为0:不需要进行位置调整
元素hash值第N+1位为1:调整至原索引的两倍位置
在resize方法中,确定元素hashi值第N+1位是否为0:
若为0,则使用loHead与loTail,将元素移至新table的原索引处
若不为0,则使用hiHead与hiHead,将元素移至新table的两倍索引处
扩容或初始化完成后,resize方法返回新的table
hashMap为啥初始化容量为2的次幂
hashMap源码获取元素的位置:
static int indexFor(int hashcode, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return hashcode & (length-1);
}
如果length不为2的幂,比如15。那么length-1的2进制就会变成1110。在h为随机数的情况下,和1110做&操作。尾数永远为0。那么0001、1001、1101等尾数为1的位置就永远不可能被entry占用。这样会造成浪费,不随机等问题
3. HashMap,HashTable,ConcurrentHashMap的区别。
HashTable
底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
初始size为11,扩容:newsize = olesize*2+1
计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
HashMap
底层数组+链表实现,可以存储null键和null值,线程不安全
初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
计算index方法:index = hash & (tab.length – 1)
ConcurrentHashMap
底层采用分段的数组+链表实现,线程安全
通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
4. 极高并发下HashTable和ConcurrentHashMap哪个性能更好,为什么,如何实现的。
ConcurrentHashMap在多线程下效率更高
HashTable使用一把锁处理并发问题,当有多个线程访问时,需要多个线程竞争一把锁,导致阻塞
ConcurrentHashMap则使用分段,相当于把一个HashMap分成多个,然后每个部分分配一把锁,这样就可以支持多线程访问
推荐:https://blog.csdn.net/zhushuai1221/article/details/51706468
5. HashMap在高并发下如果没有处理线程安全会有怎样的安全隐患,具体表现是什么。
1、多线程put时可能会导致get无限循环,具体表现为CPU使用率100%;
原因:在向HashMap put元素时,会检查HashMap的容量是否足够,如果不足,则会新建一个比原来容量大两倍的Hash表,然后把数组从老的Hash表中迁移到新的Hash表中,迁移的过程就是一个rehash()的过程,多个线程同时操作就有可能会形成循环链表,所以在使用get()时,就会出现Infinite Loop的情况
2、多线程put时可能导致元素丢失
原因:当多个线程同时执行addEntry(hash,key ,value,i)时,如果产生哈希碰撞,导致两个线程得到同样的bucketIndex去存储,就可能会发生元素覆盖丢失的情况
6. java中四种修饰符的限制范围。
访问权限 |
类 |
包 |
子类 |
其他包 |
public |
∨ |
∨ |
∨ |
∨ |
protect |
∨ |
∨ |
∨ |
× |
default |
∨ |
∨ |
× |
× |
private |
∨ |
× |
× |
× |
7. Object类中的方法。
equale()用于确认两个对象是否相同。 hashCode()用于获取对象的哈希值,用于检索 finalize():这个函数在进行垃圾回收的时候会用到,匿名对象回收之前会调用到 toString()返回一个String对象,用来标识自己 getClass()返回一个Class对象 wait()用于让当前线程失去操作权限,当前线程进入等待序列 notify()用于随机通知一个持有对象的锁的线程获取操作权限 notifyAll()用于通知所有持有对象的锁的线程获取操作权限 wait(long) 和wait(long,int)用于设定下一次获取锁的距离当前释放锁的时间间隔 |
8. 接口和抽象类的区别,注意JDK8的接口可以有实现。
首先是相同的地方:
1. 接口和抽象类都能定义方法和属性。
2. 接口和抽象类都是看作是一种特殊的类。大部分的时候,定义的方法要子类来实现
3. 抽象类和接口都可以不含有抽象方法。接口没有方法就可以作为一个标志。比如可序列化的接口Serializable,没有方法的接口称为空接口
4. 抽象类和接口都不能创建对象。
5. 抽象类和接口都能利用多态性原理来使用抽象类引用指向子类对象。
6. 继承和实现接口或抽象类的子类必须实现接口或抽象类的所有的方法,抽象类若有没有实现的方法就继续作为抽象类,要加abstract修饰。若接口的子类没有实现的方法,也要变为抽象类。
下面是接口和抽象类的不同点:
1. 接口能够多实现,而抽象类只能单独被继承,其本质就是,一个类能继承多个接口,而只能继承一个抽象类。
2. 方法上,抽象类的方法可以用abstract 和public或者protect修饰。而接口默认为public abttact 修饰。
3. 抽象类的方法可以有需要子类实现的抽象方法,也可以有具体的方法。而接口在老版本的jdk中,只能有抽象方法,但是Java8版本的接口中,接口可以带有默认方法。
4. 属性上,抽象类可以用各种各样的修饰符修饰。而接口的属性是默认的public static final
5. 抽象类中可以含有静态代码块和静态方法,而接口不能含有静态方法和静态代码块。
6. 抽象类可以含有构造方法,接口不能含有构造方法。
7. 既然说到Java 8 那么就来说明,Java8中的接口中的默认方法是可以被多重继承的。而抽象类不行。
8. 另外,接口只能继承接口。而抽象类可以继承普通的类,也能继承接口和抽象类。
9. 动态代理的两种方式,以及区别。
jdk动态代理和cglib动态代理。两种方法同时存在,各有优劣。
jdk动态代理是由java内部的反射机制来实现的,cglib动态代理底层则是借助asm来实现的。
总的来说,反射机制在生成类的过程中比较高效,而asm在生成类之后的相关执行过程中比较高效(可以通过将asm生成的类进行缓存,这样解决asm生成类过程低效问题)。还有一点必须注意:jdk动态代理的应用前提,必须是目标类基于统一的接口。如果没有上述前提,jdk动态代理不能应用。由此可以看出,jdk动态代理有一定的局限性,cglib这种第三方类库实现的动态代理应用更加广泛,且在效率上更有优势。。
10. Java序列化的方式。
a.是相应的对象实现了序列化接口Serializable,这个使用的比较多,对于序列化接口Serializable接口是一个空的接口,它的主要作用就是标识这个对象时可序列化的,jre对象在传输对象的时候会进行相关的封装
b.实现序列化的第二种方式为实现接口Externalizable
11. 传值和传引用的区别,Java是怎么样的,有没有传值引用。
1. 在java中所有的参数都是传值的,引用符号&的传递是C++中才有的;
2. 在java传参中,基本类型(byte--short--int--long--float--double--boolean--char)的变量总是按值传递;
3. 对于对象来说,不是将对象本身传递给方法,而是将对象的的引用或者说对象的首地址传递给方法,引用本身是按值传递的;
4. 对于String、Integer、Long,数组等,这些都相当于对象,因此传参时相当于是传引用;
12. 一个ArrayList在循环过程中删除,会不会出问题,为什么。
for(int i),这种遍历的时候删除,被删除元素的后面那个元素会被跳过,除非在循环里面操作i,你想想是不是,因为当前对象被remove了,下一个对象的序号就减一了,但是并没有对下一个对象做判断,i照常加1,就跳过了下一个对象