Java小点随记

小点随记

1.什么是反射?

  1. Java反射机制的核心是在程序运行时动态加载类并获取类的详细信息,从而操作类或对象的属性和方法。本质是JVM得到class对象之后,再通过class对象进行反编译,从而获取对象的各种信息。
  2. Java属于先编译再运行的语言,程序中对象的类型在编译期就确定下来了,而当程序在运行时可能需要动态加载某些类,这些类因为之前用不到,所以没有被加载到JVM。通过反射,可以在运行时动态地创建对象并调用其属性,不需要提前在编译期知道运行的对象是谁。

下图是类的正常加载过程,反射原理与class对象

img

2.JAVA与C++的区别

  • Java 是纯粹的面向对象语言,所有的对象都继承自 java.lang.Object,C++ 为了兼容 C 即支持面向对象也支持面向过程。
  • Java 通过虚拟机从而实现跨平台特性,但是 C++ 依赖于特定的平台。
  • Java 没有指针,它的引用可以理解为安全指针,而 C++ 具有和 C 一样的指针。
  • Java 支持自动垃圾回收,而 C++ 需要手动回收。
  • Java 不支持多重继承,只能通过实现多个接口来达到相同目的,而 C++ 支持多重继承。
  • Java 不支持操作符重载,虽然可以对两个 String 对象执行加法运算,但是这是语言内置支持的操作,不属于操作符重载,而 C++ 可以。
  • Java 的 goto 是保留字,但是不可用,C++ 可以使用 goto。

3.什么是CopyOnWrite?以及它的适用场景

CopyOnWrite容器即写时复制的容器。通俗的理解是当往一个容器添加元素的时候,不直接往当前知容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素道,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容权器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。

但是 CopyOnWriteArrayList 有其缺陷:

  • 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
  • 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。

所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。

4.解决hash冲突的方法:

  • 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。
  • 开放定址法:即发生冲突时,去寻找下一个空的哈希地址。只要哈希表足够大,总能找到空的哈希地址。
  • 再哈希法:即发生冲突时,由其他的函数再计算一次哈希值。
  • 建立公共溢出区:将哈希表分为基本表和溢出表,发生冲突时,将冲突的元素放入溢出表。

5.HashMap中的哈希值计算问题

1.hash计算

JDK1.8
HashMap源码

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
static int indexFor(int h, int length) {
    return h & (length-1);
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Cu2fwkgC-1650903428031)(https://images.weserv.nl/?url=https://img2018.cnblogs.com/blog/984423/201907/984423-20190718113737330-625791541.png)]

右移16位相当于将高16位移入到低16位,再与原hashcode做异或计算(位相同为0,不同为1)可以将高低位二进制特征混合起来 => 高16位没有发生变化,但是低16位改变了

拿到的hash值会参与hashmap中数组槽位的计算,计算公式:(n - 1) & hash,假设数组初始槽位16个,那么槽位计算如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NPWCyGRs-1650903428032)(https://images.weserv.nl/?url=https://img2018.cnblogs.com/blog/984423/201907/984423-20190718114255570-1053064897.png)]

高区的16位很有可能会被数组槽位数的二进制码锁屏蔽,如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征

虽然丢失了高区特征,不同hashcode也可以计算出不同的槽位来,但是如果两个hashcode很接近时,高区的特征差异可能会导致一次哈希碰撞。

2.使用异或运算的原因

异或运算能更好的保留各部分的特征,若不进行异或的话,这将会很容易发生冲突。当低位相同时, h & (length - 1) 结果也会是一样的。即 indexFor() 的计算结果只与 hashCode 的低位相关。如果采用 & 运算计算出来的值会向0靠拢,采用 | 运算计算出来的值会向1靠拢

3.为什么槽位数必须使用2^n / 为什么要 &length-1

length为2的整数次幂的话,h&(length-1)就相当于对length取模(当b=2^n时,a&b=a%(b-1)),这样便保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列 ,并且使用位运算,计算机不用将数字转换成十进制再进行计算,运算速度快.

img

4.扩容后Hash值计算

length * 2,即新增的bit位是1,在 (n - 1) & hash 时,只需要判断新增加的这一个bit位,如果是0的话,说明索引不变,如果变成1了,索引变成 原索引+扩容前的容量大小

6.为什么HashMap的初始容量是16?

因为最理想的是16,像上一问中的一样,当容量为16时,对应的二进制为10000,所以(16-1)对应的2进制数为1111,这样的话index(数组的下标)就等同于hashcode后4位的值,只要输入的hash值本身分布均匀,那么index必然均匀,所以hashmap默认长度为16主要就是为了解决hash碰撞的几率

7.为什么HashMap的负载因子是0.75?

https://segmentfault.com/a/1190000023308658

网上文章对于负载因子为什么是0.75定义很模糊,总结下来大概有三种原因

  1. jdk1.7hashmap源码上的英文注释说明,大概意思是:一般而言,默认负载因子为0.75的时候在时间和空间成本上提供了很好的折衷。太高了可以减少空间开销,但是会增加查找复杂度,太小的话反之。我们设置负载因子尽量减少rehash的操作,但是查找元素的也要有性能保证。

但是这虽然是官方注释,但是解释的很敷衍的感觉,这种回答就像是:“嗯,你说的很有道理样子,也没什么错,但是为什么是0.75呢?这数怎么就出来了呢?”

  1. jdk1.8hashmap源码上的英文注释,大概意思是跟泊松分布有关,翻译过来是:因为TreeNode的大小约为链表节点的两倍,所以我们只有在一个拉链已经拉了足够节点的时候才会转为tree(参考TREEIFY_THRESHOLD)。并且,当这个hash桶的节点因为移除或者扩容后resize数量变小的时候,我们会将树再转为拉链。如果一个用户的数据他的hashcode值分布十分好的情况下,就会很少使用到tree结构。在理想情况下,我们使用随机的hashcode值,loadfactor为0.75情况,尽管由于粒度调整会产生较大的方差,桶中的Node的分布频率服从参数为0.5的泊松分布。下面就是计算的结果:桶里出现1个的概率到为8的概率。桶里面大于8的概率已经小于一千万分之一了。

然而,这段话的本意其实更多的是表示jdk1.8中为什么拉链长度超过8的时候进行红黑树转换,因为是1.8的代码,默认拿0.75做计算,也并没有说明为什么是0.75,只是和0.75有关系罢了

  1. 第三个是经由数学二项式分布公式推导出了在0.5~1之间适合的三个数,0.625,0.75,0.875,于是选取了中位数0.75,刚好0.75*16还是一个整数.

8.HashMap的扩容机制

什么时候触发扩容?

一般情况下,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的2倍。

HashMap的容量是有上限的,必须小于1<<30,即1073741824。如果容量超出了这个数,则不再增长,且阈值会被设置为Integer.MAX_VALUE( [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BHZB9Utz-1650903428033)(https://www.zhihu.com/equation?tex=2%5E%7B31%7D-1)] ,即永远不会超出阈值了)。

JDK7中的扩容机制

JDK7的扩容机制相对简单,有以下特性:

  • 空参数的构造函数:以默认容量、默认负载因子、默认阈值初始化数组。内部数组是空数组
  • 有参构造函数:根据参数确定容量、负载因子、阈值等。
  • 第一次put时会初始化数组,其容量变为不小于指定容量的2的幂数。然后根据负载因子确定阈值。
  • 如果不是第一次扩容,则 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V7EIAzo1-1650903428034)(https://www.zhihu.com/equation?tex=%E6%96%B0%E5%AE%B9%E9%87%8F%3D%E6%97%A7%E5%AE%B9%E9%87%8F%5Ctimes2)] , [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8EkexLeo-1650903428035)(https://www.zhihu.com/equation?tex=%E6%96%B0%E9%98%88%E5%80%BC%3D%E6%96%B0%E5%AE%B9%E9%87%8F%5Ctimes%E8%B4%9F%E8%BD%BD%E5%9B%A0%E5%AD%90)] 。

JDK8中的扩容机制

JDK8的扩容做了许多调整。

HashMap的容量变化通常存在以下几种情况:

  1. 空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。
  2. 有参构造函数:用于指定容量。会根据指定的正整数找到不小于指定容量的2的幂数,将这个数设置赋值给阈值(threshold)。第一次调用put方法时,会将阈值赋值给容量,然后让 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FeZDszlP-1650903428036)(https://www.zhihu.com/equation?tex=%E9%98%88%E5%80%BC%3D%E5%AE%B9%E9%87%8F%5Ctimes%E8%B4%9F%E8%BD%BD%E5%9B%A0%E5%AD%90)] 。(因此并不是我们手动指定了容量就一定不会触发扩容,超过阈值后一样会扩容!!)
  3. 如果不是第一次扩容,则容量变为原来的2倍,阈值也变为原来的2倍。(容量和阈值都变为原来的2倍时,负载因子还是不变)

此外还有几个细节需要注意:

  • 首次put时,先会触发扩容(算是初始化),然后存入数据,然后判断是否需要扩容;
  • 不是首次put,则不再初始化,直接存入数据,然后判断是否需要扩容;

注意:1.8中是先插入数据,然后判断是否需要扩容,如果需要,转移数据时统一计算

​ 1.7中是如果要插入数据,会先判断是否会导致扩容,如果需要,则会触发扩容,并且在扩容到新数组时并不会计算刚才新的要插入数据所在的位置,直到扩容结束后,才会单独计算新数据的位置

扩容机制说完了,接下来是扩容时的元素迁移:

JDK7中的元素迁移

JDK7中,HashMap的内部数据保存的都是链表。因此逻辑相对简单:在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。

这里有几个注意点:

  • 是否要重新计算hash值的条件这里不深入讨论,读者可自行查阅源码。
  • 因为是头插法,因此新旧链表的元素位置会发生转置现象。
  • 元素迁移的过程中在多线程情境下有可能会触发死循环(无限进行链表反转)。

JDK8中的元素迁移

JDK8则因为巧妙的设计,性能有了大大的提升:由于数组的容量是以2的幂次方扩容的,那么一个Entity在扩容时,新的位置要么在原位置,要么在原长度+原位置的位置。原因如下图:

img

数组长度变为原来的2倍,表现在二进制上就是多了一个高位参与数组下标确定。此时,一个元素通过hash转换坐标的方法计算后,恰好出现一个现象:最高位是0则坐标不变,最高位是1则坐标变为“10000+原坐标”,即“原长度+原坐标”。如下图:

img

因此,在扩容时,不需要重新计算元素的hash了,只需要判断最高位是1还是0就好了。

JDK8的HashMap还有以下细节:

  • JDK8在迁移元素时是正序的,不会出现链表转置的发生。
  • 如果某个桶内的元素超过8个,则会将链表转化成红黑树,加快数据查询效率。

9.HashMap中put的流程

jdk1.8下的流程图:

这里写图片描述

10.为什么在JDK1.7的时候是先进行扩容后进行插入,而在JDK1.8的时候则是先插入后进行扩容的呢?

首先梳理一下jdk7和8在put方法执行流程的区别,jdk7使用头插法,也没有节点数量超过8变为红黑树,jdk8在rehash过程中计算扩容过后结点位置也做了优化。

无论是jdk7还是8,在put的时候,都会先算出key的哈希值,得到数组的位置,然后遍历这个索引上的无论是链表还是红黑树。如果在遍历过程中找到了equals的key,直接修改一下value就返回了,不涉及扩容。

如果上面遍历的过程没找到,而且容量要超过了,才涉及到扩容。

jdk7先扩容,然后使用头插法,直接把要插入的Entry插入到扩容后数组中,头插法不需要遍历扩容后的数组或者链表。而jdk8如果要先扩容,由于是尾插法,扩容之后还要再遍历一遍,找到尾部的位置,然后插入到尾部。(也没怎么节约性能)

感觉jdk8可能浪费性能的地方,在Node插入之后,如果当前数组位置上节点数量达到了8,先树化,然后再计算需不需要扩容(如果需要,可能树会被拆成链表,树化浪费),前面的树化可能被浪费了。

其中还需要注意,在jdk1.7中并不是超过阈值就会扩容,还会再判断一下,要插入的节点是否会发生hash冲突,如果不会则不扩容,以下是1.7的源码

void addEntry(int hash, K key, V value, int bucketIndex) {
		//这里当前数组如果大于等于12(假如)阈值的话,并且当前的数组的Entry数组还不能为空的时候就扩容
      if ((size >= threshold) && (null != table[bucketIndex])) {
       //扩容数组,比较耗时
          resize(2 * table.length);
          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];
    //把新加的放在原先在的前面,原先的是e,现在的是new,next指向e
      table[bucketIndex] = new Entry<>(hash, key, value, e);//假设现在是new
      size++;
  }

这是1.8的源码

//其实就是当这个Map中实际插入的键值对的值的大小如果大于这个默认的阈值的时候(初始是16*0.75=12)的时候才会触发扩容,
//这个是在JDK1.8中的先插入后扩容
if (++size > threshold)
            resize();

11.JDK1.8下的hashmap链表和红黑树的转换

红黑树转换为链表

基本思想是当红黑树中的元素减少并小于一定数量时,会切换回链表。而元素减少有两种情况:

1、调用map的remove方法删除元素

hashMap的remove方法,会进入到removeNode方法,找到要删除的节点,并判断node类型是否为treeNode,然后进入删除红黑树节点逻辑的removeTreeNode方法中,该方法有关解除红黑树结构的分支如下:

//判断是否要解除红黑树的条件
if (root == null || root.right == null ||
                (rl = root.left) == null || rl.left == null) {
                tab[index] = first.untreeify(map);  // too small
                return;
            }

可以看到,此处并没有利用到网上所说的,当节点数小于UNTREEIFY_THRESHOLD(值为6)时才转换,而是通过红黑树根节点及其子节点是否为空来判断。而满足该条件的最大红黑树结构如下:

img

节点数为10,大于 UNTREEIFY_THRESHOLD(6),但是根据该方法的逻辑判断,是需要转换为链表的

2、resize的时候,对红黑树进行了拆分

resize的时候,判断节点类型,如果是链表,则将链表拆分,如果是TreeNode,则执行TreeNode的split方法分割红黑树,而split方法中将红黑树转换为链表的分支如下:

//在这之前的逻辑是将红黑树每个节点的hash和一个bit进行&运算,
//根据运算结果将树划分为两棵红黑树,lc表示其中一棵树的节点数
if (lc <= UNTREEIFY_THRESHOLD)
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    if (hiHead != null) // (else is already treeified)
                        loHead.treeify(tab);
                }

这里才用到了 UNTREEIFY_THRESHOLD 的判断,当红黑树节点元素小于等于6时,才调用untreeify方法转换回链表.

总结

1、hashMap并不是在链表元素个数大于8就一定会转换为红黑树,而是先考虑扩容(至少table长度要大于等于64),扩容达到默认限制后才转换(即要转换成红黑树需要满足,table数组长度大于等于64,并且其中桶的元素大于8这两个条件才会将这个桶上的元素转换成红黑树存储)

注意:超过64才树化是为了防止hash表的查询时间复杂度从O(1)过快退化成O(lg n),如果不重写类比较器Compare,查询复杂度会更进一步退化为O(n)

2、hashMap的红黑树不一定小于6的时候才会转换为链表,而是只有在resize的时候才会根据 UNTREEIFY_THRESHOLD 进行转换。

12.为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢?

由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值.

//Java中解释的原因
   * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

13.哈希表如何解决Hash冲突

这里写图片描述

14.为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

这里写图片描述

15.HashMap 中的 key若 Object类型, 则需实现哪些方法?

这里写图片描述

16.HashMap为什么会引起死循环

https://blog.csdn.net/zhuqiuhui/article/details/51849692

总的来说:jadk1.7下,因为采用头插法,在并发插入导致扩容时,可能会因为一个线程执行到 transfer()方法while循环中的 Entry<K,V> next = e.next; 这一步时,线程挂起,然后另外的线程执行扩容,扩容结束后,之前挂起的线程再继续运行,看到的next节点不一致导致循环链表,然后下次get数据时,就会引起死循环.

17.hashmap1.7和1.8都会造成什么不安全的因素

jdk1.7

  • **put的时候导致的多线程数据不一致。**比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
  • 另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%)

jdk1.8

  • 在并发执行put操作时会发生数据覆盖的情况,和1.7中一样
  • size计算出错,因为在put操作中有一步 ++size,这并不是一个原子操作,如果在多线程下,可能A执行到此还没有执行++size,然后时间片用尽,而切换到B执行完了++size,再切换到A,A再++size,这个时候两个线程就会出现数据不一致的情况.
 ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;

18.ConcurrentHashMap1.7实现原理是怎么样的或者问ConcurrentHashMap如何在保证高并发下线程安全的同时实现了性能提升?

ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了分段锁技术。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,只要多个修改操作发生在不同的段上,它们就可以并发进行。

  • 初始化做了什么事?

初始化有三个参数

initialCapacity:初始容量大小 ,默认16。

loadFactor, 扩容因子,默认0.75,当一个Segment存储的元素数量大于initialCapacity* loadFactor时,该Segment会进行一次扩容。

concurrencyLevel 并发度,默认16。并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。

  • 在get和put操作中,是如何快速定位元素放在哪个位置的?

对于某个元素而言,一定是放在某个segment元素的某个table元素中的,所以在定位上,

定位segment:取得key的hashcode值进行一次再散列(通过Wang/Jenkins算法),拿到再散列值后,以再散列值的高位进行取模得到当前元素在哪个segment上。

定位table:同样是取得key的再散列值以后,用再散列值的全部和table的长度进行取模,得到当前元素在table的哪个元素上。

  • get()方法

定位segment和定位table后,依次扫描这个table元素下的的链表,要么找到元素,要么返回null。

  • 在高并发下的情况下如何保证取得的元素是最新的?

用于存储键值对数据的HashEntry,在设计上它的成员变量value等都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。

  • put()方法

首先定位segment,当这个segment在map初始化后,还为null,由ensureSegment方法负责填充这个segment。

然后先对Segment 加锁,定位所在的table元素,并扫描table下的链表,找到这个元素时,put操作会覆盖原来的值,然后中断循环,返回原来的值给调用者,如果没有找到,按头插法的方式插入table数组中,然后检查是否需要扩容(和hashmap1.7一样)

  • 扩容操作

Segment 不扩容,扩容下面的table数组,每次都是将数组翻倍

  • size方法

每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。

在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来,因为这个累加操作可能需要锁住全部Segment,太耗性能,所以ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。

尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。

如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。

19.ConcurrentHashMap1.8中的实现

JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本

img

  • put操作
public V put(K key, V value) {
    return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode()); //两次hash,减少hash冲突,可以均匀分布
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) { //对这个table进行迭代
        Node<K,V> f; int n, i, fh;
        //这里就是上面构造方法没有进行初始化,在这里进行判断,为null就调用initTable进行初始化,属于懒汉模式初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//如果i位置没有数据,就直接无锁插入
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)//如果在进行扩容,则先进行扩容操作
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            //如果以上条件都不满足,那就要进行加锁操作,也就是存在hash冲突,锁住链表或者红黑树的头结点
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { //表示该节点是链表结构
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            //这里涉及到相同的key进行put就会覆盖原先的value
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {  //插入链表尾部
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {//红黑树结构
                        Node<K,V> p;
                        binCount = 2;
                        //红黑树结构旋转插入
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) { //如果链表的长度大于8时就会进行红黑树的转换
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);//统计size,并且检查是否需要扩容
    return null;
}

这个put的过程很清晰,对当前的table进行无条件自循环直到put成功,可以分成以下六步流程来概述

  1. 如果没有初始化就先调用initTable()方法来进行初始化过程
  2. 如果没有hash冲突就直接CAS插入
  3. 如果还在进行扩容操作就先进行扩容
  4. 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
  5. 最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构(treeifyBin()),break再一次进入循环
  6. 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

可以从源码中发现,他在并发处理中使用的是乐观锁,当有冲突的时候才进行并发处理

  • get操作

ConcurrentHashMap的get操作的流程很简单,也很清晰,可以分为三个步骤来描述:

  1. 计算hash值,定位到该table索引位置,如果是首节点符合就返回
  2. 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
  3. 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
  • size操作

在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,因为size是实时在变的,只能计算某一刻的大小,但是某一刻太快了,人的感知是一个时间段,所以并不是很精确。在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,因为size是实时在变的,只能计算某一刻的大小,但是某一刻太快了,人的感知是一个时间段,所以并不是很精确。

  • 扩容机制

什么情况下会触发扩容

  1. 如果新增节点之后,所在链表的元素个数达到了阈值 8,则会调用treeifyBin方法把链表转换成红黑树,不过在结构转换之前,会对数组长度进行判断.如果数组长度n小于阈值MIN_TREEIFY_CAPACITY,默认是64,则会调用tryPresize方法把数组长度扩大到原来的两倍,并触发transfer方法,重新调整节点的位置。
  2. 新增节点之后,会调用addCount方法记录元素个数,并检查是否需要进行扩容,当数组元素个数达到阈值时,会触发transfer方法,重新调整节点的位置。

首先介绍几个属性

nextTable:扩容期间,将table数组中的元素 迁移到 nextTable。

sizeCtl:多线程之间,以volatile的方式读取sizeCtl属性,来判断ConcurrentHashMap当前所处的状态。通过cas设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。

不同状态,sizeCtl所代表的含义也有所不同。

  • 未初始化:

    • sizeCtl=0:表示没有指定初始容量。
    • sizeCtl>0:表示初始容量。
  • 初始化中:

    • sizeCtl=-1,标记作用,告知其他线程,正在初始化
  • 正常状态:

    • sizeCtl=0.75n ,扩容阈值
  • 扩容中:

    • sizeCtl < 0 : 表示有其他线程正在执行扩容
    • sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 :表示此时只有一个线程在执行扩容

transferIndex:扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)。

transfer实现(扩容的主要方法)

transfer方法实现了在并发的情况下,高效的从原始组数往新数组中移动元素,假设扩容之前节点的分布如下,这里区分蓝色节点和红色节点,是为了后续更好的分析:

img

在上图中,第14个槽位插入新节点之后,链表元素个数已经达到了8,且数组长度为16,优先通过扩容来缓解链表过长的问题

  1. 根据当前数组长度n,新建一个两倍长度的数组nextTable

  2. 初始化ForwardingNode节点,其中保存了新数组nextTable的引用,在处理完每个槽位的节点之后当做占位节点,表示该槽位已经处理过了;

  3. 通过for自循环处理每个槽位中的链表元素,默认advace为真,通过CAS设置transferIndex属性值,并初始化ibound值,i指当前处理的槽位序号,bound指需要处理的槽位边界,先处理槽位15的节点;

  4. 在当前假设条件下,槽位15中没有节点,则通过CAS插入在第二步中初始化的ForwardingNode节点,用于告诉其它线程该槽位已经处理过了;

  5. 如果槽位15已经被线程A处理了,那么线程B处理到这个节点时,取到该节点的hash值应该为MOVED,值为-1,则直接跳过,继续处理下一个槽位14的节点;

  6. 处理槽位14的节点,是一个链表结构,先定义两个变量节点lnhn,按我的理解应该是lowNodehighNode,分别保存hash值的第X位为0和1的节点;

    使用fn&n可以快速把链表中的元素区分成两类,A类是hash值的第X位为0,B类是hash值的第X位为1,并通过lastRun记录最后需要处理的节点,A类和B类节点可以分散到新数组的槽位14和30中,在原数组的槽位14中,蓝色节点第X为0,红色节点第X为1

  7. 如果该槽位是红黑树结构,遍历红黑树中的节点,同样根据hash&n算法,把节点分为两类.

注意:如果在扩容期间,有其它线程get,扫描到应获取的数据桶位置被标记为ForwardingNode,则表示该位置的元素已经被移到新数组中,这个时候就该调用ForwardingNode的find方法,去新数组相应的位置寻找数据,这也是为什么ForwardingNode节点要关联新数组的引用

扩容总结:多线程无锁扩容的关键就是通过CAS设置sizeCtl与transferIndex变量,协调多个线程对table数组中的node进行迁移。(因为1.7中使用Segment锁来控制并发效率底下,1.8中才摒弃了Segment数组的方式,采用CAS无锁并发的方式保证写).

https://www.jianshu.com/p/f6730d5784ad 1.8扩容机制

整体总结

  1. JDK1.8取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。
  2. JDK1.8存储数据时采用了链表+红黑树的形式,纯链表的形式时间复杂度为O(n),红黑树则为O(logn),性能提升很大。什么时候链表转红黑树?当key值相等的元素形成的链表中元素个数超过8个并且数组长度超过64的时候。
  3. JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
  4. JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
  5. JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
  6. JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
    1. 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
    2. JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
    3. 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

20.能用linkenHahsMap实现LRU缓存么?

可以!!,linkenHahsMap继承自hashmap,它与hashmap的实现基本一致,但是它会额外维护一个双向链表,用来维护插入顺序或者LRU顺序

/**
 * The head (eldest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> head;

/**
 * The tail (youngest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> tail;

accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序。

final boolean accessOrder;

LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }

afterNodeAccess

当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。

afterNodeInsertion

在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。

evict 只有在构建 Map 的时候才为 false,在这里为 true。

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。

以下是使用 LinkedHashMap 实现的一个 LRU 缓存:

  • 设定最大缓存空间 MAX_ENTRIES 为 3;
  • 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序;
  • 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除。
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_ENTRIES = 3;

    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }

    LRUCache() {
        super(MAX_ENTRIES, 0.75f, true);
    }
}
public static void main(String[] args) {
    LRUCache<Integer, String> cache = new LRUCache<>();
    cache.put(1, "a");
    cache.put(2, "b");
    cache.put(3, "c");
    cache.get(1);
    cache.put(4, "d");
    System.out.println(cache.keySet());
}
[3, 1, 4]

21.使用线程的方法

有三种使用线程的方法:

  • 实现 Runnable 接口;
  • 实现 Callable 接口;
  • 继承 Thread 类。

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以理解为任务是通过线程驱动从而执行的。

实现接口VS继承Thread

实现接口会更好一些,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。

22.分布式一致性算法

Raft 和Paxos是目前分布式系统领域中两种非常著名的解决一致性问题的共识算法,两者都能解决分布式系统中的一致性问题,但是Paxos的实现被证明非常难以理解,Raft的实现则比较简洁并且遵循人的直觉,它的出现就是为了解决 Paxos 难以理解和和难以实现的问题

Raft:

  • https://mp.weixin.qq.com/s?__biz=MzU3NDY4NzQwNQ==&mid=2247484187&idx=1&sn=1fd6e18c99ff845816c223c89f486312&chksm=fd2fd2d9ca585bcf664f61e3c1285b91ff8b7800aabc5e888dbf82cf611fe14c8283b277c98f&scene=21#wechat_redirect

Paxos:

  • https://blog.csdn.net/weixin_44296862/article/details/95277801
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

走出半生仍是少年

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值