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

文章会收录在 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 401 二进制手表

    401. 二进制手表 二进制手表顶部有 4 个 LED 代表小时(0-11),底部的 6 个 LED 代表分钟(0-59). 每个 LED 代表一个 0 或 1,最低位在右侧. 例如,上面的二进制手表 ...

  2. Java实现 LeetCode 101 对称二叉树

    101. 对称二叉树 给定一个二叉树,检查它是否是镜像对称的. 例如,二叉树 [1,2,2,3,4,4,3] 是对称的. 1 / \ 2 2 / \ / \ 3 4 4 3 但是下面这个 [1,2,2 ...

  3. 两种方法设置MMDVM静态组

    方法一.进入BM页面设置静态组 1.仪表盘配置页面点击下图所示进入BM 2.或是点击链接进入https://brandmeister.network 3..进入页面后点击My hotspots,显示你 ...

  4. surface go重新做系统

    此教程适用于使用U盘恢复介质来恢复Surface Go二合一设备系统SurfaceGo_BMR_45_64_1.011.2.zip 大致两个步骤 一.制作U盘恢复介质 下载适用于自己平板的恢复镜像文件 ...

  5. Spring Boot 教程 - Elasticsearch

    1. Elasticsearch简介 Elasticsearch是一个基于Lucene的搜索服务器.它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口.Elasticsearc ...

  6. js中有遍历作用相关的方法详解总结

    题外话 os:个人笔记: 大概接触过map, foreach, for, filter, findIn, includes等等 字符串检索 .indexOf() 返回某个指定字符串值在字符串中首次出现 ...

  7. [转]记一次linux(被)入侵,服务器变矿机~

    0x00 背景 周一早上刚到办公室,就听到同事说有一台服务器登陆不上了,我也没放在心上,继续边吃早点,边看币价是不是又跌了.不一会运维的同事也到了,气喘吁吁的说:我们有台服务器被阿里云冻结了,理由:对 ...

  8. 分享一个springboot脚手架

    项目介绍 在我们开发项目的时候各个项目之间总有一些可共用的代码或者配置,如果我们每新建一个项目就把代码复制粘贴再修改就显得很没有必要.于是我就做了一个 poseidon-boot-starter 该项 ...

  9. TensorFlow从0到1之TensorFlow Keras及其用法(25)

    Keras 是与 TensorFlow 一起使用的更高级别的作为后端的 API.添加层就像添加一行代码一样简单.在模型架构之后,使用一行代码,你可以编译和拟合模型.之后,它可以用于预测.变量声明.占位 ...

  10. redis5.0.7集群搭建

    这里实验的是129.130.240三台服务器6个节点的部署(redis集群最低要6个节点,不然无法创建). 1.压缩包安装 #wget http://download.redis.io/release ...