Java面试题总结(泛型的实现,hashmap的实现,List数据结构)

1、java泛型的内部实现:

java的泛型是伪泛型,会在编译时做类型擦除(类型擦出(type erasure),变为原始变量,如果不指定泛型类型,则在jvm中默认是object,如果指定了则是指定的类型,比如

public class Pair<T extends Comparable& Serializable> {} 这种

缺陷:不能用instanceof来检测泛型,因为由于类型擦除的原因,任何泛型都会擦除成为他的原始类型

static方法和static域不能引用泛型变量,因为同一个类的所有实例共用类的static域和static方法,如果静态域接受泛型参数,那么这个参数到底是类型参数的哪一种实际类型就无法确定了。

不能创建泛型数组,因为数组有严格的类型检查

不能创建泛型的实例

2、java Hashmap内部实现

Hashmap实际是由数组+链表组成的,首先内部维护了一个Entry的数组,Entry是Hashmap的基础组成单元,为链表结构,内部维护了key,value和hash,以及指向下一个节点的引用。其代码如下:

    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
        int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        } 

如果查找到的节点不含链表,则时间复杂度为O(1),否则需要遍历链表为O(N)

loadFactor 加载因子存在的原因,还是因为减缓哈希冲突,如果是1,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。如果是0.5,那么每次最少都会剩余一半的空间,数量大时会很浪费空间 所以加载因子默认为0.75

为什么重写equals的时候也要重写hashcode

put操作时,先根据key的hashcode来进行hash,然后根据hash得到数组下标,然后放到索引的位置。

key(hashcode1)–>hash–>indexFor–>最终索引位置 ,然后会判断key是否存在,判断条件是 e.hash == hash && ((k = e.key) == key || key.equals(k))

即hash相同,key相同并且equals相同,如果重写了equals但是没重写hashcode,那么判断不相同会导致重复插入成功

而通过key取出value的时候 key(hashcode1)–>hash–>indexFor–>最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)

所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。
即equals相等的,hashcode必须相等,而hashcode相等的,equals可以不相等,但是会发生hash冲突产生链表

在这里插入图片描述

根据key获取hash桶数组索引位置,源码实现:

方法一:
static final int hash(Object key) {   //jdk1.8 & jdk1.7
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

HashMap的扩容机制,为什么是2幂

1、在put方法中,会调用indexFor来根据key的hash值找到这个entry在hash表数组中的位置,上述代码也相当于对length求模,但是&操作会比%取模运算快。如果是2的n次幂,比如16,则length-1的二进制为1111(8+4+2+1),如果不是2的n次幂,比如15,则是1110,在h为随机数的情况下和1110做&操作,尾数永远为0,那么0001,1001等尾数为1的位置永远不会被entry占用,造成浪费不随机等问题,因此为2的n次幂,会使散列更加均匀

还有就是取模运算可以在桶是2^n的时候用位运算代替,位运算性能要比取模预算好的多

2、在resize的时候,因为n变为原来2倍,那么n-1的mask在高位就会多1bit,那么新的index就会发生这样的变化

resize过程不需要像1.7那样重新计算hash,只需要看原来hash对应length-1新增的那个bit位是1还是0就好了,如果是0的话索引没变,如果是1的话索引就变成“原索引+oldCap”(这样一方面位运算更快,另一方面如果重新hash,hash函数还是挺耗时的)

会把所有的bucket移到新bucket中,遍历所有元素,判断元素如果没有链表,则直接根据e.hash & (newCap - 1)确定位置,否则如果是链表,则遍历所有链表,确定每个链表是原位置还是原位置+oldCap,如果是红黑树,则走红黑树的拆分逻辑

在这里插入图片描述

扩容的源码如下:

 1 final Node<K,V>[] resize() {
 2     Node<K,V>[] oldTab = table;
 3     int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4     int oldThr = threshold;
 5     int newCap, newThr = 0;
 6     if (oldCap > 0) {
 7         // 超过最大值就不再扩充了,就只好随你碰撞去吧
 8         if (oldCap >= MAXIMUM_CAPACITY) {
 9             threshold = Integer.MAX_VALUE;
10             return oldTab;
11         }
12         // 没超过最大值,就扩充为原来的2倍
13         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14                  oldCap >= DEFAULT_INITIAL_CAPACITY)
15             newThr = oldThr << 1; // double threshold
16     }
17     else if (oldThr > 0) // initial capacity was placed in threshold
18         newCap = oldThr;
19     else {               // zero initial threshold signifies using defaults
20         newCap = DEFAULT_INITIAL_CAPACITY;
21         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22     }
23     // 计算新的resize上限
24     if (newThr == 0) {
25 
26         float ft = (float)newCap * loadFactor;
27         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28                   (int)ft : Integer.MAX_VALUE);
29     }
30     threshold = newThr;
31     @SuppressWarnings({"rawtypes","unchecked"})
32         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33     table = newTab;
34     if (oldTab != null) {
35         // 把每个bucket都移动到新的buckets中
36         for (int j = 0; j < oldCap; ++j) {
37             Node<K,V> e;
38             if ((e = oldTab[j]) != null) {
39                 oldTab[j] = null;
                   // 判断是否存在链表,如果不存在,则直接&运算确定新索引位置
40                 if (e.next == null)
41                     newTab[e.hash & (newCap - 1)] = e;
42                 else if (e instanceof TreeNode)
43                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44                 else { // 链表优化重hash的代码块
45                     Node<K,V> loHead = null, loTail = null;
46                     Node<K,V> hiHead = null, hiTail = null;
47                     Node<K,V> next;
48                     do {
49                         next = e.next;
50                         // 原索引
51                         if ((e.hash & oldCap) == 0) {
52                             if (loTail == null)
53                                 loHead = e;
54                             else
55                                 loTail.next = e;
56                             loTail = e;
57                         }
58                         // 原索引+oldCap
59                         else {
60                             if (hiTail == null)
61                                 hiHead = e;
62                             else
63                                 hiTail.next = e;
64                             hiTail = e;
65                         }
66                     } while ((e = next) != null);
67                     // 原索引放到bucket里
68                     if (loTail != null) {
69                         loTail.next = null;
70                         newTab[j] = loHead;
71                     }
72                     // 原索引+oldCap放到bucket里
73                     if (hiTail != null) {
74                         hiTail.next = null;
75                         newTab[j + oldCap] = hiHead;
76                     }
77                 }
78             }
79         }
80     }
81     return newTab;
82 }

1.8新增的变化:1、扩容的时候1.7是原有数据全部采用hash值和需要扩容的二进制数进行&,而1.8是判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。如果是0位置不变,如果是1则新的位置=原有位置+扩容前的旧容量

2、1.7采用的是头插法,而1.8是尾插法,1.7是单链表,当采用头插法时会容易出现逆序且环形链表死循环问题,但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

3、当链表的长度超过8时会转为红黑树,使查询效率提高,时间复杂度从O(N)提升为O(logN)

3、List数据结构

ArrayList

ArrayList为线性数组,查询效率高,超出现有长度后,自动扩容,他和LinkedList都是线程异步的,并不是线程安全的,保证线程安全的方法:

1、通过Collections.synchronizedList()转为同步list,他是通过在方法内部添加synchronized关键字来实现线程安全的

2、Vector是线程同步的,vector增长率为目前数组长度的100%,而arraylist增长率为目前数组长度的50%,Vector通过在每个方法声明处添加synchronized来保证线程安全。上述两种同步类如果在遍历时修改元素,会抛出java.util.ConcurrentModificationException异常,即fast-fail机制

两个关键变量:

  • expectedModCount:表示对List修改次数的期望值,它的初始值与modCount相等

  • modCount:表示List集合结构被修改次数,是AbstractList类中的一个成员变量,初始值为0

看过ArrayList的源码就知道,每次调用add()和remove()方法时就会对modCount进行加1操作。而我们上面的测试代码中调用了Vector类的clear()方法,这个方法中对modCount进行了加1,而迭代器中的expectedModCount依然等于0,两者不等,因此抛了异常。这就是集合中的fail-fast机制,fail-fast 机制用来防止在对集合进行遍历过程当中,出现意料之外的修改,会通过Unchecked异常暴力的反应出来。

3、JUC中的CopyOnWriteArrayList,利用了写时复制思想,即如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。

应用场景:Linux通过Copy On Write技术极大地减少了Fork的开销。文件系统通过Copy On Write技术一定程度上保证数据的完整性。数据库服务器也一般采用了写时复制策略,为用户提供一份snapshot。

CopyOnWriteArrayList的主要原理:add操作首先加锁防止并发写入导致数据丢失,然后复制一个新数组,在新数组上做增加操作,然后将array指向新数组,然后解锁,读的话直接读array数组中的元素

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

final void setArray(Object[] a) {
    array = a;
}

public E get(int index) {
    return get(getArray(), index);
}

final Object[] getArray() {
    return array;
}

遍历时修改元素如何保证不报错:遍历时传入的是数组的快照,在修改元素时是基于复制的新的数组修改的,而遍历的是原来的快照,因此不会发生异常

优点:适合读多写少的数据,读的时候不会加锁,在读的性能方面好过Vecrot,可以实现更高的并发,比如说配置,黑名单等变化比较少的数据

缺点:不适合写比较频繁的数据,因此写需要复制数组

  • 数据一致性问题:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。比如线程A在迭代CopyOnWriteArrayList容器的数据。线程B在线程A迭代的间隙中将CopyOnWriteArrayList部分的数据修改了,但是线程A迭代出来的是旧数据。

  • 内存占用问题。如果CopyOnWriteArrayList经常要增删改里面的数据,并且对象比较大,频繁地写会消耗内存,从而引发Java的GC问题,这个时候,我们应该考虑其他的容器,例如ConcurrentHashMap。

LinkedList

为双向链表,保存了firstNode和lastNode,由于是链表,因此删除或者添加元素很方便,插入和删除效率很高,但是查询效率比较低,除了可以用作堆栈外,还可以用作队列或者双端队列

插入效率比较:Arraylist耗时的地方是需要自动扩容和初始元素移位,LinkedList耗时的地方是找到插入的位置

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值