0

0

java面试题:你知道什么是循环依赖么?Spring是如何解决循环依赖的?

王林

王林

发布时间:2021-01-08 10:33:06

|

3578人浏览过

|

来源于csdn

转载

java面试题:你知道什么是循环依赖么?Spring是如何解决循环依赖的?

首先向大家介绍下什么是循环依赖。

(学习视频分享:java视频教程

所谓循环依赖就是A依赖B,同时B又依赖A,两者之间的依赖关系形成了一个圆环,一般是由于不正确的编码所导致。Spring只能解决属性循环依赖问题,不能解决构造函数循环依赖问题,因为这个问题无解。

接下来我们首先写一个Demo来演示Spring是如何处理属性循环依赖问题的。

立即学习Java免费学习笔记(深入)”;

Talk is cheap. Show me the code

第一步:定义一个类ComponentA,其有一个私有属性componentB。

package com.tech.ioc;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * @author 君战
 * **/
@Component
public class ComponentA {

	@Autowired
	private ComponentB componentB;

	public void say(){
		componentB.say();
	}

}

第二步:定义一个类ComponentB,其依赖ComponentA。并定义一个say方法便于打印数据。

package com.tech.ioc;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
 * @author 君战
 * **/
@Component
public class ComponentB {

	@Autowired
	private ComponentA componentA;

	public void say(){
		System.out.println("componentA field " + componentA);
		System.out.println(this.getClass().getName() + " -----> say()");
	}

}

第三步:重点,编写一个类-SimpleContainer,模仿Spring底层处理循环依赖。如果理解这个代码,再去看Spring处理循环依赖的逻辑就会很简单。

package com.tech.ioc;

import java.beans.Introspector;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 演示Spring中循环依赖是如何处理的,只是个简版,真实的Spring依赖处理远比这个复杂。
 * 但大体思路都相同。另外这个Demo很多情况都未考虑,例如线程安全问题,仅供参考。
 * @author 君战
 *
 * **/
public class SimpleContainer {

	/***
	 * 用于存放完全初始化好的Bean,Bean处于就绪状态
	 * 这个Map定义和Spring中一级缓存命名一致
	 * */
	private Map singletonObjects = new ConcurrentHashMap<>();

	/***
	 * 用于存放刚创建出来的Bean,其属性还没有处理,因此存放在该缓存中的Bean还不可用。
	 * 这个Map定义和Spring中三级缓存命名一致
	 * */
	private final Map singletonFactories = new HashMap<>(16);


	public static void main(String[] args) {
		SimpleContainer container = new SimpleContainer();
		ComponentA componentA = container.getBean(ComponentA.class);
		componentA.say();
	}

	public  T getBean(Class beanClass) {
		String beanName = this.getBeanName(beanClass);
		// 首先根据beanName从缓存中获取Bean实例
		Object bean = this.getSingleton(beanName);
		if (bean == null) {
			// 如果未获取到Bean实例,则创建Bean实例
			return createBean(beanClass, beanName);
		}
		return (T) bean;
	}
	/***
	 * 从一级缓存和二级缓存中根据beanName来获取Bean实例,可能为空
	 * */
	private Object getSingleton(String beanName) {
		// 首先尝试从一级缓存中获取
		Object instance = singletonObjects.get(beanName);
		if (instance == null) { // Spring 之所以能解决循环依赖问题,也是靠着这个三级缓存--singletonFactories
			instance = singletonFactories.get(beanName);
		}
		return instance;
	}

	/***
	 * 创建指定Class的实例,返回完全状态的Bean(属性可用)
	 *
	 * */
	private  T createBean(Class beanClass, String beanName) {
		try {
			Constructor constructor = beanClass.getDeclaredConstructor();
			T instance = constructor.newInstance();
			// 先将刚创建好的实例存放到三级缓存中,如果没有这一步,Spring 也无法解决三级缓存
			singletonFactories.put(beanName, instance);
			Field[] fields = beanClass.getDeclaredFields();
			for (Field field : fields) {
				Class fieldType = field.getType();
				field.setAccessible(true); 
				// 精髓是这里又调用了getBean方法,例如正在处理ComponentA.componentB属性,
				// 执行到这里时就会去实例化ComponentB。因为在getBean方法首先去查缓存,
				// 而一级缓存和三级缓存中没有ComponentB实例数据,所以又会调用到当前方法,
				// 而在处理ComponentB.componentA属性时,又去调用getBean方法去缓存中查找,
				// 因为在前面我们将ComponentA实例放入到了三级缓存,因此可以找到。
				// 所以ComponentB的实例化结束,方法出栈,返回到实例化ComponentA的方法栈中,
				// 这时ComponentB已经初始化完成,因此ComponentA.componentB属性赋值成功!
				field.set(instance, this.getBean(fieldType));
			}
			// 最后再将初始化好的Bean设置到一级缓存中。
			singletonObjects.put(beanName, instance);
			return instance;
		} catch (Exception e) {
			e.printStackTrace();
		}
		throw new IllegalArgumentException();
	}

	/**
	 * 将类名小写作为beanName,Spring底层实现和这个差不多,也是使用javaBeans的
	 * {@linkplain Introspector#decapitalize(String)}
	 **/
	private String getBeanName(Class clazz) {
		String clazzName = clazz.getName();
		int index = clazzName.lastIndexOf(".");
		String className = clazzName.substring(index);
		return Introspector.decapitalize(className);
	}
}

如果各位同学已经阅读并理解上面的代码,那么接下来我们就进行真实的Spring处理循环依赖问题源码分析,相信再阅读起来就会很容易。

底层源码分析

分析从AbstractBeanFactory的doGetBean方法着手。可以看到在该方法首先调用transformedBeanName(其实就是处理BeanName问题),和我们自己写的getBeanName方法作用是一样的,但Spring考虑的远比这个复杂,因为有FactoryBean、别名问题。

// AbstractBeanFactory#doGetBean
protected  T doGetBean(
			String name, @Nullable Class requiredType, @Nullable Object[] args, boolean typeCheckOnly)
			throws BeansException {

		String beanName = transformedBeanName(name);
		Object bean;

		// !!!重点是这里,首先从缓存中beanName来获取对应的Bean。
		Object sharedInstance = getSingleton(beanName);
		if (sharedInstance != null && args == null) {
			// 执行到这里说明缓存中存在指定beanName的Bean实例,getObjectForBeanInstance是用来处理获取到的Bean是FactoryBean问题
			bean = getObjectForBeanInstance(sharedInstance, name, beanName, null);
		else {
			try {
				// 删除与本次分析无关代码....
				// 如果是单例Bean,则通过调用createBean方法进行创建
				if (mbd.isSingleton()) {
					sharedInstance = getSingleton(beanName, () -> {
						try {
							return createBean(beanName, mbd, args);
						} catch (BeansException ex) {
							destroySingleton(beanName);
							throw ex;
						}
					});
				
				}	
			catch (BeansException ex) {
				cleanupAfterBeanCreationFailure(beanName);
				throw ex;
			}
		}
		return (T) bean;
	}

getSingleton方法存在重载方法,这里调用的是重载的getSingleton方法,注意这里传递的boolean参数值为true,因为该值决定了是否允许曝光早期Bean。

// DefaultSingletonBeanRegistry#getSingleton
public Object getSingleton(String beanName) {
	return getSingleton(beanName, true);
}
// DefaultSingletonBeanRegistry#getSingleton
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
		// 首先从一级缓存中获取
		Object singletonObject = this.singletonObjects.get(beanName);
		if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
			// 如果一级缓存中未获取到,再从二级缓存中获取
			singletonObject = this.earlySingletonObjects.get(beanName);
			// 如果未从二级缓存中获取到并且allowEarlyReference值为true(前面传的为true)
			if (singletonObject == null && allowEarlyReference) {
				synchronized (this.singletonObjects) {
				   //Double Check 
					singletonObject = this.singletonObjects.get(beanName);
					if (singletonObject == null) {
						singletonObject = this.earlySingletonObjects.get(beanName);
						if (singletonObject == null) {
							// 最后尝试去三级缓存中获取
							ObjectFactory singletonFactory = this.singletonFactories.get(beanName);
							if (singletonFactory != null) {
								singletonObject = singletonFactory.getObject();
								// 保存到二级缓存
								this.earlySingletonObjects.put(beanName, singletonObject);
								// 从三级缓存中移除
								this.singletonFactories.remove(beanName);
							}
						}
					}
				}
			}
		}
		return singletonObject;
	}

(更多面试题请访问:java面试题及答案

ok,看完Spring是如何从缓存中获取Bean实例后,那再看看creatBean方法是如何创建Bean的

protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
		throws BeanCreationException {
	// 删除与本次分析无关的代码...
	try {// createBean方法底层是通过调用doCreateBean来完成Bean创建的。
		Object beanInstance = doCreateBean(beanName, mbdToUse, args);
		if (logger.isTraceEnabled()) {
			logger.trace("Finished creating instance of bean '" + beanName + "'");
		}
		return beanInstance;
	} catch (BeanCreationException | ImplicitlyAppearedSingletonException ex) {
		throw ex;
	} catch (Throwable ex) {
		throw new BeanCreationException(
				mbdToUse.getResourceDescription(), beanName, "Unexpected exception during bean creation", ex);
	}
}
// AbstractAutowireCapableBeanFactory#doCreateBean
protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
			throws BeanCreationException {

		BeanWrapper instanceWrapper = null;
		if (mbd.isSingleton()) {
			instanceWrapper = this.factoryBeanInstanceCache.remove(beanName);
		}
		if (instanceWrapper == null) {
			// 创建Bean实例
			instanceWrapper = createBeanInstance(beanName, mbd, args);
		}
		Object bean = instanceWrapper.getWrappedInstance();
		// 如果允许当前Bean早期曝光。只要Bean是单例的并且allowCircularReferences 属性为true(默认为true)
		boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
				isSingletonCurrentlyInCreation(beanName));
		if (earlySingletonExposure) {
			// 这里调用了addSingletonFactory方法将刚创建好的Bean保存到了三级缓存中。
			addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
		}

		// 删除与本次分析无关的代码.....
		Object exposedObject = bean;
		try {// Bean属性填充
			populateBean(beanName, mbd, instanceWrapper);
			// 初始化Bean,熟知的Aware接口、InitializingBean接口.....都是在这里调用
			exposedObject = initializeBean(beanName, exposedObject, mbd);
		} catch (Throwable ex) {
			
		}
		// 删除与本次分析无关的代码.....
		return exposedObject;
	}

先分析addSingletonFactory方法,因为在该方法中将Bean保存到了三级缓存中。

Napkin AI
Napkin AI

Napkin AI 可以将您的文本转换为图表、流程图、信息图、思维导图视觉效果,以便快速有效地分享您的想法。

下载
protected void addSingletonFactory(String beanName, ObjectFactory singletonFactory) {
	Assert.notNull(singletonFactory, "Singleton factory must not be null");
	synchronized (this.singletonObjects) {
		// 如果一级缓存中不存在指定beanName的key
		if (!this.singletonObjects.containsKey(beanName)) {
			// 将刚创建好的Bean实例保存到三级缓存中
			this.singletonFactories.put(beanName, singletonFactory);
			// 从二级缓存中移除。
			this.earlySingletonObjects.remove(beanName);
			this.registeredSingletons.add(beanName);
		}
	}
}

处理Bean的依赖注入是由populateBean方法完成的,但整个执行链路太长了,这里就不展开讲了,只说下IoC容器在处理依赖时是如何一步一步调用到getBean方法的,这样就和我们自己写的处理字段注入的逻辑对上了。

protected void populateBean(String beanName, RootBeanDefinition mbd, @Nullable BeanWrapper bw) {
	// 删除与本次分析无关代码...
	PropertyDescriptor[] filteredPds = null;
	if (hasInstAwareBpps) {
		if (pvs == null) {
			pvs = mbd.getPropertyValues();
		}
		// 遍历所有已注册的BeanPostProcessor接口实现类,如果实现类是InstantiationAwareBeanPostProcessor接口类型的,调用其postProcessProperties方法。
		for (BeanPostProcessor bp : getBeanPostProcessors()) {
			if (bp instanceof InstantiationAwareBeanPostProcessor) {
				InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp;
				PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName);
				// 删除与本次分析无关代码...
				pvs = pvsToUse;
			}
		}
		// 删除与本次分析无关代码...
	}
	
}

在Spring 中,@Autowired注解是由AutowiredAnnotationBeanPostProcessor类处理,而@Resource注解是由CommonAnnotationBeanPostProcessor类处理,这两个类都实现了InstantiationAwareBeanPostProcessor接口,都是在覆写的postProcessProperties方法中完成了依赖注入。这里我们就分析@Autowired注解的处理。

// AutowiredAnnotationBeanPostProcessor#postProcessProperties
public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) {
		// 根据beanName以及bean的class去查找Bean的依赖元数据-InjectionMetadata 
		InjectionMetadata metadata = findAutowiringMetadata(beanName, bean.getClass(), pvs);
		try {// 调用inject方法
			metadata.inject(bean, beanName, pvs);
		} catch (BeanCreationException ex) {
			throw ex;
		} catch (Throwable ex) {
			throw new BeanCreationException(beanName, "Injection of autowired dependencies failed", ex);
		}
		return pvs;
	}

在InjectionMetadata的inject方法中,获取当前Bean所有需要处理的依赖元素(InjectedElement),这是一个集合,遍历该集合,调用每一个依赖注入元素的inject方法。

// InjectionMetadata#inject
public void inject(Object target, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
	// 获取当前Bean所有的依赖注入元素(可能是方法,也可能是字段)
	Collection checkedElements = this.checkedElements;
	Collection elementsToIterate =
			(checkedElements != null ? checkedElements : this.injectedElements);
	if (!elementsToIterate.isEmpty()) {
		// 如果当前Bean的依赖注入项不为空,遍历该依赖注入元素
		for (InjectedElement element : elementsToIterate) {
			// 调用每一个依赖注入元素的inject方法。
			element.inject(target, beanName, pvs);
		}
	}
}

在AutowiredAnnotationBeanPostProcessor类中定义了两个内部类-AutowiredFieldElement、AutowiredMethodElement继承自InjectedElement,它们分别对应字段注入和方法注入。

93dda476df16765e1c25a605f753606.png

以大家常用的字段注入为例,在AutowiredFieldElement的inject方法中,首先判断当前字段是否已经被处理过,如果已经被处理过直接走缓存,否则调用BeanFactory的resolveDependency方法来处理依赖。

// AutowiredAnnotationBeanPostProcessor.AutowiredFieldElement#inject
protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
		Field field = (Field) this.member;
		Object value;
		if (this.cached) {// 如果当前字段已经被处理过,直接从缓存中获取
			value = resolvedCachedArgument(beanName, this.cachedFieldValue);
		} else {
			// 构建依赖描述符
			DependencyDescriptor desc = new DependencyDescriptor(field, this.required);
			desc.setContainingClass(bean.getClass());
			Set autowiredBeanNames = new LinkedHashSet<>(1);
			Assert.state(beanFactory != null, "No BeanFactory available");
			TypeConverter typeConverter = beanFactory.getTypeConverter();
			try {// 调用BeanFactory的resolveDependency来解析依赖
				value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
			} catch (BeansException ex) {
				throw new UnsatisfiedDependencyException(null, beanName, new InjectionPoint(field), ex);
			}
			// 删除与本次分析无关代码....
		}
		if (value != null) {
			// 通过反射来对属性进行赋值
			ReflectionUtils.makeAccessible(field);
			field.set(bean, value);
		}
	}
}

在DefaultListableBeanFactory实现的resolveDependency方法,最终还是调用doResolveDependency方法来完成依赖解析的功能。在Spring源码中,如果存在do什么什么方法,那么该方法才是真正干活的方法。

// DefaultListableBeanFactory#resolveDependency
public Object resolveDependency(DependencyDescriptor descriptor, @Nullable String requestingBeanName,
			@Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
		// .....
		// 如果在字段(方法)上添加了@Lazy注解,那么在这里将不会真正的去解析依赖
		Object result = getAutowireCandidateResolver().getLazyResolutionProxyIfNecessary(
				descriptor, requestingBeanName);
		if (result == null) {
			// 如果未添加@Lazy注解,那么则调用doResolveDependency方法来解析依赖
			result = doResolveDependency(descriptor, requestingBeanName, autowiredBeanNames, typeConverter);
		}
		return result;
}
// DefaultListableBeanFactory#doResolveDependency
public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
			@Nullable Set autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {

	//.....
	try {
		// 根据名称以及类型查找合适的依赖
		Map matchingBeans = findAutowireCandidates(beanName, type, descriptor);
		if (matchingBeans.isEmpty()) {// 如果未找到相关依赖
			if (isRequired(descriptor)) { // 如果该依赖是必须的(例如@Autowired的required属性),直接抛出异常
				raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
			}
			return null;
		}

		String autowiredBeanName;
		Object instanceCandidate;
		// 如果查找到的依赖多于一个,例如某个接口存在多个实现类,并且多个实现类都注册到IoC容器中。
		if (matchingBeans.size() > 1) {// 决定使用哪一个实现类,@Primary等方式都是在这里完成
			autowiredBeanName = determineAutowireCandidate(matchingBeans, descriptor);
			if (autowiredBeanName == null) {
				if (isRequired(descriptor) || !indicatesMultipleBeans(type)) {
					return descriptor.resolveNotUnique(descriptor.getResolvableType(), matchingBeans);
				} else { 
					return null;
				}
			}
			instanceCandidate = matchingBeans.get(autowiredBeanName);
		} else {
			// We have exactly one match.
			Map.Entry entry = matchingBeans.entrySet().iterator().next();
			autowiredBeanName = entry.getKey();
			instanceCandidate = entry.getValue();
		}

		if (autowiredBeanNames != null) {
			autowiredBeanNames.add(autowiredBeanName);
		}
		// 如果查找到的依赖是某个类的Class(通常如此),而不是实例,
		//调用描述符的方法来根据类型resolveCandidate方法来获取该类型的实例。
		if (instanceCandidate instanceof Class) {
			instanceCandidate = descriptor.resolveCandidate(autowiredBeanName, type, this);
		}
		//...
}

在依赖描述符的resolveCandidate方法中,是通过调用BeanFactory 的getBean方法来完成所依赖Bean实例的获取。

// DependencyDescriptor#resolveCandidate
public Object resolveCandidate(String beanName, Class requiredType, BeanFactory beanFactory)
			throws BeansException {

	return beanFactory.getBean(beanName);
}

而在getBean方法实现中,依然是通过调用doGetBean方法来完成。这也和我们自己写的依赖处理基本一致,只不过我们自己写的比较简单,而Spring要考虑和处理的场景复杂,因此代码比较繁杂,但大体思路都是一样的。

// AbstractBeanFactory#getBean
public Object getBean(String name) throws BeansException {
	return doGetBean(name, null, null, false);
}

重点是前面我们写的处理循环依赖的Demo,如果理解那个代码,再看Spring的循环依赖处理,就会发现很简单。

总结:

循环依赖就是指两个Bean之间存在相互引用关系,例如A依赖B,B又依赖A,但Spring只能解决属性循环依赖,不能解决构造函数循环依赖,这种场景也无法解决。

Spring解决循环依赖的关键就是在处理Bean的属性依赖时,先将Bean存到三级缓存中,当存在循环依赖时,从三级缓存中获取到相关Bean,然后从三级缓存中移除,存入到二级缓存中,最后初始化完毕后存入到一级缓存中。

相关推荐:java入门教程

相关文章

java速学教程(入门到精通)
java速学教程(入门到精通)

java怎么学习?java怎么入门?java在哪学?java怎么学才快?不用担心,这里为大家提供了java速学教程(入门到精通),有需要的小伙伴保存下载就能学习啦!

下载

相关标签:

本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn

相关专题

更多
Golang gRPC 服务开发与Protobuf实战
Golang gRPC 服务开发与Protobuf实战

本专题系统讲解 Golang 在 gRPC 服务开发中的完整实践,涵盖 Protobuf 定义与代码生成、gRPC 服务端与客户端实现、流式 RPC(Unary/Server/Client/Bidirectional)、错误处理、拦截器、中间件以及与 HTTP/REST 的对接方案。通过实际案例,帮助学习者掌握 使用 Go 构建高性能、强类型、可扩展的 RPC 服务体系,适用于微服务与内部系统通信场景。

8

2026.01.15

公务员递补名单公布时间 公务员递补要求
公务员递补名单公布时间 公务员递补要求

公务员递补名单公布时间不固定,通常在面试前,由招录单位(如国家知识产权局、海关等)发布,依据是原入围考生放弃资格,会按笔试成绩从高到低递补,递补考生需按公告要求限时确认并提交材料,及时参加面试/体检等后续环节。要求核心是按招录单位公告及时响应、提交材料(确认书、资格复审材料)并准时参加面试。

44

2026.01.15

公务员调剂条件 2026调剂公告时间
公务员调剂条件 2026调剂公告时间

(一)符合拟调剂职位所要求的资格条件。 (二)公共科目笔试成绩同时达到拟调剂职位和原报考职位的合格分数线,且考试类别相同。 拟调剂职位设置了专业科目笔试条件的,专业科目笔试成绩还须同时达到合格分数线,且考试类别相同。 (三)未进入原报考职位面试人员名单。

58

2026.01.15

国考成绩查询入口 国考分数公布时间2026
国考成绩查询入口 国考分数公布时间2026

笔试成绩查询入口已开通,考生可登录国家公务员局中央机关及其直属机构2026年度考试录用公务员专题网站http://bm.scs.gov.cn/pp/gkweb/core/web/ui/business/examResult/written_result.html,查询笔试成绩和合格分数线,点击“笔试成绩查询”按钮,凭借身份证及准考证进行查询。

11

2026.01.15

Java 桌面应用开发(JavaFX 实战)
Java 桌面应用开发(JavaFX 实战)

本专题系统讲解 Java 在桌面应用开发领域的实战应用,重点围绕 JavaFX 框架,涵盖界面布局、控件使用、事件处理、FXML、样式美化(CSS)、多线程与UI响应优化,以及桌面应用的打包与发布。通过完整示例项目,帮助学习者掌握 使用 Java 构建现代化、跨平台桌面应用程序的核心能力。

65

2026.01.14

php与html混编教程大全
php与html混编教程大全

本专题整合了php和html混编相关教程,阅读专题下面的文章了解更多详细内容。

36

2026.01.13

PHP 高性能
PHP 高性能

本专题整合了PHP高性能相关教程大全,阅读专题下面的文章了解更多详细内容。

75

2026.01.13

MySQL数据库报错常见问题及解决方法大全
MySQL数据库报错常见问题及解决方法大全

本专题整合了MySQL数据库报错常见问题及解决方法,阅读专题下面的文章了解更多详细内容。

21

2026.01.13

PHP 文件上传
PHP 文件上传

本专题整合了PHP实现文件上传相关教程,阅读专题下面的文章了解更多详细内容。

35

2026.01.13

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
Kotlin 教程
Kotlin 教程

共23课时 | 2.5万人学习

C# 教程
C# 教程

共94课时 | 6.8万人学习

Java 教程
Java 教程

共578课时 | 46.3万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号