二、并发容器

1.时间复杂度

算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,比如排序就有前面的十大经典排序和几种奇葩排序,虽然结果相同,但在过程中消耗的资源和时间却会有很大的区别,比如快速排序与猴子排序:)。

那么我们应该如何去衡量不同算法之间的优劣呢?
主要还是从算法所占用的「时间」和「空间」两个维度去考量。

  • 时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。

  • 空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。

本小节将从「时间」的维度进行分析。

1.1 什么是大O

当看「时间」二字,我们肯定可以想到将该算法程序运行一篇,通过运行的时间很容易就知道复杂度了。

这种方式可以吗?当然可以,不过它也有很多弊端。
比如程序员小吴的老式电脑处理10w数据使用冒泡排序要几秒,但读者的iMac Pro 可能只需要0.1s,这样的结果误差就很大了。更何况,有的算法运行时间要很久,根本没办法没时间去完整的运行,还是比如猴子排序:)。

那有什么方法可以严谨的进行算法的时间复杂度分析呢?

有的!
「 远古 」的程序员大佬们提出了通用的方法:「 大O符号表示法 」,即 T(n) = O(f(n))。
其中 n 表示数据规模 ,O(f(n))表示运行算法所需要执行的指令数,和f(n)成正比。

上面公式中用到的 Landau符号是由德国数论学家保罗·巴赫曼(Paul Bachmann)在其1892年的著作《解析数论》首先引入,由另一位德国数论学家艾德蒙·朗道(Edmund Landau)推广。Landau符号的作用在于用简单的函数来描述复杂函数行为,给出一个上或下(确)界。在计算算法复杂度时一般只用到大O符号,Landau符号体系中的小o符号、Θ符号等等比较不常用。这里的O,最初是用大写希腊字母,但现在都用大写英语字母O;小o符号也是用小写英语字母o,Θ符号则维持大写希腊字母Θ。

注:本文用到的算法中的界限指的是最低的上界。

1.2 常见的时间复杂度量级

我们先从常见的时间复杂度量级进行大O的理解:

  • 常数阶O(1)

  • 线性阶O(n)

  • 平方阶O(n²)

  • 对数阶O(logn)

  • 线性对数阶O(nlogn)

O(1)

在这里插入图片描述

无论代码执行了多少行,其他区域不会影响到操作,这个代码的时间复杂度都是O(1)

void swapTwoInts(int &a, int &b){
  int temp = a;
  a = b;
  b = temp;
}

O(n)

在这里插入图片描述

在下面这段代码,for循环里面的代码会执行 n 遍,因此它消耗的时间是随着 n 的变化而变化的,因此可以用O(n)来表示它的时间复杂度。

int sum ( int n ){
   int ret = 0;
   for ( int i = 0 ; i <= n ; i ++){
      ret += i;
   }
   return ret;
}

O(n2)

在这里插入图片描述

当存在双重循环的时候,即把 O(n) 的代码再嵌套循环一遍,它的时间复杂度就是 O(n²) 了。

void selectionSort(int arr[],int n){
   for(int i = 0; i < n ; i++){
     int minIndex = i;
     for (int j = i + 1; j < n ; j++ )
       if (arr[j] < arr[minIndex])
           minIndex = j;
       
     swap ( arr[i], arr[minIndex]);
   }
}

这里简单的推导一下

  • 当 i = 0 时,第二重循环需要运行 (n - 1) 次
  • 当 i = 1 时,第二重循环需要运行 (n - 2) 次
  • 。。。。。。

不难得到公式:

(n - 1) + (n - 2) + (n - 3) + ... + 0
= (0 + n - 1) * n / 2
= O (n ^2)

O(logn)

在这里插入图片描述

int binarySearch( int arr[], int n , int target){
  int l = 0, r = n - 1;
  while ( l <= r) {
    int mid = l + (r - l) / 2;
    if (arr[mid] == target) return mid;
    if (arr[mid] > target ) r = mid - 1;
    else l = mid + 1;
  }
  return -1;
}

在二分查找法的代码中,通过while循环,成 2 倍数的缩减搜索范围,也就是说需要经过 log2^n 次即可跳出循环。

O(nlogn)

将时间复杂度为O(logn)的代码循环N遍的话,那么它的时间复杂度就是 n * O(logn),也就是了O(nlogn)。

void hello (){
  for( m = 1 ; m < n ; m++){
    i = 1;
    while( i < n ){
        i = i * 2;
    }
   }
}

2.HashMap源码分析

3.ConcurrentHashMap

Hashmap多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。

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

putIfAbsent() :没有这个值则放入map,有这个值则返回key本来对应的值。

3.1 JDK1.7中原理和实现

ConcurrentHashMap中的数据结构

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment实际继承自可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,每个Segment里包含一个HashEntry数组,我们称之为table,每个HashEntry是一个链表结构的元素。

在这里插入图片描述

初始化做了什么事?

初始化有三个参数

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

构造方法中部分代码解惑

在这里插入图片描述

保证Segment数组的大小,一定为2的幂,例如用户设置并发度为17,则实际Segment数组大小则为32

在这里插入图片描述

保证每个Segment中tabel数组的大小,一定为2的幂,初始化的三个参数取默认值时,table数组大小为2

在这里插入图片描述

初始化Segment数组,并实际只填充Segment数组的第0个元素。

在这里插入图片描述

用于定位元素所在segment。segmentShift表示偏移位数,通过前面的int类型的位的描述我们可以得知,int类型的数字在变大的过程中,低位总是比高位先填满的,为保证元素在segment级别分布的尽量均匀,计算元素所在segment时,总是取hash值的高位进行计算。segmentMask作用就是为了利用位运算中取模的操作:a % 2 n = a & (2 n - 1)

在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()方法

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

  • 2.对Segment 加锁
    在这里插入图片描述

  • 3.定位所在的table元素,并扫描table下的链表,
    找到时:
    在这里插入图片描述

没有找到时:
在这里插入图片描述

扩容操作

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

在这里插入图片描述

带来的好处

假设原来table长度为4,那么元素在table中的分布是这样的:

Hash值1523345677
在table中下标3 = 15%43 = 23 % 42 = 34%40 = 56%41 = 77 % 4

扩容后table长度变为8,那么元素在table中的分布变成:

Hash值56347715,23
下标01234567

可以看见 hash值为34和56的下标保持不变,而15,23,77的下标都是在原来下标的基础上+4即可,可以快速定位和减少重排次数。

size方法

size的时候进行两次不加锁的统计,两次一致直接返回结果,不一致,重新加锁再次统计

弱一致性

get方法和containsKey方法都是通过对链表遍历判断是否存在key相同的节点以及获得该节点的value。但由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这一点是ConcurrentHashMap在弱一致性上的体现。

3.2 JDK1.8中原理和实现

与1.7相比的重大变化

  • 1.取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。

  • 2.存储数据时采用了链表+红黑树的形式,纯链表的形式时间复杂度为O(n),红黑树则为O(logn),性能提升很大。什么时候链表转红黑树?当key值相等的元素形成的链表中元素个数超过8个的时候。

主要数据结构和关键变量

Node类存放实际的key和value值。
sizeCtl:
负数:表示进行初始化或者扩容,-1表示正在初始化,-N,表示有N-1个线程正在进行扩容
正数:0 表示还没有被初始化,>0的数,初始化或者是下一次进行扩容的阈值
TreeNode 用在红黑树,表示树的节点, TreeBin是实际放在table数组中的,代表了这个红黑树的根。

初始化做了什么事?

只是给成员变量赋值,put时进行实际数组的填充

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

在这里插入图片描述

在这里插入图片描述

get()方法

在这里插入图片描述

put()方法

数组的实际初始化

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

扩容操作

transfer()方法进行实际的扩容操作,table大小也是翻倍的形式,有一个并发扩容的机制。

size方法

估计的大概数量,不是精确数量

一致性

弱一致

4.ConcurrentSkipListMap 和 ConcurrentSkipListSet

TreeMap和TreeSet有序的容器,这两种容器的并发版本

5.跳表SkipList

跳跃表(SkipList)是一种可以替代平衡树的数据结构。跳跃表让已排序的数据分布在多层次的链表结构中,默认是将Key值升序排列的,以 0-1 的随机值决定一个数据是否能够攀升到高层次的链表中。它通过容许一定的数据冗余,达到 “以空间换时间” 的目的。

5.1 有序表的搜索

考虑一个有序表:

在这里插入图片描述

从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数

为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉

搜索树,我们把一些节点提取出来,作为索引。得到如下结构:

在这里插入图片描述

提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。
我们还可以再从一级索引提取一些元素出来,作为二级索引,三级索引…

在这里插入图片描述

5.2 跳表

下面的结构是就是跳表:
其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。

在这里插入图片描述

5.2.1 跳表具有如下性质

  • (1) 由很多层结构组成
  • (2) 每一层都是一个有序的链表
  • (3) 最底层(Level 1)的链表包含所有元素
  • (4) 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
  • (5) 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

5.2.2 跳表的搜索

在这里插入图片描述

例子:查找元素 117
(1) 比较 21, 比 21 大,往后面找
(2) 比较 37, 比 37大,比链表最大值小,从 37 的下面一层开始找
(3) 比较 71, 比 71 大,比链表最大值小,从 71 的下面一层开始找
(4) 比较 85, 比 85 大,从后面找
(5) 比较 117, 等于 117, 找到了节点。

5.2.3 跳表的实现

在这里插入图片描述

首先,我们需要定义链表中节点的模型

在这里插入图片描述

Java代码实现如下:


    // data
    public String key;
    public Integer value;

    // links
    public SkipListEntry up;
    public SkipListEntry down;
    public SkipListEntry left;
    public SkipListEntry right;

    // special
    public static final String negInf = "-oo";
    public static final String posInf = "+oo";

    // constructor
    public SkipListEntry(String key, Integer value) {
        this.key = key;
        this.value = value;
    }

    // methods...
}

可以看到节点模型主要分为2个部分。

data部分包含具体的存储数据,这里为了不引入其他杂乱的问题,使用String作为key的类型,Integer作为value的类型。

links部分包含4个指针,分别是up,down,left,right,单从名字上就能够明白它们的作用。

最后一个需要解释的是,2个special的字符串变量,它们是用来处理一些特殊节点的初始化的,从上面的图中可以看到,跳跃表每一层链表中都有那么2个节点。具体的初始化方法如下:

SkipListEntry x = new SkipListEntry(SkipListEntry.negInf, null);
SkipListEntry y = new SkipListEntry(SkipListEntry.posInf, null);

接下来,我们回到跳跃表本身的模型

public class SkipList {

    public SkipListEntry head;  // First element of the top level
    public SkipListEntry tail;  // Last element of the top level

    public int n;       // number of entries in the Skip List
    public int h;       // Height

    public Random r;    // Coin toss
}

Note: Random类的实例对象r用来决定新添加的节点是否能够向更高一层的链表攀升。

初始化一个跳跃表的实例

构造函数将初始化一个空的跳跃表看起来像下面这样:
在这里插入图片描述

构造函数的Java代码:

// constructor
public SkipList() {
    SkipListEntry p1, p2;

    // 创建一个 -oo 和一个 +oo 对象
    p1 = new SkipListEntry(SkipListEntry.negInf, null);
    p2 = new SkipListEntry(SkipListEntry.posInf, null);

    // 将 -oo 和 +oo 相互连接
    p1.right = p2;
    p2.left = p1;

    // 给 head 和 tail 初始化
    head = p1;
    tail = p2;

    n = 0;
    h = 0;
    r = new Random();
}

实现Map的基本操作

Map的基本操作:

  • get(String key) : 根据key值查找某个元素

  • put(String key, Integer value) :插入一个新的元素,元素已存在时为修改操作

  • remove(String key): 根据key值删除某个元素

虽然看似是3个不同的操作,但是究其本质,要实现这3个操作,都得先找到某个元素 或是 定位到一个元素,好在下一个位子插入新元素。那么,我们就先把这个findEntry的方法实现吧。

在这里插入图片描述

上面的图示使用紫色的箭头画出了在一个SkipList中查找key值50的过程。简述如下:

1.从head出发,因为head指向最顶层(top level)链表的开始节点,相当于从顶层开始查找;

2.移动到当前节点的右指针(right)指向的节点,直到右节点的key值大于要查找的key值时停止;

3.如果还有更低层次的链表,则移动到当前节点的下一层节点(down),如果已经处于最底层,则退出;

4.重复第2步 和 第3步,直到查找到key值所在的节点,或者不存在而退出查找;

Java代码实现如下:

private SkipListEntry findEntry(String key) {

    SkipListEntry p;

    // 从head头节点开始查找
    p = head;

    while(true) {
        // 从左向右查找,直到右节点的key值大于要查找的key值
        while(p.right.key != SkipListEntry.posInf
                && p.right.key.compareTo(key) <= 0) {
            p = p.right;
        }

        // 如果有更低层的节点,则向低层移动
        if(p.down != null) {
            p = p.down;
        } else {
            break;
        }
    }

    // 返回p,!注意这里p的key值是小于等于传入key的值的(p.key <= key)
    return p;
}

注意以下几点:

  • 1.如果传入的key值在跳跃表中存在,则findEntry返回该对象的底层节点;

  • 2.如果传入的key值在跳跃表中不存在,则findEntry返回跳跃表中key值小于key,并且key值相差最小的底层节点;

示例,在跳跃表中查找key=42的元素节点,将返回key=39的节点。如下图所示:

在这里插入图片描述

基于findEntry方法,我们就能很容易的实现前面所说的一些操作了。

实现get方法

public Integer get(String key) {

    SkipListEntry p;

    p = findEntry(key);

    if(p.key.equals(key)) {
        return p.value;
    } else {
        return null;
    }
}

实现put方法

put方法有一些需要注意的步骤:

  • 1.如果put的key值在跳跃表中存在,则进行修改操作;

  • 2.如果put的key值在跳跃表中不存在,则需要进行新增节点的操作,并且需要由random随机数决定新加入的节点的高度(最大level);

  • 3.当新添加的节点高度达到跳跃表的最大level,需要添加一个空白层(除了-oo和+oo没有别的节点)

下面我们一步一步的通过图示看一下插入节点的过程:

在这里插入图片描述

第一步,查找适合插入的位子

在这里插入图片描述

第二步,在查找到的p节点后面插入新增的节点q

在这里插入图片描述

第三步,重复下面的操作,使用随机数决定新增节点的高度

从p节点开始,向左移动,直到找到含有更高level节点的节点;
将p指针向上移动一个level;

创建一个和q节点data一样的节点,插入位子在跳跃表中p的右方和q的上方;
直到随机数不满足向上攀升的条件为止;

图示如下:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

只要随机数满足条件,key=42的节点就会一直向上攀升,直到它的level等于跳跃表的高度(height)。这个时候我们需要在跳跃表的最顶层添加一个空白层,同时跳跃表的height+1,以满足下一次新增节点的操作。

在这里插入图片描述

只要随机数满足条件,key=42的节点就会一直向上攀升,直到它的level等于跳跃表的高度(height)。这个时候我们需要在跳跃表的最顶层添加一个空白层,同时跳跃表的height+1,以满足下一次新增节点的操作。

在这里插入图片描述

Java代码实现如下:

public Integer put(String key, Integer value) {

    SkipListEntry p, q;
    int i = 0;

    // 查找适合插入的位子
    p = findEntry(key);

    // 如果跳跃表中存在含有key值的节点,则进行value的修改操作即可完成
    if(p.key.equals(key)) {
        Integer oldValue = p.value;
        p.value = value;
        return oldValue;
    }

    // 如果跳跃表中不存在含有key值的节点,则进行新增操作
    q = new SkipListEntry(key, value);
    q.left = p;
    q.right = p.right;
    p.right.left = q;
    p.right = q;

    // 再使用随机数决定是否要向更高level攀升
    while(r.nextDouble() < 0.5) {

        // 如果新元素的级别已经达到跳跃表的最大高度,则新建空白层
        if(i >= h) {
            addEmptyLevel();
        }

        // 从p向左扫描含有高层节点的节点
        while(p.up == null) {
            p = p.left;
        }
        p = p.up;

        // 新增和q指针指向的节点含有相同key值的节点对象
        // 这里需要注意的是除底层节点之外的节点对象是不需要value值的
        SkipListEntry z = new SkipListEntry(key, null);

        z.left = p;
        z.right = p.right;
        p.right.left = z;
        p.right = z;

        z.down = q;
        q.up = z;

        q = z;
        i = i + 1;
    }

    n = n + 1;

    // 返回null,没有旧节点的value值
    return null;
}

private void addEmptyLevel() {

    SkipListEntry p1, p2;

    p1 = new SkipListEntry(SkipListEntry.negInf, null);
    p2 = new SkipListEntry(SkipListEntry.posInf, null);

    p1.right = p2;
    p1.down = head;

    p2.left = p1;
    p2.down = tail;

    head.up = p1;
    tail.up = p2;

    head = p1;
    tail = p2;

    h = h + 1;
}

实现remove方法

删除节点的操作相对put就比较简单了,首先查找到包含key值的节点,将节点从链表中移除,接着如果有更高level的节点,则repeat这个操作即可。
Java代码实现如下:

public Integer remove(String key) {

    SkipListEntry p, q;

    p = findEntry(key);

    if(!p.key.equals(key)) {
        return null;
    }

    Integer oldValue = p.value;
    while(p != null) {
        q = p.up;
        p.left.right = p.right;
        p.right.left = p.left;
        p = q;
    }

    return oldValue;
}

跳跃表的原理和实现到这里就结束了。

还有需要说明的一点是:跳跃表每次运行的结果是不一样的,这就是为什么说跳跃表是属于随机化数据结构。(Random的存在导致的)

5.2.4 跳跃表在Java中的应用

  • ConcurrentSkipListMap:在功能上对应HashTable、HashMap、TreeMap;

  • ConcurrentSkipListSet : 在功能上对应HashSet;

确切的说,SkipList更像Java中的TreeMap,TreeMap基于红黑树(一种自平衡二叉查找树)实现的,时间复杂度平均能达到O(log n)。
HashMap是基于散列表实现的,查找时间复杂度平均能达
到O(1)。ConcurrentSkipListMap是基于跳跃表实现的,查找时间复杂度平均能达到O(log n)。

ConcurrentSkipListMap具有SkipList的性质 ,并且适用于大规模数据的并发访问。多个线程可以安全地并发执行插入、移除、更新和访问操作。与其他有锁机制的数据结构在巨大的压力下相比有优势。

TreeMap插入数据时平衡树采用严格的旋转操作(比如平衡二叉树有左旋右旋)来保证平衡,因此SkipList比较容易实现,而且相比平衡树有着较高的运行效率。

6.ConcurrentLinkedQueue

无界非阻塞队列,底层是个链表,遵循先进先出原则。
add,offer将元素插入到尾部,peek(拿头部的数据,但是不移除)和poll(拿头部的数据,但是移除)

7.写时复制容器

  • CopyOnWriteArrayList

  • CopyOnWriteArraySet

写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以写时复制容器也是一种读写分离的思想,读和写不同的容器。如果读的时候有多个线程正在向容器添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的,只能保证最终一致性。

适用读多写少的并发场景,常见应用:白名单/黑名单, 商品类目的访问和更新场景。
存在内存占用问题。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值