扫码加入

  • 正文
  • 相关推荐
申请入驻 产业图谱

理想汽车今年的薪资来了...

3小时前
152
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

面试刷题网站:xiaolincoding.com

大家好,我是小林。

之前我跟大家盘点 26 届校招薪资的时候,文章里提了小鹏、蔚来这俩车企,但是!我忘了理想汽车了,哈哈,这不赶紧来给大家补上,不能厚此薄彼嘛。

先说句实在的,目前我收集到的26届理想汽车开发岗校招薪资案例,真不多,就下面这几个,大家凑活看,做个参考就行。

    测开:25k x (14-16)开发:25k x (14-16)自驾软开:32k x (14-16)

整体看下来,年薪都能到35w以上,高的也能开到40w+,还行哈。

虽然写的最高能到16薪,但根据最近一两年理想发年终奖的情况,16薪真的很难拿满。之前也有读者给我留言说,“别想16薪了,能拿满14薪都得偷着乐”,这话太真实了。

其实不管哪个公司,年终奖都没法保证一定能拿满,真不是画饼哈。除了跟你自己的绩效有关系,还跟公司当年的市场环境、赚不赚钱挂钩,甚至你所在的部门,给公司创造的价值多不多,都影响年终奖。

说白了就是,公司要是赚翻了,超预期了,那之前跟你谈的年终,大概率能兑现;但要是公司赚钱不及预期,那年终要么减,要么可能就直接没了,这都是很正常的事。

我特意查了下理想最近三年的销量,25年比24年是有下滑的。

所以今年想拿16薪,概率真的不高了。建议大家看理想的offer,就按14薪来算就行,别把预期拉到16薪,不然最后大概率会落空。

还有个事跟说一说,我记得24届校招的时候,理想的开发岗hc给得老多了,当时不少读者都来跟我报喜,说拿到理想的offer了。但今年26届,开发岗的hc感觉就少了很多,我没看到几个人去面理想的开发岗,能拿到offer的就更少了,所以薪资爆料的人也不多。

数据不会说谎嘛,24届之所以招得多,就是因为理想2023年销量爆涨,有业务需求带来的岗位增长,但25年销量不行,所以26届就收缩了,开发岗招得自然就少了。

不过虽然开发岗不如以前能招了,但今年理想在算法岗上,投入是真不小。我看到好多拿到「理想+」招聘计划的算法岗同学,薪资能开到50k-60k,有的还有大几万的签字费,更牛的,能到70k-80k,这算下来,年薪都上百万了!

难怪之前听理想的CEO李想说,理想其实不是汽车企业,是人工智能企业,现在一看,是这么个道理,对算法岗是真舍得花钱。

好了好了,扯远了,回归正题。

咱们这次就来盘一盘理想汽车秋招的Java面经,我看了下,问的八股还挺多的,主要就是JavaSE、Java并发、JVM、设计模式这些知识点,而且问得也不浅,大家拿来查漏补缺,真的特别合适。

理想汽车(Java 校招)

1. Java 的面向接口和多态继承都有啥好处?具体怎么选择?

面向接口和继承其实都能实现多态,但它们的侧重点不太一样。

先说继承的好处。继承最大的优势是可以复用代码,父类已经实现的方法,子类直接就能用,不需要重复写。比如说我有一个Animal类实现了eat、sleep这些通用行为,Dog和Cat继承它之后,这些方法就自动有了,只需要重写run或者叫声这种差异化的方法就行。这种代码复用能省很多事,而且父类的逻辑改了,所有子类自动就跟着改了,维护起来比较方便。

但继承的问题也很明显,就是耦合度太高。子类和父类绑得很死,父类一改,子类可能就得跟着改。而且Java是单继承,一个类只能继承一个父类,如果我想让Dog既有Animal的行为,又有Pet的行为,继承就做不到了。这就是继承的局限性,它适合那种明确的"是一个"关系,比如Dog是一个Animal,这种层级关系很清晰的场景。

面向接口就灵活多了。接口定义的是一种能力或者契约,实现类必须提供这些能力,但具体怎么实现,接口不管。比如我定义一个Flyable接口,里面有个fly方法,Bird可以实现它,Plane也可以实现它,它们的实现完全不一样,但对外都提供飞的能力。接口最大的好处是解耦,调用方只需要知道接口就行,不用关心具体实现是什么。我可以随时换一个实现类,只要它实现了这个接口,调用方的代码不需要改。

而且接口支持多实现,一个类可以实现多个接口。比如Dog可以实现Runnable接口表示能跑,也可以实现Swimmable接口表示能游泳,这就比单继承灵活太多了。这种组合的方式,让我们可以把不同的能力按需组装到一个类上,符合组合优于继承的设计原则。

具体怎么选择呢?我的经验是这样的。

如果是明确的父子关系,而且子类确实需要复用父类的大量实现代码,那就用继承。比如做一个UI框架,BaseActivity里有很多通用的初始化逻辑,所有具体的Activity都继承它,这种场景继承就很合适,能少写很多重复代码。

但如果是定义规范或者能力,或者需要多个实现之间能够灵活替换,那就用接口。比如我们定义一个PaymentService接口,有支付宝支付、微信支付、银行卡支付这些不同的实现类,业务代码只依赖PaymentService接口,具体用哪个实现可以通过配置或者策略模式来切换。这种场景用接口就特别好,扩展性强,改动也小。

一般是能用接口就用接口,只有确实需要复用大量实现代码,并且继承关系很清晰的时候才用继承。

2. Java 的 Map 这种集合有哪些类型,都是怎么用的?

Java的Map实现类其实挺多的,但常用的主要就那么几个,我按使用场景来说吧。

最常用的肯定是HashMap。它是基于哈希表实现的,查询、插入、删除的时间复杂度基本都是O(1),性能很好。HashMap不保证顺序,你put进去的顺序和遍历出来的顺序可能完全不一样。它允许key和value都是null,key只能有一个null,value可以有多个。我平时写代码用得最多的就是HashMap,像缓存数据、做映射关系、统计词频这些场景都很合适。需要注意的是HashMap线程不安全,多线程环境下不能直接用。

如果需要保持插入顺序,就用LinkedHashMap。它在HashMap的基础上,用双向链表把所有entry连起来了,所以遍历的时候是按照插入顺序来的。我一般在需要保持顺序的场景用它,比如实现一个LRU缓存,LinkedHashMap就特别方便,它有个accessOrder参数,设置成true就能按访问顺序排序了。性能上比HashMap稍微差一点点,但也不明显。

如果要按key排序,就用TreeMap。TreeMap底层是红黑树,它会按照key的自然顺序或者自定义的Comparator来排序。查询性能是O(log n),比HashMap慢一些,但它的优势是有序。比如我要统计分数段的人数,或者需要范围查询,用TreeMap就很方便。它的key不能是null,因为要做比较,value可以是null。

多线程环境下,如果需要线程安全的Map,老一点的做法是用Hashtable,但这个现在基本不用了,因为它性能太差,所有方法都加了synchronized,并发度很低。

现在都用ConcurrentHashMap,它是专门为并发设计的。在Java 8之前,它用分段锁来提高并发度,把整个Map分成多个Segment,不同线程可以同时操作不同的段。Java 8之后改成了CAS加synchronized,粒度更细,性能更好。我在做高并发的业务逻辑时,需要共享Map的话,基本都用ConcurrentHashMap。

3. String 都有哪些类型?

主要有String、StringBuilder、StringBuffer 这些类型。

首先说String,它是不可变的,这个特别重要。一旦创建之后,内容就不能改了。比如你写:

String s = "hello"; 
s = s + "world";

这里并不是在原来的字符串上追加,而是创建了一个新的字符串对象,然后把引用指向它。老的"hello"如果没有其他引用,就等着被GC回收了。

String不可变的好处是线程安全,可以在多线程环境下安全共享,而且因为内容不变,它的hashCode可以缓存起来,放在HashMap里做key效率特别高。String还有个字符串常量池的概念,用双引号创建的字符串会放在常量池里,相同内容的字符串会复用同一个对象,节省内存。

但String的问题就是,如果频繁做字符串拼接,会产生大量临时对象。比如在循环里不断拼接字符串,每次拼接都创建新对象,性能会很差,而且给GC造成很大压力。这种场景就不能用String了。

这时候就该用StringBuilder了。StringBuilder是可变的,它内部维护了一个字符数组,做字符串拼接的时候是在原数组上操作,不会创建新对象。比如:

StringBuilder sb = new StringBuilder(); 
sb.append("hello").append("world");,

这样不管append多少次,都是在同一个对象上操作,性能比String高多了。我平时写代码,只要涉及到循环拼接字符串或者需要频繁修改字符串内容,都会用StringBuilder。它的缺点是线程不安全,但单线程环境下完全够用。

StringBuffer和StringBuilder基本一样,也是可变的,区别就是StringBuffer的所有方法都加了synchronized,是线程安全的。但也正因为加了锁,性能比StringBuilder差一些。

现在开发中,单线程环境基本都用StringBuilder,多线程环境如果真的需要共享一个字符串缓冲区,才会用StringBuffer。不过说实话,我工作这么久,真正需要多线程共享字符串缓冲区的场景特别少,大部分时候都是每个线程有自己的StringBuilder,根本不需要StringBuffer。

4. ConcurrentHashMap 的原理是什么?

JDK 1.7 ConcurrentHashMap

在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。

JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。

JDK 1.8 ConcurrentHashMap

在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:

JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:

如果为空则使用  volatile  加  CAS  来初始化

如果容器不为空,则根据存储的元素计算该位置是否为空。

如果根据存储的元素计算结果为空,则利用  CAS  设置该节点;

如果根据存储的元素计算结果不为空,则使用 synchronized  ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。

如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。

而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。

5. 强引用和弱引用有啥区别?

Java里的引用其实有四种,强引用、软引用、弱引用和虚引用,我主要说说最常用的强引用和弱引用吧。

强引用就是我们平时写代码最常见的那种,比如Object obj = new Object();,这个obj就是强引用。只要强引用还在,垃圾回收器就绝对不会回收这个对象,哪怕内存不够了要抛OutOfMemoryError,也不会回收它。这是Java默认的引用方式,也是最普通的。强引用的生命周期就是从创建开始,到引用被置为null或者超出作用域为止。

弱引用就不一样了,它是通过WeakReference类来实现的。弱引用指向的对象,只要发生垃圾回收,不管内存够不够,都会被回收掉。比如说WeakReference<Object> weakRef = new WeakReference<>(new Object());,这个对象在下次GC的时候就会被回收,因为除了这个弱引用,没有其他强引用指向它了。

它们最大的区别就是对垃圾回收的影响。强引用会阻止对象被回收,而弱引用不会。换句话说,弱引用不会阻碍垃圾回收器回收它指向的对象。

6. Java 存在垃圾回收为什么还会有内存泄漏?

首先要理解Java的垃圾回收机制。GC回收的是那些不可达的对象,也就是说从GC Roots出发,通过引用链找不到的对象才会被回收。

但问题就在这儿,如果一个对象你实际上已经不需要了,但它还被某个地方持有着引用,从GC的角度看它就是可达的,GC就不会回收它。这种情况下,对象占着内存却永远不会被使用,这就是内存泄漏

说白了,Java的内存泄漏不是GC不工作,而是你不想要的对象还被引用着,GC根本不知道你不想要它了

我说几个工作中经常遇到的场景吧。最常见的就是静态集合类。比如说你定义了一个static List或者static Map,然后不断往里面add对象,但从来不remove。这些对象因为被静态变量引用着,生命周期和应用程序一样长,永远不会被回收。我之前就遇到过这种情况,有个缓存Map定义成了static,业务量一大,内存就爆了,因为数据只进不出。

没有关闭资源也会导致内存泄漏。像数据库连接、文件流、Socket连接这些,用完了一定要close。如果不关闭,这些对象和它们关联的资源就会一直占着内存。虽然现在有try-with-resources语法,但如果手动管理资源,忘记关闭是很常见的错误。

ThreadLocal使用不当也是个大坑。ThreadLocal的value是强引用,如果用完不调用remove方法,这个value就会一直占着内存,特别是在线程池环境下,线程会复用,ThreadLocal的数据就会累积。我之前排查过一个线上问题,就是因为某个接口用了ThreadLocal存了很大的对象,但没有remove,线程池的线程一直不销毁,内存就慢慢涨上去了。

还有集合里的对象引用。比如说你把对象放进HashSet或者HashMap里,后来修改了对象的属性,导致hashCode变了,但你想从集合里remove的时候,因为hashCode变了,可能remove不掉,这个对象就永远留在集合里了。所以一般作为集合key的对象,我们都要保证它是不可变的。

所以说,Java有GC不代表不会内存泄漏,关键是要理解GC的工作原理,知道什么样的引用关系会导致对象回收不了,然后在写代码的时候多注意,养成良好的编码习惯,这样才能避免内存泄漏问题。

7. 如何快速定位内存泄漏?

定位内存泄漏我一般分三步走。

首先要确认是不是真的泄漏。最简单的方式就是用jstat -gcutil pid 1000命令观察GC情况,如果老年代内存使用率一直涨,执行Full GC后也降不下来,那基本就是有内存泄漏了。或者看监控曲线,内存一直往上走,呈现锯齿状但低点越来越高,这就是典型特征。

确认有泄漏后,第二步就是dump堆快照。用jmap -dump:live,format=b,file=heap.hprof pid命令,这个live参数会先触发Full GC,dump出来的都是真正泄漏的对象。不过注意dump的时候应用会暂停,最好在流量低峰期操作。如果担心OOM突然发生来不及dump,可以提前加JVM参数-XX:+HeapDumpOnOutOfMemoryError,这样OOM时会自动dump。

第三步就是用MAT分析。我一般用Eclipse Memory Analyzer,打开heap.hprof后它会自动给一个Leak Suspects报告,通常能直接定位问题。如果报告不够明确,我会看Histogram视图,找到实例数量特别多或者占用内存特别大的对象,然后右键选择Path to GC Roots,选exclude weak references,这样就能看到是哪些强引用持有了这个对象导致它回收不了。比如说你可能看到某个静态HashMap持有了这些对象,或者被ThreadLocal持有,问题就很清楚了。

还有个实用技巧是对比两次堆快照。间隔一段时间dump两次,用MAT的Compare功能对比,增长最多的对象往往就是泄漏的对象,这对缓慢泄漏的问题特别有效。

如果是本地环境调试,我会直接用JProfiler实时监控,能看到内存实时分配情况和对象引用关系,比dump快照更直观。

基本上这套流程下来,大部分内存泄漏都能定位到。关键就是先确认有泄漏,然后dump堆快照,最后用MAT分析GC Roots引用链,找到是哪里的代码持有了不该持有的引用。我之前遇到的内存泄漏问题,十有八九都是静态集合没清理、ThreadLocal没remove或者监听器没反注册这几种情况,看到引用链基本就能马上判断出来。

8. 熟悉哪些设计模型?

主要熟悉单例模式、工厂模式、策略模式这些。

9. 讲一下工厂设计模式?有什么好处?

工厂模式简单来说就是把对象的创建过程封装起来,客户端不直接new对象,而是通过工厂来获取对象。这个模式主要分三种:简单工厂、工厂方法和抽象工厂,实际工作中我用得最多的是简单工厂和工厂方法。

简单工厂最好理解,就是一个工厂类,里面有个方法根据传入的参数来决定创建哪种对象。比如说我之前做支付系统,有微信支付、支付宝支付、银联支付,我会写个PaymentFactory,传入支付类型,工厂就返回对应的支付处理器。代码大概就是一个switch-case,根据type创建不同的支付对象返回。这种方式很直观,适合对象种类不多的场景。

工厂方法模式稍微复杂一点,它是定义一个工厂接口,然后每种产品有自己对应的工厂实现类。还是支付的例子,我会定义一个PaymentFactory接口,然后有WechatPaymentFactory、AlipayPaymentFactory这些实现类,每个工厂只负责创建自己对应的支付对象。这样做的好处是扩展性更好,如果要新增一种支付方式,只需要加一个新的工厂类,不用修改原来的代码,符合开闭原则。

抽象工厂我用得少一些,它是用来创建一系列相关对象的。比如说UI组件,Windows风格需要WindowsButton和WindowsTextBox,Mac风格需要MacButton和MacTextBox,抽象工厂可以保证创建出来的组件风格是统一的。不过实际业务开发中这种场景不太多,更多是在框架设计里会用到。

工厂模式的好处我觉得主要有这么几点。

首先是解耦,客户端代码不需要知道具体创建的是哪个类,只需要知道接口就行。比如说我调用工厂获取一个支付处理器,我只关心它实现了Payment接口,至于底层是微信还是支付宝,我不需要知道,这样客户端代码和具体实现就解耦了。

第二个好处是便于扩展。如果要新增一种产品类型,用工厂方法模式的话,只需要加一个新的工厂实现类就行,不用改动原有代码。而如果你在客户端代码里直接new对象,那每次新增类型都得修改客户端代码,改的地方可能还挺多,容易出bug。

第三个是统一管理对象创建。有些对象创建过程比较复杂,可能需要读取配置、初始化参数、做一些前置处理,如果这些逻辑散落在各处,维护起来很麻烦。用工厂模式的话,所有创建逻辑都集中在工厂里,改起来也方便,而且可以保证对象创建的一致性。

10. 一般使用注册的回调函数,这种属于什么模式啊?

注册回调函数这种方式,其实就是观察者模式的一种实现形式,也可以说是发布-订阅模式

这个模式的核心思想就是,当某个事件发生时,不需要主动去调用相关的处理逻辑,而是让关心这个事件的对象提前把自己的回调函数注册进来,事件发生时系统自动通知这些注册的回调函数执行。这样就实现了解耦,事件的触发方不需要知道有哪些监听者,监听者也不需要主动去轮询事件是否发生。

11. 多线程有哪些手段?

多线程的实现手段,我在项目中用得比较多的主要有这么几种。

最基础的就是直接继承Thread类或者实现Runnable接口。继承Thread的话重写run方法,然后new一个对象调start就行。不过一般我更推荐实现Runnable接口,因为Java是单继承的,用Runnable的话更灵活,而且可以把任务和线程分离开。实现Callable接口也挺常用,它和Runnable的区别是Callable可以有返回值,还能抛出异常,配合FutureTask可以获取线程执行的结果。但说实话,这种直接new Thread的方式在生产环境用得不多,因为线程创建和销毁的开销挺大的,而且不好管理。

实际工作中用得最多的还是线程池。Java提供的Executors可以创建几种常见的线程池,比如FixedThreadPool固定线程数的、CachedThreadPool缓存线程池、ScheduledThreadPool定时任务线程池。不过阿里的开发手册不建议直接用Executors,因为它底层用的是无界队列,容易造成OOM。所以我一般都是手动创建ThreadPoolExecutor,自己指定核心线程数、最大线程数、队列大小、拒绝策略这些参数,这样可以根据业务场景做精细化控制。比如说IO密集型任务线程数可以设大一点,CPU密集型就设成CPU核数或者核数加一。队列满了之后的拒绝策略也要根据业务来定,是直接抛异常,还是调用者自己执行,还是丢弃最老的任务,这些都要考虑清楚。

12. Future 底层是谁在执行?

Future本身其实只是一个接口,它不执行任何任务,只是用来获取异步任务的执行结果。真正执行任务的是线程池或者说是具体的工作线程

具体来说,当我们把一个Callable任务提交给线程池,比如调用ExecutorService的submit方法,线程池会把这个任务包装成一个FutureTask对象返回给我们,然后线程池内部的工作线程会去执行这个FutureTask。FutureTask既实现了Runnable接口又实现了Future接口,所以它既可以作为任务被线程执行,又可以作为Future来获取结果。

这个执行过程大概是这样的。工作线程从线程池的任务队列里取出FutureTask,然后调用它的run方法开始执行。run方法里会调用我们传入的Callable的call方法,执行完后会把结果存储在FutureTask内部的一个状态变量里。如果我们在主线程调用Future的get方法,这时候会去检查任务的状态,如果任务还没执行完,get方法就会阻塞等待,底层用的是LockSupport的park机制让当前线程挂起。等工作线程执行完任务后,会调用unpark唤醒等待的线程,然后get方法就能返回结果了。

所以简单总结一下,Future只是一个结果的容器和获取接口,真正干活的是线程池里的工作线程。我们提交任务时指定用哪个线程池,那个线程池的线程就负责执行。比如我用的是一个核心线程数是10的线程池,那就是这10个线程在轮流执行任务。

13. 死锁了解过吗?

死锁我还是比较了解的,因为之前项目中真的遇到过,排查起来挺头疼的。

简单来说,死锁就是两个或多个线程互相持有对方需要的资源,然后都在等待对方释放,结果谁也进行不下去,就卡死在那里了。最经典的场景就是线程A持有锁1想要获取锁2,同时线程B持有锁2想要获取锁1,这样两个线程就互相等待,形成死锁。

死锁的产生需要同时满足四个条件。第一是互斥条件,资源不能被多个线程同时使用,必须独占。第二是持有并等待,线程已经持有了至少一个资源,但又在等待获取其他资源。第三是不可剥夺,资源不能被强制从线程手里抢走,只能等它主动释放。第四是循环等待,存在一个线程的等待环路,A等B,B等C,C又等A这种。这四个条件缺一不可,只要破坏其中一个就能预防死锁。

我之前遇到过一次死锁是在处理订单和库存的业务。有个场景需要同时锁定订单和库存,有的代码是先锁订单再锁库存,另外有个地方是先锁库存再锁订单,结果在高并发情况下就死锁了。当时系统突然卡住不动了,所有相关的请求都超时,查日志也看不出什么问题。最后用jstack打印线程堆栈,发现有几个线程状态是BLOCKED,而且明确提示waiting to lock,一看就是死锁了。

定位到问题后解决办法其实挺简单,就是统一加锁顺序。我把所有需要同时获取这两个锁的地方都改成先锁订单再锁库存,保证加锁顺序一致,就不会形成循环等待了。这是破坏死锁的第四个条件。

相关推荐