集合

集合

集合基础知识

集合概览图

ca3ab922cb62c1b4200d1f7e510a1b0c

Collection接口

image-20210813144758666

接口说明:

所有单列集合的最顶层接口,里面定义了单列集合共性的方法,任意的单列集合都可以使用Collection接口中的方法。

Collection可以分为List和Set:
List:有序的,可以存储重复的数据,有下标索引,可以for循环遍历
Set:一般无序,LinkedHashSet是有序的,不允许重复元素,无索引,不能循环遍历

共性的方法:
add(E e):将对象添加到集合
clear:清空集合所有元素;但是不删除集合
remove(E e): 移除指定集合中对象
contains(E e):是否包含指定对象
isEmpty():当前集合是否为空
size():返回集合中元素个数
toArray():将集合元素存储到数组中

List集合

List集合分为:
ArrayList:底层结构是数组,底层查询快,增删慢。
LinkedList:底层结构是链表型的,增删快,查询慢。
Voctor:底层结构是数组,线程安全的,增删慢,查询慢。

List集合特点:
1)有序的集合,存储元素和取出元素的顺序是一致的
2)有索引(注意:防止索引越界异常)
3)允许存储重复的元素

Set集合

Set集合分为:
HashSet:Set接口的重要实现类,不包含重复元素且无序的集合类
SortedSet:不包含重复值且实现元素排序的集合
TreeSet:SortedSet重要实现类,TreeSet要存放实体类,不能直接存放,需要对象具备比较性
LinkedHashSet:底层是一个哈希表(数组+链表/红黑树+链表),多一条链条用于记录存储顺序,保证元素有序


Set集合特点:
1)不允许存储重复元素,没有索引,所以不能用简单for循环遍历
2)是无序集合,存储与取出顺序可能不一致
3)底层是一个哈希表结构(查询速度非常快)
jdk8之前:哈希表=数组+链表
jdk8之后:哈希表=数组+链表;哈希表=数组+红黑树;
根据哈希值进行分组(初始16个分组),相同哈希值串成一条链,链超过8个节点,就变成红黑树存储

Map集合

image-20210816142509971

Map集合说明:

Map可以分为HashMap和LinkedHashMap
HashMap:底层是哈希表,多线程,查询速度快,无序的集合,HashMap能存储空值与设置空key,但是不能存储相同的key,当key相同会将新的value覆盖老的值,在JDK1.8之前HashMap是数组+链表的形式,JDK1.8(包括)之后是数组+链表+红黑树。
LinkedHashMap:双链,有序的集合。

Map集合特点:
1)是一个双列集合,一个元素包含二个值
2)元素的key和value的数据类型可以相同也可不同
3)元素的key不能重复,但是value可以重复
4)元素的key和value是一一对应的

遍历HashMap方式

方式一

1)首先通过keyset方法将map集合的key取出保存在一个set集合中;
2)通过迭代器遍历这个set,获取每一个key值;
3)通过map的get方法获取每一个key保存的value值。

Map<String, Integer> map = new HashMap<>();
map.put("张三", 18);
map.put("李四", 18);
map.put("王五", 18);
map.put("赵六", 18);

Set<String> keySet = map.keySet();

//迭代器遍历
Iterator<String> it = keySet.iterator();
while (it.hasNext()) {
    //获取每一个key值
    String key = it.next();
    //获取每一个key保存的value值
    Integer value = map.get(key);
    System.out.println(key + "=" + value);
}

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

//增强for循环
for (String key : map.keySet()) {
    //获取每一个key保存的value值
    Integer value = map.get(key);
    System.out.println(key + "=" + value);
}

方式二

方式二:Entry遍历map集合
Entry:当map集合一创建,就会在集合中创建一个Entry对象,用于记录键与值(键值对对象,键与值的映射关系)
实现原理:
1)通过entrySet方法将map集合中所有的entry对象取出放入到set集合中;
2)遍历set集合,取出每一个entry对象;
3)通过entry对象的getKey方法获取key,getValue方法获取值。

Map<String, Integer> map = new HashMap<>();
map.put("张三", 18);
map.put("李四", 18);
map.put("王五", 18);
map.put("赵六", 18);

//这个set保存的是map中所有的entry对象
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();

Iterator<Map.Entry<String, Integer>> it = entrySet.iterator();
while (it.hasNext()) {
    //获取每一个entry对象
    Map.Entry<String, Integer> entry = it.next();
    //获取集合键
    String key = entry.getKey();
    //获取集合值
    Integer value = entry.getValue();
    System.out.println(key + "=" + value);
}

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

for (Map.Entry<String, Integer> entry : entrySet) {
    //获取集合键
    String key = entry.getKey();
    //获取集合值
    Integer value = entry.getValue();
    System.out.println(key + "=" + value);
}

Hash冲突(碰撞)

哈希表和哈希函数:基于数组的一种存储方式,它主要由哈希函数和数组构成。当要存储一个数据的时候,首先用一个函数计算数据的地址,然后再将数据存进指定地址位置的数组里面。这个函数就是哈希函数,而这个数组就是哈希表。哈希表的优势在于,相比于简单的数组以及链表,它能够根据元素本身在第一时间,也就是时间复杂度为o(1)内找到该元素的位置。这使得它在查询和删除、插入上会比数组和链表要快很多,因为他们的时间复杂度为o(n)。

Hash冲突/Hash碰撞:哈希函数算出来的地址被别的元素占用了,也就是,这个位置有人了。

解决哈希冲突方法:
1)开放定址法:一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

2)再哈希法:再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,直到哈希函数计算地址后无冲突。虽然不易发生聚集,但是增加了计算时间。

3)链地址法:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,如:键值对k2, v2与键值对k1, v1通过计算后的索引值都为2,这时产生冲突,但是可以通道next指针将k2, k1所在的节点连接起来,这样就解决了哈希的冲突问题。hashMap用的就是链地址法,发生冲突的时候,它会在哈希函数找到的当前数组内存地址位置下添加一条链表。

4)建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

集合与数组

1)数组的长度是固定的,集合的长度是可变的

2)数组存储的是同一数据类型数据,可以存储基本数据类型和对象;集合存储的都是对象,而且对象的数据类型可以不一致

ArrayList与LinkedList

结构区别:ArrayList和Vector使用了数组的实现,可以认为ArrayList或者Vector封装了对内部数组的操作,比如向数组中添加,删除,插入新的元素或者数据的扩展和重定向。LinkedList使用了循环双向链表数据结构。与基于数组的ArrayList相比,这是两种截然不同的实现技术,这也决定了它们将适用于完全不同的工作场景。LinkedList链表由一系列表项连接而成。一个表项总是包含3个部分:元素内容,前驱表和后驱表,如图所示:

image-20210813162209579

在下图展示了一个包含3个元素的LinkedList的各个表项间的连接关系。在JDK的实现中,无论LikedList是否为空,链表内部都有一个header表项,它既表示链表的开始,也表示链表的结尾。表项header的后驱表项便是链表中第一个元素,表项header的前驱表项便是链表中最后一个元素。

image-20210813162347988

性能区别:

1、对ArrayList和LinkedList而言,在列表末尾增加一个元素所花的开销都是固定的。对ArrayList而言,主是在内部数组中增加一项,指向所添加的元素,偶尔可能会导致对数组重新进行分配;而对LinkedList而言,这开销是统一的,分配一个内部Entry对象。 

2、在ArrayList的中间插入或删除一个元素意味着这个列表中剩余的元素都会被移动;而在LinkedList的中间插入或删除一个元素的开销是固定的。 

3、LinkedList不支持高效的随机元素访问。 

4、ArrayList的空间浪费主要体现在在list列表的结尾预留一定的容量空间,而LinkedList的空间花费则体现在每一个元素都需要消耗相当的空间。

可以这样说:当操作是在一列数据的后面添加数据而不是在前面或中间,并且需要随机地访问其中的元素时,使用ArrayList会提供比较好的性能;当你的操作是在一列数据的前面或中间添加或删除数据,并且按照顺序访问其中的元素时,就应该使用LinkedList了。

ArrayList与Voctor

1)同步性:Vector是同步的,ArrayList是异步的

2)数据增长:Vector超值时自动增长1倍,ArrayList超值时自动增长50%,如果存储大量数据,首选Vector

HashMap与HashTable

HashTable与HashMap的不同点:
1)HashMap可以存储空键和空值,但是HashTable则不行。原因就是里面equlas()方法需要对象,因为HashMap是后出的版本API经过处理才可以。
2)HashMap是不同步多线程不安全的,速度快,HashTable是同步的单线程安全的,速度慢,HashTable的实现方法里面都添加了synchronized关键字来确保线程同步,因此相对而言HashMap性能会高一些,我们平时使用时若无特殊需求建议使用HashMap,在多线程环境下若使用HashMap需要使用Collections.synchronizedMap()方法来获取一个线程安全的集合。
3)HashMap(1.8)采用了数组+链表+红黑树的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改。

IdentityHashMap

这是一个允许key内容重复的map,当k1==k2时,IdentityHashMap才认为两个key相等,而HashMap在hashCode相同,k1.equals(k2) == true时会认为两个key相等。案例:

String a = "aa";
String b = new String("aa");
//地址值不相同,false
System.out.println("a、b地址值是否相等:" + (a == b));
//同类型内容相同,返回true
System.out.println("a、b内容是否相等:" + (a.equals(b)));

//创建HashMap
HashMap<String, String> hashMap = new HashMap(16);
hashMap.put(a, "张三");
hashMap.put(b, "李四");
hashMap.put("aa", "王五");
//输出1,字符串的hashCode值相等,内容相等则不能同时保存
System.out.println("HashMap.size:" + hashMap.size());
//王五
System.out.println("HashMap:" + hashMap);

//创建IdentityHashMap
Map<String, String> identityHashMap = new IdentityHashMap<>(16);
identityHashMap.put(a, "张三");
identityHashMap.put(a, "李四");
identityHashMap.put(b, "王五");
identityHashMap.put("aa", "赵六");
//2
System.out.println("IdentityHashMap.size:" + identityHashMap.size());
//无序,输出王五、赵六,地址值一样的才会被覆盖,赵六覆盖了张三、李四
System.out.println("IdentityHashMap:" + identityHashMap);
//取值
System.out.println(identityHashMap.get("aa"));
System.out.println(identityHashMap.get(b));

List、Set、Map

结构区别
1)List和Set是存储单列数据的集合,Map是存储键和值这样的双列数据的集合
2)List中存储的数据是有顺序,并且允许重复,Map中存储的数据是没有顺序的,其键是不能重复的,它的值是可以有重复的,Set中存储的数据是无序的,且不允许有重复,但元素在集合中的位置由元素的hashcode决定,位置是固定的(Set集合根据hashcode来进行数据的存储,所以位置是固定的,但是位置不是用户可以控制的,所以对于用户来说Set中的元素还是无序的)。

实现类区别
1)List接口实现类
LinkedList:基于链表实现,链表内存是散乱的,每一个元素存储本身内存地址的同时还存储下一个元素的地址,链表增删快,查找慢。
ArrayList:基于数组实现,非线程安全的,效率高,便于索引,但不便于插入删除。
Vector:基于数组实现,线程安全的,效率低。 

2)Map接口实现类
HashMap:基于hash表的Map接口实现,非线程安全,高效,支持null值和null键。
HashTable:线程安全,低效,不支持null值和null键。
LinkedHashMap:是HashMap的一个子类,保存了记录的插入顺序。
SortMap接口:TreeMap,能够把它保存的记录根据键排序,默认是键值的升序排序。 

3)Set接口实现类
HashSet:底层是由HashMap实现,不允许集合中有重复的值,使用该方式时需要重写equals()和 hashCode()方法。
LinkedHashSet:继承于HashSet,同时又基于LinkedHashMap来进行实现,底层使用的是LinkedHashMap。 

总结
1)List集合中对象按照索引位置排序,可以有重复对象,允许按照对象在集合中的索引位置检索对象,例如通过list.get(i)方法来获取集合中的元素。
2)Map中的每一个元素包含一个键和一个值,成对出现,键对象不可以重复,值对象可以重复。
3)Set集合中的对象不按照特定的方式排序,并且没有重复对象,但它的实现类能对集合中的对象按照特定的方式排序,例如TreeSet类,可以按照默认顺序,也可以通过实现Java.util.Comparator<Type>接口来自定义排序方式。

ArrayList底层源码解析

创建集合源码

空参构造创建:当我们new一个空参ArrayList的时候,系统内部使用了一个new Object[0]数组。
带参构造创建:该构造函数传入一个int值,该值作为数组的长度值。如果该值小于0,则抛出一个运行时异常。如果等于0,则使用一个空数组,如果大于0,则创建一个长度为该值的新数组。带参构造创建二,源码如下:
/** 
* Constructs a new instance of {@code ArrayList} containing the elements of 
* the specified collection. 
* 
* @param collection 
* the collection of elements to add. 
*/ 
public ArrayList(Collection<? extends E> collection) { 
    if (collection == null) { 
        throw new NullPointerException("collection == null"); 
    } 

    Object[] a = collection.toArray(); 
    if (a.getClass() != Object[].class) { 
        Object[] newArray = new Object[a.length]; 
        System.arraycopy(a, 0, newArray, 0, a.length); 
        a = newArray; 
    } 
    array = a; 
    size = a.length; 
} 

如果调用构造函数的时候传入了一个Collection的子类,那么先判断该集合是否为null,为null则抛出空指针异常。如果不是则将该集合转换为数组a,然后将该数组赋值为成员变量array,将该数组的长度作为成员变量size。这里面它先判断a.getClass是否等于Object[].class,其实一般都是相等的,我也暂时没想明白为什么多加了这个判断,toArray方法是Collection接口定义的,因此其所有的子类都有这样的方法,list集合的toArray和Set集合的toArray返回的都是Object[]数组。 

添加元素源码

/** 
* Adds the specified object at the end of this {@code ArrayList}. 
* 
* @param object 
* the object to add. 
* @return always true 
*/ 
@Override public boolean add(E object) { 
    Object[] a = array; 
    int s = size; 
    if (s == a.length) { 
        Object[] newArray = new Object[s + 
                                       (s < (MIN_CAPACITY_INCREMENT / 2) ? 
                                        MIN_CAPACITY_INCREMENT : s >> 1)]; 
        System.arraycopy(a, 0, newArray, 0, s); 
        array = a = newArray; 
    } 
    a[s] = object; 
    size = s + 1; 
    modCount++; 
    return true; 
} 

1、首先将成员变量array赋值给局部变量a,将成员变量size赋值给局部变量s。 
2、判断集合的长度s是否等于数组的长度(如果集合的长度已经等于数组的长度了,说明数组已经满了,该重新分配新数组了),重新分配数组的时候需要计算新分配内存的空间大小,如果当前的长度小于MIN_CAPACITY_INCREMENT/2(这个常量值是12,除以2就是6,也就是如果当前集合长度小于6)则分配12个长度,如果集合长度大于6则分配当前长度s的一半长度。这里面用到了三元运算符和位运算,s >> 1,意思就是将s往右移1位,相当于s=s/2,只不过位运算是效率最高的运算。 
3、将新添加的object对象作为数组的a[s]个元素。
4、修改集合长度size为s+1。
5、modCount++,该变量是父类中声明的,用于记录集合修改的次数,记录集合修改的次数是为了防止在用迭代器迭代集合时避免并发修改异常,或者说用于判断是否出现并发修改异常的。 
6、return true,这个返回值意义不大,因此一直返回true,除非报了一个运行时异常。 

ArrayList扩容机制

1)ArrayList每次扩容是原来的1.5倍。
2)数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍(原来容量乘以1.5)。
3)代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。尽可能,就指定其容量,以避免数组扩容的发生。
4)创建方式方式不同,容量不同
List arrayList = new ArrayList();
初始数组容量为0,当真正对数组进行添加时,才真正分配初始容量10(10->15->22->33->49->74->...)
List arrayList = new ArrayList(4);
初始数组容量为4(4->6->9->13->19->...)

移除元素源码

/** 
* Removes the object at the specified location from this list. 
* 
* @param index 
* the index of the object to remove. 
* @return the removed object. 
* @throws IndexOutOfBoundsException 
* when {@code location < 0 || location >= size()} 
*/ 
@Override public E remove(int index) { 
    Object[] a = array; 
    int s = size; 
    if (index >= s) { 
        throwIndexOutOfBoundsException(index, s); 
    } 
    @SuppressWarnings("unchecked")  
    E result = (E) a[index]; 
    System.arraycopy(a, index + 1, a, index, --s - index); 
    a[s] = null;  // Prevent memory leak 
    size = s; 
    modCount++; 
    return result; 
}

1、先将成员变量array和size赋值给局部变量a和s。
2、判断形参index是否大于等于集合的长度,如果是则抛出运行时异常。
3、获取数组中脚标为index的对象result,该对象作为方法的返回值。
4、调用System的arraycopy函数,拷贝原理如下图。
5、接下来就是很重要的一个工作,因为删除了一个元素,而且集合整体向前移动了一位,因此需要将集合最后一元素设置为null,否则就可能内存泄露。
6、重新给成员变量array和size赋值。
7、记录修改次数。
8、返回删除的元素(让用户再看最后一眼) 。

拷贝原理:

image-20210813160357763

清空元素源码

如果集合长度不等于0,则将所有数组的值都设置为null,然后将成员变量size设置为0即可,最后让修改记录加1。

HashSet不允许重复原理

Set集合在调用add方法时会调用元素的hashCode和equals方法判断元素是否重复

1)首先如果集合中没有该元素,会保存一份

2)再次存储的话,会先比较哈希值是否相同,如果相同,则继续比较是否是同一个元素(equals)

3)如果是同一个元素,则不会保存

所以HashSet存储自定义类型数据时,需要重写对象hashCode和equals方法,建立自己的比较方式,才能保证在集合中的唯一性

案例:

/**
 * @author XDZY
 * @date 2019/03/14 23:01
 * @description 自定义存储类型
 * 保存在set中时,我们假如它的姓名和年龄相同即是同一个人
 */
public class Person implements Comparable {
    private String name;
    private int age;

    /**
     * 重写equals方法
     *
     * @param o 传入对象
     * @return 比较结果
     */
    @Override
    public boolean equals(Object o) {
        //地址值相同为同一个对象
        if (this == o) {
            return true;
        }
        //防止传入其他不适合的对象(list)
        //getClass() == o.getClass()等价于o instanceof Person
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        //转为person进行比较
        Person person = (Person) o;
        //如果年龄和姓名相同,则认为是同一个人
        //Objects.equals看源码得知可以防止空指针异常
        return age == person.age && Objects.equals(name, person.name);
    }

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

    @Override
    public int compareTo(Object o) {
        if (o != null) {
            Person s2 = (Person) o;
            // 自定义比较方法
            if (this.age > s2.age) {
                return 1;
            } else if (this.age < s2.age) {
                return -1;
            } else {
                return 0;
            }
        }
        return 0;
    }

    public Person() {
    }

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

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

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

如果元素(对象)的hashCode值相同,是不是就无法存入HashSet中了,当然不是,会继续使用equals进行比较。如果equals为true那么HashSet认为新加入的对象重复了,所以加入失败。如果equals为false那么HashSet认为新加入的对象没有重复,新元素可以存入。如:

//哈希冲突的2个字符串,都是1179395
System.out.println("重地".hashCode());
System.out.println("通话".hashCode());

HashMap底层源码解析

HashMap数据存储结构

JDK1.8之前:数组+链表

20190703094011414

结构说明:

HashMap底层就是一个数组结构,数组中的每一项又是一个链表。数组+链表结构,新建一个HashMap的时候,就会初始化一个数组。Entry就是数组中的元素,每个Entry其实就是一个key-value的键值对,它持有一个指向下一个元素的引用,这就构成了链表,HashMap底层将key-value当成一个整体来处理,这个整体就是一个Entry对象。HashMap底层采用一个Entry[]数组来保存所有的key-value键值对,当需要存储一个Entry对象时,会根据hash算法来决定在其数组中的位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry对象时,也会根据hash算法找到其在数组中的存储位置,在根据equals方法从该位置上的链表中取出Entry。

一般情况是通过hash(key.hashCode())%len获得,也就是元素的key的哈希值对数组长度取模得到。比如,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。HashMap里面实现了一个静态内部类Entry,其重要的属性有hash,key,value,next。Entry类里面的next属性,作用是指向下一个Entry。打个比方,第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C,这样我们发现index=0的地方其实存取了ABC三个键值对,他们通过next这个属性链接在一起,也就是说数组中存储的是最后插入的元素。

JDK1.8之后:数组+链表+红黑树

v2-894d2f03f6672c9b6a1ced07fe27e1be_720w

链表中元素太多的时候会影响查找效率,所以当链表的元素个数达到8的时候使用链表存储就转变成了使用红黑树存储,原因就是红黑树是平衡二叉树,在查找性能方面比链表要高。在Java 1.8中,如果链表的长度超过了8且数组长度最小要达到64 ,那么链表将转化为红黑树;链表长度低于6,就把红黑树转回链表。

构造方法源码

HashMap()    //构造一个默认初始容量16和默认加载因子0.75的空HashMap
HashMap(int initialCapacity)  //指定初始容量
HashMap(int initialCapacity, float loadFactor) //指定初始容量和负载因子
HashMap(Map<? extends K,? extends V> m)  //指定集合,转化为HashMap
    
HashMap提供了四个构造方法,构造方法中,依靠第三个方法来执行的,但是另外三个方法都没有进行数组的初始化操作,即使调用了构造方法此时存放HaspMap中数组元素的table表长度依旧为0。在第四个构造方法中调用了inflateTable()方法完成了table的初始化操作,并将m中的元素添加到HashMap中。

属性源码

// 初始容量1向左移动4位为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量1向左移30位
static final int MAXIMUM_CAPACITY = 1 << 30;
// 加载因子 也就是桶大小使用要是超过0.75 那么就要考虑扩容了
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表太长 要变树的阀值
static final int TREEIFY_THRESHOLD = 8;
// 树变成链表的阀值
static final int UNTREEIFY_THRESHOLD = 6;
// TREEIFY_THRESHOLD达到8也不一定树化,还要容量达到64
static final int MIN_TREEIFY_CAPACITY = 64;

HashMap中有两个重要的参数:初始容量大小和加载因子。初始容量大小是创建时给数组分配的容量大小,默认值为16,用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用rehash方法将数组容量增加到原来的两倍,专业术语叫做扩容。在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能。创建HashMap时我们可以通过合理的设置初始容量大小和加载因子来达到尽量少的扩容的目的,除非特殊情况不建议自己设置,默认16就是为了避免不断的扩容以及发生Hash碰撞。

添加元素源码

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict)
{
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 如果说桶(也就是数组,以下都用"桶"代替)为空,或者桶大小为0 则进行初始化
    // 这里要区分桶大小 和 桶内元素的大小 桶大小是指桶装东西的能力
    // 桶内元素大小 是指桶装了多少东西
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 这里是帮助元素查找元素在桶中的定位 如果定位的位置没有元素 
    // 那么直接将元素放入桶的该位置就行
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // 运行到这说明定位的位置已经有元素了
        Node<K,V> e; K k;
        // 既然有人霸占元素的位置,那么就要与该元素进行对比,看看自己的Hash值和
        // key值是不是和该位置的元素一致,如果都一致就记录下该元素,以下为e表示
        // 说明有一个和我插入元素的key一样的元素 后续可能要用新值替换旧值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果只是Hash值相等而key不等,这里就是Hash碰撞啦,要解决hash碰撞
        // hashMap采用的是链地址法 就是碰撞的元素连成一个链表 这里由于链表
        // 如果太长就会树化成红黑树,以下是判断p也就是桶里放的是不是红黑树
        else if (p instanceof TreeNode)
            // 是红黑树 我们就把节点放入红黑树 注意:这里也不是一定插入到树中,
            // 因为如果我们要插入的元素和红黑树中某个节点的key相同的话,也会考虑
            // 新值换旧值的问题
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 跳到这 说明p不是树,而是链表 binCount用来记录链表中元素的个数,
            // 那么为啥要记录链表中元素的个数呢?主要判断链表是否需要树化成红黑树
            for (int binCount = 0; ; ++binCount) {
                // e的后一个节点为空 那么直接挂上我们要插入的元素
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // TREEIFY_THRESHOLD 是树化的阈值且其值为8
                    // 这里要注意:我们要插入的节点p是还没有加到binCount中的
                    // 也就是说这里虽然binCount>=7就可以树化,其实真正的树化
                    // 条件是链表中元素个数大于等于8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 待插入的key在链表中找到了,记录下来然后退出
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 说明找到了key相同的元素
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 判断是否需要旧值换新值,默认情况下是允许更换的
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 这个方法点进去就是个空方法,主要是为了给继承HashMap的
            // LinkedHashMap服务的
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 修改次数+1
    ++modCount;
    // 看下达到扩容的阀值没
    if (++size > threshold)
        // 扩容 ,在本方法的前面需要初始化的时候也出现过
        resize();
    // 这个方法同样也是为LinkedHashMap服务的
    afterNodeInsertion(evict);
    // 没找到元素 就返回空
    return null;
}

在该方法中,添加键值对时,首先进行table是否初始化的判断,如果没有进行初始化(分配空间,Entry[]数组的长度)。然后进行key是否为null的判断,如果key==null ,放置在Entry[]的0号位置。计算在Entry[]数组的存储位置,判断该位置上是否已有元素,如果已经有元素存在,则遍历该Entry[]数组位置上的单链表。判断key是否存在,如果key已经存在,则用新的value值,替换旧的value值,并将旧的value值返回。如果key不存在于HashMap中,程序继续向下执行。将key-vlaue,生成Entry实体,添加到HashMap中的Entry[]数组中。
    
增加元素的具体操作
/*
 * hash hash值
 * key 键值
 * value value值
 * bucketIndex Entry[]数组中的存储索引
 * */ 
void addEntry(int hash, K key, V value, int bucketIndex) {
     if ((size >= threshold) && (null != table[bucketIndex])) {
         resize(2 * table.length); //扩容操作,将数据元素重新计算位置后放入newTable中,链表的顺序与之前的顺序相反
         hash = (null != key) ? hash(key) : 0;
         bucketIndex = indexFor(hash, table.length);
     }
 
    createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到Entry[]数组中,先进性扩容操作,扩充的容量为table长度的2倍。重新计算hash值,和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反。然后将新添加的Entry实体存放到当前Entry[]位置链表的头部。在1.8之前,新插入的元素都是放在了链表的头部位置,但是这种操作在高并发的环境下容易导致死锁,所以1.8之后,新插入的元素都放在了链表的尾部。
    
总结:
1)对Key求Hash值,然后再计算下标
2)如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)
3)如果碰撞了,以链表的方式链接到后面
4)如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表
5)如果节点已经存在就替换旧值
6)如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)

获取元素源码

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 桶不为空 并且桶的元素大于0 同时定位的位置元素还不为空 那就顺藤摸瓜
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 第一个元素是不是我们要找的啊?判断一下,是就返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 第一个元素不是我们要找的,而且后面还接着元素 判断一下是不是树
            if (first instanceof TreeNode)
                // 是树 按照树的获取节点方法去获取
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 到这说明是链表了 那就按照链表的方式去循环
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

在get方法中,首先计算hash值,然后调用indexFor()方法得到该key在table中的存储位置,得到该位置的单链表,遍历列表找到key和指定key内容相等的Entry,返回entry.value值。
    
总结:
1)当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置
2)找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点
3)最终找到要找的值对象
posted @ 2018-08-13 11:21  肖德子裕  阅读(236)  评论(0编辑  收藏  举报