常见的锁策略(锁开销最大的地方就是 阻塞)
如果你自己需要实现一把锁(你认为 标准库 给你提供的锁不够用)那你需要关注锁策略。
不过在Java中 synchronized 已经非常好用了,足以覆盖绝大多数的使用场景。
此处的“锁策略”不是和heJava强相关的,其他语言,但凡涉及到并发编程,涉及到锁,都可以谈到这样的锁策略。(这个锁在加锁的时候,有啥特点,有啥行为)
1.悲观锁 VS 乐观锁
不是针对某一种具体的锁,而是某个锁具有“悲观”或“乐观”特性。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。(加锁的时候,预测接下来的锁竞争情况非常激烈。就需要针对这样的情况额外做一些工作。)
有一把琐,有二十个线程尝试获取锁,每个线程加锁的频率都很高,一个线程加锁的时候,很可能锁被另一个线程占用着。
乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。(加锁的时候,预测接下来的锁竞争的情况不激烈,就不需要做额外工作。)
有一把锁,假设只有两个线程尝试获取这个锁,每个线程加锁的频率都很低,一个线程加锁的时候,大概率另一个线程没有和它竞争。
//Synchronized 初始使⽤乐观锁策略,当发现锁竞争⽐较频繁的时候, 就会⾃动切换成悲观锁策略。
2.重量级锁 VS 轻量级锁
重量级锁,当悲观的场景下,此时就要付出更多的代价。 => 更低效
轻量级锁,对应乐观的场景,此时付出的代价就会更小。 => 更高效
//synchronized 开始是⼀个轻量级锁.,如果锁冲突⽐较严重, 就会变成重量级锁。
重量级锁:加锁机制重度依赖 OS 提供了 mutex(操作系统基于CPU的原子指令,实现了 mutex 互斥锁,JVM基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类。)
- 大量的内核态用户态切换。
- 很容易引发线程的调度。
这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着“沧海桑田”。
轻量级锁:加锁机制尽可能不使用 mutex ,而是尽量在用户态代码完成,实在搞不定了,再使用mutex。 - 少量的内核态用户态切换。
- 不太容易引发线程调度。
理解用户态和内核态:
想象去银行办业务。在窗口外,自己做,这是用户态。用户态的时间成本是比较可控的。
在窗口内,工作人员做,这是内核态,内核态的时间成本是不太可控的。
如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。
3.挂起等待锁 VS 自旋锁
挂起等待锁 => 重量级锁的典型实现。 => 操作系统内核级别的,加锁的时候发现竞争,就会是该线程进入阻塞状态,后续就需要内核进行唤醒了。
自旋锁 => 轻量级锁的典型实现 => 应用程序级别的,加锁的时候发现竞争,一般也不是进入阻塞,而是通过忙等的形式来进行等待。(忙等:乐观锁 的场景,本身遇到锁竞争的概率就很小,真的遇到竞争,在短时间内就能拿到锁)
//synchronized 中的轻量级锁策略⼤概率就是通过⾃旋锁的⽅式实现的。
理解自旋锁和挂起等待锁:
想象一下,都去追女神(只是你眼中的女神=>乐观,大家眼中的女神=>悲观),当男生向女神表白后,女神说:你是个好人,但是我有男朋友了。
挂起等待锁:陷入沉沦不能自拔…过了很久之后,突然女神发消息,”要不咱两试试“(但是,在这个很长的时间间隔里,女神可能已经换了好几个男票了)。在这个过程中,你这边不在继续在女神身上消耗精力了。(获取锁的周期更长的,很难做到及时获取。但是,在这个过程就不必一直消耗cpu,把cpu省出来做别的事情,锁竞争激烈)
自旋锁:死皮赖脸坚韧不拔,任然每天持续的和女神说早安晚安,一旦女神和上一任分手,那么就能立刻住机会上位。(整个锁的等待时间,其实是不长。获取锁的周期更短,及时获取到锁,这个过程会一直消耗cpu,锁的竞争不激烈)。
synchronized 是自适应锁,在JVM内部,会统计每个锁竞争的激烈程度,如果竞争不激烈,此时synchronized就会按照轻量级锁(自旋),如果竞争激烈,此时synchronized就会按照重量级锁(挂起等待 )。
4.普通互斥锁 VS 读写锁
多个线程读取一个数据,是本身就线程安全的。多个线程读取,一个线程修改,肯定会涉及到线程安全问题。大部分操作在读,少数操作在写。如果你把读和写都加上普通的互斥锁,意味这锁冲突非常严重。而读写锁确保读锁和读锁之间,不是互斥的(不会产生阻塞),写锁和读锁之间,才会产生互斥,写锁和写锁之间,也有互斥,保证线程安全的前提下,降低锁冲突的概率,提高效率。读多写少的情况,在服务器开发中,非常常见的场景。(教务系统,布置作业…)
读写锁:多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
一个线程对于数据的访问,主要存在两种操作:读数据和写数据。
如果两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可。但是如果两个线程都要写一个数据,就有线程安全问题。或者一个线程读另一个线程写,也有线程安全问题。
读写锁就是把读操作和写操作区分对待。Java标准库提供了 ReentrantReadWriteLock 类,实现了读写锁。
- ReentrantReadWriteLock.ReadLock 类表示一个读锁。这个对象提供了lock/unlock 方法进行加锁解锁。
- ReentrantReadWriteLock.WriteLock 类表示一个写锁。这个对象提供了lock/unlock 方法进行加锁解锁。
其中,和上面意思一样,(读加锁和读加锁之间,不互斥)(写加锁和写加锁之间,互斥)(读加锁和写加锁之间,互斥)
注意:只要涉及到”互斥“,就会产生线程的挂起等待。一旦线程挂起,再次唤醒就不知道隔了多久了。因此尽可能减少”互斥“的机会,就是提高效率的重要途径。
synchronized 不是读写锁。
5.可重入锁 VS 不可重入锁
可重入锁:允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(也因为这个原因可重入锁也叫做递归锁)。
也就是一个线程,一把锁,连续加锁多次,是否会死锁。不会就是可重入锁,会就是不可重入锁。
Java里只要以Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而Linux 系统提供的 mutex 是不可重入锁。
核心要点:
1.锁要记录当前是那个线程拿到的这把锁。
2.使用计数器,记录当前加锁了多少次,在合适的时候进行加锁。
6.公平锁 VS 非公平锁
谈论这两个锁之前,我们先谈论谈论什么是公平。
先来后到可以是公平的,概率均等也可以是公平的。所以在这里公平只是相对的,并不是绝对的。
公平锁和非公平锁的作者,采用的是”先来后到“的方式来实现”公平“的。
(概率均等,锁默认情况下,就相当于”概率均等“,因为操作系统针对线程的调度是随机的)
那么,公平锁:遵循”先来后到“。非公平锁:不遵循”先来后到“。
举个例子:就像是有ABC三个线程,A先尝试获取锁,获取成功。B再尝试获取锁,获取失败,陷入阻塞等待。然后C也尝试获取锁,也获取失败,也陷入阻塞等待。那么如果是公平锁,再A释放了之后,由于是B先来的,所以B获取成功锁,C继续陷入阻塞等待。但如果是非公平锁,那么B和C都有可能获取到锁。
注意:
- 操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的机制,那么锁就是非公平锁。但是如果要想实现公平锁,就需要依赖额外的数据结构,比如需要使用一个队列,来记录一下,各个线程获得锁的顺序。
- 公平锁和非公平锁没有好坏之分,关键还是看使用场景。
synchronized是非公平锁。
synchronized 详细情况
结合上面的锁策略,我们就可以总结出,synchronized 具有以下特性:
- 自适应的(开始时是乐观的,如果锁冲突频繁,就转换为悲观锁。开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。实现轻量级锁的时候大概率用到的是自旋锁策略)。
- 不是读写锁。
- 是可重入锁。
- 是非公平锁。
JVM将 synchronized 锁分为 无锁 , 偏向锁 , 轻量级锁 , 重量级锁 状态。会根据情况,进行依次升级。
无锁 => 偏行锁:代码进入 synchronized 的代码块。
偏向锁 => 轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁。
轻量级锁 => 重量级锁:JVM 发现,当前竞争锁的情况非常激烈。
当前,JVM中,只提供了”锁升级“不能”锁降级“。
解释一下什么是偏向锁:
第一个尝试加锁的过程,优先进入偏向锁状态。
偏向锁并不是真的”加锁“。进行synchronized,刚一上来,不是真加锁,而是只是简单给对象头中做一个标记,记录这个锁属于哪个线程,而这个标记,非常轻量,相比于加锁解锁来说,效率高很多。
如果后续没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单清除上述标记即可(不涉及真加锁,真解锁)。
如果后续有其他线程来竞争该锁,就抢先一步,在另一个线程拿到锁之前,抢先拿到锁。就相当于取消原来的偏向锁状态,进入一般的轻量级锁状态(其他线程只能阻塞等待)。
偏向锁本质先当于懒汉模式,能不加锁就不加锁,尽量来避免不必要的加锁开销。但是该做的标记还是得做,否则无法区分何时需要真正加锁。
synchronized其他的优化策略
1.锁消除
锁消除,也是编译器优化的一种体现。编译器会判定,当前这个代码逻辑是否需要真的加锁,如果确实不需要加锁,但是你写了 synchronized ,就会自动把 synchronized 给去掉。
但是这个锁消除是比较保守的。百分百确认你这个代码确实是单线程操作的时候,才会真正触发消除。像一些判定不清楚情况d,是不会触发的。也有人想到处 synchronized ,但是到处 synchronized 意味着优化机制,只能把其中一部分,他能明确判定的给优化掉,还会有很多不应该使用,但是编译器也优化不掉。所以目前还是不能完全依赖编译器优化。
2.锁粗化
一段逻辑中,如果出现多次加锁解锁,编译器 + JVM会自动进行锁的粗化。
在讨论锁的粗话的时候,我们绕不开的是锁的粒度。
那什么是锁的粒度呢?其实就是 加锁和解锁直接,包含的代码越多,就认为锁的粒度就越粗,如果包含的代码越少,就认为锁的粒度就越细。(不是代码行数,是实际执行的指令/时间)。
一个代码中,反复针对细粒度的代码加锁,就可能被优化成更粗粒度的加锁。当然,也不是锁越优秀越粗,还是要具体视情形而定的,锁粗了,就会影响到线程的并发程度。
在实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁。但是在实际上可能并没有其他线程来抢占这个锁,这种情况 JVM 就会自动把锁粗化,避免频繁释放锁。
锁粗化,这件事也并不是说合理,比如,确实三件事,本身都需要加锁,粗化成一把锁,合理的。如果三件事里,两件事需要加锁,一件事不需要粗化成一把锁,使得本来不需要加锁,能够并行执行的事情,也变成串行了。
细粒度
粗粒度
CAS
(一般实际开发中很少直接使用 CAS (尤其是 Java 程序员),但雀氏很多地方有他的影子。砸门谈到的 CAS ,是一条 CPU 的一条指令(原子),这样的指令,就给编写多线程代码/线程安全,打开了新世界的大门)。
CAS:全称 Compare and swap , 字面意思:“比较并交换”,一个 CAS 涉及到以下操作:
假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1.比较 A 与 V 是否相等。(比较)
2.如果比较相等,将 B 写入 V。(交换)
3.返回操作是否成成功。
CAS伪代码:
下⾯写的代码不是原⼦的, 真实的 CAS 是⼀个原⼦的硬件指令完成的. 这个伪代码只是辅助理解 CAS的⼯作流程.
CPU 的指令有两种风格:
精简指令集 => 一般每个指令都是很简单,基本上一个时钟周期就是一个指令。
复杂指令集 => 经常出现一个指令,要做很多工作,消耗很多个时钟周期
(时钟周期: CPU的频率就是在描述时钟周期)
当多个线程同时对某个资源进行 CAS 操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。(CAS 可以视为一种乐观锁。(或者可以理解成 CAS 是乐观锁的一种实现方式))
CAS是怎么实现的
针对不同的操作系统, JVM 用到了不同的 CAS 实现原理,简单来讲:
- Java 的 CAS 利用的是 unsafe 这个类提供的 CAS 操作。
- unsafe 的 CAS 依赖的是 JVM 针对不同的操作系统实现的 Atomic::cmpxchg
- Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 CPU 硬件提供的 lock 机制保证其原子性。
简而言之,是因为 硬件给予了支持,软件层面才能做到。
(也就是,CAS 本质上是 CPU 的指令,操作系统就会把这个指令进行封装,提供一些 api,就可以在 C++ 中被调用了, JVM 又是基于 C++ 实现的,JVM 也能够使用 C++调用的这样 CAS 操作)
CAS应用
1.实现原子类
Java标准库中提供了 java.util.concurrent.atomic 的包,里面的类都是基于这种方式实现的。
boolean,int,long 这些类型进行了封装,前面典型的操作, count ++ 线程不安全,就需要加锁来解决问题。但是又认为,加锁的效率比较低,于是就可以通过 CAS 来实现 count++ 确保性能,同时保证线程安全。(++,-- 这样的操作,针对内置类型,都是非原子的,针对原子类,则是原子的。基于CAS实现,不涉及到加锁。)
之前谈到的通过 synchronized 保证一个修改的原子性,和“原子类”这样的术语不相关。
使用原子类的目的,就是为了避免加锁。
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作。
伪代码实现:
(oldValue 可以当作是寄存器)
假设两个线程同时调用 getAndIncrement
1.两个线程都读取 value 的值到 oldValue 中。
2.线程1 先执行 CAS 操作,由于 oldValue 和 value 的值相同,直接进行对 value 赋值。
注意:
- CAS 是直接读写内存的,而不是操作寄存器。
- CAS 的读内存,比较,写内存操作是一条硬件指令,是原子的。
3.线程2 再执行CAS 操作,第一次 CAS 的时候发现 oldValue 和 value 不相等,不能进行赋值。因此需要进入循环,在循环里重新读取value 的值赋给 oldValue。
4.线程2 接下来二次执行 CAS ,此时 oldValue 和 value 相同,于是直接执行赋值操作。
5.线程1 和线程2 返回各自的 oldValue 的值即可。
通过形如上述代码就可以实现一个原子类。不需要使用重量级锁,就可以高效的完成多线程的自增操作。
(即使上述代码中出现线程切换,由于在进行自增之前,先判定当前寄存器读到的值是否是“科学的值”,如果是不科学的值,就会进行重新读取)
2.实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
伪代码:
解释:
CAS 的 ABA 问题
使用 CAS 能够进行线程安全的编程,核心就是先比较“相等”,内存和寄存器是否相等。(这里本质上在判定是否有其他线程插入进来,做了一些修改,认为如果发现这里寄存器和内存的值一致,就可以认为是没有线程穿插过来修改,因此接下来的修改操作就是线程安全的。本来判定内存的值是否是A,发现果然是A,说明没有其他线程修改过,但是实际上,可能存在一种情况,另一个线程,把内存那从 A 修改成 B,又从B修改会A)
举个例子:
取钱的过程:比如我的卡上有1000余额,取500,点下取款的时候,卡了一下,紧接着,我又连续按了好几下取款,此时就可能产生两个线程,并发的执行扣款操作。假设,按照 CAS 的方式来执行扣款。
正常情况:
1.存款1000,线程1获取到当前存款值为1000,期望更新为500,线程2获取到当前存款值为1000,期望更新为500.
2.线程1执行扣款成功,存款被改为500,线程2阻塞等待中。
3.轮到线程2执行了,发现当前存款为500,和之前读到的1000不相同,执行失败。
异常过程:
1.存款1000,线程1获取到当前存款值为1000,期望更新为500,线程2 获取当前存款值为1000,期望更新为500.
2.线程1执行扣款成功,存款被改为500,线程2阻塞等待。
3.在线程2执行之前,盆友刚好给我转了500,此时账户余额变成1000.
4.轮到线程2执行,发现当前存款为1000,和之前读到的1000相同,再次执行扣款操作。
这个时候,扣款操作被执行了两次,这就是 CAS的ABA问题。
触发条件:
1)得是特定情况,搞出多个线程。
2)第三个线程得是在巧妙的时机。
3)第三个线程也得是巧妙的数值。
虽然ABA问题是小概率问题,但即便是万分之一的概率的问题,乘以一个很大的基数,也会变成大问题。
解决办法
给要修改的值,引入版本号,在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
- CAS操作在读取旧值的同时,也要读取版本号。
- 真正修改的时候,如果当前版本号和读到的版本好相同,则修改数据,并把版本号 + 1。如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了。)。
在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进⾏包装, 在内部就提供了上⾯描述的版本管理功能。(这里就不展开讲了,想了解的,可以去查一查)
JUC(java.util.concurrent) 的常⻅类
Callable接口
Callable接口和Runnable 接口并列关系、
- Callable -> 返回值 call() 泛型参数
- Runnable -> void run()
但是有一个问题是 Thread 的构造方法中没有提供版本,可以传Callable的对象。
这其中也不是因为Callable接口用的少,而是为了解耦合。(Thread 就是 线程,和任务这个概念能剥离开,更不希望关心,任务是啥样的任务(是否有返回值))
Callable 通常需要搭配 FutuerTask 来使用。FutuerTask 用来保存 Callable 的返回结果,因为 Callable 往往是在另一个线程中执行的,啥时候执行完并不确定。而FutureTask就可以负责这个等待结果出来的工作。
理解FutuerTask;就可以想象成吃饭点餐后,服务员给你的小票,这个小票就是FutuerTask,后面就可以凭借这张小票取餐了。
创建线程的写法:
1.继承 Thread(定义单独的类/匿名内部类)
2.实现 Runnable (定义单独的类/匿名内部类)
3.lambda
4.实现 Callable(定义单独的类/匿名内部类)
5.线程池, ThreadFactory
ReentrantLock
可重入互斥锁,和 synchronized 是并列关系。只是更经典风格的锁。
ReentrantLock的用法:
- lock():加锁,如果获取不到锁就死等。
- trylock(超时时间);加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
- unlock();解锁。
这样子写代码肯定会因为线程安全引发问题。我们来用 ReentrantLock 来解决。
但是,使用 ReentrantLock 最大的问题是unlock 可能不小心忘记调用。如果中间有什么if条件,或抛出异常这里的unlock可能就忘记调用。
所以我们还可以这样子来写代码来有效忘记避免解锁:
但即便如此,还是权宜之计,还是 synchronized 更香一点。
ReentrantLock 和 synchronized 的区别:
synchronized 是一个关键字,在JVM内部实现的(内部实现是JVM通过 C++ 实现的),ReentrantLock 是标准库的一个类,在JVM外实现的(基于Java实现)。
synchronized 使用是不需要手动释放锁,是通过代码块控制加锁解锁,ReentrantLock使用时需要手动释放(使用lock/unlock方法)。使用起来更灵活,但是也容易遗漏unlock。
synchronized 在申请锁失败时,会死等。ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
synchronized 是非公平锁,ReentrantLock 默认是非公平锁,但是可以通过构造方法传入一个 true 开启公平锁模式。
ReentrantLock 搭配的等待通知机制是 Condition 类,相比与 synchronized 通过 Object 的 wait nitify 来说功能更强大一些。
(wait/nitify 实现等待唤醒,每次唤醒的是一个随机等待的线程。
Condition类实现等待唤醒,可以更精确控制唤醒某个指定的线程。)
如何选择使用哪个锁?
- 锁竞争不激烈的时候,使用 synchronized ,效率更高,自动释放更方便。
- 锁竞争激烈的时候,使用 ReentrantLock ,搭配 trylock 更灵活控制加锁的行为,而不是死等。
- 如果需要使用公平锁,使用 ReentrantLock。
信号量 Semaphore
信号量用来表示“可用资源的个数”,能够协调多个进程之间的资源分配,也能协调多个线程之间的资源分配。本质上就是一个计数器。
理解信号量:
可以把信号量想象成是停车场的展示牌:当前有车位 100 个,表示有 100 个可用资源。
当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源。
Semaphore的 PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
代码示例:
- 创建 Semaphore 示例,初始化为 4,表示4个可用资源。
- acquire 方法表示申请资源(P操作),release 方法表示释放资源(V操作)
- 创建 20 个线程,每个线程都尝试申请资源,sleep 1秒之后,释放资源观察程序的执行效果。
信号量的一个特殊情况:初始值为 1 的信号量;取值要么是1 要么是 0(称为二元信号量 ) -> 等价于”锁“(普通的信号量,就相当于锁的更广泛的推广)
如果是普通的 N 的信号量,就可以限制,同时有多少个线程来执行某个逻辑。
二元信号量的代码示例:
CountDownLatch
使用多线程,经常把一个大的任务拆分成多个子任务。使用多线程执行这些子任务,从而提高程序的效率。那么如何衡量,这多个子任务都完成了呢?整个任务都完成了呢?
CountDownLatch:同时等待 N 个任务执行结束。
- 1)构造方法指定参数,描述拆成了多少个任务。
- 2)每个任务执行完毕之后,都调用一次 countDown 方法。 在 CountDownLatch 内部的计数器同时自减。
- 3)主线程在调用 await 方法,阻塞等待所有任务执行完毕。(相当于计数器为0了)
就好像跑步比赛,10个选手依次就为,哨声响才同时出发。但所有的选手都通过终点,才公布成绩。
线程安全的集合类
原来的集合类,大部分都不是线程安全的。
Vector,Stack,HashTable,是线程安全的(不建议使用),其他的集合类不是线程安全的。
多线程环境使用ArrayList
1.自行加锁(推荐)
分析清除,要把那些代码打包到一起,成为一个“原子操作”
2.Collections.synchronizedList(new ArrayList);(不推荐)
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的List。synchronizedList的关键操作上都带有synchronized。
3.使用CopyOnWriteArrayList(不去加锁)
CopyOnWrite是编程中的一种常见思想方法 -> 写实拷贝
- CopyOnWrite容器即写时复制的容器。当我们往一个容器里添加元素的时候,不会直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后在新的容器里添加元素。
- 添加完元素之后,在将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下,性能很高,不需要加锁竞争。
缺点:
1.占用内存较多。
2.新写的数据不能被第一时间读取到。
3.如果多个线程同时修改,容易出现问题。
多线程环境使用队列
1.ArrayBlockingQueue(基于数组实现的阻塞队列)
2.LinkedBlockingQueue(基于链表实现的阻塞队列)
3.PriorityBlockingQueue(基于堆实现的带优先级的阻塞队列)
4.TransferQueue(最多值包含一个元素的阻塞队列)
多线程环境使用哈希表
HashMap 本身不是线程安全的。
Hashtable 线程安全的(给各种 public 方法都加 synchronized)。(不推荐)
-> 这相当于直接给 Hashtable 对象本身加锁。
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突。
- size 属性也是通过synchronized 来控制同步,也是比较慢的。
- 一旦触发扩容,就该由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率非常低。
(就像一个 Hashtable 只有一把锁,两个线程访问 Hashtable 中的任意数据都会出现锁竞争)
ConcurrentHashMap 线程安全(效率更高。按照桶级别进行加锁,而不是给整个哈希加一个全局锁,有效降低锁冲突的概率)(推荐)
相比于 Hashtable 做了一系列的改进和优化,以 Java 1.8 为例:
- 1.读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁,加锁的方式仍然是使用 synchronized ,但是不是锁整个对象,而是“锁桶”(用每个链表的头节点作为锁对象),大大降低了锁冲突的概率。
- 充分利用 CAS ,size属性通过 CAS 来更新,避免出现重量级锁的情况。
- 优化了扩容方式:化整为零
1)发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去。
2)扩容期间,新老数组同时存在。
3)后续每个来操作 ConcurrentHashMap 的线程,都会参与搬家的过程,每个操作负责搬运一小部分元素。
4)搬完最后一个元素再把老数组删掉。
5)这个期间,插入只往新数组加。
6)这个期间,查找需要同时查数组和老数组。
在时间开销上 ConcurrentHashMap 再怎么优化,肯定不会高于HashMap,但是和Hashtable比就小很多了。
(就像 ConcurrentHashMap 每个哈希桶都有一把锁,只有两个线程访问的恰好是同一个哈希桶上的数据才会出现锁冲突)
Hashtable,HashMap,ConcurrentHashMap之间的区别?
- HashMap:线程不安全,key允许为null。
- Hashtable:线程安全,使用 synchronized 锁 Hashtable 对象,效率较低, key 不允许为null。
- ConcurrentHashMap:线程安全,使用 synchronized 锁每个链表头节点,锁冲突概率低,充分利用 CAS 机制,优化了扩容方式,key不允许为null。