鹅厂在整个互联网大厂中,属于工作环境非常舒适的那种,所以很多小伙伴都很向往~
之前有一位读者从腾讯出来,拿到 80 万的年包,却很不满意,觉得不如当时在鹅厂的 60 万年包。那今天我们就分享一个腾讯的 Java 暑期实习一面面经(收录到了Java面试指南中同学 22 名下),虽然已凉,但可以给大家打个样。
能看得出,面试的题目围绕着二哥一直给大家强调的 Java 后端四大件展开,所以准备秋招或者春招的时候,一定要以这些为主,知道轻重缓急。
对,腾讯也招 Java 的😁,不要以为腾讯只招 Go 和 CPP,大厂的产品线非常多,Java 自然是有一席之地的。所以想冲腾讯暑期实习的小伙伴可以放心冲一波。
腾讯面经
面渣逆袭在线版:javabetter.cn/sidebar/san…
内容较长,撰写硬核面经不容易,建议大家先收藏起来,我会尽量用通俗易懂+手绘图的方式,让大家不仅能背会,还能理解和掌握。
ThreadLocal 什么情况下会内存泄漏
使用 ThreadLocal 发生内存泄露的原因可能是:
①、ThreadLocalMap 的生命周期过长,在使用线程池等长生命周期的线程时,线程不会立即销毁。
如果ThreadLocal
变量在使用后没有被及时清理(通过调用ThreadLocal
的remove()
方法),那么ThreadLocalMap
中的键值对会一直存在,即使外部已经没有对ThreadLocal
对象的引用。
这意味着ThreadLocalMap
中的键值对无法被垃圾收集器回收,从而导致内存泄露。
②、ThreadLocal 对象生命周期结束,线程继续运行。
如果一个ThreadLocal
对象已经不再被使用,但是线程仍然在运行,并且其ThreadLocalMap
中还保留着对这个ThreadLocal
对象的键的引用,这会导致ThreadLocal
对象所引用的数据也无法被回收,因为ThreadLocalMap
中的键是对ThreadLocal
对象的弱引用(WeakReference),但值(存储的数据)是强引用。
Spring AOP实现原理
Spring 的 AOP 是通过动态代理来实现的,动态代理主要有两种方式:JDK 动态代理和 CGLIB 代理。
①、JDK 动态代理是基于接口的代理方式,它使用 Java 原生的 java.lang.reflect.Proxy
类和 java.lang.reflect.InvocationHandler
接口来创建和管理代理对象。
- 基于 Interface:JDK 动态代理要求目标对象必须实现一个或多个接口。代理对象不是直接继承自目标对象,而是实现了与目标对象相同的接口。
- 使用 InvocationHandler:在调用代理对象的任何方法时,调用都会被转发到一个 InvocationHandler 实例的 invoke 方法。可以在这个 invoke 方法中定义拦截逻辑,比如方法调用前后执行的操作。
- 基于 Proxy:Proxy 利用 InvocationHandler 动态创建一个符合目标类实现的接口实例,生成目标类的代理对象。
②、CGLIB(Code Generation Library)是一个第三方代码生成库,它通过继承方式实现代理,不需要接口,被广泛应用于 Spring AOP 中,用于提供方法拦截操作。
- 基于继承,CGLIB 通过在运行时生成目标对象的子类来创建代理对象,并在子类中覆盖非 final 的方法。因此,它不要求目标对象必须实现接口。
- 基于 ASM,ASM 是一个 Java 字节码操作和分析框架,CGLIB 可以通过 ASM 读取目标类的字节码,然后修改字节码生成新的类。它在运行时动态生成一个被代理类的子类,并在子类中覆盖父类的方法,通过方法拦截技术插入增强代码。
选择 CGLIB 还是 JDK 动态代理?
- 如果目标对象没有实现任何接口,则只能使用 CGLIB 代理。如果目标对象实现了接口,通常首选 JDK 动态代理。
- 虽然 CGLIB 在代理类的生成过程中可能消耗更多资源,但在运行时具有较高的性能。对于性能敏感且代理对象创建频率不高的场景,可以考虑使用 CGLIB。
- JDK 动态代理是 Java 原生支持的,不需要额外引入库。而 CGLIB 需要将 CGLIB 库作为依赖加入项目中。
单例模式的好处
单例模式(Singleton Pattern)是一种创建型设计模式,它确保一个类只有一个实例,并提供一个全局访问点来获取该实例。单例模式主要用于控制对某些共享资源的访问,例如配置管理器、连接池、线程池、日志对象等。
使用枚举(Enum)实现单例是最简单的方式,也能防止反射攻击和序列化问题。
public enum Singleton {
INSTANCE;
// 可以添加实例方法
}
单例模式能确保一个类仅有一个实例,并提供一个全局访问点来访问这个实例。
这对于需要控制资源使用或需要共享资源的情况非常有用,比如数据库连接池,通过单例模式,可以避免对资源的重复创建和销毁,从而提高资源利用率和系统性能。
Redis key删除策略
Redis 处理过期数据(即键值对)的回收策略主要有两种:惰性删除和定期删除。
惰性删除
当某个键被访问时,如果发现它已经过期,Redis 会立即删除该键。这意味着如果一个已过期的键从未被访问,它不会被自动删除,可能会占用额外的内存。
定期删除
Redis 会定期随机测试一些键,并删除其中已过期的键。这个过程是 Redis 内部自动执行的,旨在减少过期键对内存的占用。
可以通过 config get hz
命令查看当前的 hz 值。
结果显示 hz 的值为 "10"。这意味着 Redis 服务器每秒执行其内部定时任务(如过期键的清理)的频率是 10 次。
可以通过 CONFIG SET hz 20
进行调整,或者直接通过配置文件中的 hz 设置。
缓存雪崩,如何解决
缓存雪崩是指在某一个时间点,由于大量的缓存数据同时过期或缓存服务器突然宕机了,导致所有的请求都落到了数据库上(比如 MySQL),从而对数据库造成巨大压力,甚至导致数据库崩溃的现象。
总之就是,崩了,崩的非常严重,就叫雪崩了(电影电视里应该看到过,非常夸张)。
如何解决缓存雪崩呢?
第一种:提高缓存可用性
01、集群部署:采用分布式缓存而不是单一缓存服务器,可以降低单点故障的风险。即使某个缓存节点发生故障,其他节点仍然可以提供服务,从而避免对数据库的大量直接访问。
可以利用 Redis Cluster。
或者第三方集群方案 Codis。
02、备份缓存:对于关键数据,除了在主缓存中存储,还可以在备用缓存中保存一份。当主缓存不可用时,可以快速切换到备用缓存,确保系统的稳定性和可用性。
第二种:过期时间
对于缓存数据,设置不同的过期时间,避免大量缓存数据同时过期。可以通过在原有过期时间的基础上添加一个随机值来实现,这样可以分散缓存过期时间,减少同一时间对数据库的访问压力。
第三种:限流和降级
通过设置合理的系统限流策略,如令牌桶或漏斗算法,来控制访问流量,防止在缓存失效时数据库被打垮。
此外,系统可以实现降级策略,在缓存雪崩或系统压力过大时,暂时关闭一些非核心服务,确保核心服务的正常运行。
MySQL 为什么选用 B+树
MySQL 的默认存储引擎是 InnoDB,它采用的是 B+树索引。
那在说 B+树之前,必须得先说一下 B 树(B-tree)。
B 树是一种自平衡的多路查找树,和红黑树、二叉平衡树不同,B 树的每个节点可以有 m 个子节点,而红黑树和二叉平衡树都只有 2 个。
换句话说,红黑树、二叉平衡树是细高个,而 B 树是矮胖子。
好,我继续说。
内存和磁盘在进行 IO 读写的时候,有一个最小的逻辑单元,叫做页(Page),页的大小一般是 4KB。
那为了提高读写效率,从磁盘往内存中读数据的时候,一次会读取至少一页的数据,比如说读取 2KB 的数据,实际上会读取 4KB 的数据;读取 5KB 的数据,实际上会读取 8KB 的数据。我们要尽量减少读写的次数。
因为读的次数越多,效率就越低。就好比我们在工地上搬砖,一次搬 10 块砖肯定比一次搬 1 块砖的效率要高,反正我每次都搬 10 块(😁)。
对于红黑树、二叉平衡树这种细高个来说,每次搬的砖少,因为力气不够嘛,那来回跑的次数就越多。
是这个道理吧,树越高,意味着查找数据时就需要更多的磁盘 IO,因为每一层都可能需要从磁盘加载新的节点。
B 树的节点大小通常与页的大小对齐,这样每次从磁盘加载一个节点时,可以正好是一个页的大小。因为 B 树的节点可以有多个子节点,可以填充更多的信息以达到一页的大小。
B 树的一个节点通常包括三个部分:
- 键值:即表中的主键
- 指针:存储子节点的信息
- 数据:表记录中除主键外的数据
不过,正所谓“祸兮福所倚,福兮祸所伏”,正是因为 B 树的每个节点上都存了数据,就导致每个节点能存储的键值和指针变少了,因为每一页的大小是固定的,对吧?
于是 B+树就来了,B+树的非叶子节点只存储键值,不存储数据,而叶子节点存储了所有的数据,并且构成了一个有序链表。
这样做的好处是,非叶子节点上由于没有存储数据,就可以存储更多的键值对,树就变得更加矮胖了,于是就更有劲了,每次搬的砖也就更多了(😂)。
由此一来,查找数据进行的磁盘 IO 就更少了,查询的效率也就更高了。
再加上叶子节点构成了一个有序链表,范围查询时就可以直接通过叶子节点间的指针顺序访问整个查询范围内的所有记录,而无需对树进行多次遍历。
总结一下,InnoDB 之所以选择 B+树是因为:
- 更高效的磁盘 IO,因为它减少了磁盘寻道时间和页的加载次数。
- 支持范围查询,与 B 树相比,B+树的叶子节点通过指针连接成一个链表,这使得范围查询变得非常高效。在 B+树上执行范围查询可以简单地从范围的起始点开始,然后沿着链表向后遍历,直到结束点。
- 查询性能稳定,B+树的所有查找操作都要查到叶子节点,这使得所有的查询操作都有着相同的访问深度,因此查询性能非常稳定。不像某些其他数据结构,如 B 树,其查询性能因为数据存在所有的节点上导致深度不一致,性能不稳定。
查询优化、联合索引、覆盖索引
我在进行慢SQL 优化的时候,主要通过以下几个方面进行优化:
如何避免不必要的列?
比如说尽量避免使用 select *
,只查询需要的列,减少数据传输量。
SELECT * FROM employees WHERE department_id = 5;
改成:
SELECT employee_id, first_name, last_name FROM employees WHERE department_id = 5;
如何进行分页优化?
当数据量巨大时,传统的LIMIT
和OFFSET
可能会导致性能问题,因为数据库需要扫描OFFSET + LIMIT
数量的行。
延迟关联(Late Row Lookups)和书签(Seek Method)是两种优化分页查询的有效方法。
①、延迟关联
延迟关联适用于需要从多个表中获取数据且主表行数较多的情况。它首先从索引表中检索出需要的行ID,然后再根据这些ID去关联其他的表获取详细信息。
SELECT e.id, e.name, d.details
FROM employees e
JOIN department d ON e.department_id = d.id
ORDER BY e.id
LIMIT 1000, 20;
延迟关联后:
SELECT e.id, e.name, d.details
FROM (
SELECT id
FROM employees
ORDER BY id
LIMIT 1000, 20
) AS sub
JOIN employees e ON sub.id = e.id
JOIN department d ON e.department_id = d.id;
首先对employees
表进行分页查询,仅获取需要的行的ID,然后再根据这些ID关联获取其他信息,减少了不必要的JOIN操作。
②、书签(Seek Method)
书签方法通过记住上一次查询返回的最后一行的某个值,然后下一次查询从这个值开始,避免了扫描大量不需要的行。
假设需要对用户表进行分页,根据用户ID升序排列。
SELECT id, name
FROM users
ORDER BY id
LIMIT 1000, 20;
书签方式:
SELECT id, name
FROM users
WHERE id > last_max_id -- 假设last_max_id是上一页最后一行的ID
ORDER BY id
LIMIT 20;
优化后的查询不再使用OFFSET
,而是直接从上一页最后一个用户的ID开始查询。这里的last_max_id
是上一次查询返回的最后一行的用户ID。这种方法有效避免了不必要的数据扫描,提高了分页查询的效率。
如何进行索引优化?
正确地使用索引可以显著减少 SQL 的查询时间,通常可以从索引覆盖、避免使用 !=
或者 <>
操作符、适当使用前缀索引、避免列上函数运算、正确使用联合索引等方面进行优化。
①、利用覆盖索引
使用非主键索引查询数据时需要回表,但如果索引的叶节点中已经包含要查询的字段,那就不会再回表查询了,这就叫覆盖索引。
举个例子,现在要从 test 表中查询 city 为上海的 name 字段。
select name from test where city='上海'
如果仅在 city 字段上添加索引,那么这条查询语句会先通过索引找到 city 为上海的行,然后再回表查询 name 字段,这就是回表查询。
为了避免回表查询,可以在 city 和 name 字段上建立联合索引,这样查询结果就可以直接从索引中获取。
alter table test add index index1(city,name);
②、避免使用 != 或者 <> 操作符
!=
或者 <>
操作符会导致 MySQL 无法使用索引,从而导致全表扫描。
例如,可以把column<>'aaa'
,改成column>'aaa' or column<'aaa'
,就可以使用索引了。
优化策略就是尽可能使用 =
、>
、<
、BETWEEN
等操作符,它们能够更好地利用索引。
③、适当使用前缀索引
适当使用前缀索引可以降低索引的空间占用,提高索引的查询效率。
比如,邮箱的后缀一般都是固定的@xxx.com
,那么类似这种后面几位为固定值的字段就非常适合定义为前缀索引:
alter table test add index index2(email(6));
需要注意的是,MySQL 无法利用前缀索引做 order by 和 group by 操作。
④、避免列上使用函数
在 where 子句中直接对列使用函数会导致索引失效,因为数据库需要对每行的列应用函数后再进行比较,无法直接利用索引。
select name from test where date_format(create_time,'%Y-%m-%d')='2021-01-01';
可以改成:
select name from test where create_time>='2021-01-01 00:00:00' and create_time<'2021-01-02 00:00:00';
通过日期的范围查询,而不是在列上使用函数,可以利用 create_time 上的索引。
⑤、正确使用联合索引
正确地使用联合索引可以极大地提高查询性能,联合索引的创建应遵循最左前缀原则,即索引的顺序应根据列在查询中的使用频率和重要性来安排。
select * from messages where sender_id=1 and receiver_id=2 and is_read=0;
那就可以为 sender_id、receiver_id 和 is_read 这三个字段创建联合索引,但是要注意索引的顺序,应该按照查询中的字段顺序来创建索引。
alter table messages add index index3(sender_id,receiver_id,is_read);
如何进行 JOIN 优化?
对于 JOIN 操作,可以通过优化子查询、小表驱动大表、适当增加冗余字段、避免 join 太多表等方式来进行优化。
①、优化子查询
子查询,特别是在 select 列表和 where 子句中的子查询,往往会导致性能问题,因为它们可能会为每一行外层查询执行一次子查询。
使用子查询:
select name from A where id in (select id from B);
使用 JOIN 代替子查询:
select A.name from A join B on A.id=B.id;
②、小表驱动大表
在执行 JOIN 操作时,应尽量让行数较少的表(小表)驱动行数较多的表(大表),这样可以减少查询过程中需要处理的数据量。
比如 left join,左表是驱动表,所以 A 表应小于 B 表,这样建立连接的次数就少,查询速度就快了。
select name from A left join B;
③、适当增加冗余字段
在某些情况下,通过在表中适当增加冗余字段来避免 JOIN 操作,可以提高查询效率,尤其是在高频查询的场景下。
比如,我们有一个订单表和一个商品表,查询订单时需要显示商品名称,如果每次都通过 JOIN 操作查询商品表,会降低查询效率。这时可以在订单表中增加一个冗余字段,存储商品名称,这样就可以避免 JOIN 操作。
select order_id,product_name from orders;
④、避免使用 JOIN 关联太多的表
《阿里巴巴 Java 开发手册》上就规定,不要使用 join 关联太多的表,最多不要超过 3 张表。
因为 join 太多表会降低查询的速度,返回的数据量也会变得非常大,不利于后续的处理。
如果业务逻辑允许,可以考虑将复杂的 JOIN 查询分解成多个简单查询,然后在应用层组合这些查询的结果。
如何进行排序优化?
MySQL 生成有序结果的方式有两种:一种是对结果集进行排序操作,另外一种是按照索引顺序扫描得出的自然有序结果。
因此在设计索引的时候要充分考虑到排序的需求。
select id, name from users order by name;
如果 name 字段上有索引,那么 MySQL 可以直接利用索引的有序性,避免排序操作。
如何进行 UNION 优化?
UNION 操作用于合并两个或者多个 SELECT 语句的结果集。
①、条件下推
条件下推是指将 where、limit 等子句下推到 union 的各个子查询中,以便优化器可以充分利用这些条件进行优化。
假设我们有两个查询分支,需要合并结果并过滤:
SELECT * FROM (
SELECT * FROM A
UNION
SELECT * FROM B
) AS sub
WHERE sub.id = 1;
可以改写成:
SELECT * FROM A WHERE id = 1
UNION
SELECT * FROM B WHERE id = 1;
通过将查询条件下推到 UNION 的每个分支中,每个分支查询都只处理满足条件的数据,减少了不必要的数据合并和过滤。
Spring Boot 如何做到启动的时候注入一些 bean
在 Spring Boot 中,开启自动装配的注解是@EnableAutoConfiguration
。
Spring Boot 项目为了进一步简化,直接通过 @SpringBootApplication
注解一步搞定,这个注解包含了 @EnableAutoConfiguration
注解。
①、@EnableAutoConfiguration
只是一个简单的注解,但是它的背后却是一个非常复杂的自动装配机制,它的核心是AutoConfigurationImportSelector
类。
@AutoConfigurationPackage //将main同级的包下的所有组件注册到容器中
@Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
Class<?>[] exclude() default {};
String[] excludeName() default {};
}
②、AutoConfigurationImportSelector
实现了ImportSelector
接口,这个接口的作用就是收集需要导入的配置类,配合@Import()
就将相应的类导入到 Spring 容器中。
③、获取注入类的方法是 selectImports()
,它实际调用的是getAutoConfigurationEntry()
,这个方法是获取自动装配类的关键。
protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
// 检查自动配置是否启用。如果@ConditionalOnClass等条件注解使得自动配置不适用于当前环境,则返回一个空的配置条目。
if (!isEnabled(annotationMetadata)) {
return EMPTY_ENTRY;
}
// 获取启动类上的@EnableAutoConfiguration注解的属性,这可能包括对特定自动配置类的排除。
AnnotationAttributes attributes = getAttributes(annotationMetadata);
// 从spring.factories中获取所有候选的自动配置类。这是通过加载META-INF/spring.factories文件中对应的条目来实现的。
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
// 移除配置列表中的重复项,确保每个自动配置类只被考虑一次。
configurations = removeDuplicates(configurations);
// 根据注解属性解析出需要排除的自动配置类。
Set<String> exclusions = getExclusions(annotationMetadata, attributes);
// 检查排除的类是否存在于候选配置中,如果存在,则抛出异常。
checkExcludedClasses(configurations, exclusions);
// 从候选配置中移除排除的类。
configurations.removeAll(exclusions);
// 应用过滤器进一步筛选自动配置类。过滤器可能基于条件注解如@ConditionalOnBean等来排除特定的配置类。
configurations = getConfigurationClassFilter().filter(configurations);
// 触发自动配置导入事件,允许监听器对自动配置过程进行干预。
fireAutoConfigurationImportEvents(configurations, exclusions);
// 创建并返回一个包含最终确定的自动配置类和排除的配置类的AutoConfigurationEntry对象。
return new AutoConfigurationEntry(configurations, exclusions);
}
画张图来总结下:
Spring Boot 默认的包扫描路径?
Spring Boot 的默认包扫描路径是以启动类 @SpringBootApplication
注解所在的包为根目录的,即默认情况下,Spring Boot 会扫描启动类所在包及其子包下的所有组件。
比如说在技术派实战项目中,启动类QuickForumApplication
所在的包是com.github.paicoding.forum.web
,那么 Spring Boot 默认会扫描com.github.paicoding.forum.web
包及其子包下的所有组件。
@SpringBootApplication
是一个组合注解,它里面的@ComponentScan
注解可以指定要扫描的包路径,默认扫描启动类所在包及其子包下的所有组件。
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
}
比如说带有 @Component
、@Service
、@Controller
、@Repository
等注解的类都会被 Spring Boot 扫描到,并注册到 Spring 容器中。
如果需要自定义包扫描路径,可以在@SpringBootApplication
注解上添加@ComponentScan
注解,指定要扫描的包路径。
@SpringBootApplication
@ComponentScan(basePackages = {"com.github.paicoding.forum"})
public class QuickForumApplication {
public static void main(String[] args) {
SpringApplication.run(QuickForumApplication.class, args);
}
}
这种方式会覆盖默认的包扫描路径,只扫描com.github.paicoding.forum
包及其子包下的所有组件。
参考链接
- 1、星球嘉宾三分恶的面渣逆袭:javabetter.cn/sidebar/san…
- 2、二哥的 Java 进阶之路:javabetter.cn
最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。