Java集合详解(含JDK8源码)

目录

一.集合与数组的区别

1.1 数组

1.2 集合

二.Java集合

2.1 Java 集合框架体系

2.2 Collections

2.2.1 List

1.ArrayList

2.Vector

3.LinkedList

2.2.2 Set

1.HashSet

2.LinkedHashSet

3.TreeSet

2.3 Map

1.HashMap

2.LinkedHashMap

3.Hashtable

4.TreeMap


一.集合与数组的区别

1.1 数组

  1. 数组长度开始时必须指定,而且一旦指定,不能更改
  2. 保存的必须为同一类型(基本类型/引用类型)的元素
  3. 使用数组进行增加/删除元素的代码比较复杂

1.2 集合

  1. 集合不仅可以用来存储不同类型(不加泛型时)不同数量的对象,还可以保存具有映射关系的数据
  2. 集合是可以动态扩展容量,可以根据需要动态改变大小
  3. 集合提供了更多的成员方法,能满足更多的需求

二.Java集合

Java集合类存放于 java.util 包中,是一个用来存放对象的容器。主要是由两大接口派生而来:一个是 Collection接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于Collection 接口,下面又有三个主要的子接口:ListSet 和 Queue

注意:

① 集合只能存放对象。比如你存一个 int 型数据 1放入集合中,其实它是自动装箱成 Integer 类后存入的,Java中每一种基本类型都有对应的引用类型。

② 集合存放的是多个对象的引用,对象本身还是放在堆内存中。

③ 集合可以存放不同类型,不限数量的数据类型。

如果增加了泛型,Java 集合可以记住容器中对象的数据类型,即只允许存放一种数据类型。

2.1 Java 集合框架体系

Java集合详解(含JDK8源码)_第1张图片

  • List: 存储的元素是有序的、可重复的。
  • Set: 存储的元素是无序的、不可重复的。
  • Queue: 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
  • Map: 使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

集合主要是分了两组(单列集合和双列集合),单列集合表明在集合里放的是单个元素,双列集合往往是键值对形式(key-value)

2.2 Collections

Collections 中提供了大量对集合元素进行排序、查询和修改等操作的方法,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。以下定义的方法既可用于操作 List集合,也可用于操作Set 和 Queue

常用操作:

  • add() - 将元素添加到列表
  • addAll() - 将一个列表的所有元素添加到另一个
  • get() - 有助于从列表中随机访问元素
  • iterator() - 返回迭代器对象,该对象可用于顺序访问列表的元素
  • set() - 更改列表的元素
  • remove() - 从列表中删除一个元素
  • removeAll() - 从列表中删除所有元素
  • clear() - 从列表中删除所有元素(比removeAll()效率更高)
  • size() - 返回列表的长度
  • toArray() - 将列表转换为数组
  • contains() - 如果列表包含指定的元素,则返回true

2.2.1 List

List 接口是 Collection 接口的子接口,常用的List实现类有ArrayList、Vector、LinkedList

  • List集合类中元素有序(即添加顺序和取出顺序一致)、且可重复
  • List集合中的每个元素都有其对应的顺序索引,即支持索引
  • List集合可以添加任意元素,包括null,并且可以添加多个

1.ArrayList

(一)ArrayList的底层实现

ArrayList底层维护了一个Object类型的数组,所以ArrayList里面可以存放任意类型的元素transient表示该属性不会被序列化

size变量用来保存当前数组中已经添加了多少元素

Java集合详解(含JDK8源码)_第2张图片

(二)ArrayList的扩容机制(面试题)

  • 当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData为1.5倍(如果是奇数的话会丢掉小数,下同)。
  • 如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容则直接扩容elementData为1.5倍。

补充:JDK6 new 无参构造的 ArrayList 对象时,直接创建了长度是10的Object[] 数组 elementData

无参构造器

//使用无参构造器创建 ArrayList 对象
ArrayList list = new ArrayList();

源码:

有参构造器

ArrayList list = new ArrayList(8);

源码:

Java集合详解(含JDK8源码)_第3张图片

添加元素 

//使用无参构造器创建 ArrayList 对象
ArrayList list = new ArrayList();
for (int i = 1; i <= 11; i++) {
    list.add(i);
}

第一次add时

源码分析:

先确定是否需要扩容,再执行赋值

确定最小需求容量minCapacity,这里返回的minCapacity值为10

最小需求容量minCapacity(此时为10) - elementData数组容量长度(此时为0)明显是大于0的,所以需要扩容

确定扩容大小,执行扩容,并且可以看到除了无参构造第一次添加元素以外,扩容都是1.5倍的

Java集合详解(含JDK8源码)_第4张图片建议自己用debug模式走一遍 

(三)ArrayList是线程不安全的

线程不安全:因为没有采用加锁机制,不提供数据访问保护,当多线程访问同一个资源时,有可能出现多个线程先后更改数据造成所得到的数据是脏数据

我们可以看它的添加元素的有关方法

ensureCapacityInternal()的作用就是判断如果将当前的新元素加到列表后面,列表的elementData数组的大小是否满足,如果size + 1的这个需求长度大于了elementData这个数组的长度,那么就要对这个数组进行扩容。

Java集合详解(含JDK8源码)_第5张图片

安全隐患(一)

在多个线程进行add操作时可能会导致elementData数组越界。

  1. 假设某一时刻集合里已经有9个元素,即size=9,集合容量此时为10
  2. 线程A开始进入add方法,这时它获取到size的值为9,调用ensureCapacityInternal方法进行容量判断。
  3. 线程B此时也进入add方法,它获取到size的值也为9,也开始调用ensureCapacityInternal方法。
  4. 线程A发现需求大小为10,而elementData的大小就为10,可以容纳。于是它不再扩容,返回。
  5. 线程B也发现需求大小为10,也可以容纳,返回。
  6. 线程A开始进行设置值操作, elementData[size++] = e 操作。此时size变为10。
  7. 线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常ArrayIndexOutOfBoundsException.

安全隐患(二)

另外第二步 elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这步操作也不是一个原子操作,它由如下两步操作构成:

  1. elementData[size] = e;
  2. size = size + 1;

在单线程执行这两条代码时没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:

  1. 列表大小为0,即size=0
  2. 线程A开始添加一个元素,值为A。此时它执行第一条操作,将A放在了elementData下标为0的位置上。
  3. 接着线程B刚好也要开始添加一个值为B的元素,且走到了第一步操作。此时线程B获取到size的值依然为0,于是它将B也放在了elementData下标为0的位置上。
  4. 线程A开始将size的值增加为1
  5. 线程B开始将size的值增加为2

这样线程AB执行完毕后,理想中情况为size为2,elementData下标0的位置为A,下标1的位置为B。而实际情况变成了size为2,elementData下标为0的位置变成了B,下标1的位置上什么都没有。并且后续除非使用set方法修改此位置的值,否则将一直为null,因为size为2,添加元素时会从下标为2的位置上开始。

注意 :JDK11 移除了 ensureCapacityInternal() 和 ensureExplicitCapacity() 方法

补充:

2.Vector

(一)Vector的底层实现

Vector的底层也是一个Object类型的数组

(二)Vector的扩容机制

  • 如果是无参,初始elementData容量为10,默认10满后,就按2倍扩容
  • 如果指定大小,则每次直接按2倍扩容

建议自己用debug模式走一遍,有很多地方其实和ArrayList相似,我在这里就只简单讲讲了

 //无参构造器
 Vector vector = new Vector();
 for (int i = 0; i <= 10; i++) {
      vector.add(i);
 }

源码分析:

第11次add时

从这里就可以看到 Vector ArrayList  的不同点,Vector的add方法加上了synchronized锁,任何时刻至多只能有一个线程访问该方法,所以Vector是线程安全

ensureCapacityHelper是为了确定是否需要扩容

Java集合详解(含JDK8源码)_第6张图片

最小需求容量minCapacity(此时为11) - elementData数组容量长度(此时为10)明显是大于0的,所以进入grow方法

Java集合详解(含JDK8源码)_第7张图片

Vector在此时扩容了一倍

Java集合详解(含JDK8源码)_第8张图片

Vector和ArrayList的区别(面试题)

Java集合详解(含JDK8源码)_第9张图片

3.LinkedList

LinkedList 同时实现了List接口和Deque接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue)。但 LinkedList 是采用链表结构的方式来实现List接口的,因此在进行insert 和remove动作时效率要比ArrayList高。LinkedList 是不同步的,也就是不保证线程安全

LinkedList的底层实现

  • LinkedList中维护了一个双向链表,两个属性 first 和 last 分别指向 首节点和尾节点
  • 每个节点 (Node对象),里面又维护了prev、next、item三个属性,其中通过prev指向前一个,通过next指向后一个节点。如下图
  • Java集合详解(含JDK8源码)_第10张图片
LinkedList linkedList = new LinkedList();
linkedList.add(1);
linkedList.add(2);
linkedList.remove(); // 这里默认删除的是第一个结点
System.out.println("linkedList=" + linkedList);

debug模式走一波,第一次add时

Java集合详解(含JDK8源码)_第11张图片

Java集合详解(含JDK8源码)_第12张图片

效果如下:

Java集合详解(含JDK8源码)_第13张图片

第二次add后,效果如下:

Java集合详解(含JDK8源码)_第14张图片

Arraylist 与 LinkedList 的区别(面试题)

  • 底层数据结构: Arraylist 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环
  • 是否支持快速随机访问:LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  • 插入和删除是否受元素位置的影响:ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n))因为需要先移动到指定位置再插入。
  • 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了RandomAccess接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)

注:我在项目中很少使用到 LinkedList (基本没有),需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好,即便是在元素增删的场景下,因为LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n)

2.2.2 Set

Set接口是 Collection 接口的子接口,常用的Set实现类有HashSet、TreeSet、LinkedHashSet。

  • Set集合无序 (添加和取出的顺序不一致),没有索引
  • Set集合不允许重复元素

1.HashSet

问题引入:Hashset 不能添加相同的元素/数据,它是以什么为判断依据的?(面试题)

我们必须要研究一下HashSet 的底层实现

@SuppressWarnings("all")
public class HashSet_ {
    public static void main(String[] args) {
        HashSet set = new HashSet();
        set.add("lucy");//会返回一个boolean值
        set.add("lucy");//加入不了
        set.add(new Dog("tom"));//true
        set.add(new Dog("tom"));//true
        System.out.println("set=" + set);
        //非常经典的面试题
        set.add(new String("111"));//true
        set.add(new String("111"));//false
        System.out.println("set=" + set);
    }
}
class Dog { //定义了 Dog 类
    private String name;
    public Dog(String name) {
        this.name = name;
    }
    @Override
    public String toString() {
        return "Dog{" +
                "name='" + name + '\'' +
                '}';
    }
}

HashSet 的底层是 HashMap(数组+链表+红黑树)

  1. 添加一个元素时,通过哈希函数得到hash值,通过((n - 1) & hash) 将hash值转成 索引值。HashMap没有简单的直接通过对 数组长度取模% 来散列它是用了位与运算,用hash值跟数组大小n减一做&。这种算法同样能达到取模那种效果而且二进制的位运算,速度快。
  2. 找到存储数据表table(数组),看这个索引位置是否已经存放了元素
  3. 如果没有存放,则直接添加
  4. 如果存放了,调用 equals() 比较,如果相同,就放弃添加,如果不相同,则添加到最后
  5. 在java8中,如果一条 链表 的元素个数 超过 TREEIFY_THRESHOLD(默认是8)[我看到网上其他人有说达到的,应该是没把第一个位于数组的元素算进来]并且 table 的大小 >= MIN_TREEIFY_CAPACITY(默认是64)就会进行树化(红黑树)。如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树

从这里已经可以看出HashMap是使用 拉链法 来解决Hash冲突。

注:JDK1.8 之前 HashMap 就是由数组+链表组成的,1.8的时候加入了红黑树转换机制。还有一个就是当我们发生Hash碰撞时1.7采用 头插法,而1.8采用 尾插法

并且根据hashcode()+equals()方法判重

HashSet先调用元素对象的hashcode方法,通过((n - 1) & hash)算出散列的索引值。 如果该位置上已经存在元素,再根据两个元素对象的equals方法判断在业务上是否相等,是否返回true,为ture则被认为是相同对象,不能重复添加,为false则可以添加。

结构大致如下图所示(未树化前)

Java集合详解(含JDK8源码)_第15张图片

debug模式开启

@SuppressWarnings("all")
public class HashSetSource {
    public static void main(String[] args) {
        HashSet hashSet = new HashSet();
        hashSet.add("java");
        hashSet.add("java");
        System.out.println("set=" + hashSet);
    }
}

无参构造器

  HashSet hashSet = new HashSet();

源码

Java集合详解(含JDK8源码)_第16张图片

从这里就可以很明显的看到HashSet的底层,当第一次add("java")时,我们来看它的add方法

PRESENT其实是一个静态对象,起到占位的作用,因为HashMap是一个Key-Value结构的, HashSet需要用它来充当所有Key的Value

我们接着看put方法

这里我们先通过强制步入的方式看一看它的Hash算法

这里的 ^ 是按位异或,>>>是算术右移 。将生成的hashcode值的高16位于低16位进行异或运算,这样得到的值再与(数组长度-1)进行相与[在后面的putVal方法里],可以得到最散列的下标值。这里得到hash值后我们返回去看一下putVal方法

Java集合详解(含JDK8源码)_第17张图片

  • table就是我们之前讲到的数组 ,tab、p、n都是一些辅助变量

这里我们进入resize()扩容方法 重点分析一下,我们在这里先只看它的上半段,因为旧数组不为空才能进入下半段很明显此时不符合这个条件,我们后面借助另一个程序再来分析下半段

Java集合详解(含JDK8源码)_第18张图片

  • final float loadFactor; 加载因子,代表了table的填充度有多少,默认是0.75。如果初始容量为16,等到满16个元素才扩容,某些桶(数组的一个元素又称作桶)里可能就有不止一个元素了。 所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
  • int threshold; 阈值,当table被填充了,也就是为table分配内存空间后, threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,如果哈希表里的元素个数size(只要加入了一个节点,size就会++,不论节点存在数组或是链表或是树中)超过了阈值就会扩容

HashMap 的加载因子是为了平衡哈希表的性能空间占用而引入的。当哈希表的元素数量达到容量乘以加载因子时,就会触发扩容操作,将哈希表的容量增加一倍,并重新计算每个元素在新哈希表中的位置。

加载因子的默认值是 0.75,这个值经过实验得出,可以在时间和空间上取得一个比较好的平衡点。设置更高的加载因子可以减少哈希表的空间占用,但会增加哈希冲突的概率,导致查找性能下降。相反,设置更低的加载因子可以提高哈希表的查找性能,但会增加空间占用。

总结:上半段主要是确定新的容量和阈值,并且进行扩容

分析完 resize() 扩容方法后我们返回去看 putVal() 方法

Java集合详解(含JDK8源码)_第19张图片

  1. 根据先前得出的key的 hash 值,通过 (n - 1) & hash 去计算该 key 应该存放到 table 表的哪个索引位置 ,并把这个位置的对象,赋给 p
  2. 判断 p 是否为 null ,如果 p 为 null, 表示该位置还没有存放元素, 就创建一个 Node  ,并放在此处

我们追过去看看newNode

Node其实是HashMap的一个静态内部类

Java集合详解(含JDK8源码)_第20张图片

我们继续往下执行看看 

Java集合详解(含JDK8源码)_第21张图片

  • transient int size; 实际存储的key-value键值对的个数(只要加入了一个节点,size就会++,不论节点存在数组或是链表或是树中)
  • transient int modCount; HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时, 如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作), 需要抛出异常ConcurrentModificationException

当它返回null时,程序回到了add方法

此时很明显表示添加成功

第二次add("java")时,我们直接看putVal方法

Java集合详解(含JDK8源码)_第22张图片

因为第一次已经添加了值为"java"的key,它们的hash值和内容都是一样的,可是当前索引位置已经存放了元素,所以前两个if都不会进,执行完  e=p 后便会进入下面的程序,然后返回value,因为value!=null,所以会添加失败

Java集合详解(含JDK8源码)_第23张图片

这里我假设又add了一个字符串"jack",并且假设它的hash值和"java"一样,但很明显它们的内容不一样,所以它会先进入下面这个判断

判断此时p是否已经为红黑树,如果是则按红黑树的方式添加节点。我们追过去看看TreeNode

Java集合详解(含JDK8源码)_第24张图片

它也是HashMap的一个静态内部类,继承自LinkedHashMap中的Entry类,关于LInkedHashMap.Entry这个类我们后面再讲。

TreeNode是一个典型的树型节点,其中,prev是链表中的节点,用于在删除元素的时候可以快速找到它的前置节点。

这里明显还没有树化,接着就会进入下面的程序段

Java集合详解(含JDK8源码)_第25张图片先前说了 HashMap是使用 拉链法 来解决Hash冲突 ,这里就是使用 for 循环比较链表每个元素是否与将要加入的key重复,如果重复则添加失败,如果不重复则添加到链表末尾。

再提醒一下,JDK1.7采用的是 头插法,JDK1.8采用的是 尾插法

把元素添加到链表后,立即判断 该链表是否已经达到 8 个结点  , 如果已经达到,就调用 treeifyBin() 对当前这个链表进行树化(转成红黑树) 

Java集合详解(含JDK8源码)_第26张图片
注意,在转成红黑树时,要进行判断

 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize();

如果上面条件成立,也就是table此时的长度<64时,会先对 table 扩容。 只有上面条件不成立时,才进行转成红黑树

分析到这里我再提一个问题:为什么建议重写equals方法需同时重写hashCode方法

我们看一个具体的例子:

public class Test {
    private static class Person{
        String name;

        public Person(String name) {
            this.name = name;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Person person = (Person) o;
            return Objects.equals(name, person.name);
        }

    }
    public static void main(String []args){
        HashMap map = new HashMap<>();
        Person person = new Person("金刚");
        //put到hashmap中去
        map.put(person,"功");
        System.out.println("结果:"+map.get(new Person("金刚")));
    }
}

实际输出结果:null。尽管key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以get操作时导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其node的hash值是否相等)

所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。

探究HashMap扩容时如何重新分配桶

debug模式开启

@SuppressWarnings("all")
public class HashSetIncrement_ {
    public static void main(String[] args) {
        /*

          HashSet 底层是 HashMap, 第一次添加时,table 数组扩容到 16,

          临界值(threshold)是 16*加载因子(loadFactor)是 0.75 = 12

          如果 table 数组使用超过了临界值 12,就会扩容到 16 * 2 = 32,

          新的临界值就是 32*0.75 = 24, 依次类推

          */
        HashSet hashSet = new HashSet();
        for(int i=1;i<=100;i++){
            hashSet.add(i);
        }

    }

}

因为初始临界值是12,我们在第十三次add的时候进去看看

Java集合详解(含JDK8源码)_第27张图片

很明显他会进入resize() 扩容方法,我们先前说了

上半段主要是确定新的容量和阈值,并且进行扩容

我们再看看它的下半段

Java集合详解(含JDK8源码)_第28张图片

总结: 扩容后,HashMap会重新计算索引,并且重新分配元素,从而减少哈希冲突,提高查找和插入操作的效率。

  • 如果这个桶中只有一个元素,把它搬移到新桶里新的位置。具体的新位置需要根据(e.hash & (newCap - 1))来计算
  • 如果这个链表不止一个元素且不是一颗树,则分化成两个链表插入到新的桶中去。具体的新位置需要根据(e.hash & oldCap)来计算,(e.hash & oldCap) == 0的元素放在低位链表中,否则放在高位链表中。高位链表在新桶中的位置正好是原来的位置加上旧容量。如果不能理解的可以先看下面的面试题。
  • 如果第一个元素是树节点,则把这颗树打散成两颗树插入到新桶中去

注:HashMap扩容是一个挺影响性能的过程,实际项目中可以通过给出合适的初始化容量来减少扩容次数

为何HashMap的数组长度一定是2的次幂(面试题)

Java集合详解(含JDK8源码)_第29张图片

  • 将key尽可能均匀地分布在数组中,并且速度更快。Hash值是很大的,我们不能直接用hash值作为下标索引值,所以用之前还要先做对数组的长度取模运算,得到的余数才能用来作为要存放的位置,也就是对应的数组下标。但是HashMap并没有用这么简单的取模算法,它是用了位与运算,用hash值跟数组大小减一做&。这种算法同样能达到取模那种效果(取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方)),而且二进制的位运算,速度更快。

Java集合详解(含JDK8源码)_第30张图片

因为hash值是不固定的,所以说key的hash值的二进制数任何位都可能是0也可能是1,那么要想保证尽量减少hash碰撞,而且充分占据每个数组的位置,因为我们的容量是2的次幂所以 (容量 - 1)就可以保证它的高位都是0,而低位都是1,所以他再与我们的hash进行与运算后一定能得到在我们容量之内的一个值,这个值也就是它存储在数组的下标。

  • 扩容迁移的时候不需要再重新计算hash值。如果数组的长度不是2的次幂,那么每次扩容时就需要重新计算每个元素的索引位置,这样会增加计算量和时间复杂度。而如果数组的长度是2的次幂,那么扩容时只需要进行位运算即可,计算效率更高。

例如我们从16扩展为32时,具体的变化如下所示:

Java集合详解(含JDK8源码)_第31张图片

我们可以再看看下图为16扩充为32resize示意图: 

Java集合详解(含JDK8源码)_第32张图片这个设计非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以 认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

所以即便创建时给定了容量初始值,HashMap 也会将其扩充为最近的 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)

HashMap里还有个比较有意思的地方

在求key的hash值时,为什么要无符号右移16位,然后做异或运算?(面试题)

因为最后参与&运算的是hashMap长度-1,而在hashMap的长度不是特别长的情况下,hashMap长度-1 的二进制高16位肯定都是0。所以大部分最后参与&运算的哈希值都只有二进制的低位参与,高位是会被hashMap长度-1的二进制高位的0屏蔽掉的,是不参与不了&运算的,所以此时就需要 把key的哈希值先右移16位再做异或运算,来把高位的一些特征也加入到低位中,就相当于让高位的一些特征也参与到&运算,这样&算出来的结果才会更散列,更均匀,这个在hashMap中叫做“扰动”

2.LinkedHashSet

LinkedHashSet继承自HashSet,它的添加、删除、查询等方法都是直接用的HashSet的,唯一的不同就是它使用LinkedHashMap存储元素(这里建议先看下面的LinkedHashMap再回来看LinkedHashSet)。

LinkedHashSet所有的构造方法都是调用HashSet的同一个构造方法

我们追过去看

Java集合详解(含JDK8源码)_第33张图片

因为构造器里把accessOrder 写死了,所以,LinkedHashSet是不支持按访问顺序对元素排序的,只能按插入顺序排序

3.TreeSet

TreeSet 是 SortedSet 接口的实现类,TreeSet 可以确保集合元素处于排序状态。 TreeSet 支持两种排序方法:自然排序和定制排序。默认情况下,TreeSet 采用自然排序。

自然排序

TreeSet 会调用集合元素的 compareTo(Object obj) 方法来比较元素之间的大小关系,然后将集合元素按升序排列。

如果 this > obj,返回正数 1

如果 this < obj,返回负数 -1

如果 this = obj,返回 0 ,则认为这两个对象相等

必须放入同样类的对象(默认会进行排序) 否则可能会发生类型转换异常.我们可以使用泛型来进行限制

定制排序

如果需要实现定制排序,则需要在创建 TreeSet 集合对象时,提供一个 Comparator 接口的实现类对象,该实现类需要重写Comparator 接口中的compare方法。由该 Comparator 对象负责集合元素的排序逻辑

下面是操作演示:

import java.util.Comparator;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.StreamSupport;

public class TreeSet_ {
    public static void main(String[] args) {
        Set set=new TreeSet();
        set.add(3);
        set.add(1);
        set.add(0);
        set.add(8);
        set.add(-6);
        System.out.println(set);
        for(Object obj:set)
        {
            System.out.print(obj+" ");
        }
        System.out.println();
        Person p1=new Person(18,"张三");
        Person p2=new Person(11,"李华");
        Person p3=new Person(1,"小明");
        Person p4=new Person(45,"李四");
        Set set1=new TreeSet(new Person());//由该 Comparator 对象负责集合元素的排序逻辑
        set1.add(p1);
        set1.add(p2);
        set1.add(p3);
        set1.add(p4);
        for(Person obj:set1)
        {
            System.out.println(obj.name+'\t'+obj.age);
        }
    }
}
class Person implements Comparator
{
    int age;
    String name;
    public Person(){

    }
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int compare(Person x,Person y)//重写了compare方法,以年龄大小升序排列
    {
        if(x.age>y.age)return 1;
        else if(x.age

2.3 Map

  • Map 用于保存具有映射关系的数据,因此 Map 集合里保存着两组值,一组值用于保存 Map 里的 Key,另外一组用于保存 Map 里的 Value。
  • Map 中的 key 和  value 都可以是任何引用类型的数据 Map 中的 Key 不允许重复,即同一个 Map 对象的任何两个 Key 通过 equals 方法比较中返回 false。
  • Key 和 Value 之间存在单向一对一关系,即通过指定的 Key 总能找到唯一的,确定的 Value。

常用操作:

  • put(K,V) - 将键K和值V的关联插入到map中。如果键已经存在,则新值将替换旧值。
  • putAll() - 将指定Map集合中的所有条目插入此Map集合中。
  • putIfAbsent(K,V) - 如果键K尚未与value关联,则插入关联V。
  • get(K) - 返回与指定键K关联的值。如果找不到该键,则返回null。
  • getOrDefault(K,defaultValue) - 返回与指定键K关联的值。如果找不到键,则返回defaultValue。
  • containsKey(K) - 检查指定的键K是否在map中。
  • containsValue(V) - 检查指定的值V是否存在于map中。
  • replace(K,V) - 将键K的值替换为新的指定值V。
  • replace(K,oldValue,newValue) - 仅当键K与值oldValue相关联时,才用新值newValue替换键K的值。
  • remove(K) - 从键K表示的Map中删除条目。
  • remove(K,V) - 从Map集合中删除键K与值V相关联的条目。
  • keySet() -返回Map集合中存在的所有键的集合。
  • values() -返回一组包含在Map集合中的所有值。
  • entrySet() -返回map中存在的所有键/值映射的集合。

1.HashMap

JDK1.8 之前 HashMap 就是由数组+链表组成的,1.8的时候加入了红黑树转换机制

HashMap的扩容机制和HashSet是一样的:

  1. HashMap底层维护了Node类型的数组table,默认为null
  2. 当创建对象时,将加载因子(loadfactor)初始化为0.75.当添加key-val时,根据key的 hash 值,通过 (n - 1) & hash 计算出索引值(实际上和对数组长度取模的效果是一样的,只不过位运算更快) 。然后判断该索引处是否有元素
  3. 如果没有元素直接添加。如果该索引处有元素,继续判断该元素的key和准备加入的key相是否等,如果相等,则直接替换val;如果不相等需要判断是树结构还是链表结构,做出相应处理。如果添加时发现容量不够,则需要扩容。
  4. 第1次添加,则需要扩容table容量为16,临界值(threshold)为12 (16*0.75)
  5. 以后再扩容,则需要扩容table容量为原来的2倍(32),临界值为原来的2倍,即24,依次类推
  6. 在Java8中,如果一条链表的元素个数超过 TREEIFY THRESHOLD(默认是 8)[我看到网上其他人有说达到的,应该是没把第一个位于数组的元素算进来],并且table的大小 >= MIN TREEIFY CAPACITY(默认64),就会进行树化(红黑树)。如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
@SuppressWarnings("all")
public class HashMap_ {
    public static void main(String[] args) {
        HashMap map = new HashMap();
        map.put("java", 10);//ok
        map.put("php", 10);//ok
        map.put("java", 20);//替换 value
        System.out.println("map=" + map);
    }
}

无参构造器 

HashMap map = new HashMap();

源码

  • 初始化加载因子 loadfactor = 0.75
  • Node[] table = null 

当执行第一次put时 

这其实就和之前HashSet分析的流程是一样的了,因为HashSet的底层就是HashMap,这里就不再赘述了。

Map实现类之间的区别

Java集合详解(含JDK8源码)_第34张图片

2.LinkedHashMap

LinkedHashMap继承HashMap,拥有HashMap的所有特性,并且额外增加了按一定顺序访问的特性。LinkedHashMap也是线程不安全的。

我们知道HashMap使用(数组 + 单链表 + 红黑树)的存储结构,LinkedHashMap的内部也有这三种结构,但是它还额外添加了一种“双向链表”的结构存储所有元素的顺序。

Java集合详解(含JDK8源码)_第35张图片

LinkedHashMap可以看成是 LinkedList + HashMap。添加删除元素的时候需要同时维护在HashMap中的存储,也要维护在LinkedList中的存储,所以性能上来说会比HashMap稍慢。

LinkedHashMap的存储结构

Java集合详解(含JDK8源码)_第36张图片

Java集合详解(含JDK8源码)_第37张图片

存储节点,继承自HashMap的Node类,next 用于单链表存储于table数组(桶)中,before 和 after 用于双向链表存储所有元素。 

按访问顺序排序的特性

Java集合详解(含JDK8源码)_第38张图片

LinkedHashMap还有一个比较重要的属性是accessOrder,默认构造器会将其赋为false,即按插入顺序存储元素,当然LinkedHashMap也留了一个构造器可以让我们指定accessOrder的值,如果传入true,LinkedHashMap就可以按访问顺序存储元素

有兴趣的看看LinkedHashMap对HashMap的3个空方法的实现以及LinkedHashMap的get方法

Java集合详解(含JDK8源码)_第39张图片

这里我就讲一下LinkedHashMap对afterNodeAccess的实现吧

Java集合详解(含JDK8源码)_第40张图片

  1. 如果accessOrder为true,并且访问的节点不是尾节点;
  2. 从双向链表中移除访问的节点;
  3. 把访问的节点加到双向链表的末尾;(末尾为最新访问的元素)

使用LinkedHashMap实现LRU缓存淘汰策略 

LRU,Least Recently Used,最近最少使用,也就是优先淘汰最近最少使用的元素。

基于LinkedHashMap可以按访问顺序排序的特性,用LinkedHashMap写一个有关LRU的小demo

public class LRUTest {
    public static void main(String[] args) {
        // 创建一个只有5个元素的缓存
        LRUlru=new LRU<>(5,0.75f);
        lru.put(1,"a");
        lru.put(2,"b");
        lru.put(3,"c");
        lru.put(4,"d");
        lru.put(5,"e");
        lru.put(6,"f");
        System.out.println(lru.get(3));
        System.out.println(lru);
    }
}
class LRU extends LinkedHashMap{
     //保存缓存的容量
    private int capacity;

    public LRU(int initialCapacity, float loadFactor) {
        super(initialCapacity, loadFactor, true);
        this.capacity = initialCapacity;
    }
    //重写removeEldestEntry()方法设置何时移除旧元素
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        // 当元素个数大于了缓存的容量, 就移除元素
        return size()>this.capacity;
    }
}

removeEldestEntry方法是设置何时移除旧元素,在LinkedHashMap里就是一直返回false,即不会移除旧元素

如果需要移除旧元素,则需要重写removeEldestEntry()方法设定移除策略

3.Hashtable

Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。

Hashtable的底层数据结构 

Hashtable的底层就是由 数组+链表 组成的,数组的类型是 Hashtable.Entry

Java集合详解(含JDK8源码)_第41张图片

Hashtable没有像HashMap那样的红黑树转换机制

Hashtable的扩容机制

  • Hashtable 的扩容机制与 HashMap 类似,都是在装载因子 (load factor) 超过阈值时进行扩容。默认的装载因子是 0.75,当哈希表中的元素数量超过容量与装载因子的乘积时,就会触发扩容操作。
  • 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11
  • 在进行扩容操作时,容量变为原来的 2n+1,并将所有的键值对重新分配到新的哈希表中

注:Hashtable 基本被淘汰,如果要保证线程安全的话建议使用 ConcurrentHashMap,效率更高

HashMap 与 Hashtable的区别(面试题)

线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。

效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。

对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException

初始容量大小和每次扩充容量大小的不同 :创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。

底层数据结构: HashMap多了一个红黑树转换机制

4.TreeMap

TreeMap 存储 Key-Value 对时,需要根据 Key 对 key-value 对进行排序。TreeMap 可以保证所有的 Key-Value 对处于有序状态。

TreeMap 的 Key 的排序:

  1. 自然排序:TreeMap 的所有的 Key 必须实现 Comparable 接口,而且所有的 Key 应该是同一个类的对象,否则将会抛出 ClasssCastException。
  2. 定制排序:创建 TreeMap 时,传入一个 Comparator 对象,该对象负责对 TreeMap 中的所有 key 进行排序。此时不需要 Map 的 Key 实现 Comparable 接口

 演示如下:


import java.util.Map;
import java.util.TreeMap;

public class TreeMap_ {
    public static void main(String[] args) {
        //默认排序
        Map map=new TreeMap();
        map.put(4,"d");
        map.put(1,"w");
        map.put(3,"q");
        map.put(0,"b");
        System.out.println(map);

        Mapmap1=new TreeMap();
        map1.put("1","a");//根据ASCII码值,数字在字母前
        map1.put("15","b");
        map1.put("13","c");
        map1.put("ab","d");
        map1.put("c","e");
        map1.put("dd","f");
        System.out.println(map1);
    }
}

你可能感兴趣的:(JavaSE基础,java,集合)