集合笔记

1、集合框架体系

img

集合主要是两组:单列集合和双列集合

  • Collection 接口有两个重要的子接口 List 和 Set,他们的实现子类都是单列集合
  • Map 接口的实现子类是双列集合,存放的 Key-value

Java集合时间复杂度 - Alex-XYL - 博客园 (cnblogs.com)

image-20220722095530477

2、Collection

2.1 Collection 接口常用方法:

Collection接口遍历元素方式:

  • 使用 Iterator 迭代器
  • 增强for 循环

1651375718691

List list = new ArrayList();

list.add("jack");
list.add("tom");
list.add("mary");

Iterator iterator = list.iterator(); // 得到集合迭代器

// has.next() 判断是否还有下一个元素
while (iterator.hasNext()) {
    // next() 指针下移,将下移位置上的元素返回
    System.out.println(iterator.next());
}

注意:在调用 iterator.next() 方法之前必须要调用 iterator.hasNext() 进行检测。若不调用,且下一条记录无效,直接调用 iterator.next() 会抛出 NoSuchElementException 异常。

1651375999294

List list = new ArrayList();

list.add("jack");
list.add("tom");
list.add("mary");

// 使用增强for循环
for (Object o : list) {
    System.out.println(o);
}

说明:

  1. 增强for循环,不仅可以用在Collection集合中,也可以用在数组中
  2. 增强for,底层仍然是迭代器,可以认为是简化版的迭代器

2.2 List 接口常用方法

1651376275240

1651376415661

List 的三种遍历方式:

  • Iterator 迭代器

  • 增强for循环

  • 普通for循环

2.2.1 ArrayList

说明:

  • 运行添加空值,并且可以添加多个,即arrayList.add(null)

  • ArrayList是由数组来实现数据存储的

  • ArrayList基本等同于Vector,除了ArrayList线程不安全(但是执行效率高),多线程下情况下,不建议使用ArrayList

    // ArrayList的add方法没有加synchronized关键字修饰,线程不安全
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
    

底层源码分析

  • ArrayList中维护了一个Object类型的数组elementData

    transient Object[] elementData;
    // transient 修饰的属性不会被序列化
    
  • 当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData为1.5倍

    newCapacity = oldCapacity + (oldCapacity >> 1);
    
  • 如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容,则直接扩容elementData大小的1.5倍

1、使用无参构造器,创建和使用ArrayList

创建了一个空的数组,elementData={};

1651648371466

执行list.add,

(1)先确定是否要扩容

(2)然后再执行 赋值

1651648472306

该方法确定minCapacity

(1)第一次扩容为10

1651648672779

1651648737259

(1)modCount++ 记录集合被修改的次数,防止多线程操作出现异常

(2)如果elementData容量不够minCapacity,就调用grow()去扩容

1651648847981

(1)真的扩容

(2)使用扩容机制来确定要扩容到多大

1. 第一次newCapacity=10
2. 第二次及以后,按照1.5倍扩容
3. 扩容使用的是Arrays.copyof()

1651649008872

2、使用有参构造器,创建和使用ArrayList

创建了一个指定大小数组,this.elementData=new Object[capacity];

1651650454946

如果是有参构造器,扩容机制:

(1)第一次扩容,就按照elementData的1.5倍扩容

(2)整个执行的流程还是和前面无参构造一样

2.2.2 Vector

  • 底层也是一个对象数组

    protect Object[] elementData;
    
  • Vector 是线程同步的,即线程安全,Vector类的操作方法带有synchronized关键字

1651651481007

Vector 构造器

1651652360720

1651652476574

1651652510942

添加数据到vector集合

1651651800375

确定是否需要扩容,条件 minCapacity - elementData,length > 0

1651651848753

使用2倍oldCapacity 扩容 或者 指定增量扩容。

1651652037657

2.2.3 LinkedList

  • LinkedList 底层维护了一个双向链表
  • LinkedList中维护了两个属性 first 和 last分别指向首结点和尾结点
  • 每个结点(Node对象),里面又维护了prev、next、item三个属性,其中通过prev指向前一个结点,通过next指向后一个结点。
  • LinkedList的元素添加和删除不是通过数组完成的,效率较高

空构造函数初始化对象,这时 linkedList 的属性 first==null,last==null

1651655303498

执行 添加

1651655018581

将新的结点加入到双向链表的最后

1651655058983

执行 删除

1651657273322

实际执行 removeFirst() {...}

1651657292719

删除的具体代码 unlinkFirst() {....},将 f 指向的双向链表的第一个结点删除

1651657319064

ArrayList 和 LinkedList 比较

1651657904550

2.3 Set接口和常用方法

  • 无序(添加和取出的顺序不一致),没有索引

    注意:取出的顺序虽然不是添加的顺序,但是他是固定的

  • 不允许重复元素,最多包含一个null

Set接口的常用方法:和Collection接口一样

Set接口的遍历方式:

  • 迭代器
  • 增强for循环
  • 不能使用索引方式遍历

2.3.1 HashSet

HashSet实际上是HashMap

// HashSet 构造器
public HashSet() {
    map = new HashMap<>();
}
  • HashSet 底层是 HashMap
  • 添加一个元素时,先得到hash值,然后转成索引值
  • 找到存储数据表table,看这个索引位置是否已经存放有元素
  • 如果没有,直接加入
  • 如果有,调用 equals 比较,如果相同,就放弃添加,如果不相同,则添加到最后
  • 在JDK1.8中,如果一条链表的长度超过 TREEIFY_THRESHOLD(默认是 8 ),并且table的大小>=MIN_TREEIFY_CAPACITY(默认是64),就会进行树化(红黑树)。

源码分析:

public static void main(String[] args) {
    HashSet hashSet = new HashSet(); //1
    hashSet.add("java"); //2
    hashSet.add("php"); //3
    hashSet.add("java"); //4
    System.out.println("set=" + hashSet); //5
}
  1. 执行构造器 HashSet()

    1651727320402

  2. 执行 add() 方法

    1651727437465

    PRESENT 没有具体意义,主要起到占位的作用

    1651727615497

    执行 put() 方法,该方法会执行 hash(key) 得到key对应的hash值(不是hashcode)

    1651727526739

    hash() 方法

    1651728017597

  3. 执行 putVal() 方法

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i; //定义了辅助变量
        //table 就是 HashMap 的一个属性,类型是 Node[]
        //if语句表示如果当前table是null,或者大小==0
        //就是第一次扩容,扩容大小为16
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //(1)根据key,得到hash 去计算该key应该存放到table表的哪个索引位置
        //   并把这个位置的对象赋给辅助变量p
        //(2)判断p 是否为空
        //(2.1)如果p为空,表示还没有存放元素,就创建一个Node(key="java",value=PRESENT)
        //  直接放在该位置
        //
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k; // 定义辅助变量
            //如果当前索引位置对应的链表的第一个元素的准备添加的key的hash值一样
            //并且满足 下面两个条件之一:
            //(1)准备加入的key和p指向的Node结点的key是同一个对象(地址相同)
            //(2)p指向的Node结点的key的equals()和准备加入的key相同(属性相同)
            //就不能加入
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //判断 p 是不是一棵红黑树
            //如果是一棵红黑树,就调用 putTreeVal来进行添加
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//如果table对应索引位置,已经是一个链表,就是用for循环比较
                //(1)依次和该链表的每一个元素比较,若都不相同,则加入到该链表的最后
                //注意:在把元素添加到链表后,立即判断该链表是否已经超过8个结点
                //(添加第9个元素时,转成红黑树)
                //如果达到,就调用 treeifyBin() 对当前这个链表进行树化(转成红黑树)
                //在转成红黑树时,要进行判断table的长度是否到达64
                //否则,,先进行table扩容,再转成红黑树
                //(2)在比较过程中,如果有相同的,就直接break
                for (int binCount = 0; ; ++binCount) {//死循环
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //size 就是我们每加入一个结点Node,size就++
        if (++size > threshold)
            resize(); // 扩容函数
        afterNodeInsertion(evict); //对于HashMap来说,该方法为空,留给子类实现
        return null; // 返回空,表示添加成功
    }
    

HashSet扩容机制:

  • HashSet底层是HashMap,第一次添加时,table数组扩容到16,临界值(threshold)是 16*加载因子(loadFactor,0.75)=16*0.75=12

  • 如果table数组使用到了临界值12,就会扩容到 16*2=32,新的临界值就是32*0.75=24,以此类推

    注意:只要添加元素就size++,并不一定要求添加在table数组第一个位置,有可能全部添加在同一条链表中,也会扩容。

  • 在JDK1.8中,如果一条链表的元素个数到达TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_CAPACITY(默认64),就会进行树化(红黑树),否则仍然采用数组扩容机制

练习:定义一个Employee类,该类包含private成员属性name,salary,birthday(MyDate类型),要求:1.创建3个Employee放入 HashSet中;2.当name和birthday值相同时,认为员工相同,不能添加到HashSet集合中。

package set_;

import java.util.HashSet;
import java.util.Objects;

public class HashSetExercise {
    public static void main(String[] args) {
        HashSet hashSet = new HashSet();
        hashSet.add(new Employee("milan",100,new MyDate(1998,1,1)));
        hashSet.add(new Employee("smith",200,new MyDate(1998,1,1)));
        hashSet.add(new Employee("milan",100,new MyDate(1998,1,1)));

        System.out.println("hashset=" + hashSet);
    }
}

//创建Employee对象
class Employee {
    private String name;
    private int salary;
    private MyDate birthday;

    public Employee(String name, int salary, MyDate birthday) {
        this.name = name;
        this.salary = salary;
        this.birthday = birthday;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getSalary() {
        return salary;
    }

    public void setSalary(int salary) {
        this.salary = salary;
    }

    public MyDate getBirthday() {
        return birthday;
    }

    public void setBirthday(MyDate birthday) {
        this.birthday = birthday;
    }

    @Override
    public String toString() {
        return "Employee{" +
                "name='" + name + '\'' +
                ", salary=" + salary +
                ", birthday=" + birthday +
                '}';
    }

    //如果name和birthday相同,则返回相同的hash值
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return Objects.equals(name, employee.name) && Objects.equals(birthday, employee.birthday);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, birthday);
    }
}

class MyDate {
    private int year;
    private int month;
    private int day;

    public MyDate(int year, int month, int day) {
        this.year = year;
        this.month = month;
        this.day = day;
    }

    public int getYear() {
        return year;
    }

    public void setYear(int year) {
        this.year = year;
    }

    public int getMonth() {
        return month;
    }

    public void setMonth(int month) {
        this.month = month;
    }

    public int getDay() {
        return day;
    }

    public void setDay(int day) {
        this.day = day;
    }

    @Override
    public String toString() {
        return year + "/" + month + "/" + day;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MyDate myData = (MyDate) o;
        return year == myData.year && month == myData.month && day == myData.day;
    }

    @Override
    public int hashCode() {
        return Objects.hash(year, month, day);
    }
}

2.3.2 LinkedHashSet

  • LinkedHashSet 是 HashSet 的子类
  • LinkedHashSet 底层是一个 LinkedHashMap,底层维护了一个 数组 + 双向链表
  • LinkedHashSet 根据元素的hashCode 值来决定元素的存储位置,同时使用链表维护元素的次序,使得元素看起来是以插入顺序保存的
  • LinkedHashMap 不允许添加重复元素

1651817358666

说明:

  1. 在LinkedHashSet 中维护了一个hash表和双向链表(LinkedHashSet 有 head 和 tail)

  2. 每一个结点有 before 和 after 属性,这样可以形成双向链表

  3. 在添加一个元素时,先求 hash 值,再求索引,确定该元素在 table 的位置,然后将添加的元素加入到双向链表(如果已存在,不添加,原则和HashSet一样)

    tail.next = newElement;

    newElement.pre = tail;

    tail = newElement;

  4. 这样的话,我们遍历LinkedHashSet 也能保证插入顺序和遍历顺序一致

package set_;

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetSource {
    public static void main(String[] args) {
        // 分析一下 LinkedHashSet 底层机制
        Set set = new LinkedHashSet();
        set.add(new String("AA"));
        set.add(456);
        set.add(456);
        set.add(new Customer("刘",1001));
        set.add(123);
        set.add("KXY");

        System.out.println("linkedHashSet=" + set);

        // 解读
        //1. LinkedHashSet 元素加入顺序和取出顺序一致
        //2. LinkedHashSet 底层维护的是一个LinkedHashMap(是HashMap的子类)
        //3. LinkedHashSet 底层结构(数组+双向链表)
        //4. 第一次添加时,直接将 数组table 扩容到16,存放的结点类型是 LinkedHashMap$Entry
        //5. 数组是 HashMap$Node[] 存放的元素是 LinkedHashMap$Entry类型  数组多态:数组存放子类型元素
        /*  Entry 继承了 Node
            static class Entry<K,V> extends HashMap.Node<K,V> {
                Entry<K,V> before, after;
                Entry(int hash, K key, V value, Node<K,V> next) {
                    super(hash, key, value, next);
                }
            }
         */
    }
}

class Customer {
    private String name;
    private int no;

    public Customer(String name, int no) {
        this.name = name;
        this.no = no;
    }

    @Override
    public String toString() {
        return "Customer{" +
                "name='" + name + '\'' +
                ", no=" + no +
                '}';
    }
}

2.3.3 TreeSet

1651997197465

package set_;

import java.util.Comparator;
import java.util.TreeSet;

public class TreeSet_ {
    public static void main(String[] args) {
        //1. 使用TreeSet提供的一个构造器,可以传入一个比较器(匿名内部类)
        // 并指定顺序规则
        TreeSet treeSet = new TreeSet(new Comparator() {
            @Override
            public int compare(Object o1, Object o2) {
                //下面调用String的compareTo方法进行字符串大小比较
                //return ((String) o1).compareTo(((String) o2));
                //按照字符串长度大小排序
                return ((String) o1).length() - ((String) o2).length();
            }
        });
        treeSet.add("jack");
        treeSet.add("tom");
        treeSet.add("sp");
        treeSet.add("a");

        System.out.println("treeSet = " + treeSet);

        /*
            1. 构造器把传入的比较器对象,赋给了 TreeSet底层TreeMap的一个属性this.comparator
            public TreeMap(Comparator<? super K> comparator) {
                this.comparator = comparator;
            }
            2.在调用 treeSet.add("tom")时,在底层会执行到
            if (cpr != null) { //cpr 就是我们的匿名内部类(对象)
                do {
                    parent = t;
                    cmp = cpr.compare(key, t.key); //动态绑定到匿名内部类的compare()方法
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else //如果相等,即返回0,这个key加入不了
                        return t.setValue(value);
                } while (t != null);
            }


         */
    }
}

2.3.4 TreeMap

package map_;

import java.util.Comparator;
import java.util.TreeMap;
import java.util.TreeSet;

public class TreeMap_ {
    public static void main(String[] args) {
        TreeMap treeMap = new TreeMap(new Comparator() {
            @Override
            public int compare(Object o1, Object o2) {
                //下面调用String的compareTo方法进行字符串大小比较
                //return ((String) o1).compareTo(((String) o2));
                //按照字符串长度大小排序
                return ((String) o1).length() - ((String) o2).length();
            }
        });
        treeMap.put("jack","杰克");
        treeMap.put("tom","汤姆");
        treeMap.put("kristina","克瑞斯提诺");
        treeMap.put("smith","史密斯");
        System.out.println("TreeMap="+treeMap);

        /*
            1.构造器,把传入的实现了Comparable接口的匿名内部类(对象),传给TreeMap的comparator
            public TreeMap(Comparator<? super K> comparator) {
                this.comparator = comparator;
            }
            2.调用put方法
            2.1 第一次添加,把k-v封装到Entry对象,放入root
            Entry<K,V> t = root;
            if (t == null) {
                compare(key, key); // type (and possibly null) check

                root = new Entry<>(key, value, null);
                size = 1;
                modCount++;
                return null;
            }
            2.2 以后添加
            Comparator<? super K> cpr = comparator;
            if (cpr != null) {
                do { //遍历所有的key,给当前key找到适当的位置
                    parent = t;
                    cmp = cpr.compare(key, t.key); //动态绑定到匿名内部类的compare()
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else //替换value
                        return t.setValue(value);
                } while (t != null);
            }
         */
    }
}

3、Map

1651841764212

package map_;

import java.util.HashMap;
import java.util.Map;

public class Map_ {
    public static void main(String[] args) {
        //1. Map 中的 key 和 value 可以是任何引用类型的数据,会封装到HashMap$Node 对象中
        Map map = new HashMap();

        map.put("No1","111");
        map.put("No2","222");
        //2. Map 中的 key 不允许重复,原因和HashSet一样
        map.put("No1","333"); // 当有相同的key,会替换掉原有的 value
        //3. Map 中的 value 可以重复
        map.put("No4","111");
        //4. Map 的key可以为 null,value也可以为null,注意 key为null 有且只有一个,value为null可以有多个
        map.put(null,null);
        map.put(null,"444"); //替换 key为null的value
        map.put("No5",null);
        map.put("No5",null);
        //5. key 和 value 之间一一对应,通过一个key 总能找到对应的 value
        System.out.println(map.get("No4"));
    }
}

1651841582302

package map_;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapSource_ {
    public static void main(String[] args) {
        Map map = new HashMap();
        map.put("No1","111");
        map.put("No2","222");

        //1. k-v 最后是 HashMap$Node node = newNode(hash,key,value,null)
        //2. k-v 为了方便程序员的遍历,还会创建 EntrySet 集合,该集合存放的元素的类型是 Entry,
        //   而一个Entry 对象就有key 和 value,即 EntrySet<Entry<key,value>>
        //3. entrySet 中,定义的类型是 Map.Entry,但实际上存放的还是 HashMap$Node
        //   这时因为 HashMap$Node implements Map.Entry   接口多态
        //4. 当把 HashMap$Node 对象存放到 entrySet就方便我们的遍历,因为 Map.Entry 提供了重要方法
        //   getKey()   getValue()

        Set set = map.entrySet();
        System.out.println(set.getClass());
        for (Object obj : set) {
            //System.out.println(obj.getClass()); //HashMap$Node
            //为了从 HashMap$Node 取出k-v
            //1. 先做一个向下转型
            Map.Entry entry = (Map.Entry) obj;
            System.out.println(entry.getKey() + "-" + entry.getValue());
        }

        Set set1 = map.keySet();
        System.out.println(set1.getClass());
        Collection values = map.values();
        System.out.println(values.getClass());
    }
}

Map接口的常用方法:

  • put:添加
  • remove:根据键删除映射关系
  • get:根据键获取值
  • size:获取元素个数
  • isEmpty:判断个数是否为0
  • clear:清除
  • containsKey:查找键是否存在

Map接口的遍历方法:

  1. keySet:获取所有的键
  2. values:获取所有的值
  3. entrySet:获取所有的键值对
package map_;

import java.util.*;

public class MapFor {
    public static void main(String[] args) {
        Map map = new HashMap();
        map.put("No1","111");
        map.put("No2","222");
        map.put("No3","333");
        map.put("No4","444");
        map.put("No5","555");

        //第一种:先取出所有key,通过key 取出对应value
        Set keySet = map.keySet();
        //(1) 增强for
        System.out.println("------方式一------");
        for (Object key : keySet) {
            System.out.println(key + "-" + map.get(key));
        }
        //(2) 迭代器
        System.out.println("------方式二------");
        Iterator iterator = keySet.iterator();
        while (iterator.hasNext()) {
            Object key = iterator.next();
            System.out.println(key + "-" + map.get(key));
        }

        //第二种:直接取出value
        Collection values = map.values();
        //(1) 增强for
        for (Object value : values) {
            System.out.println(value);
        }
        //(2) 迭代器
        Iterator iterator1 = values.iterator();
        while (iterator1.hasNext()) {
            Object value = iterator1.next();
            System.out.println(value);
        }

        //第三种:通过EntrySet来获取
        Set entrySet = map.entrySet();
        //(1) 增强for
        for (Object entry : entrySet) {
            //将entry 转成 Map.Entry
            Map.Entry m = (Map.Entry) entry;
            System.out.println(m.getKey()+"-"+m.getValue());
        }
        //(2) 迭代器
        Iterator iterator2 = entrySet.iterator();
        while (iterator2.hasNext()) {
            Object entry = iterator2.next();
            Map.Entry m = (Map.Entry) entry;
            System.out.println(m.getKey()+"-"+m.getValue());
        }
    }
}

3.1 HashMap

1651906953371

1651907294122

源码分析:

无参构造

  1. 执行构造器 HashMap(),初始化加载因子 loadFactor = 0.75,HashMap$Node[] table=null

    1651910245528

  2. 执行 put() 会调用 hash(),计算 key 的 hash值 (h = key.hashCode()) ^ (h >>> 16)

    1651910436493

    hash()

    1651910713340

    说明:HashMap中key的存储索引计算:首先根据key的值计算出hashcode的值,然后根据hashcode计算出hash值,最后通过hash&(length-1)计算得到存储的位置。

    image-20210112191920111

  3. 执行putVal()

    简要流程如下:

    1. 首先根据 key 的值计算 hash 值,找到该元素在数组中存储的下标;
    2. 如果数组是空的,则调用 resize 进行初始化;
    3. 如果没有哈希冲突直接放在对应的数组下标里;
    4. 如果冲突了,且 key 已经存在,就覆盖掉 value;
    5. 如果冲突后,发现该节点是红黑树,就将这个节点挂在树上;
    6. 如果冲突后是链表,判断该链表是否大于 8 ,如果大于 8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value。
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i; //辅助变量
        //如果底层数组为null或者length==0,就扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //取出hash值对应table索引位置的Node,如果为null,
        //直接把加入的k-v创建成Node加入该位置即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //如果key的hash值相同,并且key是同一个对象或者key equals 为true,则不添加
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果当前table已有的Node是红黑树,就按照红黑树的方式处理
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //如果table已有的Node是一条链表,就循环比较
            else {
                for (int binCount = 0; ; ++binCount) { //死循环
                    //没有找到,就加到链表最后,break,进入++modCount
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果整个链表元素超过8个,就树化
                        //即 添加第9个元素时树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            //treeifyBin并不是立刻树化,前提条件:
                            //table大小要 > 64
                            //若不满足,会先进行扩容
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果存在hash值相同,并且key为同一个对象或者equals为true,
                    //直接break,替换值后直接返回结果
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value; //替换对应的值
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount; //每增加一个Node,就modCount++,size++
        //如果size大于临界值,就扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    
  4. 扩容:HashMap 在容量超过负载因子所定义的容量之后,就会扩容。 HashMap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。

    void resize(int newCapacity) {   //传入新的容量
        Entry[] oldTable = table;    //引用扩容前的Entry数组
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
            threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
            return;
        }
    
        Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
        transfer(newTable);                         //!!将数据转移到新的Entry数组里
        table = newTable;                           //HashMap的table属性引用新的Entry数组
        threshold = (int)(newCapacity * loadFactor);//修改阈值
    }
    

    这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

    void transfer(Entry[] newTable) {
        Entry[] src = table;                   //src引用了旧的Entry数组
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
            Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
            if (e != null) {
                src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                    e.next = newTable[i]; //标记[1]
                    newTable[i] = e;      //将元素放在数组上
                    e = next;             //访问下一个Entry链上的元素
                } while (e != null);
            }
        }
    }
    

有参构造

  1. 执行构造器

    1654325217688

    tableSizeFor(int cap) 函数作用是使 cap 变为大于等于他的2幂次

    1654325314694

    n |= n >>>1 作用:使n中为1的下一位变为1

    同理 n |= n >>>2 使n中为1的下2位变为1

    ...

    最后 n 全部变为1,即00...11111111=2^8-1

    返回 n + 1,即2^8

补充:HashMap为什么线程不安全

图片

  • 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
  • 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
  • put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。

具体分析:面试官:HashMap 为什么线程不安全? (qq.com)

3.2 HashTable

1651923862172

  1. 底层有数组 Hashtable$Entry[] 初始化大小为11
  2. 临界值 threshold 8 = 11*0.75
  3. 扩容:newCapacity = (oldCapacity << 1)+1,即:capacity*2+1

HashMap 与 Hashtable 对比

  1. 部分对比

    1651925163013

  2. HashMap的初始容量为16,Hashtable初始容量为11,两者的填充因子默认都是0.75

  3. HashMap扩容时是当前容量翻倍即:capacity*2,Hashtable扩容时是容量翻倍+1即:capacity*2+1

  4. 两者计算hash的方法不同

    • Hashtable:使用key的hashcode对table数组的长度直接进行取模

      int hash = key.hashCode();
      int index = (hash & 0x7FFFFFFF) % tab.length;
      
      
    • HashMap:对key的hashcode进行了二次hash,以获得更好的散列值,然后对table数组长度取摸

      static final int hash(Object key) {
          int h;
          //二次hash
          return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
      }
      
      public V put(K key, V value) {
          return putVal(hash(key), key, value, false, true);
      }
      
      final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                     boolean evict) {
          
          if ((tab = table) == null || (n = tab.length) == 0)
              n = (tab = resize()).length;
          //对table数组长度取摸
          if ((p = tab[i = (n - 1) & hash]) == null)
              tab[i] = newNode(hash, key, value, null);
          else {
              ...
          }
          ...
      }
      

3.3 Properties

1651925219829

1651926874198

3.4 ConcurrentHashMap

JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加低粒度的锁。

  1. put()方法执行逻辑:

    大致可以分为以下步骤:

    1. 根据 key 计算出 hash值。
    2. 判断是否需要进行初始化。
    3. 定位到 Node,拿到首节点 f,判断首节点 f:
      • 如果为 null ,则通过cas的方式尝试添加。
      • 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容。
      • 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入。
    4. 当在链表长度达到8的时候,数组扩容或者将链表转换为红黑树。
  2. get()方法:

    get 方法不需要加锁。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        //可以看到这些都用了volatile修饰
        volatile V val;
        volatile Node<K,V> next;
    }
    
  3. ConcurrentHashMap 的并发度:在JDK1.7中,并发度默认是16,这个值可以在构造函数中设置。如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。

4、Collections

1652001648824

1652002065736

HashMap面试小抄

1.存储结构

HashMap的底层数据结构是什么?

JDK1.7中,由 “数组+链表” 组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。

JDK1.8中,由 “数组+链表+红黑树” 组成,

  • 当链表超过 8 且数据总量超过 64 才会转红黑树。
  • 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,

为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?

因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。

不用红黑树,用二叉查找树可以么?

可以。但是二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。

为什么链表改为红黑树的阈值是 8?

当使用良好的哈希码时,树结构是很少使用到的,理想的情况下,在随机的哈希码下,节点在链表中出现的频率符合泊松分布,在数组调整阈值为 0.75 的时候,该泊松分布的平均参数约为 0.5,因为数组调整的阈值大小对平均参数有很大影响。如果忽略这个影响,列表长度 k 出现的次数按照泊松分布依次为:

0: 0.60653066;
1: 0.30326533;
2: 0.07581633;
3: 0.01263606;
4: 0.00157952;
5: 0.00015795;
6: 0.00001316;
7: 0.00000094;
8: 0.00000006;
更大:不足千万分之一
可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的。

默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8 ?

在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下 :

  • 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值 。
  • 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

2.索引计算

HashMap 中 key 的存储索引是怎么计算的?

首先根据key的值计算出hashcode的值,然后根据hashcode计算出hash值,最后通过hash&(length-1)计算得到存储的位置。看看源码的实现:

static final int hash(Object key) {   
     int h;
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    /* 
     h = key.hashCode() 为第一步:取hashCode值
     h ^ (h >>> 16)  为第二步:高位参与运算
    */
}

这里的 Hash 算法本质上就是三步:取key的 hashCode 值、根据 hashcode 计算出hash值、通过取模计算下标

我们来看下详细过程,以JDK1.8为例,n为table的长度。

image-20210112191920111

JDK1.8 为什么要 hashcode 异或其右移十六位的值?

这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

为什么 hash 值要与length-1相与?

  • 把 hash 值对数组长度取模运算,模运算的消耗很大,没有位运算快。
  • 当 length 总是等于 2 的n次方时,h& (length-1) 运算等价于对length取模,也就是 h%length,但是 & 比 % 具有更高的效率。

HashMap数组的长度为什么是 2 的幂次方?

这样做效果上等同于取模,在速度、效率上比直接取模要快得多。除此之外,2 的 N 次幂有助于减少碰撞的几率。如果 length 为2的幂次方,则 length-1 转化为二进制必定是11111……的形式,在与h的二进制与操作效率会非常的快,而且空间不浪费。例如:

map-幂次-1

当 length =15时,6 和 7 的结果一样,这样表示他们在 table 存储的位置是相同的,也就是产生了碰撞,6、7就会在一个位置形成链表,4和5的结果也是一样,这样就会导致查询速度降低。

3.put方法

简要流程如下:

  1. 首先根据 key 的值计算 hash 值,找到该元素在数组中存储的下标;
  2. 如果数组是空的,则调用 resize 进行初始化;
  3. 如果没有哈希冲突直接放在对应的数组下标里;
  4. 如果冲突了,且 key 已经存在,就覆盖掉 value;
  5. 如果冲突后,发现该节点是红黑树,就将这个节点挂在树上;
  6. 如果冲突后是链表,判断该链表是否大于 8 ,如果大于 8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value。

hashmap之put方法(JDK1.8)

JDK1.7 和1.8 的put方法区别是什么?

区别在两处:

  • 解决哈希冲突时,JDK1.7 只使用链表,JDK1.8 使用链表+红黑树,当满足一定条件,链表会转换为红黑树。

  • 链表插入元素时,JDK1.7 使用头插法插入元素,在多线程的环境下有可能导致环形链表的出现,扩容的时候会导致死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,但JDK1.8 的 HashMap 仍然是线程不安全的,具体原因会在另一篇文章分析。

4.扩容机制

HashMap 的扩容方式?

Hashmap 在容量超过负载因子所定义的容量之后,就会扩容。方法是将 Hashmap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。

JDK1.8做了两处优化:

  1. resize 之后,元素的位置在原来的位置,或者原来的位置 +oldCap (原来哈希表的长度)。如下图所示:

    image-20210113115127725

    元素在重新计算 hash 之后,因为 n 变为 2倍,那么 n-1 的 mask 范围在高位多 1 bit(红色),因此新的index就会发生这样的变化:

    image-20210113115401801

  2. JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法)。JDK1.8 不会倒置,使用尾插法。

posted @ 2022-11-28 17:38  柯文先生  阅读(59)  评论(0)    收藏  举报