并发编程锁机制!

锁分类

偏向锁/轻量级锁/重量级锁:

偏向锁:

  • 一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的。
  • 当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来。
    • 以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。

轻量级锁:

  • 当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁。
    • 线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。

重量级锁:

  • 重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。

  • 当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。

  • 重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

性能对比:

  • 偏向锁性能最好,可以避免执行 CAS 操作。

  • 轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。

  • 重量级锁则会把获取不到锁的线程阻塞,性能最差。

可重入锁/非可重入锁:

可重入锁:

  • 线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。

不可重入锁:

  • 虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。

共享锁/独占锁:

共享锁:

  • 同一把锁可以被多个线程同时获得。

独占锁:

  • 这把锁只能同时被一个线程获得。

公平锁/非公平锁:

公平锁:

  • 如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队。
    • 在等待队列里等待时间长的线程会优先拿到这把锁。

非公平锁:

  • 它会在一定情况下,忽略掉已经在排队的线程,发生插队现象。
    • 整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题。

悲观锁/乐观锁:

悲观锁:

  • 在获取资源之前,必须先拿到锁,以便达到 独占 的状态。

    • 当前线程在操作资源的时候,其他线程由于不能拿到锁。
  • 适合用于并发写入多、临界区代码复杂、竞争激烈等场景。

    • 这种场景下悲观锁可以避免大量的无用的反复尝试等消耗。

乐观锁:

  • 它并不要求在获取资源前拿到锁,也不会锁住资源。

  • 乐观锁利用 CAS 理念,在不独占资源的情况下,完成了对资源的修改。

  • 适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。

自旋锁/非自旋锁:

自旋锁:

  • 如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁。
    • 优点是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

非自旋锁:

  • 如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等。

可中断锁/不可中断锁:

不可中断锁:

  • 一旦线程申请了锁,只能等到拿到锁以后才能进行其他的逻辑处理。

可中断锁:

  • 在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直等到获取到锁才离开。

锁升级

img
锁升级细化流程:

img

锁优化

自适应的自旋锁:

自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率

  • 以及当前锁的拥有者的状态等多种因素来共同决定。

  • 如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间。

  • 但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。

锁消除:

1
2
3
4
5
6
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}

如果编译器能确定这个 StringBuffer 对象只会在一个线程内被使用,就代表肯定是线程安全的

那么我们的编译器便会做出优化,把对应的 Synchronized 给消除

  • 省去加锁和解锁的操作,以便增加整体的效率。

锁粗化:

1
2
3
4
5
6
7
8
9
10
11
public void lockCoarsening() {
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
}

把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁

  • 那么就可以把中间这些无意义的解锁和加锁的过程消除。

  • 锁粗化不适用于循环的场景,仅适用于非循环的场景。

死锁

死锁就是两个或多个线程(或进程)被无限期地阻塞,相互等待对方手中资源的一种状态。

1
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
/**
* 描述: 必定死锁的情况
*/
public class MustDeadLock implements Runnable {

public int flag;
static Object o1 = new Object();
static Object o2 = new Object();

public void run() {
System.out.println("线程"+Thread.currentThread().getName() + "的flag为" + flag);
if (flag == 1) {
synchronized (o1) {
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("线程1获得了两把锁");
}
}
}
if (flag == 2) {
synchronized (o2) {
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (o1) {
System.out.println("线程2获得了两把锁");
}
}
}
}

public static void main(String[] argv) {
MustDeadLock r1 = new MustDeadLock();
MustDeadLock r2 = new MustDeadLock();
r1.flag = 1;
r2.flag = 2;
Thread t1 = new Thread(r1, "t1");
Thread t2 = new Thread(r2, "t2");
t1.start();
t2.start();
}
}

排查死锁:

通过jdk工具jps、jstack排查死锁问题。

jps:jdk提供的一个工具,可以查看到正在运行的java进程。

jstack:jdk提供的一个工具,可以查看java进程中线程堆栈信息。

堆栈信息中我们可以发现这个内容:Found one Java-level deadlock,表示程序中发现了一个死锁。

通过jdk提供的工具jconsole排查死锁问题。

jconsole:jdk提供的一个可视化的工具,方便排查程序的一些问题,如:程序内存溢出、死锁问题等等。

通过jdk提供的工具VisualVM排查死锁问题。

VisualVM:jdk提供的一个排查java程序问题的一个工具,可以监控程序的性能、查看jvm配置信息、堆快照、线程堆栈信息。

如何避免死锁

正确的顺序获得锁。

  • 死锁的根本原因就是获取锁的顺序是乱序的。

超时放弃。

  • 当线程获取锁超时了则放弃。