Java主流锁
# Java主流锁
# 乐观锁VS悲观锁
悲观锁适合写操作多的场景,乐观锁适合读操作多的场景。
乐观锁
乐观锁是一种乐观思想,认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,使用不会上锁。但在更新的时候会判断一下在此期间别人有没有取更新这个数据,在写的时候判断当前版本号和之前读的版本号是否相同,相同则更新成功,失败则重试。
Java的乐观锁基本上都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
悲观锁
悲观锁是一种悲观思想,认为写多读少,遇到并发的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写数据就会阻塞。
Java中,synchronized关键字和Lock的实现类都是悲观锁。
# 公平锁VS非公平锁
公平锁:多个线程按照申请锁的顺序来获取锁,队列,先来先解决。
Lock lock = new ReentrantLock(true);
非公平锁:多线程获取锁的顺序可能不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
Lock lock = new ReentrantLock();
synchronize(xxx) {
...
}
2
3
# 可重入锁(递归锁)
指同一个线程外层函数获得锁之后,内存递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
也就是,线程可以进入任何一个它已经拥有的锁所同步着的代码块。
ReentrantLock 与 synchronized 都是可重入锁。
# 自旋锁 VS 适应性自旋锁
只有轻量级锁才会使用自旋,重量级锁不使用自旋。
阻塞和唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要消耗处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间取切换线程,线程挂起和恢复现场的花费可能让系统得不偿失。如果物理机器有很多处理器,能够让两个或两个以上的线程同时并行执行,我们就可以让后面能够请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
单个处理器上是无法使用自旋锁的。
自旋锁
为了让当前线程”稍等一下“,不释放锁,我们需要让该器线程进行自旋,如果在自旋过程中,前面锁定同步资源的线程释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。
自适应性自旋锁
自旋锁虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋超过了限定次数没有成功获得锁,就应当挂起线程。
自适应意味着自旋的次数(时间)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋成功的概率也很大,并允许自选等待时间更长。如果对于某个锁,自旋很少成功获得过,那么在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
# 独占锁 VS 共享锁
独占锁:该锁一次只能被一个线程所持有。ReentrantLock 和 Synchronized 都是独占锁。
共享锁:该锁可被多个线程持有。对于 ReentrantReadWriteLock,其读锁时共享锁,写锁是独占锁。
读锁的共享锁可保证并发读是非常高效的,读写、写写的过程是互斥的。
// 同时只能一个线程在写,同时可以多个线程在读,一个线程在写其他线程不能读
public class ReadWriteDemo {
public static void main(String[] args) {
MyCache myCache = new MyCache();
for (int i = 0; i < 5; i++) {
int tempI = i;
new Thread(() -> {
myCache.put(tempI + "", tempI + "");
}).start();
}
for (int i = 0; i < 5; i++) {
int tempI = i;
new Thread(() -> {
myCache.get(tempI + "");
}).start();
}
}
}
// 模拟缓存
class MyCache {
private volatile Map<String, Object> map = new HashMap<>();
// 读写锁
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
public void put(String key, Object value) {
rwLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
try {
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t 写入完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
rwLock.writeLock().unlock();
}
}
public void get(String key) {
rwLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t 正在读取");
try {
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + result);
} catch (Exception e) {
e.printStackTrace();
} finally {
rwLock.readLock().unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# Java对象头
包含两部分:Mark word 标记字段;Klass Poniter 类型指针;数组长度(只有数组对象才有)
Klass Pointer:指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象时哪个类的实例。
Mark word:
# Monitor
可以理解为一个同步工具或机制,每一个Java对象都有一把看不见的锁,称为内部锁或Monitor对象。Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
# 无锁
所有线程都能访问并修改同一资源,但只有一个线程能修改成功。线程会不断的尝试修改共享资源,修改成功就退出,否则继续循环尝试。无锁就是应用了CAS原理。
# 偏向锁
出现场景:锁不存在多线程竞争,并且总是由同一线程多次获得。
概念:当一个线程访问同步代码块并获取锁时,会在对象头中存储偏向线程ID,以后该线程在进入和退出同步块的时候都不需要进行CAS操作来加锁和解锁,只需要检查对象头中是否存在当前线程的偏向ID,如果存在则获得锁。如果不存在,检查偏向锁标识是否为1,如果没有设置,则使用CAS获取锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销:当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),首先会暂停拥有偏向锁的线程,检查持有偏向锁的线程是否存活,如果线程不处于活动状态,则将对象头设置为无锁状态;如果线程存活,根据一些条件,要么恢复无锁状态、要么偏向其他线程,要么锁升级。最后唤醒暂停的线程。
# 轻量级锁
出现场景:一个同步代码块很少有多个线程来竞争
概念:线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建一个用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中(hashcode、分代年龄就存储在栈帧的锁记录中)。然后线程尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,会尝试自旋获取锁,如果获取失败,锁就会升级为重量级锁。
# 重量级锁
我们要进入一个同步、线程安全的方法时,是需要先获得这个方法的锁的,退出这个方法时,则会释放锁。如果获取不到这个锁的话,意味着有别的线程在执行这个方法,这时我们就会马上进入阻塞的状态,等待那个持有锁的线程释放锁,然后再把我们从阻塞的状态唤醒,我们再去获取这个方法的锁。这种获取不到锁就马上进入阻塞状态的锁,我们称之为重量级锁。
# 锁是如何进行升级的?
偏向锁:仅有一个线程进入临界区,不竞争
轻量级锁:多个线程交替进入临界区,CAS自旋
重量级锁:多个线程同时进入临界区
偏向锁—>轻量级锁
如果出现其他线程访问该方法这种情况,其他线程一看这个方法的 ID 不是自己,这个时候说明,至少有两个线程要来执行这个方法了,这意味着偏向锁已经不适用了,这个时候就会从偏向锁升级为轻量级锁。
轻量级锁的自旋锁
当一个线程来执行一个方法的时候,方法里面已经有人在执行了。此时便是遇到了竞争,我们就会认为轻量级锁已经不适合了,我们就会把轻量级锁升级为自旋锁了。
轻量级锁—>重量级锁
如果轻量级锁,通过自旋,空循环一定的次数还拿不到锁,那么它就会进入阻塞的状态,即升级为重量级锁。
重量级锁可以使用自旋进行优化。
# 什么是线程死锁?
是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象。如下图,线程A持有资源2,线程B持有资源1,他们同时想申请对方的资源,两个线程就会互相等待而进入死锁状态。
产生死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求于保持条件:一个进程因请求资源而阻塞时,对己获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接循环等待的资源关系。
# 死锁演示?
🎬 代码演示
class HoldLockThread implements Runnable {
private String lockA;
private String lockB;
public HoldLockThread(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}
@Override
public void run() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "\t 持有" + lockA + ", 想要获取:" + lockB);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "\t 持有" + lockA + ", 获取:" + lockB + "成功!");
}
}
}
}
public class DeadLockDemo {
public static void main(String[] args) {
String lockA = "lockA";
String lockB = "lockB";
new Thread(new HoldLockThread(lockA, lockB), "A").start();
new Thread(new HoldLockThread(lockB, lockA), "B").start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
🔨 查看进程(jps)
步骤1:jps -l
查看java进程
步骤2:jstack 进程号
# 如何预防和避免死锁?
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
# 什么是线程饥饿?
如果一个线程因为处理时间全部被其他线程抢走而得不到处理运行时间,这种状态称为饥饿,一般是由于高优先级吞噬所有低优先级线程的处理时间引起的。
我们还可以把处理器想象成皇帝,把各个线程想象成妃子,皇帝隔几分钟就换一个妃子陪他。我们设置线程优先级就像是调整某个妃子的好看程度,具体皇帝挑不挑这个妃子还是具体的皇帝说了算,而且不同的皇帝有不同的口味,最后结果是啥还真说不准。如果我们把一个妃子弄的很好看,一个皇帝太宠信她,从而使某些妃子得不到宠信,就是传说中的饥饿现象。
# 什么是活锁?
不像死锁那样因为获取不到资源而阻塞,也不像饥饿那样得不到处理时间而无可奈何,但活锁仍旧让程序无法执行下去。
消息重试,当某个消息处理失败的时候,一致重试,但是重试由于某种原因,比如消息格式不对,导致解析失败,而它又继续重试。解决方案:不可修复的错误不要重试,或者是重试次数限定。
互相协作的线程彼此响应从而修改自己的状态,导致无法无法执行下去。比如两个很有礼貌的人在同一条路上相遇,彼此给多方让路,但又在同一条路上遇到,互相之间反复的避让下去。解决方案:选择一个随机退让,使得具备一定的随机性。