JUC并发编程第三章——Java中的锁

1.Java中锁的常见面试题:

一、Synchronized 相关问题

Synchronized 用过吗,其原理是什么?


在Java中,synchronized的底层原理是通过对象监视器(monitor)实现的。每个对象都有一个与之关联的监视器,也称为锁。当一个线程尝试进入一个被synchronized修饰的代码块或方法时,它会尝试获取对象的监视器(锁)。如果锁被其他线程持有,那么线程就会被阻塞,直到锁被释放。在Java虚拟机(JVM)中,每个对象的监视器在对象头中保存。对象头包含用于存储监视器信息的字段,以及其他对象元数据。JVM维护一个全局的监视器表,在表中记录每个对象的锁状态,包括被哪个线程持有、等待队列等信息。当线程尝试进入同步代码块时,JVM会检查对象的锁状态,如果锁未被持有,则线程可以获取锁并执行代码,同时在持有锁的线程中记录这个线程的信息。如果锁已被持有,则线程会被放到等待队列中等待锁被释放。通过对象监视器的机制,synchronized能够保证对共享资源的互斥访问,确保线程安全性。

你刚才提到获取对象的锁,这个"锁"到底是什么? 如何确定对象的锁?


对象锁的概念

在Java中,锁通常指的是同步机制中的一种状态,它用于控制多个线程对共享资源的访问。锁的本质是monitorentermonitorexit字节码指令的一个Reference类型的参数,即要锁定和解锁的对象。

如何确定对象的锁

确定对象的锁可以通过以下方式:

  1. 明确指定锁对象(同步代码块):如果Synchronized关键字明确指定了锁对象,例如Synchronized(变量名)Synchronized(this),那么加解锁的对象就是指定的那个对象。

  2. 非静态方法:如果Synchronized修饰的是非静态方法,那么此方法对应的对象实例就是锁对象。

  3. 静态方法:如果Synchronized修饰的是静态方法,那么此方法对应的类对象(Class对象)就是锁对象。

需要注意的是,当一个对象被锁住时,该对象内所有使用Synchronized修饰的方法都会被阻塞,而非Synchronized修饰的方法仍然可以正常被调用,不会受到锁的影响.


什么是可重入性,为什么说 Synchronized 是可重入锁?
JVM 对 Java 的原生锁做了哪些优化?
为什么说 Synchronized 是非公平锁?
什么是锁消除和锁粗化?

为什么说 Synchronized 是一个悲观锁?


Synchronized被称为悲观锁是因为它的策略是“先获取锁再进行操作”。在进入Synchronized代码块或方法时,线程会尝试获取锁,如果锁已被其他线程持有,则当前线程会被阻塞,直到获取到锁为止。这种机制是一种悲观的做法,它默认认为在并发情况下会发生竞争和冲突,因此需要通过获取锁来确保对共享资源的互斥访问。悲观锁的缺点在于当并发量较高时,线程之间会频繁竞争锁资源,导致线程阻塞和上下文切换的开销增加,降低了系统的并发性能。而且悲观锁会导致一些线程长时间等待获取锁,可能会出现死锁的情况。相对于悲观锁,乐观锁则是一种更加乐观的做法,它默认认为在并发情况下不会发生冲突,所以先进行操作,然后在提交时再进行冲突检测。乐观锁在一定程度上降低了锁的竞争和等待时间,提高了并发性能。常见的乐观锁实现包括基于CAS(Compare And Swap)的方式。

乐观锁的实现原理又是什么? 什么是 CAS ,它有什么优点?
乐观锁一定就是好的吗?


二、可重入锁 ReentrantLock 及其它显式锁相关问题

跟 Synchronized 相比,可重入锁 其实现原理有什么不同?
那么请谈谈 AQS 框架是怎么回事儿?
请尽可能详尽地对比下 Synchronized 和 ReentrantLock 的异同
ReentrantLock 是如何实现可重入性的?
三、其它

你怎么理解 Java 语言多线程的? 怎么处理并发? 线程池有那几个核心参数?
Java 加锁有哪儿种锁? 我先说 synchronized,刚讲到偏向锁,他就不让我讲了,太自信了
简单说说锁?
hashmap的实现原理? hash冲突怎么解决? 为什么使用红黑树?
spring 里面都使用了哪些设计模式? 循环依赖怎么解决?
项目中哪个地方用了 CountDownLatch,怎么使用的?

2.乐观锁和悲观锁的简单介绍


2.1悲观锁
认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改

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

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

一句话定义:狼性锁

乐观锁
认为自己在使用数据的时候不会有别的线程修改数据或资源,所以不会添加锁

Java中使用无锁编程来实现,只是在更新的时候去判断,之前有没有别的线程更新了这个数据

如果这个数据没有被更新,当前线程将自己修改的数据成功写入
如果这个数据已经被其他线程更新,则根据不同的实现方式执行不同的操作,比如:放弃修改、重试抢锁等等
判断规则

版本号机制Version
最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的
适合读操作多的场景,不加锁的特性能够使其读操作的性能大幅提升

乐观锁则直接去操作同步资源,是一种无锁算法,得之我幸不得我命,再努力就是

一句话定义:佛系锁

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

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

3.通过8种情况演示锁运行案例,看看我们到底锁的是什么


3.1锁相关的8种案例演示code

package com.bilibili.juc.lock;

import java.util.concurrent.TimeUnit;

/**
 * 题目:谈谈你对多线程锁的理解,8锁案例说明
 * 口诀:线程 操作 资源类
 * 8锁案例说明:
 * 1. 标准访问ab两个线程,请问先打印邮件还是短信? --------先邮件,后短信  共用一个对象锁
 * 2. sendEmail钟加入暂停3秒钟,请问先打印邮件还是短信?--------先邮件,后短信  共用一个对象锁
 * 3. 添加一个普通的hello方法,请问先打印普通方法还是邮件? --------先hello,再邮件  资源没有争抢,hello方法没有用到对象锁
 * 4. 有两部手机,请问先打印邮件还是短信? --------先短信后邮件  资源没有争抢,不是同一个对象锁
 * 5. 有两个静态同步方法,一部手机, 请问先打印邮件还是短信?--------先邮件后短信  共用一个类锁
 * 6. 有两个静态同步方法,两部手机, 请问先打印邮件还是短信? --------先邮件后短信  共用一个类锁
 * 7. 有一个静态同步方法,一个普通同步方法,一部手机,请问先打印邮件还是短信? --------先短信后邮件  一个类锁一个对象锁
 * 8. 有一个静态同步方法,一个普通同步方法,两部手机,请问先打印邮件还是短信? ---------先短信后邮件  一个类锁一个对象锁
 */
public class Lock8Demo {

    public static void main(String[] args) {
        Phone phone = new Phone();
        Phone phone2 = new Phone();

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

        // 暂停200毫秒,保证线程先启动
        try {
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

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

}

// 资源类
class Phone {
    public static synchronized void sendEmail() {
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("--------sendEmail--------");
    }
    public /*static*/ synchronized void sendSMS() {
        System.out.println("--------sendSMS--------");
    }
    public void hello() {
        System.out.println("--------hello--------");
    }
}

3.2案例总结


1-2:

一个对象里面如果有多个 synchronized 方法,某一个时刻内,只要一个线程去调用其中的一个synchronized方法,其它线程都只能等待。换句话说,某一个时刻内,只能有唯一的一个线程去访问这些synchronized方法。锁的是当前对象this,被锁定后,其它线程都不能进入到当前对象的其它的synchronized方法

3-4:

加个普通方法后发现和同步锁无关

换成两个对象后,不是同一把锁了,情况立即变化

5-6:

都换成静态同步方法后,情况又变化

三种synchronized锁的内容有一些差别:

对于普通同步方法(被synchronized修饰的成员方法),锁的是当前实例对象,通常指this,所有的同步方法用的都是同一把锁—>实例对象本身(即Phone phone = new Phone();)
对于静态同步方法(被synchronized修饰的静态方法),锁的是当前类的Class对象,即Phone.class唯一的一个模板
对于同步方法块,锁的是synchronized括号内的对象

7-8:

当一个线程试图访问同步代码时,它首先必须得到锁,正常退出或抛出异常时必须释放锁

所有的普通同步方法用的都是同一把锁——实例对象本身,就是new出来的具体实例对象本身,本类this。也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其它普通同步方法必须等待获取锁的方法释放锁后才能获取锁

所有的静态方法用的也是同一把锁——类对象本身,就是我们说过的唯一模板class。具体实例对象this和唯一模板class,这两把锁是两个不同的对象,所以静态同步方法和普通同步方法之间是不会有竞态条件的。但是一旦一个静态同步方法获取锁后,其它的静态同步方法都必须等待该方法释放锁后才能获取锁

如图中Car Class就是模板,存放在虚拟机中的方法区/元空间,Car Class这个模板就只有一份。但是Car的实例对象,如:car1、car2、car3都在堆内存中,可以有多个。所以说Car Class和car实例加锁的地方和对象都不一样。运行效果也不一样。

3.3 synchronized有三种应用方式

  • 作用于实例方法,当前实例加锁,进入同步代码块前要获得当前实例的锁;
  • 作用于代码块,对括号里配置的对象加锁
  • 作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁

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

  • javap -c(v附加信息) ***.class 文件反编译
  • synchronized同步代码块
    • 实现使用的是monitorenter和monitorexit指令
1.synchronized同步代码块
public class LockSyncDemo {

    Object object = new Object();

    public void m1() {
        synchronized (object) {
            System.out.println("--------hello synchronized code block--------");
        }
    }
    public static void main(String[] args) {

    }
}

反编译结果:

通过javap -c LockSyncDemo.class反编译

实现使用的是monitorenter和monitorexit指令

2.synchronized普通同步方法
public class LockSyncDemo2 {

    public synchronized void m2() {
        System.out.println("--------hello synchronized m2--------");
    }
    public static void main(String[] args) {

    }
}

反编译结果:

通过javap -v LockSyncDemo2.class反编译(为了看到更详细的信息用 -v )

总结:synchronized普通同步方法调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程会将现持有monitor锁,然后再执行该方法,最后在方法完成(无论是否正常结束)时释放monitor

3.synchronized静态同步方法
package com.bilibili.juc.lock.sync;

public class LockSyncDemo3 {

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

    public static synchronized void m3() {
        System.out.println("--------hello static synchronized m3--------");
    }

    public static void main(String[] args) {

    }
}

反编译结果:

通过javap -v LockSyncDemo3.class反编译

总结

ACC_STATIC、ACC_SYNCHRONIZED访问标志区分该方法是否是静态同步方法


3.5反编译synchronized锁的是什么

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

C++源码:ObjectMonitor.java--->ObjectMonitor.cpp--->ObjectMonitor.hpp

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

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

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

        Java中任何一个对象都可以成为锁,每个对象在创建时都会初始化一个ObjectMonitor对象。当一个线程尝试访问同步代码块或者方法时,它会首先尝试获取该对象的锁(ObjectMonitor)。如果锁已经被其他线程持有,那么这个线程就会被阻塞,直到锁变为可用状态。ObjectMonitor内部可以记录占有锁的线程、等待队列以及重入次数等信息,从而管理对共享资源的并发访问。这种机制确保了即使多个线程试图同时访问同一资源,也能够按照一定的顺序安全有序地进行。

面试题:synchronized原理是什么?

        在Java中,synchronized的底层原理是通过对象监视器(monitor)实现的。每个对象都有一个与之关联的监视器,也称为锁。当一个线程尝试进入一个被synchronized修饰的代码块或方法时,它会尝试获取对象的监视器(锁)。如果锁被其他线程持有,那么线程就会被阻塞,直到锁被释放。

        在Java虚拟机(JVM)中,每个对象的监视器在对象头中保存。对象头包含用于存储监视器信息的字段,以及其他对象元数据。JVM维护一个全局的监视器表,在表中记录每个对象的锁状态,包括被哪个线程持有、等待队列等信息。当线程尝试进入同步代码块时,JVM会检查对象的锁状态,如果锁未被持有,则线程可以获取锁并执行代码,同时在持有锁的线程中记录这个线程的信息。如果锁已被持有,则线程会被放到等待队列中等待锁被释放。

        通过对象监视器的机制,synchronized能够保证对共享资源的互斥访问,确保线程安全性。


4.公平锁和非公平锁

4.1公平锁和非公平锁的概念

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

4.2面试题

  • 为什么会有公平锁/非公平锁的设计?为什么默认非公平?
    • 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分地利用CPU的时间片,尽量减少CPU空间状态时间。
    • 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当一个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得很大,所以就减少了线程的开销。
  • 什么时候用公平?什么时候用非公平?
    • 如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省了很多线程切换的时间,吞吐量自然就上去了;否则就用公平锁,大家公平使用。

5.可重入锁

5.1概念说明

可重入锁又名递归锁

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

如果是1个有 synchronized 修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是十分荒谬,出现了作茧自缚

所以Java中的 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁

5.2可重入锁种类

  • 隐式锁(即synchronized关键字使用的锁),默认是可重入锁
    • 在一个synchronized修饰的方法或者代码块的内部调用本类的其他synchronized修饰的方法或者代码块时,是永远可以得到锁。
  • 显式锁(即Lock)也有ReentrantLock这样的可重入锁
5.2.1.synchronized隐式锁

同步块

package com.bilibili.juc.lock.reentry;

public class ReEntryLockDemo {

    public static void main(String[] args) {
        final Object o = new Object();

        new Thread(() -> {
            synchronized (o) {
                System.out.println(Thread.currentThread().getName() + "\t --------外层调用--------");
                synchronized (o) {
                    System.out.println(Thread.currentThread().getName() + "\t --------中层调用--------");
                    synchronized (o) {
                        System.out.println(Thread.currentThread().getName() + "\t --------内层调用--------");
                    }
                }
            }
        }, "t1").start();
    }

}

输出结果:
t1	 --------外层调用--------
t1	 --------中层调用--------
t1	 --------内层调用--------

同步方法

package com.bilibili.juc.lock.reentry;

public class ReEntryLockDemo2 {

    public static void main(String[] args) {
        ReEntryLockDemo2 reEntryLockDemo2 = new ReEntryLockDemo2();
        new Thread(() -> reEntryLockDemo2.m1(), "t1").start();
    }

    public synchronized void m1() {
        System.out.println(Thread.currentThread().getName() + "\t --------m1 come in--------");
        m2();
        System.out.println(Thread.currentThread().getName() + "\t --------m1 end--------");
    }

    public synchronized void m2() {
        System.out.println(Thread.currentThread().getName() + "\t --------m2 come in--------");
        m3();
    }

    public synchronized void m3() {
        System.out.println(Thread.currentThread().getName() + "\t --------m3 come in--------");
    }

}

输出结果:
t1	 --------m1 come in--------
t1	 --------m2 come in--------
t1	 --------m3 come in--------
t1	 --------m1 end--------

synchronized的重入的实现原理

之前解释为什么任何一个对象都可以称为一个锁可以看到ObjectMonitor.hpp类中初始化monitor代码,ObjectMonitor对象中的_recursions_count,锁的重入次数和用来记录该线程获取锁的次数

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

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

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

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

5.2.2显式锁(即Lock)也有 ReentrantLock 这样的可重入锁
package com.bilibili.juc.lock.reentry;

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

public class ReEntryLockDemo3 {

    static Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        new Thread(() -> {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "\t --------外层调用come in--------");
                lock.lock();
                try {
                    System.out.println(Thread.currentThread().getName() + "\t --------内层调用come in--------");
                } finally {
                    lock.unlock();
                }
            } finally {
                lock.unlock();
            }
        }, "t1").start();
    }

}

输出结果:
t1	 --------外层调用come in--------
t1	 --------内层调用come in--------

总结

隐式锁(即synchronized关键字使用的锁)天生具备可重入性,显式锁(即Lock)的可重入性,需要注意锁了几次就要释放几次


6.死锁及排查

6.1 概念

死锁是指两个或两个以上的线程在执行过程中,因抢夺资源而造成的一种互相等待的现象,若无外力干涉,则它们无法再继续推进下去。

产生原因:

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


6.2写一个死锁代码case

    static Object a = new Object();
    static Object b = new Object();

    public static void main(String[] args) {

        new Thread(() -> {
            synchronized (a) {//获取锁a
                try {
                    Thread.sleep(1);//休眠一秒确保另一个线程获取到了锁b
                    System.out.println(Thread.currentThread().getName()+"获取了锁a");
                    synchronized (b) {//当前线程尝试获取锁b,注意此时锁a并没有被释放
                        System.out.println("成功获取锁a、b");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"线程1").start();

        new Thread(() -> {
            synchronized (b) {//获取锁b
                try {
                    Thread.sleep(1);//休眠一秒确保另一个线程获取到了锁a
                    System.out.println(Thread.currentThread().getName()+"获取了锁b");
                    synchronized (a) {//当前线程尝试获取锁b,注意此时锁b并没有被释放
                        System.out.println("成功获取锁a、b");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"线程2").start();
    }

输出结果:
线程2获取了锁b
线程1获取了锁a
//程序卡死,一直无法结束

6.3如何排查死锁

  • 纯命令
    • jps -l
    • jstack 进程编号
  • 图形化
    • jconsole

总结

7.后续锁的学习

写锁(独占锁)/读锁(共享锁)

深度源码分析见后面

自旋锁spinLock

深度源码分析见后面

无锁->独占锁->读写锁->邮戳锁

深度源码分析见后面

无锁->偏向锁->轻量锁->重量锁

深度源码分析见后面

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值