关于集合

本文深入解析Java集合框架,包括List、Set、Queue、Map的主要实现类及其特性,如ArrayList、LinkedList、HashSet、TreeSet、HashMap、ConcurrentHashMap等。探讨各集合的底层数据结构、性能特征及适用场景。
摘要由CSDN通过智能技术生成

Collection集合

Collection主要有三个子接口,分别为List(列表)、Set(集)、Queue(队列)。其中,List、Queue中的元素有序可重复,而Set中的元素无序不可重复;

List集合

为顺序可重复存储方式

  • ArrayList :为线程不安全集合,底层采用数组的形式,即Object[];允许存储null。因为采用数组,所以查询,修改的速度快。但不适合进行频繁的增加或删除。

在此需要注意 ArrayList集合不能使用foreach增删改

  • LinkedList: 底层采用双向链表的方式,为线程不安全。为顺序可重复存储方式。允许存储null。链表特性是添加删除速度快。

需要注意:remove()
remove(int index)是针对于角标来进行删除,不需要去遍历整个集合,效率更高;
而remove(Object o)是针对于对象来进行删除,需要遍历整个集合进行equals()方法比对,所以效率较低;

Set集合(均为线程不安全)

Set集合不允许包含相同的元素,如果试图把两个相同元素加入同一个Set集合中,则添加操作失败,add()方法返回false,且新元素不会被加入。

  • HashSet集合: 存储无序(是指内存上无序),底层采用哈希表的形式。在存储的时候,依据哈希值来进行存储。若哈希值相同,则通过equals方法比较两元素是否相同,若不同,则存储在响应哈希值对应的链表中。否则则不进行存储。(equals与HashCode方法要重写)
    一定要注意:删除或添加的时候,都是要先比较HashCode值是否相等,接下来比较值是否相等。

  • ListedHashSet集合: 同HashSet类似,不同的是在添加元素的时候,会同时存在一个双向链表用来记录每一个新添加元素的前一个元素和后一个元素。所以在遍历的时候速度更快,同时输出的顺序同添加的顺序相同。(但是在内存中的顺序依旧时无序的)

  • TreeSet集合: 底层采用红黑树的数据结构,类似于二叉排序树。所以不可添加相同的元素。同时添加的元素还会自然的进行排序。所以不可添加不同类型的元素。若想要添加对象类型的数据,需要指定所依据的排序字段。即需要实现Comparable接口:如下,根据name从小到大排序。
    注意: 相对于前两个set集合,判断两对象是否相同的条件不再是equals,而是compareTo

	@Override
    public int compareTo(Object o) {
        if (o instanceof user){
            User user = (user)o;
            return this.name.compareTo(user.name);
        }else {
            throw new RuntimeException("输入类型不匹配");
        }
    }

特点:查询比List集合快,有序。

Map集合

HashMap集合(线程不安全)

底层为哈希表/红黑树,线程不安全,键值可以为null。

哈希表: 相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
结构图为(存储过程)
在这里插入图片描述
采用这种结构可以直接根据散列值来获取元素在数组中的下标,从而快速定位到数组中。若散列值对应的数组中的位置存在单链表,则遍历单链表。

哈希冲突

通过哈希函数得出的实际存储地址相同便是哈希冲突。好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。

哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,

HashMap实现原理

一: HashMap的主干是一个Node数组(1.7中为Entry数组)。Node是HashMap的基本组成单元,每 一个Node包含一个key-value键值对。

HashMap整体结构图
在这里插入图片描述
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

二: 根据HashMap的构造函数可以看出,在创建HashMap对象的时候并未创建数组,而是在执行put方法的时候创建数组
查看put方法的源码

public V put(K key, V value) {
        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold); //inflateTable这个方法用于为主干数组table在内存中分配存储空间
        }
       //如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
        int i = indexFor(hash, table.length);//获取在table中的实际位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
        addEntry(hash, key, value, i);//新增一个entry
        return null;
    }

inflateTable这个方法用于为主干数组table在内存中分配存储空间,
通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂
比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

注意:如果key为null,存储位置为table[0]或table[0]的冲突链上

三:Hash函数
这是一个神奇的函数,用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

/**
     * 返回数组下标
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

四:重写equals方法需同时重写hashCode方法

/**
 * Created by chengxiao on 2016/11/15.
 */
public class MyTest {
    private static class Person{
        int idCard;
        String name;

        public Person(int idCard, String name) {
            this.idCard = idCard;
            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;
            //两个对象是否等值,通过idCard来确定
            return this.idCard == person.idCard;
        }

    }
    public static void main(String []args){
        HashMap<Person,String> map = new HashMap<Person, String>();
        Person person = new Person(1234,"乔峰");
        //put到hashmap中去
        map.put(person,"天龙八部");
        //get取出,从逻辑上讲应该能输出“天龙八部”
        System.out.println("结果:"+map.get(new Person(1234,"萧峰")));
    }
}

实际输出结果:
结果:null

如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)–>hash–>indexFor–>最终索引位置 ,而通过key取出value的时候 key(hashcode1)–>hash–>indexFor–>最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)

所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。

五:哈希表转换成为红黑树的两个条件
1:链表的长度大于 8 ,调用treeifyBin方法
在这里插入图片描述
2:开头有判断数组长度是否小于64,小于则进行扩容,否则转红黑树

六:HashMap在JDK7和JDK8中的区别

JDK7中的HashMap

基于链表+数组实现,底层维护一个Entry数组

Entry<K,V>[] table;

根据计算的hashCode将对应的KV键值对存储到该table中,一旦发生hashCode冲突,那么就会将该KV键值对放到对应的已有元素的后面, 此时,形成了一个链表式的存储结构

JDK8中的HashMap
基于位桶+链表/红黑树的方式实现,底层维护一个Node数组

Node<K,V>[] table;
在JDK7中HashMap,当成百上千个节点在hash时发生碰撞,存储一个链表中,那么如果要查找其中一个节点,那就不可避免的花费O(N)的查找时间,这将是多么大的性能损失,这个问题终于在JDK8中得到了解决。

JDK8中,HashMap采用的是位桶+链表/红黑树的方式,当链表的存储的数据个数大于等于8的时候,不再采用链表存储,而采用了红黑树存储结构。这是JDK7与JDK8中HashMap实现的最大区别。

其他异同
共同点

1.容量(capacity):容量为底层数组的长度,JDK7中为Entry数组,JDK8中为Node数组
a. 容量一定为2的次幂
b. 默认初始容量16(容量为低层数组的长度,JDK7中为Entry数组,JDK8中为Node数组)
c.最大容量1<<30,即2的30次方

hashmap的“最大容量“其实是Integer.MAX_VALUE

2.扩容机制:扩容时resize(2 * table.length),扩容到原数组长度的2倍。

3.key为null:若key == null,则hash(key) = 0,则将该键-值 存放到数组table 中的第1个位置,即table [0]

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

不同点
1.发生hash冲突时

JDK7:发生hash冲突时,新元素插入到链表头中,即新元素总是添加到数组中,就元素移动到链表中。
JDK8:发生hash冲突后,会优先判断该节点的数据结构式是红黑树还是链表,如果是红黑树,则在红黑树中插入数据;如果是链表,则将数据插入到链表的尾部并判断链表长度是否大于8,如果大于8要转成红黑树。

2.扩容时
JDK7:在扩容resize()过程中,采用单链表的头插入方式,在将旧数组上的数据 转移到 新数组上时,转移操作 = 按旧链表的正序遍历链表、在新链表的头部依次插入,即在转移数据、扩容后,容易出现链表逆序的情况 。
多线程下resize()容易出现死循环。此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态 。

**JDK8:**由于 JDK 1.8 转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况 ,但jdk1.8仍是线程不安全的,因为没有加同步锁保护。

建议:
1.使用时设置初始值,避免多次扩容的性能消耗
2.使用自定义对象作为key时,需要重写hashCode和equals方法
3.多线程下,使用CurrentHashMap代替HashMap



为什么线程不安全

个人觉得 HashMap 在并发时可能出现的问题主要是两方面,首先如果多个线程同时使用put方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程的 put 的数据被覆盖。第二就是如果多个线程同时检测到元素个数超过数组大小* loadFactor ,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失。

《Java并发编程的艺术》一书中是这样说的:HashMap 在并发执行 put 操作时会引起死循环,导致 CPU 利用率接近100%。因为多线程会导致 HashMap 的 Node 链表形成环形数据结构,一旦形成环形数据结构,Node 的 next 节点永远不为空,就会在获取 Node 时产生死循环。但是这个问题在jdk1.8中已经解决,在jdk1.8链表节点的插入中放弃了头插法从而采用尾插法进而解决死循环。

死循环并不是发生在 put 操作时,而是发生在扩容时。

线程不安全的原因及后果
https://mp.weixin.qq.com/s/EO6H0MViOugdMfT3rvuRYA

LinkedHashMap(线程不安全)

  • 本身是HashMap的实现类。
  • 底层实现跟HashMap相同
  • 与HashMap比起来,多了一个双向链表,用来记录添加元素时前驱与后继。所以在遍历的时候,输出的顺序与添加的顺序相同。
  • 类比LinkedTreeSet

TreeMap(线程不安全)

TreeMap 是一个有序的key-value集合,它是通过红黑树实现的,每个key-value对即作为红黑树的一个节点。能够把它保存的记录根据键排序,默认是按键值的升序排序(自然顺序),也可以指定排序的比较器,不允许key值为空,非同步的。

Hashtable(线程安全但效率低下)

Hashtable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下Hashtable的效率非常低下。因为当一个线程访问Hashtable的同步方法时,其他线程访问Hashtable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。

ConcurrentHashMap(线程安全)

讲解HashMap与ConcurrentHashMap的博客
https://blog.csdn.net/weixin_44460333/article/details/86770169
敖丙:ConcurrentHashMap讲解

简述,详细的还是直接看上面的两篇博客
在1.7中ConcurrentHashMap底层是 Segment 数组、HashEntry 组成,形式是数组加链表的形式。线程安全采用的是分段锁的机制。即一段一段加锁。所以若初始容量是16,那么最多同时能够有十六个线程同时访问。
Segment 是ConcurrentHashMap的一个内部类
源代码如下

static final class Segment<K,V> extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
    transient volatile HashEntry<K,V>[] table;

    transient int count;
        // 记得快速失败(fail—fast)么?
    transient int modCount;
        // 大小
    transient int threshold;
        // 负载因子
    final float loadFactor;

}

其HashEnty是被volatile修饰的,保证的线程的可见性柯禁止指令重排列。
源代码也可以看出来segment是继承自ReentrantLock,采用分段锁原理上要比hashtable
并发更好。
插入数据的时候需要先定位到segment,在执行put操作。put的源码如下

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
          // 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
 // 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                 // 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
               //释放锁
                unlock();
            }
            return oldValue;
        }

先进行tryLock尝试获取锁,若返回true则获取成功,否则执行scanAndLockForPut进行自旋获取锁,若尝试次数超过自旋的最大值,则进入阻塞状态。

get操作就很简单了,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

由于 HashEntry中的value属性是用 volatile关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

但是1.7jdk中,ConcurrentHashMap实现是数组加链表,若是链表长度过长,则会造成性能降低。
所以在1.8中采用的hash表加上链表/红黑树的形式。锁机制也换成了CAS+synchronized 的方式。
在1.8中,将HashEnty改成了Node的方式,但作用不变,同时使用volatile修饰值和next。使之线程间可见。

1.8中put操作的流程:

  1. 通过计算得到hash值
  2. 判断是否需要初始化分配内存
  3. 如当前位置为空,则利用cas锁尝试获取锁进行写入操作,否则就行自旋获取锁
  4. 若当前位置hashcode == MOVED == -1,则需要进行扩容。
  5. 若都不满足以上两中情况,则使用synchronized 锁写入数据
  6. 若链表过长,则将其转换成红黑树

CAS锁
7. 是一种乐观锁,操作流程如下
在这里插入图片描述
在修改的时候我们进行数据的判断,如下sql代码

update a set value = newValue where value = #{oldValue}//oldValue就是我们执行前查询出来的值 

CAS锁缺点:

  1. CAS若是得不到锁,就会进入自旋操作,尝试重新获得锁。在并发请求大且冲突概率高的情况下,大量线程会反复尝试更新一个变量,自旋获得锁,会造成cpu的负载过高。
  2. 会产生ABA问题,所以我们可以采用版本号的方式实现乐观锁。
  3. 不能保证代码块的原子性
    CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值