Java HashMap详解:一场由于不懂Map集合而引发的“血案”

故事背景

我们公司的 JDK 是 1.7 版本,最近新上线了一个服务,但是我们发现我们的服务时常会卡死。经过我们的分析,一件可怕的事情浮出了水面......

哈哈,标题虽然有些"标题党"。但是内容绝对是相当的干货。在接下来的文章中我会带领大家深入了解 Map 集合,然后讲解是什么引发的"血案"。

在 Java 的 Collection 集合中,我们不可避免的要用到 Map 集合。那么,问题来了。你够深刻的理解 Java 的 Map 集合吗?

 

什么是 Map

时间复杂度

首先,如果你学过计算机相关专业课程。你就会了解过一个学术名词。它叫做时间复杂度。说白了,时间复杂度就是在计算机内执行一个算法,从开始到获取一个结果所需要耗费时间的一种通用描述。它使用 大 O 符号表示法 来表示 ( 即 T(n) = O(f(n)) )。例如我们常见的一个循环:

// 伪代码

for(i=1; i<=n; ++i)   // 第一行
{
   arr[i] = i;     // 第二行
}

它的时间复杂度怎么算出来呢?

  • 代码第一行只会执行一次。则所耗时间为 1 。
  • 代码第二行会执行 n 次。则所耗时间为 n 。

那么,总时间为 1 + n 。我们带入公式。该方法的时间复杂度 T( n ) = O( 1+n ),从这个结果可以看出来,该算法的耗时随着 n 的不断变化而变化。则按照数学逻辑可以将其简写为 T( n ) = O ( n )。

 


HashMap

为了使得一个数据结构可以将平均插入、查找、删除时间复杂度都为O( 1 ),Hash 表应运而生,而 HashMap 就是 Hash 表的标准实现。

 

Hash 表结构

Hash 表由 Hash桶+ 链表实现。

 

什么是 HashMap

简单点说,就是对传入的 key 做一个 hash 运算,然后找到相应的 hash 桶,把值丢进去。如果里面有其他值的话,就把桶内变成链表结构,如下图。

 

Hash 表有多重要

由于该结构的插入、查找、删除的时间复杂度低。致使其应用广泛。具体有多重要呢?

我们可以看一下 Java 世界中的先祖 Object 类,

 

 

Object 一共就这么几个方法,其中两个方法就是为了 hash 表所量身定制的。

 

equals & hashCode 约定

首先,我们来简单讲解一下什么是 equals & hashCode 约定。

  • 一个对象的 hashCode 必须永远一致。
  • 如果对象 A 和对象 B 调用 equals 方法不相等,允许它们调用 hashCode 方法返回的值相等。但是不推荐这样做,这样会影响内部 Hash 表的查找速度。
  • 如果对象 A 和对象 B 调用 equals 方法相等,那么它们调用 hashCode 方法也一定返回相同值。

 

如何计算 hash 值

在 Java 世界中,当你需要往 HashMap 里面存入数据的时候,JVM 会帮你调用该对象的 hashCode 方法来计算获取其 hash 值,按理说正常是不会有什么奇奇怪怪的问题。 但是总有人喜欢玩一些 "骚操作"。从而违反了上述约定。

那么违反约定会引起什么问题呢 ?

我们分开来看一下。

 

对象消失之谜

如果你违反了上面的第 1 条约定。那么你将会发现你的对象莫名的消失了。

实例代码:

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

class Scratch {

    private String name;
    private String nickName;

    public Scratch(String name, String nickName) {
        this.name = name;
        this.nickName = nickName;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setNickName(String nickName) {
        this.nickName = nickName;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Scratch scratch = (Scratch) o;
        return Objects.equals(name, scratch.name) && Objects.equals(nickName, scratch.nickName);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name + nickName);  // 错误的写法  	
    }

    public static void main(String[] args) {
        Scratch numberOne = new Scratch("1号技师", "阿水");
        Scratch numberTwo = new Scratch("2号技师", "阿旺");
        Map<Scratch, String> testMap = new HashMap<>();
        testMap.put(numberOne, "我是1号技师");
        testMap.put(numberTwo, "我是2号技师");
        System.out.println(" ------ 现在开始发言  -------");
        System.out.println(testMap.get(numberOne));
        System.out.println(testMap.get(numberTwo));
        numberTwo.setNickName("李四");
        System.out.println(" ------ 现在开始第二轮发言  -------");
        System.out.println(testMap.get(numberOne));
        System.out.println(testMap.get(numberTwo));
    }
}

你猜,它会打印返回什么 ?

 

很不幸,二号技师丢失了。

【 原因 】

在放入 HashMap 之前 JVM 需要调用该对象的 hashCode 方法。由于我们错误的重写和后面对内部属性的修改。导致 HashMap 得到的是不一样的 hash 值,导致我们找不到我们想要的对象了。

 

你太慢了!

其实如果你违反了第 2 条约定。不会有太大的问题,代码可以正常运行。但是 Map 所具有的性能优势则没有了,如下面这个代码。

class Scratch {

    @Override
    public int hashCode() {
        return 1;  // 错误的写法  
    }

}

如果你每个对象的 hashCode 都为 1。则每次都会出现 hash 碰撞,导致所有值都会落在在一个 hash 桶内。此时,hashmap 将退化成链表。查询的时间复杂度将提升为 O( n )。

如图所示:

 

我是复制人

如果你违反了约定 3。 那么,我只能说,祝你好运。

class Scratch {

    private String name;
    private String nickName;

    public Scratch(String name, String nickName) {
        this.name = name;
        this.nickName = nickName;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setNickName(String nickName) {
        this.nickName = nickName;
    }

    @Override
    public boolean equals(Object o) {
        Scratch o1 = (Scratch) o;
        return (name + nickName).equals(o1.name + o1.nickName); // 错误的写法 
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, nickName);
    }

    public static void main(String[] args) {
        Scratch numberOne = new Scratch("1号技师", "阿水");
        Map<Scratch, String> testMap = new HashMap<>();
        testMap.put(numberOne, "我是1号技师");
        numberOne.setNickName("张三");
        testMap.put(numberOne, "我还是2号技师");
        System.out.println(testMap.get(numberOne));
    }
}

它的运行结果为:

 

到这,看起来没什么不对的问题。但是!当我修改一下代码。你便会发现一个惊人的秘密......

public static void main(String[]args){
        Scratch numberOne=new Scratch("1号技师","阿水");
        Map<Scratch, String> testMap=new HashMap<>();
        testMap.put(numberOne,"我是1号技师");
        numberOne.setNickName("张三");
        testMap.put(numberOne,"我是2号技师");
        System.out.println(testMap.get(numberOne));
        numberOne.setNickName("阿水");
        System.out.println(testMap.get(numberOne));
        }

它的运行结果为:

 

这是怎么个情况?我灰太狼又回来了?没有对 map 进行 put 操作,我们便得到之前的对象。下面由我来解答这个问题,真相只有一个!( 此时柯南背景音乐响起 )

hashmap 在添加元素时的步骤如下:

  • 调用 hashCode 方法获取其 hash 值,然后定位到桶的位置。
  • 如果这个位置上没有元素,我们将直接存储起来。
  • 如果这个位置上有元素,将会调用 equals 方法进行比较,如果相同则更新该元素。如果不同,则新增。

所以,原因便是:由于我们错误的设置了 equals 方法。导致每次判断都是一个新元素,将会不断的新增。最后导致非常大的内存占用问题。

( 柯南背景音乐结束 )

 

为什么容量必须是 2 的幂?(其实知道并无luǎn用,但是面试官还老爱问 )

容量是 2 的幂?这是谁规定的?

我们都知道 Java 的 Collection 集合是提前被封装好的,会自动进行扩容。基本都是内部有一个类似于 MAXIMUM_CAPACITY 的参数来定义的。如果超过该大小的临界值 DEFAULT_LOAD_FACTOR 将会被自动扩容。

但是如果你预先便知道大约有多少数据,则可以调用构造函数来指定其大小,省略了扩容的操作( 毕竟扩容也很复杂 )。

源码方法签:

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and the default load factor (0.75).
 *
 * @param  initialCapacity the initial capacity.
 * @throws IllegalArgumentException if the initial capacity is negative.
 */
public HashMap(int initialCapacity)

但是,你不知道的是,Java 会在内部偷偷的帮你取成 2 的幂。源码如下:


/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap){
     int n=cap-1;
     n|=n>>>1;
     n|=n>>>2;
     n|=n>>>4;
     n|=n>>>8;
     n|=n>>>16;
     return(n< 0)?1:(n>=MAXIMUM_CAPACITY)?MAXIMUM_CAPACITY:n+1;
}

何等的骚操作啊。为了压榨性能,提升 CPU 处理速度,采取全部为位运算。在这里我们不去深究该方法,只需了解它会帮你取成 2 的幂。

 

那么,为什么非要把它取成 2 的幂呢?

因为 Hash 表内是有 Hash 桶的,为了快速寻找是第几个桶,取成 2 的幂是最快方式。

源码:

 /**
 * Computes key.hashCode() and spreads (XORs) higher bits of hash
 * to lower.  Because the table uses power-of-two masking, sets of
 * hashes that vary only in bits above the current mask will
 * always collide. (Among known examples are sets of Float keys
 * holding consecutive whole numbers in small tables.)  So we
 * apply a transform that spreads the impact of higher bits
 * downward. There is a tradeoff between speed, utility, and
 * quality of bit-spreading. Because many common sets of hashes
 * are already reasonably distributed (so don't benefit from
 * spreading), and because we use trees to handle large sets of
 * collisions in bins, we just XOR some shifted bits in the
 * cheapest possible way to reduce systematic lossage, as well as
 * to incorporate impact of the highest bits that would otherwise
 * never be used in index calculations because of table bounds.
 */
static final int hash(Object key){
     int h;
     return(key==null)?0:(h=key.hashCode())^(h>>>16);
}

该方法取得该对象的 hashCode 后计算与其本身无符号右移 16 位的异或来获取桶编号。以此来解决 hash 冲撞的问题。

 

Java 1.7 的问题

经过了这么长时间的铺垫,现在我们来讲一讲由 Java 1.7 的 HashMap 引发的血案。

 

Java Hashmap 死循环

【 原因 】

因小 A 在项目中不慎在多线程环境下使用了 Hashmap,导致了 CPU 一直占用 100%,经过仔细排查,发现了该问题。其具体原因如下:

首先,由于 Hashmap 是一个非线程安全的容器。所以它的内部实现并未对多线程环境下进行了兼容。这就导致在多线程环境下进行扩容转移链表时发生死循环状态。其步骤具体如下:

这是一个原始的 Hashmap, 在插入一个新节点时触发了 rehash 方法,进行重新分配 hash 桶。其实现的方法为遍历所有的节点并重新计算 hash 值。但是,这不是有序的。

 

步骤一:两个线程并发在执行。首先,他们都获得了所有节点的集合。该方法内记录了第一个获取到的节点元素 1 和它的 next 元素,元素 2。 然后在线程 1 的时间片区里。对所有的元素进行了重新分配。这里注意由于链表是头节点插入方式,所以顺序是反序的。如下图。

 

步骤二:线程 1 还没来得及将更分配好的值变成新的 table 就过了 CPU 的时间片。现在轮到线程 2 开始遍历。然后继续执行步骤。

 

步骤三:线程 2 获取了元素 1 以后,将把元素 1 挂到 4 号桶,而后获取之前已经记录的 next 节点,也就是元素 2。此时发现元素 2 指向的是 1 ,就又将 元素 1 挂在了元素 2 上。从而形成了一个循环。最终如下:

 

如果是线程 2 先执行完毕更新 table 的话,还有可能导致元素 3 的丢失。

现在,你只要在 get 元素时走入了这个链表,就会引发死循环。导致 CPU 100%。

【 解决办法 】

解决办法很简单。心里默念三遍:

HashMap 不是线程安全的!!

HashMap 不是线程安全的!!

HashMap 不是线程安全的!!

如果要在多线程环境下使用 Map 集合,可以无脑使用 ConcurrentHashMap,该容器是一个线程安全的容器。我们将会在以后介绍多线程的时候讲解。

 

总结

本篇文章花费大篇幅来讲解了什么是时间复杂度、HashMap 的结构、不遵守约定的后果、面试官常问的"为什么容量必须是 2 的幂"的问题和 由于错误使用 HashMap 导致 CPU 跑满的"血案"。

感谢你可以阅读到这里,如果文章对你有所帮助的话,麻烦帮忙一键三连支持一下,我会持续更新java干货分享给大家。祝各位万事胜意,来日可期!

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值