这里讨论的锁策略不仅仅局限于 Java,此篇幅主要是认识几种常见的锁策略,能够知道概念。
接下来提及到的都不是某个具体的锁,而是抽象的概念。
描述的是锁的特性,描述的是“一类锁”。
二者都是对后续场景中的锁冲突现象进行一个预估。
乐观锁:预测后续的场景中,不会出现很多锁冲突的现象。(后续的工作会更少)
悲观锁:预测后续的场景中,很容易出现锁冲突的现象。(后续会做出更多的工作来保证线程安全)
锁冲突:两个线程尝试获取同一把锁,一个线程能获取成功,另一个线程阻塞等待。
锁冲突的概率大还是小,对后续的工作,是有一定影响的。
重量级锁:加锁的开销是比较大的(花的时间多,占用系统资源多)
轻量级锁:加锁开销比较小的(花的时间少,占用系统资源少)
乐观悲观锁 vs 重量轻量锁
乐观悲观锁,是在加锁之前,对锁冲突概率的预测,决定工作的多少。
重量轻量,是在加锁之后,考量实际的锁的开销。
正是因为这样的概念存在重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量锁。但是此观点是不绝对的,反之也成立。
自旋锁:是轻量级锁的一种典型实现。
在用户态下,通过自旋的方式(while…循环),实现类似于加锁的效果。
这种锁,会消耗一定 cpu 资源,但是可以做到最快速度拿到锁。
等待挂起锁:是重量级锁的一种典型实现。
通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,是冲突的线程出现挂起(阻塞等待)
这种方式,消耗 cpu 资源更少.也就无法保证第一时间拿到锁。
互斥锁:就是简单的加锁(synchronized)解锁
读写锁:把读操作枷锁和写操作加锁分开了。
读锁:是在读的时候加锁。 写锁:是在写的时候加锁。
如果两个线程,都对读加锁加锁,则不会产生锁竞争。(多线程并发执行的效率就更高)
如果两个线程,一个线程写加锁,一个线程也是写加锁,则会产生锁竞争。
如果两个线程,一个线程写加锁,两一个线程读加锁,则也会产生锁竞争。
实际开发中,读操作的频率,往往比读操作,高更多。
java标准库中也提供了现成的读写锁。
公平锁:遵循先来后到。(通过一定的数据结构去实现)
非公平锁:一拥而上,抢占式。(原生)
操作系统自带的锁(pthread_mutex)属于是非公平锁。
一个线程,针对同一把锁,连续加锁多次。如果产生了死锁,则是不可重入锁,如果没有产生死锁,就是可重入锁。
可以按照字面意思来理解,可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
观察下面这串伪代码:
public synchronized void increase() {
synchronized (this) {
count ++;
}
}
1.调用方法,先针对this加锁。 此时假设加锁成功了。
⒉接下来往下执行到代码块中的 synchronized ,此时,还是针对this来进行加锁。
在不可重入锁中:
第二次 this 上的锁,得在 increase 方法执行完毕之后,才能释放。要想让代码继续往下执行,就需要把第二次加锁获取到,也就是把第一次加锁释放。要想把第一次加锁释放,又需要保证代码先继续执行。这就陷入了一个死锁状态,程序无法执行。(这个状态是非常不合理的,第二次尝试加锁的时候,该线程已经有了这个锁的权限了,这个时候不应该加锁失败,不应该阻塞等待)
不可重入锁:如果是一个不可重入锁。这把锁不会保存,是哪个线程对它加的锁,只要它当前处于加锁状态之后,收到了"加锁”这样的请求,就会拒绝当前加锁。而不管当下的线程是哪个。就会产生死锁。
可重入锁:一把可重入锁,是会让这个锁保存,是哪个线程加上的锁。后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候就可以灵活判定了。
注:synchronized 实际上是一个可重入锁。
可重入锁,是让锁记录了当前是哪个线程持有了锁,观察下面伪代码。
synchronized (this) { //这个是真正加了锁,下面的锁只是虚晃一枪。
synchronized (this) { //判定了一下持有线程就是当前线程
synchronized (this) { //同上
…………
} //执行到这个代码,出了代码块的时候,刚才加上的锁是否要释放?? 答案是:不释放。
} //如果在里层就释放了锁,意味着最外面的 synchronized 和次外层的代码,就没有处于锁的保护之中了
}
问题:如果加了 N 层锁,在遇到大括号时,JVM 咋知道当前这个大括号是最后一个(最外层的)呢??
答:让锁这里持有一个“计数器”就行了。让锁对象不光要记录是哪个线程持有的锁,同时再通过一个整形变量记录当前这个线程加了几次锁!!
什么是死锁??
死锁是一种严重的 BUG!!导致一个程序的线程 “卡死”, 无法正常工作!
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
1. 一个线程,一把锁,但是是不可重入锁。该线程针对这个锁连续加锁两次,就会出现死锁。
public synchronized void increase() {
synchronized (this) {
count ++;
}
}
2. 两个线程,两把锁。这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁。
public class Demo1 {
private static Object locker1 = new Object(); //第一把锁
private static Object locker2 = new Object(); //第二把锁
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) { //获取第一把锁,成功获取。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) { //获取第二把锁,由于 locker2 被占用,获取失败。(死锁)
System.out.println("t1 两把锁加锁成功!");
}
}
},"t1");
Thread t2 = new Thread(() -> {
synchronized (locker2) { //获取第二把锁,成功获取。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) { //获取第一把锁,由于 locker1 被占用,获取失败。(死锁)
System.out.println("t2 两把锁加锁成功!");
}
}
},"t2");
t1.start();
t2.start();
}
}
从 jconsole 中可以看到程序中两个线程中(t1、t2)的死锁。死锁的线程就僵住了,无法正常工作,会对程序造成严重的影响。
3. N个线程M把锁,哲学家就餐问题。
可以通过一个抽象的图来进行理解。有五个哲学家(五个线程),五根筷子(五把锁)。
每个哲学家,主要要做两件事:
其他设定:
基于上述的模型设定,绝大部分情况下,这些哲学家都是可以很好的工作。
但是,如果出现了极端情况,就会出现死锁。
比如,同一时刻,五个哲学家都想吃面,并且同时伸出左手拿起左边的筷子。再尝试伸右手拿右边的筷子。
解决方法:针对锁进行编号,并且规定加锁的顺序。每个线程如果要获取多把锁,必须先获取编号小的锁,后获取编号大的锁。
利用上述办法,1 2 3 4 号哲学家分别获取到 1 2 3 4 号筷子。当 5 号哲学家开始获取筷子时,只能去获取 4 号筷子,但是 4 号筷子已经被 4 号哲学家获取到了,因此只能阻塞等待 4 号哲学家用完后释放,才能获取到。当 1 号哲学家用完 1 5 两根筷子时,1 5 均被释放,2 号就可以获取到 1 号筷子……以此类推,当 4 号哲学家释放 4 号筷子时,5号哲学家才能开始动筷。
synchronized 加锁过程:代码中写了一个 synchronized 之后,这里可能会产生一系列的"自适应的过程",锁升级(锁膨胀)
无锁→偏向锁→轻量级锁→重量级锁
偏向锁,不是真的加锁,而只是做了一个"标记"。如果有别的线程来竞争锁了,才会真的加锁。如果没有别的线程竞争,就自始至终都不会真的加锁了。
加锁本身,有一定开销。能不加,就不加。非得是有人来竞争了,才会真的加锁~
偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量)。一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级成真正加锁的状态,让别人只能阻塞等待。
synchronized 通过自旋锁的方式来实现轻量级锁。
当一个线程把锁占用时,其它线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。
但是,后续如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量锁升级成重量级锁。
编译器,会智能的判定当前代码是否有必要加锁。
如果你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉。
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer(); sb.append("a"); sb.append("b"); sb.append("c"); sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁.。但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销。
关于"锁的粒度",如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大。
有的时候,希望锁的粒度小比较好,并发程度更高。
有的时候,也希望锁的粒度大比较好 (因为加锁解锁本身也有开销).
CAS: 全称Compare and swap,字面意思:”比较并交换“。
能够比较和交换某个寄存器中的值和内存中的值,看是否相等。如果相等,则把另外一个寄存器中的值和内存进行交换。
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 address 与 expectValue 是否相等。(比较)
- 如果比较相等,将 swapValue写入 address。(交换)
- 返回操作是否成功。
CAS 伪代码
boolean CAS(address, expectValue, swapValue) { //判断 address 与 expectValue 是否相等,若相等则将 swapValue 写入 address
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
比如,多线程针对一个 count 变量进行 ++。在 java 标准库中,已经提供了一组原子类。
基于CAS又能衍生出一套"无锁编程",进一步提高代码运行效率。
这里面提供了 自增/自减/自增任意值/自减任意值,这些操作,就可以基于 CAS 无锁编程的方式来实现。
上述的原子类,就是基于 CAS 来实现的。
//伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用 getAndIncrement ,同时假设 value 是 0.
在 CAS 中比较 value 和 oldValue 是否相等时,其实就是在检查当前 value 的值是不是变了。是不是被别的线程穿插进来做出修改了!!进一步就发现了当前的 ++ 操作不是一气呵成的原子操作了,一旦发现出现其他线程穿插的情况,立即重新读取内存的值准备下一次尝试~~
当两个线程并发的去执行++操作的时候,如果不加任何限制,就意味着,有时候,这俩++是串行的,能计算正确的。有的时候这俩++操作是穿插的,这个时候是会出现问题的。可以通过加锁保证线程安全:通过锁,强制避免出现穿插~~
原子类/CAS保证线程安全:借助CAS来识别当前是否出现“穿插"的情况,如果没穿插,此时直接修改,就是安全的。如果出现穿插了,就重新读取内存中的最新的值,再次尝试修改。
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
public class SpinLock {
private Thread owner = null; //用owner表示当前线程持有的锁,null为解锁状态。
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
//获取当前线程引用.哪个线程调用lock,这里得到的结果就是哪个线程的引用!
}
}
public void unlock (){
//当该锁已经处于加锁状态,这里就会返回false, cas不会进行实际的交换操作.接下来循环条件成立,继续进入下一轮循环.
this.owner = null;
}
}
上面讲到了,CAS 的关键要点,是比较 寄存器1 和 内存 中的值,通过这里的是否相等,来判定内存的值是否发生变化。
如果内存的值发生变化,则存在其他线程进行了修改。如果内存的值没有发生变化,则没有别的线程修改,接下来进行的修改就是安全的。
但是我们要想到一个问题,如果这里的值没变,就一定没有别的线程进行修改吗?
ABA 问题就描述了另一个线程,把变量的值从A->B,又从B->A。此时本线程区分不了,这个值是始终没变,还是出现变化又回来了的情况。
大部分情况下,就算是出现 ABA 问题,也没啥太大影响。但是在一些比较极端情况下,还是会出现问题。
虽然上述操作,概率比较小,也需要去考虑。
ABA 问题,CAS 基本的思路是 ok 的,但是主要是修改操作能够进行反复横跳,就容易让咱们 CAS 的判定失效。
我们也有相应的解决办法,可以给上述案例中的账户余额安排一个隔壁邻居 ——— 版本号。
给要修改的值,引入版本号。 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
在 Java 标准库中提供了 AtomicStampedReference
类。这个类可以对某个类进行包装,在内部就提供了上面描述的版本管理功能。
Callable 是一个 interface。相当于把线程封装了一个 “返回值”。方便程序猿借助多线程的方式计算结果。
Callable interface 也是创建线程的一种方式。
如果进行多线程操作,如果你只是关心多线程执行的过程,使用 Runnable 即可。(只关心过程)
如果是关心多线程的计算结果,使用Callable更合适。(比如说通过多线程,计算一个公式,返回结果)
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
Integer result = futureTask.get();
System.out.println(result);
}
}
使用 Callable 不能直接作为 Thread 的构造方法参数。而是需要用到 FutureTask 。
上面 Callable 线程结果啥时候能算出来??这是最关心的一点。使用 futureTask 就可以帮助咱们解决这个问题。
获取 call 方法的返回结果。get 类似于 join 一样,如果 call 方法没执行完,会阻塞等待。
Juc (java.util.concurrent) 的常见类也是并发编程。
这个锁,没有 synchronized 那么常用,但是也是一个可选的加锁的组件。
ReentrantLock 具有一些特点,是 synchronized 不具备的功能。
synchronized 锁对象是任意对象。ReentrantLock 锁对象就是自己本身。如果你多个线程针对不同的 ReentrantLock 调用 lock 方法,此时是不会产生锁竞争的。
计数需求
播放量、点赞量、投币量、转发量、收藏量等……
同一个视频,有很多人同时播放、点赞、收藏……
统计效果
统计出现错误的请求数量。使用原子类,记录出错的请求的数目
semaphore 是并发编程中的一个重要组件。它可以用来控制同时访问某个资源的线程数量。Semaphore维护了一个许可证集合,线程在访问资源前必须先获取许可证,如果许可证已经全部被占用,则线程必须等待其他线程释放许可证后才能获取许可证并访问资源。
准确来说,Semaphore 是一个计数器(变量),描述了“可用资源的个数”。
描述当前线程,是否“有临界资源可以使用”。(多个线程修改同一个变量,这个变量就可以认为是临界资源)
acquire 方法表示申请资源(P操作),release 方法表示释放资源(V操作)。
import java.util.concurrent.Semaphore;
// 信号量
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
// 构造方法中, 就可以用来指定计数器的初始值.
Semaphore semaphore = new Semaphore(4); //申请 4 个资源
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 1");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 2");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 3");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 4"); //到此为止所有资源已占用完,如果再申请资源则阻塞等待。
semaphore.release(); // 计数器 +1
System.out.println("执行 V 操作 1");
semaphore.acquire(); // 计数器 -1
System.out.println("执行 P 操作 5");
}
}
打印结果:
同时等待 N 个任务执行结束。
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
当需要把一个任务拆成多个任务,如何衡量现在是把多个任务都搞定了呢?这时候就需要用到 CountDownLatch.
import java.util.concurrent.CountDownLatch;
public class Demo3 {
public static void main(String[] args) throws InterruptedException {
// 构造方法中, 指定创建几个任务.
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(() -> {
System.out.println("线程" + id + "开始工作!");
try {
// 使用 sleep 代指某些耗时操作, 比如下载.
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程" + id + "结束工作!");
// 每个任务执行结束这里, 调用一下方法
// 把 10 个线程想象成短跑比赛的 10 个运动员. countDown 就是运动员撞线了.
countDownLatch.countDown();
});
t.start();
}
// 主线程如何知道上述所有的任务都完成了呢??
// 难道要在主线程中调用 10 次 join 嘛?
// 万一要是任务结束, 但是线程不需要结束, join 不就也不行了嘛?
// 主线程中可以使用 countDownLatch 负责等待任务结束.
// a => all 等待所有任务结束. 当调用 countDown 次数 < 初始设置的次数, await 就会阻塞.
countDownLatch.await();
System.out.println("多个线程的所有任务都执行完毕了!!");
}
}
原来的集合类, 大部分都不是线程安全的。
Vector,Stack,HashTable,是线程安全的(不建议用),其他的集合类不是线程安全的。
自己使用同步机制 (synchronized 或者 ReentrantLock)
Collections.synchronizedList(new ArrayList);
使用 CopyOnWriteArrayList
ArrayBlockingQueue
基于数组实现的阻塞队列
LinkedBlockingQueue
基于链表实现的阻塞队列
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
TransferQueue
最多只包含一个元素的阻塞队列
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用: