系列文章目录
JAVAEE初阶第三节------多线程进阶
文章目录
- 系列文章目录
- [一. 常见的锁策略](#一. 常见的锁策略)
-
- 1.乐观锁和悲观锁
- [2. 轻量级锁和重量级锁](#2. 轻量级锁和重量级锁)
- 3.自旋锁和挂起等待锁
- [4. 普通互斥锁和读写锁](#4. 普通互斥锁和读写锁)
- [5. 公平锁和非公平锁](#5. 公平锁和非公平锁)
- 6.可重入锁和不可重入锁
- [二. synchronized的优化手段](#二. synchronized的优化手段)
-
- 1.synchronized的锁升级
- [2. synchronized的其他的优化操作](#2. synchronized的其他的优化操作)
- 三.CAS
- [四.Callable 接口](#四.Callable 接口)
-
- [1.Callable 的用法入](#1.Callable 的用法入)
- [五.JUC(java.util.concurrent) 的常见类](#五.JUC(java.util.concurrent) 的常见类)
-
- 1.ReentrantLock
- [2. 原子类](#2. 原子类)
- [3. 信号量 Semaphore](#3. 信号量 Semaphore)
- [4. CountDownLatch](#4. CountDownLatch)
- 5.线程池
- 六.线程安全的集合类
-
- [1. 多线程环境使用 ArrayList](#1. 多线程环境使用 ArrayList)
- [2. 多线程环境使用队列](#2. 多线程环境使用队列)
- [3. 多线程环境使用哈希表](#3. 多线程环境使用哈希表)
- 七.死锁
多线程进阶
- 常见的锁策略
- synchronized的优化手段
- CAS
- Callable 接口
- JUC(java.util.concurrent) 的常见类
- 线程安全的集合类
- 死锁
一. 常见的锁策略
1.乐观锁和悲观锁
这是两种不同的锁的实现方式:
乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候就不会做太多的工作。加锁过程做的事情比较少,加锁的速度可能就更快,但是更容易引入一些其他的问题(但是可能会消耗更多的CPU资源)悲观锁,在加锁之前,预估当前锁冲突出现的概率比较大,因此加锁的时候,就会做更多的工作。做的事情更多,加锁的速度可能更慢,但是整个过程中不容易出现其他问题。
2. 轻量级锁和重量级锁
轻量级锁,加锁的开销小,加锁的速度更快。=>轻量级锁,一般就是乐观锁。
重量级锁,加锁的开销更大,加锁速度更慢。=>重量级锁,一般也就是悲观锁。
轻量重量,加锁之后,对结果的评价,而悲观乐观,是加锁之前,对未发生的事情进行的预估。整体来说,这两种角度,描述的是同一个事情。
3.自旋锁和挂起等待锁
自旋锁就是轻量级锁的一种典型实现。
自旋锁进行加锁的时候搭配一个while循环。如果加锁成功,自然循环结束。如果加锁不成功,不是阻塞放弃cpu,而是进行下一次循环,再次尝试获取到锁。这个反复快速执行的过程,就称为自旋。</mark(一旦其他线程释放了锁,就能第一时间拿到锁)
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
- 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的).
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
挂起等待锁就是重量级锁的一种典型实现,同时也是一种悲观锁.重量级锁的体现:进行挂起等待的时候,就需要内核调度器介入。这一块要完成的操作就多了。真正获取到锁要花的时间也就更多一些了。
悲观锁:这个锁可以适用于锁冲突激烈的情况
线程一旦进入阻塞,就需要重新参与系统的调度,啥时候能够调度上CPU就是未知数了。但是好处就是这个阻塞的过程中把CPU资源让出来,用来做点别的事情了.
4. 普通互斥锁和读写锁
普通互斥锁类似于synchronized操作涉及到加锁和解锁。
这里的读写锁,把加锁分成两种情况:(1)加读锁,一个线程加读锁的时候另一个线程,只能读,不能写。
(2)加写锁,一个线程加写锁的时候,另一个线程,不能写,也不能读。
读锁和读锁之间,不会出现锁冲突(不会阻塞)
写锁和写锁之间,会出现锁冲突(会阻塞)
读锁和写锁之间,会出现锁冲突(会阻塞)
引入读写锁的原因:如果两个线程读,本身就是线程安全的!不需要进行互斥!如果使用synchronized这种方式加锁,两个线程读,也会产生互斥,产生阻塞。(对于性能有一定的损失)完全给读操作不加锁,也不行,就怕一个线程读一个线程写.可能会读到写了一半的数据.读写锁,就可以很好的解决上述问题.(读写锁就可以节省这些并发读之间锁冲突的开销,这就对于性能提升很明显了。)
5. 公平锁和非公平锁
公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁。
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程的先后顺序(使用公平锁,天然就可以避免线程饿死的问题)
非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁。
synchronized 是非公平锁
6.可重入锁和不可重入锁
一个线程针对这一把锁,连续加锁两次,不会死锁,就是可重入锁。会死锁,就是不可重入锁。
synchronized是可重入锁。系统自带的锁,是不可重入的锁(可重入锁中需要记录持有锁的线程是谁,加锁的次数的计数)
二. synchronized的优化手段
1.synchronized的锁升级
当线程执行到synchronized的时候,如果这个对象当前处于未加锁的状态,就会经历以下过程:
- 偏向锁阶段
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
- 轻量级锁阶段
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态.(自适应的自旋锁)于此同时,synchronized内部也会统计当前这个锁对象上,有多少个线程在参与竞争。当发现参与竞争的线程比较多了,就会进一步升级为重量级锁。
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源. 因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 "自适应"。
优势:另外的线程把锁释放了,就会第一时间拿到锁。劣势:比较消耗CPU。
- 重量级锁阶段
此时拿不到锁的线程就不会继续自旋了,而是进入"阻塞等待',就会让出cpu了。(不会使cpu占用率太高)等到当前线程释放锁的时候,就由系统随机唤醒一个线程来获取锁了。
此处锁只能升级,不能降级。自适应这个词,严格的说不算很严谨
2. synchronized的其他的优化操作
- 锁消除
锁消除也是synchronized中内置的优化策略,是编译器优化的一种方式。编译器编译代码的时候,如果发现这个代码,不需要加锁,就会自动把锁给消除掉。
这里的优化是比较保守的。比如,就只有一个线程,在这一个线程里,加锁了。或者说加锁代码中,没有涉及到"成员变量的修改",就只是一些局部变量都是不需要加锁的和其他的很多"模棱两可"的线程,编译器也不知道这里是要加还是不加,都不会去消除。
锁消除,针对一眼看上去就完全不涉及线程安全问题的代码,能够把锁消除掉。
- 锁粗化
锁的粗化会把多个细粒度的锁,合并成一个粗粒度的锁。
synchronized { } 大括号里包含的代码越少,就认为锁的粒度越细。包含的代码越多,就认为锁的粒度越粗。(通常情况下,是更偏好于让锁的粒度细一些,更有利于多个线程并发执行的,但是有的时候,是希望锁的粒度粗点也挺好)
三.CAS
1.概念
CAS: 全称Compare and swap,是一个CPU的指令。完成的任务就是:"比较并交换",一个 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;
}
比较address内存地址中的值和expected寄存器中的的值判断它们的值是否相同。
如果相同,就把swap寄存器的值和address内存中的值,进行交换,返回true.(说是"交换",也可以理解成"赋值".因为往往只关注内存中最终的值。因此在伪代码中体现的结果也是"赋值"而不是交换。)
如果不相同,则啥都不干,无事发生,返回false。
实际上一条CPU就能完成上面操作
基于CAS指令,编写线程安全的代码又有了一种新的方法。(当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。)之前线程安全都是靠加锁,加锁=>阻塞=>性能降低。使用CAS,不涉及加锁,不会阻塞。合理使用也能保证线程安全。(无锁编程)
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
2.CAS的应用
CAS本身是CPU指令,操作系统对指令进行了封装。而jvm又对操作系统提供的api又封装了一层.
JAVA中CAS的api放到了unsafe包里,这样的操作,涉及到一些系统底层的内容,使用不当的话可能会带来一些风险。一般不建议直接使用CAS
JAVA的标准库,对于CAS又进行了进一步的封装,提供了一些工具类,让咱们直接使用。其中最主要的一个工具,叫做"原子类"。
2.1 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作(没有加锁也能保证线程安全).
用"原子类"解决自增到12W的线程安全问题
java
public static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(()->{
for (int i = 0; i < 60000; i++) {
count.getAndIncrement();
}
});
Thread thread2 = new Thread(()->{
for (int i = 0; i < 60000; i++) {
count.getAndIncrement();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count = " + count);
}
之前的count++是3个指令,不是原子的操作。所以用synchronized将这三个指令打包成一个"原子"的就能解决线程安全问题。而count.getAndIncrement()这个操作本身就只有一个指令,是原子的。就能直接解决线程安全问题。
伪代码实现:
java
class AtomicInteger {
private int value;//实际上就是AtomicInteger类型的变量
public int getAndIncrement() {
int oldValue = value;//这个值就是初始化成Atomiclnteger里面保存的整数值value
while (CAS(value, oldValue, oldValue + 1) != true) {
oldValue = value;
}
return oldValue;
}
}
如果发现比较交换成功,循环就结束了。此时value已经被更新成value+1了。
如果没成功,就再来一次循环,直到成功为止.
多线程中的执行过程:
之前的线程不安全,是因为内存变了但是寄存器中的值没有跟着变,所以接下来的修改就出错了。使用CAS这种方式,就能确保识别出内存的值是不是变了。不变,才会进行修改(VaLue没有被修改)
如果Value变了,重新读取内存的值。确保是基于内存中的最新的值再进行修改,这样就非常巧妙的把之前的线程安全问题就解决了。
2.2 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码
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;
}
}
当owner不为null的时候,循环就会一直执行下去。通过这样的"忙等"来完成等待效果
此处自旋式的等,没有放弃cpu,不会参与到调度,也就没有调度开销了。
但是他的缺点就是要消耗更多的CPU资源.
3. CAS的ABA问题
3.1 概念
CAS在使用的时候,关键要点,是要判定当前内存的值是否是和寄存器中的值一样的.是一样的,就进行修改,不一样就啥都不做.这个本质上是判定,当前这个代码执行过程中,是否有其他线程穿插进来执行了。
CAS的ABA问题就是在这个基础上的一种的情况。
假设存在两个线程 thread1 和 thread2. 有一个共享变量 num, 初始值为 0.
在执行CAS之前,另一个线程把num从0变成了100,然后又从100变成了0。
一般来说,即使出现上述情况,也问题不大,不会产生啥BUG但是一些极端场景下就会出现BUG
3.2 ABA问题引来的BUG
假设有 100 块存款. 想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行-50操作.
此时期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
- 正常情况:
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期 望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50.此时线程2还在阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败
- 异常的过程:
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 线程3又存入了 50, 账户余额变成 100
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
这个时候, 扣款操作被执行了两次!!都是 ABA 问题引起的问题。
3.3 ABA问题的解决方法
解决方法:
(1)约定数据变化是单向的(只能增加或者只能减少),不能是双向的(又能增加又能减少)。
(2)对于本身就必须双向变化的数据,可以给它引入一个版本号。版本号这个数字就是只能增加,不能减少的.(主要方法)
对比理解上面的转账例子假设有 100 块存款. 想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行-50操作.
此时期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100, 版本号为 1, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 线程3又存入了 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败.
在 Java 标准库中提供了 AtomicStampedReference< E > 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能
四.Callable 接口
( 1 )继承Thread(包含了匿名内部类的方式)
( 2 )实现Runnable(包含了匿名内部类的方式)
( 3 )基于lambda
( 4 ) 基于Callable
( 5 )基于线程池
Runnable关注的是这个过程,不关注执行结果。Runnable提供的run方法,返回值类型是void。Callable要关注执行结果。Callable提供的call方法,返回值就是线程执行任务得到的结果.
1.Callable 的用法入
代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000
普通方法:
java
private static int sum = 0;
private static Object lock = new Object();
public static void main(String[] args) {
Thread thread = new Thread(new Runnable(){
@Override
public void run() {
synchronized(lock) {
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
sum = result;
lock.notify();
}
}
});
thread.start();
synchronized(lock) {
try {
lock.wait();
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("sum = " + sum);
}
可以看到, 上述代码需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
使用Callable更好的解决这个问题
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下.
- 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果
java
public static void main(String[] args) throws ExecutionException, InterruptedException{
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 1; i <= 1000; i++) {
result += i;
}
return result;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
// 接下来这个代码也不需要 join, 使用 futureTask 获取到结果.
System.out.println(futureTask.get());
}
五.JUC(java.util.concurrent) 的常见类
1.ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入"
ReentrantLock 的用法:lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁
使用ReentrantLock最好把unlock操作放到finally中。
java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
//操作代码
}
finally {
lock.unlock()
}
ReentrantLock 和 synchronized 的区别:
(1)synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
(2)synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
(3)synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
(4)synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式
java
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
(5)更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
2. 原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
以 AtomicInteger 举例,常见方法有:addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
3. 信号量 Semaphore
信号量, 用来表示 "可用资源的个数". 本质上就是一个理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
代码示例
- 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
- acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
- 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
java
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable(){
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
4. CountDownLatch
同时等待 N 个任务执行结束.
比如,多线程下载这样的场景,最终执行完成之后,要把所有内容拼到一起。这个拼接必然要等到所有线程执行完成。这时候使用CountDownLatch就可以很方便完成这个操作。
如果使用join方式,就只能使用每个线程执行一个任务。借助countDownLatch就可以让一个线程能执行多个任务。
代码示例:
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
- 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
java
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(10);
Runnable r = new Runnable(){
@Override
public void run() {
try {
Thread.sleep((long)(Math.random() * 100));
latch.countDown();
}
catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
new Thread(r).start();
}
// 必须等到 10 人全部回来
latch.await();
System.out.println("比赛结束");
}
5.线程池
详情可以看多线程基础(下)中的线程池部分
六.线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(关键方法加上了synchronized)(不建议用), 其他的集合类不是线程安全的
1. 多线程环境使用 ArrayList
- 自己使用同步机制 (synchronized 或者 ReentrantLock)
- Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
ArrayList 的关键操作上都带有 synchronized。
- 使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素, 添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
(1) 占用内存较多.
(2)新写的数据不能被第一时间读取到
2. 多线程环境使用队列
-
ArrayBlockingQueue - 基于数组实现的阻塞队列
-
LinkedBlockingQueue - 基于链表实现的阻塞队列
-
PriorityBlockingQueue - 基于堆实现的带优先级的阻塞队列
-
TransferQueue - 最多只包含一个元素的阻塞队列
3. 多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
Hashtable
ConcurrentHashMap
- Hashtable
只是简单的把关键方法加上了 synchronized 关键字.
java
public synchronized V put(K key,V value)
public synchronized V get(object key)
这相当于直接针对 Hashtable 对象本身加锁.
如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低
- ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例
(1) 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是用 synchronized, 但不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.(2)充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新,减少一些加锁. 避免出现重量级锁的情况. (针对哈希表元素个数的维护)
(3)优化了扩容方式: 化整为零 。
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
- 搬完最后一个元素再把老数组删掉.
- 这个期间, 插入只往新数组加.
- 这个期间, 查找需要同时查新数组和老数组
七.死锁
死锁问题具体可以看多线程基础(中)介绍的死锁问题