JUC并发编程(2)—synchronized锁原理

synchronized关键字以及锁的原理学习笔记:
学习b站周扬老师视频:https://www.bilibili.com/video/BV1ar4y1x727
讲得真的很不错!

乐观锁和悲观锁介绍

  1. 悲观锁:
    认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
    synchronized关键字和Lock的实现类都是悲观锁
    显式的锁定之后再操作同步资源

  2. 乐观锁:
    认为自己在使用数据时不会有别的线程修改数据或资源,所以不会添加锁,在Java中是通过使用无锁编程来实现,只是在更新数据的时候去判断,之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果这个数据已经被其它线程更新,则根据不同的实现方式执行不同的操作,比如放弃修改、重试抢锁等等,如原子操作类那些底层是CAS算法,也就是乐观锁。

判断规则
1)版本号机制Version
2)最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

使用场景:
适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
乐观锁则直接去操作同步资源,是种无锁算法,得之我幸不得我命,再努力就是

synchronized用法介绍

阿里巴巴Java规范手册上说明:
高并发时,同步调用应该去考置锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。
说明︰尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用RPC方法。

synchronized的三种应用方式

  1. 作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;
    public synchronized void sayHello(){
        System.out.println("作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;");
    }
  1. 作用于代码块,对括号里配置的对象加锁。
		synchronized (this){
        	System.out.println("作用于代码块,对括号里配置的对象加锁");
        }
  1. 作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;
    public static synchronized void sayHello(){
        System.out.println("作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;");
    }

synchronized和ReentrantLock的区别

  1. Sychronized是一个关键字,ReentrantLock是一个类
  2. Sychronized会自动的加锁和释放锁,ReentrantLock需要程序员手动的加锁和释放锁
  3. Sychronized底层是JVM层面的锁,ReentrantLock是API层面的锁
  4. Sychronized是非公平锁,ReentrantLock可以选择公平锁或非公平
  5. Sychronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中的int类型的state标识来标识锁的状态

经典8锁问题案例

①. 标准访问有ab两个线程,请问先打印邮件还是短信

②. sendEmail方法暂停3秒钟,请问先打印邮件还是短信

③. 新增一个普通的hello方法,请问先打印邮件还是hello

④. 有两部手机,请问先打印邮件还是短信

⑤. 两个静态同步方法,同1部手机,请问先打印邮件还是短信

⑥. 两个静态同步方法, 2部手机,请问先打印邮件还是短信

⑦. 1个静态同步方法,1个普通同步方法,同1部手机,请问先打印邮件还是短信

⑧. 1个静态同步方法,1个普通同步方法,2部手机,请问先打印邮件还是短信

class Phone{ //资源类
    public static synchronized void sendEmail() {
        //暂停几秒钟线程
        try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println("-------sendEmail");
    }

    public synchronized void sendSMS()
    {
        System.out.println("-------sendSMS");
    }

    public void hello()
    {
        System.out.println("-------hello");
    }
}
public class Lock8Demo{
    public static void main(String[] args){//一切程序的入口,主线程
        Phone phone = new Phone();//资源类1
        Phone phone2 = new Phone();//资源类2

        new Thread(() -> {
            phone.sendEmail();
        },"a").start();

        //暂停毫秒
        try { TimeUnit.MILLISECONDS.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); }

        new Thread(() -> {
            //phone.sendSMS();
            //phone.hello();
            phone2.sendSMS();
        },"b").start();

    }
}
/**
 *
 * ============================================
 *  1-2
 *  *  一个对象里面如果有多个synchronized方法,某一个时刻内,只要一个线程去调用其中的一个synchronized方法了,
 *  *  其它的线程都只能等待,换句话说,某一个时刻内,只能有唯一的一个线程去访问这些synchronized方法
 *  *  锁的是当前对象this,被锁定后,其它的线程都不能进入到当前对象的其它的synchronized方法
 *
 *  3-4
 *  *  加个普通方法后发现和同步锁无关
 *  *  换成两个对象后,不是同一把锁了,情况立刻变化。
 *
 *  5-6 都换成静态同步方法后,情况又变化
 *  三种 synchronized 锁的内容有一些差别:
 * 对于普通同步方法,锁的是当前实例对象,通常指this,具体的一部部手机,所有的普通同步方法用的都是同一把锁——实例对象本身,
 * 对于静态同步方法,锁的是当前类的Class对象,如Phone.class唯一的一个模板
 * 对于同步方法块,锁的是 synchronized 括号内的对象
 *
 *  7-8
 *    当一个线程试图访问同步代码时它首先必须得到锁,退出或抛出异常时必须释放锁。
 *  *
 *  *  所有的普通同步方法用的都是同一把锁——实例对象本身,就是new出来的具体实例对象本身,本类this
 *  *  也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取锁的方法释放锁后才能获取锁。
 *  *
 *  *  所有的静态同步方法用的也是同一把锁——类对象本身,就是我们说过的唯一模板Class
 *  *  具体实例对象this和唯一模板Class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的
 *  *  但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁。
 **/

从字节码角度分析synchronized实现

反编译:javap -v -p *.class > 类.txt 将进行输出到txt中
-c:对代码进行反汇编
-v -verbose 输出附加信息(包括行号、本地变量表,反汇编等详细信息)

在这里插入图片描述

synchronized同步代码块

实现使用的是monitorenter和monitorexit指令,他俩总是搭配使用,一个monitorenter对应两个monitorexit,monitorenter表示获得锁,monitorexit表示释放锁。但是经过反编译发现,里面多了monitorexit,是为了发生异常时也能正常释放锁。正常情况下走前面的monitorexit,异常情况走后面的monitorexit。极端情况下,也会出现一对一的情况,在退出同步代码前抛出异常,此时就是一对一的情况,因为就没有正常情况了,无论那种情况都会抛出异常。

在这里插入图片描述

synchronized普通同步方法

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

在这里插入图片描述

synchronized静态同步方法

javap -v .class文件反编译
ACC_STATIC,ACC_SYNCHRONIZED。相比于普通同步方法,静态同步方法多了一个ACC_STATIC访问标志,使用它来区分该方法是否静态同步方法

在这里插入图片描述

面试题:为什么任何一个对象都可以成为一个锁

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

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

通过C底层原语了解,在HotSpot虚拟机中,monitor采用objectMonitor实现
ObjectMonitor.java→ObjectMonitor.cpp→objectMonitor.hpp
每个对象天生都带着一个对象监视器
每一个被锁住的对象都会和Monitor关联起来
objectMonitor.hpp的关键属性

公平锁与非公平锁

  1. 公平锁:
    指多个线程按照中请求锁的顺序来获取锁,这里类似排队买票,先来的人先买后来的人在队尾排着,这是公平的
    Lock lock = new ReentrantLock(true);/l/true表示公平锁,先来先得

  2. 非公平锁:
    是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先中请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)
    Lock lock = new ReentrantLock(false); false表示非公平锁,后来的也可能先获得锁。空参默认非公平锁

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

恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU的时间片,尽量减少CPU空闲状态时间。

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

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

可重入锁(递归锁)

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

如果是1个有 synchronized修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚。所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
一个线程中的多个流程可以获取同一把锁,持有这把同步锁可以再次进入。自己可以获取自己的内部锁

可重入锁种类

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

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

显式锁(即Lock)也有ReentrantLock这样的可重入锁。
Lock.unLock();//正常情况,加锁几次就要解锁几次
由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待

Synchronized的重入的实现机理

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

死锁

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

产生死锁主要原因

  • 系统资源不足
  • 资源分配不当
  • 进程运行推进的顺序不合适

死锁代码案例

public class DeadLockTest {

    public static void main(String[] args) {
        Object objectA = new Object();
        Object objectB = new Object();
        new Thread(()->{
            synchronized (objectA){
                System.out.println("获得objectA锁,尝试获得objectB锁");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (objectB){
                    System.out.println("获得objectA锁,成功获得objectB锁");
                }
            }
        },"A").start();

        new Thread(()->{
            synchronized (objectB){
                System.out.println("获得objectB锁,尝试获得objectA锁");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (objectA){
                    System.out.println("获得objectB锁,成功获得objectA锁");
                }
            }
        },"B").start();
    }
}

tips
如何排查死锁?
纯命令:jps -l
jstack 进程编号
在这里插入图片描述

图形化:jconsole
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

总结

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

synchronized锁原理以及执行过程如下图所示:

在这里插入图片描述
在这里插入图片描述

posted @ 2023-07-20 21:18  愤怒的小肥  阅读(15)  评论(0编辑  收藏  举报