JUC并发编程02——锁

2023-12-14 06:04:54

一.乐观锁和悲观锁

悲观锁

  • 认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
  • synchronized关键字和Lock的实现类都是悲观锁。

适用场景:适合写操作多的场景,先加锁可以保证写操作时数据正确。

乐观锁

  • 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
  • 如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。
  • 乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

适用场景:适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

实现方式

乐观锁一般有两种实现方式:

  • 采用版本号机制
  • CAS(Compare-and-Swap,即比较并替换)算法实现

二.synchronized 锁

2.1synchronized 锁的内容

  • 对于普通同步方法锁的是调用当前方法的对象,通常指 this 。并且该对象中所有的普通同步方法用的都是同一把锁。也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取锁的方法释放锁后才能获取锁。(对象锁)
  • 对于静态同步方法锁的是当前类的Class对象,如 Phone.class 唯一的一个模板。一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁。(类锁)
  • 对于同步代码块,锁的是 synchronized 括号内的对象。

注意:具体实例对象 this 和 Class对象,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的

2.2从字节码角度分析 synchronized 实现

synchronized 同步代码块

synchronized的底层实现原理

synchronized作用在代码块时,实现使用的是 monitorenter monitorexit 指令

这里有一个 monitorenter,却有两个 monitorexit 指令的原因是:JVM 要保证每个 monitorenter 必须有与之对应的 monitorexit,monitorenter 指令被插入到同步代码块的开始位置,而 monitorexit 需要插入到方法正常结束处和异常处两个地方,这样就可以保证抛异常的情况下也能释放锁。

可以把执行 monitorenter 理解为加锁,执行 monitorexit 理解为释放锁,每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0,我们来具体看一下 monitorenter 和 monitorexit 的含义:

monitorenter

执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:

  1. 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个 monitor 的所有者。
  2. 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。
  3. 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。

monitorexit

monitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权。

synchronized 普通同步方法

可以看出,被 synchronized 修饰的方法会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。

synchronized 静态同步方法

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

总结

同步代码块和同步方法这两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

2.3为什么每个对象都能成为锁

Java中的每个对象都派生自Object类,而每个Java Object在JVM内部都有一个native的C++对象oop/oopDesc进行对应。线程在获取锁的时候,实际上就是获得一个监视器对象 (monitor),monitor可以认为是一个同步对象,所有的Java对象是天生携带monitor。因此,任何一个对象都可以成为一个锁。这也是为什么我们常说的“Java中每个对象都持有一把锁”,这是synchronized实现同步的基础。

2.4synchronized 锁为什么被称为"重量级锁"

上下文切换

一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或者继续运行,就是“切入”。在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”了。

Java 线程的生命周期状态

  • 当一个线程从 RUNNING 状态转为 BLOCKED 状态时,我们称为一个线程的暂停,线程暂停被切出之后,操作系统会保存相应的上下文,以便这个线程稍后再次进入RUNNABLE 状态时能够在之前执行进度的基础上继续执行。
  • 当一个线程从 BLOCKED 状态进入到 RUNNABLE 状态时,我们称为一个线程的唤醒,当被调度器选中执行后,此时线程将获取上次保存的上下文继续完成执行。

这就是一个上下文切换的过程。

那么在线程运行时,线程状态由 RUNNING 转为BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这又是什么诱发的呢?

我们可以分两种情况来分析,一种是程序本身触发的切换,这种我们称为自发性上下文切换,另一种是由系统或者虚拟机诱发的非自发性上下文切换。 自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。

  • sleep()
  • wait()
  • yield()
  • join()
  • park()
  • synchronized
  • lock

在Java中,synchronized关键字用于控制并发线程的访问,以确保同一时间只有一个线程可以访问特定的代码块或对象。当一个线程试图访问被synchronized保护的代码块或对象时,如果该代码块或对象已经被其他线程锁定,那么这个线程就会被阻塞。这种阻塞会导致线程的状态从RUNNING(运行)转变为BLOCKED(阻塞)。当阻塞的线程重新获得资源并准备运行时,它的状态会从BLOCKED转变为RUNNABLE(可运行)。这个过程就是上下文切换。

上下文切换涉及到保存当前线程的状态和恢复新线程的状态,这会带来一定的系统开销。因此,过度使用synchronized可能会导致频繁的上下文切换,从而降低程序的性能。

三.公平锁和非公平锁

  • 公平锁:是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买后来的人在队尾排着,这是公平的。
    Lock lock = new ReentrantLock(true);//true 表示公平锁,先来先得
  • 非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)。
    Lock lock = new ReentrantLock(false)://false 表示非公平锁,后来的也可能先获得锁
    Lock lock = new ReentrantLock();//默认非公平锁

为什么线程切换会导致用户态与内核台的切换?

因为线程的调度是在内核态运行的,而线程中的代码是在用户态运行。

公平锁执行流程

  1. 获取锁时,先将线程自己添加到等待队列的队尾并休眠
  2. 当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序

在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。

非公平锁执行流程

当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。

非公平锁因为不用按(顺)序执行,所以后来的锁也可以直接尝试获得锁,没有了阻塞和恢复执行的步骤,所以它的性能会更高。

四.可重入锁

是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。

所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。与可重入锁相反,不可重入锁不可递归调用,递归调用就会发生死锁。

隐式锁(synchronized)

在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的

同步代码块

同步方法

显式锁(Lock)

五.死锁及排查

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

纯命令

jps -l
  • jps?是 Java 开发工具包(JDK)提供的一个命令行工具,用于列出当前系统中正在运行的 Java 虚拟机(JVM)进程的信息。
  • -l:显示主类的全名,如果进程是通过 -jar 选项启动的,将显示 JAR 文件的路径。

jstack 进程编号
  • jstack 是 JDK 提供的一个用于生成 Java 线程堆栈跟踪的命令行工具。它通常用于分析 Java 应用程序的线程状态,查找线程死锁、性能问题等。jstack 主要用于定位和解决与多线程相关的问题。
  • 常见的 jstack 选项包括:?-l:以长格式输出,显示关于锁的附加信息。

需要注意的是,jstack 可能会在生成堆栈跟踪时暂停目标 Java 进程的部分工作,因此在生产环境中使用时要谨慎,以避免对应用程序性能产生负面影响。

图形化

jconsole

文章来源:https://blog.csdn.net/qq_62767608/article/details/134866082
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。