并发编程防御装-锁(基础版)

并发编程防御装-锁(基础版)

大家好,我是小高先生。在Java并发编程的世界中,锁的地位至关重要。它就像是一道坚固的防线,确保了并发编程运行结果的正确性。你可以不准备攻击装备,但是锁这个防御装备是必不可少的。相信大家在之前都对锁或多或少有些了解,本文将带领大家学习锁的基础知识。

  • 乐观锁和悲观锁
  • synchronized案例
  • synchronized字节码分析
  • synchronized锁的是什么
  • 公平锁和非公平锁
  • 可重入锁
  • 死锁

乐观锁和悲观锁

在并发编程的世界中,悲观锁和乐观锁是两种截然不同的锁定策略,每种策略都有其适用的场合和特定的使用场景。

悲观锁,如其名所示,持有一种对数据冲突的悲观看法。它假设在共享数据的访问过程中,很可能会遇到其他线程的争用和修改,这可能导致数据的不一致性和其他问题。因此,为了确保数据修改操作的安全性,悲观锁会采取一种保守的策略:在修改数据之前,它会加锁,确保在数据被修改的同时,没有其他线程可以访问这些数据。这种策略的代表是Java中的synchronized关键字和Lock接口的实现类。悲观锁尤其适用于写操作较为频繁的环境,通过预先加锁,它可以保证在进行写入操作时的数据一致性。

相对于悲观锁,乐观锁则持有一种相反的乐观态度。它假设在大多数情况下,共享数据在被访问时不会发生冲突,因此不会默认进行加锁。在Java中,乐观锁通常通过无锁编程来实现,允许所有线程访问共享数据。但是,只有在数据实际被写入内存时,线程才会检查在此期间是否有其他线程也进行了更新。如果数据未被其他线程修改,当前线程就可以成功地将其更改写入内存。如果检测到数据已被其他线程更新,当前线程可能需要采取其他措施,例如放弃其更改或进入重试循环。

乐观锁的常见实现方式包括:

  • 版本号机制(Version)
  • CAS算法

乐观锁适合读操作较多的环境,不加锁可以提升读取操作的性能。如果使用悲观锁,同一时间只能有一个线程获取到锁,这可能会影响效率。

总的来说,悲观锁和乐观锁各自代表了安全性和效率的两个极端。悲观锁提供了高度的安全性,但可能牺牲了效率;而乐观锁追求高效的操作,但可能在某些情况下牺牲了安全性。在实际的应用场景中,我们需要根据具体的需求和环境来选择最合适的锁定策略,以达到既安全又高效的并发编程。

synchronized案例

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

下面以8个案例说明上述准则

1.有两个线程a和b,是先打印邮件还是先打印短信?(先打印邮件)

class Phone{
    public synchronized void sendEmail(){
        System.out.println("-----sendEmail");
    }
    public synchronized void sendSMS(){
        System.out.println("-----sendSMS");
    }
}
public class Lock8Demo {
    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(() -> {
            phone.sendEmail();
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

2.sendEmai方法中暂停3s,先打印邮件还是短信?(先打印邮件)

class Phone{
    public synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        System.out.println("-----sendEmail");
    }
    public synchronized void sendSMS(){
        System.out.println("-----sendSMS");
    }
}
public class Lock8Demo {
    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(() -> {
            try {
                phone.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

3.添加一个普通的hello方法,请问先打印邮件还是hello?(先打印hello)

class Phone{
    public synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        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) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(() -> {
            try {
                phone.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

4.有两部手机,先打印邮件还是短信?(先打印短信)

class Phone{
    public synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        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) throws InterruptedException {
        Phone phone1 = new Phone();
        Phone phone2 = new Phone();
        new Thread(() -> {
            try {
                phone1.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

5.有两个静态方法,有一部手机,先打印邮件还是短信?(先打印邮件)

class Phone{
    public static synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        System.out.println("-----sendEmail");
    }
    public static synchronized void sendSMS(){
        System.out.println("-----sendSMS");
    }
    public void hello(){
        System.out.println("hello");
    }
}
public class Lock8Demo {
    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(() -> {
            try {
                phone.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

6.有两个静态方法,有两部手机,先打印邮件还是短信?(先打印邮件)

class Phone{
    public static synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        System.out.println("-----sendEmail");
    }
    public static synchronized void sendSMS(){
        System.out.println("-----sendSMS");
    }
    public void hello(){
        System.out.println("hello");
    }
}
public class Lock8Demo {
    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        Phone phone2 = new Phone();
        new Thread(() -> {
            try {
                phone.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

7.有一个静态同步方法,有一个普通同步方法,有一部手机,先打印邮件还是短信?(先打印短信)

class Phone{
    public static synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        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) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(() -> {
            try {
                phone.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

8.有一个静态同步方法,有一个普通同步方法,有两部手机,先打印邮件还是短信?(先打印短信)

class Phone{
    public static synchronized void sendEmail() throws InterruptedException {
        TimeUnit.SECONDS.sleep(3);
        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) throws InterruptedException {
        Phone phone = new Phone();
        Phone phone2 = new Phone();

        new Thread(() -> {
            try {
                phone.sendEmail();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"a").start();

        //暂停,保证a先启动
        TimeUnit.MILLISECONDS.sleep(200);

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

八案例总结:

  • 1-2:
    • 当一个对象中有多个使用synchronized关键字修饰的方法时,在同一时刻,只能有一个线程能够调用其中的任何一个方法。这是因为synchronized关键字锁定的是当前对象的this引用,就是new出来的Phone。一旦一个线程获得了某个对象的锁,其他线程将无法进入该对象的任何其他被synchronized修饰的方法,直到锁被释放。
  • 3-4:
    • 没有加synchronized的方法不受synchronized的影响
    • 有两个对象后,synchronized锁的不是同一个对象,两个phone互不影响,相当于是两把锁
  • 5-6:
    • 在涉及静态资源的加载时,synchronized关键字锁定的是类的字节码对象,而非实例对象。这意味着无论创建了多少个类的实例,synchronized修饰的静态方法始终是针对类本身进行加锁。因此,在多线程环境中,即便创建了多个Phone对象,当一个线程正在执行某个synchronized修饰的静态方法时,其他线程将无法同时执行该类的任何其他synchronized静态方法,它们必须等待锁被释放后才能继续。
    • 对于普通同步方法,锁的是当前对象,通常指this,所有普通同步方法使用的是同一把锁。
    • 对于静态同步方法,锁的是当前类的Class对象,如Phone。
    • 对于同步代码块,锁的是synchronized括号里的对象
  • 7-8:
    • 类锁和对象锁互不干涉

synchronized字节码分析

synchronized是 Java 中用于实现线程同步的关键字,它有三种常见的应用方式:

  1. 锁代码块(Synchronized Blocks): 通过在方法内部使用 synchronized关键字修饰一个代码块,可以确保在同一时刻只有一个线程能够执行该代码块。这种方式通常用于保护共享资源的访问,以避免多线程并发访问导致的数据不一致问题。示例如下:
Object lock = new Object();

void someMethod() {
    synchronized (lock) {
        // 需要同步的代码块
        // ...
  1  }
}

  1. 锁静态方法(Synchronized Static Methods): 当一个方法被声明为static有实例共享。如果一个静态方法使用了 synchronized关键字进行修饰,那么它将锁定整个类对象,而不仅仅是单个实例。这意味着在同一时刻,只有一个线程能够执行该类的任何静态同步方法。示例如下:
class MyClass {
    static void myStaticMethod() {
        synchronized (MyClass.class) {
            // 需要同步的静态方法代码
            // ...
        }
    }
}

  1. 锁普通方法(Synchronized Instance Methods): 对于非静态方法,可以使用 synchronized 关键字直接修饰方法。这样,当一个线程调用该方法时,它将获取到对象锁,从而确保在同一时刻只有一个线程能够执行该方法。示例如下:
class MyClass {
    synchronized void myMethod() {
        // 需要同步的实例方法代码
        // ...
    }
}

我们从字节码的角度分析一下synchronized实现,通过javap -c xxx.class命令对字节码文件反编译,如果想看更多信息,可以用javap -v xxx.class

synchronized关键字的实现细节可以通过分析字节码来深入理解。字节码是Java代码编译后的中间表示形式,它描述了程序执行时的各个指令和操作。通过使用javap命令行工具,我们可以对字节码文件进行反编译,以便查看其中的内容。

1.同步代码块

public class LockSyncDemo {
    Object object = new Object();

    public void m1(){
        synchronized (object){
            System.out.println("hello");
        }
    }

    public static void main(String[] args) {

    }
}

在IDEA打开终端,输入javap -c .\LockSyncDemo.class运行。

第6行monitorenter为进入synchronized同步代码块的指令,第16行monitorexit为退出同步代码块的指令,是一对的,但是发现22行多出来一个monitorexit,这是哪来的?在正常情况下,第一个monitorexit指令用于在同步代码块执行完毕后释放锁。而第二个monitorexit指令则是为了确保在同步代码块中发生异常时,锁能够被正确释放,从而避免死锁或资源泄漏的问题。

一般情况下就是一个monitorenter对应两个monitorexit,但也有极端情况,就是在代码块里抛出异常,就会发现只有一个monitorexit了。

2.同步方法

public class LockSyncDemo {
    /*
    Object object = new Object();

    public void m1(){
        synchronized (object){
            System.out.println("hello");
        }
    }
    */

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

    }
}

javap -v .\LockSyncDemo.class进行反编译,可以发现方法处有ACC_SYNCHRONIZED标志,代表方法被synchronized修饰,同步方法没有monitor指令,JVM遇到这个标志时,它会在执行方法前自动获取锁,并在方法执行完毕后释放锁,以确保在同一时刻只有一个线程能够执行被synchronized修饰的方法。

3.同步静态方法

public class LockSyncDemo {
    /*
    Object object = new Object();

    public void m1(){
        synchronized (object){
            System.out.println("hello");
        }
    }
    */

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

    public static void main(String[] args) {

    }
}

静态方法和普通方法没差太多,就是多一个ACC_STATIC标志。

synchronized锁的是什么

大家想一个问题,为什么任何对象都能成为一个锁?Java是以C++为基础进行改进得到的,那我们就通过反编译从底层C++的角度来分析一下。

先来了解一个概念,管程/监视器(monitor),具体概念太复杂,可以直接晚上搜索一下,直白说就是锁。JVM可以支持同步方法和同步代码块,这两种同步结构就是通过管程monitor实现的。比如同步方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED标志是否被设置,如果设置了,执行线程就要先获取Monitor才能执行方法,最后不管方法是否成功完成都要释放Monitor。在线程持有Monitor的期间,其他线程不可获取同一个Monitor。

那为什么Java中任何一个对象都能成为一个锁呢?因为在HotSpot虚拟机中,monitor采用ObjectMonitor实现的。我们用C++源码解读:

java源码中可以找到ObjectMonitor.java文件,但JVM真正运行的是ObjectMonitor.cpp文件,这个文件又有引入头文件ObjectMonitor.hpp。在ObjectMonitonr.hpp文件中,有一段初始化monitor的代码,里面有一大堆预先设置的变量,看一下ObjectMonitor中几个关键属性:

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

这个_owner就是锁的关键,这个锁对象被哪个线程持有了,这个ObjectMonitor就会把该属性设置为对应线程,谁持有谁记录,谁释放谁取消。

在Java中,Object和ObjectMonitor之间的关联是通过对象头中锁的状态来实现的。每个Java对象都有一个与之关联的ObjectMonitor,这个关联是通过对象头中的重量级锁指针实现的。当一个线程尝试获取对象的监视器锁(即synchronized关键字所表达的锁)时,它会将对象头的锁状态标记为重量级锁(有关锁升级后续会讲到,这里先知道大部分情况下我们说的多个线程去抢synchronized的锁是重量级锁),并将对象头中的指针指向对应的ObjectMonitor对象,ObjectMonitor是在对象被锁定时动态创建的。具体来说,当一个线程尝试获取对象的监视器锁时,如果发现该对象尚未关联ObjectMonitor,则会创建一个与该对象关联的Monitor。这个ObjectMonitor负责维护持有锁的线程、等待锁释放的线程队列、以及因调用wait()方法而阻塞的线程队列。这就是为什么每个对象都能成为锁。

总的来说,ObjectMonitor是Java同步机制背后的功臣,它通过维护锁的状态和线程之间的交互,使得每个对象都能成为一个有效的锁,从而简化了多线程编程的复杂性。

公平锁和非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。以售票代码为例,使用ReentrantLock,默认为非公平锁,下面代码运行结果全是a线程卖票,也就是a线程一直抢到锁,b和c线程都没有抢到,体现出非公平性。

class Ticket{
    private int number = 50;
    //非公平
    ReentrantLock lock = new ReentrantLock();
    public void sale(){
        lock.lock();
        try {
            if(number > 0){
                System.out.println(Thread.currentThread().getName() + "卖出第:\t" + (number--) + "\t 还剩下:" + number);
            }
        }finally {
            lock.unlock();
        }
    }
}
public class SelectTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> {
            for (int i = 0;i < 55;i++){
                ticket.sale();
            }
        },"a").start();
        new Thread(() -> {
            for (int i = 0; i < 55; i++) {
                ticket.sale();
            }
        },"b").start();
        new Thread(() -> {
            for (int i = 0; i < 55; i++) {
                ticket.sale();
            }
        },"c").start();
    }

}

将ReentrantLock设置为公平锁,运行结果可以看出三个线程抢锁次数很平均,体现出公平性。

class Ticket{
    private int number = 50;
    //公平
    ReentrantLock 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();
        }
    }
}
public class SelectTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> {
            for (int i = 0;i < 55;i++){
                ticket.sale();
            }
        },"a").start();
        new Thread(() -> {
            for (int i = 0; i < 55; i++) {
                ticket.sale();
            }
        },"b").start();
        new Thread(() -> {
            for (int i = 0; i < 55; i++) {
                ticket.sale();
            }
        },"c").start();
    }

}

两个案例就很好的体现出公平锁和非公平锁的特点。公平锁是指多个线程按照申请锁的顺序来获取锁,就类似于食堂排队打饭,先来的先买,按顺序排好队,很公平。非公平锁是指多个线程获取锁的顺序不是按照申请锁的顺序,有后来居上的情况,就像在打饭的时候有人插队一样,所以就可能出现有的线程一直无法获取锁。

存在就有存在的道理,那为什么要把锁设置为非公平锁和公平锁呢?又为什么默认为非公平锁?这是因为在锁的分配策略对系统的性能有重要影响。

默认情况下,synchronized和ReentrantLock都是非公平锁,是因为非公平锁性能更好。因为要考虑线程切换带来的开销,当一个线程获取锁失败会被阻塞,当它从阻塞状态恢复到就绪状态再到真正获取锁的过程是有一定时间消耗的,从CPU角度看这个时间消耗还是很明显的。使用非公平锁时,当一个线程请求锁获取同步状态,然后释放锁,刚释放锁的线程更容易获取锁,可以减少线程的切换,所以使用非公平锁更可以利用CPU的时间片。

可重入锁

可重入锁是这一个线程已经得到这个对象的锁,但是它再次遇到这个锁的时候还可以再次获取,锁必须是同一个对象,不会因为之前已经持有锁而阻塞。一个线程在多个流程中可获取同一把锁,持有这把同步锁可再次进入,就比如一个线程调用一个同步方法获取到一把锁,然后再方法里又调用了一个同步方法,两个方法用的是同一把锁,线程仍可进入第二个方法。synchronized和ReentrantLock都是可重入锁,可重入锁的一个优点是一定程度上可避免死锁。

下面这个例子就体现出在一个synchronized修饰的方法内部调用本类其他synchronized修饰的方法时,是可以获取锁的。

public class Hello {
    public synchronized void helloA(){
        System.out.println("hello");
    }
    
    public synchronized void helloB(){
        System.out.println("hello B");
        helloA();
    }
}

ObjectMonitor中recursions (锁的重入次数 )和owner(指向持有ObjectMonitor对象的线程 )两个参数就是synchronized的可重入关键,每个锁对象都有一个锁计数器以及指向持有锁的线程的指针。当一个线程获取锁时发现计数器为0,这说明该锁没被占据,计数器就会变成1,并将_owner指向这个线程,其他线程获取锁的时候发现锁的计数器不为0并且锁的拥有者不是自己,就会被阻塞挂起。

当获取到该锁的线程再次获取锁是发现锁的主人是自己,就会把计数器值+1,当线程释放锁后就会把值-1,当计数器为0时,锁的_owner就会置为null,这时被阻塞的线程会唤醒来抢锁。

死锁

死锁这个Bug想必大家都应该知道,先回忆一下这个概念。死锁是指两个或两个以上的线程在执行过程中,因争夺共享资源而出现一种相互等待的现象,若无外力干涉则他们都将无法推进下去。如果资源充足,进程的资源请求都能得到满足,死锁出现的概率很低,如果资源不充足,就很可能陷入死锁。

下面是死锁案例,让我想起我去年秋招面试一家互联网公司,二面的时候面试官让我写个死锁,当时很绝望,从没想过面试会让手撕一个bug哈哈哈。

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

        new Thread(() -> {
            synchronized (objectA){
                System.out.println(Thread.currentThread().getName() +"\t 自己持有A锁,希望获得B锁");
                try {
                    TimeUnit.SECONDS.sleep(1);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                synchronized (objectB){
                    System.out.println(Thread.currentThread().getName() + "\t 成功获取B锁");
                }
            }
        },"a").start();
        new Thread(() -> {
            synchronized (objectB){
                System.out.println(Thread.currentThread().getName() +"\t 自己持有B锁,希望获得A锁");
                try {
                    TimeUnit.SECONDS.sleep(1);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                synchronized (objectA){
                    System.out.println(Thread.currentThread().getName() + "\t 成功获取A锁");
                }
            }
        },"b").start();
    }
}

运行就是都卡住了。现在是我们自己写的bug,知道是死锁,那如果工作中遇到这种问题该怎么排查是不是死锁了呢?

  • 用jdk的命令排查,在终端依次输入命令
    • jps -l:查询进程id
    • jstack 1396

双方互相锁着,持有并等待。

  • 用jconsole,看图形化界面。



总结

  1. 我们通过8个锁案例知道了synchronzied锁可以分为类锁和对象锁
  2. 抢锁机制可分为公平锁和非公平锁,synchronized和ReentrantLock默认都是非公平锁,因为非公平锁性能好,避免线程切换。
  3. 一个线程抢到一把锁之后,这个线程还可以继续获取这把锁,这是锁的可重入性
  4. 死锁是一种bug,要避免。可以通过终端命令和jconsole来排查死锁
  5. 从底层角度分析每一个Java对象都可以做为锁,关键就是ObjectMonitor。

以上是本文几个重点,在这篇文章中,我们聚焦于Java中的锁机制,以synchronized关键字为我们的主线,深入探讨了它的核心特性。我们从宏观的角度出发,对锁的基本概念和特性进行了阐述,旨在为大家提供一个清晰的认识。

后续我将带领朋友们进一步深入到synchronized的内部世界,详细解析其背后的工作原理,包括锁的升级过程。此外,我还会涉猎Java并发编程中的其他常用锁机制,如ReentrantLock、ReadWriteLock等,帮助大家更全面地理解和掌握Java中的锁机制。

敬请期待后续的内容,让我们一起探索Java并发编程的奥秘。

热门相关:影帝偏要住我家   混在三国当军阀   秾李夭桃   明朝好丈夫   玉堂金闺