目录
一、常见的锁策略
乐观锁VS悲观锁
读写锁
重量级锁VS轻量级锁
总结:
自旋锁(Spin Lock)
公平锁VS非公平锁
可重入锁VS不可重入锁
二、CAS
何为CAS
CAS有哪些应用
1)实现原子类
2)实现自旋锁
CAS的ABA问题
1)什么是ABA问题
2)ABA问题引来的bug
三、synchronized原理
基本特点:
加锁工作过程
其他的优化操作
锁消除
锁粗化
四、Callable接口
Callable的用法
五、JUC(java.util.concurrent)常见类
ReentrantLock
原子类
线程池
信号量Semaphore
六、CountDownLatch
一、常见的锁策略
常见的所策略主要有:
- 乐观锁,悲观锁
- 读写锁
- 重量级锁,轻量级锁
- 自旋锁
- 公平锁,非公平锁
- 可重入锁,不可重入锁
乐观锁VS悲观锁
这种是“锁的一种特性”,即“一类锁”,不是指具体的一把锁,而悲观乐观是对后续锁冲突是否激烈(频繁)给出的预测。
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,即预测接下来锁冲突的概率很大,就应该多做一些工作。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候(未加锁,直接访问资源),才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户的错误信息,让用户决定如何去做,即如果预测接下来锁冲突的概率很小,就可以少做一些工作。
synchronized初始使用乐观锁策略(未加锁,直接访问数据),当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入一个“版本号来解决”。
例如:
假设我们需要多线程修改用户账户余额。设当前余额为100,引入一个版本号version,初始值为1,并且我们规定“提交版本必须大于记录当前版本才能执行更新余额”。
1)线程1此时从内存中读出(version=1,balance =100),线程2也读入此信息(version=1,balance=100)。
2)线程1操作过程中并从其账户余额中扣除50(100-50),线程2从其账户余额中扣除20(100-20):
3)线程1完成修改工作,将数据版本号加1(version =2),连同账户扣除后余额(balance = 50),写回到内存中。
4)线程2完成了操作,也将版本号加1(version=2)试图向内存中提交数据(balance=80),但此时对比版本发现,线程2提交的数据版本号为2,数据库记录的当前版本也为2,不满足“提交版本必须大于记录当前版本才能执行更新”的乐观锁策略,就认为这次操作失败。
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方相互之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以通过利用读写锁,避免有时反复加锁,这样可以减少性能的损耗。
读写锁(readers-writer lock),看英文可以知道,在执行加锁操作的时候需要额外表明读写意图,不同读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问,主要存在两种操作:读数据和写数据。
- 两个线程都只是读一个数据,此时并没有线程安全问题。直接并发的读取即可。
- 两个线程都要写一个数据,有线程安全问题。
- 一个线程读另外一个线程写,这样也有线程安全问题。
读写锁就是把读操作和写操作区分对待,Java标准库提供了ReentrantReadWreiteLock类,实现了读写锁。
- ReentrantReadWreiteLock.ReadLcok类表示一个读锁,这个对象提供了lock/unlock方法进行加锁操作。
- ReentrantReadWreiteLock.WriteLock类表示一个写锁,这个对象也提供了lock/unlock方法进行加锁操作。
其中,
- 读加锁和读加锁之间不互斥,读的时候能读但不能写。
- 写加锁和写加锁之间互斥,写的时候不能写。
- 读加锁和写加锁之间互斥,读的时候不能写,写的时候不能读。
注意:只要是涉及到“互斥”,就会产生线程的挂起等待,一旦线程挂起,再次唤醒就不知道隔了多久了。
因此尽可能减少“互斥”的机会,就是提高效率的重要途径。
其中,读写锁特别适合于“频繁读,不频繁写”的场景中(这样的场景其实也就是非常广泛存在的)。
重量级锁VS轻量级锁
锁的核心特性“原子性”,这样的机制追根溯源是CPU这样的硬件设备提供的,是层层递进的。
- CPU提供了“原子操作指令”。
- 操作系统基于CPU的原子指令,实现了mutex互斥锁。
- jvm基于操作系统提供的互斥锁,实现了synchronized和ReentrantLock等关键字和类。
如下图:
可以看到,synchronized并不仅仅是对mutex进行封装,在synchronized内部还做了很多其它的工作。
重量级锁:加锁机制重度依赖了OS提供了mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
这两个操作成本比较高,一旦涉及到用户态和内核态的切换,就意味着资源消耗和系统状态变化是非常巨大和复杂的。
轻量级锁:加锁机制尽可能不使用mutex,而是尽量在用户态代码完成,实在搞不定了,再使用mutex。
- 少量的内核态用户切换。
- 不太容易引发线程调度。
理解用户态VS内核态
想象去银行办业务。
在窗外,自己做,这是用户态。用户态的时间成本是比较可控的。
在窗口内,工作人员做,这是内核态,内核态的时间成本是不太可控的。
如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。
总结:
轻量级锁,锁的开销比较小;重量级锁,锁的开销比较大。
乐观锁所预测冲突的概率小,少做了一些工作,通常也就是轻量级锁;悲观锁,所预测冲突的概率大,多做了一些工作,通常也就是重量级锁。
synchronized开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。
自旋锁(Spin Lock)
按之前的方式,线程在强锁失败后进入阻塞状态,放弃CPU,需要过很久才能被调度。
但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃CPU。这个时候就可以使用自旋锁来处理这样的问题。
自旋锁伪代码:
while(抢锁(lock)==失败){ }
从上面可以看到,如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来,这样会有更快地响应速度。
一旦锁被其他线程释放,就能第一时间获取到锁。
自旋锁是一种典型的轻量级锁的实现方式:
- 优点:没有放弃CPU。不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
- 缺点:如果锁被其他线程持有的时间比较久,这个循环会消耗CPU资源,那么就会持续的消耗CPU资源。(而互斥锁挂起等待的时间是不消耗CPU的,因为OS会将CPU分配给其他线程使用,挂起期间等待锁的线程是不会消耗CPU资源的)。
synchronized中的轻量级锁策略大概率就是通过自旋锁的方式实现的。
公平锁VS非公平锁
假设三个线程A、B、C,A先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待;然后,C也尝试获取锁,C也获取失败,也阻塞等待。
当线程A释放锁的时候,会发生啥呢?
- 公平锁:遵守“先来后到”,B比C先来的,当A释放锁的之后,B就能先于C获取到锁。
- 非公平锁:不遵守“先来后到”,B和C都可能获取到锁。
注意:
- 操作系统内部的线程调度可以视为是随机的。如果不做任何额外的限制,锁就是非公平锁。如果要想实现非公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。
- 公平锁和非公平锁没有好坏之分,关键还是看适用场景。
synchronized是非公平锁。
可重入锁VS不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中,这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(可重入锁也可以叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有线程的Lock实现类,包括synchronized关键字锁都是可重入的。
详细内容可以看这篇博客中的synchronized的可重入部分:https://blog.csdn.net/2302_76435884/article/details/143416705?spm=1001.2014.3001.5501
二、CAS
何为CAS
CAS:全称Compare and swap,字面意思:“比较并交换”,一个CAS涉及到一下操作:
比如有一个内存,M,现在还有两个寄存器,A,B,CAS(M,A,B):
- 如果M和A的值相同的话,就把M和B里的值进行交换,同时整个操作返回true。
- 如果M和A的值不同的话,无事发生,同时整个操作返回false。
CAS伪代码
下面的代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个伪代码只是辅助理解CAS的工作流程。
boolean CAS(address,expectValue,swapValue){if(&address == expectedValue){&address = swapValue;return true;}return false;
}
当多个线程同时对某个资源进行CAS操作,只有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS可以视为是一种乐观锁(或者可以理解成CAS是乐观锁的一种实现方式)
CAS其实是一个CPU指令,单个CPU指令是原子的。
CAS有哪些应用
1)实现原子类
标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式来实现的。
典型的就是AtomicInteger 类。其中的getAndIncrement相当于i++操作。
AtomicInteger atomicInteger = new AtomicInteger(0);
//相当于i++
atomicInteger.getAndIncrement();
class AtomicInteger{private int value;public int getAndIncrement(){int oldValue = value;while(CAS(value,oldValue,oldValue+1)!=true)){oldValue = value;}return oldValue;}
}
根据上面的代码,假设两个线程同时调用getAndIncrement
- 1)两个线程都读取value的值到oldValue中。(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实现更灵活的锁,获取到更多的控制权。
自旋锁伪代码:
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问题
1)什么是ABA问题
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,还是经历了一个变化过程。
就比如买了一个手机,无法判定这个手机是刚出厂的新手机还是被人用旧了的又翻新过的手机。
2)ABA问题引来的bug
大部分情况下,t2线程这样的一个反复横跳的改动,对于t1是否修改num是没有影响的。但是不排除一些特殊情况。
- 银行存款100,线程1获取到当前存款值为100,期望更新为50;线程2获取到当前存款为100,期望更新为50。
- 线程1执行扣款成功,存款被改成50,线程2阻塞等待中。
- 线程2执行之前,该银行卡号正好被转账50,账户余额变成100.
- 轮到线程2执行了,发现当前存款为100,和之前读到的100相同,再次执行扣款操作。
这个时候扣款操作被执行了两次!!!
解决方案:
给要修改的值,引入版本号。在CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
CAS操作在读取旧值的同时,也要读取版本号。
原理:
某一线程修改值后,值变化,版本号加一,该线程要更新内存中的值和版本号,当该线程的版本号大于内存中的版本号时,对内存中的版本号和值进行更新,否则对内存更新失败。
具体例子可以看本篇博客中的乐观锁悲观锁的版本号的例子。
在Java标准库中提供了AtomicStampReference<E>类。这个类可以对某个类进行包装,在内部就提供了上面描述的版本管理功能。而对于该类不再展开,有需要的可以自行查找文档了解。
三、synchronized原理
基本特点:
结合上面的锁策略,我们就可以总结出,synchronized具有以下特性(只考虑jdk1.8):
- 开始时是乐观锁,如果锁冲突频繁,就转换悲观锁。
- 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
- 实现轻量级锁的时候大概率用到的自旋锁策略。
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
加锁工作过程
JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。
此处只解释偏向锁:
第一个尝试加锁的线程,优先进入偏向锁状态:
- 偏向锁不是真的加锁,只是给对象头中做一个“偏向锁的标记”,记录这个锁属于哪个线程。
- 如果后续没有其他线程竞争该锁,那么就不用进行其他同步操作了(避免了解锁的开销)。
- 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。
偏向锁本质上相当于“延迟加锁”,能不加锁就不加锁,尽量来避免不必要的加锁开销。
但是该做的标记还是得做的,否则无法区分何时需要真正加锁。
其他的优化操作
锁消除
编译器+jvm判断锁是否可消除,如果可以,就直接消除。
如果在单个线程中使用StringBuffer,此时编译器就会自动的把synchronized给优化掉
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
上面代码的每个append的调用都会涉及加锁和解锁,但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销。
锁粗化
一段逻辑中如果出现多次加锁解锁,编译器+jvm会自动进行锁的粗化。
锁的粒度:粗和细
实际开发中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁,减少持有锁的时间,其他线程等待锁的时间也减少,从而提高并发执行的效率。
但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(涉及到反复的锁竞争),这种情况下jvm就会自动把锁粗化,避免频繁加锁释放锁。
举个锁粗化的例子:
方式一:
打电话,交代任务1,挂电话
打电话,交代任务2,挂电话
打电话,交代任务3,挂电话
方式二:
打电话,交代任务1,任务2,任务3,挂电话
显然,方式二是更高效的方案。
四、Callable接口
Callable的用法
Callable是一个interface,也是一种创建线程的方式。这个接口相当于把线程封装一个“返回值”,方便程序员借助多线程的方式计算结果。
代码示例:创建线程计算1+2+3+...+1000,不使用Callable版本
步骤:
- 创建一个类Result,包含一个sum,表示最终结果,lock表示线程同步使用的锁对象。
- main方法中先创建了Result实例,然后创建了一个线程t,在线程内部计算1+2+3+..+1000。
- 主线程同时使用wait等待线程t计算结束(注意,如果执行到wait之前,线程t已经计算完了,就不必等待了)。
- 当前线程t计算完毕后,通过notify唤醒主线程,主线程再打印结果 。
public class Solution {static class Result{public int sum = 0;public Object lock = new Object();}public static void main(String[] args) throws InterruptedException {Result result =new Result();Thread t = new Thread(){@Overridepublic void run() {int sum = 0;for(int i = 1;i<=1000;i++){sum+=i;}synchronized (result.lock){result.sum =sum;result.lock.notify();}}};t.start();synchronized (result.lock){while(result.sum == 0){result.lock.wait();}System.out.println(result.sum);}}
}
可以看到,上述代码需要一个辅助类Result,还需要使用一系列的加锁操作和wait,notify操作,代码复杂,容易出错。
使用Callable版本的:
- 创建一个匿名内部类,实现Callable接口,Callable带有泛型参数,泛型参数表示返回值类型。
- 重写Callable的call方法,完成累加的过程,直接通过返回值返回计算结果(call是Callable的核心方法)。
- 把callable实例使用FutureTask包装一下。
- 创建线程,线程的构造方法传入FutureTask,此时新线程就会执行FutureTask内部的Callable的call方法,完成计算。计算结果就放到了FutureTask对象中。
- 在主线程中调用futureTask.get()能够阻塞等待新线程计算完毕,并获取到FutureTask中的结果。
public class Solution {public static void main(String[] args) throws ExecutionException, InterruptedException {Callable<Integer> callable = new Callable<Integer>() {@Overridepublic 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();
//此处的get是获取到callable里面的返回结果的int result = futureTask.get();System.out.println(result);}
}
可以看到,使用Callable和FutureTask之后,代码简化了很多,也不必手动写线程同步代码了。
理解Callable:
- Callable和Runnable是相对的,都是描述一个“任务”。Callable描述的是带有返回值的任务。
- Runnable描述的是不带返回值的任务。
- Callable通常需要搭配FutureTask来使用。FutureTask用来保存Callable的返回结果。因为Callable往往是在另一个线程中执行的,啥时候执行完并不确定。
- FutureTask就可以负责这个等待结果出来的工作。
理解FutureTask:
想象去吃麻辣烫,当餐点完好后,后厨就开始做了,同时前台会给你一张“小票”,这个小票就是FutureTask。后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没。
五、JUC(java.util.concurrent)常见类
ReentrantLock
可重入互斥锁,和synchronized类似,都是用来实现互斥效果,保证线程安全的。
ReentrantLock也是可重入锁。“Reentrant”这个单词的原意就是“可重入”
ReentrantLock的用法:
- lock():加锁,如果获取不到锁就死等。
- trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃锁。
- unlock():解锁.
ReentrantLock lock = new ReentrantLock();
lock.lock();
try{
//working
}finally{
lock.unlock();
}
ReentrantLock和synchronized的区别:
- synchronized是一个关键字,是jvm内部实现的。ReentrantLock是标准库的一个类,在jvm外实现的(基于Java实现的)。
- synchronized使用时不需要手动释放锁;ReentrantLock使用时需要手动释放,使用起来更灵活,但是也容易遗漏unlock。
- synchronized在申请锁失败时,会死等。ReentrantLock可以通过trylock的方式等待一段时间就放弃。
- synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造方法传入一个true开启公平锁模式。
//ReentrantLock的构造方法
public ReentrantLock(boolean fair){sync = fair?new FairSync():new NonfairSync();
}
- 强大的唤醒机制。synchronized是通过Object的wait/notify实现等待-唤醒。每次唤醒的是一个随机等待的线程。ReentrantLock搭配Condition类实现等待-唤醒,可以更精确控制唤醒某个指定的线程。
如何选择使用哪个锁?
- 锁竞争不激烈的时候,使用synchronized,效率更高,自动释放更方便。
- 锁竞争激烈的时候,使用ReentrantLock,搭配trylock更灵活控制加锁行为,而不是死等。
- 如果需要使用公平锁,使用ReentrantLock。
原子类
上面的内容有讲到
线程池
本篇博客中有涉及到 线程池的内容
信号量Semaphore
信号量,用来表示“可用资源的个数”,本质上就是一个计数器。
理解信号量:
- 可以把信号量想象成是停车场的展示牌:当前有车位100个,表示有100个可用资源。
- 当有车开进去的时候,就相当于申请一个可用资源,可用车位就-1(这个称为信号量的p操作)。
- 当有车开出来的时候,就相当于释放一个可用资源,可用车位就+1(这个称为信号量的V操作)。
- 如果计数器的值已经是0了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源。
注意:Semaphore的PV操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
代码示例:
- 创建Semaphore实例,初始化为4,表示有4个可用资源。
- acquire方法表示申请资源(P操作),release方法表示释放资源(V操作)。
- 创建20个线程,每个线程都尝试申请资源,sleep1秒之后,释放资源,观察程序的执行效果。
public class Solution {public static void main(String[] args) {Semaphore semaphore = new Semaphore(4);Runnable runnable = new Runnable() {@Overridepublic 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();}}
}
上述程序执行的结果首先会有四个线程抢到资源,而抢不到的线程就会进行阻塞等待,打印“申请资源”,等待一秒之后,抢到资源的线程就开始释放,阻塞等待的线程就会开始去抢,最后等到所有线程执行完,程序结束。
六、CountDownLatch
CountDownLatch主要适用于多个线程来完成一系列任务的时候,用来衡量任务的进度是否完成。比如需要把一个大的任务,拆分成多个小的任务,让这些任务并发的去执行,这时就可以使用CountDownLatch来判定说当前这些任务是否全部完成了。
比如好像跑步比赛,10个选手依次就位,哨声响才同时出发,等到所有选手都通过终点后,才公布成绩。
- 构造CountDownLatch实例,初始化10表示有10个任务需要完成。
- 每个任务执行完毕,都调用latch.countDown()。在CountDownLatch内部的计数器同时自减。
- 主线程中使用latch.await();阻塞等待所有任务执行完毕,相当于计数器为0了。
public class Solution {public static void main(String[] args) throws InterruptedException {CountDownLatch latch = new CountDownLatch(10);Runnable r = new Runnable() {@Overridepublic void run() {try{Thread.sleep((long) (Math.random()*10000));latch.countDown();}catch(InterruptedException e){e.printStackTrace();}}};for (int i =0;i<10;i++){new Thread(r).start();}latch.await();System.out.println("比赛结束");}
}
说明:countDown()方法,每次调用这个方法,CountDownLatch内部的计数就会减少1直至减到0。
await方法,阻塞等待所有线程执行完毕后,相当于计数器为0了,最后会唤醒所有在阻塞等待的线程。
以上就是多线程进阶部分的内容了,感谢浏览!!!