长文预警!吐血总结2万字Java容器,再也不怕面试官刨根问底了。

人世仙家本自殊,何须相见向中途。惊鸿瞥过游龙去,漫恼陈王一事无。
嗨,大家好,我是洛神,性别男。一个来自快乐星球的程序员。
欢迎大家专注我的公众号【程序员洛神】,不仅分享技术,还会分享生活趣事、体育。

前言

失踪人口回归啦!!!俺也不是故意失踪这么久的,这一个多月事儿太多啦,实在是处理不过来才停更了,希望各位见谅。后面我会正常恢复更新的,嘤嘤嘤。
在这里插入图片描述

引言

数组为我们提供了一种存储对象的方式,然而,很显然数组在动态增减、空间利用率等方面有缺陷。所以,我们需要一种能够满足动态增减的存储方式。

数组:

int[] args = new int[10];

数组每次扩容需要创建一个比原数组更大的新的数组,然后将原数组的值依次放入。

餐前甜点:

容器家族族谱:

image-20210730103511482

根据上图我们可以看到,Java容器分为两类,Collection和Map

Collection

一个独立元素的序列,这些元素都服从一条或多条规则。其中List必须按照插入的顺序保存元素(List的有序并不是指内部自动排序,而是保证插入的顺序和存储的顺序一致)。Set不能有重复的元素(可以利用这个特性对集合进行去重->HashSet set = new HashSet(list);) Queue按照排队规则来确定对象的产生顺序(FIFO(先进先出) 栈(FILO 先进后出))

(关于队列、栈引申出堆、栈、队列 内存中(堆(公共)、栈(私有栈)) 数据结构中(栈、队列)) https://www.cnblogs.com/guoxiaoyan/p/8664150.html

Map

一组成对的键值对(key-value),允许用键来查找值。(可以理解为一本字典 key就是目录 通过hash+散列法取余获取下标(字典页数))

正文开始

List

List承诺可以将元素维护在特定的序列中。List接口在Collection的基础上加入了大量的方法,使得可以在集合中任意位置插入和移除元素。

ArrayList

特点

随机访问元素快(就是查询的时候快的很) 但是插入和移除比较慢。

题外话:为什么说ArrayList是随机访问的?

image-20210730111547356

重点看介个RandomAccess接口 它为操作类提供了随机快速访问的能力

来让我们看一下RandomAccess接口的官方注释:

image-20210730111602563

总结一句话:你实现我,你查询起来就快的一批。

为啥访问访问快?

因为ArrayList底层是数组

证据:

image-20210730111735825

数组是紧凑连续存储的,可以随机访问,通过数组在内存中的起始位置以及下标来快速查找对应元素。时间复杂度是O(1)

image-20210730111802175

多维数组也是一样的:(arr[0][1])

image-20210730111816525

那他为什么插入和删除很慢嘞?

成也连续,败也连续,正是因为数组在内存中是连续的,所以一开始就需要分配好空间的(新建数组的时候必须分配大小,不分配默认10)

这也就意味着,如果超过了既定数组长度,那么我就需要重新建立一个比原数组更大的数组来存放以前的数据和以后的数据,这个操作过程的时间复杂度是O(N)。而且如果你想在数组中间插入或者删除数据,那么你每次必须将操作的元素后面所有的元素都整体挪动,以此保证元素永远连续,这个操作的时间复杂度也是O(N)。

image-20210730111838085

ArrayList实际使用:
 //默认长度是10  每次扩容为原数组的1.5倍 

ArrayList<String> list1 = new ArrayList<String>( ); 

list1.add("我");

 list1.add("秦始皇"); 

 list1.add("打钱!!"); 

 list1.forEach(System.out::println); 

 //获取集合中的n个元素 

 System.out.println(list1.get(0));  

//获取集合大小

 System.out.println(list1.size()); 

  //截取集合元素  下标从0开始 到1结束(不包括1)  

 List<String> newList1 = list1.subList(0, 1); 

 newList1.forEach(System.out::println); 

 //数组转换为ArrayList  

String[] strArr = new String[10];

 strArr[0] = "拿来吧你";  

java.util.List<String> strs = Arrays.asList(strArr);

 strs.forEach(System.out::println);               

LinkedList

LinkedList底层是基于链表的。

先上证据,免得你觉得我在骗你:

image-20210730112706847

Node作为每个链表的节点,里面会有一个next指向下一个链表。prev指向前一个链表。

特点

查询速度慢,增删改速度快。

它为什么查询数据很慢嘞?

在内存中不连续,所以无法随机访问,每次查询元素需要遍历链表查询,时间复杂度为O(n)。

image-20210730113604312

为什么增删改速度快嘞?

也正是因为它是不连续的,所以每个元素需要依靠指针指向下一个元素的地址,所以不存在数组的类似扩容问题,每次新增或删除操作只需要将操作位置的前后元素进行拆开后插入或移除即可。时间复杂度是O(1)。但是也是由于不连续的,,而且由于每个元素都要存放前后元素位置的指针(前驱节点和后驱节点),所以会消耗额外的存储空间。

LinkedList和ArrayList的API基本上是一致的,区别就是根据不同的业务场景来挑选使用不同的集合。

Vector

vector主要实现了一个动态数组。和上面我们说到的ArrayList很相似,但是两者是不同的。

最大的不同点,就是Vector是同步访问的,也就是说vector是绝对线程安全的。

image-20210730114002368

几乎所有的方法都加了synchronized锁,而且锁的整个方法哟。当然安全。

但是,众所周知,锁方法是很危险的,尤其是在并发量较大的情况下。会造成严重的延迟响应。所以Vector平时开发中我们使用的是比较少的(可能只是我用的少,小丑竟是我自己!)

Set集合

Set集合的人生格言:哥注定就是要跟别人不一样!

为啥这么说呢,因为Set集合的特性就是比较特殊的:

1.不存储重复的元素。

2.存进去的值的顺序是无法保证的。

大家先知道下这个特性,下面讲HashSet的时候我会带大家实战。

HashSet:我来了兄dei!

唉WC,说曹操曹操就到,HashSet底层其实是使用HashMap来存放值的,也就是使用哈希表,当我add()一个元素的时候,其实是将元素作为map的key存储,value默认是一个固定的Object。

image-20210730114147484

image-20210730134311619

当add元素的时候,会计算待插入元素的hashCode值,将元素插入到对hash表中对应的位置。

特点

除了Set集合的无序和不可重复外,还具有:

1.允许插入null值,也就是允许key为null。

2.线程不安全,如果两个线程同时操作这个HashSet,必须通过代码实现同步(考虑线程安全,目前用的比较多的是ConcurrentHashMap,后面详细介绍)

话不多说,咱们直接刚代码
HashSet<Integer> sets1 = new HashSet<>(); 
sets1.add(1);
sets1.add(1);
sets1.add(1);
sets1.add(1);
sets1.add(6);
sets1.add(7);
sets1.forEach(System.out::println);      

输出结果:

image-20210730134605372

呐,看到了吧,刚才说过,HashSet的底层基于HashMap的,map的特性就是key不重复,相同的会被覆盖(当然,只是我们感觉的重复了,如果出现了Hash冲突而且两个元素equals不相等的时候,就会将重复的值放到同一位置的链表中。。。。JDK8以后链表长度大于8又会退化成红黑树。。。 我们后面仔细说)

知识点扩充

Object的hashCode()和equals()方法:

这两个方法的作用其实是一致的,都是用来比较两个对象是否相同的,但是hashCode()是没有equals()可靠的,因为hashCode()相等,两个对象不一定是一样的,也就是说equals()比较不一定为true,而equals()比较后相等,那么他们的hashCode()结果也一定相等,那么问题来了:

既然equals()方法比hashCode()可靠,为什么还要使用hashCode()呢?

答案:为了效率,hashCode()的执行效率是比equals()高的,所以通常都会先使用hashCode()来比较,如果hashCode()都不相同,那么这两个对象一定就不相同,直接返回false,如果hashCode()结果为true,再去调用equals()方法比较,结果还是true的话就代表这俩对象确实是相同的。这种方法常用在hash容器中。

好了,回到正题,继续我们的测试:

HashSet<String> sets = new HashSet<>(); 
sets.add("我");  
sets.add("秦始皇");
sets.add("打钱!!!");   
sets.add("不给?");     
sets.add("拿来吧你!!!!");   
sets.forEach(System.out::println);              

输出结果:

image-20210730134746646

看到了吧,我add进去的顺序和输出的顺序是不一样的,这就叫无序。

你以为这就结束了?来,给你看个神奇的现象

同样的,我是用hashSet来存放值。

HashSet<Integer> sets1 = new HashSet<>(); 
sets1.add(1); 
sets1.add(2); 
sets1.add(3); 
sets1.add(4); 
sets1.add(5); 
sets1.add(6); 
sets1.add(7); 
sets1.forEach(System.out::println);              

输出结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pZcb46I2-1627631753514)(C:/Users/DD-28/AppData/Roaming/Typora/typora-user-images/image-20210730134908684.png)]

结果每次输出都是有序的,都是按照我add进去的顺序输出的,完了,它变了,变得陌生了~~~

这是为什么呢? 原因的话就得从盘古开天辟地解释开始解释了。

上面我们已经讲过了,HashSet的add()方法底层是调用了HashMap的put()方法,那么,我们来看下put()方法

image-20210730134953084

如图所示,return值之前有一个hash(key)的操作,这个就是对key值进行hash计算,来,我们继续点进去。

image-20210730135006906

int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;              

首先我们来分析一下:

hashCode是Object类中的一个方法,在子类中一般都会重写,而根据我们之前自己给出的程序,暂以Integer类型为例,我们来看一下Integer中hashCode方法的源码:

image-20210730135357747

我们可以看到,Integer类的hashCode返回的hashCode是这个数本身。

那么上面的 (h = key.hashCode()) ^ (h>>>16) 就可以化简为 key ^ key>>>16,我们可以看到基于key的hash结果又进行了16位的高位运算以及两者的异或计算,这个操作其实就是所谓的扰动函数,归根结底就是为了降低hash冲突的概率。

image-20210730135413431

hash&(table.length-1)这一步是取模计算得到最终存放再Node中的下标值,为什么要这样子呢?

因为如果使用之前的hash值,我们可以看到转换成十进制之后,数字是很大的,总不能把一个数组扩容到几千万的大小吧。

好了,讲到这里就行了,适可而止,再深入的话一篇文章讨论不过来了,回到最初的问题上,就很容易解释清楚为什么1 2 3 4 5 6 都是有序的了,其实hashSet本身只是保证不一定有序,不保证一定无序,根本原因就在于add的时候(也就是map put的时候)的位置计算的原因。其实也是恰巧我们使用了Integer,Integer重写了hashcode方法,使得它的hashCode就是它本身然后最终计算的下标结果也是它本身了,所以在插入的时候其实已经排好序了(元素下标位置有序)。

同样的,我们再来观察一组示例:

HashSet<Integer> sets1 = new HashSet<>();
sets1.add(1);
sets1.add(10);
sets1.add(11);
sets1.add(18);
sets1.forEach(System.out::println);            

猜猜输出结果是什么?

image-20210730135528871

如果我没有讲上面关于map put操作原理的话,你可能会一脸懵逼,但是,现在,你可以把18带入计算公式自己推演一边,你会发现最终18计算出的下标是2,所以,它就出现在了一个莫名其妙的地方。

LinkedHashSet

LinkedHashSet是HashSet的一个子类,它底层其实是维护了一个LinkedHashMap,所有的操作也是通过这个LinkedHashMap来操作的,LinkedHashMap的底层维护了一个hash表和一个双向链表。每个插入的元素节点都有一个before和after属性。

image-20210730135656981

实战:
LinkedHashSet<Integer> sets = new LinkedHashSet<>();
sets.add(1);
sets.add(12);
sets.add(15);
sets.add(14);
sets.add(12);
sets.forEach(System.out::println);              

输出结果:

image-20210730135810117

我们可以到,输出结果是有序的,按照输入顺序输出出来了。这就是它跟HashSet最大的区别。

它能保证有序的最大功臣,就是底层维护的双向链表了,当我们add()元素的时候(对于LinedHashMap来说就是put操作),会先计算元素的hash值,然后计算出它的索引,确定在元素中的位置,然后将元素put到hash表中,这是我们常说的hashMap的put过程。

但是,HashMap源码中设置了这三个回调函数,注释说明了允许LinkedHashMap使用。也就是通过这三个我们来将元素插入到链表中,这样的话我们就保证了在看起来,输入和输出是一致的。

image-20210730135855515

TreeSet

TreeSet是一个有序的集合 有序 有序 有序 重要的事情说三遍!而且它默认是升序的,对于数字就是比较大小,对于字符串就是比较首字母,其他的类型则需要自己实现Comparable接口。

先看实战代码
TreeSet<Integer> set = new TreeSet<>();
sets.add(5);
set.add(7);
set.add(2);
set.add(13);
set.forEach(System.out::println);

输出结果:

image-20210730140205654

呐,看到了吗?鲁迅先生都说过:实践是检验真理的唯一标准,你要是不相信,那我就带你看看源码。

image-20210730140221347

看 底层其实是使用的TreeMap来存储元素的,TreeMap也是有序的,底层使用红黑树。红黑树天然支持自然排序。

同时TreeSet支持传入一个Comparator,这意味着我们可以通过创建排序器来自定义排序规则。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pf8B6OtU-1627631753520)(C:/Users/DD-28/AppData/Roaming/Typora/typora-user-images/image-20210730140315843.png)]

//从大到小
TreeSet<Integer> sets = new TreeSet<>((o1, o2) -> o2 - o1);
sets.add(5);
sets.add(7);
sets.add(2);
sets.add(13);
sets.forEach(System.out::println);              

输出结果:

image-20210730141428518

现在我们知道了TreeSet是有序的了,那么,究竟它是如何实现有序的呢?

刚才既然都说了,TreeSet底层使用的是TreeMap,当然,add()操作也是用的map的put操作了。

image-20210730141559112

呐,捋一下逻辑哈,如果树为null的话,就会构建一个TreeMapEntry并且将它设置为root根。

image-20210730141616924

然后检查TreeMap有没有设置构造器,如果设置了就用它去对比key,如果没设置就用k.compareTo()去进行比较,注意了,put的时候,key不允许为null,否则会报空指针。

如果遍历树没有找到节点,就会通过new Entry<>(key, value, parent)去创建一个节点,然后扔到树上。

image-20210730141819329

通过对比结果去判断放到父类的左节点还是右节点。

image-20210730142158581

放完后会通过fixAfterInsertion(e)对节点进行操作,保证插入后还是一颗红黑树。

image-20210730142216790

这一块的代码还是比较重要的,这个地方会去校验所有关于红黑树的规则。

扩展一点点红黑树知识

红黑树是一种含有红黑结点并能自平衡的二叉查找树。

image-20210730144558274

我们把正在处理(遍历)的结点叫做当前结点,如图2中的D,它的父亲叫做父结点,它的父亲的另外一个子结点叫做兄弟结点,父亲的父亲叫做祖父结点。

红黑树的特性:

性质1:每个节点要么是黑色,要么是红色。

性质2:根节点是黑色。

性质3:每个叶子节点(NIL)是黑色。

性质4:每个红色结点的两个子结点一定都是黑色。

性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点。

Queue(队列)

定义

队列是一种特殊的线性表,它只允许在表的前端进行删除操作,而在表的后端进行插入操作。先进先出。

image-20210730145546970

先思考一个问题,为什么我们要使用队列?

队列其实说到底只是一种数据结构,它不依赖于任何业务,但可以适用于任何业务,像我们常用的MQ,底层也是用到了队列,队列更多的是一种思路,为我们提供了一种先进先出的方式,我们可以用这种特性来约束功能,例如我们可以用它来做排队功能,先进先出。可以适用于绝大部分生产者消费者的情景。

站在整体架构看队列家族谱

image-20210730145742684

Queue家族还是很庞大的,总整体来看,分为三类:Deque(双端队列)、BlockingQueue(阻塞队列)、AbstractQueue(非阻塞队列)

如果每个都铺开讲的话,估计打底两篇文章的篇幅,太多了,我会讲下每种队列的含义和应用并且找其中用的比较多的进行讲解,其余的大家自行www.google.com

AbstractQueue(非阻塞队列)

定义

顾名思义,非阻塞,队列中不会出现阻塞情况,进行任何操作,如果未达到预想,会直接返回null,而不是像阻塞队列那样停在那里,非阻塞队列想实现阻塞效果,需要使用wait/notify来实现。

ConcuretnLinkedQueue(无界的线程安全的非阻塞队列)

类图:

image-20210730150157151

由类图我们看出,ConcurrentLinkedQueue是由head和tail节点组成的,而每个节点(Node)由Item(节点元素)和Next(指向下一个节点的引用)组成,正是通过这个next将队列变成了一个链表。

来,废话不多说,讲下实际应用

ConcurrentLinkedDeque<Integer> queue = new ConcurrentLinkedDeque<>();
for (int i = 0; i < 20; i++) {
    queue.add(1+i);
    System.out.println("我放进去了一个元素:"+(1+i));
} 
System.out.println("队列中的一个元素是:"+queue.peek());
System.out.println("我取出了一个元素" + queue.poll());
//在队首放入一个元素
queue.addFirst(0); 
//在队尾放入一个元素
queue.addLast(66);
System.out.println(queue);              

API:

image-20210730150327967

有兴趣的可以去看下它的底层源码,它使用了大量的cas思想来保证线程安全。

image-20210730150401155

BlockingQueue(阻塞队列)

阻塞队列用的是很多的,例如在线程池中,就会使用到阻塞队列,处理不了的线程会放到阻塞队列中排队等待。而且JDK中很多关于多线程的解决方案中,都会使用BlockingQueue来做

定义

BlockingQueue 方法以四种形式出现,对于不能立即满足但可能在将来某一时刻可以满足的操作,这四种形式的处理方式不同:第一种是抛出一个异常,第二种是返回一个特殊值(null 或 false,具体取决于操作),第三种是在操作可以成功前,无限期地阻塞当前线程,第四种是在放弃前只在给定的最大时间限制内阻塞。

ArrayBlockingQueue

基于数组的有界队列,创建的时候需要指定大小

image-20210730150536565

如果想要抛出异常:

queue.add();

如果添加的元素数量超过了队列长度,会抛出Queue full的异常

ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);
for (int i = 0; i < 5; i++) {
    queue.add(1+i); 
}
for (int i = 0; i < 5; i++) {
    queue.remove(1+i); 
}              

queue.remove();

如果队列已经空了 还执行remove操作会报NoSuchElement的异常

如果想要不 抛出异常:

添加元素的时候使用queue.offer(); //这样如果队列满了的话就会返回false 不会抛异常

移除元素的时候使用queue.poll(); //这样如果队列中空了话就会返回null 不会抛出异常。

ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);
for (int i = 0; i < 5; i++) {
    boolean offer = queue.offer(1 + i);
    System.out.println("我放入成功了吗?:" + offer);
}
for (int i = 0; i < 6; i++) {
    Integer poll = queue.poll();
    System.out.println("我从队列里拿出的值:"+poll); 
}              

如果想要满了之后一直阻塞:

queue.put();//添加 如果满了之后会阻塞在最后一个添加的满了元素的位置。

queue.take();//移除 如果没有了会一直阻塞在最后一个移除的元素的位置。

ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);
for (int i = 0; i < 5; i++) {
    queue.put(1 + i); 
    System.out.println("我放入了值:"+(1+i)); 
} 
//再试一下i<2的情况
//..... 
for (int i = 0; i < 6; i++) {
    Integer take = queue.take();
    System.out.println("我取出了值:" + take); 
}           

设置阻塞超时等待:

queue.offer(“a”,2,TimeUnit.Seconds) 代表添加元素a时如果超时两秒钟 就超时返回false

SynchronousQueue

它本质上并不是一个队列,因为他没有给元素保留任何的存储空间,而是维护了一份线程清单,也就是说一个线程进入后,它就被从清单中移除,除非这个线程出去,否则下一个是永远无法进入队列的,会一直阻塞,所以这种队列只适用于消费者很充足的时候。

SynchronousQueue<Integer> queue = new SynchronousQueue<Integer>();
Thread putThread = new Thread(new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("我放进去了一个值");
            queue.put(1);
        } catch (InterruptedException e) { 
        }    
    } 
}); 
Thread putThread1 = new Thread(new Runnable() {
    @Override
    public void run() { 
        try { 
            queue.put(1); 
        } catch (InterruptedException e) { 
        }        
        System.out.println("我放进去了一个值");    
    } 
}); 
Thread takeThread = new Thread(new Runnable() {
    @Override 
    public void run() {
        try { 
            queue.take(); 
            System.out.println("好了,我出来了"); 
        } catch (InterruptedException e) {     
            e.printStackTrace();     
        }    
    } 
}); 
putThread.start(); 
putThread1.start();
takeThread.start(); 
Thread.sleep(1000);        
DelayQueue(延时队列):无界的阻塞队列

用于放置实现了Delayed接口的对象,这些对象只有在设置的过期时间到期后才能取出,DelayQueue是有序的,不能将null元素放到这个队列中。

它能做什么?
  1. 淘宝订单业务:下单之后如果三十分钟之内没有付款就自动取消订单。

  2. 饿了吗订餐通知:下单成功后60s之后给用户发送短信通知。

    3.关闭空闲连接。服务器中,有很多客户端的连接,空闲一段时间之后需要关闭之。

    4.缓存。缓存中的对象,超过了空闲时间,需要从缓存中移出。

    5.任务超时处理。在网络协议滑动窗口请求应答式交互时,处理超时未响应的请求等。

它的使用步骤会比普通的队列实现繁琐些,需要重写其中的方法:

        public class DelayQueueTest {
            public static void main(String[] args) throws InterruptedException {

                DelayQueue<Delayed> queue = new DelayQueue<>();
                queue.offer(new TestDelayTask("刘国华",10));
                queue.offer(new TestDelayTask("吴彦祖",2));
                queue.offer(new TestDelayTask("哈哈哈",3));

                while (true){
                    Delayed take = queue.take();
                    System.out.println(take.toString());
                }
            }

        }
/**
 *延时队列实现类
 */
        class TestDelayTask implements Delayed {

            private String name;
            private long time;
            private long startTime = System.currentTimeMillis();

            public TestDelayTask(String name,long time){
                this.name = name;
                this.time = time;
            }

            /**
             * 设置延时时间  过期时间-当前时间
             * @param unit
             * @return
             */
            @Override
            public long getDelay(TimeUnit unit) {
                return unit.convert((startTime+time) - System.currentTimeMillis(),TimeUnit.SECONDS);
            }

            /**
             * 延迟队列内部比较排序
             * @param o
             * @return
             */
            @Override
            public int compareTo(Delayed o) {
                TestDelayTask o1 = (TestDelayTask) o;
                return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.SECONDS));
            }

            @Override
            public String toString() {
                return "TestDelayTask{" +
                        "name='" + name + '\'' +
                        ", time=" + time +
                        '}';
            }
        }

Deque

Deque全名 Double Ended Queue 即双端队列, 顾名思义,就是队列的队列的两端都可以进行插入或移除元素,这就是它最大的优势,可以当做队列使用,也可以当做栈使用(从头部插入,这样就是后进先出了)。

这种队列有什么好处呢?

假设我们做了一个排队购票的功能,每个人就是一个元素,有序的进入队列,先进去的人先买票(从实际开发角度来说就是通知消费者端进行购票业务处理),一切看起来美好且安详。但是,如果中间有个人要走嘞?怎么办,如果用普通的队列,需要从队尾开始遍历,然后取出,这时间复杂度就是O(n)了,但是如果我使用双端队列,我是可以从队尾或者队列来取的,这效率就很高了,对吧。

实战

image-20210730152023391

ArrayDeque<Integer> deque = new ArrayDeque<>(5);
//add方法默认就是调用addLast();
deque.add(1);
//后进先出
deque.addFirst(0);
Integer poll = deque.poll();
System.out.println(poll);
Deque的著名应用

Fork/Join(并行执行框架)

这个框架是JDK7提供的,可以把一个大任务拆分成多个小任务,终汇总每个小任务结果后得到大任务结果。

双端队列在里面的作用是什么呢?

所有fork的子任务都会放到自己的双端队列中,然后启动几个线程从队列里面拿任务执行,子任务执行完后的结果都会放到另外一个队列里,然后再启动一个线程,从这个队列里取数据进行合并。如果这个过程中,某个线程完成了自己双端队列中的任务,就可以去其他队列的队尾偷任务来做(随机),这种算法就是工作窃取算法。这个算法的作用就是为了合理分配每个线程的任务量。

那为什么非得要用双端队列?我用阻塞队列不香吗?

想想双端的队列的特点是什么?可以从队列两头拿数据对吧,假设一下,如果我用阻塞队列来做,元素只能从队首出来,那我的任务做完了,我想去偷你的做,但我只能去队首偷,诶,巧不巧,正好你也要拿队首的这个任务去执行,那我不就被逮个正着?多尴尬。所以,使用双端队列就是为了避免这种情况,从队尾来拿,简单一句话概括:避免线程资源竞争。

队列使用场景

如果不需要阻塞队列,优先选择ConcurrentLinkedQueue;

如果需要阻塞队列,队列大小固定优先选择ArrayBlockingQueue,队列大小不固定优先选择LinkedBlockingQueue;

如果需要对队列进行排序,选择PriorityBlockingQueue;

如果需要一个快速交换的队列,选择SynchronousQueue;

如果需要对队列中的元素进行延时操作,则选择DelayQueue。

Map

map主要是用来存储元素对(KV键值对),每个键映射一个值,时间复杂度是O(1)。根据key可以查询到对应的Value。

像我们常用的缓存,也是使用了Map的存储结构。

再重新温习一下Map的家族图谱

image-20210730152222385
Map家族人丁其实也挺兴旺的,其中HashMap、LinkedHashMap、ConcurrentHashMap更是被人津津乐道,实际开发中很多地方也用到了这几个,下面我会一一介绍下。

HashMap

基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 HashTable 大致相同,所以后面关于HashTable介绍就一带而过了。)

HashMap的底层是基于数组+链表的,在JDK8以后加入了红黑树结构,当链表长度大于8,桶的数量大于64,链表会转换为红黑树来提升性能(如果链表长度大于8,但是数组大小小于64,会进行数组动态扩容)。

进入实战之前,请允许我先来解释下为什么要用这样的结构以及为什么要引入红黑树?

刚才我们也说过了,HashMap是基于hash表的,也就是说,当我们put值的时候,会对key值进行hash运算,然后取模拿到它在hash桶中的坐标放进去,存储到数组中。

image-20210730152308227
大概就是这样婶儿滴~

横着的是数组,竖着的是链表。

其实从理想角度来说,每个值都应该是均匀分布在数组里的,但是,毕竟现实是hien残酷的,虽然在put的时候加入了扰动函数,但是还是避免不了出现hash碰撞(两个key的hash结果相同),比如一个name = ‘刘国华’ 一个name =‘吴彦祖’ 诶,这俩人长得一样!(比喻手法!)这时候,就需要用链表来存储了,将重复的key存入到同一个数组下标的链表中。

但是我们都知道链表的查询时间复杂度是O(N),链表一旦过长,就会影响hashMap的效率,所以才有了当链表长度到8的时候,链表会转换为红黑树。至于为什么是8,来我们看下官方给出的解释:

image-20210730152323165

长度大于8的概率是相当相当低的,所以就使用了8,这应该也是概率学的角度来确定。

HashMap的默认大小是16。它有一个加载因子的概念(就是扩容阈值),这个阈值怎么计算呢?

例如我们使用默认的大小16和默认的加载因子0.75,那么大小*扩容阈值= 12 也就是当存放了12个元素后,会触发扩容,扩容为原来的2倍。

image-20210730152514834
image-20210730152407646
课外小知识:扩容在JKD8之前和8之后是不一样的,JDK8之前插入数据用的是头插法,但是头插法在并发情况下可能会造成死链,所以在JDK8以后就改为了使用尾插法。

**JDK7使用头插法是因为考虑到热点数据的问题,就是说我新插入的数据是最有可能被用到的,所以就直接将数据插到头部了。**但这其实是个伪命题,因为JDK7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(就是因为头插) 所以最后的结果 还是打乱了插入的顺序 所以总的来看支撑JDK7使用头插的这点原因也不足以支撑下去了 所以就干脆换成尾插 一举多得。

实战操作
HashMap<String, Object> map = new HashMap<>();
//将元素插入到map中
map.put("key1","1");
map.put("key2","2");
map.put("key3","3");
System.out.println(map);
//根据key拿到对应的Value值
System.out.println(map.get("1"));
//map中是否包含这个key
System.out.println(map.containsKey("11111"));
//拿到所有的key
System.out.println(map.keySet());
//如果不存在这个key就插入
map.putIfAbsent("key1",6666666);
map.putIfAbsent("key4",6666666);
System.out.println("最新的map:"+map);

LinkedHashMap

定义

LinkedHashMap其实我们在上面将LinkedHashSet的时候,已经大体讲过了,LinkedHashMap是HashMap的一个子类,它的存在就是为了解决HashMap无序的情况,在基于哈希表的基础上维护了一个双向链表,来保证元素的顺序性。所以说:LinkedHashMap = HashMap+双向链表缺点就是额外维护双向链表的性能开销了。

LinkedHashMap直观结构图

image-20210730152624350

是不是懵圈了?? 别慌,老弟给你极限分析一波:

我们都知道啊,HashMap的结构是数组加链表,所以用图表示出来就是这样的:

image-20210730152637715

那么,上面我们也说过了,LinkedHashMap是在HashMap的基础上,额外维护了一个双向链表,那么,我们就可以用下图表示:

image-20210730152647676

呐,基于HashMap结构的基础上,在每个entry节点上维护了一个before和after,分别指向上一个和下一个,以此实现双向关联。

再回过头看上面的图,竖向的是一个数组,我分别在数组的1 3下标插入了三个元素,元素A、B、C 插入的位置各不相同,但是仔细看,三个元素分别用红黄线指向了下一个元素,每个元素都用before连接上一个元素,用after连接下一个元素, 这样就实现了顺序读写。

注意:看到右下角的header了吗,这个是默认存在的,因为插入的一个节点需要有一个默认指向的before。

LinkedHashMap的实战的话,就不多再演示了,因为继承了HashMap,所以操作相关API和HashMap是一致的。

HashMap<Integer, Integer> map = new HashMap<>();
LinkedHashMap<Integer, Integer> linkedHashMap = new LinkedHashMap<>();
map.put(1,111);
map.put(22,222);
map.put(33,333);
linkedHashMap.put(1,111);
linkedHashMap.put(22,222);
linkedHashMap.put(33,333);
System.out.println("map的输出结果:"+map);
System.out.println("linkedHashMap的输出结果:"+linkedHashMap);

IdentityHashMap

定义

这个map的话在平时开发中其实是很少用到的,但是在一些JDK源码中是可以看到它的身影的,它是一个比较特殊的map。

它有一个很有意思的特性,就是当比较两个元素是否相等的时候,并不会调用compare方法,而是直接使用==进行比较,去比较两个对象的引用,也就是说,两个对象必须完全一样才可以。

image-20210730152733772

这个特性一般被用在序列化、浅拷贝等拓扑保存对象图的转换中。要进行这样一个转换,程序必须保存节点表来追踪所有的已经产生了的对象引用。节点表不能将不同的对象看成是一样的,哪怕其值相等。另一个典型的运用就是保存代理对象。例如,调试工具可能希望为正在调试的程序中的每个对象维护一个代理对象。

TreeMap

定义

TreeMap是一个实现了默认自动排序的map结构,底层是用到了红黑树(红黑树结构天然支持排序),默认情况下通过key值的自然排序进行排序(字符串按照首字母,数字按照从小到大)。当然也可以使用集合中自定义的比较器来进行排序。

TreeMap的特点

1.key不允许重复。

2.允许出现null值。

3.可以对元素进行自定义排序。

4.无序集合

实战
//自然排序   
TreeMap<String, Object> map = new TreeMap<>();
map.put("a",1);
map.put("e",1);
map.put("c",1);
map.put("d",1);
map.put("b",1);
map.put("f",1);
System.out.println("map自然排序结果:"+map);

//自定义排序
TreeMap<String, Object> map1 = new TreeMap<>(Comparator.reverseOrder());
map1.put("a",1);
map1.put("e",1);
map1.put("c",1);
map1.put("d",1);
map1.put("b",1);
map1.put("f",1);
System.out.println("map自定义排序结果:"+map1);

WeakHashMap

定义

WeakHashMap继承AbstractMap,实现了Map接口。和HashMap一样,WeakHashMap也是一个散列表,它存储的内容也是KV键值对,而且K和V都允许为null。

不过它和HashMap最最最大的不同就是,它是一个弱引用的(准确的说是它的元素)。就是说,只要发生GC,这个元素就会被回收掉。

呐,我可能要给大家加餐扩充一下关于引用相关的内容了(就亿点点,别担心)

Java目前有四大引用类型:强、软、弱、虚。引用级别是直接和JVM内存管理挂钩的。

1.强引用:如果一个对象具有强引用,那么它就是一个钉子户了,什么情况下它都不会被当作垃圾回收掉,就算是内存空间满了,JVM宁愿抛出OutOfMemoryError,也不会去回收它。比如String str = “hello world”; str就是一个强引用。

2.软引用:当内存足够用的时候,它是不会被GC掉的,但是一旦内存不够了,就会毫不犹豫的把它回收掉。(软引用:终究还是错付了~~~~~)

3.弱引用:不管内存够不够用,只要发生GC,弱引用的对象都会被回收掉(有且仅有弱引用)。

4.虚引用:虚引用其实可以理解为无引用,任何情况下都可能会被回收,使用它的目的就是为了知道对象什么时候被回收,这样就可以在回收前做一些操作,比如资源释放等。

好了,了解了Java的引用类型,我们再来看WeakHashMap,既然它是弱引用,那就说明它的元素随时可能会被回收,这意味着什么,我执行同一个代码,可能会得到不同的两个结果,比如调用map.size(),可能第一次返回的是100,第二次可能就是95了。

那么既然这玩意随时可能都会消失,那我用它干嘛?总不能我前脚刚存进去的东西,你后脚就给我回收了吧?

想想什么机制不需要持久化?没错,缓存。缓存是基于内存的,所以空间是很有限的,如果对于缓存结构我们使用普通的map,那么这个map的生命周期就太长了,不容易被回收,对象多了容易造成内存溢出。所以我们可以利用WeakHashMap的弱引用特性,来实现本地缓存等。

注意:只有弱引用的对象并不会像我们想象中那么快的被回收,因为GC的线程优先度是最低的,所以可能需要一段时间才会被找到并回收掉。

ConcurrentHashMap

定义

这个其实是今天的一个重头戏,所以我把它放在了最后,它的重要程度和HashMap不相上下。ConcurrentHashMap是一个线程安全的Map,内部使用合理的锁机制完美解决了HashMap线程不安全的问题。

先说一下线程安全的几种方式

1.使用HashTable

HashTable和HashMap的区别就是重要的方法都加了synchronized锁

image-20210730153057295

这种方式的缺点很明显,就是锁的粒度太粗了,在并发量较大的情况下会出现严重的锁竞争问题,影响性能。

2.使用Collections.synchronizedMap();

image-20210730153110099

其实也是给对象本身加了一把锁,性能很低,和HashTable差不多。

3.使用我们今天的主角,ConcurrentHashMap。

ConcurrentHashMap是JUC包下的一个线程安全容器,它的设计在JDK8前后是不一样的,所以我会分成两部分来讲:

JDK8之前

image-20210730153146820

底层使用了锁分段的Segment来做并发安全,每个Segment各自持有一把锁,高度自治,互不影响,而且锁的读写是分开的,不会出现阻塞,理论上来说,能存放多少个Segment就能支持多大的并发数(默认是16)

image-20210730153212099

这个参数为ConcurrentHashMap的默认并发度,也是Segment数组默认的大小。

image-20210730153236268

从整体来说,ConcurrentMap就是由一个Segment数组组成,每个Segment里又有一个entry数组,其实就等于是将HashMap的结构套了一层。变成了一个二维的结构。

Get

1.对key进行hash运算,得到hash值。

int hash = spread(key.hashCode()); 

static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS; 
}              

2.通过hash结果,定位到Segment对象。

3.再次通过hash值,定位到Segment当中数组的具体位置。

Put

1.对key进行hash运算,得到hash值。

2.通过hash值,定位到Segment对象。

3.获取可重入锁。

4.再通过Hash值,定位到Segment对象中数组的具体位置。

5.插入/覆盖HashEntry对象。

6.释放锁

问题

每个Segment都是独立管理的,那么如果我调用size(),如何保证我统计的结果是对的?如果统计第二个Segment的时候第一个Segment又插入数据了呢?

public int size() {
    //当前segements数组
    final Segment<K,V>[] segments = this.segments;
    int size;
    //如果大小溢出32位,就是true
    boolean overflow;
    //修改次数
    long sum;
    //上一次的修改次数
    long last = 0L;   // previous sum
    //记录重试次数
    int retries = -1;
    try {
        for (;;) {
            //如果重试次数 == 最大容忍次数   RETRIES_BEFORE_LOCK默认为2  也就是说可以重试2次
            if (retries++ == RETRIES_BEFORE_LOCK) {
                //对每一个semengt对象上锁
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock();
            }
            //初始化
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {

                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    //统计修改次数
                    sum += seg.modCount;
                    //统计seg元素的大小
                    int c = seg.count;
                    //如果元素大小小于0或者总数相加后<0 代表溢出
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            //如果这次的修改次数等于上次的统计次数 说明没有被修改过 直接跳出  统计结束
            if (sum == last)
                break;
            //否则就将这次的修改统计次数变成最后一次 然后再循环 直到break;
            last = sum;
        }
    } finally {
        //如果是加锁了  那么就解锁
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

其实源码里已经讲的很清楚了,但是作为暖男的我还是再整理一下内容:

1.遍历所有的Segment。

2.把Segment的元素数量和修改次数都累计出来。

3.判断所有Segment的总修改次数是否等于上一次的修改次数,如果等于,就直接退出,否则就继续循环计算。(每次进入循环重试次数都会+1)

4.如果重试了两次还是无法统一结果的话,第三次就会给整个segments数组上锁,然后统计。

5.统计完成后再判断修改次数是不是相同,当然,由于加锁了,结果肯定是true了,所以就直接跳出循环,统计结束。

6.解锁。

JDK8之后

到了JDK8以后,Segment的概念就被弃用掉了,取而代之的是使用synchronized和CAS来保证并发安全性。将HashEntry替换为了Node,但是作用还是相同的。

image-20210730153508386

JDK8之前没有解决查询链表效率太低的问题,在1.8的时候会判断链表长度,超出指定长度链表会转为红黑树,和HashMap1.8相似。

不同的地方是,如果转换位红黑树,Node节点会变为TreeBin节点,而不是像HashMap那样直接转换为红黑树的根节点;然后TreeBin节点会指向树的根节点。TreeBin中封装了红黑树的root根以及链表指向。而且封装了对于红黑树的细节锁内容,可以实现更加细的锁功能。

PUT

对key进行hash计算,根据hash值定位到桶坐标,判断node是否需要进行初始化,然后判断:①如果为空则使用CAS进行插入 ②如果为-1则说明在扩容,就跟着一起扩容 ③否则的话就用synchronized加锁put

GET

和1.7相同,因为value用volatile修饰了,保证了可见性,所以不需要加锁。

为什么ConcurrentHashMap1.8之后不用Segement分段锁改用Sync+CAS了?

先看下官方和JDK源码中开发人员是如何解释的:

1.加入多个分段锁浪费内存空间。

这个不解释,将空间分为很多段之后,会造成内存空间的不连续以及产生过多的内存碎片。

2.生产环境中,map在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。

3.为了提高GC的概率。

其实值得一提的是,Synchronized一直都被定义为一个重量级锁,但是JDK开发人员其实一直都没有放弃怼Sync锁的优化,特别是JDK1.5后,Sync锁有了一个锁升级的过程,并不是一开始就是重量级的锁。

一开始的时候是无锁状态,被触发后会变成一个偏向锁,当前获取到锁资源的线程,会优先让他再去获取锁,如果这个线程没有获取到,就会升级为一个轻量级的锁,CAS的乐观锁,如果这个CAS连续没有设置成功的话,就会不断进行自旋,自旋到一定的次数之后,就会升级成重量级锁。

而且JDK8之前使用的Segment锁分段技术,本质上是使用的ReetrantLock锁,它是API级别的,而Sync锁是JDK级别的,从本质上来说,Sync拥有更大的优化空间(虽然ReetrantLock锁更加灵活)。

结尾

看完这篇文章,我就不相信Java容器这块你还有什么明白的?不明白就再回去看一遍!!!!要是还看不明白,那可能就是我的问题了,欢迎随时私聊或评论交流哦。

人世仙家本自殊,何须相见向中途。惊鸿瞥过游龙去,漫恼陈王一事无。我是洛神,我们下期间啦。

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值