目录
一、常见的锁策略
1.1乐观锁 vs 悲观锁
1.2 读写锁
1.3重量级锁 vs 轻量级锁
1.4挂起等待锁和自旋锁
1.5公平锁 和非公平锁
1.6可重入锁 和 不可重入锁
二、CAS(Compare and swap)比较并交换
2.1CAS的实现
2.2 CAS中的ABA问题
三、Synchronized 原理
3.1加锁过程
3.2 锁的其他优化(锁的粒度)
3.3 Callable 接口
3.4 ReentrantLock 可重入互斥锁.
四、信号量Semaphore
五、CountDownLatch倒计时的锁存器
六、线程安全的集合类
6.1多线程环境使用 ArrayList
6.2 多线程环境使用队列
6.3 多线程环境使用哈希表
锁策略不局限于 Java . 任何和 "锁" 相关的话题, 都可能会涉及到以下内容. 这些特性主要是给锁的实现者来参考的.普通的程序猿也需要了解一些, 对于合理的使用锁也是有很大帮助的.
悲观锁(重量级锁): 假设冲突概率高甚至每次加锁都会冲突(总是假设出现坏情况)
乐观锁(轻量级锁):假设冲突概率高甚至每次加锁都会冲突,发现冲突才去解决;引入版本号:提交版本号>当前版本号(线程内版本号>内存中版本号)
小数据量可考虑乐观锁,Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。
synchronized对读写没有区分,如果读写都有,使用就会互斥
一个线程对于数据的访问, 主要存在两种操作: 读数据、 写数据.
锁的核心特性 "原子性", 追根溯源是 CPU 这样的硬件设备提供的:
悲观锁乐观锁描述的是应用场景,看锁冲不冲突;
重量级锁 和轻量级锁 与 应用场景 没啥关系,主要看 开销
重量级锁:重度依赖了 OS 提供了 mutex接口。加锁开销较大,往往在内核态完成,易引发线程调度
轻量级锁:尽可能不使用 mutex接口, 尽量在用户态代码完成, 再使用 mutex.加锁开销较小,往往在用户态完成。(少量的内核态用户态切换),用户态的代码更可控,更高效
自旋锁、挂起等待锁,都是在synchronized内置的。
1.自旋锁: 线程获取不到锁,会再快速再试一次,节省了OS调度线程的开销,比挂起等待锁能更及时获取到锁(用户态实现,轻量级锁),没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
2.挂起等待锁:获取不到锁就会阻塞等待,(啥时结束阻塞)取决于OS调度 --->线程挂起时,不占CPU(一般基于内核态的机制实现,较重),重量级锁
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁:不遵守 "先来后到". B 和 C 都有可能获取到锁.(抢占式执行)
实时操作系统:vxworks(凤河公司)线程调度可控;Windows、Linux:线程调度不可控
适用场合:
大部分情况使用---》非公平锁
期望线程调度的时间成本可控时,---》公平锁
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁;
实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
可重入锁:“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁不会阻塞自己(可重入锁、递归锁)
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。 Linux 系统提供的 mutex 是不可重入锁.
synchronized锁策略:
- 即是悲观锁也是乐观锁(根据锁竞争的激烈程度自适应)
- 不是读写锁,只是普通的互斥锁
- 既是轻量锁,也是重量锁(根据锁竞争的激烈程度自适应)
- 轻量级锁的实现基于自旋锁,重量级锁部分基于挂起等待锁来实现
- 非公平锁
- 可重入锁
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式),CAS也是多线程安全的一种实现手段。
假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。比较 两个内存 或者 寄存器和内存,操作是原子的。
伪代码(逻辑理解):
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
一条单独的CAS指令即可完成这个逻辑
(1)CAS 的实现
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理:
(2)CAS的应用
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.针对常用数据类型进行了疯涨,可基于CAS(不涉及线程等待)进行修改,并且线程安全(Atomic原子的)
import java.util.concurrent.atomic.AtomicInteger;
public class suoDemo {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
num.getAndIncrement(); //num自增
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i < 5000; i++) {
num.getAndIncrement();
}
});
t.start();
t2.start();
t.join();
t2.join();
}
}
CAS操作步骤:
- 相等则直接赋值
- 不等则进入循环,重新读取内存中的值,再次进行比较,然后再赋值
- 各自线程返回交换前的数值
伪代码实现:
CAS由CPU指令一次性完成比较和交换的过程,不加锁也可保证线程安全
若当前锁this.owner=null,则设置this.owner=Thread.currentThread()
public class SpinLock { //自旋锁 SpinLock
private Thread owner = null; // 通过 CAS 看当前锁是否被某个线程持有(null表示没加锁).
public void lock(){
while(!CAS(this.owner, null, Thread.currentThread())){
//循环调用 :锁已经被别的线程持有(不是null), 就自旋等待.
//锁没有被别的线程持有(null), 那么就把 owner 设为当前尝试加锁的线程.
}
}
public void unlock (){
this.owner = null;
}
}
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A。线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A(ABA 问题引来的 BUG)
ABA问题如何解决???
数据从A-->B -->A:
引入版本号是否一致,一致才替换(乐观锁机制),修改变量的时候比较的是版本号
给要修改的数据引入版本号。 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。当前版本号和之前读到的版本号一致, 就执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
(1)偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态(给对象头中做一个 "偏向锁的标记",记录锁属于哪个线程.)。
偏向锁本质:相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.(延时加锁,提前先设置一个标记,无冲突则不加锁;有冲突时再加锁-->变为轻量级锁(基于CAS的自旋锁,用户态完成不涉及内核态切换和阻塞,会耗费CPU))
(2)轻量级锁
其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(CAS实现,自适应的自旋锁):
(3) 重量级锁
若竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁。重量级锁就是指用到内核提供的 mutex接口:
编译器和JVM自行判断是否需要加锁(不需要时取消锁,需要时加锁)
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁的粒度:synchronized包含的代码多少:
实际开发过程中, 使用细粒度锁, 是期望释放锁时,其他线程能使用锁。但是实际上,可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
Callable 是一个 interface . 相当于把线程封装了一个 "返回值".
代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000:
- 创建一个匿名内部类, 实现 Callable ( 带泛型参数(返回值类型))接口
- 重写 Callable 的 call 方法, 完成累加的过程(任务的执行)
- 把 callable 实例使用 FutureTask 包装一下(辅助类)
- 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class classDemo2 {
public static void main(String[] args) {
Callable callable = new Callable() { //通过callable描述任务
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask task = new FutureTask<>(callable); //使用中间类FutureTask辅助线程执行任务
Thread t = new Thread(task); //创建线程为了完成此处的计算工作
t.start();
}
}
Callable 和 Runnable 的区别:
- Callable 描述带有返回值的任务;Runnable 描述不带返回值的任务.
- Callable 通常搭配 FutureTask (保存 Callable 的返回结果)来使用;Callable 往往是在另一个线程中执行的, 什么时候执行完并不确定.
- FutureTask 就可以负责这个等待结果出来的工作.
可重入互斥锁,是对synchronized的补充,保证线程安全,分开了加锁、解锁,也是用来实现互斥效果。ReentrantLock 的用法:
ReentrantLock和synchronized的区别:
- 1.synchronized 是一个关键字, 是 JVM 内部实现的( C++ ); ReentrantLock 是标准库的一个类, 在 JVM 外实现的( Java ).
- 2.synchronized 不需要手动释放锁,出了代码块锁自然释放; ReentrantLock 使用时需要手动释放,使用起来更灵活,但是也容易遗漏 unlock.
- 3.synchronized在申请锁失败时, 会死等; ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
- 4.synchronized: 非公平锁;ReentrantLock: 支持公平 和 非公平锁,且提供更强大的唤醒机制(Condition类)--->显式指定唤醒哪个线程
- 5. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒.唤醒的是随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 更精确控制唤醒某个指定线程.
如何选择???
- 锁竞争不激烈: 使用 synchronized, 效率更高, 自动释放更方便.
- 锁竞争激烈:使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
- 如果需要使用公平锁, 使用 ReentrantLock.
信号量:表示 "可用资源的个数". 本质上是一个计数器。锁为一个”二元信号量“,可用资源就一个,计数器取值(非0即1)
信号量的 P 操作:申请资源,计数器 -1 (acquire)
信号量的 V 操作:释放资源,计数器 +1 (release)
当计数器为0时,在申请资源就会出现阻塞等待
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
import java.util.concurrent.Semaphore;
public class classDemo3 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(4); //初始化表示可用资源4个
semaphore.acquire(); //申请资源P操作
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
// semaphore.acquire(); // 资源申请完毕,触发acquire等待机制
// System.out.println("申请成功");
semaphore.release();
System.out.println("释放资源");
}
}
同时等待 N 个任务执行结束,待所有任务的线程执行完毕,主线程调用latch.await()
import java.util.concurrent.CountDownLatch;
public class classDemo4 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10); // CountDownLatch(10)是构造方法,10表示任务个数
for (int i = 0; i < 10; i++) {
Thread t1 = new Thread(() ->{
try {
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "到达终点!");
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
}
latch.await(); //线程未执行完毕,await就阻塞,直至所有线程执行完毕,await才返回
}
}
1) 使用同步机制 (synchronized 或者 ReentrantLock)
2) Collections.synchronizedList(new ArrayList);
3) 使用 CopyOnWriteArrayList(写时拷贝:修改的时候会创建一个副本出来)双缓冲区策略
对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器是一种读写分离的思想,读和写不同的容器。
1) ArrayBlockingQueue:基于数组实现的阻塞队列
2) LinkedBlockingQueue:基于链表实现的阻塞队列
3) PriorityBlockingQueue:基于堆实现的带优先级的阻塞队列
4) TransferQueue:最多只包含一个元素的阻塞队列
HashMap 是线程不安全的。在多线程环境下使用哈希表可以使用:
ConcurrentHashMap锁对象:针对每个元素(哈希桶)
(1)Hashtable
简单的给方法加锁(针对this加锁),会导致锁竞争加大,效率降低.直接针对 Hashtable 对象本身加锁
(2)ConcurrentHashMap
相较于Hashtable使用粒度更小的锁,对每个元素象进行加锁,降低了锁冲突的概率。