面试干货3——基于JDK1.8的HashMap(与Hashtable的区别、数据结构、内存泄漏..)


推荐:在准备面试的同学可以看看这个系列

        面试干货1——请你说说Java类的加载过程

        面试干货2——你对Java GC垃圾回收有了解吗?

        面试干货3——基于JDK1.8的HashMap(与Hashtable的区别、数据结构、内存泄漏…)

        面试干货4——你对Java类加载器(自定义类加载器)有了解吗?

        面试干货5——请详细说说JVM内存结构(堆、栈、常量池)

        面试干货6——输入网址按下回车后发生了什么?三次握手与四次挥手原来如此简单!

        面试干货7——刁钻面试官:关于redis,你都了解什么?

        面试干货8——面试官:可以聊聊有关数据库的优化吗?

        面试干货9——synchronized的底层原理


一、HashMap基本知识

1. 简述

        HashMap基于Map接口实现,元素以键值对的方式存储,key、value均允许存null,但key不允许重复,如果后者key与前者一样,前者的值将被后者覆盖;HashMap为无序集合,不能保证元素存储顺序,线程也不安全。

2. 继承关系

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

3. 基本属性

// 初始容量, 1左移4位相当于1*2^4=16, 该容量为Node<K,V>[]数组的长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 默认负载因子, 扩容使用
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 初始数组, 用来存放元素, 每一对键值对会被封装成一个Node对象放在该数组中
transient Node<K,V>[] table;

        Node: 集合在扩容时非常消耗性能(因为元素放在数组当中,数组一旦创建,长度是不可变的,扩容只能遍历旧数组, 将旧数组的元素放到新数组,包括在旧数组位置上的链表或是红黑树的元素(1.8新特性,以前只是链表)

4. 遍历

        forEach

Map<String, String> map = new HashMap<>();
map.put("name", "张三");
map.forEach((String k, String v) -> System.out.println(k + "-" + v));

        增强for循环

Set<String> keys = map.keySet();
for (String key : keys)
    System.out.println(map.get(key));
------------------------------------------------------
Set<Map.Entry<String, String>> entries = map.entrySet();
for (Map.Entry<String, String> entry : entries)
    System.out.println(map.get(entry.getKey()));

        迭代器

Iterator<String> setIterator = map.keySet().iterator();
while (setIterator.hasNext())
    System.out.println(map.get(setIterator.next()));
------------------------------------------------------
Set<Map.Entry<String, String>> entries = map.entrySet();
Iterator<Map.Entry<String, String>> iterator = entries.iterator();
while (iterator.hasNext()) 
    System.out.println(map.get(iterator.next().getKey()));

        values()

for (String value : map.values())
    System.out.println(value);

二、HashMap与HashTabl的区别

1. 继承结构

        HashMap继承自AbstractMap,实现Map接口;HashTable继承Dictionary,实现Map接口

2. 对待null

        HashMap的key、value都可为null,HashTable的key、value都不可为null,原因如下代码
        ·HashMap的put方法

public V put(K key, V value) {
    // 存值的时候调用hash方法对key进行hash计算
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
    // 如果key为null, 则hash值返回0
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

        · Hashtable的put方法

public synchronized V put(K key, V value) {
    // 如果值为null, 则抛出空指针异常
    if (value == null)
        throw new NullPointerException();
    Entry<?,?> tab[] = table;
    // 没有对key进行限制, 直接调用了key的hashcode的方法, 如果key为null则也为空指针异常
    // 从JDK文档中, 我们可以看出作者的想法, 他希望每个key都会实现hashcode和equals方法
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    // ...
}

3. 线程安全性

        HashMap线程不安全,HashTable线程安全
        Hashtable的线程安全是通过synchronized关键字实现的,且同步粒度比较大,为方法级的,在高并发环境下非常容易发生竞争,并发效率就会降低。所以相对而言,HashMap性能要更高一些的。如果需要并发环境,我们可以通过Collections.synchronizedMap()方法来获取一个线程安全的集合
        笔记: Collections.synchronizedMap(Map<K,V> m)获取一个线程安全的集合,看源码,不难理解,本质上还是对传入map的操作,在Collections中定义了SynchronizedMap内部类,该内部类,使用synchronized关键字使线程安全

4. 初始容量与扩容

    · HashMap
        初始容量:16;负载因子:0.75
        扩容机制:数组元素超过 数组容量 * 0.75取整,则容量翻倍,即capacity << 1
    ·Hashtable
        初始容量:11;负载因子0.75
        扩容机制:数组元素超过 数组容量 * 0.75,则容量翻倍并+1,即capacity << 1 + 1
    扩容有两件事要做,第一件事就是创建新的Node<K, V>[ ]数组,长度为原始长度的2倍,第二件事需要对key再次计算元素存放于数组的位置,公式为 e.hash & (newCap - 1) ,称之为rehash,整个扩容过程对性能的开销也比较大。在JDK1.7采用头插法,插入数据的时候总是向节点头部放数据,原因是设计者认为新放进去的数据,被使用的可能比较大,但是在扩容迁移数据的时候,链表数据顺序会反过来,由于从正向指针变为反向指针,在并发环境下,极容易出现环形链表的情况导致取值死循环;在JDK1.8采用尾插法,不管是插入数据还是扩容时的数据迁移,都在尾部插入数据,如此便能避免并发环境下扩容造成的环形链表从而引发死循环问题,但HashMap本身就是非同步的,这个优化倒也没那么重要,我们避免在多线程下使用就好。

5. 计算hash的方法

· HashMap

// 我们可以看到HashMap并没有直接用hashcode方法获取的哈希值, 而是又将哈希值无符号右移16位并和原来
// 的哈希值进行异或运算, 其目的是为了更好的散列
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 计算数组位置
i = (n - 1) & hash
// 如果让我们计算, 可能会用 hash % n, 就像Hashtable那样计算, 其实这两者结果相同, 位运算效率更高

· Hashtable

// 直接调用hashcode()方法, 然后对数组长度取余算出元素存放位置
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;

三、HashMap数据存储结构

1. JDK1.8之前

        HashMap的数据结构是数组+链表的方式存储数据结构,首先有一个Entry<K, V>[ ]数组,Entry<K, V>则是一个链表,元素在放入HashMap时都会被封装成一个Entry对象放到数组里,如果哈希冲突,那么在数组同一位置,用链表存放。
在这里插入图片描述
在这里插入图片描述

2. JDK1.8时

        HashMap的数据结构为数组+链表+红黑树,首先是一个Node<K, V>[ ]数组,Node<K, V>是一个链表,还有一个TreeNode<K, V>,它是Node<K, V>的子类。元素放入HashMap时会被封装成一个Node对象,如果哈希冲突,会以链表的方式存放在数组同一位置,与JDK1.7及以前一样,如果链表长度超过阈值8时,则会将链表替换为红黑树,在性能上得到进一步提升。
在这里插入图片描述

3. put方法剖析

public V put(K key, V value) {
    //调用putVal()方法, hash(key) 计算哈希值, 上述提到过
    return putVal(hash(key), key, value, false, true);
}
 
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判断初始数组是否进行过初始化, 如果没有则初始化, 
    //resize即为初始化方法, 也为扩容方法
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //计算存储的索引位置, 如果没有元素, 直接赋值
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //节点若已经存在,执行赋值操作
        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,将链表转化为红黑树存储
                    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;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //记录修改次数
    ++modCount;
    //判断是否需要扩容
    if (++size > threshold)
        resize();
    //空操作
    afterNodeInsertion(evict);
    return null;
}

四、拓展

1. Hash冲突?

        Hash值相同,即为hash冲突,在HashMap中,我们知道,put一个元素,要先算出hash值,然后根据hash值与数组长度得到存放位置,如果put一个元素,所得到的hash与集合中原有的hash相等,那么则hash冲突。
        如果hash冲突,HashMap回去判断key与冲突的那个元素的key是否==,或者是否equals,如果相等,则覆盖原来的值,如果不相等,则以链地址法解决hash冲突。也就是数据结构里的链表。

2. Hash冲突的解决办法

1. 链地址法
        即用一个链表存放Hash冲突的元素
2. 再哈希法
        顾名思义,当hash冲突是,在原有的hash值上二次hash,如果还冲突,继续hash,直到不冲突为止
3. 开放定址法
        可以参考这篇文章 -> 开放定址法处理冲突
4. 建立公共溢出区

3. HashMap内存泄漏问题

        什么是内存泄漏? 简单地说就是一个未来没有用的对象一直占着内存不释放,也就是说GC回收机制检测该对象一直处于存活状态,但是它是个无用对象。所有的内存泄漏最终都会导致OOM。
        HashMap在什么情况下会发生内存泄漏呢? 举例说明:如果往HashMap存放自定义的对象,不重写equels和hashcode方法,如下:

public class Person {
	private String name;
}

然后在一个死循环中有如下代码:

for(;;) {
	map.put(new Person("张三"), "123");
}

由于我们没有重写equels和hashcode方法,HashMap发现每次放进来的key的hash都不一样,然后就会不停地往集合里放新数据,如此一来,集合里的没用对象会越来越多,且一直都为存活状态,但逻辑上同样是张三这个人,第二次放的值应该覆盖上一次的才对。这就是HashMap的内存泄漏问题,解决的方式就是重写equels和hashcode方法

五、结语

        集合是比较重要的一块知识点,面试中会经常问到,平时工作中也经常会用到,希望大家可以多学习,多剖析,多比较各种集合的性能以及实现差异。此外,Java集合体系是Java类与类之间关系的完美体现,例如继承is-a,强调从属关系,实现like-a,强调功能,举例:HashMap,继承了AbstractMap,说明它是一个map集合,实现了Map、Cloneable、Serializable三个接口,强调了它有Map接口的功能,能够被克隆,能够序列化,大家不妨也研究研究。

本文多有参考一篇基于1.7的博客,后又更新过一次:https://blog.csdn.net/qq_41345773/article/details/92066554

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值