本文章集干货满满,适用于长期处于“螺丝钉”岗位并渴望快速提升能力的 Java 工程师们。如果读者认为本文某些地方未讲解清楚或存在错误欢迎指正 👏
故事背景
我们公司的 JDK 是 1.7 版本,最近新上线了一个服务,但是我们发现我们的服务时常会卡死。经过我们的分析,一件可怕的事情浮出了水面…
哈哈,大家好。我是扶摇。标题虽然有些"标题党"。但是内容绝对是相当的干货。在接下来的文章中我会带领大家深入了解 Map 集合,然后讲解是什么引发的"血案"。
在 Java 的 Collection
集合中,我们不可避免的要用到 Map
集合。那么,问题来了。你够深刻的理解 Java 的 Map
集合吗?
什么是 Map
如果你要想知道什么是
Map
集合。我现在就带你研究。🤪
时间复杂度
首先,如果你学过计算机相关专业课程。你就会了解过一个学术名词。它叫做时间复杂度
。
在计算机科学中,算法的时间复杂度(Time complexity)是一个函数,它定性描述该算法的运行时间。 这是一个代表算法输入值的字符串的长度的函数。 时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。 使用这种方式时,时间复杂度可被称为是渐近的,亦即考察输入值大小趋近无穷时的情况。
— 摘抄自 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
简单来说,就是对传入的 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 值,正常来说是不会有什么奇奇怪怪的问题。 但是总有人喜欢玩一些 “奇淫巧技”。从而违反了上述约定。
那么违反约定会引起什么问题呢 ?
我们分开来看一下。
对象消失之谜
讲到这,想起一个笑话。
对于软件工程师来说,没有对象根本不算事。自己 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));
}
}
你猜,它会打印返回什么 ?
很不幸,二号技师丢失了。
【 原因 】
在放入 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 的幂 ( 其实知道没啥大用,但是面试官还老爱问 )?
容量是 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 1.8 对于 HashMap 的一些改进和简单粗暴的 Set 集合。
共同学习,写下你的评论
评论加载中...
作者其他优质文章