Java集合综述

本文介绍了Java集合框架中的Map接口实现类,包括HashTable、HashMap、LinkedHashMap和ConcurrentHashMap的特点和工作原理。HashMap是非线程安全的,适用于高并发场景,而ConcurrentHashMap则通过CAS和synchronized实现线程安全。LinkedHashMap通过双向链表维护插入顺序或访问顺序,适用于LRU缓存策略。此外,文章还探讨了阻塞队列的基本原理。
摘要由CSDN通过智能技术生成

Map综述

概述

  • HashTable基于哈希表,是线程安全的,其同步是通过synchronized实现的,核心操作put和get均通过synchronized修饰,所以并发效率低,现在已经不再推荐使用。
  • HashMap基于哈希表,是非线程安全的,对并发无要求的话,推荐使用这个map。
  • LinkedHashMap继承了HashMap,它改变了HashMap无序的特征,使用双向链表来会维护key-value对的次序,非线程安全。LinkedHashMap支持两种类型的顺序,一种是按照插入顺序(默认),一种是访问顺序。最近最少使用算法(LRU,Least Recently Used)就是基于LinkedHashMap访问顺序模式实现的。
  • TreeMap是有序的集合,底层是通过红黑树实现的。
  • ConcurrentHashMap基于哈希表,是线程安全的,通过CAS和synchronized实现并发的同步操作。

零星知识点

HashTable

  • 其同步是通过synchronized实现的,核心操作put和get均通过synchronized修饰,锁的粒度太粗,所以并发效率低,现在已经不再推荐使用。如果想使用并发的HashMap,使用ConcurrentHashMap。

HashMap

HashMap 1.8相对于1.7做了3点改动

  • HashMap通过“链表法”解决哈希冲突,1.7使用的是头插,而1.8则使用的是尾插。1.7头插的方式,在并发扩容的场景下,可能会引起死循环。死循环产生的场景:
    • 两个线程同时进入扩容流程,假设旧的hash数组某个链表上包含两个键值对,且扩容后依旧同属一个链表。old[] -> a -> b -> null
    • 线程A执行后:new[] -> b -> a -> null。
    • 线程B执行,处理键值对a后:new[] -> a -> b -> a 这样就形成了环,迁移和访问都会产生死循环。
  • 如果链表的长度超过8,则会将链表转成红黑树,加快查询的速度。

 

put操作核心流程

  • 1.判断hash数组是否为空,如果为空,则初始化hash数组。初始化hash数组,关键是指定几个核心参数:hash数组的大小(初始默认16),hash数组的动态扩容的阈值(0.75*16)。所以hash数组使用的是懒汉模式,使用时才进行初始化。
  • 2.hash数组里面存储的元素是Node节点,Node可以是单向链表的头结点,也可以是红黑树的根节点。前面一步之后,hash数组就肯定存在了,根据key的hash值和当前哈希数组长度,计算出hash索引。根据hash索引查找在hash数组中对应的元素,分为下面4种情况:
    • 查找得到的元素为空,说明该hash索引对应的链表为空,直接创建一个新的节点,hash索引对应的引用指向这个节点。此时链表的长度为1,且新建的节点便是这个链表的首节点,链表是单向链表,新增的节点放到链表的尾部(超过8将会调整为红黑树)。
    • 查找得到的元素不为空,且对应的key等于新加入的key(通过==和equal两种方式进行比较),说明hashMap中已经存在对应的key,记录对应节点,最后根据onlyIfAbsent参数统一处理。
      • onlyIfAbsent参数指定了,如果hashMap中已经存在对应key了,是舍弃新值,还是覆盖旧值。onlyIfAbsent意为只有不存在对应key才插入,因此为true时,将舍弃旧值。onlyIfAbsent默认为false,会覆盖旧值。
    • 如果查找得到的元素对应的key和新插入的不一致,那么将按照一定规则在链表或是红黑树中查找,是否存在对应的key值(因为数组中存储的node可能是链表的头结点,也可能是红黑树的根节点)。如果是红黑树的根节点,那么按照红黑树的规则,将新增的节点加入红黑树。(目前对红黑树还没详细看过)
    • 如果查找得到的元素对应的key和新插入的不一致,且不是红黑树的根节点,那么肯定是链表的首节点,遍历列表,如果存在对应key则记录下来,后续根据onlyIfAbsent参数处理。如果一直到链表的尾部还没发现,就创建新的节点,并加入链表的尾部。根据遍历时对链表大小的统计,若超过阈值(默认8),则将链表调整为红黑树。
  • 3.对新加入的key已经在hashMap中存在的情况,根据onlyIfAbsent参数进行处理。onlyIfAbsent指定了,如果hashMap中已经存在对应key了,是舍弃新值,还是覆盖旧值。onlyIfAbsent意为只有不存在对应key才插入,因此为true时,将舍弃旧值。onlyIfAbsent默认为false,会覆盖旧值。
  • 4.插入新的键值对后,判断hashMap中元素的个数是否超过了需要扩容的阈值(数组大小和负载因子的乘积0.75),若超过阈值就进行扩容。扩容步骤如下:
    • 调整hash数组大小和扩容阈值
    • 根据新的hash数组大小,创建新的hash数组
    • 遍历旧的hash数组,将元素迁移到新的hash数组中,根据hash数组中元素类型,分为3种情况:
      • 如果只有一个元素,则根据新的数组长度重新计算hash索引,并迁移。
      • 如果元素是红黑树的根节点,则调用红黑树进行调整,本质上跟下面链表处理的流程一致。
      • 如果元素是链表的头结点,遍历链表,重新计算键值对所在位置。根据扩容和hash索引的计算特点,链表最多被拆分成两个子链表,链表生成采用尾插法。核心在于重新计算hash索引,具体参见下面的描述。完成遍历后,将两个链表分别存储到对应的hash数组中。

 

remove操作核心流程

  • remove操作的核心流程在于查找,首先根据hash值计算索引得到对应的值,跟插入操作一样,分为4种情况讨论:
    • 为空,返回null
    • 不为空,且该节点的key就是查找得到的key,记录该节点,最后统一处理删除和返回
    • 不为空,那么就需要遍历这个hash索引对应的所有键值对,分为链表和红黑树分别讨论
  • 最后将查找得到的节点删除,并对链表或是红黑树做删除节点对应的调整

 

零星知识点

  • hashMap计算hash索引方法
    • 普通hash索引:(length - 1) & hash,length是2的指数倍
    • 扩容hash索引:newLength = 2*oldLength,根据扩容的特点,(newLength - 1) 比 (oldLength - 1)在最高位多了一位。
      因此如果 hash & oldLength = 0,则扩容后,hash对应的索引不会变更。如果hash & oldLength = 1,扩容后,hash对应的索引增加oldLength。

 

LinkedHashMap

  • LinkedHashMap继承了HashMap,它改变了HashMap无序的特征,使用双向链表来会维护key-value对的次序,非线程安全。LinkedHashMap支持两种类型的顺序,一种是按照插入顺序(默认),一种是访问顺序。最近最少使用算法(LRU,Least Recently Used)就是基于LinkedHashMap访问顺序模式实现的。accessOrder默认为false,按照插入顺序。
  • LinkedHashMap继承了HashMap,内部实现一个静态内部类Entry,继承自HashMap的静态内部类Node,增加了前指针和后指针。基于Entry维护了一个双向链表。LinkedHashMap重写了一些方法,例如创建键值对节点方法,因此LinkedHashMap hash数组存储的不再是Node,而是Entry。
  • LinkedHashMap 新增了两个成员变量,头指针head和尾指针tail,初始为null。新增(put操作)、访问(get操作)、删除(removed操作)
    • 新增:记录之前的tail指针,将tail指向新增的节点,将新增的节点加到之前tail指针的后面。特殊的LinkedHashMap加入第一个节点的时候,head和tail均为null,因此对这种情况需要判断tail做特殊处理,将head指向新增的第一个节点。

 

TreeMap

 

ConcurrentHashMap

ConcurrentHashMap如何做到线程安全和高效并发

  • JAVA7中的ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。
  • 相较于JAVA7的分段锁技术,使用Reentrant实现线程安全,JAVA8采用CAS和synchronized实现线程安全,锁的粒度更细,所以并发的效率较高。在非扩容阶段,JAVA7锁住的是一个分段,对应一段连续的hash索引;而JAVA8采用CAS无锁,或是synchronized锁住一个hash索引,也即对应一个链表或是红黑树。这里讨论的线程安全更多是在写入、删除、扩容场景下的线程安全。JAVA8 ConcurrentHashMap 在读取的时候,是没有采取同步措施的。
  • JAVA7的扩容是单线程实现的,而JAVA8的扩容是多线程并发实现的。put操作结束后,会判断当前map中元素的个数是否需要扩容,如果达到了扩容的阈值,就进入扩容流程。流程如下:
    • 遍历hash数组,如果某个hash索引已经处理完成,则创建一个节点,该节点的hash值为-1,并用该hash索引指向这个节点。后续put和删除操作通过节点hash值是否为-1判断当前map是否在进行扩容,如果在进行扩容,则一起协作进行扩容。

 

put操作核心流程

  • 因为会有cas操作、以及协作迁移操作,最外层是循环,达到插入操作重试的目的

get操作核心流程

  • ConcurrentHashMap的get操作跟HashMap基本一致,不需要同步策略。唯一区别的地方是,访问时要判断对应节点是否已经扩容完成,如果已经扩容完成(通过节点hash值为-1判断),则访问nextTable数组。其他均和HashMap保持一致。扩容过程中,不会改变原有的节点的链表或是红黑树,因此可以正常访问。

 

扩容核心流程

ConcurrentHashMap有两个hash数组,一个为table,一个为nextTable,nextTable是用于扩容的。扩容操作,从3个方面讲:

  • 1.什么场景下会进入扩容的逻辑
    • put操作结束后,发现map中键值对数量已经超过阈值,进入扩容流程
    • put操作过程中,发现节点hash值为-1,说明map在扩容中,进入扩容流程
    • remove操作过程中,发现节点hash值为-1,说明map在扩容中,进入扩容流程
  • 2.扩容时怎么将原hash数组待迁移节点的迁移任务分配给多个线程
    • 每个线程每次都分配hash数组某一区间范围内的数据迁移
    • 每个线程每次处理的hash数组长度是根据hash数组大小、可用CPU数计算得到的,公式为:(n/8/NCPU),最小为16
    • 借助transferIndex变量和CAS操作,给多个线程分配hash数组的区间范围
  • 3.单个节点的迁移流程是怎样的
    • 如果某一hash索引对应的节点已经迁移完成,则创建一个ForwardingNode节点,并将旧的table相应位置指向这个节点,用以标识该节点已经迁移完成。ForwardingNode hash值为-1,且记录了nextTable。
    • 给每个线程分配完需要处理的hash数组空间后,就开始遍历迁移这些数据。分为下面几种情况:
      • 如果节点为null,直接标记完成迁移
      • 如果节点在已经迁移完成,hash值为-1,直接跳过
      • 通过synchronized对节点加锁,根据节点是链表的头结点还是红黑树的根节点进行对应的迁移。以链表为例,迁移时,链表前面的节点都是新建的且采用头插的方式,后面的部分节点是两个hash数组中节点指向的链表所共用的,所以迁移时不会影响其他线程通过旧的hash数组访问数据。如果迁移完成,标记节点迁移完成。

 

阻塞队列原理

 

零星知识点

  • 代码里有很多类似的写法,在方法里面创建一个用final修饰的临时变量去引用对象的成员变量。

 

final ReentrantLock lock = this.lock; //这里有引用
  • 这样的写法有以下几点好处(其实如果访问的不是类对象,这些优势更容易理解一点。针对类对象,其实这些优点,并不是十分能站住脚,因为引用虽然是本地变量,在栈上,但是指向的对象依旧是同一个,在堆上。因此无法保持一致性):
    • 访问效率高(主要是本地临时变量的作用,跟final无关)
    • 先把成员或静态变量读到局部变量里保持一定程度的一致性,例如:在同一个方法里连续两次访问静态变量A.x可能会得到不一样的值,因为可能会有并发读写;但如果先有final int x = A.x然后连续两次访问局部变量x的话,那读到的值肯定会是一样的。这种做法的好处通常在有数据竞态但略微不同步没什么问题的场景下,例如说有损计数器之类的。
    • final关键字在这里的作用是不明确的,实际上这种场景下,编译后final关键字就被去掉了。唯一的作用是避免在方法内部修改创建的临时变量罢了。很多人认为这里的final只是习惯。
  • 可中断的获取锁方式
    • 争抢锁的线程在调用lock的时候会阻塞,无法通过其他方式终止操作,中断也无用;而lockInterruptibly可以通过中断操作,中断对应线程。(putLock.lockInterruptibly())
    • 线程中断的相关概念
      • Thread.interrupt()操作在线程特定的阻塞状态下,会唤醒线程,抛出异常,由线程本身处理异常。因此interrupt是唤醒线程的一种特殊方式。如果线程被 Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,调用interrupt,将会抛出异常。(对synchroized的阻塞是没有作用的)
      • 如果线程在运行中,或是其他状态,调用interrupt仅仅改变了线程的状态,并不会抛出异常。因此程序可以实时检测线程是否中断,以采取合适的处理策略。
      • join操作会阻塞线程,直至线程结束,常用来保证线程执行的顺序性。

 

阻塞队列的核心操作

  • 插入数据
    • 有2种插入:offer()和put(),offer如果队列满的话,插入失败,直接返回;put操作会阻塞等待,直到队列不满的时候,执行插入操作。

  • 删除数据
    • 有两种删除:poll()和take(),poll如果队列为空的话,直接返回null;take会一直阻塞。

  • 并发下的阻塞和唤醒操作
    • put操作如果发现队列当前容量达到设置的容量时,便等待队列不满的condition(notFull)。可想而知,notFull只有在删除操作的时候,才会从不满足变为满足,唤醒阻塞线程,此时是唤醒单个线程,也即调用signal()方法,而非signalAll()方法,因为锁只能一个线程获得,没必要通知所有的阻塞线程去争抢。关键点在于可能多个线程阻塞于notFull,而满足队列不满的时候,只唤醒了一个线程,所以所有线程在执行完插入操作后,要判断下是否满足notFull,如果满足,便唤醒一个线程。
    • take操作和put操作的流程是一致的
  • 关于解锁操作的细节
    • 获取锁之后,所有的操作都放到try块里面,finally块释放锁。
  • 插入元素和删除元素的队列操作(不用考虑任何同步和边界条件,因为调用这两个操作的上层做了相应的控制)
    • 创建阻塞队列的时候,会创建一个临时的节点,头指针和尾指针均指向这个节点
    • 插入元素只是利用尾结点将元素插到后面,并移动尾结点
    tail = tail.next = new Node();
    
    • 删除操作,是通过删除head节点,将head的下一个节点变为head节点实现的,而非直接删除head的下一个节点。也就是说每次删除操作,均会改变head节点。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值