由于不懂 Map 集合而引发的 "血案" ( 上 )|Java 开发实战

1,263 阅读7分钟

本文正在参加「Java主题月 - Java 开发实战」,详情查看 活动链接

本文章集干货满满,适用于长期处于“螺丝钉”岗位并渴望快速提升能力的 Java 工程师们。如果读者认为本文某些地方未讲解清楚或存在错误欢迎指正 👏

故事背景

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

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

img_8.png

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

什么是 Map

如果你要想知道什么是 Map 集合。我现在就带你研究。🤪

时间复杂度

首先,如果你学过计算机相关专业课程。你就会了解过一个学术名词。它叫做时间复杂度

在计算机科学中,算法的时间复杂度(Time complexity)是一个函数,它定性描述该算法的运行时间。 这是一个代表算法输入值的字符串的长度的函数。 时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。 使用这种方式时,时间复杂度可被称为是渐近的,亦即考察输入值大小趋近无穷时的情况。

img_9.png

--- 摘抄自 Wikipedia ( 其主要用于装 X 显得本文很高大上 )

说白了,时间复杂度就是在计算机内执行一个算法,从开始到获取一个结果所需要耗费时间的一种通用描述。它使用 大 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

img_10.png

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

Hash 表有多重要

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

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

img_11.png

img_12.png

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

由此可见,学好它还是很重要的 👍

equals & hashCode 约定

首先,我们来讲解一下什么是 equals & hashCode 约定,在之前的文章中我详细讲过。这里我简单提一下。

  1. 一个对象的 hashCode 必须永远一致。

  2. 如果对象 A 和对象 B 调用 equals 方法不相等,允许它们调用 hashCode 方法返回的值相等。但是不推荐这样做,这样会影响内部 Hash 表的查找速度。

  3. 如果对象 A 和对象 B 调用 equals 方法相等,那么它们调用 hashCode 方法也一定返回相同值。

如何计算 hash 值

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

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

我们分开来看一下。

对象消失之谜

讲到这,想起一个笑话。

对于软件工程师来说,没有对象根本不算事。自己 new 一个不就行了吗。

-- 冷笑话( 怕大家睡着 )

如果你违反了上面的第 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));
    }
}

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

img_13.png

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

【 原因 】

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

你太慢了!

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

class Scratch {

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

}

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

如图所示:

img_14.png

我是复制人

如果你还做了其他的 “奇淫巧技” , 例如让两个对象 equals 不同,但内容和 hashcode 相同。 那么,我只能说,祝你好运。

import java.util.*;

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) throws IllegalAccessException, NoSuchFieldException {
        Map<Scratch, String> testMap=new HashMap<>();
        Scratch numberOne = new Scratch("1号技师","阿水");
        testMap.put(numberOne,"我是1号技师");
        Scratch sameKey = new Scratch("1号技师","阿水");
        testMap.put(sameKey,"我是2号技师");
        System.out.println(testMap);
    }
}

它的运行结果为:

image.png

到这,虽然相同的 hashcode会导致性能问题,不过结果确是我们想要的,根据相同的key来更新值。但是!当我修改一下代码。你变会发现一个惊人的秘密......

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

它的运行结果为:

image.png

怎么个情况?怎么多出来一个元素 ?下面由我来解答这个问题 :( 柯南背景音乐响起 )

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

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

所以,原因便是:由于我们错误的设置了 equals 方法。导致每次判断都是一个新元素,将会不断的新增。导致反常识的结果。

( 柯南背景音乐结束 )

为什么容量必须是 2 的幂 ( 其实知道没啥大用,但是面试官还老爱问 )?

容量是 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 值。但是,这不是有序的。

img_20.png

  1. 步骤一:两个线程并发在执行。首先,他们都获得了所有节点的集合。该方法内记录了第一个获取到的节点元素 1 和它的 next 元素,元素 2。 然后在线程 1 的时间片区里。对所有的元素进行了重新分配。如下图。

    注意,由于链表是头节点插入方式,所以顺序是反序的。

img_22.png

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

img_23.png

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

img_24.png

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

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

【 解决办法 】

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

HashMap 不是线程安全的!!

HashMap 不是线程安全的!!

HashMap 不是线程安全的!!

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

总结

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

下篇我们将会讲解 Java 1.8 对于 HashMap 的一些改进和简单粗暴的 Set 集合。