Java反射原理以及一些常见的应用-阿里云开发者社区

开发者社区> Java开发者> 正文

Java反射原理以及一些常见的应用

简介: 创建对象有四大方法,new、反射、克隆、反序列化创建,今天我们将由阿里技术专家 关键,围绕反射来展开分享。主要内容有类装载、反射原理、反射使用场景、AOP。

本文由内容志愿者整理阿里云社群直播而来。

讲师:关键

目录
image.png

一、类装载

我们都知道,java在编译类后并不是产生固有机器的机器码,而是一段字节码,这段字节码可以存放于任何地方,如.class文件,jar包中,可以通过网络传输。JVM虚拟机在拿取到这段二进制数据流字节码后,就会处理这些数据,并最终转换成一个java.lang.Class的实例(注意,这里并不是类本身的实例)。

java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据。通过Class类提供的接口,可以访问一个类型的方法,字段等信息。

二进制数据流字节码被加载到虚拟机之后,会进行一系列的验证检查,主要步骤如下:

1.格式检查:包括魔数检查(识别文件类型开头的几个十六进制数,每一种文件类型都不同),版本检查,长度检查

2.语义检查:是否继承final,是否有父类,抽象方法是否有实现

3.字节码验证:跳转指令是否指向正确位置,操作数类型是否合理

4.符号引用验证:符号引用的直接引用是否存在

当一个类验证通过时,虚拟机就会进入准备阶段。分配内存空间,分配初始值。如果类存在常量字段,如果被final修饰,就会被直接放入常量池中。如果没有final修饰,就会在初始化中赋值,而不是直接放入常量池。

准备阶段完成后,就是解析类,解析类就是把字节码中的类,接口,字段,方法放入JVM虚拟机实际内存的地址中,方便程序可以真正执行,比如说类的方法会有一个方法表,当需要调用一个类的方法时,就要知道这个方法在方法表中的偏移量,直接调用该方法。

类的初始化是类装载的最后一个阶段。初始化的重要工作就是执行类的初始化方法。方法是由编译器自动生成的,它是由类静态成员的赋值语句以及static语句合并产生的。类似代码如下

public class SimpleStatic {
    public static int id = 1;
    public static int number;
    static {
        number = 4;
    }
}

反射代码示例如下:

public class ClassTest {
    public static void main(String[] args) throws ClassNotFoundException {
        Class clzStr = Class.forName("java.lang.String");
        //获取该类的所有方法
        Method[] ms = clzStr.getDeclaredMethods();
        for (Method m:ms) {
            //获取该方法的修饰符
            String mod = Modifier.toString(m.getModifiers());
            //打印方法的修饰符,方法名跟起始括号
            System.out.print(mod + " " + m.getName() + " (");
            //获取方法的所有参数类型
            Class<?>[] ps = m.getParameterTypes();
            //如果没有参数,直接打印结束括号
            if (ps.length == 0) {
                System.out.print(')');
            } else {
                //取出所有的参数类型名称,以逗号分隔
                for (int i = 0; i < ps.length; i++) {
                    char end = i == ps.length - 1 ? ')' : ',';
                    System.out.print(ps[i].getSimpleName() + end);
                }
            }
            System.out.println();
        }
    }
}

运行结果:

public equals (Object)
public toString ()
public hashCode ()
public compareTo (String)
public volatile compareTo (Object)
public indexOf (String,int)
public indexOf (String)
public indexOf (int,int)
public indexOf (int)
static indexOf (char[],int,int,char[],int,int,int)
static indexOf (char[],int,int,String,int)
public static valueOf (int)
public static valueOf (long)
public static valueOf (float)
public static valueOf (boolean)
public static valueOf (char[])
public static valueOf (char[],int,int)
public static valueOf (Object)
public static valueOf (char)
public static valueOf (double)
public charAt (int)
private static checkBounds (byte[],int,int)
public codePointAt (int)
public codePointBefore (int)
public codePointCount (int,int)
public compareToIgnoreCase (String)
public concat (String)
public contains (CharSequence)
public contentEquals (CharSequence)
public contentEquals (StringBuffer)
public static copyValueOf (char[])
public static copyValueOf (char[],int,int)
public endsWith (String)
public equalsIgnoreCase (String)
public static transient format (Locale,String,Object[])
public static transient format (String,Object[])
public getBytes (int,int,byte[],int)
public getBytes (Charset)
public getBytes (String)
public getBytes ()
public getChars (int,int,char[],int)
 getChars (char[],int)
private indexOfSupplementary (int,int)
public native intern ()
public isEmpty ()
public static transient join (CharSequence,CharSequence[])
public static join (CharSequence,Iterable)
public lastIndexOf (int)
public lastIndexOf (String)
static lastIndexOf (char[],int,int,String,int)
public lastIndexOf (String,int)
public lastIndexOf (int,int)
static lastIndexOf (char[],int,int,char[],int,int,int)
private lastIndexOfSupplementary (int,int)
public length ()
public matches (String)
private nonSyncContentEquals (AbstractStringBuilder)
public offsetByCodePoints (int,int)
public regionMatches (int,String,int,int)
public regionMatches (boolean,int,String,int,int)
public replace (char,char)
public replace (CharSequence,CharSequence)
public replaceAll (String,String)
public replaceFirst (String,String)
public split (String)
public split (String,int)
public startsWith (String,int)
public startsWith (String)
public subSequence (int,int)
public substring (int)
public substring (int,int)
public toCharArray ()
public toLowerCase (Locale)
public toLowerCase ()
public toUpperCase ()
public toUpperCase (Locale)
public trim ()

Class.forName可以得到代表String类的Class实例,它是反射中最重要的方法。

二、反射原理

反射离不开Class.forName(),我们先从Class.forName说起。
反射可以跟new一个对象有相同的效果。例如:

public class Company {
    private String a;
    private String b;

    @Override
    public String toString() {
        return "Company{" +
                "a='" + a + '\'' +
                ", b='" + b + '\'' +
                '}';
    }

    public Company() {
        this.a = "A";
        this.b = "B";
    }
}


public class CompanyInstance {
    private static Company company = new Company();

    public static void main(String[] args) {
        System.out.println(company);
    }
}

运行结果:

Company{a='A', b='B'}

又可以写成如下

public class CompanyInstance {

    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        System.out.println(Class.forName("com.guanjian.Company").newInstance());
    }
}

运行结果:

Company{a='A', b='B'}
new VS newInstance

虽然效果一样,但他们的过程并不一样。 首先,newInstance( )是一个方法,而new是一个关键字;其次,Class下的newInstance()的使用有局限,因为它生成对象只能调用无参的构造函数,而使用 new关键字生成对象没有这个限制。

newInstance()的时候是使用的上篇说的类装载机制的,它会走完全部过程。具体可以看 浅析类装载 ,而new一个实例的时候,走的流程不太一样,它会先在JVM内部先去寻找该类的Class实例,然后依照该Class实例的定义,依葫芦画瓢,把该类的实例给生成出来。

但如果找不到该类的Class实例,则会走上篇说的装载流程。 其中JDK的Class实例一般是在jvm启动时用启动类加载器完成加载,用户的Class实例则是在用到的时候再加载。

Class.forName()

Class.forName()被重载为有一个参数和三个参数的,我们来看一下其源码。

@CallerSensitive
public static Class<?> forName(String className)
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

无论是三参还是单参的都调用了此方法:

private static native Class<?> forName0(String name, boolean initialize,
                                        ClassLoader loader,
                                        Class<?> caller)
    throws ClassNotFoundException;

它的第二个参数boolean initialize表示是否要初始化该类,单参Class.forName()默认true是要初始化的,三参的Class.forName()由你自己选择。一旦初始化,就会触发目标对象的 static块代码执行,static参数也也会被再次初始化。当然如果你使用了三个参数的Class.forName(),并调用了newInstance()以后,是肯定会初始化的。

ClassLoader,这个才是真正装载类的核心组件。所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制字节码数据流读入系统,然后交给JVM虚拟机进行连接、初始化等操作。

ClassLoader的分类

在标准的Java程序中,Java虚拟机会创建3类ClassLoader为整个应用程序服务。它们分别是:BootStrap ClassLoader(启动类加载器),Extension ClassLoader(扩展类加载器),App ClassLoader(应用类加载器,也称为系统类加载器)。

此外,每一个应用程序还可以拥有自定义的ClassLoader,扩展Java虚拟机获取Class数据的能力。其中,应用类加载器的双亲为扩展类加载器,扩展类加载器的双亲为启动类加载器。

当系统需要使用一个类时,在判断类是否已经被加载时,会先从当前底层类加载器进行判断。当系统需要加载一个类时,会从顶层类开始加载,依次向下尝试,直到成功。

三、反射使用场景

1、编码阶段不知道需要实例化的类名是哪个,需要在runtime从配置文件中加载:

Class clazz = class.forName("xxx.xxx.xxx")
clazz.newInstance();

2、在runtime阶段,需要临时访问类的某个私有属性

ClassA objA = new ClassA();
Field xxx = objA.getClass().getDeclaredField("xxx")
xxx.setAccessible(true);

3、当使用标签的时候,我们要获取标签

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnotionTest {
    String value() default "哈士奇";
}
@AnnotionTest
public class Dog {
    private String type;
    private String name;
    public Dog() {
        type = "金毛";
        name = "大黄";
    }
public Dog(String type,String name){
        this.type = type;
        this.name = name;
}

// toString()省略
}
public class CheckDog {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("com.guanjian.Dog");
        if (clazz.isAnnotationPresent(AnnotionTest.class)) {
            Field field = clazz.getDeclaredField("type");
            field.setAccessible(true);
            System.out.println(field.get(clazz.newInstance()));
            AnnotionTest test = (AnnotionTest)clazz.getAnnotation(AnnotionTest.class);
            System.out.println(test.value());
        }
    }
}

4、获取具体的构造器来构造类本身的实例

Class<?>[] constructorParams = {String.class,String.class};
        Constructor<?> cons = clazz.getConstructor(constructorParams);
        System.out.println(cons.newInstance("哈士奇","神哈"));

5、其他(可以用到的地方还有很多,比如获取父类的方法,判断是不是一个接口等等)

四、AOP

AOP主要是通过动态代理实现的,动态代理分两种,一种是JDK的,一种是CGlib。JDK的动态代理必须有一个接口。

public interface Greeting {
    void sayHello(String name);
}

还有一个实现类,是实现了它的主要功能。

public class GreetingImpl implements Greeting {
    public void sayHello(String name) {
        System.out.println("Hello! " + name);
    }
}
public class DynamicProxy<T> implements InvocationHandler {
    private Object target;

    public DynamicProxy(Object target) {
        this.target = target;
    }
    //获取目标对象的代理类实例
    public T getProxy() {
        return (T) Proxy.newProxyInstance(
                target.getClass().getClassLoader(),  //目标对象类的加载器
                target.getClass().getInterfaces(),  //目标对象类的接口数组(因为一个类可能有几个接口)
                this
        );
    }

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        before();
        Object result = method.invoke(target,args);
        after();
        return result;
    }
    private void before() {
        System.out.println("Before");
    }
    private void after() {
        System.out.println("After");
    }
}

main方法

public class Client {
    public static void main(String[] args) {
        Greeting greetingProxy = new DynamicProxy<Greeting>(new GreetingImpl()).getProxy();
        greetingProxy.sayHello("Jack");
    }
}

运行结果:

Before
Hello! Jack
After

动态代理是通过反射来实现的,这里有一个Proxy.newProxyInstance,我们来看一下它的源代码。

//代理类的构造器的参数类型数组
private static final Class<?>[] constructorParams =
    { InvocationHandler.class };
@CallerSensitive
public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)
    throws IllegalArgumentException
{
    //要求动态代理类本身不为空,如果为空会抛出异常
    Objects.requireNonNull(h);
    //将接口数组克隆出来
    final Class<?>[] intfs = interfaces.clone();
    //启动Java安全管理器
    final SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        //检查调用类是否有启动接口的Class实例的加载器的权限
        checkProxyAccess(Reflection.getCallerClass(), loader, intfs);
    }

    /*
     * Look up or generate the designated proxy class.
     * 创建代理类的Class实例
     */
    Class<?> cl = getProxyClass0(loader, intfs);

    /*
     * Invoke its constructor with the designated invocation handler.
     */
    try {
        if (sm != null) {
            //检查调用类是否有该代理类的权限
            checkNewProxyPermission(Reflection.getCallerClass(), cl);
        }
        //通过构造参数类型来获取代理类的具体某一个构造器
        final Constructor<?> cons = cl.getConstructor(constructorParams);
        //代理类本身实例
        final InvocationHandler ih = h;
        //获取代理类Class实例的所有修饰符,并判断是否为public,如果不为public则通过cons.setAccessible(true)设置为可以访问
        if (!Modifier.isPublic(cl.getModifiers())) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    cons.setAccessible(true);
                    return null;
                }
            });
        }
        //通过该具体的构造器来构造代理类本身的实例,之前我们有说过Class的newInstance只能通过无参构造器来构造,但此处是选择了具体的有参构造器构造
        return cons.newInstance(new Object[]{h});
    } catch (IllegalAccessException|InstantiationException e) {
        throw new InternalError(e.toString(), e);
    } catch (InvocationTargetException e) {
        Throwable t = e.getCause();
        if (t instanceof RuntimeException) {
            throw (RuntimeException) t;
        } else {
            throw new InternalError(t.toString(), t);
        }
    } catch (NoSuchMethodException e) {
        throw new InternalError(e.toString(), e);
    }
}

由以上代码可知,该动态代理必须要有接口才能代理,而不能代理没有接口的类。

没有接口的类,我们可以使用CGLib来动态代理,要使用CGLib,先要在pom中加入它的引用

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>2.2.2</version>
</dependency>
public class CGLibDynamicProxy<T> implements MethodInterceptor {
    private static CGLibDynamicProxy instance = new CGLibDynamicProxy();

    private CGLibDynamicProxy() {
    }

    /**
     * 通过单例模式获取代理类的实例
     * @return
     */
    public static CGLibDynamicProxy getInstance() {
        return instance;
    }

    /**
     * 通过实现类的Class实例跟代理类对象关联
     * @param cls
     * @return
     */
    public T getProxy(Class<T> cls) {
        return (T)Enhancer.create(cls,this);
    }
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        before();
        Object result = methodProxy.invokeSuper(o,objects);
        after();
        return result;
    }
    private void before() {
        System.out.println("Before");
    }
    private void after() {
        System.out.println("After");
    }
}

main方法

public class Client {
    public static void main(String[] args) {
        Greeting greetingProxy = (Greeting) CGLibDynamicProxy.getInstance().getProxy(GreetingImpl.class);
        greetingProxy.sayHello("Jack");
    }
}

我们来简单看一下Enhancer.create的源码

public static Object create(Class type, Callback callback) {
    Enhancer e = new Enhancer();  //创建一个增强代理类对象,它可以拦截被代理的除final,static以外的所有类
    e.setSuperclass(type);  //设置该代理类的父类为实现类
    e.setCallback(callback);  //设置实现了MethodInterceptor接口的回调类,这里即为我们动态代理类本身的实例,因为MethodInterceptor是继承于Callback的接口,它是一个
                              //对象方法拦截器
    return e.create();       //返回实现类的增强类(所谓增强即可以对这个类进行拦截和各种处理操作)
}

了解了动态代理之后,我们来了解一下Spring+AspectJ的AOP。我们主要结合拦截指定注解(@annotation)的方式来大概说明一下用法。

比如说我们要拦截一个自定义的标签,对标有该标签的方法,记录日志,通过MQ(具体不限定哪种MQ)发送到日志中心,进行存储。

首先我们有一个日志的实体类。

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Log implements Serializable {

   private static final long serialVersionUID = -5398795297842978376L;

   private Long id;
   private String username;
   /** 模块 */
   private String module;
   /** 参数值 */
   private String params;
   private String remark;
   private Boolean flag;
   private Date createTime;
   private String ip;
   private String area;
}

有一个自定义的标签,这是一个方法级标签。

/**
 * 日志注解
 *
 */
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface LogAnnotation {

   String module();

   /**
    * 记录参数<br>
    * 尽量记录普通参数类型的方法,和能序列化的对象
    * 
    * @return
    */
   boolean recordParam() default true;
}

增加日志所属的模块(用户操作)。

/**
 * 日志模块定义
 *
 */
public abstract class LogModule {

    public static final Map<String, String> MODULES = new HashMap<>();

    public static final String LOGIN = "LOGIN";
    public static final String LOGOUT = "LOGOUT";

    public static final String ADD_PERMISSION = "ADD_PERMISSION";
    public static final String UPDATE_PERMISSION = "UPDATE_PERMISSION";
    public static final String DELETE_PERMISSION = "DELETE_PERMISSION";

    public static final String ADD_ROLE = "ADD_ROLE";
    public static final String UPDATE_ROLE = "UPDATE_ROLE";
    public static final String DELETE_ROLE = "DELETE_ROLE";
    public static final String SET_PERMISSION = "SET_PERMISSION";

    public static final String SET_ROLE = "SET_ROLE";
    public static final String UPDATE_USER = "UPDATE_USER";
    public static final String UPDATE_ME = "UPDATE_ME";

    public static final String UPDATE_PASSWORD = "UPDATE_PASSWORD";
    public static final String RESET_PASSWORD = "RESET_PASSWORD";

    public static final String ADD_MENU = "ADD_MENU";
    public static final String UPDATE_MENU = "UPDATE_MENU";
    public static final String DELETE_MENU = "DELETE_MENU";
    public static final String SET_MENU_ROLE = "SET_MENU_ROLE";

    public static final String ADD_BLACK_IP = "ADD_BLACK_IP";
    public static final String DELETE_BLACK_IP = "DELETE_BLACK_IP";

    public static final String FILE_UPLOAD = "FILE_UPLOAD";
    public static final String FILE_DELETE = "FILE_DELETE";

    public static final String ADD_MAIL = "ADD_MAIL";
    public static final String UPDATE_MAIL = "UPDATE_MAIL";
    public static final String DELETE_USER = "DELETE_USER";

    static {
        MODULES.put(LOGIN, "登陆");
        MODULES.put(LOGOUT, "退出");

        MODULES.put(ADD_PERMISSION, "添加权限");
        MODULES.put(UPDATE_PERMISSION, "修改权限");
        MODULES.put(DELETE_PERMISSION, "删除权限");

        MODULES.put(ADD_ROLE, "添加角色");
        MODULES.put(UPDATE_ROLE, "修改角色");
        MODULES.put(DELETE_ROLE, "删除角色");
        MODULES.put(SET_PERMISSION, "分配权限");
        MODULES.put(SET_ROLE, "分配角色");

        MODULES.put(UPDATE_USER, "修改用户");
        MODULES.put(UPDATE_ME, "修改个人信息");
        MODULES.put(UPDATE_PASSWORD, "修改密码");
        MODULES.put(RESET_PASSWORD, "重置密码");

        MODULES.put(ADD_MENU, "添加菜单");
        MODULES.put(UPDATE_MENU, "修改菜单");
        MODULES.put(DELETE_MENU, "删除菜单");
        MODULES.put(SET_MENU_ROLE, "分配菜单");

        MODULES.put(ADD_BLACK_IP, "添加黑名单");
        MODULES.put(DELETE_BLACK_IP, "删除黑名单");

        MODULES.put(FILE_UPLOAD, "文件上传");
        MODULES.put(FILE_DELETE, "文件删除");

        MODULES.put(ADD_MAIL, "保存邮件");
        MODULES.put(UPDATE_MAIL, "修改邮件");
        MODULES.put(DELETE_USER, "删除用户");

    }

}

然后我们定义一个AOP的实现类。

@Aspect
public class LogAop {

    private static final Logger logger = LoggerFactory.getLogger(LogAop.class);
    //对@LogAnnotation标签进行环绕增强
    @Around(value = "@annotation(com.cloud.model.log.LogAnnotation)")
    public Object logSave(ProceedingJoinPoint joinPoint) throws Throwable { //ProceedingJoinPoint joinPoint连接点,可以通过该对象获取方法的任务信息,例如,方
        Log log = new Log();                                                //法名,参数等。
        log.setCreateTime(new Date());
        LoginAppUser loginAppUser = AppUserUtil.getLoginAppUser();
        if (loginAppUser != null) {
            log.setUsername(loginAppUser.getUsername());
        }
        //通过连接点获取方法签名
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        //通过方法的签名获取方法的标签
        LogAnnotation logAnnotation = methodSignature.getMethod().getDeclaredAnnotation(LogAnnotation.class);
        log.setModule(logAnnotation.module());
        //我们是否要收集方法的参数,默认为true
        if (logAnnotation.recordParam()) {
            String[] paramNames = methodSignature.getParameterNames();// 获取方法参数名
            if (paramNames != null && paramNames.length > 0) {
                Object[] args = joinPoint.getArgs();// 通过连接点获取传入参数值
                //定义一个HashMap,并将对应的参数名和参数值全部放入该HashMap
                Map<String, Object> params = new HashMap<>();
                for (int i = 0; i < paramNames.length; i++) {
                    params.put(paramNames[i], args[i]);
                }

                try {
                    log.setParams(JSONObject.toJSONString(params)); //Json序列化对应参数信息放入log对象中
                } catch (Exception e) {
                    logger.error("记录参数失败:{}", e.getMessage());
                }
            }
        }

        try {
            Object object = joinPoint.proceed();// 执行原方法
            //执行成功处理
            log.setFlag(Boolean.TRUE);

            return object;
        } catch (Exception e) {
            //执行失败处理
            log.setFlag(Boolean.FALSE);
            log.setRemark(e.getMessage());
            throw e;
        } finally {
            // 异步将Log对象发送到队列
            CompletableFuture.runAsync(() -> {
                try {
                    /////////此处可以将日志对象log发送到消息队列,由于在一些系统中,一些用户的操作大量且频繁,推荐使用Kafka来作为推送日志的消息队列,比如
                    /////////商品购买,退货等等,在LogModule中添加对应的操作标志。
                    logger.info("发送日志到队列:{}", log);
                } catch (Exception e2) {
                    e2.printStackTrace();
                }
            });

        }

    }
}

因为@Aspect标签是未被Spring托管的,所以要对LogAop实行自动配置,在资源文件夹下的spring.factories中

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.cloud.log.autoconfigure.LogAop

有多条以, 分隔成多行。在一些自己开发的jar包导入新的Spring boot工程中,此种方法是最为有效的依赖注入方式。

比如在用户模块要修改个人信息的时候

/**
 * 修改自己的个人信息
 *
 * @param appUser
 * @return
 */
@LogAnnotation(module = LogModule.UPDATE_ME)
@PutMapping("/users/me")
public Map<String, Object> updateMe(@RequestBody AppUser appUser) {
    AppUser user = AppUserUtil.getLoginAppUser();
    appUser.setId(user.getId());

    appUserService.updateAppUser(appUser);

    return ResponseUtils.getDataResult(user);
}

这个updateMe方法就会被AOP环绕增强,将操作的信息,传递参数放入log对象,并通过MQ发送到日志中心进行接收。其实这里面接入点的具体实现也是通过反射来处理的。而AOP的整个实现也是通过动态代理来实现的。当然不要忘了在本模块内的配置文件中增加。

spring:
  aop:  
    proxy-target-class: true

它的默认值是false,默认只能代理接口(使用JDK动态代理),当为true时,才能代理目标类(使用CGLib动态代理)。

版权声明:本文中所有内容均属于阿里云开发者社区所有,任何媒体、网站或个人未经阿里云开发者社区协议授权不得转载、链接、转贴或以其他方式复制发布/发表。申请授权请邮件developerteam@list.alibaba-inc.com,已获得阿里云开发者社区协议授权的媒体、网站,在转载使用时必须注明"稿件来源:阿里云开发者社区,原文作者姓名",违者本社区将依法追究责任。 如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:developer2020@service.aliyun.com 进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
Java开发者
使用钉钉扫一扫加入圈子
+ 订阅

Java开发者成长课堂,课程资料学习,实战案例解析,Java工程师必备词汇等你来~

官方博客
官网链接