@PostConstruct虽好,请勿乱用

news2024/9/28 5:35:04

1.问题说明

在日常的业务开发中,有时会利用@PostConstruct在容器启动时执行一些任务。例如:

@PostConstruct
public void init(){
    System.out.println("service 初始化...............");
}

一般情况这没什么问题,但最近一个同事在做一个数据结转的任务中使用这个注解进行测试的时候却出现了问题,大概的伪代码如下:

@Component
public class TreePrune{
	@PostConstruct
	public void init() {
		System.out.println("初始化开始...............");
		CompletableFuture<Void> voidCompletableFuture =  CompletableFuture.runAsync(this::process);
		try {
    		voidCompletableFuture.get();
		} catch (Exception e) {
   	 		throw new RuntimeException(e);
		}
		System.out.println("初始化成功...............");
}

private void process() {
		SpringContextHolder.getBean(Tree.class).test(null);
	}
}

@Component
public class Tree {
	public TreeNode test(TreeNode root) {
		System.out.println("测试Tree");
		return root;
	}
}

启动项目,控制台输出:

"初始化成功...............

控制台并没有继续输出测试Tree初始化成功...............这两句,看起来程序似乎处于中止的状态,没有继续向下执行。

为了查看线程的执行状态,使用jstack -l pid命令打印堆栈,查看输出的日志,发现线程确实处于BLOCKED状态,而且仔细看堆栈信息的话可以发现是在执行DefaultSingletonBeanRegistry.getSingleton方法时等待获取monitor锁。

在这里插入图片描述

我们先找到相关源码,Spring的版本是5.2.11.RELEASE,在org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:179)

	protected Object getSingleton(String beanName, boolean allowEarlyReference) {
		// Quick check for existing instance without full singleton lock
		Object singletonObject = this.singletonObjects.get(beanName);
		if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
			singletonObject = this.earlySingletonObjects.get(beanName);
			if (singletonObject == null && allowEarlyReference) {
				// singletonObjects就是一个ConcurrentHashMap
				synchronized (this.singletonObjects) {
					// Consistent creation of early reference within full singleton lock
					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;
	}

对Spring创建bean相关源码有一定了解的同学应该对这个方法比较熟悉,Spring在创建bean的时候会先尝试从一级缓存里获取,如果获取到直接返回,如果没有获取到会先获取锁然后继续尝试从二级缓存、三级缓存中获取。CompletableFuture里执行任务的线程在获取singletonObjects对象的monitor锁时被阻塞了也就是说有其它线程已经提前获取了这个锁并且没有释放。根据锁对象的地址0x00000005c4e76198在日志中搜索,果然有发现。

在这里插入图片描述

可以看到持有对象0x00000005c4e76198monitor锁的线程就是main线程,也就是Springboot项目启动的主线程,也就是执行被@PostConstruct修饰的init方法的线程,同时main线程在执行get方法等待获取任务执行结果时切换为WAITING状态。看堆栈的话,main线程是在启动时创建TreePrune对象时获取的锁,相关源码如下,在org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:222):

public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
		Assert.notNull(beanName, "Bean name must not be null");
    	// 获取singletonObjects的monitor锁
		synchronized (this.singletonObjects) {
			Object singletonObject = this.singletonObjects.get(beanName);
			if (singletonObject == null) {
			    ......
				beforeSingletonCreation(beanName);
				boolean newSingleton = false;
				boolean recordSuppressedExceptions = (this.suppressedExceptions == null);
				if (recordSuppressedExceptions) {
					this.suppressedExceptions = new LinkedHashSet<>();
				}
				try {
                    // 创建对象,后续会执行到TreePrune类中的init方法
					singletonObject = singletonFactory.getObject();
					newSingleton = true;
				}
				.......
				if (newSingleton) {
					addSingleton(beanName, singletonObject);
				}
			}
			return singletonObject;
		}
	}

因此,整个流程就是main线程在创建TreePrune对象时,先获取singletonObjectsmonitor锁然后执行到init方法,在init方法里异步开启CompletableFuture任务,使用get方法获取任务结果,在结果返回之前main线程处于WAITING状态,并且不释放锁。与此同时CompletableFuture内的异步线程从容器中获取bean也需要获取singletonObjectsmonitor锁,由于main线程不释放锁,CompletableFuture内的异步线程一直处于BLOCKED状态无法返回结果,get方法也就一直处于WAITING状态,形成了一个类似死锁的局面。

tips:分析stack文件的时候,有一个比较好用的在线工具Online Java Thread Dump Analyzer,它能比较直观的展示锁被哪个线程获取,哪个线程又在等待获取锁。

在这里插入图片描述

2.问题解决

根据上面的分析解决办法也很简单,既然问题是由于main线程在获取锁后一直不释放导致的,而没有释放锁主要是因为一直在get方法处等待,那么只需要从get方法入手即可。

  • 方法一,如果业务允许,干脆不调用get方法获取结果;

  • 方法二,get方法添加等待超时时间,这样其实也无法获取到异步任务执行结果:

    voidCompletableFuture.get(1000L)
    
  • 方法三,get方法放在异步线程执行:

        new Thread(){
            @Override
            public void run(){
                try {
                    voidCompletableFuture.get();
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } 
            }
        }.start();
    
  • 方法四,CompletableFuture里的异步任务改为同步执行

    @PostConstruct
    public void init() {
        System.out.println("初始化开始...............");
        process();
        System.out.println("初始化成功...............");
    }
    

单纯就上面这个伪代码例子来说,除了上面几种方法,其实还有一种方法也可以解决,那就是修改process方法,将手动从容器中获取tree改为自动注入,至于原因将在后文进行分析,可以提示一下与@PostConstruct执行的时机有关。前面的例子之所以要写成手动从容器获取是因为原始代码process方法里是调用Mapper对象操作数据库,为了复现问题做了类似的处理。

@Component
public class TreePrune{
    
    @Autowired
    Tree tree;
    
	@PostConstruct
	public void init() {
		System.out.println("初始化开始...............");
		CompletableFuture<Void> voidCompletableFuture = 				CompletableFuture.runAsync(this::process);
	try {
    	voidCompletableFuture.get();
	} catch (Exception e) {
   	 	throw new RuntimeException(e);
	}
		System.out.println("初始化成功...............");
}

private void process() {
		tree.test(null);
	}
}

@Component
public class Tree {

	public TreeNode test(TreeNode root) {
		System.out.println("测试Tree");
		return root;
	}
}

3.问题拓展

问题看起来是解决了,但对于问题形成的根本原因以及@PostConstruct的原理还没有过多的讲解,下面就简单介绍下。

@PostConstruct注解是在javax.annotation包下的,也就是java拓展包定义的注解,并不是Spring定义的,但Spring对它的功能做了实现。与之类似的还有@PreDestroy@Resource等注解。

package javax.annotation;
....
@Documented
@Retention (RUNTIME)
@Target(METHOD)
public @interface PostConstruct {
}

Spring提供了一个CommonAnnotationBeanPostProcessor来处理这几个注解,看名字就知道这是一个bean的后置处理器,它能介入bean创建过程。

	public CommonAnnotationBeanPostProcessor() {
		setOrder(Ordered.LOWEST_PRECEDENCE - 3);
		setInitAnnotationType(PostConstruct.class);
		setDestroyAnnotationType(PreDestroy.class);
		ignoreResourceType("javax.xml.ws.WebServiceContext");
	}

这个后置处理器会在容器启动时进行注册

		// Check for JSR-250 support, and if present add the CommonAnnotationBeanPostProcessor.
		if (jsr250Present && !registry.containsBeanDefinition(COMMON_ANNOTATION_PROCESSOR_BEAN_NAME)) {
			RootBeanDefinition def = new RootBeanDefinition(CommonAnnotationBeanPostProcessor.class);
			def.setSource(source);
			beanDefs.add(registerPostProcessor(registry, def, COMMON_ANNOTATION_PROCESSOR_BEAN_NAME));
		}

首先我们看Spring创建bean的一个核心方法,只保留一些核心的代码,源码在org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBeann(AbstractAutowireCapableBeanFactory.java:547)。

protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
			throws BeanCreationException {

		// Instantiate the bean.
		BeanWrapper instanceWrapper = null;
		.....
		if (instanceWrapper == null) {
            // 创建对象
			instanceWrapper = createBeanInstance(beanName, mbd, args);
		}
		Object bean = instanceWrapper.getWrappedInstance();
		Class<?> beanType = instanceWrapper.getWrappedClass();
		if (beanType != NullBean.class) {
			mbd.resolvedTargetType = beanType;
		}

		....
		// Initialize the bean instance.
		Object exposedObject = bean;
		try {
            // 注入属性
			populateBean(beanName, mbd, instanceWrapper);
            // 初始化
			exposedObject = initializeBean(beanName, exposedObject, mbd);
		}
		......
            
		return exposedObject;
	}

我们主要看初始化的initializeBean方法

	protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
		if (System.getSecurityManager() != null) {
			AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
				invokeAwareMethods(beanName, bean);
				return null;
			}, getAccessControlContext());
		}
		else {
            // 处理Aware接口
			invokeAwareMethods(beanName, bean);
		}

		Object wrappedBean = bean;
		if (mbd == null || !mbd.isSynthetic()) {
            //后置处理器的before方法
			wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
		}

		try {
            //处理InitializingBean和init-method
			invokeInitMethods(beanName, wrappedBean, mbd);
		}
		catch (Throwable ex) {
			throw new BeanCreationException(
					(mbd != null ? mbd.getResourceDescription() : null),
					beanName, "Invocation of init method failed", ex);
		}
		if (mbd == null || !mbd.isSynthetic()) {
            //后置处理器的after方法
			wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
		}

		return wrappedBean;
	}

	@Override
	public Object applyBeanPostProcessorsBeforeInitialization(Object existingBean, String beanName)
			throws BeansException {

		Object result = existingBean;
        //遍历所有的后置处理器然后执行它的postProcessBeforeInitialization
		for (BeanPostProcessor processor : getBeanPostProcessors()) {
			Object current = processor.postProcessBeforeInitialization(result, beanName);
			if (current == null) {
				return result;
			}
			result = current;
		}
		return result;
	}


protected void invokeInitMethods(String beanName, Object bean, @Nullable RootBeanDefinition mbd)
			throws Throwable {

		boolean isInitializingBean = (bean instanceof InitializingBean);
		if (isInitializingBean && (mbd == null || !mbd.isExternallyManagedInitMethod("afterPropertiesSet"))) {
			if (logger.isTraceEnabled()) {
				logger.trace("Invoking afterPropertiesSet() on bean with name '" + beanName + "'");
			}
			if (System.getSecurityManager() != null) {
				try {
					AccessController.doPrivileged((PrivilegedExceptionAction<Object>) () -> {
						((InitializingBean) bean).afterPropertiesSet();
						return null;
					}, getAccessControlContext());
				}
				catch (PrivilegedActionException pae) {
					throw pae.getException();
				}
			}
			else {
                // 处理处理InitializingBean
				((InitializingBean) bean).afterPropertiesSet();
			}
		}

		if (mbd != null && bean.getClass() != NullBean.class) {
			String initMethodName = mbd.getInitMethodName();
			if (StringUtils.hasLength(initMethodName) &&
					!(isInitializingBean && "afterPropertiesSet".equals(initMethodName)) &&
					!mbd.isExternallyManagedInitMethod(initMethodName)) {
                // 处理init-method方法
				invokeCustomInitMethod(beanName, bean, mbd);
			}
		}
	}

applyBeanPostProcessorsBeforeInitialization方法里会遍历所有的后置处理器然后执行它的postProcessBeforeInitialization,前面说的CommonAnnotationBeanPostProcessor类继承了InitDestroyAnnotationBeanPostProcessor,所以执行的是下面这个方法。

	@Override
	public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        // 查找@PostConstruct、@PreDestroy注解修饰的方法
		LifecycleMetadata metadata = findLifecycleMetadata(bean.getClass());
		try {
            // 通过反射调用
			metadata.invokeInitMethods(bean, beanName);
		}
		catch (InvocationTargetException ex) {
			throw new BeanCreationException(beanName, "Invocation of init method failed", ex.getTargetException());
		}
		catch (Throwable ex) {
			throw new BeanCreationException(beanName, "Failed to invoke init method", ex);
		}
		return bean;
	}

	private LifecycleMetadata findLifecycleMetadata(Class<?> clazz) {
		if (this.lifecycleMetadataCache == null) {
			return buildLifecycleMetadata(clazz);
		}
		// 从缓存里获取
		LifecycleMetadata metadata = this.lifecycleMetadataCache.get(clazz);
		if (metadata == null) {
			synchronized (this.lifecycleMetadataCache) {
				metadata = this.lifecycleMetadataCache.get(clazz);
				if (metadata == null) {
                    // 没有去创建
					metadata = buildLifecycleMetadata(clazz);
					this.lifecycleMetadataCache.put(clazz, metadata);
				}
				return metadata;
			}
		}
		return metadata;
	}

buildLifecycleMetadata方法里,会通过反射去获取方法上有initAnnotationTypedestroyAnnotationType类型方法,而initAnnotationTypedestroyAnnotationType的值就是前面创建CommonAnnotationBeanPostProcessor的构造方法里赋值的,也就是PostConstruct.classPreDestroy.class

	private LifecycleMetadata buildLifecycleMetadata(final Class<?> clazz) {
		if (!AnnotationUtils.isCandidateClass(clazz, Arrays.asList(this.initAnnotationType, this.destroyAnnotationType))) {
			return this.emptyLifecycleMetadata;
		}

		List<LifecycleElement> initMethods = new ArrayList<>();
		List<LifecycleElement> destroyMethods = new ArrayList<>();
		Class<?> targetClass = clazz;

		do {
			final List<LifecycleElement> currInitMethods = new ArrayList<>();
			final List<LifecycleElement> currDestroyMethods = new ArrayList<>();

			ReflectionUtils.doWithLocalMethods(targetClass, method -> {
                //initAnnotationType就是PostConstruct.class
				if (this.initAnnotationType != null && method.isAnnotationPresent(this.initAnnotationType)) {
					LifecycleElement element = new LifecycleElement(method);
					currInitMethods.add(element);
				}
                //destroyAnnotationType就是PreDestroy.class
				if (this.destroyAnnotationType != null && method.isAnnotationPresent(this.destroyAnnotationType)) {
					currDestroyMethods.add(new LifecycleElement(method));
				}
			});

			initMethods.addAll(0, currInitMethods);
			destroyMethods.addAll(currDestroyMethods);
			targetClass = targetClass.getSuperclass();
		}
		while (targetClass != null && targetClass != Object.class);

		return (initMethods.isEmpty() && destroyMethods.isEmpty() ? this.emptyLifecycleMetadata :
				new LifecycleMetadata(clazz, initMethods, destroyMethods));
	}

获取到方法上有initAnnotationTypedestroyAnnotationType类型方法后,后续就是通过反射进行调用,就不赘述了。完整的流程其实还是相对比较简单的,下面有个大致的流程图,感兴趣的同学可以自己打个断点跟着走一走。

在这里插入图片描述

根据源码的执行流程我们可以知道,在一个bean 创建的过程中@PostConstruct的执行在属性注入populateBean方法之后的initializeBean方法即初始化bean的方法中。现在你知道为什么我们前面说将process方法中手动从容器中获取tree改为自动注入也可以解决问题了吗?

改为自动注入后获取tree对象就是在populateBean方法中执行,也就是说是main线程在执行,当它尝试去获取singletonObjectsmonitor锁时,由于Sychronized是可重入锁,它不会被阻塞,等执行到CompletableFuture的异步任务时,由于并不需要去容器中获取bean,也就不会尝试去获取singletonObjectsmonitor锁,即不会被阻塞,那么get方法自然就能获取到结果,程序也就能正常的执行下去。

此外,通过源码我们也可以知道在Bean初始化的执行三种常见方法的执行顺序,即

1.注解@PostConstruct

2.InitializingBean接口的afterPropertiesSet方法

3.<bean>或者@Bean注入bean,它的init-method的属性

4.结论

通过上述的分析,可以做几个简单的结论:

1.@PostConstruct 修饰的方法是在bean初始化的时候执行,并且相比其它初始化方法,它们的顺序是@PostConstruct > InitializingBean > init-method

2.不要在@PostConstruct 中执行耗时任务,它会影响程序的启动速度,如果实在有这样的需求可以考虑异步执行或者使用定时任务。

3.程序中如果有类似future.get获取线程执行结果的代码,尽量使用有超时时间的get方法。

参考:Spring 框架中 @PostConstruct 注解详解

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1235683.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Android加固为何重要?很多人不学

为什么要加固&#xff1f; APP加固是对APP代码逻辑的一种保护。原理是将应用文件进行某种形式的转换&#xff0c;包括不限于隐藏&#xff0c;混淆&#xff0c;加密等操作&#xff0c;进一步保护软件的利益不受损坏。总结主要有以下三方面预期效果&#xff1a; 1.防篡改&#x…

django restful framework序列化与反序列化

在前后端分离开发中&#xff0c;对于RESTfulAPI设置&#xff0c;一般需要将查询/更新数据以JSON方式进行返回。 序列化 Model.py from django.db import models class User(models.Model):username models.CharField(verbose_name用户名,max_length10)age models.IntegerF…

RT-Thread JSN-SR04T

JSN-SR0T4-2.0 超声波测距模块可提供 20cm-600cm 的非接触式距离感测功能&#xff0c;测距精度可达高到 2mm&#xff1b;模块包括收发一体的超声波传感器与控制电路组成。产品采用工业级一体化超声波探头设计&#xff0c;防水型&#xff0c;性能稳定&#xff0c;谦容市场上所有…

手搓js轮播图_JavaScript进阶

手搓js轮播图 逻辑解析html结构图片切换方法圆点导航切换效果左右箭头点击切换圆点导航点击切换自动播放&#xff0c;介入暂停 完整代码 逻辑解析 css的样式我就不再进行讲述&#xff0c;如果有需求可以评论区告诉我&#xff0c;我再出一篇文章进行详细讲解 js轮播图最主要的核…

java算法学习索引之字符串问题

一 判断两个字符串是否互为变形词 【题目】给定两个字符串str1和str2&#xff0c;如果str1和str2中出现的字符种类一样且每种字符出现的次数也一样&#xff0c;那么str1与str2互为变形词。请实现函数判断两个字符串是否互为变形词。 public boolean isDeformation(String str1…

SPSS快速聚类

前言&#xff1a; 本专栏参考教材为《SPSS22.0从入门到精通》&#xff0c;由于软件版本原因&#xff0c;部分内容有所改变&#xff0c;为适应软件版本的变化&#xff0c;特此创作此专栏便于大家学习。本专栏使用软件为&#xff1a;SPSS25.0 本专栏所有的数据文件请点击此链接下…

React结合antd5实现整个表格编辑

通过react hooks 结合antd的table实现整个表格新增编辑。 引入组件依赖 import React, { useState } from react; import { Table, InputNumber, Button, Space, Input } from antd;定义数据 const originData [{ key: 1, name: 白银会员, value: 0, equity: 0, reward: 0…

全球首款容器计算产品重磅发布,激活上云用云新范式

云布道师 10 月 31 日&#xff0c;杭州云栖大会上&#xff0c;阿里云云原生应用平台负责人丁宇宣布&#xff0c;阿里云容器计算服务 ACS 正式发布&#xff01;ACS 将大幅降低企业和开发者用云门槛&#xff0c;真正将 Serverless 理念大规模落地。 容器计算服务 ACS&#xff0c…

零代码编程:用ChatGPT将SRT字幕文件批量转为Word文本文档

一个文件夹中有多个srt视频字幕文件&#xff0c;srt文件里面有很多时间轴&#xff1a; 现在想将其批量转为word文档&#xff0c;去掉里面与字符无关的时间轴&#xff0c;在ChatGPT中输入提示词&#xff1a; 你是一个Python编程专家&#xff0c;要完成一个批量将SRT字幕文件转为…

jbase仪器接口设计

jbase的计划有借助虚拟M来实现连仪器&#xff0c;之前陆续写了些TCP逻辑&#xff0c;今天终于整理完成了仪器设计。首先用java的cs程序测试TCP的服务和客户端。 javafx的示例加强 package sample;import javafx.application.Application; import javafx.event.EventHandler; …

如何给shopify motion主题的产品系列添加description

一、Description是什么 Description是一种HTML标签类型&#xff0c;通过指定Description的内容&#xff0c;可以帮助搜索引擎以及用户更好的理解当前网页包含的主要了内容。 二、Description有什么作用 1、基本作用&#xff0c;对于网站和网页做一个简单的说明。 2、吸引点击&…

部署单仓库多目录项目

部署单仓库多目录项目 文章目录 部署单仓库多目录项目1.部署单仓库多目录项目2.Shell脚本进行部署单仓库多目录项目2.1 编写Shell脚本2.2 Demo推送代码及测试 3.小结 1.部署单仓库多目录项目 #部署单仓库多目录项目 在开发过程中,研发团队往往会将一个大型项目拆分成几个子目录…

Python 安装win32com失败

今天进行服务器迁移&#xff0c; 中间有用的python调用win32com组件让docx转换成pdf。不出意外的话出意外了&#xff0c;pip安装win32com的时候各种安装不上&#xff0c; 今天处理完问题之后&#xff0c;记录一下&#xff0c;与人方便与己方便。 在cmd上面&#xff0c;一开始…

opencv-图像平滑

高斯平滑 高斯平滑即采用高斯卷积核对图像矩阵进行卷积操作。高斯卷积核是一个近似服从高斯分布的矩阵&#xff0c;随着距离中心点的距离增加&#xff0c;其值变小。这样进行平滑处理时&#xff0c;图像矩阵中锚点处像素值权重大&#xff0c;边缘处像素值权重小。 import cv2 …

C语言入门——第十七课

一、二分查询 1.概念 二分查询又被称为二分查找&#xff0c;是一种在有序数组或序列中快速查找到对应元素的一种方法。每次查找范围缩小至原来的一半。 ①前提条件 数组和列表必须有序&#xff0c;否则无法进行二分查找。 ②初始化 确定查找数组和列表的左边界&#xff0…

js ::after简单实战

::after的作用是在元素后面再加个XXX样式 工作中遇到了一个表格&#xff0c;鼠标指到单元格要有个整行编辑态的效果&#xff0c;下面写个简单的demo 有人可能会说了&#xff0c;直接修改某个单元格的hover样式不就行了嘛&#xff0c;问题是如果鼠标指到单元格和单元格直接的…

自己动手打包构建编译cri-dockerd

1.背景 本机是 armv7l架构cpu&#xff0c;发现官方文档中竟然没有&#xff0c;因此需要自己编译下&#xff1b; [rootcontainer0 ~]# uname -a Linux container0 5.4.206-v7l.1.el7 #1 SMP Mon Jul 25 14:13:29 UTC 2022 armv7l armv7l armv7l GNU/Linux2.打包/构建/编译 gi…

Linux—简介安装常用命令系统中软件安装项目部署

目录 1. 前言1.1 什么是Linux1.2 为什么要学Linux1.3 学完Linux能干什么 2. Linux简介2.1 主流操作系统2.2 Linux发展历史2.3 Linux系统版本 3. Linux安装3.1 安装方式介绍3.2 安装VMware3.3 安装Linux3.4 网卡设置3.5 安装SSH连接工具3.5.1 SSH连接工具介绍3.5.2 FinalShell安…

北醒携全球首款256线车规量产激光雷达亮相广州国际车展

11月17日&#xff0c;北醒携全球首款256线车规量产激光雷达亮相广州国际车展。在车展期间&#xff0c;北醒还公布了与广州花都区人民政府达成投资合作&#xff0c;获滴滴自动驾驶投资以及与捷普联合打造的全球首条量产256线级别车规激光雷达的生产线即将贯通的等多条利好信息&a…

如何提高图片转excel的效果?(软件选择篇)

在日常的工作中&#xff0c;我们常常会遇到一些财务报表类的图片需要转换成可编辑的excel&#xff0c;但是&#xff0c;受各种条件的限制&#xff0c;常常只能通过手工录入这种原始的方式来实现&#xff0c;随着人工智能、深度学习以及网络技术的发展&#xff0c;这种原始的录入…