Java并发编程"锁"篇1-说说Java的锁事

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

0. 大厂面试题

在这里插入图片描述

1. 乐观锁和悲观锁

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

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

悲观锁伪代码说明如下:

//=====悲观锁的调用方式
public synchronized void m1()
{
    //加锁后的业务逻辑......
}

// 保证多个线程使用的是同一个lock对象的前提下
ReentrantLock lock = new ReentrantLock();
public void m2() {
    lock.lock();
    try {
        // 操作同步资源
    }finally {
        lock.unlock();
    }
}

//=============乐观锁的调用方式
// 保证多个线程使用的是同一个AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
复制代码

乐观锁: 乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。

如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的

乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。乐观锁则直接去操作同步资源,是一种无锁算法,得之我幸不得我命,再抢。乐观锁一般有两种实现方式:

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

2. synchronized锁

2.1 synchronized有三种应用方式

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

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

命令:javap -c a.class文件反编译

  • -c:对代码进行反汇编
  • -v -verbose 输出附加信息(包括行号、本地变量表,反汇编等详细信息)

2.2.1 synchronized同步代码块

通过javap -c a.class文件反编译结果如下,发现实现同步代码块使用的是 monitorentermonitorexit 指令 :

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

一定是一个enter两个exit吗? m1方法里面自己添加一个异常试试,结果如下:

在这里插入图片描述

在这里插入图片描述

2.2.2 synchronized普通同步方法

javap -v a.class文件反编译,结果如下: 在这里插入图片描述

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

2.2.3 synchronized静态同步方法

在这里插入图片描述

synchronized静态同步方法是通过ACC_STATIC, ACC_SYNCHRONIZED访问标志区分该方法是否静态同步方法。

2.3 反编译synchronized锁的是什么?

大厂面试题:

在这里插入图片描述

管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。

执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程

在HotSpot虚拟机中,monitor采用ObjectMonitor实现,从ObjectMonitor.java→ObjectMonitor.cpp→objectMonitor.hpp

objectMonitor.hpp 如下:

在这里插入图片描述

ObjectMonitor中几个关键属性:

  • _owner: 指向持有ObjectMonitor对象的线程
  • WaitSet: 存放处于wait状态的线程队列
  • EntryList: 存放处于等待锁block状态的线程队列
  • recursions: 锁的重入次数
  • _count: 用来记录该线程获取锁的次数

每个对象天生都带着一个对象监视器synchronized必须作用于某个对象中,所以Java在对象的头文件存储了锁的相关信息。锁升级功能主要依赖于 MarkWord 中的锁标志位和释放偏向锁标志位。

在这里插入图片描述

3. 公平锁和非公平锁

ReentrantLock卖票编码演示公平和非公平现象,代码演示如下:

class Ticket {
    private int number = 30;
    ReentrantLock lock = new ReentrantLock();

    public void sale()
    {
        lock.lock();
        try
        {
            if(number > 0)
            {
                System.out.println(Thread.currentThread().getName()+"卖出第:\t"+(number--)+"\t 还剩下:"+number);
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}

public class SaleTicketDemo
{
    public static void main(String[] args)
    {
        Ticket ticket = new Ticket();

        new Thread(() -> { for (int i = 0; i <35; i++)  ticket.sale(); },"a").start();
        new Thread(() -> { for (int i = 0; i <35; i++)  ticket.sale(); },"b").start();
        new Thread(() -> { for (int i = 0; i <35; i++)  ticket.sale(); },"c").start();
    }
}
复制代码

排队讲求先来后到视为公平。程序中的公平性也是符合请求锁的绝对时间的,其实就是 FIFO,否则视为不公平。

按序排队公平锁,就是判断同步队列是否还有先驱节点的存在(我前面还有人吗?),如果没有先驱节点才能获取锁;先占先得非公平锁,是不管这个事的,只要能抢获到同步状态就可以。

在AQS源码中两者之前的区别如下图所示,具体AQS源码参考AQS那一章: 在这里插入图片描述

面试题

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

    • 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分的利用CPU的时间片,尽量减少 CPU 空闲状态时间。
    • 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。
  2. 使⽤公平锁会有什么问题? 公平锁保证了排队的公平性,非公平锁霸气的忽视这个规则,所以就有可能导致排队的长时间在排队,也没有机会获取到锁,这就是传说中的 “锁饥饿

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

4. 可重入锁(又名递归锁)

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

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

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

可重入锁种类分两种:分别是:

  • 隐式锁(即synchronized关键字使用的锁)默认是可重入锁
  • 显式锁(即Lock)也有ReentrantLock这样的可重入锁。

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

同步块代码演示如下:

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

        new Thread(() -> {
            synchronized (objectLockA)
            {
                System.out.println("-----外层调用");
                synchronized (objectLockA)
                {
                    System.out.println("-----中层调用");
                    synchronized (objectLockA)
                    {
                        System.out.println("-----内层调用");
                    }
                }
            }
        },"a").start();
    }
}
复制代码

同步方法代码演示如下:

/**
 * 在一个Synchronized修饰的方法或代码块的内部调用本类的其他Synchronized修饰的方法或代码块时,是永远可以得到锁的
 */
public class ReEntryLockDemo
{
    public synchronized void m1()
    {
        System.out.println("-----m1");
        m2();
    }
    public synchronized void m2()
    {
        System.out.println("-----m2");
        m3();
    }
    public synchronized void m3()
    {
        System.out.println("-----m3");
    }

    public static void main(String[] args)
    {
        ReEntryLockDemo reEntryLockDemo = new ReEntryLockDemo();

        reEntryLockDemo.m1();
    }
}
复制代码

3.1 Synchronized的重入的实现机理

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

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

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

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

显式锁代码演示如下:

/**
 * 在一个Synchronized修饰的方法或代码块的内部调用本类的其他Synchronized修饰的方法或代码块时,是永远可以得到锁的
 */
public class ReEntryLockDemo
{
    static Lock lock = new ReentrantLock();

    public static void main(String[] args)
    {
        new Thread(() -> {
            lock.lock();
            try
            {
                System.out.println("----外层调用lock");
                lock.lock();
                try
                {
                    System.out.println("----内层调用lock");
                }finally {
                    // 这里故意注释,实现加锁次数和释放次数不一样
                    // 由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
                    lock.unlock(); // 正常情况,加锁几次就要解锁几次
                }
            }finally {
                lock.unlock();
            }
        },"a").start();

        new Thread(() -> {
            lock.lock();
            try
            {
                System.out.println("b thread----外层调用lock");
            }finally {
                lock.unlock();
            }
        },"b").start();

    }
}
复制代码

4. 死锁及排查

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

产生死锁主要原因

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

面试题:请写一个死锁代码case

public class DeadLockDemo {
    public static void main(String[] args) {
        final Object objectLockA = new Object();
        final Object objectLockB = new Object();

        new Thread(() -> {
            synchronized (objectLockA) {
                System.out.println(Thread.currentThread().getName()+"\t"+"自己持有A,希望获得B");
                //暂停几秒钟线程
                try { 
                    TimeUnit.SECONDS.sleep(1); 
                } catch (InterruptedException e) { 
                    e.printStackTrace(); 
                }
                synchronized (objectLockB) {
                    System.out.println(Thread.currentThread().getName()+"\t"+"A-------已经获得B");
                }
            }
        },"A").start();

        new Thread(() -> {
            synchronized (objectLockB)
            {
                System.out.println(Thread.currentThread().getName()+"\t"+"自己持有B,希望获得A");
                //暂停几秒钟线程
                try { 
                    TimeUnit.SECONDS.sleep(1); 
                } catch (InterruptedException e) { 
                    e.printStackTrace(); 
                }
                synchronized (objectLockA) {
                    System.out.println(Thread.currentThread().getName()+"\t"+"B-------已经获得A");
                }
            }
        },"B").start();

    }
}
复制代码

面试题:如何排查死锁?

方式一:命令:首先使用**jps -l**命令查看线程信息(类似于linux中的ps -ef)

在这里插入图片描述 接着通过**jstack 进程编号** 查看指定线程的堆栈信息,如下图所示:

在这里插入图片描述

方式二:通过图形化工具jconsole 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

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

ReentrantLock、ReentrantReadWriteLock、StampedLock讲解

6. 自旋锁SpinLock

并发编程"锁"篇2-自旋锁(CAS)

7. 无锁→偏向锁→轻量锁→重量锁

底层原理篇4-Synchronized与锁升级

8. 无锁→独占锁→读写锁→邮戳锁

ReentrantLock、ReentrantReadWriteLock、StampedLock讲解

参考资料

Java并发编程知识体系
Java并发编程的艺术
Java多线程编程核心技术
Java并发实现原理 JDK源码剖析

Guess you like

Origin juejin.im/post/7075291542813933582