Java的集合类由Collection接口和Map接口派生,其中:

  • List代表有序集合,元素有序且可重复
  • Set代表无序集合,元素无序且不可重复
  • Map集合存储键值对

那么本篇文章将从源码角度讨论一下无序集合Set。

HashSet

HashSet实现 Set 接口,由哈希表(实际上是一个 HashMap 实例)支持。它不保证 set 的迭代顺序;特别是它不保证该顺序恒久不变。此类允许使用 null 元素。看下面的一个例子:

  1. HashSet<String> hs = new HashSet<String>();
  2. // 添加元素
  3. hs.add("hello");
  4. hs.add("world");
  5. hs.add("java");
  6. hs.add("world");
  7. hs.add(null);
  8. //遍历
  9. for (String str : hs) {
  10. System.out.println(str);
  11. }

执行结果:

  1. null
  2. world
  3. java
  4. hello

由执行结果可知,它允许加入null,元素不可重复,且元素无序。

那我们想,它是如何保证元素不重复的呢?这就要来分析一下它的源码。

首先是HashSet集合的add()方法:

  1. public boolean add(E e) {
  2. return map.put(e, PRESENT)==null;
  3. }

该方法中调用了map对象的put()方法,那么map对象是什么呢?

  1. private transient HashMap<E,Object> map;

可以看到,这个map对象就是HashMap,我们继续查看HashSet的构造方法:

  1. public HashSet() {
  2. map = new HashMap<>();
  3. }

到这里,应该就能明白,HashSet的底层实现就是HashMap,所以调用的put()方法就是HashMap的put()方法,那我们继续查看put()方法:

  1. public V put(K key, V value) {
  2. return putVal(hash(key), key, value, false, true);
  3. }

put()方法调用了putVal()方法,那么重点就是这个putVal()方法了:

  1. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  2. boolean evict) {
  3. Node<K,V>[] tab; Node<K,V> p; int n, i;
  4. //判断hashmap对象中 tabel属性是否为空--->为空---->resize()
  5. if ((tab = table) == null || (n = tab.length) == 0)
  6. n = (tab = resize()).length;
  7. //发现tab[i] 没有值,直接存入即可
  8. if ((p = tab[i = (n - 1) & hash]) == null)
  9. tab[i] = newNode(hash, key, value, null);
  10. else {
  11. //tab[i]有值,分情况讨论
  12. Node<K,V> e; K k;
  13. // 如果新插入的元素和table中p元素的hash值,key值相同的话
  14. if (p.hash == hash &&
  15. ((k = p.key) == key || (key != null && key.equals(k))))
  16. e = p;
  17. // 如果是红黑树结点的话,进行红黑树插入
  18. else if (p instanceof TreeNode)
  19. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  20. else {
  21. for (int binCount = 0; ; ++binCount) {
  22. // 代表这个单链表只有一个头部结点,则直接新建一个结点即可
  23. if ((e = p.next) == null) {
  24. p.next = newNode(hash, key, value, null);
  25. // 链表长度大于8时,将链表转红黑树
  26. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  27. treeifyBin(tab, hash);
  28. break;
  29. }
  30. // 如果与单向链表上的某个结点key值相同,则跳出循环,此时e是需要修改的结点,p是e的前驱结点
  31. if (e.hash == hash &&
  32. ((k = e.key) == key || (key != null && key.equals(k))))
  33. break;
  34. //更新变量p
  35. p = e;
  36. }
  37. }
  38. //处理完毕,添加元素
  39. if (e != null) { // existing mapping for key
  40. V oldValue = e.value;
  41. //判断是否允许覆盖,并且value是否为空
  42. if (!onlyIfAbsent || oldValue == null)
  43. e.value = value;
  44. afterNodeAccess(e);
  45. return oldValue;
  46. }
  47. }
  48. ++modCount;// 更改操作次数
  49. //如果大于临界值
  50. if (++size > threshold)
  51. //将数组大小设置为原来的2倍,并将原先的数组中的元素放到新数组中
  52. resize();
  53. afterNodeInsertion(evict);
  54. return null;
  55. }

我们一起分析一下这段源码,首先它将table对象赋值给tab,并判断tab是否为空,这里的table就是哈希表,因为HashMap是基于哈希表的Map接口的实现,如果哈希表为空则调用resize()方法开辟存储空间并赋值给tab,然后将tab的长度赋值给n。接着根据 (n - 1) & hash 算法计算出i并获取tab的第i个元素,如果没有值,那么可以直接存入,如果有值,那么就存在两种情况:

  1. hash值重复
  2. 位置冲突

也就是说,如果在添加过程中发现key值重复,那么就把p复制给e,p为当前位置上的元素,e为需要被修改的元素。而位置冲突又分为几种情况:

  • 产生位置冲突时,table数组下面的结点以单链表的形式存在,插入结点时直接放在链表最末位
  • 产生位置冲突时,key值和之前的结点一样
  • 产生位置冲突时,table数组下面的结点以红黑树的形式存在,插入结点时需要在树中查找合适位置

那么根据这三种情况,需要分别作出判断:如果p是TreeNode的实例(p instanceof TreeNode),说明p下面挂着红黑树,需要在树中找到一个合适的位置e插入。如果p下面的结果数没有超过8,则p就是以单向链表的形式存在,然后在链表中逐个往下找到空位置;如果超过了8,就要将p转换为红黑树;如果与单向链表上的某个结点key值相同,则跳出循环,此时e是需要修改的结点,p是e的前驱结点。最后就是判断插入后的大小,如果大于threshold,则继续申请空间。

那么这是jdk1.8之后的关于HashMap的存储方式,也就是数组 + 链表 + 红黑树的结构,而在1.8之前,HashMap是由数组 + 链表的结构作为存储方式的。

所以HashSet如何保证元素是唯一的呢?关键就在于这一句判断:

  1. if (e.hash == hash && ((k = e.key) == key || key.equals(k)))

它先看hashCode()值是否相同,如果相同,则继续看equals()方法,如果也相同,则证明元素重复,break跳出循环,元素不添加,如果不相同则进行添加。所以当一个自定义的类想要正确存入HashSet集合,就应该去重写equals()方法和hashCode()方法,而String类已经重写了这两个方法,所以它就可以把相同的字符串去掉,只保留其中一个。

那我们继续看下面的一个例子:

自定义学生类

  1. public class Student {
  2. private String name;
  3. private int age;
  4. public Student(String name, int age) {
  5. this.name = name;
  6. this.age = age;
  7. }
  8. public String getName() {
  9. return name;
  10. }
  11. public void setName(String name) {
  12. this.name = name;
  13. }
  14. public int getAge() {
  15. return age;
  16. }
  17. public void setAge(int age) {
  18. this.age = age;
  19. }
  20. @Override
  21. public String toString() {
  22. return "Student [name=" + name + ", age=" + age + "]";
  23. }
  24. }

然后编写测试代码:

  1. HashSet<Student> hs = new HashSet<Student>();
  2. //添加元素
  3. Student s = new Student("刘德华",30);
  4. Student s2 = new Student("陈奕迅",31);
  5. Student s3 = new Student("周星驰",32);
  6. Student s4 = new Student("刘德华",30);
  7. hs.add(s);
  8. hs.add(s2);
  9. hs.add(s3);
  10. hs.add(s4);
  11. //遍历
  12. for (Student student : hs) {
  13. System.out.println(student);
  14. }

在上述代码中,s4和s对象的姓名和年龄都相同,按理说这是两个相同的对象,是不能同时在HashSet集合中存在的,然而我们看运行结果:

  1. Student [name=周星驰, age=32]
  2. Student [name=刘德华, age=30]
  3. Student [name=陈奕迅, age=31]
  4. Student [name=刘德华, age=30]

如果前面的源码分析大家都理解了的话,这个相信大家就能明白,这是因为我们没有去重写hashCode()方法和equals()方法,而它默认就会去调用Object的方法,所以它会认为每个学生对象都是不相同的。那我们现在来重写一下这两个方法:

  1. @Override
  2. public int hashCode() {
  3. return 0;
  4. }
  5. @Override
  6. public boolean equals(Object obj) {
  7. //添加了一条输出语句,用于显示比较次数
  8. System.out.println(this + "---" + obj);
  9. if (this == obj) {
  10. return true;
  11. }
  12. if (!(obj instanceof Student)) {
  13. return false;
  14. }
  15. Student s = (Student) obj;
  16. return this.name.equals(s.name) && this.age == s.age;
  17. }

然后我们运行程序:

  1. Student [name=陈奕迅, age=31]---Student [name=刘德华, age=30]
  2. Student [name=周星驰, age=32]---Student [name=刘德华, age=30]
  3. Student [name=周星驰, age=32]---Student [name=陈奕迅, age=31]
  4. Student [name=刘德华, age=30]---Student [name=刘德华, age=30]
  5. Student [name=刘德华, age=30]
  6. Student [name=陈奕迅, age=31]
  7. Student [name=周星驰, age=32]

可以看到,虽然去除了重复元素,但是比较的次数未免过多,因为hashCode()方法返回的是一个固定值0,所以在进行判断的时候hashCode值永远相同从而多次调用equals()进行判断,那么我们就可以尽可能地使hashCode值不相同,那么哈希值和哪些内容相关呢?

因为它和对象的成员变量值相关,所以我们可以进行如下措施:

如果是基本类型变量,直接加值;

如果是引用类型变量,加哈希值。

所以对hashCode()作如下修改:

  1. @Override
  2. public int hashCode() {
  3. //为了避免某种巧合导致两个不相同的对象其计算后返回的hashCode值相同,这里对基本类型age进行一个乘积的运算
  4. return this.name.hashCode() + this.age * 15;
  5. }

现在运行看效果:

  1. Student [name=刘德华, age=30]---Student [name=刘德华, age=30]
  2. Student [name=周星驰, age=32]
  3. Student [name=刘德华, age=30]
  4. Student [name=陈奕迅, age=31]

重复元素成功被去除,而比较次数缩减为了一次,大大提升了程序运行效率。

LinkedHashSet

它是具有可预知迭代顺序的 Set 接口的哈希表和链接列表实现,该集合方法全部继承自父类HashSet,但它与HashSet的唯一区别就是它具有可预知迭代顺序,它遵从存储和取出顺序是一致的。直接举例说明:

  1. LinkedHashSet<String> linkedHashSet = new LinkedHashSet<String>();
  2. //添加元素
  3. linkedHashSet.add("hello");
  4. linkedHashSet.add("world");
  5. linkedHashSet.add("java");
  6. //遍历
  7. for (String str : linkedHashSet) {
  8. System.out.println(str);
  9. }

运行结果:

  1. hello
  2. world
  3. java

TreeSet

它是基于 TreeMap 的 NavigableSet 实现。使用元素的自然顺序对元素进行排序,或者根据创建 set 时提供的 Comparator 进行排序,具体取决于使用的构造方法。

举例说明:

  1. TreeSet<Integer> treeSet = new TreeSet<Integer>();
  2. //添加元素
  3. treeSet.add(10);
  4. treeSet.add(26);
  5. treeSet.add(20);
  6. treeSet.add(13);
  7. treeSet.add(3);
  8. //遍历
  9. for(Integer i : treeSet) {
  10. System.out.println(i);
  11. }

运行结果:

  1. 3
  2. 10
  3. 13
  4. 20
  5. 26

由此可见,TreeSet是具有排序功能的。但请注意,如果使用无参构造创建TreeSet集合,它将默认使用元素的自然排序;当然你也可以传入比较器来构造出TreeSet。

那么它是如何实现元素的自然排序的呢?我们通过源码来分析一下:

首先看它的add()方法

  1. public boolean add(E e) {
  2. return m.put(e, PRESENT)==null;
  3. }

方法内部调用了m对象的put()方法,而这个m是一个NavigableMap对象:

  1. private transient NavigableMap<E,Object> m;

当我们继续跟进put()方法的时候,发现它是一个抽象方法:

  1. V put(K key, V value);

该方法处于Map接口中,那么我们就要去找Map接口的实现类,我们知道,TreeSet是基于TreeMap实现的,所以我们认为它调用的其实是TreeMap的put()方法,查阅TreeMap的继承结构也可以证实这一点:

  1. java.util
  2. TreeMap<K,V>
  3. java.lang.Object
  4. 继承者 java.util.AbstractMap<K,V>
  5. 继承者 java.util.TreeMap<K,V>
  6. 类型参数:
  7. K - 此映射维护的键的类型
  8. V - 映射值的类型
  9. 所有已实现的接口:
  10. Serializable, Cloneable, Map<K,V>, NavigableMap<K,V>, SortedMap<K,V>

TreeMap确实也实现了NavigableMap接口,那我们就来看一看TreeMap的put()方法:

  1. public V put(K key, V value) {
  2. Entry<K,V> t = root;
  3. //创建树的根结点
  4. if (t == null) {
  5. compare(key, key); // type (and possibly null) check
  6. root = new Entry<>(key, value, null);
  7. size = 1;
  8. modCount++;
  9. return null;
  10. }
  11. int cmp;
  12. Entry<K,V> parent;
  13. // split comparator and comparable paths
  14. Comparator<? super K> cpr = comparator;
  15. //判断是否拥有比较器
  16. if (cpr != null) {
  17. //比较器排序
  18. do {
  19. parent = t;
  20. cmp = cpr.compare(key, t.key);
  21. if (cmp < 0)
  22. t = t.left;
  23. else if (cmp > 0)
  24. t = t.right;
  25. else
  26. return t.setValue(value);
  27. } while (t != null);
  28. }
  29. else {
  30. //判断元素是否为空
  31. if (key == null)
  32. //抛出异常
  33. throw new NullPointerException();
  34. @SuppressWarnings("unchecked")
  35. //将元素强转为Comparable类型
  36. do {
  37. parent = t;
  38. cmp = k.compareTo(t.key);
  39. if (cmp < 0)
  40. t = t.left;
  41. else if (cmp > 0)
  42. t = t.right;
  43. else
  44. return t.setValue(value);
  45. } while (t != null);
  46. }
  47. Entry<K,V> e = new Entry<>(key, value, parent);
  48. if (cmp < 0)
  49. parent.left = e;
  50. else
  51. parent.right = e;
  52. fixAfterInsertion(e);
  53. size++;
  54. modCount++;
  55. return null;
  56. }

我们来分析一下。

首先它会判断Entry类型的变量t是否为空,那么一开始该变量肯定为空,所以它会去创建Entry对象,我们知道, TreeMap是基于红黑树的实现,所以它其实是在创建树的根结点。接着它会去判断是否拥有比较器,因为我们使用的是无参构造创建的TreeSet,所以在这里肯定是没有比较器的,那么他就执行else语句块,我们可以看到这一句代码:

  1. Comparable<? super K> k = (Comparable<? super K>) key;

根据我们刚才的程序分析,这里的key就是我们传入的Integer对象,那么它是怎么能够将Integer对象强转为Comparable对象的呢?查询Comparable类的文档后,我们知道,这是一个接口,此接口强行对实现它的每个类的对象进行整体排序。这种排序被称为类的自然排序,类的 compareTo 方法被称为它的自然比较方法。而Integer类实现了Comparable接口,所以可以将Integer向上转型为Comparable对象。接着该对象调用了compareTo()方法,该方法返回一个int类型值,作用是:如果该 Integer 等于 Integer 参数,则返回 0 值;如果该 Integer 在数字上小于 Integer 参数,则返回小于 0 的值;如果 Integer 在数字上大于 Integer 参数,则返回大于 0 的值(有符号的比较)。所以它通过该方法的返回值即可判断出两个数字的大小。如果小于0,则放在左边(t.left);如果大于0,则放在右边(t.right)。这样说可能过于抽象,我们可以通过画图来进一步理解:



这是二叉树的存储规则,第一个元素作为根结点,然后接下来的每个元素都先与根结点比较,大于根结点则作为右孩子,小于根结点则作为左孩子;如果位置上已经有元素了,则要继续与该元素比较,比它大作为右孩子,比它小作为左孩子,以此类推。(若元素相等,则不存储)

那么元素是如何取出来的呢?学过数据结构的同学都知道,二叉树有三种遍历方式:

  1. 前序遍历
  2. 中序遍历
  3. 后序遍历

那我们以前序遍历为例进行元素提取(按照左、中、右的原则):

首先从根结点开始,根结点为10,然后看它的左孩子,左孩子为3,此时3已经没有孩子,所以3第一个取出;这样左边就都取完了,我们取中间,也就是10;然后取右边26,因为26还有孩子,所以取26的左边20,因为20还有左孩子,所以13第三个取出;这样20已经没有孩子,我们取中间,也就是20,最后取出26。最终,元素的取出顺序为:3,10,13,20,26;这样就完成了元素的排序。

那么以上是元素的自然排序,接下来介绍比较器排序。

还是之前的Student类,我们编写测试代码:

  1. TreeSet<Student> treeSet = new TreeSet<Student>();
  2. // 添加元素
  3. Student s = new Student("liudehua", 30);
  4. Student s2 = new Student("chenyixun", 32);
  5. Student s3 = new Student("zhourunfa", 20);
  6. Student s4 = new Student("gutianle", 40);
  7. Student s5 = new Student("zhouxingchi", 29);
  8. treeSet.add(s);
  9. treeSet.add(s2);
  10. treeSet.add(s3);
  11. treeSet.add(s4);
  12. treeSet.add(s5);
  13. // 遍历
  14. for (Student student : treeSet) {
  15. System.out.println(student);
  16. }

此时运行程序会报错,因为Student类没有实现Comparable接口。

因为在TreeSet的构造方法中需要传入一个Comparator的对象,而这是一个接口,所以我们自定义一个类实现该接口,那么我们来实现一个需求,根据姓名长度进行排序:

  1. public class MyComparator implements Comparator<Student> {
  2. @Override
  3. public int compare(Student o1, Student o2) {
  4. //根据姓名长度
  5. int num = o1.getName().length() - o2.getName().length();
  6. //根据姓名内容
  7. int num2 = num == 0 ? o1.getName().compareTo(o2.getName()) : num;
  8. //根据年龄
  9. int num3 = num2 == 0 ? o1.getAge() - o2.getAge() : num2;
  10. return num3;
  11. }
  12. }

编写测试代码:

  1. TreeSet<Student> treeSet = new TreeSet<Student>(new MyComparator());
  2. // 添加元素
  3. Student s = new Student("liudehua", 30);
  4. Student s2 = new Student("chenyixun", 32);
  5. Student s3 = new Student("zhourunfa", 20);
  6. Student s4 = new Student("gutianle", 40);
  7. Student s5 = new Student("zhouxingchi", 29);
  8. treeSet.add(s);
  9. treeSet.add(s2);
  10. treeSet.add(s3);
  11. treeSet.add(s4);
  12. treeSet.add(s5);
  13. // 遍历
  14. for (Student student : treeSet) {
  15. System.out.println(student);
  16. }

运行结果:

  1. Student [name=gutianle, age=40]
  2. Student [name=liudehua, age=30]
  3. Student [name=chenyixun, age=32]
  4. Student [name=zhourunfa, age=20]
  5. Student [name=zhouxingchi, age=29]

也可以通过匿名内部类的方式实现。

希望这篇文章能够使你更加深入地理解Set集合。

深入Java源码剖析之Set集合的更多相关文章

  1. 【java集合框架源码剖析系列】java源码剖析之TreeSet

    本博客将从源码的角度带领大家学习TreeSet相关的知识. 一TreeSet类的定义: public class TreeSet<E> extends AbstractSet<E&g ...

  2. 【java集合框架源码剖析系列】java源码剖析之HashSet

    注:博主java集合框架源码剖析系列的源码全部基于JDK1.8.0版本.本博客将从源码角度带领大家学习关于HashSet的知识. 一HashSet的定义: public class HashSet&l ...

  3. 【java集合框架源码剖析系列】java源码剖析之TreeMap

    注:博主java集合框架源码剖析系列的源码全部基于JDK1.8.0版本.本博客将从源码角度带领大家学习关于TreeMap的知识. 一TreeMap的定义: public class TreeMap&l ...

  4. 【java集合框架源码剖析系列】java源码剖析之ArrayList

    注:博主java集合框架源码剖析系列的源码全部基于JDK1.8.0版本. 本博客将从源码角度带领大家学习关于ArrayList的知识. 一ArrayList类的定义: public class Arr ...

  5. 【java集合框架源码剖析系列】java源码剖析之LinkedList

    注:博主java集合框架源码剖析系列的源码全部基于JDK1.8.0版本. 在实际项目中LinkedList也是使用频率非常高的一种集合,本博客将从源码角度带领大家学习关于LinkedList的知识. ...

  6. 【java集合框架源码剖析系列】java源码剖析之HashMap

    前言:之所以打算写java集合框架源码剖析系列博客是因为自己反思了一下阿里内推一面的失败(估计没过,因为写此博客已距阿里巴巴一面一个星期),当时面试完之后感觉自己回答的挺好的,而且据面试官最后说的这几 ...

  7. 【java集合框架源码剖析系列】java源码剖析之java集合中的折半插入排序算法

    注:关于排序算法,博主写过[数据结构排序算法系列]数据结构八大排序算法,基本上把所有的排序算法都详细的讲解过,而之所以单独将java集合中的排序算法拿出来讲解,是因为在阿里巴巴内推面试的时候面试官问过 ...

  8. java源码剖析: 对象内存布局、JVM锁以及优化

    一.目录 1.启蒙知识预热:CAS原理+JVM对象头内存存储结构 2.JVM中锁优化:锁粗化.锁消除.偏向锁.轻量级锁.自旋锁. 3.总结:偏向锁.轻量级锁,重量级锁的优缺点. 二.启蒙知识预热 开启 ...

  9. JAVA源码剖析(容器篇)HashMap解析(JDK7)

    Map集合: HashMap底层结构示意图: HashMap是一个“链表散列”,其底层是一个数组,数组里面的每一项都是一条单链表. 数组和链表中每一项存的都是一“Entry对象”,该对象内部拥有key ...

随机推荐

  1. python 中文分词库 jieba库

    jieba库概述: jieba是优秀的中文分词第三方库 中文文本需要通过分词获得单个的词语 jieba是优秀的中文分词第三方库,需要额外安装 jieba库分为精确模式.全模式.搜索引擎模式 原理 1. ...

  2. 18.Llinux-触摸屏驱动(详解)【转】

    转自:https://www.cnblogs.com/lifexy/p/7628889.html 本节的触摸屏驱动也是使用之前的输入子系统 1.先来回忆之前第12节分析的输入子系统 其中输入子系统层次 ...

  3. Windows 跟 Linux 文件共享:Samba 设置

    用 Samba  服务器 https://my.oschina.net/u/3783115/blog/1919892?from=timeline https://blog.51cto.com/1372 ...

  4. UML类图基础说明

    UML类图主要由类和关系组成. 类: 什么具有相同特征的对象的抽象, 具体我也记不住, 反正有官方定义 关系: 指各个类之间的关系 类图 类就使用一个方框来表示, 把方框分成几层, 来表示不同的信息, ...

  5. unittest执行顺序,使用unittest.main()按照test开头,由0-9,A-Z,a-z的顺序执行; 可使用TestSuite类的addTest方法改变执行顺序;

    import unittestclass Study(unittest.TestCase): # def setUp(self): # print('start') # def tearDown(se ...

  6. SpringBoot关于静态js资源的报错问题

    2019-12-02 09:45:01.636 WARN 9572 --- [nio-8080-exec-2] o.s.web.servlet.PageNotFound : No mapping fo ...

  7. [C3] Andrew Ng - Neural Networks and Deep Learning

    About this Course If you want to break into cutting-edge AI, this course will help you do so. Deep l ...

  8. zz“老司机”成长之路:自动驾驶车辆调试实践

    随着自动驾驶技术的发展,一辆新车从被改装到上路需要经过的调试流程也有了许多提升.今天,我希望结合自己之前的调车经验来跟大家分享一下我们是如何将系统的各个模块逐步上车.调试.集成,进而将一辆“新手”车培 ...

  9. 最短路问题的三种算法&模板

    最短路算法&模板 最短路问题是图论的基础问题.本篇随笔就图论中最短路问题进行剖析,讲解常用的三种最短路算法:Floyd算法.Dijkstra算法及SPFA算法,并给出三种算法的模板.流畅阅读本 ...

  10. 对mglearn库的理解(转)

    https://blog.csdn.net/az9996/article/details/86490496