乐观锁:预测当前锁冲突概率不大,后续要做的工作往往就更少,加锁开销就更少(时间,系统资源).
悲观锁:预测当前锁冲突的概率大,后续要做的工作往往就更多,加锁的开销就更多(时间,系统资源).
乐观和悲观的区分在于:主要看预测锁竞争的激烈程度.
Synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换为悲观锁.
轻量级锁:加锁机制尽可能不使用mutex,而是尽量在用户态代码完成.实在搞不定了,在使用mutex.一般来说,乐观锁往往就是轻量级锁,加锁过程事情少,轻量.
重量级锁:加锁机制重度依赖了os提供的mutex.一般来说,悲观锁往往就是重量级锁,加锁过程事情多,重量.
synchronized开始时一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁.
自旋锁:一种典型的轻量级锁的实现方式.
循环判断,如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止.第一次获取锁失败,第二次的尝试机会会在极短的时间内到来.
优点:没有放弃cpu,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁.
缺点:如果锁被其他线程持有的时间比较久,那么就会持续消耗cpu资源.
while(true) {
if(判断是否锁了) {
continue;
}else {
break;
}
}
挂起等待锁:一种典型的重量级锁的实现方式.
没有申请到锁的时候,线程被挂起,加入到阻塞队列中等待,当锁被释放掉后,有机会获取到锁.
synchronized中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
互斥锁:synchronizes就是普通的互斥锁,提供了加锁和解锁两个操作,如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待.
读写锁:读写锁就是把读操作和写操作区分对待.
注意:只要时涉及到"互斥",就会产生线程的挂起等待,一旦挂起等待,再次被唤醒就不知道要隔多久了.
因此减少"互斥"的机会,就是提高效率.
读写锁特别适合于"读频繁,写不频繁"的场景中.
假设有三个线程A,B,C .A先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待,然后C也尝试获取锁,获取失败,阻塞等待.
当线程A释放锁的时候,会发生什么呢?
公平锁:遵循"先来先到".B比C先来,当A释放锁后,B就能先于C获取锁.
非公平锁:不遵循"先来先到",B和C都有可能获取到锁.
注意:操作系统内部的线程调度就可以视为随机的,如果不做任何额外的限制,锁就是非公平的,如果要实现公平锁,就需要以来额外的数据结构来记录线程的先后顺序,公平锁和给公平锁之间没有好坏之分,关键看场景.
synchronized是非公平锁.
可重入锁:一个线程争对这把锁连续加锁两次,不会死锁,
不可重入锁:一个线程争对这把锁连续加锁两次,会死锁.
CAS全称: compare and swap. 根据字面意思理解为"比较并交换",一个CAS涉及到一下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B.
1.比较A与V是否相等.(比较)
2.如果相等,将B写入V.(交换)
3.返回操作是否成功.
CAS伪代码
下面的代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个只是辅助理解CAS的工作流程.
boolean CAS(address,expectValue,swapValue) {
if(&address == expectValue) {
&address = swapValue;
return true;
}
return false;
}
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败信号.
CAS可以视为一种乐观锁.(或者可以理解成CAS是乐观锁的一种实现方式)
CAS是怎么实现的
正对不同的操作系统,JVM用到了不同的CAS实现原理,简单来讲:
简而言之,是因为硬件予以了支持,软件层面才能做到.
(1)实现了原子类
标准库中提供了Java.until.concurrent.atomic包,里面的类都是基于这种方式来实现的.
典型的就是AtomicInteger类.其中的getAndcrement相当于count++操作.
AtomicInteger count = new AtomicInteger(0);
count.getAndIncrement();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while(CAS(value,oldValue,oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
两个线程增加同一个变量:
AtomicInteger count = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);//结果:100000
(2)实现自旋锁
自旋锁伪代码:
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的核心是"比较",发现相等"交换"===>潜台词==>数据中间没有发生任何变化.
但是 相等 != 没有改变过.CAS期望的是,中间没有其他线程修改过这个数据.但实际上,不一定,可能会出现其他线程把这个数据 从A=>B=>A这样修改,看起来好像没有改,其实已经修改过了.
ABA问题引来的BUG
假如你有存款100万,你想取50万,取款机创建了两个线程,并发来执行-50万这个操作.
我们期望一个线程执行-50万成功,另一个线程-50万操作失败.
如果使用CAS的方式来完成这个操作,就可能出现问题.
正常的过程:
1.存款100万,线程1获取到当前存款值为100万,期望更新为50万,线程2获取到当前存款值为100万,期望更新为50万.
2.线程1扣款成功,存款被修改为50万,线程2阻塞等待中.
3.轮到线程2执行了,发现当前存款为50万,和之前读到的100万不相同,执行失败.
异常过程:
1.存款100万,线程1获取到当前存款值为100万,期望更新为50万,线程2获取到当前存款值为100万,期望更新为50万.
2.线程1扣款成功,存款被修改为50万,线程2阻塞等待中.
3.在线程2执行之前你的项目入账50万,余额变为100万.
4.轮到线程2执行了,发现当前存款为100万,和之前读到的100万相同,再次执行扣款操作.
这个时候扣款工作执行了两次,这都是ABA搞的鬼.
给要修改的值,引入版本号.在CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期.
(1)CAS操作在读取旧值的同时,也要读取版本号.
(2)真正修改的时候.
1.如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1.
2.如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了).
1.synchronized开始时乐观锁,如果锁冲突频繁,就转为悲观锁.
2.synchronized开始时轻量级锁,如果锁被持有的时间较长,就转换为重量级锁.
3.synchronized实现轻量级锁的时候大概率用到自旋锁.
4.synchronized时一种不公平锁
5.synchronized是可重入锁
6.synchronized不是读写锁
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁 状态.会更具情况,依次升级.
第一个尝试加锁的线程,优先进入偏向锁状态.
偏向锁不是真的"加锁",只是给对象头中做一个"偏向锁的标记",记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态.偏向锁本质上相当于"延迟加锁".能不加锁就不加锁,尽量来避免不必要的加锁开销.
但是该做的标记还是得做,否则无法区分何时需要正真的加锁.
举个例子理解偏向锁:
假设男主是一个锁,女主是一个线程.如果只有这一个线程来使用这个锁,那么男主女主即使不领证结婚(避免高成本操作),也可以幸福的生活下去.
但是女配出现了,也尝试竞争男主,此时不管领证结婚的操作成本多高,女主也必须完成这个动作,让女配死心.
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过CAS来实现的.
1.通过CAS检查并更新一块内存(比如null=>该线程引用)
2.如果更新成功,则认为加锁成功
3.如果更新失败,则认为锁被占用,继续自旋式等待(并不放弃cpu).
如果竞争进一步激烈,自旋锁不能快速获取到锁状态,就会膨胀为重量级锁.
编译器+JVM判断锁是否可消除,如果可以,就直接消除.
有些应用程序的代码中,用到了synchronized,但其实没有在多线程环境下.(例如SreingBuffer)
StringBuffer sc = new StringBuffer(); sc.append("z"); sc.append("e"); sc.append("r"); sc.append("o");
此时每个append的调用否会涉及加锁和解锁,但是如果只是在单线程中执行这个代码,那么这些加锁解锁的操作是没有必要进行的,拜拜浪费资源开销.
一段代码中如果出现多次加锁解锁,编译器+JVM会自动进行锁的粗化.
锁的粒度:粗和细,加锁范围内,包含的代码越多,就认为锁的粒度就越粗,反之,就越细.