悲观锁与乐观锁

悲观锁

他认为自己在使用数据时一定有别的线程来修改数据,因此在获取数据时先加锁,确保数据被别的线程修改。常见的有synchronized关键字和Lock的实现类

应用场景

适合写操作多的场景,先加锁可以保证写操作时数据正确,显式地锁定后再操作同步资源

乐观锁

他认为自己在使用数据时不会有别地线程修改数据或资源,所以不会添加锁。在Java中是通过使用无锁编程来实现,只是在更新数据时去判断之前有没有其他线程更新这个数据。如果该数据没有更新,当前线程将自己修改的数据成功写入;如果该数据已经被其他线程更新,则根据不同实现方式执行不同的操作

判断规则

  • 版本号机制(Version)
  • CAS算法(Java原子类中的递增操作采用CAS自旋实现)

应用场景:

适合读操作多的场景,不加锁的特点使其读操作的性能大幅提升。乐观锁则直接去操作同步资源,是一种无锁算法

案例演示

悲观锁

1
2
3
public synchronized void m1() {
// 加锁后的业务逻辑
}
1
2
3
4
5
6
7
8
9
10
11
// 保证多个线程使用的是同一个lock对象的前提下
ReentrantLock lock = new ReentrantLock();

public void m2() {
lock.lock();
try {
// 操作同步资源
} finally {
lock.unlock();
}
}

乐观锁

1
2
3
// 保证多个线程使用的是同一个AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();

synchronized

基础回顾

synchronized

synchronized 是 Java 中的关键字,是一种同步锁

  • 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{} 括起来的代码,作用的对象是调用这个代码块的对象
  • 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象
    • 虽然可以使用 synchronized 来定义方法,但 synchronized 并不属于方法定义的一部分,因此,synchronized 关键字不能被继承
    • 如果在父类中的某个方法使用了 synchronized 关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上 synchronized 关键字才可以
  • 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象
  • 修改一个类,其作用的范围是 synchronized 后面括号括起来的部分,作用的对象是这个类的所有对象

如果一个代码块被 synchronized 修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁

  1. 获取锁的线程执行完了该代码块,然后线程释放对锁的占有
  2. 线程执行发生异常,此时 JVM 会让线程自动释放锁

synchronized实现同步的基础︰Java中的每一个对象都可以作为锁具体表现为以下3种形式。

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的class对象。
  • 对于同步方法块,锁是synchonized括号里配置的对象

字节码角度分析

同步代码块

1
javap -c xxx.class # 反编译
javap参数 说明
-c 对代码进行反汇编
-v 输出附加信息

使用synchronized锁同步代码块,字节码角度使用的是monitorentermonitorexit指令,一般情况下一个enter对应两个exit,但极端情况下不一定如下图:

普通同步方法

调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置。如果设置了,执行线程会将先持有monitor锁,然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor

静态同步方法

ACC_STATICACC_SYNCHRONIZED访问标志区分该方法是否静态同步方法

底层原语角度分析

问题:为什么任意一个对象都可以成为一个锁?

管程

管程(Monitors,也称为监视器)是一种程序结构,结构内的多个子程序(对象或模块〉形成的多个工作线程互斥访问共享资源。

这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。

管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。

走进底层

在HotSpot虚拟机中,monitor采用ObjectMonitor实现,跟踪源码ObjectMonitor.java—>ObjectMonitor.cpp—>ObjectMonitor.hpp,如下图:

每个对象天生带有一个对象监视器,每个被锁住的对象都会和Monitor关联起来

ObjectMonitor中有几个关键属性

属性 说明
_owner 指向持有ObjectMonitor对象的线程
_WaitSet 存放处于wait状态的线程队列
_EntryList 存放处于等待锁block状态的线程队列
_recursions 锁的重入次数
_count 用来记录该线程获取锁的次数

公平锁和非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁

1
Lock lock = new ReentrantLock(true); // true 表示公平锁,先来先得

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照中请锁的顺序,有可能后中请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)

1
2
Lock lock = new ReentrantLock(false); // false 表示非公平锁,后来的也可能先获得锁
Lock lock = new ReentrantLock(); // 默认非公平锁

为什么会有公平锁/非公平锁的设计?为什么默认非公平?

  1. 非公平锁能更充分的利用CPU 的时间片,尽量减少 CPU 空闲状态时间。

  2. 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。

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

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

可重入锁

一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁

  • 可重入锁又名递归锁
  • 指在同一个线程在外层方法获取锁时再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
  • Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

隐式锁(即synchronized关键字使用的锁),默认是可重入锁

可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁
在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的

显式锁(即Lock)

synchronized的重入实现原理

  • 每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

  • 当执行monitorenter时,如果目标锁对象的计数器为零,那么说明它没有被其他线程所持有,Jaa虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加1。

  • 在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加1,否则需要等待,直至持有线程释放该锁。

  • 当执行monitorexit时,Java虚拟机则需将锁对象的计数器减1。计数器为零代表锁已被释放。

死锁

死锁

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

产生死锁的主要原因

  • 系统资源不足
  • 进程推进顺序不当
  • 资源分配不当

排查死锁—命令行

1
2
jps -l
jstack 进程编号

排查死锁—控制台(jconsole)

小结

指针指向monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,当一个monitor被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的>