类加载流程

一个类被加载到虚拟机内存中需要经历几个过程:加载连接初始化。其中连接分为三个步骤:验证准备解析,下面一个一个说,这个几个阶段虚拟机都干了什么。

总览图

类加载过程总览图如下图:

加载

加载过程主要做了三件事:

  • 通过类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为这个类的各种数据的方位入口。

第一步主要是获取一个类的二进制字节流,意思就是把类以流的形式加载进内存,类的来源没有说,可以是jar包,也可以是class文件或者是apk文件。这个特性是能够实现插件化技术的理论基础。

第二步就是在获取到这个字节流以后,虚拟机就会把类中的静态存储结果保存到方法区中,保存的过程会转化对应方法区中的数据结构,所以说静态的结构都保存在内存中的方法区中。

第三步是当类加载进内存以后,每个类都会生成一个对应的Class对象,当我们使用这个类的时候,都是通过此Class对象为入口来使用的,比如我们写程序的时候通过 new 关键字创建一个类的对象的时候,也是通过这个类的Class对象来创建的。

总结这个过程就是加载二进制然后转换成JVM需要的结构,最后生成对应Class对象。

连接

连接阶段主要分验证、准备和解析。

  • 验证:主要是对类中的语法结构是否合法进行验证,确认类型符合Java语言的语义。
  • 准备:这个阶段是给类中的类变量分配内存,设置默认初始值,比如一个静态的int变量初始值是0,布尔变量初始值是false。
  • 解析:在类型的常量池中寻找类,接口,字段和方法的符号引用,把这些符号引用替换成直接引用的过程。

符号引用:class文件中常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info这几个结构所存储的字符串常量。

直接引用:能定位到所引用的真正内容。

解析的过程可能不好理解,关于符号引用和直接引用是什么意思可以暂时忽略,这个过程可以理解为一开始虚拟机对加载到内存中的各种类、字段等并没有一一编号,只是通过一个符号去表示,在解析阶段,虚拟机把内存中的类、方法等进行统一管理起来。

初始化

初始化阶段才真正到了类中定义的java代码的阶段,在这个阶段会对类中的变量和一些代码块进行初始化,比如以类变量进行初始化,在准备阶段对类变量进行的默认初始化,到这个阶段就对对变量进行显式的赋值,其中静态代码块就是在这个阶段来执行的。

初始化不会马上执行,当一个类被主动使用的时候才会去初始化,主要有下面这几种情况:

  • 当创建某个类的新实例时(如通过new或者反射等)
  • 当调用某个类的静态方法时
  • 当使用某个类或接口的静态字段时
  • 当调用Java API中的某些反射方法时,比如类Class中的方法,或者java.lang.reflect中的类的方法时
  • 当初始化某个子类时

关于类的加载过程就是上面这几步,分析完以后可以知道,我们程序员能够控制的只有第一步「加载」还有最后一步「初始化」,第一步记载的理论基础决定了插件化可以实现,最后一步初始化就是执行我们实际程序中的代码。

符号引用和直接引用

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可,使用符号引用时,被引用的目标不一定已经加载到内存中。

符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。

直接引用:可以是直接指向目标的指针,相对偏移量,一个能间接定位到目标的句柄,使用直接引用时,引用的目标必定已经存在于虚拟机的内存中了。

直接引用是和虚拟机的布局相关的,引用的目标必定已经被加载入内存中了。

为什么在解析阶段要符号引用转直接引用?

个人理解,如果使用符号引用,虚拟机其实也不知道具体引用的类的内存地址,那么也就无法真正的调用到该类,所以要把符号引用转为直接引用,这样就能够真正定位到类在内存中的地址,如果符号引用转直接引用失败,就说明类还没有被加载到内存中,就会报错。

反射

反射的概念

  • 反射:Refelection,反射是Java的特征之一,允许运行中的Java程序获取自身信息,并可以操作类或者对象的内部属性通过反射,可以在运行时获得程序或者程序中的每一个类型的成员或成成员的信息程序中的对象一般都是在编译时就确定下来,Java反射机制可以动态地创建对象并且调用相关属性,这些对象的类型在编译时是未知的也就是说,可以通过反射机制直接创建对象,即使这个对象类型在编译时是未知的
  • Java反射提供下列功能:在运行时判断任意一个对象所属的类在运行时构造任意一个类的对象在运行时判断任意一个类所具有的成员变量和方法,可以通过反射调用private方法在运行时调用任意一个对象的方法

反射基础

Java反射机制是在程序的运行过程中,对于任何一个类,都能够知道它的所有属性和方法;对于任意一个对象,都能够知道它的任意属性和方法,这种动态获取信息以及动态调用对象方法的功能称为Java语言的反射机制。

Java反射机制主要提供以下这几个功能:

在运行时判断任意一个对象所属的类
在运行时构造任意一个类的对象
在运行时判断任意一个类所有的成员变量和方法
在运行时调用任意一个对象的方法

Java中为什么需要反射?

反射是所有框架的灵魂 所有的框架都使用了反射技术。

  • 静态编译:在编译时确定类型,绑定对象即通过。

  • 动态编译:运行时确定类型,绑定对象。动态编译最大限度地发挥了Java的灵活性,体现了多态的应用,可以降低类之间的耦合性。

反射(reflection)允许静态语言在运行时(runtime)检查、修改程序的结构与行为。
在静态语言中,使用一个变量时,必须知道它的类型。在Java中,变量的类型信息在编译时都保存到了class文件中,这样在运行时才能保证准确无误;换句话说,程序在运行时的行为都是固定的。如果想在运行时改变,就需要反射这东西了。

一句话概括就是使用反射可以赋予jvm动态编译的能力,否则类的元数据信息只能用静态编译的方式实现,例如热加载,Tomcat的classloader等等都没法支持。

Class对象的获取

在类加载的时候,jvm会创建一个class对象。class对象可以说是反射中最常见的。

获取class对象的方式的主要三种:

  • 根据类名:类名.class
  • 根据对象:对象.getClass()
  • 根据全限定类名:Class.forName(全限定类名)

public class demo1Main1 {public static void main(String[] args) throws Exception {//获取Class对象的三种对象System.out.println("根据类名:\t" + User.class);System.out.println("根据对象:\t" + new User().getClass());System.out.println("根据全限定类名:\t" + Class.forName("demo1.User"));//常用的方法Class<User> userClass = User.class;System.out.println("获取全限定类名:\t" + userClass.getName());System.out.println("获取类名:\t" + userClass.getSimpleName());System.out.println("实例化:\t" + userClass.newInstance());}
}

输出结果:

根据类名: class demo1.User
根据对象:    class demo1.User
根据全限定类名: class demo1.User
获取全限定类名: demo1.User
获取类名:    User
实例化: User{name='init', age=0}

再来看看Class类的方法:

  • toString()
public String toString() {return (isInterface() ? "interface " : (isPrimitive() ? "" : "class "))+ getName();
}

toString()方法能够将对象转换为字符串,toString()首先判断Class类型是否是接口类型,也就是说普通类和接口都能用Class对象表示,然后在判断是否是基本数据类型,这里判断的都是基本数据类型和包装类,还有void类型。

  • getName()

    获取类的全限定名称。(包括包名)即类的完整名称。

    • 如果是引用类型。比如 String.class.getName()→java.lang.String
    • 如果是基本数据类型。比如 byte.class.getName()→byte
    • 如果是数组类型。比如 new Object[3].getClass().getName()→[Ljava.lang.Object;
  • getSimpleName()

    获取类名(不包括包名)。

  • getCanonicalName()

    获取全限定的类名(包括包名)。

  • toGenericString()

    返回类的全限定名称,而且包括类的修饰符和类型参数信息。

  • forName()

    根据类名获得一个Class对象的引用,这个方法会使类对象进行初始化。
    例如:Class t = Class.forName(“java.lang.Thread”)就能够初始化一个Thread线程对象。
    在Java中,一共有三种获取类实例的方式:

    • Class.forName(java.lang.Thread)
    • Thread.class
    • thread.getClass()
  • newInstance()
    创建一个类的实例,代表着这个类的对象。上面forName()方法对类进行初始化,newInstance方法对类进行实例化。使用该方法创建的类,必须带有无参的构造器。

  • getClassLoader()

    获取类加载器对象。

  • getInterfaces()

    获取当前类实现的类或是接口,可能是多个,所以返回的是Class数组。

  • isInterface()

    判断Class对象是否是表示一个接口。

  • getFields()

    获得某个类的所有的公共(public)的字段,包括继承自父类的所有公共字段。 类似的还有getMethodsgetConstructors

  • getDeclaredFields

    获得某个类的自己声明的字段,即包括public、private和proteced,默认但是不包括父类声明的任何字段。类似的还有getDeclaredMethodsgetDeclaredConstructors

getName、getCanonicalName与getSimpleName的区别:

  • getSimpleName:只获取类名.
  • getName:类的全限定名,jvm中Class的表示,可以用于动态加载Class对象,例如Class.forName。
  • getCanonicalName:返回更容易理解的表示,主要用于输出(toString)或log打印,大多数情况下和getName一样,但是在内部类、数组等类型的表示形式就不同了。
package com.cry;
public class Test {private  class inner{}public static void main(String[] args) throws ClassNotFoundException {//普通类System.out.println(Test.class.getSimpleName()); //TestSystem.out.println(Test.class.getName()); //com.cry.TestSystem.out.println(Test.class.getCanonicalName()); //com.cry.Test//内部类System.out.println(inner.class.getSimpleName()); //innerSystem.out.println(inner.class.getName()); //com.cry.Test$innerSystem.out.println(inner.class.getCanonicalName()); //com.cry.Test.inner//数组System.out.println(args.getClass().getSimpleName()); //String[]System.out.println(args.getClass().getName()); //[Ljava.lang.String;System.out.println(args.getClass().getCanonicalName()); //java.lang.String[]//我们不能用getCanonicalName去加载类对象,必须用getName//Class.forName(inner.class.getCanonicalName()); 报错Class.forName(inner.class.getName());}
}

Constructor类及其用法

Constructor类存在于反射包(java.lang.reflect)中,反映的是Class 对象所表示的类的构造方法。

获取Constructor对象是通过Class类中的方法获取的,Class类与Constructor相关的主要方法如下:

public class ConstructionTest implements Serializable {public static void main(String[] args) throws Exception {Class<?> clazz = null;//获取Class对象的引用clazz = Class.forName("com.example.javabase.User");//第一种方法,实例化默认构造方法,User必须无参构造函数,否则将抛异常User user = (User) clazz.newInstance();user.setAge(20);user.setName("Jack");System.out.println(user);System.out.println("--------------------------------------------");//获取带String参数的public构造函数Constructor cs1 =clazz.getConstructor(String.class);//创建UserUser user1= (User) cs1.newInstance("hiway");user1.setAge(22);System.out.println("user1:"+user1.toString());System.out.println("--------------------------------------------");//取得指定带int和String参数构造函数,该方法是私有构造privateConstructor cs2=clazz.getDeclaredConstructor(int.class,String.class);//由于是private必须设置可访问cs2.setAccessible(true);//创建user对象User user2= (User) cs2.newInstance(25,"hiway2");System.out.println("user2:"+user2.toString());System.out.println("--------------------------------------------");//获取所有构造包含privateConstructor<?> cons[] = clazz.getDeclaredConstructors();// 查看每个构造方法需要的参数for (int i = 0; i < cons.length; i++) {//获取构造函数参数类型Class<?> clazzs[] = cons[i].getParameterTypes();System.out.println("构造函数["+i+"]:"+cons[i].toString() );System.out.print("参数类型["+i+"]:(");for (int j = 0; j < clazzs.length; j++) {if (j == clazzs.length - 1)System.out.print(clazzs[j].getName());elseSystem.out.print(clazzs[j].getName() + ",");}System.out.println(")");}}
}class User {private int age;private String name;public User() {super();}public User(String name) {super();this.name = name;}/*** 私有构造* @param age* @param name*/private User(int age, String name) {super();this.age = age;this.name = name;}public int getAge() {return age;}public void setAge(int age) {this.age = age;}public String getName() {return name;}public void setName(String name) {this.name = name;}@Overridepublic String toString() {return "User{" +"age=" + age +", name='" + name + '\'' +'}';}
}

输出结果:


User{age=20, name='Jack'}
--------------------------------------------
user1:User{age=22, name='hiway'}
--------------------------------------------
user2:User{age=25, name='hiway2'}
--------------------------------------------
构造函数[0]:private com.example.javabase.User(int,java.lang.String)
参数类型[0]:(int,java.lang.String)
构造函数[1]:public com.example.javabase.User(java.lang.String)
参数类型[1]:(java.lang.String)
构造函数[2]:public com.example.javabase.User()
参数类型[2]:()

关于Constructor类本身一些常用方法如下(仅部分,其他可查API):

Constructor cs3 = clazz.getDeclaredConstructor(int.class,String.class);
System.out.println("-----getDeclaringClass-----");
Class uclazz=cs3.getDeclaringClass();
//Constructor对象表示的构造方法的类
System.out.println("构造方法的类:"+uclazz.getName());System.out.println("-----getGenericParameterTypes-----");
//对象表示此 Constructor 对象所表示的方法的形参类型
Type[] tps=cs3.getGenericParameterTypes();
for (Type tp:tps) {System.out.println("参数名称tp:"+tp);
}
System.out.println("-----getParameterTypes-----");
//获取构造函数参数类型
Class<?> clazzs[] = cs3.getParameterTypes();
for (Class claz:clazzs) {System.out.println("参数名称:"+claz.getName());
}
System.out.println("-----getName-----");
//以字符串形式返回此构造方法的名称
System.out.println("getName:"+cs3.getName());System.out.println("-----getoGenericString-----");
//返回描述此 Constructor 的字符串,其中包括类型参数。
System.out.println("getoGenericString():"+cs3.toGenericString());

输出结果:

-----getDeclaringClass-----
构造方法的类:com.example.javabase.User
-----getGenericParameterTypes-----
参数名称tp:int
参数名称tp:class java.lang.String
-----getParameterTypes-----
参数名称:int
参数名称:java.lang.String
-----getName-----
getName:com.example.javabase.User
-----getoGenericString-----
getoGenericString():private com.example.javabase.User(int,java.lang.String)

Field类及其用法

Field 提供有关类或接口的单个字段的信息,以及对它的动态访问权限。反射的字段可能是一个类(静态)字段或实例字段。

同样的道理,我们可以通过Class类的提供的方法来获取代表字段信息的Field对象,Class类与Field对象相关方法如下:

public class ReflectField {public static void main(String[] args) throws ClassNotFoundException, NoSuchFieldException {Class<?> clazz = Class.forName("reflect.Student");//获取指定字段名称的Field类,注意字段修饰符必须为public而且存在该字段,// 否则抛NoSuchFieldExceptionField field = clazz.getField("age");System.out.println("field:"+field);//获取所有修饰符为public的字段,包含父类字段,注意修饰符为public才会获取Field fields[] = clazz.getFields();for (Field f:fields) {System.out.println("f:"+f.getDeclaringClass());}System.out.println("================getDeclaredFields====================");//获取当前类所字段(包含private字段),注意不包含父类的字段Field fields2[] = clazz.getDeclaredFields();for (Field f:fields2) {System.out.println("f2:"+f.getDeclaringClass());}//获取指定字段名称的Field类,可以是任意修饰符的自动,注意不包含父类的字段Field field2 = clazz.getDeclaredField("desc");System.out.println("field2:"+field2);}
}class Person{public int age;public String name;//省略set和get方法
}class Student extends Person{public String desc;private int score;//省略set和get方法
}输出结果:
field:public int reflect.Person.age
f:public java.lang.String reflect.Student.desc
f:public int reflect.Person.age
f:public java.lang.String reflect.Person.name================getDeclaredFields====================
f2:public java.lang.String reflect.Student.desc
f2:private int reflect.Student.score
field2:public java.lang.String reflect.Student.desc

上述方法需要注意的是,如果我们不期望获取其父类的字段,则需使用Class类的getDeclaredField/getDeclaredFields方法来获取字段即可,倘若需要连带获取到父类的字段,那么请使用Class类的getField/getFields,但是也只能获取到public修饰的的字段,无法获取父类的私有字段。

//获取Class对象引用
Class<?> clazz = Class.forName("reflect.Student");Student st= (Student) clazz.newInstance();
//获取父类public字段并赋值
Field ageField = clazz.getField("age");
ageField.set(st,18);
Field nameField = clazz.getField("name");
nameField.set(st,"Lily");//只获取当前类的字段,不获取父类的字段
Field descField = clazz.getDeclaredField("desc");
descField.set(st,"I am student");
Field scoreField = clazz.getDeclaredField("score");
//设置可访问,score是private的
scoreField.setAccessible(true);
scoreField.set(st,88);
System.out.println(st.toString());//输出结果:Student{age=18, name='Lily ,desc='I am student', score=88} //获取字段值
System.out.println(scoreField.get(st));
// 88

其中的set(Object obj, Object value) 方法是Field类本身的方法,用于设置字段的值,而get(Object obj)则是获取字段的值,当然关于Field类还有其他常用的方法如下:

上述方法可能是较为常用的,事实上在设置值的方法上,Field类还提供了专门针对基本数据类型的方法,如setInt()/getInt()setBoolean()/getBoolean()setChar()/getChar()等等方法,这里就不全部列出了,需要时查API文档即可。需要特别注意的是被final关键字修饰的Field字段是安全的,在运行时可以接收任何修改,但最终其实际值是不会发生改变的。

Method类及其用法

Method 提供关于类或接口上单独某个方法(以及如何访问该方法)的信息,所反映的方法可能是类方法或实例方法(包括抽象方法)。

下面是Class类获取Method对象相关的方法:

在通过getMethods方法获取Method对象时,会把父类的方法也获取到,如上的输出结果,把Object类的方法都打印出来了。而getDeclaredMethod/getDeclaredMethods方法都只能获取当前类的方法。我们在使用时根据情况选择即可。下面将演示通过Method对象调用指定类的方法:

Class clazz = Class.forName("reflect.Circle");
//创建对象
Circle circle = (Circle) clazz.newInstance();//获取指定参数的方法对象Method
Method method = clazz.getMethod("draw",int.class,String.class);//通过Method对象的invoke(Object obj,Object... args)方法调用
method.invoke(circle,15,"圈圈");//对私有无参方法的操作
Method method1 = clazz.getDeclaredMethod("drawCircle");
//修改私有方法的访问标识
method1.setAccessible(true);
method1.invoke(circle);//对有返回值得方法操作
Method method2 =clazz.getDeclaredMethod("getAllCount");
Integer count = (Integer) method2.invoke(circle);
System.out.println("count:"+count);

输出结果:

draw 圈圈,count=15
drawCircle
count:100

在上述代码中调用方法,使用了Method类的invoke(Object obj,Object… args) 第一个参数代表调用的对象,第二个参数传递的调用方法的参数。这样就完成了类方法的动态调用。


getReturnType方法/getGenericReturnType方法都是获取Method对象表示的方法的返回类型,只不过前者返回的Class类型后者返回的Type(前面已分析过),Type就是一个接口而已,在Java8中新增一个默认的方法实现,返回的就参数类型信息

public interface Type {//1.8新增default String getTypeName() {return toString();}
}

getParameterTypes/getGenericParameterTypes也是同样的道理,都是获取Method对象所表示的方法的参数类型,其他方法与前面的Field和Constructor是类似的。

参考资料

  • Android中的类加载机制
  • 一张图总览类加载的过程
  • 符号引用和直接引用有什么区别
  • Java反射超详解✌
  • Java基础之—反射(非常重要)- - -含有参考代码
  • Java高级工程师面试:Java中的反射机制深入理解!反射的使用原理
  • Java反射

类的加载流程、反射、直接引用和符号引用相关推荐

  1. 【JVM】Java类的加载流程以及双亲委派,全盘托管,以及如何打破双亲委派机制

    JVM基础生命周期流程图 只有main()方法的java程序执行流程 classLoader.loadClass()的类加载流程(除引导类,所有类都一样) 加载:通过IO查找读取磁盘上的字节码文件,在 ...

  2. 第十九章《类的加载与反射》第3节:反射

    JAVA的反射机制是指在运行状态中,对于任意一个类都能够知道这个类的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性.这种动态获取的信息以及动态调用对象的方法的功能称为Java语言的 ...

  3. JVM源码阅读-Dalvik类的加载

    前言 本文主要研究Android dalvik虚拟机加载类的流程和机制.目的是了解Android中DEX文件结构,虚拟机如何从DEX文件中加载一个Java Class,以及到最终如何初始化这个类直至可 ...

  4. java类如何加载_简述Java类加载方式及流程

    在学习反射那一章节时想到自己之前学过的知识,故整理一番,希望能提供一点帮助,水平有限,如若有误欢迎指正. Java提供了两种类的装载方式.一是预先加载,二是按需加载.因为可以对类进行按需加载,所以程序 ...

  5. iOS进阶之底层原理-应用程序加载(dyld加载流程、类与分类的加载)

    iOS应用程序的入口是main函数,那么main函数之前系统做了什么呢? 我们定义一个类方法load,打断点,查看栈进程,我们发现dyld做了很多事,接下来就来探究到底dyld做了什么. 什么是dyl ...

  6. 反射(类的加载概述和加载时机)

    类的加载 当程序要使用某个类时,如果该类还未被加载到内存中,则系统会通过加载,连接,初始化三步来实现对这个类进行初始化. 加载 就是指将class文件读入内存,并为之创建一个Class对象. 任何类被 ...

  7. Cathy学习Java——反射和类的加载

    工厂设计模式 工厂方法模式 概述 工厂:就是生产特点产品的 实现方式 1>创建一个抽象工厂类,声明抽象方法 2>写一个具体抽象工厂类的子类,由子类负责对象的创建 优点:后期容易维护,增强了 ...

  8. 注解与反射 - 反射 - 类的加载

    所有Class 的对象 哪些类型可以有Class对象? class:外部类,成员(成员内部类,静态内部类),局部内部类,匿名内部类. interface: 接口 []:数组 enum:枚举 annot ...

  9. 【Java 19】反射 - 反射机制概述、获取Class实例、类的加载与ClassLoader的理解、创建运行时类的对象、获取运行时类的完整结构、调用运行时类的指定结构、动态代理

    反射机制概述.获取Class实例.类的加载与ClassLoader的理解.创建运行时类的对象.获取运行时类的完整结构.调用运行时类的指定结构.动态代理 反射 1 Java反射机制概述 1.1 Java ...

最新文章

  1. Unet网络实现叶子病虫害图像分割
  2. 报名 | 美团是怎样给你推荐外卖的?美团大脑知识图谱详解
  3. MySQL环境配置和入门讲解!
  4. mysql 数据文件压缩,压缩MySQL数据文件的妙招
  5. linux centos7 重启服务器报错 Run 'systemctl daemon-reload' to reload units
  6. 使用别名访问MSSQL Express
  7. python3类的继承详解_python3中类的继承以及self和super的区别详解
  8. 友善之臂编linux内核,友善之臂NanoPC-T3 Plus,s5p6818编译Linux内核流程
  9. C语言入门经典材料领走不谢!
  10. JVM内存模型与GC回收器
  11. 李子柒被坑,大厂生气了!字节跳动火速对杭州微念启动撤资
  12. 【httpClient】Timeout waiting for connection from pool
  13. redhat 6.8 配置 centos6 163 的 yum 源
  14. 第18次Scrum会议(10/30)【欢迎来怼】
  15. Hutool拼音工具的使用
  16. 什么是SpringDataJPA
  17. javacc LOOKAHEAD关键字
  18. 计算机高数用到的初高中知识,高中数学算法初步知识点整理
  19. windows主机如何登录阿里云服务器
  20. matlab电气仿真模块b25,基于Matlab的由双馈风力发电机组成的风电场仿真

热门文章

  1. Navicat for MySQL不能录入中文的问题Navicat for MySQL录入中文后MySQL显示问号
  2. Html5—plusready
  3. 【安全设备】面试·HVV专题
  4. linux运维工程师培训课程_Linux系统资深运维工程师的进阶秘籍
  5. JVM基础 - JAVA类加载机制
  6. 2022-09-17青少年软件编程(C语言)等级考试试卷(一级)解析
  7. STM32串口通信,CH340工作原理
  8. php 源代码分离,迅睿CMS 网站安全权限划分
  9. 产品经理值得收藏的博客(持续更新)
  10. ODOO13 如何在Many2one字段选择控件上进行多条件搜索