Java集合详解(含JDK8源码)

目录

一.集合与数组的区别

1.1 数组

1.2 集合

二.Java集合

2.1 Java 集合框架体系

2.2 List

1.ArrayList

(一)ArrayList的底层实现

(二)ArrayList的扩容机制

(三)ArrayList是线程不安全的

2.Vector

(一)Vector的底层实现

(二)Vector的扩容机制

(三)Vector和ArrayList的区别

3.LinkedList

(一)LinkedList的底层实现

(二)Arraylist与LinkedList的区别

2.3 Set

1.HashSet

(一)HashSet 的底层实现

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

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

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

2.LinkedHashSet

2.4 Map

1.HashMap

2.LinkedHashMap

(一)LinkedHashMap的存储结构

(二)按访问顺序排序的特性

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

3.Hashtable

(一)Hashtable的底层数据结构 

(二)HashMap 与 Hashtable的区别


一.集合与数组的区别

1.1 数组

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

1.2 集合

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

二.Java集合

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

注意:

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

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

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

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

2.1 Java 集合框架体系

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

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

2.2 List

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

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

1.ArrayList

(一)ArrayList的底层实现

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

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

(二)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);

源码:

添加元素 

//使用无参构造器创建 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倍的

建议自己用debug模式走一遍 

(三)ArrayList是线程不安全的

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

补充:

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是为了确定是否需要扩容

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

Vector在此时扩容了一倍

(三)Vector和ArrayList的区别

3.LinkedList

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

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

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

效果如下:

第二次add后,效果如下:

(二)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.3 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则可以添加。

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

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();

源码

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

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

我们接着看put方法

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

这里的 ^ 是按位异或>>>是算术右移(即无符号右移,符号位要一起移动,并且在左边补上符号位,也就是如果符号位是1就补1,符号位是0就补0)。将生成的hashcode值的高16位于低16位进行异或运算,这样得到的值再与(数组长度-1)进行相与[在后面的putVal方法里],可以得到最散列的下标值。这里得到hash值后我们返回去看一下putVal方法。

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

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

  • 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() 方法

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

我们追过去看看newNode

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

我们继续往下执行看看 

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

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

此时很明显表示添加成功

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

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

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

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

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

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

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

先前说了 HashMap是使用 拉链法 来解决Hash冲突 ,这里就是使用 for 循环比较链表每个元素是否与将要加入的key重复

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

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


注意,在转成红黑树时,要进行判断

 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<Person,String> 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的时候进去看看

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

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

我们再看看它的下半段

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

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

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

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

目的是为了让一个数到前导1之后的bit位都置1,也就得到一个0b111...111的数。最后再加1,就得到一个2的幂的数:0b100...00。

最前的c-1,是为了防止c是一个2的幂的数,导致最终得到一个比它大(二倍)的2的幂。

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

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

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

当数组元素没有挂着链表时

上图中,桶数组大小 n = 16,hash1 与 hash2 不相等。但因为只有后4位参与求余,所以结果相等。当桶数组扩容后,n 由16变成了32,对上面的 hash 值重新进行映射:

扩容后,参与模运算的位数由4位变为了5位。由于两个 hash 第5位的值是不一样,所以两个 hash 算出的结果也不一样。

当数组元素挂着链表时

假设我们上图的桶数组进行扩容,扩容后容量 n = 16,重新映射过程如下:

依次遍历链表,并计算节点 hash & oldCap 的值。如下图所示

如果值为0,将 loHead 和 loTail 指向这个节点。如果后面还有节点 hash & oldCap 为0的话,则将节点链入 loHead 指向的链表中,并将 loTail 指向该节点。如果值为非0的话,则让 hiHead 和 hiTail 指向该节点。完成遍历后,可能会得到两条链表,此时就完成了链表分组:

最后再将这两条链接存放到相应的桶中,完成扩容。如下图:

从上图可以发现,对于链表类型节点,需先对链表进行分组,重新映射后,组内节点相对位置保持不变

所以即便创建时给定了容量初始值,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的同一个构造方法

我们追过去看

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

2.4 Map

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

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实现类之间的区别

2.LinkedHashMap

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

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

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

(一)LinkedHashMap的存储结构

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

(二)按访问顺序排序的特性

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

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

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

  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个元素的缓存
        LRU<Integer,String>lru=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<K,V> extends LinkedHashMap<K,V>{
     //保存缓存的容量
    private int capacity;

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

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

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

3.Hashtable

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

(一)Hashtable的底层数据结构 

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

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

注: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多了一个红黑树转换机制

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值