目录
🥬JUC(java.util.concurrent) 的常见类
🥬常见的锁策略
因为加锁是一个开销比较大的操作,所以我们希望在特定的场景下,针对场景做一些取舍,让锁更加高效一些。
🌵乐观锁和悲观锁
乐观锁:假设一般情况下都不会产生锁冲突,因此就尝试直接访问数据,发现了锁冲突,再去处理。
悲观锁:假设一般情况下都会产生冲突,因此先进行处理,再去尝试访问数据。
🌵读写锁
当多个线程尝试修改同一个变量时,线程不安全。当多个线程同时读一个变量时,线程安全。两个读线程之间,不存在线程不安全问题,不必互斥;两个写线程之间,存在线程不安全问题,就需要互斥;一个读线程与一个写线程之间存在线程不安全,需要互斥。所以我们可以根据读写的不同场景,给读和写分别加上锁。
synchronized没有对读写进行区分,只要使用了就一定互斥。
这里的互斥是从CPU来的,CPU提供了一些特殊指令,通过这些指令来完成互斥,操作内核对这些指令进行了封装,并实现了阻塞等待。CPU提供一些特殊指令,操作系统对这些指令进行封装,提供了一个mutex(互斥量),JVM相当于对操作系统的mutex再封装一层,实现了synchronized这样的锁。如果当前的锁,是通过内核的mutex来完成的,开销往往比较大,如果当前的锁是在用户态通过一些其他的操作来完成的,开销往往比较小。
🌵重量级锁和轻量级锁
重量级锁:加锁解锁开销很大,往往是通过内核来完成。
轻量级锁:加锁开锁开销更小,往往是通过用户态来完成。
悲观锁做的工作更多,开销比较大,很大可能性是重量级锁;乐观锁做的工作更少,开销更小,很大可能性是轻量级锁。
🌵自旋锁和挂起等待锁
挂起等待锁:如果线程获取不到锁,就会阻塞等待,什么时候结束阻塞,取决于操作系统的具体调度,当线程挂起的时候不占用CPU。
自旋锁:如果线程获取不到锁,不阻塞等待,而是循环快速的再试一次。(如果线程1得到了锁,线程2就会快速循环的尝试获取锁,一旦线程1把锁释放,线程2就能第一时间获取到锁)。
自旋锁跟挂起等待锁相比,更节省操作系统调度线程的开销,但是更浪费CPU资源。
🌵公平锁和非公平锁
公平锁:遵守先来后到的规则。(如果线程1比线程2更先来,那么线程1会比线程2更先获取到锁)
非公平锁:不遵守先来后到的规则,获取锁的概率取决于操作系统的调度。(synchronized是非公平锁)
对于操作系统的调度器来说,默认是不公平的,默认获取锁的概率是均等的(不考虑线程的优先级情况下),要想实现公平锁,需要额外的数据结构(就比如说队列,用队列来记录线程的先来后到的过程)。大部分情况下,使用非公平锁就够用了,如果有些场景下,我们期望对于线程的调度的时间成本是可控的,这个时候就更需要公平锁了(比如我们需要通过10个线程并发执行10个任务,如果现在是非公平锁,此时若遇到前9个线程一直霸占着锁,则第10个线程始终拿不到锁,这时候用公平锁能更好的保证这10个任务能均衡的实行)。
🌵可重入锁和不可重入锁
可重入锁:之前有说过synchronized是可重入锁,就是针对同一把锁,连续加锁两次,此时不会死锁。为什么不会"死锁"呢?此时加锁的时候会让当前的锁记录一下这个锁是谁持有的,如果发现现在有同一个线程再次尝试获取锁,此时不会阻塞等待,代码会继续运行,此时这个锁里面会维护一个计算器,这个计算器就计算了当前这个线程针对这把锁尝试加锁了几次,每次解锁,计数器--,直到计数器为0,此时才真正的释放锁。
不可重入锁:如果针对同一把锁,连续加锁两次,会造成"死锁"。
//以下就是一个可重入锁的例子
synchronized void func1(){
func2();
}
synchronized void func2(){
}
//func1加锁操作,可以成功。接下来执行func2,再次尝试加锁,如果是不可重入锁,那么当前锁已经被占用
//了,此时func2这个加锁就应该阻塞等待,等待func1锁释放,func2才能获取得到锁,但是由于func2在func1
//内部,因此一旦func2阻塞,就会导致func1页阻塞,于是func1就无法执行到释放锁,这个时候代码就僵住了,
//此时就会造成"死锁"现象
🥬CAS
CAS:compare and swap,比较和交换,拿两个内存进行比较,或者是拿寄存器的值和内存的值进行比较,然后交换比较的这两个东西。这个操作是一个"原子的",CPU提供了一组CAS相关的指令,使用一条指令就可以完成上面的比较和交换过程。
例如:
int num=1;
num++;
//线程不安全,如果此时多个线程并发执行i++,就需要加锁,但是加锁是低效的,此时就可以CSA
//即可以保证高效,又能保证线程安全
基于CSA实现了一些"原子类",这些原子类也相当于一个整数,就可以实现一些++、--操作运算
都是不需要加锁就能保证线程安全。(java标准库中,也提供了原子类)
标准库中:
public static void main(String[] args) {
AtomicInteger num = new AtomicInteger(10);
// 这样的方法能够保证原子性. 内部就是通过 CAS 来实现的.
// 这个操作就相当于 num++
num.getAndIncrement();
// 这个操作就相当于 ++num
num.incrementAndGet();
}
模拟实现getAndIncrement
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
如果有两个线程同时调用getAndIncrement,那它是怎么不用加锁就能保证线程安全的呢?
CAS其实就是在感知这两个操作之间是否夹杂了其他线程的操作,是否有其他线程在这个过程中偷偷的修改了数据 ,如果没有,此时就能直接把数据给改了,如果其他线程已经修改了,那么就重新读取旧值,交给下次循环来判定。
CAS中的ABA问题(经典面试题):
ABA问题就是说我们在使用CAS的时候也无法区分这个数据A是始终没变,还是从A变成了B,然后又变回了A,大部分情况下这种问题影响不大,但是有时候却会引入bug。
比如说我们去取钱,如果此时机子突然卡了,我们按了两下取钱,此时线程1和线程2都尝试进行-50操作
1、线程1获取到当前值为100,线程2也获取到当前值为100
2、线程1执行-50操作,比较当前值和oldval值是否一致,一致就扣钱。线程1执行完毕后,账户余额就变成了50
3、在线程2执行-50操作之前,如果突然有人往账户里面又打了