1.常见的锁策略
如果想要自己实现一把锁,就需要关注锁策略。
1.1 悲观锁 与 乐观锁
悲观锁与乐观锁不是针对某一种具体的锁,而是某个具体的锁具有 "悲观" 特性或者 "乐观" 特性。
悲观锁:
- 加锁的时候,预测接下来的锁竞争情况非常激烈,需要针对这样的激烈情况额外做一些工作。
- 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁, 这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
- 加锁的时候,预测接下来的锁竞争情况不激烈,就不需要做额外的工作。
- 假设数据⼀般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则返回用户错误的信息,让用户决定如何去做。
示例:同学 A 和 同学 B 想请教老师⼀个问题.
- 同学 A 认为 "老师是比较忙的,问问题老师不⼀定有空解答",因此同学 A 会先给老师师发消息询问是否有空(相当于加锁操作),得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等⼀段时间, 下次再来和老师师确定时间. 这个是悲观锁。
- 同学 B 认为 "老师是比较闲的, 问问题老师大概率是有空解答的",因此同学 B 直接就来找老师.(没 加锁, 直接访问资源),如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁。
这两种锁不能说谁优谁劣,要看当前的场景是否合适。
synchronized 即是悲观锁,又是乐观锁,它是自适应的:初始使用的是乐观锁策略,当发现锁竞争比较频繁的时候,会自动切换成悲观锁策略。
2.2 重量级锁 与 轻量级锁
悲观锁和乐观锁描述的是++加锁时遇到的场景++ ,而重量级锁和轻量级锁描述的则是++加锁后遇到的场景的解决方案。++
重量级锁,当悲观场景下,付出的代价更多,更低效;轻量级锁,当乐观场景下,付出的代价更小,更高效。
锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
- CPU 提供了 "原子操作指令"。
- 操作系统基于 CPU 的原子指令, 实现了 mutex(互斥量) 互斥锁。(Java 中的 mutex = 任何能保证同一时间只有一个线程执行某段代码的锁机制,互斥+阻塞等待)
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。

注意:synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
重量级锁:加锁机制依赖操作系统提供的 mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
- 这两个操作, 成本比较高. ⼀旦涉及到用户态和内核态的切换, 就意味着 "沧海桑⽥".
轻量级锁:加锁机制尽可能不使用 mutex,而是尽量在用户态代码完成,如果最后搞不定,再使用 mutex
- 少量的内核态用户态切换
- 不太容易引发线程调度
synchronized 依然自适应:开始是⼀个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。
2.3 挂起等待锁 与 自旋锁
挂起等待锁 是重量级锁的典型实现,是++操作系统内核级别++ 的,加锁的时候发现竞争(锁竞争激烈),就会使该线程进入阻塞状态,后续就需要内核进行唤醒了。获取锁的周期长,很难做到及时获取,但是这个过程不需要一直消耗CPU资源,把CPU省出来做别的事(让出了CPU的调度)。
而 自旋锁 是轻量级锁的典型实现,是++应用程序级别的(用户态级别)++ ,加锁的时候发现竞争(锁竞争不激烈),一般也不是进入阻塞,而是通过忙等的形式来进行等待。获取锁的周期短,及时获取,但是这个过程会一直消耗CPU资源。
- 忙等,是乐观锁的场景,本身遇到的锁竞争概率很小,真的遇到竞争,在短时间内就能拿到锁。

在 JVM 内部,会统计每个锁竞争的激烈程度:
- 如果竞争不激烈,此时 synchronized 就是一个轻量级锁(自旋锁)
- 如果竞争不激烈,此时 synchronized 就是一个重量级锁(挂起等待锁)
2.4 普通互斥锁 与 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读取之间都需要进行互斥。如果两种场景下都用同⼀个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁,在执行加锁操作时需要额外表明读写意图,读取数据之间并不互斥,而写数据则要求与任何人互斥。
⼀个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据
- 两个线程都只是读⼀个数据, 此时并没有线程安全问题,直接并发的读取即可
- 两个线程都要写⼀个数据,有线程安全问题
- 一个线程读另外⼀个线程写,也有线程安全问题.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了ReentrantReadWriteLock类,实现 了读写锁。
- ReentrantReadWriteLock.ReadLock 类表示⼀个读锁,这个对象提供了 lock / unlock 方法进行加锁解锁。
- ReentrantReadWriteLock.WriteLock 类表示⼀个写锁,这个对象也提供了 lock / unlock 方法进行加锁解锁。
其中,
- 读加锁和读加锁之间,不互斥
- 写加锁和写加锁之间,互斥
- 读加锁和写加锁之间,互斥
读写锁特别适合于 "频繁读,不频繁写" 的场景中。
synchronized 不是一个读写锁。
2.5 可重入锁 与 不可重入锁
可重入锁的字⾯意思是"可以重新进入的锁",即++允许同⼀个线程多次获取同⼀把锁++。
不可重入锁就是,同一个线程多次获取同一把锁,在第二次加锁的时候,会阻塞等待,直到第一次加锁被释放,才能再次获取锁,但是第一次释放锁也是由这个线程来完成的,此时这个线程阻塞在第二次加锁,也无法对第一次加锁进行解锁,这样会造成死锁。
Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。
2.6 公平锁 与 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功,然后 B 再尝试获取锁,获取失败, 阻塞等待; 然后 C 也尝试获取锁,C 也获取失败,也阻塞等待,当线程 A 释放锁的时候, 会发生啥呢?
- 公平锁: 遵守 "先来后到",B ⽐ C 先来,当 A 释放锁的之后,B 就能先于 C 获取到锁。
- ⾮公平锁: 不遵守 "先来后到",B 和 C 都有可能获取到锁,概率均等。
操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁。如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序(优先级队列)
公平锁和非公平锁没有好坏之分,关键还是看适用场景。
synchronized 是非公平锁。
2. CAS
CAS: 全称Compare and swap,字⾯意思:"比较并交换" ,一个CAS涉及以下的操作:
- 假设内存中的原数据V,旧的预期值A,需要修改的新值B。
-
- 比较 A 与 V 是否相等。(比较)
-
- 如果比较相等,将 B 写⼊ V。(交换)
-
- 返回操作是否成功。
CAS的伪代码:下述的代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个伪代码只是辅助理解CAS的工作流程:
java
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
CAS,其实是CPU的一条指令,是原子的。操作系统会把这个指令进行封装,提供一些 API,JVM可以调用这样的 API,也就是调用 CAS 这样的操作。
CAS的应用
1)实现原子类
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的,这个包中,还对 boolean,int,long 等这些类型进行了封装。

使用原子类的目的,就是为了避免加锁。典型的就是 AtomicInteger 类,其中的其中的
- getAndIncrement() 方法,就相当于 i++ 操作
- incrementAndGet() 方法,就相当于 ++i 操作
- decrementAndGet()方法,就相当于 --i 操作
- getAndDecrement()方法,就相当于 i-- 操作
- addAndGet(i) 方法,就相当于 += i 操作
- 这说明,++,-- 等这样的操作,针对内置类型都是非原子的,而针对原子类,则是原子的,基于CAS实现,不涉及加锁操作。
count++ ,这样的操作是线程不安全的,需要通过加锁来保证线程安全,但是又认为加锁的效率比较低,就可以通过 CAS 来实现 count++,确保性能,同时保证线程安全。
如以下的代码,是线程不安全的,实现线程安全而又不需要加锁,可以使用 原子类AtomicInteger 来代替 int:

使用原子类:

这样就能保证线程安全:

注意:原子类,是专有名词,特指 atomic 这个包中的类,通过 synchronized 关键字保证一个修改的原子性,和原子类不相关。在多线程修改一个变量的情境中,直接使用原子类比加锁更多。
以上的多线程中使用原子类代替 int 类型的count,那么count++ 是线程安全的,我们可以通过以下的调度图进一步理解:
基于CAS的原子类 AtomicInteger 伪代码实现,其中 oldValue 理解为寄存器,value 是内存:
java
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}

上述代码中,出现线程切换,由于在进行自增之前,先判定当前寄存器读到的值是否是"科学的值",如果不是,就会进行重新读取。另一方面,赋值和判断是原子的,一个指令完成的,无法构造出调度顺序,它只能按照既定的顺序取调度,即无法在赋值和判断中间加入其他的线程逻辑。
2)实现自旋锁
自旋锁伪代码:
java
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}

CAS的 ABA 问题
使用CAS能够进行线程安全的编程,核心就是先比较相等,内存和寄存器的值是否相等,这里本质是在判断是否有其他线程插入进来,做了一些修改:如果发现这里寄存器和内存的值一样,就可以认为没有线程穿插过来修改,或者修改完又改回去了,因此,接下来的修改操作就是线程安全的。
而上述的修改完再改回去的过程,就是ABA问题。
1)什么是ABA问题
设存在两个线程 t1 和 t2,有⼀个共享变量 num,初始值为 A。 接下来, 线程 t1 想使⽤ CAS 把 num 值改成 Z,那么就需要
- 先读取 num 的值,记录到 oldNum 变量中
- 使用 CAS 判定当前 num 的值是否为 A,如果为 A,就修改成 Z。
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A 。
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这⼀步, t1 线程无法区分当前这个变量始终是 A, 还是经历了⼀个变化过程,这就是ABA问题。
2)ABA引来的Bug
CAS的ABA问题,其实大部分情况下,即使出现了ABA问题,最终的程序一般问题也不大。只有一些极端的场景,ABA问题才会产生一些严重的Bug。
示例:账户中有1000余额,要取款500。按照 CAS 的方式来执行:
java
int oldBalance = balance;
CAS(balance,oldBalance,oldBalance-500);
正常情况下:

异常过程:此时还有第三个线程,在线程1取款500后,转账500给账户,那么此时的账户余额又变回了1000:

3)解决方案:版本号
给要修改的值, 引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
CAS 操作在读取旧值的同时,也要读取版本号,真正修改的时候:
- 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 +1
- 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)
java
int oldVersion = version;
int oldBalance = balance;
if(CAS(version,oldVersion,oldVersion+1)) {
CAS(balance,oldBalance,oldBalance-500);
}

在 Java 标准库中提供了AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能。
3.synchronized 原理
结合上⾯的锁策略,我们就可以总结出,synchronized 具有以下特性:
- 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
- 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
- 实现轻量级锁的时候大概率用到的自旋锁策略。
- 是⼀种不公平锁。
- 是⼀种可重入锁。
- 不是读写锁。
3.1 加锁工作过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次的锁升级(自适应)。

- 1)偏向锁:
- 第一次尝试加锁的线程,优先进入偏向锁状态
- 偏向锁不是真的 "加锁", 只是给对象头中做⼀个 "偏向锁的标记", 记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),如果后续有其他线程来竞争该锁 (刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前 申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进⼊⼀般的轻量级锁状态。
- 偏向锁本质上相当于"延迟加锁",能不加锁就不加锁, 尽量来避免不必要的加锁开销(懒汉模式的体现)。但是该做的标记还是得做的, 否则⽆法区分何时需要真正加锁。
- 2)轻量级锁:
- 随着其他线程进入竞争,偏向锁状态被消除, 进⼊轻量级锁状态(自适应的自旋锁)。此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新⼀块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续⾃旋式的等待(并不放弃 CPU)
- 随着其他线程进入竞争,偏向锁状态被消除, 进⼊轻量级锁状态(自适应的自旋锁)。此处的轻量级锁就是通过 CAS 来实现.
- 3)重量级锁:
- 如果竞争进⼀步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁,此处的重量级锁就是指用到内核提供的 mutex
- 执行加锁操作, 先进入内核态
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起等待被操作系统唤醒.
- 经历了⼀系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁
- 如果竞争进⼀步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁,此处的重量级锁就是指用到内核提供的 mutex

当前JVM中,只提供了 "锁升级" ,不能 "锁降级"。
3.2 其他优化操作
锁消除
也是编译器优化的一种体现。编译器会判定,当前这个代码逻辑是否真的需要加锁,如果确实不需要加锁,但是已经写了 synchronized ,就会自动把 synchronized 消除。
锁粗化
即为锁的粒度,加锁和解锁之间,包含的代码越多,就认为锁的粒度越粗,如果包含的代码越少,就认为锁的粒度越细。++注意++,不是指代码的行数多少,而是指实际执行的指令/时间。
在一个代码中,反复针对一个细粒度的代码加锁,就可能被优化为更粗粒度的加锁。
如以下的代码,细粒度的写法,就是执行多次的加锁解锁操作:
java
for(int i = 0;i < 500; i++) {
synchronized(this) {
count++;
}
synchronized(this) {
count++;
}
synchronized(this) {
count++;
}
}
将上述代码优化成粗粒度的写法,即优化成一次加锁解锁:
java
for(int i = 0;i < 500; i++) {
synchronized(this) {
count++;
count++;
count++;
}
}
4.JUC(java.util.concurrent) 的常见类
4.1 Callable 接口
Callable是一个接口,相当于把线程包装成一个返回值,++方便通过多线程的方式计算结果++。
Callable 和 Runnable 接口是并列的关系,只不过Callable是一个泛型接口,它有一个V call()方法,会抛出 Exception 异常,而Runnable没有返回值,它有一个void run()抽象方法。


Callable 和 Runnable 一样,只是定义了一个任务,Callable是定义了一个带有返回值的任务,并没有真的在执行该任务,执行还是需要搭配 Thread 线程对象。
而 Thread 的构造方法,没有提供版本,传入Callable对象:


或者说,Thread 类的构造方法不能直接传入带返回值的任务,在Java中,Thread构造器只接受Runnable(无返回值)或直接重写run方法。Callable是有返回值的,且它的call还会抛出异常,不能直接传给Thread。Thread 本身的设计就是"执行但不返回结果"的命令模式。
如何让 Thread 执行带返回值的任务?
用 FutureTask 包装:FutureTask 实现了 Runnable,所以可以塞进 Thread 来执行;之后通过 FutureTask.get() 获取结果,即获取到FutureTask 的返回值,这个返回值就来自于 Callable 的 call()方法。


get 方法会抛出两个异常,其中一个是 InterruptException,说明 FutureTask中的get()方法也是一个带有阻塞的方法,可以阻塞等待,如果当前线程执行完毕,get 就拿到返回结果,如果没有执行完毕,会一直阻塞等待直到执行完成。

示例:打印 1+2+......+100 的结果:


也可以直接使用 Runnable 实现示例的功能,就不会因为使用 Callable 而需要 FutureTask 进行中转:
只不过需要额外创建一个变量来接受结果,且需要手动调用 join 方法来阻塞等待线程执行完毕:

到这里,顺便复习一下创建线程的写法:
- 继承 Thread 类,定义单独的类实现,或者匿名内部类实现。
- 实现 Runnable 接口,定义单独的类实现,或者匿名内部类实现。
- Lambda 反射实现。
- 实现 Callable 接口,定义单独的类实现,或者匿名内部类实现。
- 线程池,ThreadFactory类。
4.2 ReentrantLock 类
可重入互斥锁,和 synchronized 定位类似, 都是用来实现互斥效果,保证线程安全。
ReentrantLock 的用法:
- lock(): 加锁, 如果获取不到锁就死等。
- trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁。
- unlock(): 解锁。

与 synchronized 对比,还是 synchronized 更好。
synchronized 和 ReentrantLock 之间的区别:
- synchronized 是关键字(内部实现是JVM通过C++实现的),ReentrantLock 是Java标准库提供的类。
- synchronized 通过代码块控制加锁解锁,ReentrantLock 需要 lock/unlock 方法,需要注意漏写的问题。
- ReentrantLock 除了提供 lock,unlock 外,还提供了一个方法,trylock(),设置超时时间,等待时间达到超时时间再返回 true/false,注意它是不会阻塞的,加锁成功就返回true,不成功就返回false,让调用者判定返回值,决定接下来怎么做。
- synchronized 是不公平锁,ReentrantLock 默认是不公平锁,但是提供了公平锁的实现,只要在构造方法时传入 true,ReentrantLock就变成了一个 公平锁。
- ReentrantLock 搭配的 等待通知 机制是 Condition 类,相比于 wait和notify,功能更强大。
4.3 Semaphore 信号量
Semaphore类 表示信号量, 用来表示 "可用资源的个数",本质上就是⼀个计数器。能够协调多个线程/进程之间的资源分配。
- 申请一个资源,计数器就 -1。
- 释放一个资源,计数器就 +1。
- 计数器为0,继续申请,就会阻塞等待。
Semaphore 的申请操作,叫做 P操作 ,释放操作,叫做 V操作。PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
在Java中,使用 acquire 和 release 来表示 P 和 V 操作,acquire()是带有阻塞的方法。


特殊情况:
- 当指定 Semaphore 的可用资源为1个信号量时,要么取值是1,要么是0,也称Semaphore(1) 为二元信号量,就等价于 锁。
- 那么普通的信号量,就相当于锁的更广泛的推广。如果是普通的 N 的信号量,就可以限制同时可以有多少个线程来执行某个逻辑。

4.4 CountDownLatch
在使用多线程时,经常把一个大任务拆分成多个小任务,使用多线程执行这些子任务,从而提高程序的效率。
那么,如何知道,这多个子任务都完成了,也就是整个任务都完成了?
------ 就是通过 CountDownLatch 类来观察的,它的作用就是同时等待 N 个任务执行结束。
- CountDownLatch 的构造方法指定 参数,描述拆成了多少个任务。
- 例如,构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成
- 每个任务执行完毕后,都调用一次 countDown() 方法,该方法每调用一次,CountDownLatch内部的计数器就会 减1。
- 当一共调用了10次 countDown,说明任务全都完成了
- 在主线程中调用 await() 方法,阻塞等待所有任务执行完毕,当计数器归零后,所有等待的线程会被释放,即所有线程执行任务完毕,await 就会立即返回。
示例:现在把整个任务拆成10个部分,每个部分视为一个子任务,把这10个子任务丢到线程池中,让线程池中工作线程执行,或者也可以安排10个独立线程执行。

JUC中,除了上述的常见类,还有 原子类和线程池,这两个都在前面介绍过了,这里不再展开。
5.线程安全集合类
5.1 多线程环境使用ArrayList
- 方法1:自行加锁【推荐】:分析清楚要把哪些代码打包到一起,成为一个 "原子" 操作。
- 方法2:套壳,使用 Collections.synchronizedList(new ArrayList) 方法,放回的 List 的各种关键方法都带有 synchronized ,类似于 Vector、Hashtable、StringBuffer。
- 方法3:使用 CopyOnWriteArrayList
- CopyOnWrite容器即写时复制的容器,写时拷贝:
- 例如,CopyOnWrite容器 其中元素为1,2,3,4,当多线程读取时,不会有问题。但是一旦某个线程进行写操作,比如修改 1 -> 100,
- 由于CopyOnWrite,修改元素并不会真的在容器中修改,而是先将当前容器进行Copy,复制出一个新的容器,然后在新的容器中修改元素,
- 注意,在复制过程中,如果其他线程在读,就直接读到的是旧版本的数据,即原容器中的数据,当新版本的容器复制完毕且修改完毕,就将原容器的引用指向新容器,确保读取过程中,要么读的是旧数据,要么读到的是新数据,不会读到修改一半的数据。
- 这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是⼀种读写分离的思想,读和写不同的容器。
CopyOnWrite容器的优点,就是在读多写少的情况下,性能很高,不需要加锁竞争,不会产生阻塞。
而它的缺点也很明显:
- 当数组特别大时,占用内存较多,非常低效
- 如果多个线程同时修改,那就要复制多份的数组,修改完要引用的时候,会难以分辨要引用到哪一个线程的数组,可能每个线程修改的部分不一样。
所以它适合特定的场景,例如服务器进行重新加载配置的时候。
5.2 多线程环境使用队列
- ArrayBlockingQueue:基于数组实现的阻塞队列。
- LinkedBlockingQueue:基于链表实现的阻塞队列。
- PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列。
- TransferQueue:最多只包含一个元素的阻塞队列。
5.3 多线程环境使用哈希表
HashMap本身是线程不安全的,在多线程环境下哈希表可以使用:
**1)**Hashtable:就是给各种 public 方法都加上 synchronized,

- 这相当于针对 Hashtable 对象本身(this)加锁,如果多线程访问同一个Hashtable 就会直接造成锁冲突,即任意两个线程,访问两个不同的元素都会产生锁竞争,影响效率
- size 属性也是通过 synchronized 来控制同步,也是比较慢的,即当多个线程添加或者删除哈希表中的元素,size也需要相应的++ / --,而此过程中只有一个锁,需要阻塞等待某一个线程添加或删除元素后size++/-- 后,释放锁再进行下一步的操作,保证 size 正确,效率低下。
- ⼀旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝(需要一个更大的数组把旧哈希表中的所有元素全部搬到新哈希表中), 效率会非常低,即一旦某一个线程往哈希表中添加元素,表刚好满了,触发了扩容,就必须整个阻塞等待到该线程完成对哈希表的扩容。

2) ConcurrentHashMap:它是按照 桶 级别进行加锁的,而不是像Hashtable一样给整个哈希表加一个全局锁,有效降低了了锁冲突的概率。
相比于 Hashtable 做出了⼀系列的改进和优化,以 Java1.8 为例,
- 读操作没有加锁 (但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁 ,加锁的方式仍然是是用 synchronized, 但是不是锁整个对象,而是 "锁桶 " (用每个链表的头结点作为锁对象),大大降低了锁冲突的概率.。
- 即如果修改的两个元素,在不同的 链表/桶 上,本身就不涉及线程安全问题,因为修改的是不同的变量,而如果修改同一个链表/桶上的两个元素,可能会有线程安全问题,比如把这个两个元素插入到同一个元素的后边,那么这两个元素就会对这同一个元素的后边的位置产生竞争(谁先插入谁后插入)。
- 上述的锁桶方式是从 jdk8 时期开始引入的,在这之前,ConurrentHashMap 采用 "分段锁" 方案,就是把这些链表分成几组,每个组安排一个锁,而不像现在这样每个链表都安排一个锁,显然没有这种方法好。
- 充分利用 CAS 特性,比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况。
- 优化了扩容方式:化整为零
- 发现需要扩容的线程, 只需要创建⼀个新的数组, 同时只搬⼏个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运⼀小部分元素.
- 搬完最后⼀个元素再把老数组删掉.
- 这个期间,插入只往新数组加,查找需要同时查新数组和老数组。