51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

架构师必知的绝活-JVM调优

前言

为什么要学JVM?

首先:面试需要

了解JVM能帮助回答面试中的复杂问题。面试中涉及到的JVM相关问题层出不穷,难道每次面试都靠背几百上千条面试八股?

其次:基础知识决定上层建筑

自己写的代码都不知道是怎么回事,怎么可能写出靠谱的系统?只有理解了JVM的工作机制,才能真正掌握Java这门语言,写出高效、稳定的代码。

然后:学习JVM也是进行JVM调优的基础

写的代码放到线上要如何运行?要配多少内存?4G够不够?线上环境出问题,服务崩溃了,怎么快速定位?怎么解决问题?这些都离不开对JVM的深入理解。

学不学JVM,是能自主解决问题的一流程序员与跟着别人做CRUD的二流程序员的分水岭!

二流程序员会觉得学JVM无关紧要,反正开发也用不上,做开发我只要学各种框架就行了。

而一流程序员都在尽自己能力把JVM每个底层逻辑整理成自己的知识体系,从而在实际工作中游刃有余,解决各种棘手的问题。

插播一条:真的免费,如果你近期准备面试跳槽,建议在cxykk.com在线刷题,涵盖 1万+ 道 Java 面试题,几乎覆盖了所有主流技术面试题、简历模板、算法刷题

一、JVM要学什么?

一个Java文件,整体的执行过程整理如下图:

这张图就是整个JVM(JDK1.8)要学的所有东西,其中细节非常多,也很容易让人学得枯燥,但是想要成为高手,就要忍受常人难以忍受的枯燥,不是吗?

下面小北主要带大家以实战的方式整理一下这些核心模块,让大家学的没那么枯燥。

二、Class文件规范

2.1 Class文件结构

首先,从上图可以看到,JVM运行的第一步,是把一个xxxx.java文件编译成为一个class文件,class文件本质上是一个二进制文件。

比如,对于一个ByCodeTest.class文件,使用UltraEdit工具打开,看到的内容部分是这样的:

看到这很多小伙伴内心一万头草泥马就奔腾而出了,这谁看得懂。

别慌,我们可以在IDEA中安装一个ByteCodeView的插件来更直观的查看一个ClassFile的内容,看到的大概内容是下面这样的:

插件安装及使用,这里就不多说了,相信对各位都是小菜一碟

可以看到,一个class文件的大致组成部分。

然后再结合官方的文档,或许能够让你开始对class文件有一个大致的感觉。

例如,前面u4表示四个字节是magic魔数,而这个魔数就是不讲道理的 CAFEBABE 。

而后面的两个u2,表示两个字节的版本号。例如我们用 JDK8 看我们之前的class文件,minor_version就是 00 00,major_version就是 00 34。换成二进制就是 52。52.0 这就是 JVM 给 JDK8 分配的版本号。这两个版本号就表示当前这个class文件是由JDK8编译出来的。后续就只能用8以前版本的JVM执行。这就是JDK版本向前兼容的基础。

例如,如果你尝试用JDK8去引用Spring 6或者SpringBoot 3以后的新版本,就会报错。就是因为Spring 6和SpringBoot 3发布出来的class文件,是用JDK17编译的,版本号是61。JDK8是无法执行的。

2.2 理解字节码指令

在上面的字节码指令中,我们重点需要关注的是方法,也就是我们自己写的代码的部分。

例如在ByteCodeTest中的typeTest()这个方法,在Class文件中是这样记录的:

图中 的每一行就是一个字节码指令。上述字节码的含义,如果不考虑异常的话,那么JVM虚拟机执行的代码逻辑应该是下面这样的:

do{
    从程序计数器中读取 PC 寄存器的值 + 1;
    根据 PC 寄存器指示的位置,从字节码流中读取一个字节的操作码;
    if(字节码存在操作数) 从字节码流中读取对应字节的操作数;
      执行操作码所定义的操作;
}while(字节码流长度>0)

这些字节码指令你看不懂?没关系,至少现在,你可以知道你写的代码在 Class 文件当中是怎么记录的了😀

另外,如果你还想更仔细一点的分辨你的每一样代码都对应哪些指令,那么在这个工具中还提供了一个LineNumberTable,会告诉你这些指令与代码的对应关系。

起始 PC 就是这些指令的字节码指令的行数,行号则对应 Java 代码中的行数。
实际上,Java 程序在遇到异常时给出的堆栈信息,就是通过这些数据来反馈报错行数的。

2.3 字节码指令解读

在ByteCodeTest中,我们写了一个typeTest方法:

初学者小白看到这里肯定不禁困惑:这些莫名其妙的true和false是怎么蹦出来的?

如果你之前恰巧刷到过这样的面试题,或许你会记得这是因为JAVA的基础类型装箱机制引起的小误会。

但是你知道产生这个问题的底层原因是什么吗?

首先,我们可以从LineNumberTable 中获取到这几行代码对应的字节码指令:

以前面三行为例,三行代码对应的 PC 指令就是从 0 到 10 号这几条指令。把指令摘抄下来是这样的:

 0 bipush 10
 2 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
 5 astore_1
 6 bipush 10
 8 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
11 astore_2
12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>

可以看到,在执行astore指令往局部变量表中设置值之前,都调用了一次Integer.valueOf方法。

而在这个方法中,对于[-128,127]范围内常用的数字,实际上是构建了缓存的。每次都从缓存中获取一个相同的值,他们的内存地址当然就是相等的了。

这些初学者的梦魇,是不是在这个过程中找到了终极答案?

2.4 字节码指令是如何工作的

要了解字节码指令是如何工作的,就不得不先了解一下JVM中两个重要的数据结构:局部变量表和操作数栈。

在 JVM 虚拟机中,会为每个线程构建一个线程私有的内存区域。

其中包含的最重要的数据就是程序计数器和虚拟机栈。

其中程序计数器主要是记录各个指令的执行进度,用于在 CPU 进行切换时可以还原计算结果。

虚拟机栈中则包含了这个线程运行所需要的重要数据。

虚拟机栈是一个先进后出的栈结构,其中会为线程中每一个方法构建一个栈帧。而栈帧先进后出的特性也就对应了我们程序中每个方法的执行顺序。每个栈帧中包含四个部分:

  • 局部变量表

  • 操作数栈

  • 动态链接库

  • 返回地址。

操作数栈是一个先进后出的栈结构,主要负责存储计算过程中的中间变量。

操作数栈中的每一个元素都可以是包括long型和double在内的任意 Java 数据类型。

局部变量表可以认为是一个数组结构,主要负责存储计算结果。存放方法参数和方法内部定义的局部变量。以 Slot 为最小单位。

动态链接库主要存储一些指向运行时常量池的方法引用。每个栈帧中都会包含一个指向运行时常量池中该栈帧所属方法的应用,持有这个引用是为了支持方法动态调用过程中的动态链接。

返回地址存放调用当前方法的指令地址。一个方法有两种退出方式,一种是正常退出,一种是抛异常退出。如果方法正常退出,这个返回地址就记录下一条指令的地址。如果是抛出异常退出,返回地址就会通过异常表来确定。

附加信息主要存放一些 HotSpot 虚拟机实现时需要填入的一些补充信息。这部分信息不在 JVM 规范要求之内,由各种虚拟机实现自行决定。

其中最为重要的就是操作数栈和局部变量表了

例如,对于初学者最头疼的++操作,下面的 mathTest 方法

public int mathTest(){
        int i = 1 ;
        i = i++;
        return i;
    }

i 的返回结果是多少?

我们都知道 i 的返回结果是 1 ,但是++自增操作到底有没有执行呢?就可以按照指令这样进行解释:

0 iconst_1   //往操作数栈中压入一个常量1
1 istore_1  // 将 int 类型值从操作数栈中移出到局部变量表1 位置
2 iload_1  // 从局部变量表1 位置装载int 类型的值到操作数栈中
3 iinc 1 by 1  // 将局部变量表 1 位置的数字增加 1
6 istore_1  // 将int类型值从操作数栈中移出到局部变量表1 位置
7 iload_1  // 从局部变量表1 位置装载int 类型的值到操作数栈中
8 ireturn  // 从操作数栈顶,返回 int 类型的值

这个过程中,k++是在局部变量表中对数字进行了自增,此时栈中还是 1。接下来执行=操作,就对应一个istore指令,从栈中将数字装载到局部变量表中。局部变量表中的k的值(对应索引 1 位置),就还是还原成了 1。

那么接下来,你是不是可以自行理解一下 k=++k,是怎么执行的呢?

2.5 补充知识点:大厂面试题

如何确定一个方法需要多大的操作数栈和局部变量?

实际上,每个方法在执行前都需要申请对应的资源,主要是内存。如果内存空间不够,就要在执行前直接抛出异常,而不能等到执行过程中才发现要用的内存空间申请不下来。

有些面试时,是会给你一个具体的方法,让你自己一下计算过程中需要几个操作数栈和几个局部变量。

这是对算法的基础要求。但是在工作中,其实class文件当中就记录了所需要的操作数栈深度和局部变量表的槽位数。

例如对于 mathTest方法,所需的资源在工具中的纪录是这样的:

以后被领导问的时候可以直接通过这种方式告诉他就行了

这里会有一个小问题,如果你自己推演过刚才的计算过程,可以看到,局部变量表中,明明只用到了索引为 1 的一个位置而已,为什么局部变量表的最大槽数是 2 呢?

这是因为对于非静态方法,JVM 默认都会在局部变量表的 0 号索引位置放入this变量,指向对象自身。所以我们可以在代码中用this访问自己的属性。

一个槽可以存放 Java 虚拟机的基本数据类型,对象引用类型和returnAddress类型

插播一条:真的免费,如果你近期准备面试跳槽,建议在cxykk.com在线刷题,涵盖 1万+ 道 Java 面试题,几乎覆盖了所有主流技术面试题、简历模板、算法刷题

三、类加载

Class文件中已经定义好了一个Java程序执行的全部过程,接下来就是要扔到JVM中执行。

既然要执行,就少不了类加载的模块。而有趣的是,类加载模块是少数几个可以在Java代码中扩展的JVM底层功能。

类加载模块在JDK8之后,发生了非常重大的变化,本文主要以JDK8为例讲解

3.1 JDK8的类加载体系

有了 Class 文件之后,接下来就需要通过类加载模块将这些 Class 文件加载到 JVM 内存当中,这样才能执行。而关于类加载模块,最为重要的内容有以下三点:

  • 每个类加载器对加载过的类保持一个缓存。

  • 双亲委派机制,即向上委托查找,向下委托加载。

  • 沙箱保护机制。

3.2 双亲委派机制

JDK8中的类加载器都继承于一个统一的抽象类ClassLoader,类加载的核心也在这个父类中。

其中,加载类的核心流程如下:

核心代码如下:

//类加载器的核心方法
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 每个类加载起对他加载过的类都有一个缓存,先去缓存中查看有没有加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {】
               //没有加载过,就走双亲委派,找父类加载器进行加载。
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }

                if (c == null) {
                    long t1 = System.nanoTime();
                   // 父类加载起没有加载过,就自行解析class文件加载。
                    c = findClass(name);
                  
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
           //这一段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。
           // 运行时加载类,默认是无法进行链接步骤的。
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

这个方法里,就是最为核心的双亲委派机制。

双亲委派机制说简单点就是,先找父亲加载,不行再由儿子自己加载

并且,这个方法是protected声明的,意味着,是可以被子类覆盖的,所以,双亲委派机制也是可以被打破的。
而关于类加载机制的所有有趣的玩法,也都在这个核心方法里。比如class文件加密加载,热加载等。

补充知识点(面试题:Tomcat为什么要打破双亲委派呢?)

3.3 沙箱保护机制

双亲委派机制有一个最大的作用就是要保护JDK内部的核心类不会被应用覆盖。

而为了保护JDK内部的核心类,JAVA在双亲委派的基础上,还加了一层保险。就是ClassLoader中的下面这个方法。

private ProtectionDomain preDefineClass(String name,
                                            ProtectionDomain pd)
    {
        if (!checkName(name))
            throw new NoClassDefFoundError("IllegalName: " + name);
        // 不允许加载核心类
        if ((name != null) && name.startsWith("java.")) {
            throw new SecurityException
                ("Prohibited package name: " +
                 name.substring(0, name.lastIndexOf('.')));
        }
        if (pd == null) {
            pd = defaultDomain;
        }
        if (name != null) checkCerts(name, pd.getCodeSource());
        return pd;
    }

这个方法会用在JAVA在内部定义一个类之前。

这种简单粗暴的处理方式,当然是有很多时代的因素。也因此在JDK中,你可以看到很多javax开头的包。这个奇怪的包名也是跟这个沙箱保护机制有关系的。

四、执行引擎

之前已经看到过,在 Class 文件当中,已经明确的定义清楚了程序的完整执行逻辑。

而执行引擎就是将这些字节指令转为机器指令去执行了。

这一块更多的是跟操作系统打交道,对开发工作其实帮助就不是很大了。

所以,如果不是专门研究语言,执行引擎这一块就没有必要研究太深了。我们也直接跳过。

五、GC垃圾回收

执行引擎会将class文件扔到JVM的内存当中运行,在运行过程中,需要不断的在内存当中创建并销毁对象。

在传统C/C++语言中,这些销毁的对象需要手动进行内存回收,防止内存泄漏。而在Java当中,实现了影响深远的GC垃圾回收机制。

GC 垃圾自动回收,这个可以说是 JVM 最为标志性的功能。

不管是做性能调优,还是工作面试,GC 都是 JVM 部分的重中之重。

而对于 JVM 本身,GC 也是不断进行设计以及优化的核心。

几乎 Java 提出的每个版本都对 GC 有或大或小的改动。

这里,我就用目前还是用得做多的 JDK8,带大家快速梳理一下 GC 部分的主线。

插播一条:真的免费,如果你近期准备面试跳槽,建议在cxykk.com在线刷题,涵盖 1万+ 道 Java 面试题,几乎覆盖了所有主流技术面试题、简历模板、算法刷题

5.1 垃圾回收器是干什么的?

在了解 JVM之前,给大家推荐一个工具,阿里开源的 Arthas

官网地址:https://arthas.aliyun.com/ 这个工具功能非常强大,是对 Java进程进行性能调优的一个非常重要的工具,对于了解 JVM 底层帮助也非常大。

public class GCTest {
    public static void main(String[] args) throws InterruptedException {
        List l = new ArrayList<>();
        for(int i = 0 ; i < 100_0000 ; i ++){
            l.add(new String("dddddddddddd"));
            Thread.sleep(100);
        }
    }
}

运行后,使用Arthas 的dashboard指令,可以查看到这个 Java 程序的运行情况。

重点关注中间的 Memory 部分,这一部分就是记录的 JVM 的内存使用情况

  • ps_eden_space:伊甸园区

  • ps_survivor_space:幸存区

  • ps_old_gen:老年代

  • nonheap:非堆内存

  • code_cache:热点指令缓存

  • metaspace:元空间

  • compressed_class_space:压缩类空间 ...

而后面的 GC 部分就是垃圾回收的执行情况。我

们就从这些能看到的部分作为入口,来理解一下一个 Java 进程是怎么管理他的内存的。

从 Memory 部分可以看到,一个 Java 进程会将他管理的内存分为heap堆区和nonheap非堆区两个部分。其中非堆区的几个核心部分像code_cache(热点指令缓存),metaspace(元空间),compressed_class_space(压缩类空间)。这一部分就相当于 Java 进程中的地下室,属于不太活跃的部分。

而中间heap堆区就相当于客厅了,属于Java 中最为核心的部分。

而这其中,又大体分为了eden_spacesurvivor_spaceold_gen 三个大的部分,这就是 JVM 内存的主体

堆区是JVM用来存放对象的核心内存区域。

它的大小可以通过两个参数来控制:-Xms(初始堆内存大小)和 -Xmx(最大堆内存大小)。

通过这两个参数可以看出,堆内存是可以扩展的。如果初始内存不够用,JVM会自动扩大堆内存。

但是,如果堆内存扩展到了最大值还不够用,就没法继续扩展了,这时候就会抛出OOM(Out of Memory)异常。

在生产环境中,建议把 -Xms 和 -Xmx 设置成一样的大小,这样可以减少内存扩展时的性能消耗。

GC垃圾回收器的任务就是及时回收这些内存空间,让内存可以重复利用,从而提升系统的性能和稳定性。

5.2 分代收集模型

不同GC,对内存的管理和回收的方式都是不同的。但是这其中面试最喜欢问的,就是关于垃圾分代收集模型。

在Memor部分还可以看到多次出现了 ps_ 这样的字样。这其实就代表JDK8默认的垃圾回收器Parallel Scavenge。

其整体的工作机制如下图:

你知道吗?Java做过统计,80%的对象都是"朝生夕死",换句话说,这些对象创建快,消亡也快。

这些短命的对象被放在一个比较小的内存区域,这块地方叫"年轻代"。

在年轻代,垃圾回收特别频繁,叫做YoungGC。年轻代又被进一步分成三个区域:一个叫eden区,两个叫survivor区。

默认情况下,这三个区域的大小比例是8:1:1。

那剩下的20%长寿对象去哪了?

它们被放到另一块内存区域,这地方叫"老年代"。

老年代的对象竞争没那么激烈,所以垃圾回收的频率也低,只有空间不够用时才进行,叫OldGC。

年轻代和老年代的默认大小比例是1:2。

在常见的分代收集模型中,对象会首先在eden区创建,经过一次YoungGC后,如果对象没有被回收,就会被移动到一个survivor区。

下一次YoungGC时,这些幸存的对象又会被移动到另一个survivor区。每次移动,都会记录一个分代年龄。一直到分代年龄达到阈值(默认是16),这些对象就会被移到老年代。到了老年代后,就不再记录分代年龄了,安安静静地待着,直到"退休"。

这就是JDK最有代表性的分代收集机制。通过这个机制,JVM可以对不同的对象采取不同的回收策略,从而大大提高垃圾回收的效率。

5.3 JVM中有哪些垃圾回收器?

java 从诞生到现在最新的 JDK21 版本,总共就产生了以下十个垃圾回收器

其中,左边的都是分代算法。也就是将内存划分为年轻代和老年代进行管理。

而有虚线的部分表示可以协同进行工作。

JDK8默认就是使用的Parallel Scavenge和Parallel Old的组合。也就是在arthas的dashboard中看到的ps。

右侧的是不分代算法。也就是不再将内存严格划分位年轻代和老年代。

JDK9 开始默认使用 G1。而 ZGC是目前最先进的垃圾回收器。

shennandoah则是OpenJDK 中引入的新一代垃圾回收器,与 ZGC 是竞品关系。Epsilon是一个测试用的垃圾回收器,根本不干活。

六、GC 情况分析实例

GC可以说是决定JAVA程序运行效率的关键。因此我们一定要学会定制GC参数,以及分析GC日志,从而达到调优的目的

6.1 如何定制GC运行参数

现在,不同的GC垃圾回收器适用于不同的场景,所以我们得根据业务场景来定制合理的GC运行参数。

在Java程序运行过程中,会遇到各种问题:

有时CPU飙高
有时FullGC频繁
有时OOM异常等等

这些问题大多需要凭经验深入分析,才能对症下药。

那我们该怎么定制JVM运行参数呢?

首先得知道有哪些参数可以选择。

JVM的参数主要有三类

  • 标准参数:以-开头,所有HotSpot都支持。例如java -version。可以用java -help或java -?查看所有标准参数。

  • 非标准参数:以-X开头,是特定HotSpot版本支持的指令。例如java -Xms200M -Xmx200M。可以用java -X查看所有非标准参数。

  • 不稳定参数:以-XX开头,这些参数与特定HotSpot版本对应,可能换个版本就没有了,文档资料也特别少。以下是JDK8中的几个有用指令:

    java -XX:+PrintFlagsFinal:打印所有最终生效的不稳定指令。 java -XX:+PrintFlagsInitial:打印默认的不稳定指令。 java -XX:+PrintCommandLineFlags:打印当前命令的不稳定指令,可以看到使用了哪种GC。JDK1.8默认用的是ParallelGC。

例如下面一个简单的示例代码:

public static void main(String[] args) {
        ArrayList<byte[]> list = new ArrayList<>();

        for (int i = 0; i < 500; i++) {
            byte[] arr = new byte[1024 * 100];//100KB
            list.add(arr);
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

然后在执行这个方法时,添加以下 JVM 参数:

-Xms60m -Xmx60m -XX:SurvivorRatio=8 -XX:+PrintGCDetails

执行后,可以看到下面输出内容

这里面就记录了两次 MinorGC 和两次 FullGC 的执行效果。

当然,目前这些日志信息只是打印在控制台,你只能凭经验自己强行去看。

接下来,就可以添加-Xloggc参数,将日志打印到文件里。然后拿日志文件进行整体分析。

6.3 GC日志分析

这些GC日志隐藏了项目运行非常多隐蔽的问题

要如何发现其中的这些潜在的问题呢?

肉眼去看肯定是不现实的,这时候就要使用工具了。

这里推荐一个开源网站 https://www.gceasy.io/ 这是国外一个开源的GC 日志分析网站。

你可以把 GC 日志文件直接上传到这个网站上,他就会分析出日志文件中的详细情况。

这是个收费网站,但是有免费使用的额度

例如,在我们之前的示例中,添加一个参数 -Xloggc:./gc.log ,就可以将GC日志打印到文件当中。

接下来就可以将日志文件直接上传到这个网站上。网站就会帮我们对GC情况进行分析。示例文件得到的报告是这样的:

通过这份报告,你能迅速识别项目运行中潜藏的问题。报告不仅提供了具体的修改建议,还包含详尽的指标分析。如果你觉得这些建议还不够详细,可以利用这些指标做进一步的分析,找到更多改进的方向。

如果是你们自己开发的项目,那接下来就可以根据这些建议和数据,深入分析,调整参数,优化配置。到了这一步,恭喜你,已经成功入门架构师的核心技能------JVM调优了。

总结

聊到这里,你对JVM是不是有点感觉了?

在这个过程中,你是不是还有很多细节上的疑问?

保持这些疑问,它们会成为你后续深入学习那些晦涩枯燥的底层理论的动力。这不会是一个容易的过程,但正是因为有挑战,才更有价值,不是吗?

踏上这条学习JVM的路,你会发现其中的复杂与精彩。而那些疑问和挑战,正是推动你不断前行的力量。保持好奇心和耐心,你才能一步步掌握这门技术,成为真正的Java架构师! 小北私藏精品 热门推荐 小北联合公司合伙人,一线大厂在职架构师耗时9个月联合打造了**《** 2024年Java高级架构师课程》本课程对标外面3万左右的架构培训课程,分10个阶段,目前已经更新了181G 视频,已经更新**1000+**个小时视频,一次购买,持续更新,无需2次付费
近期技术热文 面试官最爱问:CPU100%该如何处理?
架构师必知的11种API性能优化方法
面试官问:百万QPS秒杀系统该如何设计
你见过最烂的代码长什么样子?你中招了吗?
美团面试:10wqps高并发,如何防止重复下单?
第3版:互联网大厂面试题 包括 Java 集合、JVM、多线程、并发编程、设计模式、算法调优、Spring全家桶、Java、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Memcached、MongoDB、Redis、MySQL、RabbitMQ、Kafka、Linux、Netty、Tomcat、Python、HTML、CSS、Vue、React、JavaScript、Android 大数据、阿里巴巴等大厂面试题等、等技术栈! **阅读原文:**高清 7701页大厂面试题 PDF

赞(4)
未经允许不得转载:工具盒子 » 架构师必知的绝活-JVM调优