夯实Spring系列|第十二章:Spring Bean 生命周期-上篇

夯实Spring系列|第十二章:Spring Bean 生命周期-上篇

本章说明

本文应该是讲 Spring Bean 生命周期最全的一篇,一共细分为 18 个阶段,从 Bean 的配置阶段到最终的销毁阶段,还特别加入了垃圾回收,Spring 设计的各个阶段的切入点都会演示到,而且都会进行源码调试;由于章节太多,所以本章将分为上中下三篇进行发布。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0w2ICEnV-1588318851630)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200429125110856.png)]
上篇:主要是讨论 Spring Bean 的元信息配置,解析,注册,合并,类加载 5 个阶段,其中后 4 个阶段主要是 Spring 容器内部操作,我们无法进行编码,所以源码分析和调试较多,最好在 Idea 中跟着一起进行调试会比较好理解。

中篇:主要是讨论 Spring 如何将 Class 进行实例化,以及实例化之后的属性赋值阶段,每个阶段均有对应的接口回调方法进行演示。

下篇:主要讨论初始化、销毁以及垃圾回收等 3 个阶段,以及每个阶段对应的接口回调方法。

友情提醒:开始前请备好晕车药~

1.项目环境

2.Spring Bean 元信息配置阶段

BeanDefinition 配置

  • 面向资源
    • XML 配置
      • <bean id="…" …>
    • Properties 资源配置
  • 面向注解
    • @Configuration、@Component、@Bean
  • 面向 API
    • BeanDefinitionBuilder

2.1 XML 配置

源码位置:ioc-container-overview 模块

com.huajie.thinking.in.spring.ioc.overview.container.BeanFactoryAsIocContainerDemo

通过 XmlBeanDefinitionReader 来加载 XML 配置

/**
 * IOC 容器示例
 */
public class BeanFactoryAsIocContainerDemo {
    public static void main(String[] args) {
        // 创建 BeanFactory 容器
        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
        // 加载配置
        XmlBeanDefinitionReader reader = new XmlBeanDefinitionReader(beanFactory);
        String location = "classpath:/META-INF/dependency-injection-context.xml";
        int beanDefinitionsCount = reader.loadBeanDefinitions(location);

        System.out.println("Bean 定义加载的数量:"+beanDefinitionsCount);
        lookupCollectionByType(beanFactory);
    }

    private static void lookupCollectionByType(BeanFactory beanFactory) {
        if (beanFactory instanceof ListableBeanFactory) {
            ListableBeanFactory listBeanFactory = (ListableBeanFactory) beanFactory;
            Map<String, User> users = listBeanFactory.getBeansOfType(User.class);
            System.out.println("查找到的所有集合对象---" + users);
        }
    }
}

执行结果

Bean 定义加载的数量:4
查找到的所有集合对象---{user=User{beanName='user', id=1, name='xwf', age=18, configFileReource=class path resource [META-INF/user-config.properties], city=WUHAN, cities=[WUHAN, BEIJING], lifeCities=[WUHAN, BEIJING]}, superUser=SuperUser{address='wuhan'}User{beanName='superUser', id=1, name='xwf', age=18, configFileReource=class path resource [META-INF/user-config.properties], city=WUHAN, cities=[WUHAN, BEIJING], lifeCities=[WUHAN, BEIJING]}}

2.2 注解 配置

这个相关的示例太多了,前面的章节有很多,这里就不演示了。

第六章:Spring Bean 注册、实例化、初始化、销毁 2.1.1 Java 注解配置元信息

2.3 Properties 资源配置

源码位置:org.springframework.beans.factory.support.PropertiesBeanDefinitionReader

Java Doc 中有相应的示例

同样我们在 resource/META-INF 目录下面 新建一个 user.properties 文件

user 相当于 xml 中的 id,user.(class) 相当于 xml 中的 class,以此类推。

user.(class) = com.huajie.thinking.in.spring.ioc.overview.domain.User
user.id = 001
user.name = 小仙
user.city = WUHAN

使用 PropertiesBeanDefinitionReader 来加载这个 properties 文件

/**
 * Bean 生命周期 示例
 */
public class BeanMetadataConfigurationDemo {
    public static void main(String[] args) {
        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();

        PropertiesBeanDefinitionReader reader = new PropertiesBeanDefinitionReader(beanFactory);
        String location = "/META-INF/user.properties";

        Resource resource = new ClassPathResource(location);
        EncodedResource encodedResource = new EncodedResource(resource,"GBK");
        int beanDefinitionsCount = reader.loadBeanDefinitions(encodedResource);
        System.out.println("Bean 定义加载的数量:"+beanDefinitionsCount);
        lookupCollectionByType(beanFactory);
    }

    private static void lookupCollectionByType(BeanFactory beanFactory) {
        if (beanFactory instanceof ListableBeanFactory) {
            ListableBeanFactory listBeanFactory = (ListableBeanFactory) beanFactory;
            Map<String, User> users = listBeanFactory.getBeansOfType(User.class);
            System.out.println("查找到的所有集合对象---" + users);
        }
    }
}

执行结果:

Bean 定义加载的数量:1
查找到的所有集合对象---{user=User{beanName='user', id=1, name='小仙', age=null, configFileReource=null, city=WUHAN, cities=null, lifeCities=null}}

2.4 面向 API

第五章:Spring Bean 定义 4.BeanDefinition 构建 小节

3.Spring Bean 元信息解析阶段

  • 面向资源 BeanDefinition 解析
    • BeanDefinitionReader
    • Xml 解析器 - BeanDefintionParser
  • 面向注解 BeanDefinition 解析
    • AnnotatedBeanDefinitonReader

3.1 面向资源 BeanDefinition 解析

这两种方式就是 2.Spring Bean 元信息配置阶段 中的 xml 和 properties 文件。

3.2 面向注解 BeanDefinition 解析

这里并不是演示 @Component 以及其派生注解的使用,而是演示 AnnotatedBeanDefinitionReader 这类如何解析注册一个类,换言之 Spring 如何使用 AnnotatedBeanDefinitionReader 将标注有 @Component 的类注册为 BeanDefintion 。

/**
 * 注解解析 BeanDefinition 示例
 */
public class AnnotatedBeanDefinitionParserDemo {

    public static void main(String[] args) {
        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
        AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader(beanFactory);
        int beanDefinitionCountBefore = beanFactory.getBeanDefinitionCount();
        //注册当前类(非 @Component class)
        reader.register(AnnotatedBeanDefinitionParserDemo.class);
        reader.register(Test.class);
        int beanDefinitionCountAfter = beanFactory.getBeanDefinitionCount();
        int beanDefinitionCount = beanDefinitionCountAfter - beanDefinitionCountBefore;
        System.out.println("Bean 定义注册的数量:"+beanDefinitionCount);
        //Bean 名称生成来自于 BeanNameGenerator,注册实现 AnnotatedBeanNameGenerator
        AnnotatedBeanDefinitionParserDemo demo = beanFactory.getBean("annotatedBeanDefinitionParserDemo",
                AnnotatedBeanDefinitionParserDemo.class);

        System.out.println(demo);
    }

    public class Test{

    }
}

执行结果

Bean 定义注册的数量:2
com.huajie.thinking.in.spring.bean.lifecycle.AnnotatedBeanDefinitionParserDemo@31f924f5

4.Spring Bean 注册阶段

BeanDefintion 注册接口

  • BeanDefinitionRegistry

唯一实现

org.springframework.beans.factory.support.DefaultListableBeanFactory#registerBeanDefinition

源码较多,这里省略一部分,只对重点代码进行分析

  • beanDefinitionMap 数据结构为 ConcurrentHashMap,没有顺序
  • beanDefinitionNames 数据结构为 ArrayList, 存放 beanName 保证注册的顺序
  • this.beanDefinitionMap.put(beanName, beanDefinition); //将 beanDefinition 存入 beanDefinitionMap
  • removeManualSingletonName,注册的单例对象(并非 Bean Scope)和注册 BeanDefinition 是一个互斥的操作,只能存在一个

省略后部分源码如下:

@Override
	public void registerBeanDefinition(String beanName, BeanDefinition beanDefinition)
			throws BeanDefinitionStoreException {
        ...
        // beanDefinition 效验
		if (beanDefinition instanceof AbstractBeanDefinition) {
			try {
				((AbstractBeanDefinition) beanDefinition).validate();
        ...
        //是否已经存在 BeanDefinition
		BeanDefinition existingDefinition = this.beanDefinitionMap.get(beanName);
		if (existingDefinition != null) {
            //是否允许重复定义 默认是 true 
			if (!isAllowBeanDefinitionOverriding()) {
				throw new BeanDefinitionOverrideException(beanName, beanDefinition, existingDefinition);
			}
         ...
            // 将 beanDefinition 存入 beanDefinitionMap 中
			this.beanDefinitionMap.put(beanName, beanDefinition);
		}
		else {
            //如果 Bean 已经开始创建
			if (hasBeanCreationStarted()) {
				// Cannot modify startup-time collection elements anymore (for stable iteration)
				synchronized (this.beanDefinitionMap) {//加锁保证操作的安全性
                    // 将 beanDefinition 存入 beanDefinitionMap 中
					this.beanDefinitionMap.put(beanName, beanDefinition);
            ...
				}
			}
			else {//正常创建
				// Still in startup registration phase
                // 将 beanDefinition 存入 beanDefinitionMap 中
				this.beanDefinitionMap.put(beanName, beanDefinition);
                //主要是为了注册的顺序
				this.beanDefinitionNames.add(beanName);
                //删除掉注册的单例对象,互斥操作
				removeManualSingletonName(beanName);
			}
       ...
	}

5.Spring BeanDefinition 合并阶段

BeanDefintion 合并

  • 父子 BeanDefinition 合并
    • 当前 BeanFactory 查找
    • 层次性 BeanFactory 查找

5.1 XML 配置

dependency-lookup-context.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        https://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">
    <!--<context:component-scan base-package="com"/>-->
    <!--<context:annotation-config/>-->

    <bean id="user" class="com.huajie.thinking.in.spring.ioc.overview.domain.User">
        <property name="name" value="xwf"/>
        <property name="age" value="18"/>
        <property name="id" value="1"/>
        <property name="configFileReource" value="classpath:/META-INF/user-config.properties"/>
        <property name="city" value="WUHAN"/>
        <property name="cities" value="WUHAN,BEIJING"/>
        <property name="lifeCities" value="WUHAN,BEIJING"/>
    </bean>


    <bean primary="true" id="superUser" class="com.huajie.thinking.in.spring.ioc.overview.domain.SuperUser" parent="user">
        <property name="address" value="wuhan"/>
    </bean>

    <bean id="objectFactory" class="org.springframework.beans.factory.config.ObjectFactoryCreatingFactoryBean">
        <property name="targetBeanName" value="user"/>
    </bean>

</beans>

user 是一个典型的通过 xml 方式配置的 BeanDefinition,user 没有 parent 属性,可以类比 user 是一个 Root BeanDefintion 不需要合并,实际情况还是一个普通的 BeanDefintion。

  • org.springframework.beans.factory.support.RootBeanDefinition

而 superUser 的 parent 属性指向 user,表示 superUser 是一个普通的 BeanDefintion,并且需要合并 user 中的字段属性,这样设计的好处主要是为了优化我们配置的方式。

  • org.springframework.beans.factory.support.GenericBeanDefinition

5.2 示例

/**
 * BeanDefinition 合并示例
 */
public class MergedBeanDefinitionDemo {
    public static void main(String[] args) {
        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
        XmlBeanDefinitionReader reader = new XmlBeanDefinitionReader(beanFactory);
        String location = "classpath:/META-INF/dependency-lookup-context.xml";
        // 加载 XML 资源
        int count = reader.loadBeanDefinitions(location);
        System.out.println(count);
        String[] beanDefinitionNames = beanFactory.getBeanDefinitionNames();
        Stream.of(beanDefinitionNames).forEach(System.out::println);

        User user = beanFactory.getBean("user", User.class);
        SuperUser superUser = beanFactory.getBean("superUser", SuperUser.class);

        System.out.println(user);
        System.out.println(superUser);
    }
}

执行结果:

3
user
superUser
objectFactory
User{beanName='user', id=1, name='xwf', age=18, configFileReource=class path resource [META-INF/user-config.properties], city=WUHAN, cities=[WUHAN, BEIJING], lifeCities=[WUHAN, BEIJING]}
SuperUser{address='wuhan'}User{beanName='superUser', id=1, name='xwf', age=18, configFileReource=class path resource [META-INF/user-config.properties], city=WUHAN, cities=[WUHAN, BEIJING], lifeCities=[WUHAN, BEIJING]}

我们定义了 3 个 BeanDefinition,分别是 user、superUser、objectFactory,可以看到 SuperUser 合并了 User 的属性;通过这个示例我们来调试下源码。

5.3 源码分析

先分析下相关的源码:

org.springframework.beans.factory.config.ConfigurableBeanFactory#getMergedBeanDefinition

	/**
	 * Return a merged BeanDefinition for the given bean name,
	 * merging a child bean definition with its parent if necessary.
	 * Considers bean definitions in ancestor factories as well.
	 * @param beanName the name of the bean to retrieve the merged definition for
	 * @return a (potentially merged) BeanDefinition for the given bean
	 * @throws NoSuchBeanDefinitionException if there is no bean definition with the given name
	 * @since 2.5
	 */
	BeanDefinition getMergedBeanDefinition(String beanName) throws NoSuchBeanDefinitionException;

通过 beanName 返回一个被合并的 BeanDefinition,合并 child bean definition 和它的 parent。

这个接口也是只有一个唯一实现

org.springframework.beans.factory.support.AbstractBeanFactory#getMergedBeanDefinition(java.lang.String)

	@Override
	public BeanDefinition getMergedBeanDefinition(String name) throws BeansException {
		String beanName = transformedBeanName(name);
		// Efficiently check whether bean definition exists in this factory.
		if (!containsBeanDefinition(beanName) && getParentBeanFactory() instanceof ConfigurableBeanFactory) {
			return ((ConfigurableBeanFactory) getParentBeanFactory()).getMergedBeanDefinition(beanName);
		}
		// Resolve merged bean definition locally.
		return getMergedLocalBeanDefinition(beanName);
	}

这是一个递归的方法,如果当前 BeanFactory 不包含这个 beanName 并且 Parent BeanFactory 是 ConfigurableBeanFactory 这个类型,那么继续往下查找,如果有的话在当前的 BeanFactory 中查找。

	protected RootBeanDefinition getMergedLocalBeanDefinition(String beanName) throws BeansException {
		// Quick check on the concurrent map first, with minimal locking.
		RootBeanDefinition mbd = this.mergedBeanDefinitions.get(beanName);
        // 如果 mbd 不为空 并且 没有过期
		if (mbd != null && !mbd.stale) {
			return mbd;
		}
		return getMergedBeanDefinition(beanName, getBeanDefinition(beanName));
	}

在 mergedBeanDefinitions 这个集合中查找,这个集合是合并之后的 BeanDefinition 存放的集合,集合元素类型为 RootBeanDefinition,这里需要注意的是 mergedBeanDefinitions 表示的只是当前 BeanFactory 中的 BeanDefinition,如果有多层 BeanFactory ,每个 BeanFactory 都会有这个 mergedBeanDefinitions 的缓存。

第一次进来肯定是没有的,我们继续往下看,最终到这个方法中,参数中的 containingBd 表示的是嵌套 Bean 的情况,我们这里不讨论。

	protected RootBeanDefinition getMergedBeanDefinition(
			String beanName, BeanDefinition bd, @Nullable BeanDefinition containingBd)
			throws BeanDefinitionStoreException {
        // 下面的操作既有 get 也有 put,需要加锁保证安全性,而且这个方法可能在很多地方调用
		synchronized (this.mergedBeanDefinitions) {
			RootBeanDefinition mbd = null;
			RootBeanDefinition previous = null;

			// Check with full lock now in order to enforce the same merged instance.
            // 如果为空,表示当前的 BeanDefintion 并不是一个嵌套 Bean 而是顶层 Bean
			if (containingBd == null) {
            // 再从 mergedBeanDefinitions 获取,这里主要是防止有其他线程已经添加了这个 BeanDefinition
				mbd = this.mergedBeanDefinitions.get(beanName);
			}

			if (mbd == null || mbd.stale) {
				previous = mbd;
				if (bd.getParentName() == null) {
					// Use copy of given root bean definition.
					if (bd instanceof RootBeanDefinition) {
                        // 如果是 RootBeanDefinition 直接返回
						mbd = ((RootBeanDefinition) bd).cloneBeanDefinition();
					}
					else {
                        // 构建一个 RootBeanDefinition
						mbd = new RootBeanDefinition(bd);
					}
				}
				else {
                    // 示例中 SuperUser 属于这一种情况
					// Child bean definition: needs to be merged with parent.
					BeanDefinition pbd;
					try {
                        // 获取 parent 属性中 BeanName
						String  parentBeanName = transformedBeanName(bd.getParentName());
						// 如果 beanName和parentBeanName不相同
                        if (!beanName.equals(parentBeanName)) {
                            // 获取 parent 的合并之后的 BeanDefintion,因为 parent 有可能也是一个被合并的 BeanDefintion
							pbd = getMergedBeanDefinition(parentBeanName);
						}
						else {// 如果相同的话,去 parent BeanFactory 中做层次性的查找
							BeanFactory parent = getParentBeanFactory();
							if (parent instanceof ConfigurableBeanFactory) {
								pbd = ((ConfigurableBeanFactory) parent).getMergedBeanDefinition(parentBeanName);
							}
               ...
					// Deep copy with overridden values.
					mbd = new RootBeanDefinition(pbd);
					mbd.overrideFrom(bd);
				}
               ...
				if (containingBd == null && isCacheBeanMetadata()) {
                    // 将合并之后的 mbd 存放到 mergedBeanDefinitions 集合中
					this.mergedBeanDefinitions.put(beanName, mbd);
				}
			}
			if (previous != null) {
				copyRelevantMergedBeanDefinitionCaches(previous, mbd);
			}
			return mbd;
		}
	}

5.4 源码调试

局部变量命名说明

  • bd -> BeanDefinition

  • mdb -> MergedBeanDefinition 被合并(Merged)之后的 BeanDefinition

断点打在 AbstractBeanFactory#getMergedBeanDefinition() 1309 行

第一次 user 进来 mergedBeanDefinitions 集合为空,此时参数 beanName = user
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KhCNtov8-1588318851632)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428094109055.png)]
mbd 必然也是为 null,而且 user 没有 parent 属性,所以 bd.getParentName() == null 成立,bd 目前还是普通的 BeanDefinition,
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X4PTy1Mq-1588318851634)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428094325736.png)]
合并完成,将 mdb 存入 mergedBeanDefinitions 集合,最后返回 mdb 对象。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VpuPTnCA-1588318851636)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428094705213.png)]
第二次 superUser 进来,此时 mergedBeanDefinitions 集合中已经存在了user的 RootBeanDefintion 对象。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YWXCXtEQ-1588318851637)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428095029864.png)]
区别在于这次 bd.getParentName() == null 不成立,所以进入下面的逻辑
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0KnlTZbs-1588318851637)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428095137971.png)]
最终获取到 superUser 的 parent BeanDefintion(也就是 user 的 BeanDefintion)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CkI7KkSV-1588318851638)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428095618125.png)]
通过 mbd.overrideFrom(bd) 进行合并操作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t8vCorxg-1588318851639)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428095436266.png)]
再将 superUser 存入到 mergedBeanDefinitions 并返回 superUser 合并之后的 mdb 对象。

5.5 结论

通过上面的调试过程和源码分析可知

  • user 和 superUser 都会经过合并的过程,而且最后都变成了 RootBeanDefintion
  • superUser 合并了 user 的属性

6.Spring Bean Class 加载阶段

ClassLoader 类加载

Java Security 安全控制

ConfigurableBeanFactory 临时 ClassLoader

6.1 源码调试

还是用上面的例子进行,我们将断点打在

org.springframework.beans.factory.support.AbstractBeanFactory#doGetBean 249 行

第一次 user 进来
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nZurHXgJ-1588318851640)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428111408548.png)]
1.中间的一些过程跳过,比如 getSingleton(beanName) 因为我们是通过 XML 的方式配置的 BeanDefinition,并不是通过 registerSingleton 这种方式,所以这一段跳过

2.user 没有 parentBeanFactory,这一段也跳过

3.dependsOn 这种配置方式我们也没有采用,继续跳过

4.我们将断点打在 320 行,因为 BeanDefintion 默认是 singleton 作用域,这个 mbd.isSingleton() 判断成立
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B1EGaltu-1588318851641)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428112036099.png)]
createBean 方法中 resolveBeanClass 方法就是类加载过程

	@Override
	protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
			throws BeanCreationException {
        ...
        // 返回user的Class,之前是字符串类型,处理完之后返回 Class 类型
		Class<?> resolvedClass = resolveBeanClass(mbd, beanName);
	    ...
	}

AbstractBeanFactory#doResolveBeanClass() 1508 行
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fOUcwwlQ-1588318851641)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428114356501.png)]
可以到看到 mbd.getBeanClassName() 的属性的类型是 String 类型,这里还不是真正的 Class。

AbstractBeanDefinition#resolveBeanClass 通过 forName 加载这个 Bean 的 Class,得到这个 resolvedClass 的 Class 并返回。

	public Class<?> resolveBeanClass(@Nullable ClassLoader classLoader) throws ClassNotFoundException {
		String className = getBeanClassName();
		if (className == null) {
			return null;
		}
		Class<?> resolvedClass = ClassUtils.forName(className, classLoader);
		this.beanClass = resolvedClass;
		return resolvedClass;
	}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3m8MNUgx-1588318851642)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200428115158649.png)]

6.2 结论

从源码分析可以看出,其实 Spring BeanDefinition 变成 Class 的过程其实还是通过传统 Java 的 ClassLoader 来进行加载的。

7.参考

  • 极客时间-小马哥《小马哥讲Spring核心编程思想》
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值