搞懂 Spring 循环依赖

这个其实是一个特别高频的面试题,也一直很想和大家仔细来聊一聊这个话题,网上关于这块的文章很多,但是我一直觉得要把这个问题讲清楚还有点难度,今天我来试一试,看能不能和小伙伴们把这个问题梳理清楚,

一 循环依赖

1.1 什么是循环依赖

首先,什么是循环依赖?这个其实好理解,就是两个 Bean 互相依赖,类似下面这样:

@Servicepublic class AService {    @Autowired    BService bService;}@Servicepublic class BService {    @Autowired    AService aService;}

复制代码

AService 和 BService 互相依赖:

这个应该很好理解。

1.2 循环依赖的类型

一般来说,循环依赖有三种不同的形态,上面 1.1 小节是其中一种。

另外两种分别是三者依赖,如下图:

这种循环依赖一般隐藏比较深,不易发觉。

还有自我依赖,如下图:

一般来说,如果我们的代码中出现了循环依赖,则说明我们的代码在设计的过程中可能存在问题,我们应该尽量避免循环依赖的发生。不过一旦发生了循环依赖,Spring 默认也帮我们处理好了,当然这并不能说明循环依赖这种代码就没问题。实际上在目前最新版的 Spring 中,循环依赖是要额外开启的,如果不额外配置,发生了循环依赖就直接报错了。

另外,Spring 并不能处理所有的循环依赖,后面松哥会和大家进行分析。

二 循环依赖解决思路

2.1 解决思路

那么对于循环依赖该如何解决呢?其实很简单,中加加入一个缓存就可以了,小伙伴们来看下面这张图:

我们在这里引入了一个缓存池。

当我们需要创建 AService 的实例的时候,会首先通过 Java 反射创建出来一个原始的 AService,这个原始 AService 可以简单理解为刚刚 new 出来(实际是刚刚通过反射创建出来)还没设置任何属性的 AService,此时,我们把这个 AService 先存入到一个缓存池中。

接下来我们就需要给 AService 的属性设置值了,同时还要处理 AService 的依赖,这时我们发现 AService 依赖 BService,那么就去创建 BService 对象,结果创建 BService 的时候,发现 BService 依赖 AService,那么此时就先从缓存池中取出来 AService 先用着,然后继续 BService 创建的后续流程,直到 BService 创建完成后,将之赋值给 AService,此时 AService 和 BService 就都创建完成了。

可能有小伙伴会说,BService 从缓存池中拿到的 AService 是一个半成品,并不是真正的最终的 AService,但是小伙伴们要知道,咱们 Java 是引用传递(也可以认为是值传递,只不过这个值是内存地址),BService 当时拿到的是 AService 的引用,说白了就是一块内存地址而已,根据这个地址找到的就是 AService,所以,后续如果 AService 创建完成后,BService 所拿到的 AService 就是完整的 AService 了。

那么上面提到的这个缓存池,在 Spring 容器中有一个专门的名字,就叫做 earlySingletonObjects,这是 Spring 三级缓存中的二级缓存,这里保存的是刚刚通过反射创建出来的 Bean,这些 Bean 还没有经历过完整生命周期,Bean 的属性可能都还没有设置,Bean 需要的依赖都还没有注入进来。另外两级缓存分别是:

  • singletonObjects:这是一级缓存,一级缓存中保存的是所有经历了完整生命周期的 Bean,即一个 Bean 从创建、到属性赋值、到各种处理器的执行等等,都经历过了,就存到 singletonObjects 中,当我们需要获取一个 Bean 的时候,首先会去一级缓存中查找,当一级缓存中没有的时候,才会考虑去二级缓存。

  • singletonFactories:这是三级缓存。在一级缓存和二级缓存中,缓存的 key 是 beanName,缓存的 value 则是一个 Bean 对象,但是在三级缓存中,缓存的 value 是一个 Lambda 表达式,通过这个 Lambda 表达式可以创建出来目标对象的一个代理对象。

有的小伙伴可能会觉得奇怪,按照上文的介绍,一级缓存和二级缓存就足以解决循环依赖了,为什么还冒出来一个三级缓存?那就得考虑 AOP 的情况了!

2.2 存在 AOP 怎么办

上面松哥给大家介绍的是普通的 Bean 创建,那确实没有问题。但是 Spring 中还有一个非常重要的能力,那就是 AOP。

说到这里,我得先和小伙伴么说一说 Spring 中 AOP 的创建流程。

正常来说是我们首先通过反射获取到一个 Bean 的实例,然后就是给这个 Bean 填充属性,属性填充完毕之后,接下来就是执行各种 BeanPostProcessor 了(不了解 BeanPostProcessor 的小伙伴可以看松哥之前发的文章 BeanFactoryPostProcessor 和 BeanPostProcessor 有什么区别?),如果这个 Bean 中有需要代理的方法,那么系统就会自动配置对应的后置处理器,松哥举一个简单例子,假设我有如下一个 Service:

@Servicepublic class UserService {
    @Async    public void hello() {        System.out.println("hello>>>"+Thread.currentThread().getName());    }}

复制代码

那么系统就会自动提供一个名为 AsyncAnnotationBeanPostProcessor 的处理器,在这个处理器中,系统会生成一个代理的 UserService 对象,并用这个对象代替原本的 UserService。

那么小伙伴们要搞清楚的是,原本的 UserService 和新生成的代理的 UserService 是两个不同的对象,占两块不同的内存地址!!!

我们再来回顾下面这张图:

如果 AService 最终是要生成一个代理对象的话,那么 AService 存到缓存池的其实还是原本的 AService,因为此时还没到处理 AOP 那一步(要先给各个属性赋值,然后才是 AOP 处理),这就导致 BService 从缓存池里拿到的 AService 是原本的 AService,等到 BService 创建完毕之后,AService 的属性赋值才完成,接下来在 AService 后续的创建流程中,AService 会变成了一个代理对象了,不是缓存池里的 AService 了,最终就导致 BService 所依赖的 AService 和最终创建出来的 AService 不是同一个。

为了解决这个问题,Spring 引入了三级缓存 singletonFactories。

singletonFactories 的工作机制是这样的(假设 AService 最终是一个代理对象):

当我们创建一个 AService 的时候,通过反射刚把原始的 AService 创建出来之后,先去判断当前一级缓存中是否存在当前 Bean,如果不存在,则:

  1. 首先向三级缓存中添加一条记录,记录的 key 就是当前 Bean 的 beanName,value 则是一个 Lambda 表达式 ObjectFactory,通过执行这个 Lambda 可以给当前 AService 生成代理对象。

  2. 然后如果二级缓存中存在当前 AService Bean,则移除掉。

现在继续去给 AService 各个属性赋值,结果发现 AService 需要 BService,然后就去创建 BService,创建 BService 的时候,发现 BService 又需要用到 AService,于是就先去一级缓存中查找是否有 AService,如果有,就使用,如果没有,则去二级缓存中查找是否有 AService,如果有,就使用,如果没有,则去三级缓存中找出来那个 ObjectFactory,然后执行这里的 getObject 方法,这个方法在执行的过程中,会去判断是否需要生成一个代理对象,如果需要就生成代理对象返回,如果不需要生成代理对象,则将原始对象返回即可。最后,把拿到手的对象存入到二级缓存中以备下次使用,同时删除掉三级缓存中对应的数据。这样 AService 所依赖的 BService 就创建好了。

接下来继续去完善 AService,去执行各种后置的处理器,此时,有的后置处理器想给 AService 生成代理对象,发现 AService 已经是代理对象了,就不用生成了,直接用已有的代理对象去代替 AService 即可。

至此,AService 和 BService 都搞定。

本质上,singletonFactories 是把 AOP 的过程提前了。

2.3 小结

总的来说,Spring 解决循环依赖把握住两个关键点:

  • 提前暴露:刚刚创建好的对象还没有进行任何赋值的时候,将之暴露出来放到缓存中,供其他 Bean 提前引用(二级缓存)。

  • 提前 AOP:A 依赖 B 的时候,去检查是否发生了循环依赖(检查的方式就是将正在创建的 A 标记出来,然后 B 需要 A,B 去创建 A 的时候,发现 A 正在创建,就说明发生了循环依赖),如果发生了循环依赖,就提前进行 AOP 处理,处理完成后再使用(三级缓存)。

原本 AOP 这个过程是属性赋完值之后,再由各种后置处理器去处理 AOP 的(AbstractAutoProxyCreator),但是如果发生了循环依赖,就先 AOP,然后属性赋值,最后等到后置处理器执行的时候,就不再做 AOP 的处理了。

不过需要注意,三级缓存并不能解决所有的循环依赖。

严格来说,其实也不是解决不了,所有问题都有办法解决,只是还需要额外配置。

三 特殊情况

根据前面介绍的思路,以下一些循环依赖场景无法解决。

3.1 基于构造器注入

如果依赖的对象是基于构造器注入的,那么执行的时候就会报错,代码如下:

@Servicepublic class AService {    BService bService;
    public AService(BService bService) {        this.bService = bService;    }}@Servicepublic class BService {    AService aService;
    public BService(AService aService) {        this.aService = aService;    }}

复制代码

运行时报错如下:

原因分析:

前面我们说解决循环依赖的思路是加入缓存,如下图:

我们说先把 AService 原始对象创建出来,存入到缓存池中,然后再处理 AService 中需要注入的外部 Bean 等等,但是,如果 AService 依赖的 BService 是通过构造器注入的,那就会导致在创建 AService 原始对象的时候就需要用到 BService,去创建 BService 时候又需要 AService,这样就陷入到死循环了,对于这样的循环依赖执行时候就会出错。

更进一步,如果我们在 AService 中是通过 @Autowired 来注入 BService 的,那么应该是可以运行的,代码如下:

@Servicepublic class AService {    @Autowired    BService bService;}@Servicepublic class BService {    AService aService;
    public BService(AService aService) {        this.aService = aService;    }}

复制代码

上面这段代码,AService 的原始对象就可以顺利创建出来放到缓存池中,BService 创建所需的 AService 也就能从缓存中获取到,所以就可以执行了。

3.2 prototype 对象

循环依赖双方 scope 都是 prototype 的话,也会循环依赖失败,代码如下:

@Service@Scope("prototype")public class AService {    @Autowired    BService bService;}@Service@Scope("prototype")public class BService {    @Autowired    AService aService;}

复制代码

这种循环依赖运行时也会报错,报错信息如下(跟前面报错信息一样):

原因分析:

scope 为 prototype 意思就是说这个 Bean 每次需要的时候都现场创建,不用缓存里的。那么 AService 需要 BService,所以就去现场创建 BService,结果 BService 又需要 AService,继续现场创建,AService 又需要 BService...,所以最终就陷入到死循环了。

3.3 @Async

带有 @Async 注解的 Bean 产生循环依赖,代码如下:

@Servicepublic class AService {    @Autowired    BService bService;
    @Async    public void hello() {
    }}@Servicepublic class BService {    @Autowired    AService aService;
}

复制代码

报错信息如下:

其实大家从这段报错信息中也能看出来个七七八八:在 BService 中注入了 AService 的原始对象,但是 AService 在后续的处理流程中被 AOP 代理了,产生了新的对象,导致 BService 中的 AService 并不是最终的 AService,所以就出错了!

那有小伙伴要问了,前面我们不是说了三级缓存就是为了解决 AOP 问题吗,为什么这里发生了 AOP 却无法解决?

如下两个前置知识大家先理解一下:

第一:

其实大部分的 AOP 循环依赖是没有问题的,这个 @Async 只是一个特例,特别在哪里呢?一般的 AOP 都是由 AbstractAutoProxyCreator 这个后置处理器来处理的,通过这个后置处理器生成代理对象,AbstractAutoProxyCreator 后置处理器是 SmartInstantiationAwareBeanPostProcessor 接口的子类,并且 AbstractAutoProxyCreator 后置处理器重写了 SmartInstantiationAwareBeanPostProcessor 接口的 getEarlyBeanReference 方法;而 @Async 是由 AsyncAnnotationBeanPostProcessor 来生成代理对象的,AsyncAnnotationBeanPostProcessor 也是 SmartInstantiationAwareBeanPostProcessor 的子类,但是却没有重写 getEarlyBeanReference 方法,默认情况下,getEarlyBeanReference 方法就是将传进来的 Bean 原封不动的返回去。

第二:

在 Bean 初始化的时候,Bean 创建完成后,后面会执行两个方法:

  • populateBean:这个方法是用来做属性填充的。

  • initializeBean:这个方法是用来初始化 Bean 的实例,执行工厂回调、init 方法以及各种 BeanPostProcessor。

大家先把这两点搞清楚,然后我来跟大家说上面代码的执行流程。

  1. 首先 AService 初始化,初始化完成之后,存入到三级缓存中。

  2. 执行 populateBean 方法进行 AService 的属性填充,填充时发现需要用到 BService,于是就去初始化 BService。

  3. 初始化 BService 发现需要用到 AService,于是就去缓存池中找,找到之后拿来用,但是!!!这里找到的 AService 不是代理对象,而是原始对象。因为在三级缓存中保存的 AService 的那个 ObjectFactory 工厂,在对 AService 进行提前 AOP 的时候,执行的是 SmartInstantiationAwareBeanPostProcessor 类型的后置处理器 中的 getEarlyBeanReference 方法,如果是普通的 AOP,调用 getEarlyBeanReference 方法最终会触发提前 AOP,但是,这里执行的是 AsyncAnnotationBeanPostProcessor 中的 getEarlyBeanReference 方法,该方法只是返回了原始的 Bean,并未做任何额外处理。

  4. 当 BService 创建完成后,AService 继续初始化,继续执行 initializeBean 方法。

  5. 在 initializeBean 方法中,执行其他的各种后置处理器,包括 AsyncAnnotationBeanPostProcessor,此时调用的是 AsyncAnnotationBeanPostProcessor 的 postProcessAfterInitialization 方法,在该方法中为 AService 生成了代理对象。

  6. 在 initializeBean 方法执行完成之后,AService 会继续去检查最终的 Bean 是不是还是一开始的 Bean,如果不是,就去检查当前 Bean 有没有被其他 Bean 引用过,如果被引用过,就会抛出来异常,也就是上图大家看到的异常信息。

这就是松哥和大家分享的三种 Spring 默认无法解决的循环依赖,其实也不是无法解决,需要一些额外配置也能解决。

那么对于以上问题该如何解决?

Spring 里边提供了办法来解决,但是似乎又没有解决,继续看你就明白了。

四 @Lazy

前面提到的三种无法自动解决的循环依赖,都可以通过添加 @Lazy 注解来解决。

如果是构造器注入,如下:

@Servicepublic class AService {
    BService bService;
    @Lazy    public AService(BService bService) {        this.bService = bService;    }
    public BService getbService() {        return bService;    }}@Servicepublic class BService {    AService aService;        @Lazy    public BService(AService aService) {        this.aService = aService;    }
    public AService getaService() {        return aService;    }}

复制代码

@Lazy 注解可以添加在 AService 或者 BService 的构造方法上,也可以都添加上。

添加上之后,我们再去启动项目,就不会报错了。这样看起来问题解决了,但是其实还是差点意思,小伙伴们看一下我的启动代码:

ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("aop.xml");AService aService = ctx.getBean(AService.class);BService bService = ctx.getBean(BService.class);System.out.println("aService.getClass() = " + aService.getClass());System.out.println("bService.getClass() = " + bService.getClass());System.out.println("aService.getbService().getClass() = " + aService.getbService().getClass());System.out.println("bService.getaService().getClass() = " + bService.getaService().getClass());

复制代码

最终打印结果如下:

小伙伴们看到,我们从容器中获取到的 AService 和 BService 的 Bean 都是正常的未被代理的对象,事实上我们的原始代码确实也没有需要代理的地方。但是,AService 中的 BService 以及 BService 中的 AService 却都是代理对象,按理说 AService 中的 BService 应该和我们从 Spring 容器中获取到的 BService 一致,BService 中的 AService 也应该和 Spring 容器中获取到的 AService 一致,但实际上,两者却并不相同。

不过这样也好懂了,为什么 Spring 能把一个死结给解开,就是因为 AService 和 BService 各自注入的 Bean 都不是原始的 Bean,都是一个代理的 Bean,AService 中注入的 BService 是一个代理对象,同理,BService 中注入的 AService 也是一个代理对象。

这也是为什么我一开始说这个问题 Spring 解决了又没解决。

其实,这就是 @Lazy 这个注解的工作原理,看名字,加了该注解的对象会被延迟加载,实际上被该注解标记的对象,会自动生成一个代理对象。

前面提到的另外两个问题,也可以通过 @Lazy 注解来解决,代码如下:

@Service@Scope("prototype")public class AService {    @Lazy    @Autowired    BService bService;
}@Service@Scope("prototype")public class BService {    @Lazy    @Autowired    AService aService;}

复制代码

这里 @Lazy 只要一个其实就能解决问题,也可以两个都添加。

对于含有 @Async 注解的情况,也可以通过 @Lazy 注解来解决:

@Servicepublic class AService {    @Autowired    @Lazy    BService bService;
    @Async    public void hello() {        bService.hello();    }
    public BService getbService() {        return bService;    }}@Servicepublic class BService {    @Autowired    AService aService;
    public void hello() {        System.out.println("xxx");    }
    public AService getaService() {        return aService;    }}

复制代码

如此,循环依赖可破!

总而言之一句话,@Lazy 注解是通过建立一个中间代理层,来破解循环依赖的。

2. 原理分析

接下来我们再来简单分析一下 @Lazy 注解处理的源码。

先来回顾一下属性注入的过程:

  1. 在创建 Bean 的时候,原始 Bean 创建出来之后,会调用 populateBean 方法进行 Bean 的属性填充。

  2. 接下来调用 postProcessAfterInstantiation 方法去判断是否需要执行后置处理器,如果不需要,就直接返回了。

  3. 调用 postProcessProperties 方法,去触发各种后置处理器的执行。

  1. 在第 3 步的方法中,调用 findAutowiringMetadata,这个方法又会进一步触发 buildAutorwiringMetadata 方法,去找到包含了 @Autowired、@Value 以及 @Inject 注解的属性或者方法,并将之封装为 InjectedElement 返回。

  2. 调用 InjectedElement#inject 方法进行属性注入。

  1. 接下来执行 resolvedCachedArgument 方法尝试从缓存中找到需要的 Bean 对象。

  2. 如果缓存中不存在,则调用 resolveFieldValue 方法去容器中找到 Bean。

  3. 最后调用 makeAccessible 和 set 方法完成属性的赋值。

在第 7 步中,调用 resolveFieldValue 方法去解析 Bean,@Lazy 注解的相关逻辑就是在这个方法中进行处理的(对应 最新版 Spring 源码视频教程)。

resolveFieldValue 方法最终会执行到 resolveDependency 方法:

@Nullablepublic Object resolveDependency(DependencyDescriptor descriptor, @Nullable String requestingBeanName,    @Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {  descriptor.initParameterNameDiscovery(getParameterNameDiscoverer());  if (Optional.class == descriptor.getDependencyType()) {    return createOptionalDependency(descriptor, requestingBeanName);  }  else if (ObjectFactory.class == descriptor.getDependencyType() ||      ObjectProvider.class == descriptor.getDependencyType()) {    return new DependencyObjectProvider(descriptor, requestingBeanName);  }  else if (javaxInjectProviderClass == descriptor.getDependencyType()) {    return new Jsr330Factory().createDependencyProvider(descriptor, requestingBeanName);  }  else {    Object result = getAutowireCandidateResolver().getLazyResolutionProxyIfNecessary(        descriptor, requestingBeanName);    if (result == null) {      result = doResolveDependency(descriptor, requestingBeanName, autowiredBeanNames, typeConverter);    }    return result;  }}

复制代码

在这个方法中,首先会判断注入的属性类型是 Optional、ObjectFactory 还是 JSR-330 中的注解,我们这里都不是,所以走最后一个分支。

在最后一个 else 中,首先调用 getAutowireCandidateResolver().getLazyResolutionProxyIfNecessary 方法看一下是否需要延迟加载 Bean 对象,@Lazy 注解就是在这里进行处理的。如果能够延迟加载,那么该方法的返回值就不为 null,就可以直接返回了,就不需要执行 doResolveDependency 方法了。

ContextAnnotationAutowireCandidateResolver#getLazyResolutionProxyIfNecessary:

@Override@Nullablepublic Object getLazyResolutionProxyIfNecessary(DependencyDescriptor descriptor, @Nullable String beanName) {  return (isLazy(descriptor) ? buildLazyResolutionProxy(descriptor, beanName) : null);}

复制代码

大家看一下,这个方法首先会调用 isLazy 去判断一下是否需要延迟加载,如果需要,则调用 buildLazyResolutionProxy 方法构建一个延迟加载的对象;如果不需要,则直接返回一个 null 即可。

protected boolean isLazy(DependencyDescriptor descriptor) {  for (Annotation ann : descriptor.getAnnotations()) {    Lazy lazy = AnnotationUtils.getAnnotation(ann, Lazy.class);    if (lazy != null && lazy.value()) {      return true;    }  }  MethodParameter methodParam = descriptor.getMethodParameter();  if (methodParam != null) {    Method method = methodParam.getMethod();    if (method == null || void.class == method.getReturnType()) {      Lazy lazy = AnnotationUtils.getAnnotation(methodParam.getAnnotatedElement(), Lazy.class);      if (lazy != null && lazy.value()) {        return true;      }    }  }  return false;}

复制代码

这个判断方法主要是检查当前类中各种参数上是否含有 @Lazy 注解、方法、属性以及类名上是否含有 @Lazy 注解,如果有,则返回 true,否则返回 false。

再来看 buildLazyResolutionProxy 方法:

private Object buildLazyResolutionProxy(    final DependencyDescriptor descriptor, final @Nullable String beanName, boolean classOnly) {  BeanFactory beanFactory = getBeanFactory();  final DefaultListableBeanFactory dlbf = (DefaultListableBeanFactory) beanFactory;  TargetSource ts = new TargetSource() {    @Override    public Class<?> getTargetClass() {      return descriptor.getDependencyType();    }    @Override    public boolean isStatic() {      return false;    }    @Override    public Object getTarget() {      Set<String> autowiredBeanNames = (beanName != null ? new LinkedHashSet<>(1) : null);      Object target = dlbf.doResolveDependency(descriptor, beanName, autowiredBeanNames, null);      if (target == null) {        Class<?> type = getTargetClass();        if (Map.class == type) {          return Collections.emptyMap();        }        else if (List.class == type) {          return Collections.emptyList();        }        else if (Set.class == type || Collection.class == type) {          return Collections.emptySet();        }        throw new NoSuchBeanDefinitionException(descriptor.getResolvableType(),            "Optional dependency not present for lazy injection point");      }      if (autowiredBeanNames != null) {        for (String autowiredBeanName : autowiredBeanNames) {          if (dlbf.containsBean(autowiredBeanName)) {            dlbf.registerDependentBean(autowiredBeanName, beanName);          }        }      }      return target;    }    @Override    public void releaseTarget(Object target) {    }  };  ProxyFactory pf = new ProxyFactory();  pf.setTargetSource(ts);  Class<?> dependencyType = descriptor.getDependencyType();  if (dependencyType.isInterface()) {    pf.addInterface(dependencyType);  }  ClassLoader classLoader = dlbf.getBeanClassLoader();  return (classOnly ? pf.getProxyClass(classLoader) : pf.getProxy(classLoader));}

复制代码

这个方法就是用来生成代理的对象的,这里构建了代理对象 TargetSource,在其 getTarget 方法中,会去执行 doResolveDependency 获取到被代理的对象(doResolveDependency 的获取逻辑,而 getTarget 方法只有在需要的时候才会被调用。所以,就是在给 Bean 中的各个属性注入值的时候,原本需要去 Spring 容器中找注入的对象,现在不找了,先给一个代理对象顶着,需要的时候再去 Spring 容器中查找。

```python
class BertPooler(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.dense = nn.Linear(config.hidden_size, config.hidden_size)
        self.activation = nn.Tanh()

    def forward(self, hidden_states):
        # We "pool" the model by simply taking the hidden state corresponding
        # to the first token.
        first_token_tensor = hidden_states[:, 0]
        pooled_output = self.dense(first_token_tensor)
        pooled_output = self.activation(pooled_output)
        return pooled_output
from transformers.models.bert.configuration_bert import *
import torch
config = BertConfig.from_pretrained("bert-base-uncased")
bert_pooler = BertPooler(config=config)
print("input to bert pooler size: {}".format(config.hidden_size))
batch_size = 1
seq_len = 2
hidden_size = 768
x = torch.rand(batch_size, seq_len, hidden_size)
y = bert_pooler(x)
print(y.size())
```

  • 16
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值