Java 数据结构知识点

166 阅读20分钟

树也是一类特殊结构的统称, 是一种组织数据的方式, 实现方式可以是链表可以是数组.

它是由n(n>0)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:

  • (1) 每个节点都只有有限个子节点或无子节点;
  • (2) 没有父节点的节点称为根节点;
  • (3) 每一个非根节点有且只有一个父节点;
  • (4) 除了根节点外,每个子节点可以分为多个不相交的子树;
  • (5) 树里面没有环路, 即子节点不会交叉

树出现的意义

树这种数据结构, 是在数组和链表基础上一种更复杂的数据结构, 带来了复杂性的同时, 目的就是想解决数组添加和删除性能差, 以及链表不能随机访问的问题, 虽然底层依旧是数组和链表实现的, 但是通过巧妙的设计, 使得性能能够提升.

基础树的种类

(1) 二叉平衡树

当且仅当任何节点的两棵子树的高度差不大于1的二叉树

(2) 二叉查找树, 二叉搜索树, 二叉排序树

即左节点的值 <= 根节点的值 <= 右节点的值

(3) 完全二叉树

除了最后一层外, 其它层节点是满的, 最后一层的节点全部靠左侧, 中间不允许出现空叶子节点.

(4) 满二叉树

在完全二叉树的基础上, 要求最后一层的节点也是满的

高度为h的满二叉树,有(2^h)-1个结点

具有n个结点的完全二叉树的高度为log(n+1)向上取整,或者(logn)向下取整+1

第k层至多有2^(k-1)个结点

红黑树

自平衡的二叉查找树, 底层采用链表(即节点采用Node结构)实现.

如果红黑树能够维持二叉查找树的特性, 那么查找时算法时间复杂度为O(log n)。 极大提高了查找效率.

如何维持红黑树是一个自平衡的二叉查找树

这是红黑树的难点, 总的来说红黑树通过制定一些列规则, 然后在新增或者删除的时候, 通过一些列操作维持这些规则, 一旦这些规则被维持了, 那么红黑树就是平衡二叉查找树.

保持查找性

红黑树在添加或者删除的时候, 会先经历过一次查找, 找到元素应该添加到的位置, 因此元素添加到树里的时候, 一定是有序的, 具有查找性

保持平衡

平衡的目的, 是为了保证查找性能, 因为不平衡的话, 查找性能会急剧恶化.

红黑树主要通过下面两个措施来实现平衡

  • (1) recolor (重新标记黑色或红色)
  • (2) rotation (旋转,这是树达到平衡的关键)
  • 左旋: 即将树向左旋转, 相当于原本的右节点变成现在的根节点, 原本的根节点变成了现在的左节点, 原本的左节点就变成了现在左节点的左节点.
  • 右旋: 思路类似

具体的流程:

当插入节点或者删除节点后, 首先根据颜色判断是否符合要求, 如果不符合, 尝试调整颜色, 如果调整颜色可行, 就代表不需要调整别的了, 如果调整颜色无法符合要求, 那么通过旋转改变树的结构, 然后重新染色, 判断颜色是否符合, 否则继续旋转.

可以通过颜色关系判断是否需要旋转(例如连续两个右节点是红, 连续两个左节点是红, 这种关系), 以及如何旋转

堆 (就是一种特殊的树, 但是又略有不同)

堆是计算机科学中一类特殊树形数据结构的统称。

堆通常是一个可以被看做一棵由数组实现的树。堆总是满足下列性质:

  • (1) 堆中某个节点的值总是不大于或不小于其父节点的值(堆一般都是大顶堆或者小顶堆, 无序的堆无意义);
  • (2) 堆总是一棵完全二叉树(即每层的所有节点都集中在左边, 不会跳过某些节点)。

为何能用数组实现?

因为要求堆是一颗完全二叉树, 因此堆中每个节点的位置是固定的, 可以通过数组的索引映射到. 例如根节点索引为0, 根节点的left节点索引为1, 根节点的right节点索引为2, 依次类推. 就可以根据一个节点的索引, 计算出它父节点, left节点, 和right节点的索引, 得到索引就可以在数组中找到他们的值了.

    public int left(int i) {
        return (i + 1) * 2 - 1;
    }

    public int right(int i) {
        return (i + 1) * 2;
    }

    public int parent(int i) {
        // i为根结点
        if (i == 0) {
            return -1;
        }
        return (i - 1) / 2;
    }

记忆方法: 二叉树每一层的元素个数为2^0, 2^1..., 因此如果给节点编号, 那么相邻一层的号码就会差2倍.

记住这一点, 再去枚举试一下, 就可以找到具体关系了.

如何建堆

主要思路:

首先我们拿到一组数字, 我们将这些数字存到一个数组中, 然后调整数组中元素的位置, 调整完毕后, 这个数组就构成了一个堆.

大顶堆

即任何一个父节点的数字, 都大于子节点, left节点和right节点不做要求.

建立大顶堆的过程

首先已经得到一个数组, 里面的元素是无序的

  • (1) 计算出数组最后一个元素的父节点的位置, 然后比较父节点, left, right, 这三个值的大小, 将最大值移动到父节点的位置
  • (2) 再计算数组中倒数第三个(每次根据子叶节点寻找父节点的时候, 可以跳两个元素, 这样同一个子树里无需重复比较了)元素的父节点的位置, 然后重复步骤1.
  • (3) 在(1) (2)过程中, 一旦发生了元素的交换, 必须重新计算交换到子叶节点那个元素作为父节点构成的子树是否符合大顶堆的性质.

例如我从数组倒着计算, 计算到索引0和1要互换, 那么必须计算索引1, 3, 4是否符合, 如果不符合, 例如1 和 3互换, 那么必须再计算3, 7, 8是否符合, 依次类推.

这样即保证了是满二叉树, 同时也满足了大顶堆的要求.

小顶堆

即任何一个父节点的数字, 都小于子节点, left节点和right节点不做要求.

建立小顶堆的过程

建立方式和建立大顶堆一致, 只是每次比较父亲, left, right时, 父节点选择最小值.

堆添加元素

因为是用数组实现, 因此添加元素需要新建一个数组, 拷贝之前的元素在指定位置, 然后将新添加的元素放在数组最后, 然后只堆新添加的元素进行堆堆构建过程, 注意发生元素交换, 需要递归验证发生交换的子节点是否符合要求.

堆删除元素

数组删除一个元素后, 就会出现一个孔, 然后将数组最后一个元素填充到孔里

需要经过两个判断.

  • (1) 计算这个元素作为子节点是否符合要求
  • (2) 计算这个元素作为父节点是否符合要求

java 排序

自然排序

自然排序, 就是我的类天生就能够被排序, 为了完成这一目的, 需要实现java.lang.Comparable接口, 该接口只提供了一个方法: ans = obj1.compareTo(obj2) 用来完成obj和obj2的比较, 因此自己的类实现该接口后, 再实现该方法, 就为类指定了排序的依据. 具体的返回值由自己决定, 但是默认遵循以下规范:

  • ans = 0 代表两个对象相等
  • ans > 0 代表obj1比obj2大
  • ans < 0 代表obj1比obj2小

排序比较器

将类和排序分开, 也就是构造一个新的类, 来为当前类指定排序的规则, 当需要排序的时候就使用排序比较器

  • (1) 创建一个类实现java.util.Comparator接口 例如 xxxx extends Comparator
  • (2) 直接使用接口创建匿名类, 实现指定方法 new Comparator() {}

具体用来比较的方法是public int compare(Object o1, Object o2). 具体的比较逻辑在该方法内部实现,

  • 返回值 = 0 代表两个对象相等
  • 返回值 > 0 代表obj1比obj2大 在集合中, 如果返回>0的时候, 就代表会交换obj1和obj2两个对象
  • 返回值 < 0 代表obj1比obj2小

二维数组的排序方法

        Arrays.sort(nums, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                return 0;
            }
        });

基本类型和字符串排序

基本类型排序时无需实现接口, 直接是数值排序.

字符串是实现了Comparable接口, 两个字符串在排序的时候, 是分别对照两个字符串每个位置上字符的ASCII码, 一旦出现某一位不同, 就返回相应结果(或者比较至最后)

Collection

set

TreeSet

基于红黑树实现(排序需要添加的对象实现Comparable接口, 并且必须实现排序或者指定排序),支持有序性操作,例如根据一个范围查找元素的操作。

但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。

循环结果是排序的结果.

如果对象在比较时, Comparable接口返回的0, 那么TreeSet就不会添加该元素. 保证唯一性

HashSet

内部封装了一个HashMap对象, 所有的操作都是委托给HashMap实现的.

HashSet添加的元素作为HashMap中一个entry的key值, 然后value设置为null.

使用时只能通过迭代器遍历使用, 循环结果是无序的.

HashSet在add一个已有元素的时候, 会调用map直接覆盖一次旧元素, 但是只有key值, 覆不覆盖都一样.因此是通过HashMap保证元素唯一性.

LinkedHashSet

继承自HashSet, 会使HashSet在内部创建Map的时候, 创建的是LinkedHashMap.

因为使用的LinkedHashMap, 所以循环的结果是有序的

通过LinkedHashMap保证元素唯一性.

List

ArrayList

底层采用一个Object[]数组实现, 初始化时默认容量为10, 但是创建ArrayList对象的时候, 并不会为数组分配大小, 第一次添加元素时, 才创建一个大小为10的数组.

扩容

当数组中的元素已经填满数组的时候, 会触发扩容

oldCapacity + (oldCapacity >> 1),即 oldCapacity+oldCapacity/2, oldCapacity >> 1 需要取整

新容量大约是旧容量的 1.5 倍左右

扩容操作需要调用 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

创建一个容量为40的list

List<String> list = new ArrayList(40);

插入和删除

因为底层是数组, 因此插入元素和删除元素的代价都很大

线程安全的ArrayList

(1) 可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList

(2) 可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类

Vector

它的实现与 ArrayList 类似,底层使用数组, 但是使用了 synchronized 对相关方法加了锁.

扩容

初始容量为10 默认扩容是扩大两倍 在创建Vector实例的时候, 不仅支持传初始容量, 还支持传扩容的倍数

LinkedList

基于双向链表实现,使用 Node 存储链表节点信息, Node就是LinkedList自己定义的私有类. 不同类里定义的Node类结构可能不一致, 但大体目的都是为了链表定义的

插入和删除

因为是链表, 所以插入和删除性能高

Queue

队列基本定义是一个先进先出的数据结构. 实现方式多种多样, 但是都需要遵循Queue接口定义的几个基本方法, 底层实现可以根据需要自行设计

Queue接口的基础方法

添加方法

  • add() 在队列中添加一个元素(队首或者队尾) 队列满了 抛出异常
  • offer() 在队列中添加一个元素(队首或者队尾) 队列满了 返回false

删除方法

  • remove() 在队列中删除并返回一个元素(队首或者队尾) 队列为空 抛出异常
  • poll() 在队列中删除并返回一个元素(队首或者队尾) 队列为空 返回null

查询方法

  • element() 在队列查询返回一个元素(队首或者队尾) 队列为空 抛出异常
  • peek() 在队列查询返回一个元素(队首或者队尾) 队列为空 返回null

综上所述, 根据队列为满或者为空到底是抛出异常还是返回数据, 选择合适的方法 如果是你自己实现队列, 也需要遵循这些基本方法的设计原则.

非阻塞队列(java实现)

只实现java.util.Queue接口和java.util.AbstractQueue接口

(1) LinkedList

LinkedList即属于list也属于queue, 也就是一个最简单的队列. 先进先出.

(2) PriorityQueue(不再是先进先出了)

PriorityQueue是基于优先堆的一个无界队列,这个优先队列中的元素可以默认自然排序或者通过提供的Comparator(比较器)在队列实例化的时排序。 元素一定要实现排序或者指定排序, 不然添加元素就会报错

即元素在添加到队列中后, 会自行排序

采用堆作为底层实现, 因此队列每次添加或删除元素, 都可能会进行一次堆的调整.

堆会保证堆顶的元素一定是最大或者最小的, 也就符合优先队列弹出元素一定是有序的.

(3) ConcurrentLinkedQueue (线程安全的) 采用单向链表实现, 使用 Node 存储链表节点信息.

有两个指针head和tail 指向链表的头和尾

利用CAS操作保证线程安全性(循环CAS操作)

  • 添加元素时, 一直CAS操作判断是不是尾节点(不通过tail, 而是通过Node.next == null), 如果是是就入队.
  • 弹出元素时, 一直对head节点做CAS操作, 判断(Node.value是不是null), 是null的话, 就代表队列正在弹出元素(元素弹出前, head会断开next, 然后让next指向自己, 就代表正在弹出). 其它线程如果此时想弹出就不行. 直到head的next重新指向某个元素
  • 遍历元素, 不保证线程安全, 也就是一个线程遍历, 另一个线程添加, 是会遍历到新元素的.

阻塞队列(因为加锁保证线程安全了, 所以会阻塞) (java实现)

通过对入队和出队的操作加同一把锁, 或者各加一把锁来保证线程安全.

实现BlockingQueue接口, 相关的阻塞方法是put()和get()

(1) LinkedBlockingQueue

它的容量是没有上限的(说的不准确,在不指定时容量为Integer.MAX_VALUE,不然的话在put时怎么会受阻呢),但是也可以选择指定其最大容量,它是基于链表的队列

(2) ArrayBlockingQueue

它在构造时需要指定容量, 并可以选择是否需要公平性,如果公平参数被设置true,等待时间最长的线程会优先得到处理(其实就是通过将ReentrantLock设置为true来 达到这种公平性的:即等待时间最长的线程会先操作)。通常,公平性会使你在性能上付出代价,只有在的确非常需要的时候再使用它。它是基于数组的阻塞循环队列

(3) PriorityBlockingQueue (基于PriorityQueue来实现的)

是一个带优先级的队列,而不是先进先出队列。元素按优先级顺序被移除,该队列也没有上限. 是基于PriorityQueue实现的, 底层也是数组构成的堆, 只不过加锁了

(4) DelayQueue(基于PriorityQueue来实现的)

它是一个存放Delayed元素的无界阻塞队列,只有在延迟期满时才能从中提取元素。

也就是说排序的优先级是基于设置的延迟时间的, 在获取元素的时候会校验时间是否真正到期, 到期了才能获取

Stack

栈基本定义是一个先进后出的数据结构. 实现方式多种多样, 可以由数组, 链表等实现, jave实现的Stack是基于Vector实现, 因此是线程安全.

java实现的stack类

因为是继承Vector实现的, 底层是一个数组, 相关扩容的逻辑和Vector一致.

(1) push

添加元素到栈中, 通过记录的索引, 在数组当前最后位置添加元素

(2) pop

弹出元素, 通过记录的索引, 将数组最后的元素弹出.

两个队列实现栈

  • 现有两个FIFO的队列, A队列和B队列
  • 添加元素的时候就往A队列添加
  • 一旦需要弹出, 就将A队列中所有元素弹出, 依次放入B队列, B再弹出的就是最后添加的元素了.
  • 弹出结束后, B再立刻将所有元素放回A.

Map

TreeMap

TreeMap是一个能比较元素大小的Map集合,使用红黑树实现, 会对传入的key进行了大小排序。

其中,可以使用元素的自然顺序,也可以使用集合中自定义的比较器来进行排序;通常情况下, 如果指定了比较器, 都会优先采用比较器的排序逻辑.

如果传入的对象没有实现自然排序接口, 或者没有传入排序比较器, 那么treeMap在添加元素的时候就会报错. 因此传入对象可排序是使用的必然条件.(基本类型和String都天生可排序)

HashTable

和HashMap实现类似, 通过加synchronized关键字来保证线程安全, 但是性能较差, 已经不用了, 不用关注了.

LinkedHashMap

是继承自HashMap的, 在HashMap的基础上额外维护一个双向链表(即LinkedList).

LinkedHashMap每次添加和删除元素的时候, 除了正常操作HashMap以外, 还会在双向链表添加和删除元素(链表都是通过指针相连, 因此LinkedHashMap的每个Entry还需要维护一个Node节点用于链表, 这样hashmap中每个元素都通过链表链接起来).

双链链表的目的就是可以记录map元素添加的顺序, 相当于能够有序遍历了.

LinkedHashMap实现LRU算法

LRU算法: 是Least Recently Used的缩写,即淘汰最近最少使用算法. 也就是如果最近使用了, 那么就不应该被淘汰.

正常情况下LinkedHashMap在put元素的时候, 会将元素添加到双向链表的尾部, 这样双向链表维护的就是元素添加的顺序.

LinkedHashMap有个属性accessOrder, 如果设置被True, 就会实现LRU算法, 他会在你put和get元素的时候, 把元素添加到双向链表尾部(正常情况下get是不会的), 这样在双向链表头部的元素就符合最近最少使用, 是可以被淘汰的.

HashMap

实现

采用数组加链表的形式实现, 数组中索引是根据元素key值的hash值取模数组长度得到的, 数组中该索引的位置存的是链表的第一个Node.

如何计算<k, v>在数组中索引的位置

在插入<k, v>键值对的时候, 会先计算key的hash值, 然后再取余数组的长度, 得到应该存储在数组中的哪个索引位置.

新建一个 HashMap,默认大小为 16;

  • 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
  • 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。

哈希冲突 - 拉链法

即插入不同key的<k, v>时, 计算索引位置的时候, 发现存储在数组中的同一个索引,

  • 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
  • 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6

这种情况 就会在数组的这个索引构建一个链表, 来存储.

头插法: jdk1.8以前, 在put元素的时候, 采用头插法, 即将新增元素插在链表的头部, 这种方式在多线程rehash的时候, 会形成环, 造成死循环

尾插法: jdk1.8以后, 在put元素的时候, 采用为插法, 即将新增元素插在链表的尾部

链表转红黑树

jdk 1.8 之后, 如果当一个链表的节点数大于8的时候, 在成功添加第9个节点, 这个链表就会转变成红黑树.

首先链表能有8个节点 ,其实概率已经非常非常小了, 因此很少有链表能触发转红黑树, 其次数组扩容可以有效的平均元素在数组中分布, 减少链表上元素的数目, 因此当当数组容量小于64的时候, 会优先去扩容数组, 而不是转树.

如果红黑树节点数量小于6, 还会转成链表.

put操作

put操作肯定是先查找有没有已经存在的元素的, 有就覆盖值, 没有就新加一个Entry

扩容

底层一旦采用数组, 就避免不了扩容的问题 相关属性 capacity: 数组的大小 默认是16, size:记录了放入HashMap的元素个数, 即数组中存放了元素的个数 loadFactor:负载因子 threshold:扩容的阈值,决定了HashMap何时扩容,以及扩容后的大小,等于 capacity * loadFactor

  • (1) map每次put元素后, 判断当前size 是否大于 capacity * loadFactor
  • (2) 如果大于, 触发扩容, 即resize()操作, 创建一个新的空数组,capacity是原数组的2倍(2倍是为了rehash的时候, 计算方便)。
  • (3) 新数组创建完毕后, 需要将原数组的元素拷贝到新数组, 即tranfer()操作
  • (4) 拷贝元素需要遍历map中的所有元素, 然后每个元素重新计算hash值, 即rehash操作, 然后将元素采用尾插法, 插入新数组中.

扩容为啥是固定扩大两倍

Java HashMap中在resize()时候的rehash,即再哈希法的理解 主要目的是为了减少rehash时候的计算, 使得rehash能够直接得到在新数组中的索引.

因为key本身的hashcode不会变, 变的只是数组的大小, 因此只是取余的结果变了, 而数组的大小是扩大了两倍, 因此新索引无需真正的计算取余新数组的大小, 根据hashcode里的对应的那一位是0还是1, 可以判断出新索引是在是在原位置,还是是在原位置再移动2次幂(即原数组大小)的位置

例如原大小为16, 有一个元素的key计算出来hash值为18

扩容前的索引: 18 % 16 = 2;

现在扩容后, 数组从16变成了32

扩容后的索引: 18 % 32 = 18; 相当于2移动一个原数组大小的位置

多线程扩容时能够读写操作吗

单线程下, 如果在扩容, 肯定是执行不了别的操作的, 因为是单线程的.

多线程下如果真的是在扩容中, 是不保证读写的, 因为它是创建好数组后, 就将hashmap中数组的引用替换为新的了, 此时还没拷贝元素呢.(这也是hashmap根本就不准备在多线程使用, 因此根本不用考虑这些.)

读操作: 如果读取的是新table, 很有可能数据还没有拷贝完毕, 读取不到, 如果读取的是旧table, 表示还没完成新数组的替换, 读取没问题

写操作: 如果读取的是新table, 可能会造成元素覆盖的问题,

线程不安全

jdk 1.7

  • (1) 两个线程同时put一个map中没有的元素, 会造成元素的覆盖(可能期望的是相同key, 元素累加)
  • (2) 两个线程恰巧都触发了hashmap的库容操作, 因为头插法的缘由, 某个数组的链表会形成环, 导致扩容后, get()元素的时候, 造成死循环.

jdk 1.8

  • (1) 只会有元素覆盖的问题.

因此多线程如果要使用, 肯定不用hashmap, 会有各种各样的问题

ConcurrentHashMap

线程安全的hashmap

jdk 1.7的做法

ConcurrentHashMap的结构为 Segment + table + NodeList, 变成了段对象 + 数组对象 + 链表对象. 将数据分段存储.

ConcurrentHashMap管理一个Segment[]数组(该数组默认大小为16), 每个Segment对象拥有一个数组 + 链表的结构, 就是等同于一个hashMap的结构, 相当于每个Segment就是一个小hashMap.

这样ConcurrentHashMap的大小 就是每个Segment数组大小的和.

其中 Segment 继承于 ReentrantLock. 这样每一段都由Segment对象控制, 这样理论上就允许等同于Segment[]数组大小的线程同时操作每一段.

互斥量就是Segment对象的锁. 因此锁一个Segment对象等同于锁了一个数据+链表

get()操作如何保证线程安全

get()操作首先访问Segment[]数组, 经过一次hash操作, 找到Segment对象, 再经过一次hash, 去访问它的数组, 遍历链表找到需要的元素.

ConcurrentHashMap对于链表的Node节点, 以及Node节点中的value值, 都加了volatile关键字, 保证其它线程对于元素的修改是可见的.

volatile V val;
volatile Node<K,V> next;

put()操作如何保证线程安全

put()操作也是首先访问Segment[]数组, 找到Segment对象, 如果拿到了Segment的锁, 就继续去数组中修改或者新增元素, 如果没有拿到锁, 就阻塞等待.

扩容如何保证线程安全的

因为put()操作是针对某一个segment对象的, 因此ConcurrentHashMap在扩容时也是针对segment对象的, 在扩容时需要先拿到锁才能进行扩容. 并且是扩容完毕后, 才用新数组覆盖旧数组的, 因此其它线程在这个时候读取也是能正确读到元素的.

每个segment对象扩容的操作, 和hashmap一样, 包括基础参数和策略.

jdk 1.8

多线程-ConcurrentHashMap(JDK1.8)

首先数据结构修改回数组 + 链表的方式.

ConcurrentHashMap不再采用分段锁, 而是采用CAS + synchronized的方式, 进行更细粒度的锁控制

  • (1) 锁的力度更细, 到Node节点的层面
  • (2) synchronized锁比高级锁优化的更好, 用起来性能更好.
  • (3) jdk 1.7 中 如果分的某一段很大, 那性能就会退化成hashtable了

get()操作如何保证线程安全

等同于1.7中的操作, 为Node节点的val值, 以及Node节点, 用valatile修饰, 保证了在get()的时候是从主线程读取的最新的值.

同样在扩容时, 读取的也是未扩容的数组中的数据, 是可以读取到的

put()操作如何保证线程安全 cas + synchronized

讨论没有扩容时的情况

volatile保证在寻找数组中头节点的时候, 找到的是正确的头节点

CAS操作保证在修改或者新增的时候, 一定是线程安全的. (因为一定是修改的操作才需要CAS, 普通的查询根本不需要)

  • (1) 首先是不断遍历数组(是个死循环, 相当于线程自旋, 保证put一定不会失败)
  • (2) 每次遍历计算出添加元素应该在的数组索引, 查找头节点赋值给f
  • (3) 利用cas比较f是不是null, 如果是直接添加, 无需加锁
  • (4) 如果不是, 判断是否在扩容(利用头节点的哈希值是否=MOVED (值为-1)来判断, 即头节点是不是forwardingNode)
  • (5) 如果不在扩容, 为头节点f加锁(加锁粒度更细), 然后在f开头的链表里修改或者新增元素

数组初始化时如何保证线程安全

所有hashmap的数组 都是懒加载的, 在添加第一个元素后才初始化, 那么就会有线程安全的问题, 这里通过sizeCtl属性, 本身是volatile的, 在某个线程开始初始化的时候, 利用CAS将该值设为-1, 才可以开始初始化. 保证了初始化是由单线程完成的.

同时扩容的时候也会将sizeCtl置为-1, 来保证只会由一个线程来创建数组.

扩容时如何保证线程安全

扩容并没有加锁

新增的关键属性:


static final int MOVED = -1;  // forwarding nodes的hash值
forwardingNode: 它包含一个nextTable指针,用于指向下一张表。而且这个节点的key value next指针全部为null,它的hash值为-1. 扩容时创建
sizeCtl: 正常情况下等于0
  • (1) 扩容时通过sizeCtl变量, 保证创建一个大小为2倍的数组时单线程完成的, 然后创建forwardingNode节点指向新的数组.
  • (2) 然后是拷贝元素的过程, 这部分可以多线程进行, 每个线程从后往前遍历原数组, 如果数组位置上的节点不是forwardingNode节点(利用volatile保证能正确读取数组每个位置的节点), 然后利用CAS操作将该节点设置为forwardingNode节点, 对于该节点的链表进行拷贝.
  • (3) 在扩容操作中, 如果有线程在put元素, 发现当前节点是forwardingNode的时候, 会去帮助进行当前节点的扩容(由TRANSFERINDEX可以来分配到底处理那些节点), 直到目标节点正常, 才进行put
  • (4) 在扩容操作中, 如果有线程在get(), 如果是正常节点, 直接get(), 如果是forwardingNode, 就会去新数组中获取元素.

hashmap相关的扩容负载因子为啥都是0.75

负载因子如果是1, 代表数组全部位置都有元素了才会扩容, 但是实际情况下, 元素在数组中的索引通常会集中在一些位置, 导致某些链表会拉的很长

负载因子如果是0.5, 太浪费空间了,

经过实验, 负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。