JUC(1)说说Java“锁”事

从轻松的乐观锁和悲观锁聊起

悲观锁

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

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

使用场景:

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

  • 显式的锁定之后再操作同步资源

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

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

乐观锁

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。 如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。

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

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

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

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

    • 采用版本号机制
    • CAS(Compare-and-Swap,即比较并交换)算法实现
//=============乐观锁的调用方式
// 保证多个线程使用的是同一个AtomicInteger
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();

synchronized

对于synchronized的不同位置,我们有三种不同的锁对象,

  • 作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;

  • 作用于代码块,对括号里配置的对象加锁。

  • 作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;

正如阿里巴巴《Java开发手册》编程规约的并发处理章节第七条所说:

高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能 锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。

说明:尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法。

我们通过javap -c文件查看synchronized在不同位置字节码文件所对应的表示:

  1. 作用于代码块

    public class SynchronizedDemo {
        private final Object objectLock = new Object();
        public void test() {
            synchronized (objectLock) {
                System.out.println("in synchronized");
            }
        }
    }
    
      public void test();
        Code:
           0: aload_0
           1: getfield      #3                  // Field objectLock:Ljava/lang/Object;
           4: dup
           5: astore_1
           6: monitorenter
           7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
          10: ldc           #5                  // String in synchronized
          12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          15: aload_1
          16: monitorexit
          17: goto          25
          20: astore_2
          21: aload_1
          22: monitorexit
          23: aload_2
          24: athrow
          25: return
    

    我们可以看到,在指令地址为616以及22均出现了monitorenter和monitorexit指令,并且奇怪的是,monitorenter和monitorexit指令竟然不是1:1出现的?

    这是因为:释放锁有两种情况,一种是正常释放,另一种是异常释放。

    第一个monitorexit指令是同步代码块正常释放锁的一个标志;

    如果同步代码块中出现Exception或者Error,则会调用第二个monitorexit指令来保证释放锁。

    那么作用于实例方法的synchronized在底层monitorenter和monitorexit始终都是1:2的比例吗?

    当然不是。当我们在同步代码块中,主动释放异常时,

    public class SynchronizedDemo {
        private final Object objectLock = new Object();
        public void test() {
            synchronized (objectLock) {
                System.out.println("in synchronized");
                throw new RuntimeException("Exception...");
            }
        }
    }
    
      public void test();
        Code:
           0: aload_0
           1: getfield      #7                  // Field objectLock:Ljava/lang/Object;
           4: dup
           5: astore_1
           6: monitorenter
           7: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
          10: ldc           #19                 // String in synchronized
          12: invokevirtual #21                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          15: new           #27                 // class java/lang/RuntimeException
          18: dup
          19: ldc           #29                 // String Exception...
          21: invokespecial #31                 // Method java/lang/RuntimeException."<init>":(Ljava/lang/String;)V
          24: athrow
          25: astore_2
          26: aload_1
          27: monitorexit
          28: aload_2
          29: athrow
    

    我们明显的看到,monitorenter和monitorexit指令变成是1:1出现的了,在指令地址为2429的athrow是成对出现的,

  2. 作用于实例方法

    public class SynchronizedDemo {
        public synchronized void test() {
            System.out.println("in synchronized");
        }
    }
    
      public synchronized void test();
        descriptor: ()V
        flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
        Code:
          stack=2, locals=1, args_size=1
             0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
             3: ldc           #13                 // String in synchronized
             5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
             8: return
    

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

  3. 作用于静态方法

    public class SynchronizedDemo {
        public synchronized static void test() {
            System.out.println("in synchronized");
        }
    }
    
     public static synchronized void test();
     descriptor: ()V
     flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
     Code:
       stack=2, locals=0, args_size=0
          0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
          3: ldc           #13                 // String in synchronized
          5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
          8: return
    

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

深入源码

任何一个对象都可以成为一个锁,在HotSpot虚拟机中,monitor采用ObjectMonitor实现

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


摘自《深入理解Java虚拟机》

ObjectMonitor.java — ObjectMonitor.cpp — ObjectMonitor.hpp


摘自ObjectMonitor.hpp

ObjectMonitor中有几个关键属性

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

// TODO:坑位 后面补充更深入的信息

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

公平锁和非公平锁

从卖票的例子谈起

使用非公平锁会出现锁饥饿:我们使用5个线程买100张票,使用ReentrantLock默认是非公平锁,获取到的结果可能都是A线程在出售这100张票,会导致B、C、D、E线程发生锁饥饿(使用公平锁会有什么问题)

class Ticket {
    private int number = 50;

    private Lock lock = new ReentrantLock(true); //默认用的是非公平锁,公平锁分配的平均一点,=--》公平一点
    public void sale() {
        lock.lock();
        try {
            if(number > 0) {
                System.out.println(Thread.currentThread().getName()+"\t 卖出第: "+(number--)+"\t 还剩下: "+number);
            }
        }finally {
            lock.unlock();
        }
    }
    /*Object objectLock = new Object();

    public void sale(){
        synchronized (objectLock)
        {
            if(number > 0)
            {
                System.out.println(Thread.currentThread().getName()+"\t 卖出第: "+(number--)+"\t 还剩下: "+number);
            }
        }
    }*/
}
public class SaleTicketDemo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> { for (int i = 1; i <=55; i++) ticket.sale(); },"a").start();
        new Thread(() -> { for (int i = 1; i <=55; i++) ticket.sale(); },"b").start();
        new Thread(() -> { for (int i = 1; i <=55; i++) ticket.sale(); },"c").start();
        new Thread(() -> { for (int i = 1; i <=55; i++) ticket.sale(); },"d").start();
        new Thread(() -> { for (int i = 1; i <=55; i++) ticket.sale(); },"e").start();
    }
}

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

  • 公平锁:是指多个线程按照申请锁的顺序来获取锁,类似排队打饭先来后到,也就是队列
  • 非公平锁:是指多线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取到锁,在高并发的情况下,有可能造成优先级反转或者饥饿现象(也就是某个线程一直得不到锁)
    注意:synchronized 和 ReentrantLock 默认是非公平锁

如何创建

并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或者非公平锁,默认是非公平锁

/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);

源码解读

  • 按序排队公平锁,就是判断同步队列是否还有先驱节点的存在(我前面还有人吗?),如果没有先驱节点才能获取锁;
  • 先占先得非公平锁,是不管这个事的,只要能抢获到同步状态就可以
  • 从上面图中的ReentrantLock的源码中我们可以发现,公平与非公平的源码仅仅多一个判断方法

为什么会有公平锁、非公平锁的设计?

  • 公平锁保证了排队的公平性,非公平锁霸气的忽视这个规则,所以就有可能导致排队的长时间在排队,也没有机会获取到锁,也就是传说中的 “锁饥饿”

  • 公平锁:在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列中的第一个,就占用锁,否者就会加入到等待队列中,以后按照FIFO的规则从队列中取到自己

    非公平锁: 非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。

为什么默认非公平?

  • 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间存在的还是很明显的,所以非公平锁能更充分的利用CPU的时间片,尽量减少CPU空闲状态时间
  • 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当一个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否还有前驱节点,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大了,所以就减少了线程的开销

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

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

同时,在ReentrantLock中我们看到了一个内部类,继承了AbstractQueueSynchronizer,也就是大名鼎鼎的AQS,而公平与非公平也正是基于它实现的。

可重入锁(递归锁)

  • 可重入锁就是递归锁
  • 指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没有释放而阻塞,可在一定程度避免死锁
  • 也就是说:线程可以进入任何一个它已经拥有的锁所同步的代码块
  • 可: 可以 | 重: 再次 | 入: 进入 | 锁: 同步锁 | 进入什么:进入同步域(即同步代码块、方法或显示锁锁定的代码)
  • 与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
  • ReentrantLock(显式锁) / Synchronized(隐式锁) 就是典型的可重入锁
    • 在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的
  • 可重入锁可以多次加锁,但对应的unlock也要解锁对应的次数。
    • 解锁次数比加锁次数少:死锁
    • 解锁次数比加锁次数多:报错java.lang.IllegalMonitorStateException

可重入锁代码示例:

public class ReEnterLockDemo {
    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();
    }
}
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();
    }
}

Synchronized的重入的实现机理(为什么任何一个对象都可以成为一个锁)

前文中,我们曾说过,ObjectMonitor有几个重要的属性,其中

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

死锁

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

产生死锁的原因

  1. 系统资源不足
  2. 进程运行推进的顺序不合适
  3. 资源分配不当
public class DeadLockDemo {
    static Object lockA = new Object();
    static Object lockB = new Object();

    public static void main(String[] args) {
        Thread a = new Thread(() -> {
            synchronized (lockA) {
                System.out.println(Thread.currentThread().getName() + "\t" + " 自己持有A锁,期待获得B锁");

                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lockB) {
                    System.out.println(Thread.currentThread().getName() + "\t 获得B锁成功");
                }
            }
        }, "a");
        a.start();

        new Thread(() -> {
            synchronized (lockB) {
                System.out.println(Thread.currentThread().getName() + "\t" + " 自己持有B锁,期待获得A锁");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (lockA) {
                    System.out.println(Thread.currentThread().getName() + "\t 获得A锁成功");
                }
            }
        }, "b").start();
    }
}
  • 如何排除死锁
  1. 纯命令 (jps -l jstack 进程编号)
  2. jconsole(点击检测死锁按钮)
posted @ 2021-07-27 21:38  Zoran0104  阅读(40)  评论(0编辑  收藏  举报