Java常用容器-简解

ArrayList

ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用ensureCapacity操作来增加 ArrayList 实例的容量。这可以减少递增式再分配的数量。

ArrayList继承于 AbstractList ,实现了 List, RandomAccess, Cloneable, java.io.Serializable 这些接口。

  • RandomAccess 是一个标志接口,表明实现这个这个接口的 List 集合是支持快速随机访问的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
  • ArrayList 实现了 Cloneable** 接口** ,即覆盖了函数clone(),能被克隆。
  • ArrayList 实现了 java.io.Serializable接口,这意味着ArrayList支持序列化,能通过序列化去传输。

ArrayList扩容机制

首先我们得出结论 基本上每次扩容,新的容量将是旧容量的1.5倍左右(源码:int newCapacity = oldCapacity + (oldCapacity >> 1))。

然后我们开始分情况分析:

1、当我们用无参构造函数去构造ArrayList时,实际上初始化赋值的是一个空数组,只有当真正对数组进行添加数据时才会进行真正的分配容量。当向数组添加第一个数据的时候,数组才会被扩容到10(默认初始容量)。每次加入的数据大于容量的时候就会执行扩容操作。

2、当我们使用指定容量构造ArrayList时,如果容量大于0则初始容量就是指定容量,如果等于0则创建空数组,如果小于零则抛出错误。

获取长度

数组直接使用属性 length

字符串使用 length()方法

集合使用size()方法

使用ensureCapacity方法

该方法可以使得在add大量数据时减少增量重新分配的次数。

使用 : 比如说需要加入N个数据 那么在add之前调用方法ensureCapacity(N)就ok了。

HashMap

HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。

HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。

JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。

需要注意的是,由于hashMap的存储是利用的数组,所以下标其实就是利用key的hashCode经扰动处理后得到的hash值再通过(n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度)。而这里就很好的解释了为什么 HashMap 总是使用 2 的幂作为哈希表的大小,所以只有n减1后n的二进制前n-1为都是1才能使得(n - 1) &hash的前n-1位作为数组的下标。

底层数据结构分析

JDK1.8之前

在JDK1.8之前是使用的链表+数组的结构也就是链表散列

该结构是利用拉链法解决hash碰撞问题,也就是说当得到的数组下标相同时,但是key不同,则在对应的数组单元中的对链表进行头插法将数据插入。如果key相同直接覆盖。

JDK1.8之后

当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。

HashMap扩容机制

在介绍扩容机制前 我们来了解一下一些参数。hashMap的初始容量是16,填充因子官方给的是0.75,loadFactor 加载因子一般默认为填充因子数值0.75,不过可以进行更改。最大容量 2的30次方,当容量达到这个数之后不再扩容。

loadFactor 加载因子

loadFactor 加载因子是控制数组存放数据的疏密程度,loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏

那什么时候进行扩容呢? 其实当我们加入的数据量大于 容量*loadFactor时,hashmap就会进行扩容,将容量扩充到原来的两倍

Put方法

这是HashMap提供给用户的用于添加数据的方法,但是实际上是调用了PutVal方法(该方法不提供给用户使用)。

对 putVal 方法添加元素的分析如下:

  1. 如果定位到的数组位置没有元素 就直接插入。
  2. 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。

当然在JDK1.8之前由于数据结构的不同,PUT方法也是有所不同的。

我们再来对比一下 JDK1.7 put 方法的代码

对于 put 方法的分析如下:

  • ① 如果定位到的数组位置没有元素 就直接插入。
  • ② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。

ConcurrentHashMap

ConcurrentHashMap1.7

ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rA4UCP9E-1641006678461)(https://secure1.wostatic.cn/static/iAkVEVppbqwSVUhSKYrNZt/image.png)]

初始化

初始化时可以是无参和有参构造,但是无参其实是通过调用有参提供默认参数进行初始化的,这里我们需要知道的默认初始量 有 容量:16 , 负载因子 :0.75 ,并发级别 :16 。

基本过程:

  • 必要参数校验。
  • 校验并发级别 concurrencyLevel 大小,如果大于最大值,重置为最大值。无参构造默认值是 16.
  • 寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16
  • 记录 segmentShift 偏移量,这个值在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28.
  • 记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15.
  • 初始化 segments[0]默认大小为 2负载因子 0.75扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。
Put

在介绍Put过程前,我们先来了解几个变量:

segmentShift 偏移量 :这个变量的值是由32减去N(这个N是 并发级别<=2的N次方的N, 需要注意的是这里的2的N次方是最大于或等于但是最接近并发级别的数)

segmentMask掩码 :实际上这个就是最接近并发级别并且大于并发级别的那个数-1 。

实际上了解这两个变量的目的是为了能够进行Put的第一步 也就是计算要 put 的 key 的位置,获取指定位置的 Segment。具体是让key的hash二进制右移segmentShift位,以取得高N位的数,然后再利用这高N位的数与segmentMask进行 位与运算。如果恰好这个Segment已经被初始化了那么就向该Segment进行put。

否则进行第二步,初始化该Segment

  • 初始化 Segment 流程:
    1. 检查计算得到的位置的 Segment 是否为null.
    2. 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。
    3. 再次检查计算得到的指定位置的 Segment 是否为null.
    4. 使用创建的 HashEntry 数组初始化这个 Segment.
    5. 自旋判断计算得到的指定位置的 Segment 是否为null,使用 CAS 在这个位置赋值为 Segment.

最后进行Segment.put 插入 key,value 值。

通过最后一步我们就进入到Segment了,接下来我们聊聊在Segment中发生了什么:

首先由于Segment继承了ReentrantLock,所以我们首先利用这个去获取锁,如果第一次没有成功那么将利用**scanAndLockForPut** 方法自旋继续获取。接下来就是与HashMap相似的put了,最后如果插入的位置存在值,替换后将返回旧值。否则返回null。

扩容rehash

ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。

ConcurrentHashMap1.8

它的结构是Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。这样的结构相比1.7,变化还是很大的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-glsNCF8s-1641006678463)(https://secure1.wostatic.cn/static/ekjgWJDSwaWmCJNqxhSHyK/image.png)]

初始化initTable
/**
 * Initializes table, using the size recorded in sizeCtl.
 */
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。
        if ((sc = sizeCtl) < 0)
            // 让出 CPU 使用权
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

从源码中可以发现 ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。

  1. -1 说明正在初始化
  2. -N 说明有N-1个线程正在进行扩容
  3. 表示 table 初始化大小,如果 table 没有初始化
  4. 表示 table 容量,如果 table 已经初始化。

CAS,compare and swap的缩写,中文翻译成比较并交换。我们都知道,在java语言之前,并发就已经广泛存在并在服务器领域得到了大量的应用。所以硬件厂商老早就在芯片中加入了大量直至并发操作的原语,从而在硬件层面提升效率。在intel的CPU中,使用cmpxchg指令。在Java发展初期,java语言是不能够利用硬件提供的这些便利来提升系统的性能的。而随着java不断的发展,Java本地方法(JNI)的出现,使得java程序越过JVM直接调用本地方法提供了一种便捷的方式,因而java在并发的手段上也多了起来。而在Doug Lea提供的cucurenct包中,CAS理论是它实现整个java包的基石。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前 值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。

Put
  • 根据 key 计算出 hashcode 。
  • 判断是否需要进行初始化。
  • 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
  • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
  • 如果都不满足,则利用 synchronized 锁写入数据。
  • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

总结

Java7 中 ConcurrentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。

Java8 中的 ConcurrentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。

在java8中如果CAS失败再利用synchronized 也只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值