多线程之锁基础

乐观锁和悲观锁

悲观锁

synchronized关键字和Lock的实现类都是悲观锁

  • 什么是悲观锁?认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改
  • 适合写操作多的场景,先加锁可以保证写操作时数据正确(写操作包括 增 删 改)、显式的锁定之后再操作同步资源
    synchronized 关键字和 Lock 的实现类都是悲观锁

乐观锁

  • 概念 : 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作
  • 乐观锁在 Java 中通过使用无锁编程来实现,最常采用的时CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅度提升。乐观锁一般有两种实现方式(采用版本号机制、CAS算法实现)
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
package tech.chen.juccode.a08;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
* @Date 2022/8/21 14:44
* @Author c-z-k
*/
public class LockDemo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(()->{for (int i = 0; i < 55; i++) {ticket.sale();}},"t1").start();
new Thread(()->{for (int i = 0; i < 55; i++) {ticket.sale();}},"t2").start();
new Thread(()->{for (int i = 0; i < 55; i++) {ticket.sale();}},"t3").start();

}
}
class Ticket{
private int number = 50;
private Lock lock = new ReentrantLock();

public void sale(){
lock.lock();
if (number>0) {
System.out.println(Thread.currentThread().getName()+"\t 卖出第" +number-- + "\t 哈剩下" +number);
}
}
}

公平锁和非公平锁

什么是公平锁和非公平锁

  • 公平锁:是指多个线程按照申请锁的顺序来获取锁类似排队打饭先来后到。
  • 非公平锁:是指在多线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取到锁,在高并发的情况下,有可能造成优先级反转或者饥饿现象。(注意:synchronized 和 ReentrantLock 默认是非公平锁)
    • 锁饥饿 : 我们使用5个线程买100张票,使用 ReentrantLock 默认是非公平锁,获取到的结果可能都是 A 线程在出售这100张票,会导致B、C、D、E线程发生锁饥饿(使用公平锁会有什么问题)

为什么会有公平锁、非公平锁的设计

  • 恢复挂起的线程 到 真正锁的获取 还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间存在的还是很明显的,所以非公平锁能更充分的利用CPU的时间片,尽量减少CPU空闲状态时间
  • 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当一个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大了,所以就减少了线程的开销

什么时候用公平?什么时候用非公平?

  • 如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了。否则那就用公平锁,大家公平使用

可重入锁

什么是可重入锁

  • 可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没有释放而阻塞
  • 如果是1个有 synchronized 修饰得递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。
  • 所以 Java 中 ReentrantLock 和 Synchronized 都是可重入锁,可重入锁的一个优点是可在一定程度避免死锁
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
package tech.chen.juccode.a09;

/**
* @Date 2022/8/21 14:56
* @Author c-z-k
*/
public class ReEntryLock {
public static void main(String[] args) {
Phone phone = new Phone();
try {
phone.sendSms();
} catch (Exception e) {
e.printStackTrace();
}
}
}
class Phone{
public synchronized void sendSms() throws Exception{
System.out.println(Thread.currentThread().getName()+"\tsendSms");
sendEmail();
}
public synchronized void sendEmail() throws Exception{
System.out.println(Thread.currentThread().getName()+"\tsendEmail");
}
}

运行结果:

main sendSms
main sendEmail

可重入锁的种类

  • 隐式锁(即 synchronized 关键字使用的锁)默认是可重入锁,在同步块、同步方法使用。(在一个synchronized修饰的方法或者代码块的内部调用本类的其他 synchronized 修饰的方法或代码块时,是永远可以得到锁的)
  • 显示锁 (即 Lock )也有 ReentrantLock 这样的可重入锁。(lock和unlock一定要一 一匹配,如果少了或多了,都会坑到别的线程)

重入的实现机理

  • 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
  • 当执行 monitorenter 时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有 , Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将计数器加1。
  • 在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程时当前线程,那么 Java 虚拟机可以将其计数器加1,否则需要等待,直到持有线程释放该锁
  • 当执行 monitorexit , Java 虚拟机则需将锁对象的计数器减1。计数器为零代表锁已经释放

1661065349453

死锁及排查

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们都将无法推进下去,如果资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁

死锁演示

代码示例:

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
package tech.chen.juccode.a09;

/**
* @Date 2022/8/21 15:03
* @Author c-z-k
*/
public class DeadLock {
final static Object o1 = new Object();
final static Object o2 = new Object();
public static void main(String[] args) {
new Thread(()->{
while (true) {
synchronized (o1) {
System.out.println(Thread.currentThread().getName() + "获得 o1 锁");
synchronized (o2) {
System.out.println(Thread.currentThread().getName() + "获得 o2 锁");
}
}
}
},"A").start();
new Thread(()->{
while (true) {
synchronized (o2) {
System.out.println(Thread.currentThread().getName() + "获得 o2 锁");
synchronized (o1) {
System.out.println(Thread.currentThread().getName() + "获得 o1 锁");
}
}
}
},"B").start();
}
}

结果:

1661065642376

两个线程均得不到锁,互相制约,但又不会释放。形成死锁

死锁排查

  1. 命令
  1. jps
  2. jstack 进程号
  1. jconsole

1661065818992

1661065831890