【Java核心知识】JUC包相关知识

文章目录

  • JUC包主要内容
  • Java内置锁
    • 为什么会有线程安全问题
    • Synchronize锁
    • Java对象结构
    • Synchronize锁优化
    • 线程间通信
    • Synchronize与wait原理
  • CAS和JUC原子类
    • CAS原理
    • `JUC`原子类
    • `ABA`问题
  • 可见性和有序性
    • 为什么会有可见性
    • 参考链接
  • 显式锁
    • Lock接口常用方法
    • 显式锁分类
    • 显式锁实现原理
    • 参考链接

JUC包主要内容

JUC包是与并发编程相关的包,主要包含四部分原子类并发集合多线程,如下图所示。

其中,

  • 锁可以分为内置锁和显式锁;
  • 原子类主要是一些通过CAS实现的原子类;
  • 并发集合主要就是一些线程安全的集合,比如ConcurrentHashMap,BlockQueue等;
  • 多线程包括callable接口和线程池等;

在这里插入图片描述

Java内置锁

为什么会有线程安全问题

i++线程不安全的原因在于自增操作不是原子性的,可以分为三步:内存取值寄存器加1存值到内存

除了原子性之外,可见性有序性也会导致线程安全问题。可见性是指线程B并不一定能够及时看到线程A对变量的修改。

Synchronize锁

Synchronize关键字可以作用在方法上,也可以作用于代码块上,本质上都是锁住了某个对象,但synchronize作用于方法上是一种粗粒度的锁,会导致其他线程也不能访问该对象的其他方法。

JVM的堆中,每个对象都会有一个对象监视器,synchronize就是锁住了这个对象监视器,从而保证了原子性。

那么如何保证可见性呢?线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。(这里是个泛指,不是说只有在退出synchronized时才同步变量到主存)

Java对象结构

Java的对象都放在JVM的堆中,每个对象的结构包括:

  • 对象头:

    • Mark Word:记录哈希码,GC标志位、锁状态等信息。不同锁状态下Mark Word是不同的,但最后两位都代表了锁状态。

    • 类对象指针:指向方法区的该类相关信息

    • 数组长度:如果对象是数组才有此结构

  • 对象体:包含对象的实例变量,包含父类的实例变量

  • 对齐字节:为了保证8字节的对齐而填充的数据

Synchronize锁优化

为了优化Synchronize锁的性能,Java提出了逐步升级的四种锁:无锁->偏向锁->轻量级锁->重量级锁。

  • 无锁:
  • 偏向锁:Mark Word中存储持有锁的线程ID,当有线程执行时,先判断对象头的线程ID是否与此线程ID相等,如果相等,直接向下执行;如果不相等,说明存在竞争,锁升级为轻量级锁。
  • 轻量级锁:对象头存储持有锁的线程ID,将对象头原来的哈希码放入线程栈帧中的锁记录中。当别的线程竞争锁时,不会立即阻塞,切换用户态,而是会自旋,然后使用CAS尝试获取锁,降低了阻塞线程的消耗。自旋等待时间和上一个竞争线程等待结果有关:如果上一个竞争线程自旋成功了,那么这次自旋的次数会更多;如果上一个竞争线程自旋失败了,那么这次自旋的次数会减少。自旋不会一直持续下去,如果超过了指定时间,会膨胀为重量级锁!
  • 重量级锁:重量级锁对象头会指向一个监视器对象(每个对象都有一个监视器对象),该监视器通过三个队列(竞争队列、阻塞队列、等待时间片的就绪队列)来登记和管理排队的线程,会涉及到线程的阻塞,切换用户态。

轻量级锁执行过程:

  • 1、判断对象是否加锁,如果没加锁,进行以下操作

  • 2、在自己的栈帧中创建锁记录,用来存放加锁对象的哈希码

  • 3、创建好锁记录后,通过CAS自旋操作,尝试将锁对象头的锁记录指针替换成栈帧中锁记录的地址

  • 4、替换栈帧后会返回锁对象的哈希码,然后填入栈帧的锁记录中

线程间通信

可以使用Objectwait(),notify()方法来进行线程间的通信。

wait()方法的原理

1)当线程调用了lock(某个同步锁对象)的wait()方法后,JVM会将当前线程加入lock监视器的WaitSet(等待集),等待被其他线程唤醒。
2)当前线程会释放lock对象监视器的Owner权利,让其他线程可以抢夺lock对象的监视器。
3)让当前线程等待,其状态变成WAITING

notify()方法的原理

1)当线程调用了lock(某个同步锁对象)的notify()方法后,JVM会唤醒lock监视器WaitSet中的第一个等待线程。
2)当线程调用了locknotifyAll()方法后,JVM会唤醒lock监视器WaitSet中的所有等待线程。
3)等待线程被唤醒后,会从监视器的WaitSet移动到EntryList,线程具备了排队抢夺监视器Owner权利的资格,其状态从WAITING变成BLOCKED
4)EntryList中的线程抢夺到监视器Owner权利之后,线程的状态从BLOCKED变成Runnable,具备重新执行的资格。

缓冲队列

/**
 * 生产者消费者队列
 */
//数据缓冲区,类定义
public class DataBuffer<T> {
    public static final int MAX_AMOUNT = 10; //数据缓冲区最大长度
    //保存数据
    private List<T> dataList = new LinkedList<>();
    //数据缓冲区长度
    private Integer amount = 0;
    // 用来保证只有一个线程存元素或者取元素
    private final Object LOCK_OBJECT = new Object();
    // 当队列满了后,用于阻塞生产者
    private final Object NOT_FULL = new Object();
    // 当队列为空时,用于阻塞消费者
    private final Object NOT_EMPTY = new Object();
    // 向数据区增加一个元素
    public void add(T element) throws Exception
    {
        // 队列已满,不能存元素
        while (amount > MAX_AMOUNT)
        {
            synchronized (NOT_FULL)
            {
                System.out.println("队列已经满了!");
                // 等待未满通知,这里为什么需要wait,是因为需要等待一个条件满足,而不能只用synchronize,某一时刻只有一个线程拥有NOT_FULL是不行的
                NOT_FULL.wait();
            }
        }
        // 保证原子性
        synchronized (LOCK_OBJECT)
        {
            dataList.add(element);
            amount++;
            System.out.println(Thread.currentThread().getName() + "生产了一条消息" + amount);
        }
        synchronized (NOT_EMPTY)
        {
            //发送未空通知
            NOT_EMPTY.notify();
        }
    }
    /**
     * 从数据区取出一个商品
     */
    public T fetch() throws Exception
    {
        // 数量为零,不能取元素
        while (amount <= 0)
        {
            synchronized (NOT_EMPTY)
            {
                System.out.println(Thread.currentThread().getName() + "队列已经空了!");
                //等待未空通知
                NOT_EMPTY.wait();
            }
        }
        T element = null;
        // 保证原子性
        synchronized (LOCK_OBJECT)
        {
            element = dataList.remove(0);
            amount--;
            System.out.println(Thread.currentThread().getName() + "消费了一条消息" + amount);
        }
        synchronized (NOT_FULL)
        {
            //发送未满通知
            NOT_FULL.notify();
        }
        return element;
    }
}

生产者和消费者

@Test
public void testProducerConsumerQueue() throws InterruptedException {
    //共享数据区,实例对象
    DataBuffer<String> dataBuffer = new DataBuffer<>();
    // 同时并发执行的线程数
    final int THREAD_TOTAL = 20;
    //线程池,用于多线程模拟测试
    ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_TOTAL);
    //假定共11条线程,其中有10个消费者,但是只有1个生产者
    final int CONSUMER_TOTAL = 10;
    final int PRODUCE_TOTAL = 1;
    for (int i = 0; i < PRODUCE_TOTAL; i++) {
        //生产者线程每生产一个商品,间隔50毫秒
        threadPool.submit(() -> {
            for (int j = 0; j < 10; j ++) {
                //首先生成一个随机的商品
                String s = "商品";
                //将商品加上共享数据区
                try {
                    dataBuffer.add(s);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        });
    }
    for (int i = 0; i < CONSUMER_TOTAL; i++)
    {
        //消费者线程每消费一个商品,间隔100毫秒
        threadPool.submit(() -> {
            for (int j = 0; j < 2; j ++) {
                // 从PetStore获取商品
                String s = null;
                try {
                    s = dataBuffer.fetch();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        });
    }
    Thread.sleep(10000);
}

Synchronize与wait原理

Synchronizewait都会将线程加入到等待队列中,但是两者加入的等待队列并不是同一个,Synchronize加入的是对象监视器的等待队列,当退出Synchronize代码块后会自动唤醒线程,而waitObject的方法,加入的是另一个等待集合,只能通过notify()notifyAll()唤醒。

CAS和JUC原子类

CAS原理

CAS(Compare And Swap),是比较交换的缩写,可以用来实现乐观锁。乐观锁本质上是无锁的,每次更新前都把原来的旧值和要更新的新值一块传入,如果发现传入的旧值和当前内存上的旧值一样,则更新成功;否则更新失败;

乐观锁就是一直调用CAS操作,不断获取旧值,计算新值,然后传入旧值和新值进行更新,线程一直在自旋,直到更新成功为止。

示例

public class CompareAndSwap {
    public volatile int value; //值
    //不安全类
    // private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final Unsafe unsafe = getUnsafe();
    //value 的内存偏移(相对与对象头部的偏移,不是绝对偏移)
    private static final long valueOffset;
    //统计失败的次数
    public static final AtomicLong failure = new AtomicLong(0);
    static
    {
        try
        {
            //取得value属性的内存偏移
            valueOffset = unsafe.objectFieldOffset(CompareAndSwap.class.getDeclaredField("value"));
            System.out.println("valueOffset:=" + valueOffset);
        } catch (Exception ex) {
            throw new Error(ex);
        }
    }
    //通过CAS原子操作,进行“比较并交换”
    public final boolean unSafeCompareAndSet(int oldValue, int newValue)
    {
        //原子操作:使用unsafe的“比较并交换”方法进行value属性的交换
        return unsafe.compareAndSwapInt( this, valueOffset, oldValue, newValue );
    }
    //使用无锁编程实现安全的自增方法
    public void selfPlus()
    {
        int oldValue = value;
        //通过CAS原子操作,如果操作失败就自旋,直到操作成功
        for(;;) {
            oldValue = value;
            failure.incrementAndGet();
            if (unSafeCompareAndSet(oldValue, oldValue + 1)) return;
        }
        // do
        // {
        //     // 获取旧值
        //     oldValue = value;
        //     //统计无效的自旋次数
        //     //记录失败的次数
        //     failure.incrementAndGet();
        // } while (!unSafeCompareAndSet(oldValue, oldValue + 1));
    }

    /**
     * 通过反射获取Unsafe
     * @return
     */
    public static Unsafe getUnsafe()
    {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            return (Unsafe) theUnsafe.get(null);
        } catch (Exception e) {
            throw new AssertionError(e);
        }
    }
}
/**
 * 测试CAS操作
 * @throws InterruptedException
 */
@Test
public void testCAS() throws InterruptedException {
    final CompareAndSwap compareAndSwap = new CompareAndSwap();
    AtomicInteger res = new AtomicInteger(0);
    //倒数闩,需要倒数THREAD_COUNT次
    CountDownLatch latch = new CountDownLatch(10);
    for (int i = 0; i < 10; i++)
    {
        // 提交10个任务
            Executors.newCachedThreadPool().submit(() ->
            {
                //每个任务累加1000次
                for (int j = 0; j < 1000; j++)
                {
                    compareAndSwap.selfPlus();
                    res.getAndIncrement();
                }
                latch.countDown();// 执行完一个任务,倒数闩减少一次
            });
    }
    latch.await();// 主线程等待倒数闩倒数完毕
    System.out.println(res);
    System.out.println("累加之和:" + compareAndSwap.value);
    System.out.println("失败次数:" + CompareAndSwap.failure.get());
}

JUC原子类

JUC包下的原子类可以分为四组:

  • 基本原子类:AtomicInteger,整型;AtomicLong,大整数;AtomicBoolean:布尔型;
  • 数组原子类:AtomicIntegerArray:整型数组原子类;AtomicLongArray:长整型数组原子类;AtomicReferenceArray:引用类型数组原子类。
  • 引用原子类:AtomicReference:引用类型原子类;AtomicMarkableReference:带有更新标记位的原子引用类型;AtomicStampedReference:带有更新版本号的原子引用类型。
  • 字段更新原子类:AtomicIntegerFieldUpdater:原子更新整型字段的更新器;AtomicLongFieldUpdater:原子更新长整型字段的更新器;AtomicReferenceFieldUpdater:原子更新引用类型里的字段。

JUC原子类下的底层实现也是通过不断CAS自旋+volatile(实现可见性)实现的,可以从源码看到。

ABA问题

使用CAS自旋更新虽然没有加锁,降低了线程切换成本,但是容易产生ABA问题。即线程1将值从A到B又到A,此时线程2被唤醒,以为变量没有改变过,从而引起错误的判断。解决办法是添加时间戳,可以借助AtomicStampedReference原子类实现。

可见性和有序性

为什么会有可见性

现代处理器都是多核的,每个核都会有自己独有的高速缓存L1,L2,L3,这些核又共享一个主内存,每次涉及变量更新或读取时,CPU都是先从高级缓存中读取并进行修改,然后随机写入到主存。这样就产生了问题,如果核1对公有变量A进行了修改,但是还没来得及写入主存,那么核2从主存中读取到的值就是未及时更新的脏值。

一般操作系统会使用Lock指令在总线上进行广播,哪些变量的高速缓存已失效,必须从主存中重新读取。Java的volatile关键字会在字节码上加入loadloadloadstorestorestorestoreload内存屏障来保证更改后的变量立即写入主存,且告知其他核的高速缓存该值已失效,必须从主内存重新读取。

volatile并不保证原子性,因为虽然volatile会强制将修改刷回主存,但是修改并刷回主存的指令不是原子性的,可能有中断的可能。比如线程A修改完变量后,准备刷回主存,这时发生了线程调度,线程B知道自己的数据失效了,但是从主存中重新获取的数据不一定是最新的,因为线程A只是在本地修改了数据,但还没有写入主存。

参考链接

内存屏障与JVM指令

如果你知道这灵魂拷问的6连击,面试volitile时就稳了

显式锁

Lock接口常用方法

所有的锁实现类都会实现Lock接口,该接口主要有以下几个方法:

  • lock():阻塞获取锁,如果当前线程不能抢到锁,线程会加入阻塞队列进行等待,直到获取到锁;

  • tryLock(): 非阻塞抢锁,如果当前线程抢不到锁,线程会立刻返回false

  • tryLock(long time, TimeUnit unit): 超时返回,如果当前线程在一段时间内抢不到锁,则会返回false

  • unlock(): 释放锁;

下面是一个使用ReentrantLock的示例,使用三个线程同时对某一个执行加一操作,每个线程操作100次,累计300次。

public class LockTest {
    private int count;

    @Test
    public void testReentrantLock() throws InterruptedException {
        Lock lock = new ReentrantLock();

        ExecutorService executorService = Executors.newCachedThreadPool();

        CountDownLatch countDownLatch = new CountDownLatch(3);

        for (int i = 0; i < 3; i ++) {
            executorService.execute(() -> {
                for (int j = 0; j < 100; j ++) {
                    // 获取锁
                    lock.lock();
                    try {
                        count ++;
                    } finally {
                        // 释放锁
                        lock.unlock();
                    }
                }
                // 每完成一个线程,就更新countDownLatch
                countDownLatch.countDown();
            });
        }

        countDownLatch.await();

        System.out.println(count); 
    }
}

显式锁分类

显式锁的分类有很多种,大致上可以分为下面这些:

在这里插入图片描述

显式锁实现原理

JUC包下的显式锁都是基于AQS实现的,AQS使用一个队列保存想要获取锁的线程,同时在队头使用CAS竞争获取锁,不会阻塞线程,是一种乐观锁

当有新线程加入时,会通过CAS加入队尾,然后监控队列前一个元素的状态,这时不会发生CAS,但线程也不会阻塞,而是会调用yield()主动让出时间片。

参考链接

Java中常见的各种锁

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/102684.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

说说IO多路复用

分析&回答 IO多路复用 I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态(对应空管塔里面的Fight progress strip槽)来同时管理多个I/O流。直白点说&#xff1a;多路指的是多个socket连接&#xff0c;复用指的是复用一个…

Linux系统下的zabbix监控平台(单机安装服务)

目录 一、zabbix的基本概述 二、zabbix构成 1.server 2.web页面 3.数据库 4.proxy 5.Agent 三、监控对象 四、zabbix的日常术语 1.主机(host) 2.主机组(host group) 3.监控项(item) 4.触发器(trigger) 5.事件&#xff08;event&#xff09; 6.动作&#xff08;a…

Vue.js安装步骤和注意事项

安装完node.js后开始安装和部署Vue在检查webpack的下载版本时出现错误出现错误的原因是之前下载时未指定对应的版本号导致版本不兼容先卸载掉之前下载的版本 cnpm uninstall webpack-cli -g cnpm install webpack-cli4.9.2 -g 最后检查版本是否对应

Navicat16安装教程

注&#xff1a;因版权原因&#xff0c;本文已去除破解相关的文件和内容 1、在本站下载解压后即可获得Navicat16安装包和破解补丁&#xff0c;如图所示 2、双击“navicat160_premium_cs_x64.exe”程序&#xff0c;即可进入安装界面&#xff0c; 3、点击下一步 4、如图所示勾选“…

渣土车识别监测 渣土车未盖篷布识别抓拍算法

渣土车识别监测 渣土车未盖篷布识别抓拍算法通过yolov7深度学习训练模型框架&#xff0c;渣土车识别监测 渣土车未盖篷布识别抓拍算法在指定区域内实时监测渣土车的进出状况以及对渣土车未盖篷布违规的抓拍和预警。YOLOv7 的策略是使用组卷积来扩展计算块的通道和基数。研究者将…

碳中和数据合集(含上市公司碳排放、碳减排、排污费、环境税等数据)1990-2022年

数据简介&#xff1a;“推动企业形成绿色生产方式和生活方式”“支持有条件的地方和重点行业、重点企业率先达到碳排放峰值”。可见&#xff0c;企业已成为应对气候变化、推动低碳转型、助力“双碳”目标实现的主力军&#xff0c;推动其绿色、低碳化转型已成为未来经济发展的必…

nnUNet v2数据准备及格式转换 (二)

如果你曾经使用过nnUNet V1&#xff0c;那你一定明白数据集的命名是有严格要求的&#xff0c;必须按照特定的格式来进行命名才能正常使用。 这一节的学习需要有数据&#xff0c;如果你有自己的数据&#xff0c;可以拿自己的数据来实验&#xff0c;如果没有&#xff0c;可以用十…

UE4 春节鞭炮

先搞个基类&#xff0c;一个鞭炮的 搞个鞭炮类&#xff0c;存多个鞭炮 在构造函数的位置先生成对应的鞭炮数 将鞭炮绑定到绳子上&#xff0c;随绳子摆动而一起摆动 在基类里面写爆炸事件 最后用Timer去调用

Java设计模式:四、行为型模式-05:备忘录模式

文章目录 一、定义&#xff1a;备忘录模式二、模拟场景&#xff1a;备忘录模式三、改善代码&#xff1a;备忘录模式3.1 工程结构3.2 备忘录模式模型结构图3.3 备忘录模式定义3.3.1 配置信息类3.3.2 备忘录类3.3.3 记录者类3.3.4 管理员类 3.4 单元测试 四、总结&#xff1a;备忘…

HikariCP源码修改,使其连接池支持Kerberos认证

HikariCP-4.0.3 修改HikariCP源码,使其连接池支持Kerberos认证 修改后的Hikari源码地址:https://github.com/Raray-chuan/HikariCP-4.0.3 Springboot使用hikari连接池并进行Kerberos认证访问Impala的demo地址:https://github.com/Raray-chuan/springboot-kerberos-hikari-im…

文本标注技术方案(NLP标注工具)

Doccano doccano 是一个面向人类的开源文本注释工具。它为文本分类、序列标记和序列到序列任务提供注释功能。您可以创建用于情感分析、命名实体识别、文本摘要等的标记数据。只需创建一个项目&#xff0c;上传数据&#xff0c;然后开始注释。您可以在数小时内构建数据集。 支持…

【C++深入浅出】类和对象上篇(类的基础、类的模型以及this指针)

目录 一. 前言 二. 面向对象与面向过程 2.1 面向过程 2.2 面向对象 三. 类的基础知识 3.1 类的引入 3.2 类的定义 3.3 成员变量的命名规则 3.4 封装 3.5 类的访问限定符 3.6 类的作用域 3.7 类的实例化 四. 类的对象模型 4.1 类对象的大小 4.2 类对象的存储方式 …

【Java基础】深入理解反射、反射的应用(工厂模式、代理模式)

文章目录 1. Java反射机制是什么&#xff1f;1.2 Java反射例子 2. Java反射机制中获取Class的三种方式及区别&#xff1f;3. Java反射机制的应用场景有哪些&#xff1f;3.1. 优化静态工厂模式&#xff08;解耦&#xff09;3.1.1 优化前&#xff08;工厂类和产品类耦合&#xff…

leetcode316. 去除重复字母(单调栈 - java)

去除重复字母 题目描述单调栈代码演示进阶优化 上期经典 题目描述 难度 - 中等 leetcode316. 去除重复字母 给你一个字符串 s &#xff0c;请你去除字符串中重复的字母&#xff0c;使得每个字母只出现一次。需保证 返回结果的字典序最小&#xff08;要求不能打乱其他字符的相对…

Darshan日志分析

标头 darshan-parser 输出的开头显示了有关作业的总体信息的摘要。还可以使用–perf、–file或–total命令行选项生成其他作业级别摘要信息。 darshan log version&#xff1a;Darshan 日志文件的内部版本号。compression method&#xff1a;压缩方法。exe&#xff1a;生成日志…

【前端】 Layui点击图片实现放大、关闭效果

实现效果&#xff1a;点击图片实现放大&#xff0c;点击空白处关闭效果。下图。 实现逻辑&#xff1a;二维码是使用JQ插件生成的&#xff0c;点击二维码&#xff0c;获取图片路径&#xff0c;通过Layui的弹窗显示放大后的图片。 Html <div id"qrcode" class&quo…

企业数据加密软件——「天锐绿盾」

「天锐绿盾」是一款企业数据加密软件&#xff0c;主要用于防止企业计算机信息被破坏、丢失和泄密。该软件采用文件过滤驱动实现透明加解密&#xff0c;对用户完全透明&#xff0c;不影响用户操作习惯。 PC访问地址&#xff1a; isite.baidu.com/site/wjz012xr/2eae091d-1b97-4…

两个线程并发(乱序)执行:乱箭穿心 std::thread

C自学精简教程 目录(必读) C并发编程入门 目录 在 创建2个线程并执行 创建10个线程并执行 中&#xff0c;我们已经看到了多个线程执行的顺序是没有任何保证的。 他们之间就是各自独立的同时在执行。 下面我们来看看两个线程同时往控制台打印信息&#xff0c;控制台会乱成什…

YOLOv5算法改进(13)— 替换主干网络之PP-LCNet

前言&#xff1a;Hello大家好&#xff0c;我是小哥谈。PP-LCNet是一个由百度团队针对Intel-CPU端加速而设计的轻量高性能网络。它是一种基于MKLDNN加速策略的轻量级卷积神经网络&#xff0c;适用于多任务&#xff0c;并具有提高模型准确率的方法。与之前预测速度相近的模型相比…

IDEA自定义模板

IDEA自定义模板 &#xff08;1&#xff09;定义sop模板 ①在Live Templates中增加模板 ②先定义一个模板的组 ③在模板组里新建模板 ④定义模板 Abbreviation:模板的缩略名称Description:模板的描述Template text:模板的代码片段应用范围。比如点击Define。选择如下&…