目录
一、锁概述
1.1 概述
在多线程环境下,为了让多线程安全地访问和使用共享变量,必须引入锁机制。锁机制即当一个线程持有锁后,其他线程只能进行等待,直到持有锁的线程释放锁,再次重新竞争锁。
1.2 三种锁的大类型
锁大致可以分为互斥锁、共享锁、读写锁
1.2.1 互斥锁(排它锁)
互斥锁,即只有一个线程能够访问被互斥锁保护的资源
在访问共享对象之前,对其进行加锁操作。在访问完成之后进行解锁操作。加锁后,其他试图加锁的线程会被阻塞,知道当前线程解锁。解锁后,原本等待状态的线程变为就绪状态,重新竞争锁。
1.2.2 共享锁
共享锁,即允许多个线程共同访问资源
1.2.3 读写锁
读写锁既是互斥锁,又是共享锁。在读模式下是共享锁,写模式下是互斥锁。
读读:共享
读写:互斥
写写:互斥
二、互斥锁
Synchronized 和 Lock 都是典型的互斥锁
2.1 Synchronized 和 Lock 的区别
- synchronized 是 jvm 关键字,而 lock 是 java 类
- synchronized 不用处理异常状态下的锁释放,当资源使用完毕后或连接断开时自动释放锁,而 Lock 需要显示调用释放锁
- lock 接口提供了更多可适配的类和方法,包括非公平锁、读写锁等
- jdk1.6 以前 synchronized 在大量竞争的情况下用时会迅速上升,在 jdk1.6后优化改用 CAS 实现,效率与 Lock 相差无几
2.2 ReentrantLock 可重入锁
可重入锁是一种递归无阻塞的同步机制,也叫做递归锁,指的是同一线程在外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。ReentrantLock 和 synchronized 都是可重入锁。
2.3 公平锁、非公平锁、中断锁
2.3.1 公平锁
公平锁即根据 FIFO 规则,从等待队列中取出第一个等待线程获取锁。
在并发环境下,每个线程在获取锁时会先查看此锁维护的等待队列,如果是空,或者当前线程是等待队列的第一个,就占有锁,否则会将自己加入到等待队列中。
2.3.2 非公平锁
与非公平锁相反,非公平锁下,新来的线程在一上来就会尝试直接占有锁,如果这时候刚好在发出请求时所变成可用状态,则这个锁会跳过队列中的等待线程,直接获得锁,否则,将自己加入到队列中。
可以通过 nonfairTruAcquire() 实现
2.3.3 可中断锁
可中断锁即等待锁的过程是可以中断的。在互斥锁中,synchronized 是不可中断所,而 Lock 是可中断锁。
ReentrantLock 中提供了 tryLock 和 lockInterruptibly 两种方法来中断等待操作
● tryLock
可以通过设置超时时间 timeout 以及单位 unit,在等待指定时间后,若还没有获取锁,则中断锁
if(lock.tryLock()){//尝试获取锁
try {
// ... 获取锁后要做的内容
} catch (Exception e) {
throw new Exception(e);
} finally {
lock.unlock();
}
}else{
// 指定时间内没有获取到锁
}
● lockInterruptibly
lockInterruptibly 调用后,就会马上主动中断等待,并抛出 InterruptedException 异常
public class TestInterruptibly {
static class Servier{
private Lock lock = new ReentrantLock(); //定义锁对象
public void serviceMethod(){
try {
lock.lockInterruptibly(); //如果线程被中断了,不会获得锁,会产生异常
System.out.println(Thread.currentThread().getName() + "-- begin lock");
//执行一段耗时的操作
for (int i = 0; i < Integer.MAX_VALUE; i++) {
new StringBuilder();
}
System.out.println( Thread.currentThread().getName() + " -- end lock");
} catch (InterruptedException e) {
// e.printStackTrace();
System.out.println( Thread.currentThread().getName() + " ***** exp");
} finally {
System.out.println( Thread.currentThread().getName() + " ***** 释放锁");
lock.unlock(); //释放锁
}
}
}
public static void main(String[] args) throws InterruptedException {
Servier s = new Servier();
Runnable r = new Runnable() {
@Override
public void run() {
s.serviceMethod();
}
};
Thread t1 = new Thread(r);
t1.start();
Thread.sleep(50);
Thread t2 = new Thread(r);
t2.start();
Thread.sleep(50);
t2.interrupt(); //中断t2线程
}
}
三、悲观锁和乐观锁
3.1 悲观锁
悲观锁即每次去拿数据的时候都认为别人会修改,故每次拿数据的时候都会上锁。用处广泛,如数据库的行锁、表锁、读写锁等。
3.2 乐观锁
乐观锁即每次去拿数据的时候都认为别人不会修改,所以不会上锁。但在更新的时候回判断一下在此期间有没有人更新了这个数据,实现方式有版本号等方式。
3.3 适用场景
乐观锁适用于读多写少的情况,即冲突很少发生的时候,省去了锁的开销,增大了吞吐量
悲观锁适用于写多杜少的情况,在多写的情况下冲突经常发生,需要用锁来保证变量修改的有序性
3.4 实现方式
3.4.1 悲观锁实现
悲观锁常见的方式为 synchronized 和 lock
3.4.2 乐观锁实现
乐观锁需要在代码上进行设计和实现,一般有两种方式
● 版本控制
一般形式是在数据表上增加一个数据版本号字段(如 version),用来表示数据修改的次数,当数据被修改时,version++。
在线程A要更新数据前,先读取一次数据,获取到对应的 version,在提交更新时,会将这个 version 放入查询条件中,只有当数据库中的 version 和提交更新的 version 一致时,才会更新,成功返回 1,失败返回 0。
举个例子:
有一个表 money,用于记录商户金额
修改 money 的 sql 语句为:
<update id="updateOne" parameterType="Money">
update
money
set
money=#{money},
version=#{version}+1
where id=#{id} and version=#{version}
</update>
更新方式:
@Service
public class MoneyServiceImpl implements MoneyService {
@Autowired
private MoneyMapper moneyMapper;
@Override
public void modifyMoney() {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.execute(new Runnable() {
@Override
public void run() {
int res = 0;
while (res == 0) {
try {
res = modify();
double rand = Math.random();
Thread.sleep((long) (500 + rand*100));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
}
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
public int modify() {
// 先获取到要更新的内容数据,包括金额和版本号
Money money = moneyMapper.findById(1);
// 修改金额
money.setMoney(money.getMoney() + 1000);
// 更新数据,sql 语句会保证找到对应版本号的数据,若没有一致的版本号则更新失败返回 0
int res = moneyMapper.updateOne(money);
// res = 0,更新失败
if(res == 0) {
// .. 更新失败的操作,如记录到失败表中,或是循环尝试个更新
System.out.println("更新失败 tName = " + Thread.currentThread().getName() + " data = " + money.toString());
} else {
System.out.println("更新成功 tName = " + Thread.currentThread().getName() + " data = " + money.toString());
}
return res;
}
}
● CAS 算法
CAS 全称(Compare And Swap 比较和交换),是一种无锁算法,可以在不适用锁的情况下实现多线程间变量的同步java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。
3.5 CAS 算法
3.5.1 概述
CAS 全称(Compare And Swap 比较和交换),是一种无锁算法,可以在不适用锁的情况下实现多线程间变量的同步
3.5.2 运行机制
CAS 算法内部涉及到三个操作数:
- 需要读写的内存值V
- 旧值 A
- 要写入的新值 B
当且仅当 V 等于 A 时,CAS 通过原子方式(CAS的原子操作为:比较+更新) 使用新值 B 来更新 V的值,否则不会进行任何操作。同时会尝试不断更新,直到成功为止。
3.5.3 源码分析
由于 Java 的 AtomicInteger 是使用 CAS 算法实现乐观锁的,可以查看源码
/**
* Atomically increments by one the current value.
*
* @return the previous value
*/
// 调用的自增算法,且能够保证并发下的线程安全
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
- unsafe: 获取并操作内存的数据。
- valueOffset: 存储value在AtomicInteger中的偏移量。
- value:要增长的数目,如每次加一
// ------------------------- OpenJDK 8 -------------------------
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
可以看到是通过不断循环 compareAndSwapInt() 实现的“比较 + 更新”操作。compareAndSwapInt 在 jni 中是借助于一个 cpu 指令完成的,是一个原子操作,可以保证线程安全性。当 compareAndSwapInt() 返回 true 时,则返回最新的变量给调用者,若返回 false, 则一直循环调用
3.5.4 存在的问题
● ABA 问题
通俗来讲就是你大爷还是你大爷,你大妈已经不是你大妈了
例如:
A => B => C
由于 CAS 算法是查看内存值是否发生变化,而进行更新的。如果原来的内存值是A,后面改成B,又改回了A,则 CAS 检查时就不会发现值变化了。
这会导致什么问题呢?
如果操作的对象是个链表,那么他里面的地址可能就全都被变化了
● 循环时间长,开销发
通俗来讲就是你大爷还是你大爷,你大妈已经不是你大妈了
例如:
A => B => C
四、其他名词
4.1 spinlock 自旋锁
4.1.1 概念
自旋锁即当锁被一个线程占用后,其他的线程不是阻塞挂起等待唤醒,而是不断循环尝试获取锁。
从自旋锁可以看出,其内部是使用了乐观锁的方式,避免了线程被挂起,同样可以使用 compareAndSwapInt() 实现
4.1.2 自旋锁的适用场景
● 适合场景 (锁占用时间短)
自旋锁适用于锁竞争不激烈,且占用锁时间非常短的代码块。由于自旋避免了线程的阻塞,且自旋的消耗远小于切换上下文的开销,能够极大提交这类代码块的性能
● 不适合的场景 (锁占用时间长)
自旋锁不适合用于锁竞争激烈,或是占用锁较长的代码块。
锁竞争激烈意味着更多的自旋锁开销,每个竞争者都在不断循环尝试获取锁。
而如果代码块占用锁时间较长,则会拖长自旋锁的循环判断时间,长时间占用 cpu,造成 cpu 的浪费
4.1.3 适应性自旋锁
● 概念
在 jdk1.6 中,引入了适应性自旋锁,适应性自旋锁即自旋的时间/次数不在固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
● 机制
在同一个锁对象上,如果自旋等待刚刚成功获取过锁,且持有锁的线程正在运行中,虚拟机就会认为这次自旋也是很有可能再次成功,进而允许自旋锁持续更长时间。
如果对于某个锁,自旋很少成功获得过(长时间锁住或死锁),虚拟机就会认为自旋尝试获取这个锁可能造成更大的开销,之后获取这个锁的时候没获取的线程都会被阻塞等待唤醒
● 实现类
TicketLock、CLHlock和MCSlock
4.2 无锁、偏向锁、轻量级锁 、重量级锁
4.2.1 锁状态
根据锁的级别,从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁
锁状态只能升级不能降级
4.2.1 重量级锁
重量级锁即多个线程竞争同步资源时,没有获取资源的线程会被阻塞等待唤醒。由于阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长,故 jdk6 后为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
4.2.2 偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
偏向锁认为,多数情况下锁总是由一个线程多次获得,不存在多线程竞争,那么让这个线程一直持有锁就能减去降低和释放锁的开销
持有偏向锁的线程会一直持有这个偏向锁,不会主动释放偏向锁。直到有其他线程来竞争,当另外的线程访问时,偏向锁就会升级为轻量级锁
4.2.3 轻量级锁
轻量级锁即通过自旋方式不断尝试获取锁,而不是阻塞。当偏向锁被其他线程访问后,就会升级为轻量级锁。常见的轻量级锁即自旋锁
4.3 独享锁、共享锁
4.3.1 概念
● 独享锁
独享锁即互斥锁,一个锁只能被一个线程锁持有,若锁被持有,其他线程不能在获得这个锁。获得锁的线程能够进行读写
● 共享锁
共享锁是指一个锁可以被多个线程持有,获得读锁的线程只能够读数据,不能够写数据
● 常见
常见的独享锁如 synchronized、Lock、ReentrantReadWriteLock的写锁,而共享锁则是 ReentrantReadWriteLock 的读锁
4.4. AQS
4.4.1 概念
AQS:AbstractQuenedSynchronizer抽象的队列式同步器,是除了java自带的synchronized关键字之外的锁机制。
4.4.2 核心思想
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。
用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
**注意:AQS是自旋锁:**在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功
自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
4.4.3 详细
https://blog.csdn.net/mulinsen77/article/details/84583716
五、死锁、活锁、饥饿
5.1 死锁
5.1.1 概念
死锁指两个或两个以上的线程在执行过程中,因争夺资源造成的一种互相等待的现象。当一个线程永久地持有一把锁后,其他线程将永久等待下去
5.1.2 死锁的四个条件
● 互斥性:即线程占用的锁是互斥锁,不能被其他为占用的线程访问
● 不剥夺:即线程已经获得锁,在未主动释放之前,不会被其他线程剥夺
● 请求和保持:即有锁S1,S2,线程一持有了S1,又发起了对S2的持有请求。而同时有线程二持有了S2,又发起了对S1的持有请求。
● 环路等待:即死锁发生时,必然有一个环形链。如{p0,p1,p2,....pn}。p0等待p1释放资源,p1等待p2释放资源,p2等待p3释放资源,.... pn等待p0释放资源
5.1.3 死锁的解决方式
● jstack 定位死锁
https://www.cnblogs.com/chenpi/p/5377445.html
● ThreadMXBean
https://www.jianshu.com/p/7ead63f37bbd
● 线上环境死锁,保留堆栈信息
使用jstack + jdb 命令查看现场和死锁堆栈信息
https://www.cnblogs.com/qq931399960/p/11316684.html
5.1.4 死锁的避免
- 避免相反的获取锁的顺序
- 设置超时时间(lock类的 tryLock)
- 多使用并发类而不是自己设计锁
5.2 活锁
5.2.1 概念
活锁即线程并没有阻塞,也始终在运行,但是程序却得不到进展,因为线程始终重复做同样的事。本质原因是重试机制一样,始终互相谦让。
5.2.2 案例
例如消息队列,若消息队列第一个一直消费失败,则会不断进行重试。而非一个消息则会一直等待第一个消息被消费,造成了整个队列的罢工
5.2.3 解决方案
- 增加随机因素
- 增加重试机制
5.3 饥饿
当线程需要某些资源(如CPU),但是却始终得不到
线程的优先级设置得过低,或者由于某线程持有锁同时又无限循环而不释放锁,或者某程序始终占用某文件的写锁
饥饿可能会导致响应性差
六、参考博客
AQS 详解: https://blog.csdn.net/mulinsen77/article/details/84583716