多线程(七) --- 多线程进阶
目录
1.锁的策略
1.1 乐观锁 VS 悲观锁
1.2 轻量级锁 VS 重量级锁
1.3 自旋锁 VS 挂起等待锁
1.4 普通互斥锁 VS 读写锁
1.5 公平锁 VS 非公平锁
1.6 可重入锁 VS 不可重入锁
2.synchronized的内部工作原理
2.1 偏向锁阶段
2.2 轻量级锁阶段
2.3 重量级锁阶段
2.4 锁消除
2.5 锁粗化
3.CAS
3.1 CAS的实际应用
3.2 CAS的ABA问题
4.JUC
4.1 Callable接口
4.2 ReentrantLock
4.3 Semaphore(信号量)
4.4 CountDownLatch
4.5 线程安全的集合类
5.小结
多线程初阶的知识我已经全部介绍完毕,接下来,我们来学习多线程进阶的相关知识。多线程进阶以面试题为主,涉及到一些内容工作中很少用到,但是面试会考,因此我们还是需要了解。
光学补偿 (wangjunyi6526) - Gitee.com这是我本人的码云,其中包括一些Java和C语言的代码,大家感兴趣可以关注一下!
1.锁的策略
加锁过程中,处理冲突的过程中,涉及到的一些不同的处理方式。此处的锁策略,并非是Java独有的,重点是理解一些相关的概念。
1.1 乐观锁 VS 悲观锁
这是两种不同的锁的实现方式。
乐观锁指的是,在加锁之前,预估当前出现锁冲突的概率不大,因此在加锁的时候就不会做太多的工作。在加锁过程中做的事情比较少,加锁的速度可能就更快,但是更容易引入一些其他问题(但是可能会消耗更多的CPU资源)。
悲观锁指的是,在加锁之前,预估当前锁冲突出现的概率比较大,因此在加锁的时候,就会做更多的工作。做的事情更多,加锁的速度可能更慢,但是整个过程中不容易出现其他问题。
举个栗子🌰:
之前疫情的时候,动不动就要封小区。
当疫情出现的时候,我和老妈就会有不同的判断:
老妈就会比较乐观,觉得大概率我们小区不会封,也就不会做太多的准备。
我就属于比较悲观,我觉得我们小区被封的可能性很大,就会做很多准备(屯一些物资)。当真的封小区的时候,就可以更从容。
1.2 轻量级锁 VS 重量级锁
轻量级锁,加锁的开销小,加锁的速度更快。因此,轻量级锁,一般就是乐观锁。
重量级锁,加锁的开销更大,加锁速度更慢。因此,重量级锁,一般就是悲观锁。
轻量和重量,是加锁之后,对结果的评价。悲观和乐观,是加锁之前,对未发生的事情进行的预估。整体来说,这两种角度描述的是同一个事情。
锁的核心特性"原子性",这样的机制追根溯源是CPU这样的硬件设备提供的:
CPU提供了"原子操作指令".
操作系统基于CPU的原子指令,实现了mutex 互斥锁.
JVM基于操作系统提供的互斥锁,实现了synchronized 和ReentrantLock等关键字和类.
注意,synchronized并不仅仅是对mutex进行封装,在synchronized内部还做了其他的工作。
重量级锁,就是在加锁机制重度依赖了OS提供的mutex,这就导致了大量的内核态用户态切换,很容易引发线程的调度。
而轻量级锁,在加锁机制中尽可能步使用mutex,而是尽量在用户态代码中完成,实在搞不定了,再使用mutex。这就引起少量的内核态用户态切换,不太容易引发线程调度。
1.3 自旋锁 VS 挂起等待锁
自旋锁,就是轻量级锁的一种典型实现。进行加锁的时候,搭配一个while循环,如果加锁成功,循环自然就会结束。如果加锁不成功,不是阻塞放弃CPU,而是进行下一次循环,就会再次尝试获取到锁。
这个反复执行的过程,就称为“自旋”。一旦其他线程释放了锁,就能第一时间拿到锁。同时,这样的自旋锁,也是乐观锁。使用自旋的前提,就是预期锁冲突概率不大,其他线程释放了锁,就能第一时间拿到。如果当前加锁的线程特别多,自旋的意义就不大了,白白浪费CPU了。
挂起等待锁,就是重量级锁的一种典型表现。进行挂起等待的时候,就需要内核调度器介入了,这一块要完成的操作就多了,真正获取到的锁要花的时间也就多一些了。挂起等待锁同时也是一种悲观锁,这个锁可以适用于锁冲突激烈的情况。
举个栗子🌰:
假设张三同学打算追自己的女神,每天都会向女神问候早安、午安和晚安。
有一天,张三向女神表白:女神女神,你做我女朋友好不好(尝试加锁)
女神给了张三一个否定的回答:滚 ~
被女神拒绝之后有两种处理方式:
1.张三放弃了,从此以后再也不联系女神了。
2.张三仍然坚信一个道理,“只要锄头挥的好,没有墙角挖不倒”,仍然每天向女神问候早安、午安和晚安,时不时再来表白一次。
对于第一种处理方式,就相当于进入了阻塞等待,张三就把CPU让出来就可以安心学习了。(嘴上说再也不联系了,但是真能做到嘛?)如果某一天张三通过其他途径听说了女神分手了,那么张三的心思又活跃起来了,情不自禁的又来找女神了,又尝试对女神进行加锁了。
对于第二种方式,就相当于是自旋锁。当然这种情况,一旦女神分手了,张三的机会就来了!就有很大可能性乘虚而入,一举加上锁。这种方式加锁消耗的时间就比较短,这边一释放,张三就立即加上去。但是缺点是比较消耗CPU,每天都得花时间和女神交流(导致张三这边没有信息干别的事情)
1.4 普通互斥锁 VS 读写锁
这里的普通互斥锁,类似于synchronized,操作涉及到加锁和解锁。
这里的读写锁,把加锁分成了两种情况:
1)加读锁
2)加写锁
读锁和写锁是有规律的:
读锁和读锁之间,不会出现锁冲突(不会阻塞)
写锁和写锁之间,会出现锁冲突(会阻塞)
读锁和写锁之间,会出现锁冲突(会阻塞)
此外, 一个线程加读锁的时候,另一个线程只能读,不能写;一个线程加写锁的时候,另一个线程不能写,也不能读。
那么,为什么要引入读写锁呢?
如果两个线程读,那么本身就是线程安全的,不需要进行互斥!
如果使用synchronized这种方式加锁的的,两个线程读,也会产生互斥,产生阻塞(对于性能有一定的损失)。
完全给读操作不加锁,也不行,就怕一个线程读,一个线程写,可能会读到写了一半的数据。
因此引入读写锁,就可以很好的解决上述问题。
1.5 公平锁 VS 非公平锁
公平锁和非公平锁,和前面介绍的“线程饿死”有点关系。
那么,什么叫做公平呢?这里存在不同的定义:先来后到,是一种公平;每个人等概率竞争,也未尝不是公平。
注意,此处定义的“公平”,遵循先来后到,才叫公平!
站在系统原生的锁的角度,就属于是“非公平锁”,系统线程的调度本身就是无序且随机的。上一个线程释放锁了之后,接下来唤醒哪个线程,不好说。
Java中的synchronized也是非公平的。要想实现非公平锁,就需要引入额外的数据结构(引入队列,记录每个线程的先后顺序),才能实现公平锁。使用公平锁,可以避免线程饿死的问题。
1.6 可重入锁 VS 不可重入锁
如果一个线程针对这一把锁连续加锁两次,不会死锁,就是可重入锁。会死锁,就是不可重入锁。
synchronized就是可重入锁,而系统自带的锁,是不可重入锁。可重入锁需要记录持有锁的线程是谁,加锁的次数计数。
上述“锁策略”就是名词解释,针对这些词,有一个概念上的认识即可。
2.synchronized的内部工作原理
接下来,我们来看synchronized内部的工作原理。synchronized内部优化的非常好,大部分情况下使用synchronized都是不会有什么问题的。
它具有以下特性:
1.乐观锁/悲观锁自适应
2.轻量级锁/重量级锁自适应
3.自旋锁/挂起等待锁自适应
4.不是读写锁
5.非公平锁
6.可重入锁
我们需要重点掌握synchronized的加锁过程,尤其是它的“自适应”是怎么实现的。
当一个线程执行到synchronized的时候,如果这个对象处于未加锁的状态,就会经历以下过程:
2.1 偏向锁阶段
(假设没有线程来竞争)
偏向锁的核心思想,就是“懒汉模式”:能不加锁,就不加锁;能晚点加锁,就晚点加锁。所谓偏向锁,并非是真的加锁了,而只是做了一个非常轻量的标记。
举个栗子🌰:搞暧昧,就是偏向锁。只是做一个标记,没有真正加锁(也不会有互斥)。一旦有其它线程来和我竞争这个锁,就会在另一个线程之前,先把锁获取到。这时,偏向锁就会升级到轻量级锁了(真加锁了,就有互斥了)。如果我搞暧昧的过程中,没人来竞争,整个过程就把加锁这样的操作给完全省略了。
这就是“非必要不加锁。在遇到竞争的情况下,偏向锁没有提高效率,但是如果在没有竞争的情况下,偏向锁就大幅度的提高了效率。总的来说,偏向锁的意义还是很大的。
2.2 轻量级锁阶段
(假设有线程竞争,但是不多)
此处就是通过自旋锁的方式来实现的。轻量级锁的优势在于,其他的线程把锁释放了,此线程就会第一时间拿到锁。劣势在于,比较消耗CPU。
与此同时,synchronized内部也会统计当前这个锁对象上,有多少个线程在参与竞争。一旦这里发现参与竞争的线程比较多了,就会进一步升级到重量级锁。
对于自旋锁来说,如果同一个锁的竞争者很多,大量的线程都在自旋,那么整体CPU的消耗就很大了。
2.3 重量级锁阶段
当参与锁竞争的线程多了起来,此时拿不到锁的线程就不会继续自旋了,而是进入“阻塞等待”,就会让出CPU了。(不会使CPU占用率太高)
当当前线程释放锁的时候,就由系统随机唤醒一个线程来获取锁了。
2.4 锁消除
锁消除,也是synchronized中内置的优化策略。
它是编译器优化的一种方式,编译器编译代码的时候,如果发现这个代码不需要加锁,就会自动把这个锁给干掉。这里的优化是比较保守的:
比如,就只有一个线程,在这一个线程里加锁了。或者说加锁代码中,没有涉及到“成员变量的修改”,就只是一些局部变量......都是不需要加锁的。
其他的很多“模棱两可”的情况,编译器也不知道这是要加还是不加,总之都不会去消除。锁消除,针对一眼看上去就完全不涉及线程安全问题的代码,能够把锁消除掉;而偏向锁,运行起来才知道有没有锁冲突。
2.5 锁粗化
所谓的锁粗化,会把多个细粒度的锁,合并成一个粗粒度的锁。
那么,如何判断是细粒度还是粗粒度呢?
这就要看synchronized{ }了,大括号里包含的代码越少,就认为锁的粒度越细;包含的代码越多,就认为锁的粒度越粗。
通常情况下,是更偏向于让锁的粒度更细一些,更有利于多个线程并发执行的。但是有时候,是更希望锁的粒度粗点也挺好。
如图所示,如果锁的粒度很细,每次加锁都可能涉及阻塞。
这里就是把三次细粒度的锁合并成一个粗粒度的锁了,粗化也是为了提高效率。
这里来简单总结一下。synchronized背后涉及到了很多的“优化手段”:
1.锁升级:偏向锁 -> 轻量级锁 -> 重量级锁
2.锁消除:自动干掉不必要的锁
3.锁粗化:把多个细粒度的锁合并成一个粗粒度的锁,减少锁竞争的开销
3.CAS
CAS,全称为“compare and swap”,也就是一个特殊的CPU指令,完成的工作是“比较和交换”(严格来说,和Java无关!)。包括说JVM中关于CAS的API,都是放在unsafe包里(不推荐你用的东西)。
我们来看一段伪代码:
这段伪代码中,expectValue和swapValue代表的都是寄存器中的值。伪代码比较了address内存地址中的值是否和expectValue寄存器中的值相同,如果相同,就把swapValue寄存器中的值和address内存中的值进行交换,并返回true。如果不相同,则什么都不用做,返回false。
说是“交换”,其实也可以理解成“赋值”,往往只关注内存中最终的值,寄存器用完就被抛弃了。实际操作中,上述伪代码就是一条CPU指令,直接就完成了上述工作。而单个CPU指令,本身就是原子的!
基于CAS指令,给编写线程安全的代码,打开了一个新世界的大门。因为之前保证线程安全都是依靠加锁,而加锁就可能引起阻塞,从而导致性能降低。但使用CAS,就不涉及加锁,也就不会阻塞,合理的使用也能保证线程安全,这就是“无锁编程”。
3.1 CAS的实际应用
CAS本身是CPU指令,操作系统对指令进行了封装,而JVM又对操作系统提供的API又封装了一层。
有的CPU可能会不支持CAS(但x86,arm这些主流的CPU当然是没问题的)。Java中的CAS的API放到了unsafe包里,这样的操作,涉及到一些系统底层的内容,使用不当的话可能会带来一些风险,所以一般不建议大家直接使用CAS。Java的标准库,对于CAS又进行了进一步的封装,提供了一些工具类让我们直接使用。其中最主要的一个工具,叫做“原子类”(atomic)。
图中圈出的,就是对Integer和Long进行了封装,之后针对这样的对象进行多线程修改,就是线程安全的了。
谈到这里,我们在之前的博客中提到的后置++需要加锁的问题,此时只需直接调用一个方法即可:
这里的后置++操作,就是通过CAS的方式实现的。这里的内容,就没有加锁,依旧能保证线程安全。因为之前的count++是三个指令,在多线程下,就会相互穿插执行,引起线程不安全;但是这里的getAndIncrement对变量的修改,是一个CAS指令,一个指令天然就是原子的。
我们来分析一下该方法的具体代码: 首先,这里的oldValue期望是一个放到寄存器中的值,这个值就是初始化成AtomicInteger里面保存的整数值value。接着,使用CAS进行判定,看这里的oldValue和value是否一样,如果一样,就将oldValue+1赋值给value。如果发现比较交换成功,循环就结束了,此时value已经被更新成value+1了;如果没成功,就再来一次循环,直到成功为止。
我们再来分析多线程情况下代码执行是什么样的:
假设这里有两个线程t1和t2
t1和t2分别对应两个不同的CPU:
假设value的初始值为0,此时线程t1和t2执行对应的代码,读取value到oldValue中就是0,寄存器中oldValue+1就为1。接着t2进行CAS判定,比较交换成功,将value的值修改为1.接着轮到t1执行,这时发现value和oldValue的值不一致,意味着在CAS之前,另一个线程修改了value。通过这个方式,就能识别出是否有人修改。一旦发现,就重新读取新的value到oldValue中。
这样的操作,就是“识别+重试”。
3.2 CAS的ABA问题
我们现在知道了,CAS在使用的时候,关键要点,是要判定当前内存的值是否和寄存器中的值一样,如果一样,就进行修改,反之什么也不做。
但也可能存在这样的情况,比如数值本来是0,执行CAS之前,另一个线程把这个值从0修改为100,又从100修改为0。这里的0就好比是A,100就好比是B。一般来说,即使出现上述情况,也问题不大,不会产生什么bug,但是就怕一些极端场景。
对于ABA问题来说,什么时候会有bug呢?那就是极端情况!
假设去银行取钱
初始情况下,账户余额1000,要取500。
取钱的时候,ATM卡了,按了一下没反应,又按了一下。这里的第一下就好比是线程t1,第二下是线程t2。此时产生的两个线程,就去尝试进行扣款操作了,此处假定是按照CAS的方式来扣款的。
如果是按照这种方式来执行的话,没有关系。
虽然上述的执行过程是没问题的,但是怕出现极端情况。比如,在t1执行CAS之前,出现一个t3线程,给我的账户充值500。此时,t1线程就不知道当前1000是始终没变,还是变了又变回来了。
对于ABA问题的解决方案如下:
1)约定数据变化是单向的(只能增加或者只能减少),不能是双向的(既能增加又能减少)。
2)对于本身就必须双向变化的数据,可以给它引入一个版本号,版本号这个数字就是只能增加,不能减少的。
4.JUC
所谓的JUC,其实就是java.util.concurrent,这个包下面放了一些进行多线程编程时有用的类。
4.1 Callable接口
前面的博客中我们提到过,创建线程有如下方式:
1.继承Thread(包含了匿名内部类的方式)
2.实现Runnable(包含了匿名内部类)
3.基于lambda
4.基于线程池
现在,通过Callable,也能创建线程。Runnable关注的是创建线程的过程,不关注执行结果,因此Runnable提供的run方法,返回值类型是void;但Callable要关注执行结果,因此Callable提供的call方法,返回值就是线程执行任务得到的结果。
举个栗子🌰:
如果我们要编写多线程代码,希望关注线程中代码的返回值时(就拿创建一个新线程,用新的线程实现从1+2+...+1000来说):
这是采用了之前我们谈到的创建线程的方法来写的,虽然能解决问题,但是并不优雅。这里,我们使用Callable可以更好的解决这个问题。
这里,我们期望线程入口方法里返回值是什么类型,此处的泛型参数就是什么类型,这里我们希望返回值是一个整数。
此处就不需要引入额外的成员变量了,直接借助这里的返回值即可。
但是,Callable如何搭配线程使用呢?
可以看到,Thread没有提供构造函数来传入callable。因此,这里需要我们引入一个FutureTask类,作为Thread和callable的“粘合剂”。
这里的futureTask指的就是“未来的任务”(任务还没执行完)。既然这个任务是在未来执行完毕,最终去取结果的时候,就需要有一个凭据,这个凭据就是futureTask。就好比你去吃麻辣烫,你把菜选好,钱一交,人家就会给你一个小牌子,小牌子上有一个号码,最后凭号码取餐。
最后,想要获取线程执行的结果,只需要使用FutureTask里的get方法即可:
这个操作也是带有阻塞功能的,如果线程还没执行完毕,get就会阻塞。等到线程执行完了,return的结果,就会被get返回回来。
4.2 ReentrantLock
ReentrantLock是一个可重入锁。我们都知道,synchronized也是可重入锁。上古时期的Java中,synchronized不够强壮,功能也不够强大,也没有各种优化。ReentrantLock就是用来实现可重入锁的选择。
后来,synchronized变得厉害了,因此ReentrantLock就用的少了,但是仍然有一席之地。
ReentrantLock采用的其实是一种传统的锁的风格,这个对象提供了两个方法:
lock和unlock
这个写法,就容易引起“加了锁之后,忘记解锁了”这种情况。在unlock之前,触发了return或者异常,就可能引起unlock执行不到了。正确使用ReentrantLock就需要把unlcok操作放到finally中。
那么,既然有了synchronized,为什么还要有ReentrantLock呢?
第一,ReentrantLock提供了tryLock操作。之前的lock是直接进行加锁,如果加锁不成功,就要阻塞。而tryLock会尝试进行加锁,如果加锁不成功,不阻塞,直接返回false。
第二,ReentrantLock提供了公平锁的实现,通过队列记录加锁线程的先后顺序。ReentrantLock构造方法中填写参数,就可以设置为公平锁。而synchronized是非公平锁。
第三,搭配的等待通知机制是不同的。对于synchronized来说,是搭配wait/notify;对于ReentrantLock来说,是搭配Condition类,功能比wait/notify略强。
4.3 Semaphore(信号量)
信号量,是由迪杰斯特拉大佬提出来的。除了信号量,他还提出了“图的最短路径算法”。
如何理解信号量呢,我们来举个栗子🌰:
一般停车场的门口都有一个电子牌,会显示出当前剩余xx个车位。
开进去一个车,上述数字就-1。
开出来一个车,上述数字就+1。
如果上述数字为0了,你就开不进去了。要么在门口等,要么去找下一个停车场。
显示的剩余车位其实就是信号量,表示“可用资源的个数”。
申请一个可用资源,就会使数字-1,这个操作就称为P操作。
释放一个可用资源,就会使数字+1,这个操作就是V操作。
如果数值为0了,继续P操作,P操作就会阻塞。
信号量也是操作系统内部给我们提供的一个机制。操作系统对应的API被JVM封装了下,就可以通过Java代码来调用这里的相关操作了。
信号量是更广义的锁。
所谓的锁,本质上也是一种特殊的信号量。锁,可以认为就是计数值为1的信号量。释放状态,就是1;加锁状态,就是0。对于这种非0即1的信号量,称为“二元信号量”。
信号量也可以用于实现生产者消费者模型:
定义两个信号量,一个用来表示队列中有多少个可以被消费的元素,sem1;另一个用来表示队列中有多少个可以放置新元素的空间,sem2.
生产一个元素,使用sem1.V(),sem2.P();消费一个元素,使用sem1.V(),sem2.P()。
4.4 CountDownLatch
CountDownLatch,是针对特定场景解决问题的小工具。
比如,多线程执行一个任务,会把大的任务拆成几个部分,由每个线程分别执行。
这个场景其实很常见,比如“多线程下载”,“idm软件的使用”等等。
你下载一个文件,可能很大,但是可以拆成多个部分,每个部分负责下载一部分。下载完成之后,最终会把下载的结果拼到一起。像多线程下载这样的场景,最终执行完成之后,就要把所有内容拼到一起,这个“拼”必然要等到所有线程执行完成。
使用CountDownLatch就可以很方便的感知到这个事情(比你调用很多次join要简单方便一些)。如果使用join的方式,就只能使用每个线程执行一个任务;但借助CountDownLatch的方式,就可以让一个线程能执行多个任务。
我们来看一段具体的应用实例:
import java.util.Random;
import java.util.concurrent.CountDownLatch;public class ThreadDemo1 {public static void main(String[] args) throws InterruptedException {//这里的10代表10个任务CountDownLatch latch = new CountDownLatch(10);for (int i = 0; i < 10; i++) {int n = i;Thread t = new Thread(() -> {Random random = new Random();System.out.println("线程" + n + "开始下载");try {Thread.sleep((random.nextInt(5) +1) * 1000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("线程" + n + "下载完毕");//每个任务执行完毕就countDown一次,用来记录这个任务执行完了latch.countDown();});t.start();}//等待所有任务都执行完毕latch.await();System.out.println("所有任务下载完毕");}
}
4.5 线程安全的集合类
我们原来学习的集合类,大部分都是线程不安全的。Vector,Stack,Hashtable是线程安全的(不建议用),其他的集合类不是线程安全的。
为什么不建议使用这几个类呢?因为,这几个兄弟,无论如何都得加锁,哪怕单线程也得加锁,这种设定是不科学的!
我们来看多线程环境下使用ArrayList:
1.自己使用同步机制(synchronized或者ReentrantLock)
前面或多或少已经提到过,这里不再详细展开。
2.Collections.synchronizedList(new ArrayList)
这个其实也好理解,就是给ArrayList套了个外壳。ArrayList各种操作本身是不带锁的,通过上述套壳之后,得到了新的对象,新的对象里面的关键方法都是带有锁的。
3.使用CopyOnWriteArrayList
CopyOnWriteArrayList,就是写时拷贝。那么,如何理解写时拷贝呢?
我们都知道,线程安全问题,是由多个线程修改同一个数据引发的。如果不想引发线程安全问题,我们就让多个线程去修改不同的数据,而写时拷贝就是这个思路的延申。
比如这里有一个顺序表:
如果多线程读这个顺序表,是没有任何线程安全问题的。
一旦有线程要修改这里的值,就把顺序表复制一份,修改新的顺序表内容,并且修改引用的指向(这个操作是原子的,不需要加锁)。
但是这种操作也有很大的局限性:
第一,修改不能太频繁。因为复制操作成本很高。
第二,顺序表也不应该太大。
写时拷贝一般用于以下场景:
比如,服务器加载配置文件的时候,就需要把配置文件的内容解析出来放到内存的数据结构中。(顺序表/哈希表)
服务器的配置文件,修改频率很低,而且配置文件一般体积都不大。
我们再来看多线程环境使用队列:
要想保证线程安全,我们应该也很熟悉了。
1.自己加锁
2.使用BlockingQueue(线程安全的)
我们接着来看多线程环境使用哈希表:
使用HashMap肯定是不行的,因为线程不安全。更靠谱的是Hashtable,其在关键方法上添加了synchronized。但是很遗憾,Hashtable并不好用,产生锁冲突的概率太大了。
相比之下,标准库又引入了一个更好的解决方案:ConcurrentHashMap。
我们来看ConcurrentHashMap的改进:
1.缩小了锁的粒度
我们先看Hashtable是怎么加锁的:
我们都知道,哈希表是数组+链表的形式。
Hashtable是直接在方法上使用synchronized,就相当于是对this加锁。而哈希表的整个数组就是this。此时,尝试修改两个不同链表上的元素,都会触发锁冲突。
仔细观察,我们会发现,如果修改两个不同链表上的元素,不涉及到线程安全问题,因为修改的是不同变量;如果修改的是同一个链表上的元素,就可能涉及到线程安全问题。比如这两个变量在链表上的位置是相邻的,操作引用的时候,就可能涉及到操作同一个引用。
我们再来看ConcurrentHashMap具体是如何改进的:
从图中可以看出,ConcurrentHashMap就是把锁小了,给每个链表都发了一个锁。
此时,修改不同链表上的两个元素这两个操作,不是操作同一个链表的锁,就不会产生锁冲突。
其实,上述设定,不会产生更多的空间代价。因为Java中任何一个对象都可以直接作为锁对象。本身哈希表中就得有数组,数组的元素都是已经存在的(每个链表的头节点)。此时,只要使用数组元素(链表的头节点)作为加锁的对象即可。
上述加锁的方式,我们也称之为“锁桶”(hash表也称为哈希桶)
上述模型构成了类似于“桶”的结构,每个链表就是构成桶的一个木板。所谓“锁桶”,就是针对每个木板(每个链表)分别加锁的。
2.充分的使用了CAS原子操作,减少一些加锁
比如,针对哈希表元素个数的维护。
3.针对扩容操作的优化
扩容是一个重量操作。
我们都知道,“负载因子”描述了每个桶上平均有多少元素。哈希表操作的时间复杂度是O(1),此时桶上的链表的元素个数不应该太长。如果太长:
1.会变成树(长度不平均的情况)
2.扩容:创建一个更大的数组,把旧的hash表的元素全部给搬运到(删除/插入)新的数组上。如果hash表本身元素非常多,这里的扩容操作就会很消耗时间。因此,我们可能会遇到“hash表平时都很快,突然间某个操作就慢了,过一会又快了”的情况,这就是表现不稳定。
所以,ConcurrentHashMap针对扩容做出的优化就是:化整为零,蚂蚁搬家。
HashMap的扩容操作是“一把梭”,在某一次插入元素操作中,整体完成扩容;而ConcurrentHashMap则是每次操作都只搬运一部分元素。
5.小结
写到这里,关于多线程进阶的知识已经全部介绍完毕。在下篇博客中,我会接着介绍文件IO的相关知识,大家可以期待一下!