目录
--->c.CAS底层实现-Unsafe-Atomic::cmpxchg
锁策略不仅仅是局限于 Java,任何和 "锁" 相关的话题,都可能会涉及到以下内容。这些特性主要是给锁的实现者来参考的。
1.乐观锁&悲观锁
1.1.乐观锁
1.1.1.乐观锁定义
乐观锁认为⼀般情况下不会出现锁冲突,所以只会在更新数据时才对锁冲突进⾏检测:
如果没有发⽣冲突,直接进⾏修改;
如果发⽣冲突,不做任何修改,然后把结果返回给⽤户,让⽤户⾃⾏决定处理。
1.1.2.乐观锁实现——CAS
--->a.乐观锁是定理,CAS是具体实现。
CAS(Compare And Swap)⽐较并替换(并没有锁的概念,性能高)。
执行流程:
CAS 中包含了三个操作单位:V(内存中的值)、A(预期的旧值)、B(新值);
⽐较 V 和 A 是否相等,如果相等则说明内存值未被其他线程修改过,是线程安全的,该线程将 V 的值更换成 B;
否则说明内存值已被其他线程修改,是线程不安全的,则不做任何改变,返回false。该线程就提示⽤户修改失败【理论层面】(或修改自身的值后再进行CAS【实践层面】),从⽽实现了 CAS 的机制。
PS:重试机制(循环CAS)
有很多文章说,CAS 操作失败后会一直重试直到成功,这种说法很不严谨。
- 第一,CAS 本身并未实现失败后的处理机制,它只负责返回成功或失败的布尔值,后续由调用者自行处理。只不过我们最常用的处理方式是重试而已。
- 第二,这句话很容易理解错,被理解成重新比较并交换。实际上失败的时候,原值已经被修改,如果不更改期望值,再怎么比较都会失败。而新值同样需要修改。
- 所以正确的方法是,使用一个死循环进行 CAS 操作,成功了就结束循环返回,失败了就重新从内存读取值和计算新值,再调用 CAS。
所以CAS的一个问题是:循环时间长开销大。
如果CAS不成功,则会原地自旋,如果长时间自旋会给CPU带来非常大的执行开销。
即因为操作不是原子性一次执行完,故要验证此次执行和上次执行之间有没有被其他人动过手脚,从而判断是否安全,是否要进行修正,进行下一次执行。
--->b.两个线程进行CAS操作:
线程1:V=10,A=10,B=12。
线程2:V=10,A=10,B=11。
- 线程1先得到CPU时间片,赋值得到A和B的值,还没有开始进行对比,时间片就用完了。
- 线程2先将V=10和A=10进行对比,发现二者相等true,将V值更换为B,此时V=11。线程2执行完。
- 线程1继续执行,对比V=11和A=10的值,二者不等false,不能直接进行更换操作。
- 线程1会将A改为11,B=12不变。
- 线程1对比V=11和A=11,二者相等true,将V值更换为B值,此时V=12。
线程1和线程2微观上先后执行,宏观上一起执行,最终将内存值V修改为符合预期的12,是线程安全的。
--->c.CAS底层实现-Unsafe-Atomic::cmpxchg
CAS 实现是借助 Unsafe 类(告诉程序员可能会产生不安全问题,它含有一些原生的方法,权限很大,可以直接操作物理内存,不推荐直接使用),Unsafe类中有一个compareAndSwapObject方法(CAS的具体实现),调⽤操作系统的 Atomic::cmpxchg(原⼦性汇编指令)。
--->d.CAS应⽤-AtomicInteger
以Atomic前缀开头的类,其都是根据CAS实现的。
AtomicInteger类:
- 底层使用unsafe类
- 也有compareAndSet方法
- getAndIncrement方法(相当于i++)
- incrementAndGet(相当于++i)
- getAndDecrement方法(相当于i--)
- decrementAndGet(相当于--i)
import java.util.concurrent.atomic.AtomicInteger;
/**
* CAS使用
*/
public class CASDemo1 {
private static AtomicInteger atomicInteger = new AtomicInteger(0);//初始值
private final static int MAX_COUNT = 1000000;
public static void main(String[] args) throws InterruptedException {
//++
Thread t1 = new Thread(() -> {
for (int i = 0; i < MAX_COUNT; i++) {
atomicInteger.getAndIncrement();//i++
}
});
t1.start();
//--
Thread t2 = new Thread(() -> {
for (int i = 0; i < MAX_COUNT; i++) {
atomicInteger.getAndDecrement();//i--
}
});
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + atomicInteger.get());
}
}
AtomicInteger虽然没有锁,但依旧可以避免简单的线程不安全问题的发生,就是因为其底层是根据CAS来实现的。
但也并非完全是线程安全的,会存在ABA问题。
--->e.漏洞:CAS会存在ABA的问题。
单线程没问题:
- 张三进行转账操作,原账户有100元,要-50元。
- 第一次点击转账按钮(系统卡顿没有反应):-50元(V=100,A=100,B=50)。
- 又第二次转账同样操作:-50元(V=100,A=100,B=50)。
- 第二次先执行:判断V=A=100,改V=50。
- 第一次又执行:判断此时V=50,A=100 -> 二者不等false,就不再次执行-50操作。
- 单线程使用CAS还是没问题的,虽然点击了两次转账操作,但最终结果还是50正确的。
多线程会出现问题:
- 张三进行转账操作,原账户有100元,要-50元。
- 第一次点击转账按钮(系统卡顿没有反应):-50元(V=100,A=100,B=50)。
- 又第二次转账同样操作:-50元(V=100,A=100,B=50)。
- 第二次先执行:判断V=A=100,改V=50。
- 此时财务给张三发工资50元,余额V=50+50=100。
- 第一次又执行:判断此时V=100,A=100 -> 二者相等true,再次执行-50操作,V=50。(无法判断出V虽然都是100,但已经是被修改过的了)
- 最终得到结果:V=100-50+50-50=50;而正确的结果应该是V=100-50+50=100。
- 多线程使用CAS会出现安全问题。
ABA问题代码演示:
import java.util.concurrent.atomic.AtomicInteger;
/**
* ABA问题演示
*/
public class ABADemo1 {
private static AtomicInteger money = new AtomicInteger(100);
public static void main(String[] args) throws InterruptedException {
//第一次点击转账按钮(-50)
Thread t1 = new Thread(() -> {
int old_money = money.get(); //先得到余额
//执行花费2s
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//对比并替换
money.compareAndSet(old_money, old_money - 50); //预期的旧值:100;新值:50
});
t1.start();
//第二次点击转账按钮(-50)不小心点击的,因为第一次点击之后没反应,所以第二次又点了一次
Thread t2 = new Thread(() -> {
int old_money = money.get();
//对比并替换
money.compareAndSet(old_money, old_money - 50);//预期的旧值:100;新值:50
});
t2.start();
//给账户+50元
Thread t3 = new Thread(() -> {
//执行花费1s,这样线程t2就执行完毕
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int old_money = money.get();
money.compareAndSet(old_money, old_money + 50);
});
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("最终账户余额:" + money);
//执行顺序:2,3,1
}
}
ABA解决方案AtomicStampedReference引入版本号:本次操作之后让版本号+1,执行时判断版本号的值,即可解决ABA问题。
import java.util.concurrent.atomic.AtomicStampedReference;
/**
* ABA问题演示
*/
public class ABADemo2 {
private static AtomicStampedReference<Integer> money = new AtomicStampedReference<>(100,0); //100是预先的余额,0是预先的版本号,也可设置为1
public static void main(String[] args) throws InterruptedException {
//第一次点击转账按钮(-50)
Thread t1 = new Thread(() -> {
int old_money = money.getReference();//先得到余额
int version = money.getStamp();//得到版本号
//执行花费2s
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//对比并替换
money.compareAndSet(old_money, old_money - 50, version, version + 1);//预期地旧值:100;新值:50;预期的旧的版本号;新的版本号
});
t1.start();
//第二次点击转账按钮(-50)不小心点击的,因为第一次点击之后没反应,所以第二次又点了一次
Thread t2 = new Thread(() -> {
int old_money = money.getReference();//先得到余额
int version = money.getStamp();//得到版本号
//对比并替换
money.compareAndSet(old_money, old_money - 50, version, version + 1);
});
t2.start();
//给账户+50元
Thread t3 = new Thread(() -> {
//执行花费1s,这样线程t2就执行完毕
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int old_money = money.getReference();//得到余额
int version = money.getStamp();//得到版本号
money.compareAndSet(old_money, old_money + 50, version, version + 1);
});
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("最终账户余额:" + money.getReference());
//执行顺序:2,3,1
}
}
1.2.悲观锁
1.2.1.悲观锁定义
也叫互斥锁。总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁,这样别⼈想拿这个数据就会阻塞直到它拿到锁。
1.2.2.悲观锁应用
synchronized、Lock 都是悲观锁。
2.公平锁&非公平锁
2.1.公平锁
所有任务来了之后先排队,线程空闲之后去任务队列按顺序执⾏最早任务:
ReentrantLock lock= new ReentrantLock(true)。
性能低。
2.2.非公平锁
抢占式执⾏,有⼀些先来的任务还在排队,刚好释放锁的时候新来了⼀个任务,此时并不会通知任务队列来执⾏任务,⽽是执⾏新来的任务:
ReentrantLock lock = new ReentrantLock(false)。
如果构造函数不传递参数,则默认是⾮公平锁。默认锁都是非公平锁。
性能高。
3.读写锁
3.1.读写锁
3.1.1.读写锁定义
读写锁(Readers-Writer Lock)顾名思义是将⼀把锁分为两部分:读锁和写锁。
其中读锁允许多个线程同时获得,因为读操作本身是线程安全的;
⽽写锁则是互斥锁,不允许多个线程同时获得(写锁)。
并且读操作和写操作也是互斥的,这样可以保证读到的数据是最终的数据,而不是写到一半的数据。
读写锁的特点是:读读不互斥、读写互斥、写写互斥。
3.1.2.读写锁实现
Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁。
可传参设置公平锁/非公平锁:
不传参->默认是非公平锁;
传参false->非公平锁;传参true->公平锁。
- ReentrantReadWriteLock.ReadLock 类表示⼀个读锁,这个对象提供了 lock / unlock ⽅法进⾏加锁/解锁。
- ReentrantReadWriteLock.WriteLock 类表示⼀个写锁,这个对象也提供了 lock / unlock⽅法进⾏加锁/解锁。
3.1.3.读写锁适用场景
注意:
只要是涉及到 "互斥",就会产⽣线程的挂起等待。⼀旦线程挂起,再次被唤醒就不知道隔了多久了。因此尽可能减少 "互斥" 的机会,就是提⾼效率的重要途径。
读写锁特别适合于 "频繁读,不频繁写" 的场景中(这样的场景其实也是⾮常⼴泛存在的)。
比如教务系统:
每节课⽼师都要使⽤教务系统点名,点名就需要查看班级的同学列表(读操作)。这个操作可能要每周执⾏好⼏次。
⽽什么时候修改同学列表呢(写操作)? 就新同学加⼊的时候,可能⼀个⽉都不必改⼀次。
再⽐如,同学们使⽤教务系统查看作业(读操作),⼀个班级的同学很多,读操作⼀天就要进⾏⼏⼗次上百次。
但是这⼀节课的作业,⽼师只是布置了⼀次(写操作)。
3.1.4.代码实现
import java.time.LocalDateTime;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 演示读写锁的使用
*/
public class ReadWriteLockDemo1 {
public static void main(String[] args) {
//创建读写锁
final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); //不传参默认是非公平锁;传false->非公平锁;传true->公平锁。
//创建读锁
final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
//创建写锁
final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
//线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
//启动新线程执行任务(读操作1)
executor.submit(() -> {
//加锁操作
readLock.lock();
try{
//执行业务逻辑
System.out.println("执行读锁1:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放锁操作
readLock.unlock();
}
});
//启动新线程执行任务(读操作2)
executor.submit(() -> {
readLock.lock();
try{
System.out.println("执行读锁2:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
});
//启动新线程执行任务(写操作1)
executor.submit(() -> {
writeLock.lock();
try{
System.out.println("执行写锁1:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException exception) {
exception.printStackTrace();
} finally {
writeLock.unlock();
}
});
//启动新线程执行任务(写操作2)
executor.submit(() -> {
writeLock.lock();
try{
System.out.println("执行写锁2:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException exception) {
exception.printStackTrace();
} finally {
writeLock.unlock();
}
});
}
}
3.2.独占锁
独占锁是指任何时候都只有⼀个线程能执⾏资源操作。 也叫互斥锁。
synchronized、Lock。
3.3.共享锁
共享锁指定是可以同时被多个线程读取,但只能被⼀个线程修改。
⽐如 Java 中的ReentrantReadWriteLock 就是共享锁的实现⽅式,它允许⼀个线程进⾏写操作,允许多个线程读操作。
4.可重入锁&自旋锁
4.1.可重入锁
可重⼊锁指的是该线程获取了该锁之后,可以⽆限次地进⼊该锁锁住的代码。
public class ThreadDemo21 {
public static void main(String[] args) {
synchronized (ThreadDemo21.class) {
System.out.println("线程执行进入了方法");
synchronized (ThreadDemo21.class) {
System.out.println("线程执行又进入了方法");
synchronized (ThreadDemo21.class) {
System.out.println("线程执行又又进入了方法");
}
}
}
}
}
4.2.自旋锁
⾃旋锁是指尝试获取锁的线程不会⽴即阻塞,⽽是采⽤循环的⽅式去尝试获取锁。
这样的好处是减少线程上下⽂切换的消耗,缺点是循环会消耗 CPU。
//伪代码
while(!尝试获取锁 < 15) {
}
synchronized是自适应自旋锁,自旋次数是不固定的,会自我调节,不同的JVM设置的自旋次数自适应也是不同的:
- 第一次没谱,会设置默认的自旋次数值,以后当达到上次设置的自旋次数时获取到锁,会适当缩短自旋锁次数;当超过自旋次数还没有得到锁,会适当延长自旋次数。
- 第一次会设置默认的自旋次数值,以后当达到上次设置的自旋次数时获取到锁,自旋锁次数不变,认为是合理的;当超过自旋次数还没有得到锁,会适当缩小自旋次数,因为JVM认为上次在循环了那么多次都没有得到锁,那么在下一次大概率通过自旋也不能得到锁。