我是风筝,公众号「古时的风筝」,一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!

文章会收录在 JavaNewBee 中,更有 Java 后端知识图谱,从小白到大牛要走的路都在里面。

之前的7000 字说清楚 HashMap已经详细介绍了 HashMap 的原理和实现,本次再来说说他的同胞兄弟 HashSet,这两兄弟经常被拿出来一起说,面试的时候,也经常是两者结合着考察。难道它们两个的实现方式很类似吗,不然为什么总是放在一起比较。

实际上并不是因为它俩相似,从根本上来说,它俩本来就是同一个东西。再说的清楚明白一点, HashSet 就是个套了壳儿的 HashMap。所谓君子善假于物,HashSet 就假了 HashMap。既然你 HashMap 都摆在那儿了,那我 HashSet 何必重复造轮子,借你一样,何不美哉!

HashSet 是什么

下面是 HashSet的继承关系图,还是老样子,我们看一个数据结构的时候先看它的继承关系图。与 HashSet并列的还有 TreeSet,另外 HashSet 还有个子类型 LinkedHashSet,这个我们后面再说。

套壳 HashMap

为啥这么说呢,在我第一次看 HashSet源码的时候,已经准备好了笔记本,拿好了圆珠笔,准备好好探究一下 HashSet的神奇所在。可当我按着Ctrl+鼠标左键进入源码的构造函数的时候,我以为我走错了地方,这构造函数有点简单,甚至还有点神奇。new 了一个 HashMap并且赋给了 map 属性。

private transient HashMap<E,Object> map;

public HashSet() {
map = new HashMap<>();
}

再三确认没看错的情况下,我明白了,HashSet就是在HashMap的基础上套了个壳儿,我们用的是个HashSet,实际上它的内部存储逻辑都是 HashMap的那套逻辑。

除了上面的那个无参类型的构造方法,还有其他的有参构造方法,一看便知,其实就是 HashMap包装了一层而已。

public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
} public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
} public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}

用法

HashSet应该算是众多数据结构中最简单的一个了,满打满算也就那么几个方法。

public Iterator<E> iterator() {
return map.keySet().iterator();
} public int size() {
return map.size();
} public boolean isEmpty() {
return map.isEmpty();
} public boolean contains(Object o) {
return map.containsKey(o);
} public boolean add(E e) {
return map.put(e, PRESENT)==null;
} public boolean remove(Object o) {
return map.remove(o)==PRESENT;
} public void clear() {
map.clear();
}

很简单对不对,就这么几个方法,而且你看每个方法其实都是对应的操作 map,也就是内部的 HashMap,也就是说只要你懂了 HashMap自然也就懂了 HashSet

保证不重复

Set接口要求不能有重复项,只要继承了 Set就要遵守这个规定。我们大多数情况下使用 HashSet也是因为它有去重的功能。

那它是如何办到的呢,这就要从它的 add方法说起了。

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

HashSetadd方法其实就是调用了HashMapput方法,但是我们都知道 put进去的是一个键值对,但是 HashSet存的不是键值对啊,是一个泛型啊,那它是怎么办到的呢?

它把你要存的值当做 HashMap的 key,而 value 值是一个 finalObject对象,只起一个占位作用。而HashMap本身就不允许重复键,正好被HashSet拿来即用。

如何保证不重复呢

HashMap中不允许存在相同的 key 的,那怎么保证 key 的唯一性呢,判断的代码如下。

if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))

首先通过 hash 算法算出的值必须相等,算出的结果是 int,所以可以用 == 符号判断。只是这个条件可不行,要知道哈希碰撞是什么意思,有可能两个不一样的 key 最后产生的 hash 值是相同的。

并且待插入的 key == 当前索引已存在的 key,或者 待插入的 key.equals(当前索引已存在的key),注意== 和 equals 是或的关系。== 符号意味着这是同一个对象, equals 用来确定两个对象内容相同。

如果 key 是基本数据类型,比如 int,那相同的值肯定是相等的,并且产生的 hashCode 也是一致的。

String 类型算是最常用的 key 类型了,我们都知道相同的字符串产生的 hashCode 也是一样的,并且字符串可以用 equals 判断相等。

但是如果用引用类型当做 key 呢,比如我定义了一个 MoonKey 作为 key 值类型

public class MoonKey {

    private String keyTile;

    public String getKeyTile() {
return keyTile;
} public void setKeyTile(String keyTile) {
this.keyTile = keyTile;
} @Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MoonKey moonKey = (MoonKey) o;
return Objects.equals(keyTile, moonKey.keyTile);
}
}

然后用下面的代码进行两次添加,你说 size 的长度是 1 还是 2 呢?

Map<MoonKey, String> m = new HashMap<>();
MoonKey moonKey = new MoonKey();
moonKey.setKeyTile("1");
MoonKey moonKey1 = new MoonKey();
moonKey1.setKeyTile("1");
m.put(moonKey, "1");
m.put(moonKey1, "2");
System.out.println(hash(moonKey));
System.out.println(hash(moonKey1));
System.out.println(m.size());

答案是 2 ,为什么呢,因为 MoonKey 没有重写 hashCode 方法,导致 moonkey 和 moonKey1 的 hash 值不可能一样,当不重写 hashCode 方法时,默认继承自 Object的 hashCode 方法,而每个 Object对象的 hash 值都是独一无二的。

划重点,正确的做法应该是加上 hashCode的重写。

@Override
public int hashCode() {
return Objects.hash(keyTile);
}

这也是为什么要求重写 equals 方法的同时,也必须重写 hashCode方法的原因之一。 如果两个对象通过调用equals方法是相等的,那么这两个对象调用hashCode方法必须返回相同的整数。有了这个基础才能保证 HashMap或者HashSet的 key 唯一。

非线程安全

由于HashMap不是线程安全的,自然,HashSet也不是线程安全啦。在多线程、高并发环境中慎用,如果要用的话怎么办呢,不像 HashMap那样有多线程版本的ConcurrentHashMap,不存在 ``ConcurrentHashSet`这种数据结构,如果想用的话要用下面这种方式。

Set<String> set = Collections.synchronizedSet(new HashSet<String>());

或者使用 ConcurrentHashMap.KeySetView也可以,但是,这其实就不是 HashSet了,而是 ConcurrentHashMap的一个实现了 Set接口的静态内部类,多线程情况下使用起来完全没问题。

ConcurrentHashMap.KeySetView<String,Boolean> keySetView = ConcurrentHashMap.newKeySet();
keySetView.add("a");
keySetView.add("b");
keySetView.add("c");
keySetView.add("a");
keySetView.forEach(System.out::println);

LinkedHashSet

如果说 HashSet是套壳儿HashMap,那么LinkedHashSet就是套壳儿LinkedHashMap。对比 HashSet,它的一个特点就是保证数据有序,插入的时候什么顺序,遍历的时候就是什么顺序。

看一下它其中的无参构造函数。

public LinkedHashSet() {
super(16, .75f, true);
}

LinkedHashSet继承自 HashSet,所以 super(16, .75f, true);是调用了HashSet三个参数的构造函数。

HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

这次不是 new HashMap了,而是 new 了一个 LinkedHashMap,这就是它能保证有序性的关键。LinkedHashMap用双向链表的方式在 HashMap的基础上额外保存了键值对的插入顺序。

HashMap中定义了下面这三个方法,这三个方法是在插入和删除键值对的时候调用的方法,用来维护双向链表,在LinkedHashMap中有具体的实现。

// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

由于LinkedHashMap可以保证键值对顺序,所以,用来实现简单的 LRU 缓存。

所以,如果你有场景既要保证元素无重复,又要保证元素有序,可以使用 LinkedHashSet

最后

其实你掌握了 HashMap就掌握了 HashSet,它没有什么新东西,就是巧妙的利用了 HashMap而已,新不新不要紧,好用才是最重要的。


壮士且慢,先给点个赞吧,总是被白嫖,身体吃不消!

我是风筝,公众号「古时的风筝」。一个兼具深度与广度的程序员鼓励师,一个本打算写诗却写起了代码的田园码农!你可选择现在就关注我,或者看看历史文章再关注也不迟。

看完这篇 HashSet,跟面试官扯皮没问题了的更多相关文章

  1. 【Java8新特性】Stream API有哪些中间操作?看完你也可以吊打面试官!!

    写在前面 在上一篇<[Java8新特性]面试官问我:Java8中创建Stream流有哪几种方式?>中,一名读者去面试被面试官暴虐!归根结底,那哥儿们还是对Java8的新特性不是很了解呀!那 ...

  2. 原来ReadWriteLock也能开发高性能缓存,看完我也能和面试官好好聊聊了!

    大家好,我是冰河~~ 在实际工作中,有一种非常普遍的并发场景:那就是读多写少的场景.在这种场景下,为了优化程序的性能,我们经常使用缓存来提高应用的访问性能.因为缓存非常适合使用在读多写少的场景中.而在 ...

  3. HashMap? ConcurrentHashMap? 相信看完这篇没人能难住你!

    前言 Map 这样的 Key Value 在软件开发中是非常经典的结构,常用于在内存中存放数据. 本篇主要想讨论 ConcurrentHashMap 这样一个并发容器,在正式开始之前我觉得有必要谈谈 ...

  4. 看完这篇Redis缓存三大问题,保你面试能造火箭,工作能拧螺丝。

    前言 日常的开发中,无不都是使用数据库来进行数据的存储,由于一般的系统任务中通常不会存在高并发的情况,所以这样看起来并没有什么问题. 一旦涉及大数据量的需求,如一些商品抢购的情景,或者主页访问量瞬间较 ...

  5. 关于 Docker 镜像的操作,看完这篇就够啦 !(下)

    紧接着上篇<关于 Docker 镜像的操作,看完这篇就够啦 !(上)>,奉上下篇 !!! 镜像作为 Docker 三大核心概念中最重要的一个关键词,它有很多操作,是您想学习容器技术不得不掌 ...

  6. APP的缓存文件到底应该存在哪?看完这篇文章你应该就自己清楚了

    APP的缓存文件到底应该存在哪?看完这篇文章你应该就自己清楚了 彻底理解android中的内部存储与外部存储 存储在内部还是外部 所有的Android设备均有两个文件存储区域:"intern ...

  7. 【最短路径Floyd算法详解推导过程】看完这篇,你还能不懂Floyd算法?还不会?

    简介 Floyd-Warshall算法(Floyd-Warshall algorithm),是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似.该算法名称以 ...

  8. [转帖]看完这篇文章你还敢说你懂JVM吗?

    看完这篇文章你还敢说你懂JVM吗? 在一些物理内存为8g的服务器上,主要运行一个Java服务,系统内存分配如下:Java服务的JVM堆大小设置为6g,一个监控进程占用大约 600m,Linux自身使用 ...

  9. 看完这篇还不会 GestureDetector 手势检测,我跪搓衣板!

    引言 在 android 开发过程中,我们经常需要对一些手势,如:单击.双击.长按.滑动.缩放等,进行监测.这时也就引出了手势监测的概念,所谓的手势监测,说白了就是对于 GestureDetector ...

随机推荐

  1. Java实现 LeetCode 733 图像渲染(DFS)

    733. 图像渲染 有一幅以二维整数数组表示的图画,每一个整数表示该图画的像素值大小,数值在 0 到 65535 之间. 给你一个坐标 (sr, sc) 表示图像渲染开始的像素值(行 ,列)和一个新的 ...

  2. Java实现 LeetCode 703 数据流中的第K大元素(先序队列)

    703. 数据流中的第K大元素 设计一个找到数据流中第K大元素的类(class).注意是排序后的第K大元素,不是第K个不同的元素. 你的 KthLargest 类需要一个同时接收整数 k 和整数数组n ...

  3. Java实现 LeetCode 388 文件的最长绝对路径

    388. 文件的最长绝对路径 假设我们以下述方式将我们的文件系统抽象成一个字符串: 字符串 "dir\n\tsubdir1\n\tsubdir2\n\t\tfile.ext" 表示 ...

  4. 第六届蓝桥杯JavaC组国(决)赛真题

    解题代码部分来自网友,如果有不对的地方,欢迎各位大佬评论 题目1.机器人数目 少年宫新近邮购了小机器人配件,共有3类,其中, A类含有:8个轮子,1个传感器 B类含有: 6个轮子,3个传感器 C类含有 ...

  5. java实现串中找数字

    串中找数字 以下的静态方法实现了:把串s中第一个出现的数字的值返回. 如果找不到数字,返回-1 例如: s = "abc24us43" 则返回2 s = "82445ad ...

  6. webpack从什么都不懂到入门

    前言 这篇文章是自己在整理webpack相关的东西时候突发奇想,想总结自己所学知识,也希望能够帮助想学习webpack的同学们,都是入门级别的,大佬请出门右转. 本文的webpack基于webpack ...

  7. KMP 知识点整理

    1.扩展KMP 2.最大表示法 3.最小表示法 (扩展KMP) hdu2594  模板题 #include <iostream> #include <cstdio> #incl ...

  8. Fibonacci(模板)【矩阵快速幂】

    Fibonacci 题目链接(点击) Time Limit: 1000MS   Memory Limit: 65536K Total Submissions: 20989   Accepted: 14 ...

  9. DML_Modifying Data Through Table Expressions_UPDATE

    DML_Modifying Data Through Table Expressions_UPDATE之前也学习过使用CTE,再来泛泛的学习下,最后将会将一些书籍上或学习到的CTE来个小结 /* Mi ...

  10. CentOS7.5搭建spark2.3.1集群

    一 下载安装包 1 官方下载 官方下载地址:http://spark.apache.org/downloads.html 2  安装前提 Java8         安装成功 zookeeper  安 ...