小公司工作 6 年,后面怎么走?
共 16290字,需浏览 33分钟
·
2024-04-25 14:44
大家好,我是二哥呀。
我们每个人,都在不断和命运抗争着,哪怕有时候我们会不甘心、会堕落、会迷茫、会焦虑,但总有那么一刻,内心是充满渴望的,想要去努力、想要去改变、想要有一番作为(⛽️)。
星球里就有这样一位球友,在小公司工作六年了,平凡而不平庸,他 18 年毕业,参加过 Java 后端的培训,还被骗过去外包。
后来经过自己的努力去了一家做电动自行车相关软件的自研公司,一直工作到现在,目前薪资 22k+,技术上不能说多牛,但能独当一面,像 Spring Cloud、Docker、k8s、Zookeeper、Netty 等等这些技术栈,也都能熟练应用,并且熟知一些底层原理。
他希望“自己能更上一层楼”,最近把简历也发给我帮忙优化,我看完后其实是大受震撼的,因为他的简历内容真的远超我的预期。
给大家简单看一下我优化后的专业技能(我不全贴,免得大家直接照着他的简历抄):
-
1、消息队列:掌握 RocketMQ,如消息丢失、重复消费,顺序消费、回溯消费、死信队列、分布式事务等,同时了解 RabbitMQ。 -
2、ZooKeeper:熟练使用 ZooKeeper,并了解其核心原理,如 Leader 选举、Paxos 算法、ZAB 一致性协议、 Watch 机制等,并有将其应用于分布式锁和服务协调的实战经验。 -
3、Netty:熟悉 Netty,掌握零拷贝、消息拆包粘包处理及心跳机制。 -
4、分布式系统:熟悉分布式系统的常见解决方案:分布式事务使用、缓存和数据库一致性、分布式锁等,了解 CAP、BASE 等算法。
其实对于我们大多数普通人来说,没有一帆风顺、没有平步青云,有的只是走好脚下的每一步路,不焦虑,不气馁,踏踏实实,你的付出也终将会有所回报。
这次我们就以《Java 面试指南》中小公司面经合集同学 1 为例,来看看小公司的面试官都喜欢问哪些问题,好做到知彼知己百战不殆。
可以看得出,仍然是二哥一直强调的 Java 后端四大件,Java 基础(包括 JVM 和并发编程)、Spring 全家桶(包括 Spring Boot)、MySQL 和 Redis,所以准备的时候一定要有的放矢,针对性地去复习,才能事半功倍。
1、二哥的 Linux 速查备忘手册.pdf 下载 2、三分恶面渣逆袭在线版:https://javabetter.cn/sidebar/sanfene/nixi.html 3、24 届春招急救箱更新辣 ✌️
Java 基础(详细)
==和equals()有什么区别?
在 Java 中,==
操作符和 equals()
方法用于比较两个对象:
①、==:用于比较两个对象的引用,即它们是否指向同一个对象实例。
如果两个变量引用同一个对象实例,==
返回 true
,否则返回 false
。
对于基本数据类型(如 int
, double
, char
等),==
比较的是值是否相等。
②、equals() 方法:用于比较两个对象的内容是否相等。默认情况下,equals()
方法的行为与 ==
相同,即比较对象引用,如在超类 Object 中:
public boolean equals(Object obj) {
return (this == obj);
}
然而,equals()
方法通常被各种类重写。例如,String
类重写了 equals()
方法,以便它可以比较两个字符串的字符内容是否完全一样。
举个例子:
String a = new String("沉默王二");
String b = new String("沉默王二");
// 使用 == 比较
System.out.println(a == b); // 输出 false,因为 a 和 b 引用不同的对象
// 使用 equals() 比较
System.out.println(a.equals(b)); // 输出 true,因为 a 和 b 的内容相同
String变量直接赋值和构造方法赋值==比较相等吗?
直接使用双引号为字符串变量赋值时,Java 首先会检查字符串常量池中是否已经存在相同内容的字符串。
如果存在,Java 就会让新的变量引用池中的那个字符串;如果不存在,它会创建一个新的字符串,放入池中,并让变量引用它。
使用 new String("abc")
的方式创建字符串时,实际分为两步:
-
第一步,先检查字符串字面量 "abc" 是否在字符串常量池中,如果没有则创建一个;如果已经存在,则引用它。 -
第二步,在堆中再创建一个新的字符串对象,并将其初始化为字符串常量池中 "abc" 的一个副本。
也就是说:
String s1 = "沉默王二";
String s2 = "沉默王二";
String s3 = new String("沉默王二");
System.out.println(s1 == s2); // 输出 true,因为 s1 和 s2 引用的是字符串常量池中同一个对象。
System.out.println(s1 == s3); // 输出 false,因为 s3 是通过 new 关键字显式创建的,指向堆上不同的对象。
String都有哪些常用方法?
有很多,比如说前面提到到的 equals,hashCode,substring,indexOf 等等。
抽象类和接口有什么区别?
一个类只能继承一个抽象类;但一个类可以实现多个接口。所以我们在新建线程类的时候一般推荐使用实现 Runnable 接口的方式,这样线程类还可以继承其他类,而不单单是 Thread 类。
抽象类符合 is-a 的关系,而接口更像是 has-a 的关系,比如说一个类可以序列化的时候,它只需要实现 Serializable 接口就可以了,不需要去继承一个序列化类。
Java容器有哪些?List、Set还有Map的区别?
Java 集合框架可以分为两条大的支线:
①、Collection,主要由 List、Set、Queue 组成:
-
List 代表有序、可重复的集合,典型代表就是封装了动态数组的 ArrayList 和封装了链表的 LinkedList; -
Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet; -
Queue 代表队列,典型代表就是双端队列 ArrayDeque,以及优先级队列 PriorityQueue。
②、Map,代表键值对的集合,典型代表就是 HashMap。
线程创建的方式?Runable和Callable有什么区别?
Java 中创建线程主要有三种方式,分别为继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。
第一种,继承 Thread 类,重写 run()
方法,调用 start()
方法启动线程。
class ThreadTask extends Thread {
public void run() {
System.out.println("看完二哥的 Java 进阶之路,上岸了!");
}
public static void main(String[] args) {
ThreadTask task = new ThreadTask();
task.start();
}
}
这种方法的缺点是,由于 Java 不支持多重继承,所以如果类已经继承了另一个类,就不能使用这种方法了。
第二种,实现 Runnable 接口,重写 run()
方法,然后创建 Thread 对象,将 Runnable 对象作为参数传递给 Thread 对象,调用 start()
方法启动线程。
class RunnableTask implements Runnable {
public void run() {
System.out.println("看完二哥的 Java 进阶之路,上岸了!");
}
public static void main(String[] args) {
RunnableTask task = new RunnableTask();
Thread thread = new Thread(task);
thread.start();
}
}
这种方法的优点是可以避免 Java 的单继承限制,并且更符合面向对象的编程思想,因为 Runnable 接口将任务代码和线程控制的代码解耦了。
第三种,实现 Callable 接口,重写 call()
方法,然后创建 FutureTask 对象,参数为 Callable 对象;紧接着创建 Thread 对象,参数为 FutureTask 对象,调用 start()
方法启动线程。
class CallableTask implements Callable<String> {
public String call() {
return "看完二哥的 Java 进阶之路,上岸了!";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
CallableTask task = new CallableTask();
FutureTask<String> futureTask = new FutureTask<>(task);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
这种方法的优点是可以获取线程的执行结果。
启动一个线程是run()还是start()?
在 Java 中,启动一个新的线程应该调用其start()
方法,而不是直接调用run()
方法。
当调用start()
方法时,会启动一个新的线程,并让这个新线程调用run()
方法。这样,run()
方法就在新的线程中运行,从而实现多线程并发。
class MyThread extends Thread {
public void run() {
System.out.println(Thread.currentThread().getName());
}
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start(); // 正确的方式,创建一个新线程,并在新线程中执行 run()
t1.run(); // 仅在主线程中执行 run(),没有创建新线程
}
}
如果直接调用run()
方法,那么run()
方法就在当前线程中运行,没有新的线程被创建,也就没有实现多线程的效果。
来看输出结果:
main
Thread-0
也就是说,start()
方法的调用会告诉 JVM 准备好所有必要的新线程结构,分配其所需资源,并调用线程的 run()
方法在这个新线程中执行。
Spring 全家桶
介绍Spring IoC 和 AOP?
所谓的IoC(控制反转,Inversion of Control),就是由容器来控制对象的生命周期和对象之间的关系。以前是我们想要什么就自己创建什么,现在是我们需要什么容器就帮我们送来什么。
也就是说,控制对象生命周期的不再是引用它的对象,而是容器,这就叫控制反转。
talk is cheap,show me the code,我们来看一个例子,没有 IoC 之前:
我需要一个女朋友,刚好大街上突然看到了一个小姐姐,人很好看,于是我就自己主动上去搭讪,要她的微信号,找机会聊天关心她,然后约她出来吃饭,打听她的爱好,三观。。。
有了 IoC 之后:
我需要一个女朋友,于是我就去找婚介所,告诉婚介所,我需要一个长的像赵露思的,会打 Dota2 的,于是婚介所在它的人才库里开始找,找不到它就直接说没有,找到它就直接介绍给我。
婚介所就相当于一个 IoC 容器,我就是一个对象,我需要的女朋友就是另一个对象,我不用关心女朋友是怎么来的,我只需要告诉婚介所我需要什么样的女朋友,婚介所就帮我去找。
Spring 倡导的开发方式就是这样,所有的类创建都通过 Spring 容器来,不再是开发者去 new,去 = null 销毁,这些创建和销毁的工作都交给 Spring 容器来。
于是,对于某个对象来说,以前是它控制它依赖的对象,现在是所有对象都被 Spring 控制,这就是控制反转。
AOP,也就是 Aspect-oriented Programming,译为面向切面编程。
简单点说,就是把一些业务逻辑中的相同代码抽取到一个独立的模块中,让业务逻辑更加清爽。
举个例子,假如我们现在需要在业务代码开始前进行参数校验,在结束后打印日志,该怎么办呢?
我们可以把日志记录
和数据校验
这两个功能抽取出来,形成一个切面,然后在业务代码中引入这个切面,这样就可以实现业务逻辑和通用逻辑的分离。
业务代码不再关心这些通用逻辑,只需要关心自己的业务实现,这样就实现了业务逻辑和通用逻辑的分离。
我们来回顾一下 Java 语言的执行过程:
AOP 的核心是动态代理,可以使用 JDK 动态代理来实现,也可以使用 CGLIB 来实现。
Spring框架使用到的设计模式?
Spring 框架中用了蛮多设计模式的:
①、工厂模式:IoC 容器本身可以看作是一个巨大的工厂,负责创建和管理 Bean 的生命周期和依赖关系。
像 BeanFactory 和 ApplicationContext 接口都提供了工厂模式的实现,负责实例化、配置和组装 Bean。
②、代理模式:AOP 的实现就是基于代理模式的,如果配置了事务管理,Spring 会使用代理模式创建一个连接数据库的代理对象,来进行事务管理。
③、单例模式:Spring 容器中的 Bean 默认都是单例的,这样可以保证 Bean 的唯一性,减少系统开销。
④、模板模式:Spring 中的 JdbcTemplate,HibernateTemplate 等以 Template 结尾的类,都使用了模板方法模式。
比如,我们使用 JdbcTemplate,只需要提供 SQL 语句和需要的参数就可以了,至于如何创建连接、执行 SQL、处理结果集等都由 JdbcTemplate 这个模板方法来完成。
④、观察者模式:Spring 事件驱动模型就是观察者模式很经典的一个应用,Spring 中的 ApplicationListener 就是观察者,当有事件(ApplicationEvent)被发布,ApplicationListener 就能接收到信息。
⑤、适配器模式:Spring MVC 中的 HandlerAdapter 就用了适配器模式。它允许 DispatcherServlet 通过统一的适配器接口与多种类型的请求处理器进行交互。
MyBatis
Mybatis#()和$()有什么区别?
在 MyBatis 中,#{}
和 ${}
是两种不同的占位符,#{}
是预编译处理,${}
是字符串替换。
①、当使用 #{}
时,MyBatis 会在 SQL 执行之前,将占位符替换为问号 ?
,并使用参数值来替代这些问号。
由于 #{}
使用了预处理,它能有效防止 SQL 注入,可以确保参数值在到达数据库之前被正确地处理和转义。
<select id="selectUser" resultType="User">
SELECT * FROM users WHERE id = #{id}
</select>
②、当使用 ${}
时,参数的值会直接替换到 SQL 语句中去,而不会经过预处理。
这就存在 SQL 注入的风险,因为参数值会直接拼接到 SQL 语句中,假如参数值是 1 or 1=1
,那么 SQL 语句就会变成 SELECT * FROM users WHERE id = 1 or 1=1
,这样就会导致查询所有用户的结果。
${}
通常用于那些不能使用预处理的场合,比如说动态表名、列名、排序等,要提前对参数进行安全性校验。
<select id="selectUsersByOrder" resultType="User">
SELECT * FROM users ORDER BY ${columnName} ASC
</select>
MySQL
MySQL的四个隔离级别以及默认隔离级别?
事务的隔离级别定了一个事务可能受其他事务影响的程度,MySQL 支持的四种隔离级别分别是:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。
什么是读未提交?
读未提交是最低的隔离级别,在这个级别,当前事务可以读取未被其他事务提交的数据,以至于会出现“脏读”、“不可重复读”和“幻读”的问题。
什么是读已提交?
当前事务只能读取已经被其他事务提交的数据,可以避免“脏读”现象。但不可重复读和幻读问题仍然存在。
什么是可重复读?
确保在同一事务中多次读取相同记录的结果是一致的,即使其他事务对这条记录进行了修改,也不会影响到当前事务。
是 MySQL 默认的隔离级别,避免了“脏读”和“不可重复读”,也在很大程度上减少了“幻读”问题。
什么是串行化?
最高的隔离级别,通过强制事务串行执行来避免并发问题,可以解决“脏读”、“不可重复读”和“幻读”问题。
但会导致大量的超时和锁竞争问题。
A事务未提交,B事务上查询到的是旧值还是新值?
在 MySQL 的默认隔离级别(可重复读)下,如果事务 A 修改了数据但未提交,事务 B 将看到修改之前的数据。
这是因为在可重复读隔离级别下,MySQL 将通过多版本并发控制(MVCC)机制来保证一个事务不会看到其他事务未提交的数据,从而确保读一致性。
编写SQL语句哪些情况会导致索引失效?
-
在索引列上使用函数或表达式:如果在查询中对索引列使用了函数或表达式,那么索引可能无法使用,因为数据库无法预先计算出函数或表达式的结果。例如: SELECT * FROM table WHERE YEAR(date_column) = 2021
。 -
使用不等于( <>
)或者 NOT 操作符:这些操作符通常会使索引失效,因为它们会扫描全表。 -
使用 LIKE 操作符,但是通配符在最前面:如果 LIKE 的模式串是以“%”或者“_”开头的,那么索引也无法使用。例如: SELECT * FROM table WHERE column LIKE '%abc'
。 -
OR 操作符:如果查询条件中使用了 OR,并且 OR 两边的条件分别涉及不同的索引,那么这些索引可能都无法使用。 -
如果 MySQL 估计使用全表扫描比使用索引更快时(通常是小表或者大部分行都满足 WHERE 子句),也不会使用索引。 -
联合索引不满足最左前缀原则时,索引会失效。
说说 SQL 的隐式数据类型转换?
在 SQL 中,当不同数据类型的值进行运算或比较时,会发生隐式数据类型转换。
比如说,当一个整数和一个浮点数相加时,整数会被转换为浮点数,然后再进行相加。
SELECT 1 + 1.0; -- 结果为 2.0
比如说,当一个字符串和一个整数相加时,字符串会被转换为整数,然后再进行相加。
SELECT '1' + 1; -- 结果为 2
数据类型隐式转换会导致意想不到的结果,所以要尽量避免隐式转换。
可以通过显式转换来规避这种情况。
SELECT CAST('1' AS SIGNED INTEGER) + 1; -- 结果为 2
了解的MVCC吗?
MVCC 是多版本并发控制(Multi-Version Concurrency Control)的简称,主要用来解决数据库并发问题。
在支持 MVCC 的数据库中,当多个用户同时访问数据时,每个用户都可以看到一个在某一时间点之前的数据库快照,并且能够无阻塞地执行查询和修改操作,而不会相互干扰。
在传统的锁机制中,如果一个事务正在写数据,那么其他事务必须等待写事务完成才能读数据,MVCC 允许读操作访问数据的一个旧版本快照,同时写操作创建一个新的版本,这样读写操作就可以并行进行,不必等待对方完成。
在 MySQL 中,特别是 InnoDB 存储引擎,MVCC 是通过版本链和 ReadView 机制来实现的。
什么是版本链?
在 InnoDB 中,每一行数据都有两个隐藏的列:一个是 DB_TRX_ID,另一个是 DB_ROLL_PTR。
-
DB_TRX_ID
,保存创建这个版本的事务 ID。 -
DB_ROLL_PTR
,指向 undo 日志记录的指针,这个记录包含了该行的前一个版本的信息。通过这个指针,可以访问到该行数据的历史版本。
假设有一张hero
表,表中有一行记录 name 为张三,city 为帝都,插入这行记录的事务 id 是 80。此时,DB_TRX_ID
的值就是 80,DB_ROLL_PTR
的值就是指向这条 insert undo 日志的指针。
接下来,如果有两个DB_TRX_ID
分别为100
、200
的事务对这条记录进行了update
操作,那么这条记录的版本链就会变成下面这样:
当事务更新一行数据时,InnoDB 不会直接覆盖原有数据,而是创建一个新的数据版本,并更新 DB_TRX_ID 和 DB_ROLL_PTR,使得它们指向前一个版本和相关的 undo 日志。这样,老版本的数据不会丢失,可以通过版本链找到。
由于 undo 日志会记录每一次的 update,并且新插入的行数据会记录上一条 undo 日志的指针,所以可以通过这个指针找到上一条记录,这样就形成了一个版本链。
说说什么是 ReadView?
ReadView(读视图)是 InnoDB 为了实现一致性读(Consistent Read)而创建的数据结构,它用于确定在特定事务中哪些版本的行记录是可见的。
ReadView 主要用来处理隔离级别为"可重复读"(REPEATABLE READ)和"读已提交"(READ COMMITTED)的情况。因为在这两个隔离级别下,事务在读取数据时,需要保证读取到的数据是一致的,即读取到的数据是在事务开始时的一个快照。
当事务开始执行时,InnoDB 会为该事务创建一个 ReadView,这个 ReadView 会记录 4 个重要的信息:
-
creator_trx_id:创建该 ReadView 的事务 ID。 -
m_ids:所有活跃事务的 ID 列表,活跃事务是指那些已经开始但尚未提交的事务。 -
min_trx_id:所有活跃事务中最小的事务 ID。它是 m_ids 数组中最小的事务 ID。 -
max_trx_id :事务 ID 的最大值加一。换句话说,它是下一个将要生成的事务 ID。
乐观锁和悲观锁,库存的超卖问题的原因和解决方案?
①、乐观锁
乐观锁基于这样的假设:冲突在系统中出现的频率较低,因此在数据库事务执行过程中,不会频繁地去锁定资源。相反,它在提交更新的时候才检查是否有其他事务已经修改了数据。
可以通过在数据表中使用版本号(Version)或时间戳(Timestamp)来实现,每次读取记录时,同时获取版本号或时间戳,更新时检查版本号或时间戳是否发生变化。
如果没有变化,则执行更新并增加版本号或更新时间戳;如果检测到冲突(即版本号或时间戳与之前读取的不同),则拒绝更新。
②、悲观锁
悲观锁假设冲突是常见的,因此在数据处理过程中,它会主动锁定数据,防止其他事务进行修改。
可以直接使用数据库的锁机制,如行锁或表锁,来锁定被访问的数据。常见的实现是 SELECT FOR UPDATE
语句,它在读取数据时就加上了锁,直到当前事务提交或回滚后才释放。
如何解决库存超卖问题?
按照乐观锁的方式:
UPDATE inventory SET count = count - 1, version = version + 1 WHERE product_id = 1 AND version = current_version;
按照悲观锁的方式:
在事务开始时直接锁定库存记录,直到事务结束。
START TRANSACTION;
SELECT * FROM inventory WHERE product_id = 1 FOR UPDATE;
UPDATE inventory SET count = count - 1 WHERE product_id = 1;
COMMIT;
Redis
Redisson的底层原理?以及与SETNX的区别?
Redisson 是一个基于 Redis 的 Java 驻内存数据网格(In-Memory Data Grid),提供了一系列 API 用来操作 Redis,其中最常用的功能就是分布式锁。
RLock lock = redisson.getLock("lock");
lock.lock();
try {
// do something
} finally {
lock.unlock();
}
普通锁的实现源码是在 RedissonLock 类中,也是通过 Lua 脚本封装一些 Redis 命令来实现的的,比如说 tryLockInnerAsync 源码:
其中 hincrby 命令用于对哈希表中的字段值执行自增操作,pexpire 命令用于设置键的过期时间。比 SETNX 更优雅。
Redis的持久化方式?RDB和AOF的区别?
Redis 支持两种主要的持久化方式:RDB(Redis DataBase)持久化和 AOF(Append Only File)持久化。这两种方式可以单独使用,也可以同时使用。
RDB 是一个非常紧凑的单文件(二进制文件 dump.rdb),代表了 Redis 在某个时间点上的数据快照。非常适合用于备份数据,比如在夜间进行备份,然后将 RDB 文件复制到远程服务器。但可能会丢失最后一次持久化后的数据。
AOF 的最大优点是灵活,实时性好,可以设置不同的 fsync 策略,如每秒同步一次,每次写入命令就同步,或者完全由操作系统来决定何时同步。但 AOF 文件往往比较大,恢复速度慢,因为它记录了每个写操作。
Redis宕机哪种恢复的比较快?
在 Redis 4.0 版本中,混合持久化模式会在 AOF 重写的时候同时生成一份 RDB 快照,然后将这份快照作为 AOF 文件的一部分,最后再附加新的写入命令。
这样,当需要恢复数据时,Redis 先加载 RDB 文件来恢复到快照时刻的状态,然后应用 RDB 之后记录的 AOF 命令来恢复之后的数据更改,既快又可靠。
参考链接
-
三分恶的面渣逆袭:https://javabetter.cn/sidebar/sanfene/nixi.html -
二哥的 Java 进阶之路:https://javabetter.cn
ending
一个人可以走得很快,但一群人才能走得更远。二哥的编程星球已经有 5100 多名球友加入了,如果你也需要一个良好的学习环境,戳链接 🔗 加入我们吧。这是一个编程学习指南 + Java 项目实战 + LeetCode 刷题的私密圈子,你可以阅读星球专栏、向二哥提问、帮你制定学习计划、和球友一起打卡成长。
两个置顶帖「球友必看」和「知识图谱」里已经沉淀了非常多优质的学习资源,相信能帮助你走的更快、更稳、更远。
欢迎点击左下角阅读原文了解二哥的编程星球,这可能是你学习求职路上最有含金量的一次点击。
最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。