20240111面试练习题3

1. HashMap为什么要使用红黑树而非其他数据结构来存储数据?

a. 更快的搜索和插入速度
红黑树是一种自平衡二叉搜索树,因此查找和插入操作的时间复杂度为 O(log n),而链表的时间复杂度为 O(n)。在哈希冲突比较严重的情况下,使用红黑树能够更快地进行搜索和插入操作。

b. 更稳定的性能
红黑树是“近似平衡”的。相比AVL树,在检索的时候效率其实差不多,都是通过平衡来二分查找。
但对于插入删除等操作效率提高很多。红黑树不像AVL树一样追求绝对的平衡,他允许局部很少的不完全平衡,这样对于效率影响不大,但省去了很多没有必要的调平衡操作,AVL树调平衡有时候代价较大,所以效率不如红黑树,在现在很多地方都是底层都是红黑树。

c. 更好的迭代性能
在 Java 8 中,当 HashMap 内部使用红黑树时,元素是按照键的自然顺序排序的。这意味着在迭代 HashMap 的元素时,元素的顺序是可预测的,而不是像使用链表时那样随机的。


2. 什么是负载因子?它的值为什么是0.75?

加载因子就是表示Hash表中元素的填满程度。

加载因子 = 填入表中的元素个数 / 散列表的长度

加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。

默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。
如果我们把负载因子设置成1,容量使用默认初始值16,那么表示一个HashMap需要在"满了"之后才会进行扩容。那么在HashMap中,最好的情况是这16个元素通过hash算法之后分别落到了16个不同的桶中,否则就必然发生哈希碰撞。而且随着元素越多,哈希碰撞的概率越大,查找速度也会越低。
如果负载因子设置为0.5,那么就会频繁的扩容,浪费空间。
loadFactor=0.75科学依据:
a .根据数学公式推算。负载因子为log(2)的时候,可以既减少哈希冲突,又浪费空间,是时间和空间的权衡。

log(2)大约为0.7。

b.根据HashMap的扩容机制,应该保证capacity的值永远都是2的幂。
为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数。


3. HashMap是线程安全的吗?说下具体原因?

dk1.7的数据丢失、死循环问题在JDK1.8中已经得到了很好的解决,直接在HashMap的resize()中完成了数据迁移。

JDK1.8会出现数据覆盖的情况:
源码在判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完该行判断代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

除此之外,pullVal方法中还有框中代码的有个++size语句,如果还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的size大小为10,当线程A执行到if(++size > threshold)代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。


4. HashMap会导致CPU 100%?什么场景下会出现这个问题?

以 JDK 1.7 为例,当多线程并发扩容时就会出现环形引用的问题,从而导致死循环的出现,一直死循环就会导致 CPU 运行 100%,所以在多线程使用时,我们需要使用 ConcurrentHashMap 来替代 HashMap

HashMap 发生死循环的一个重要原因是 JDK 1.7 时链表的插入是头部倒序插入的,而 JDK 1.8 时已经变成了尾部插入

以 JDK 1.7 为例,假设 HashMap 的默认大小为 2,HashMap 本身中有一个键值 key(5),我们再使用两个线程:t1 添加 key(3),t2 添加 key(7),首先两个线程先把 key(3) 和 key(7) 都添加到 HashMap 中,此时因为 HashMap 的长度不够用了就会进行扩容操作,然后这时线程 t1 在执行到 Entry next = e.next; 时,交出了 CPU 的使用权,源代码如下:

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next; // 线程一执行此处
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

那么此时线程 t1 中的 e 指向了 key(3),而 next 指向了 key(7) ;之后线程 t2 重新 rehash 之后链表的顺序被反转,链表的位置变成了 key(5) -> key(7) -> key(3),其中 “->” 用来表示下一个元素,当 t1 重新获得执行权之后,先执行 newTalbe[i] = e 把 key(3) 的 next 设置为 key(7),而下次循环时查询到 key(7) 的 e.next 为 key(3),于是就形 成了 key(3) 和 key(7) 的环形引用,就导致了死循环的产生


5. 什么是哈希冲突?如何解决哈希冲突?

哈希冲突:当两个不同的数经过哈希函数计算后得到了同一个结果,即他们会被映射到哈希表的同一个位置时,即称为发生了哈希冲突。简单来说就是哈希函数算出来的地址被别的元素占用了。

解决哈希冲突办法
1、开放定址法:我们在遇到哈希冲突时,去寻找一个新的空闲的哈希地址。
如:线性探测法
当我们的所需要存放值的位置被占了,我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。
公式:h(x)=(Hash(x)+i)mod (Hashtable.length);(i会逐渐递增加1)

平方探测法(二次探测)
当我们的所需要存放值的位置被占了,会前后寻找而不是单独方向的寻找。
公式:h(x)=(Hash(x) +i)mod (Hashtable.length);(i依次为+(i^2)
和-(i^2))

2、再哈希法:同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。

3、链地址法:将所有哈希地址相同的记录都链接在同一链表中。


6. 说下HashMap的查询流程?

首先根据 hash 方法获取到 key 的 hash 值

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

然后通过 hash & (length - 1) 的方式获取到 key 所对应的Node数组下标 ( length是数组长度 )
首先判断此结点是否为空,是否就是要找的值,是则返回空,否则进入第二个结点。
接着判断第二个结点是否为空,是则返回空,不是则判断此时数据结构是链表还是红黑树
链表结构进行顺序遍历查找操作,每次用 == 符号 和 equals( ) 方法来判断 key 是否相同,满足条件则直接返回该结点。链表遍历完都没有找到则返回空。
红黑树结构执行相应的 getTreeNode( ) 查找操作。

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    
    //Node数组不为空,数组长度大于0,数组对应下标的Node不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        //也是通过 hash & (length - 1) 来替代 hash % length 的
        (first = tab[(n - 1) & hash]) != null) {
        
        //先和第一个结点比,hash值相等且key不为空,key的第一个结点的key的对象地址和值均相等
        //则返回第一个结点
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果key和第一个结点不匹配,则看.next是否为空,不为null则继续,为空则返回null
        if ((e = first.next) != null) {
            //如果此时是红黑树的结构,则进行处理getTreeNode()方法搜索key
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //是链表结构的话就一个一个遍历,直到找到key对应的结点,
            //或者e的下一个结点为null退出循环
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

7. HashMap和Hashtable有什么区别?

1.继承的父类不同
HashMap继承自AbstractMap类。但二者都实现了Map接口。
Hashtable继承自Dictionary类,Dictionary类是一个已经被废弃的类。

2.HashMap线程不安全,HashTable线程安全
在hashmap的put方法调用addEntry()方法,假如A线程和B线程同时对同一个数组位置调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作造成A的写入操作丢失。
故解决方法就是使用 使用ConcurrentHashMap。
同时HashMap的迭代器(Iterator)是fail-fast迭代器,故当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException异常,而Hashtable的enumerator迭代器不是fail-fast的。但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。

3.包含的contains方法不同
HashMap是没有contains方法的,而包括containsValue和containsKey方法;hashtable则保留了contains方法,效果同containsValue,还包括containsValue和containsKey方法。

4.是否允许null值
Hashmap是允许key和value为null值的,用containsValue和containsKey方法判断是否包含对应键值对;HashTable键值对都不能为空,否则报空指针异常。

5.计算hash值方式不同
HashMap计算hash值:
先调用hashCode方法计算出来一个hash值,再将hash与右移16位后相异或,从而得到新的hash值
Hashtable计算hash值:
通过计算key的hashCode()来得到hash值

6.扩容机制不同:
当已用容量>总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 +1。


8. 为什么Hashtable不允许插入null?而HashMap却可以?

明确插入null的区别是:
HashMap可以存放一个键是null,多个值是null 的对象,
而Hashtable则不可以存放键为null,或者是值为null的对象

为什么HashTable不能存null键和null值?
原因:
当value值为null时主动抛出空指针异常
key值会进行哈希计算,如果为null的话,无法调用该方法,还是会抛出空指针异常

public synchronized V put(K key, V value) {
        // 确认值不为空
        if (value == null) {
            throw new NullPointerException(); // 如果值为null,则抛出空指针异常
        }
 
        // 确认值之前不存在Hashtable里
        Entry<?,?> tab[] = table;
        int hash = key.hashCode(); // 如果key如果为null,调用这个方法会抛出空指针异常
        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;
    }

为什么HashMap可以存null键和null值?
HashMap在put的时候会调用hash()方法来计算key的hashcode值,
HashMap的hash算法指定了:当key==null时返回的值为0。
因此key为null时,hash算法返回值为0,不会调用key的hashcode方法。

    //当key==null时hash的值为0
    //一个hashmap对象只会存储一个key为null的键值对,因为它们的hash值都为0
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

9. ConcurrentHashMap是怎么保证线程安全的?

JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    private static final long serialVersionUID = 2249069246763182397L;
    transient volatile HashEntry<K,V>[] table;
    transient int count;
    transient int modCount;
    transient int threshold;
    final float loadFactor;
    ...
}

Segment 是 ConcurrentHashMap 的一个内部类,继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。
存放元素的 HashEntry,也是一个静态内部类,其中,用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性

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

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


10. 说一下ConcurrentHashMap锁优化?

ConcurrentHashMap在JDK1.8中使用内置锁Synchronized来替换ReentractLock重入锁

锁粒度降低了;官方对synchronized进行了优化和升级,使得synchronized不那么“重”了,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
在大数据量的操作下,对基于API的ReentractLock进行操作会有更大的内存开销,假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。


你可能感兴趣的:(面试,职场和发展)