1222面经
1,Kafka 如何保障顺序消费?
Kafka 保障顺序消费主要通过以下几个关键机制和配置来实现:
分区策略
- Kafka 将主题划分为多个分区,每个分区内的消息是天然有序的,其按照消息发送到分区的先后顺序进行存储和追加。
- 生产者在发送消息时,可以指定消息要发送到的分区。如果不指定,Kafka 会根据默认的分区策略进行分配。例如,按照轮询的方式将消息均匀分配到各个分区,以确保每个分区的负载相对均衡。
消费者配置
- 单消费者实例按分区顺序消费:在消费者端,一个消费者实例可以同时订阅多个分区。当消费者拉取消息时,会按照分区内的顺序依次获取消息进行消费,从而保证了在单个分区内的消息顺序性。
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.util.Arrays;
import java.util.Properties;
public class KafkaConsumerExample {
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("test-topic"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (var record : records) {
System.out.printf("Received message: key = %s, value = %s, partition = %d, offset = %d\n",
record.key(), record.value(), record.partition(), record.offset());
}
}
}
}
- 多消费者实例的顺序协调:当一个消费者组中有多个消费者实例时,Kafka 会通过协调机制确保每个分区只会被组内的一个消费者实例消费,避免多个消费者同时消费同一个分区导致的顺序混乱问题。
消息确认机制
- Kafka 采用的是基于偏移量(offset)的消息确认机制。消费者在成功消费一条消息后,会向 Kafka 提交该消息的偏移量,表示这条消息已经被正确处理。
- 只有当消费者提交了偏移量,Kafka 才会认为该消息已经被成功消费,并且后续不会再次将该消息发送给消费者。这种机制确保了消息不会被重复消费,同时也保证了消息消费的顺序性。
幂等性和事务支持
- 幂等性:Kafka 生产者支持幂等性写入,即无论消息发送多少次,其在分区中的最终状态都是相同的。这对于保障消息顺序消费非常重要,因为它避免了因消息重复发送而导致的顺序混乱问题。
- 事务支持:Kafka 还提供了事务机制,允许生产者在一个事务中将多条消息发送到多个分区,并且保证这些消息要么全部成功提交,要么全部回滚。
2,秒杀场景,如何设计一个秒杀功能?
秒杀场景通常具有高并发、瞬时流量大等特点,设计一个秒杀功能需要从多个方面综合考虑,以下是一个较为全面的设计方案:
前端设计
- 静态资源优化:将秒杀页面的 HTML、CSS、JavaScript 等静态资源进行优化,如压缩、合并、缓存等,减少页面加载时间,提高用户体验。
- 防刷机制:在前端通过验证码、滑块验证等方式,增加机器人刷请求的难度,一定程度上过滤掉非法请求。
后端设计
- 库存管理
- 预扣库存:当用户发起秒杀请求时,先在缓存中预扣库存,而不是直接操作数据库。这样可以快速响应请求,减少数据库的压力。
- 库存扣减:采用乐观锁或悲观锁来保证库存扣减的原子性和一致性。例如,使用乐观锁时,在更新库存时判断当前库存是否大于等于预扣的数量,如果是则扣减库存,否则回滚事务并返回库存不足的提示。
public class SeckillServiceImpl implements SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private SeckillMapper seckillMapper;
@Override
@Transactional
public boolean seckill(Long seckillId, Long userId) {
// 从缓存中获取库存
String stockKey = "seckill:stock:" + seckillId;
Integer stock = (Integer) redisTemplate.opsForValue().get(stockKey);
if (stock == null || stock <= 0) {
return false;
}
// 预扣库存,在缓存中减1
redisTemplate.opsForValue().decrement(stockKey);
try {
// 扣减数据库库存
int result = seckillMapper.reduceStockByOptimisticLock(seckillId);
if (result > 0) {
// 生成订单等后续操作
createOrder(seckillId, userId);
return true;
} else {
// 库存扣减失败,回滚缓存中的预扣库存
redisTemplate.opsForValue().increment(stockKey);
return false;
}
} catch (Exception e) {
// 发生异常,回滚缓存中的预扣库存
redisTemplate.opsForValue().increment(stockKey);
throw new RuntimeException("秒杀失败", e);
}
}
}
- 请求限流
- 令牌桶算法:使用令牌桶算法来限制进入秒杀系统的请求流量。系统按照一定的速率生成令牌放入令牌桶中,每个请求需要获取一个令牌才能继续处理,当令牌桶中没有令牌时,请求将被拒绝。
- 漏桶算法:漏桶算法也可用于请求限流,请求以任意速率进入漏桶,漏桶以固定的速率将请求流出进行处理,当漏桶满时,新的请求将被丢弃。
- 异步处理
- 消息队列:将秒杀成功的订单生成等后续操作放入消息队列中异步处理,这样可以快速响应前端请求,提高系统的并发处理能力。
- 定时任务:对于一些需要定时执行的任务,如库存补充、订单状态更新等,可以使用定时任务来完成,避免对秒杀主流程的影响。
- 分布式事务:在秒杀过程中,如果涉及到多个数据库操作或跨系统调用,需要使用分布式事务来保证数据的一致性。可以采用 Seata 等分布式事务框架来实现。
数据存储设计
- 数据库设计:设计合理的数据库表结构,如秒杀商品表、订单表、用户表等,确保数据的完整性和一致性。
- 缓存设计:使用 Redis 等缓存数据库来存储热门商品信息、库存信息等,提高数据的读写速度。
高可用设计
- 集群部署:采用集群部署的方式,将秒杀系统部署在多个服务器上,通过负载均衡器将请求分发到不同的服务器上,提高系统的可用性和并发处理能力。
- 容灾备份:定期对数据库和缓存进行备份,当出现故障时能够快速恢复数据,减少损失。
3,Redis 持久化机制是什么?
Redis 提供了两种持久化机制,即 RDB(Redis Database)持久化和 AOF(Append Only File)持久化,它们可以将内存中的数据保存到磁盘上,以防止数据丢失,以下是具体介绍:
RDB 持久化
- 原理:RDB 持久化是通过对 Redis 中的数据进行定期的快照来实现的。在指定的时间间隔内,Redis 会将内存中的数据集快照写入到磁盘上的一个 RDB 文件中。这个过程是通过 fork 一个子进程来完成的,子进程负责将内存中的数据以二进制的形式写入到临时文件,然后替换原有的 RDB 文件,从而实现数据的持久化。
- 优点
- 高效:RDB 文件是一个经过压缩的二进制文件,存储效率高,恢复数据时速度也非常快。
- 适合备份:由于是对整个数据集的快照,因此非常适合用于数据备份和灾难恢复场景。
- 缺点
- 数据丢失风险:如果在两次快照之间 Redis 发生故障,那么这期间的数据将会丢失。
- 占用内存:在进行快照时,需要 fork 子进程,会占用一定的内存空间,可能会对性能产生一定的影响。
AOF 持久化
- 原理:AOF 持久化以日志的形式记录 Redis 服务器所执行的所有写命令,将这些命令追加到一个 AOF 文件的末尾。当 Redis 需要恢复数据时,会重新执行 AOF 文件中的所有写命令,从而将数据恢复到内存中。
- 优点
- 数据安全性高:由于是记录每一条写命令,因此数据的完整性和一致性更好,丢失数据的风险相对较小。
- 实时性好:可以通过配置将 AOF 文件的同步策略设置为每执行一条写命令就同步到磁盘,从而实现数据的实时持久化。
- 缺点
- 文件体积大:随着时间的推移,AOF 文件会变得越来越大,需要定期进行重写来压缩文件体积。
- 恢复速度慢:在恢复数据时,需要重新执行 AOF 文件中的所有写命令,因此恢复速度相对较慢。
混合持久化
- 原理:混合持久化结合了 RDB 和 AOF 两种持久化方式的优点。在开启混合持久化后,Redis 会以 RDB 的方式进行数据快照,同时将从上次 RDB 快照之后到当前时刻的所有写命令以 AOF 的方式追加到文件中。
- 优点
- 兼顾效率与安全:在数据恢复时,首先加载 RDB 文件,然后再重放 AOF 文件中的增量写命令,这样既可以快速恢复大部分数据,又可以保证数据的完整性。
- 减小 AOF 文件体积:相比单纯的 AOF 持久化,混合持久化可以有效减小 AOF 文件的体积,提高了文件的读写效率。
4,解决 Redis 热点 Key 问题的方法有哪些?
Redis 热点 Key 是指在 Redis 中,某些特定的 Key 在一段时间内被大量的请求频繁访问,导致该 Key 所在的 Redis 节点负载过高,可能会影响整个系统的性能和稳定性。以下是一些解决 Redis 热点 Key 问题的方法:
优化 Key 的设计
- 分散热点:将热点数据分散到多个不同的 Key 中,避免所有请求都集中在一个 Key 上。例如,对于一个热门商品的库存 Key,可以按照一定的规则将其拆分为多个子 Key,如 “product:stock:1”“product:stock:2” 等,不同的请求可以访问不同的子 Key。
- 添加前缀或后缀:在 Key 的命名上添加一些随机的前缀或后缀,使得请求能够均匀地分布在不同的 Key 上。比如,对于用户的订单 Key,可以在订单号的基础上添加一个随机的字符串作为前缀,如 “order_abc123_123456”。
本地缓存
- 客户端缓存:在应用程序的客户端本地缓存热点 Key 的数据,当客户端再次需要访问该热点 Key 时,首先从本地缓存中获取数据,如果本地缓存中有,则直接返回,无需再向 Redis 发送请求。
- 应用层缓存:在应用层中增加一层缓存,如使用 Guava Cache 等本地缓存框架,将热点 Key 的数据缓存到应用层。当有请求访问热点 Key 时,先从应用层缓存中获取数据,命中则直接返回,未命中再去 Redis 中获取,并将获取到的数据放入应用层缓存中。
分布式缓存
- 一致性哈希算法:采用一致性哈希算法来分配热点 Key 到不同的 Redis 节点上,使得热点 Key 能够均匀地分布在多个节点上,避免单个节点负载过高。
- Redis 集群:使用 Redis 集群来分散热点 Key 的访问压力。Redis 集群将数据分散存储在多个节点上,当有热点 Key 的访问请求时,集群会根据 Key 的哈希值将请求路由到对应的节点上,从而实现负载均衡。
限流与降级
- 请求限流:在应用程序的入口处对访问热点 Key 的请求进行限流,限制单位时间内的请求数量,避免过多的请求涌向 Redis。可以使用令牌桶算法或漏桶算法等限流算法来实现。
- 服务降级:当 Redis 的热点 Key 出现性能问题时,对一些非核心的业务功能进行降级处理,减少对热点 Key 的访问。例如,对于一些推荐系统中的热点商品推荐,可以暂时降低推荐的精度或减少推荐的数量,以减轻 Redis 的压力。
数据预热
- 提前加载热点数据:在系统启动或业务低峰期,提前将热点数据加载到 Redis 中,并进行预热,使得热点数据在被大量请求访问之前就已经在 Redis 中处于热状态,提高访问速度。
- 动态更新热点数据:根据业务的实际情况,动态地更新热点数据的缓存时间和内容。例如,对于一些实时性要求较高的热点新闻,可以每隔一段时间就更新一次缓存中的新闻内容,确保用户获取到的是最新的热点数据。
5,MySQL 主从复制是如何实现的?
MySQL 主从复制是指将一台 MySQL 服务器(主服务器)的数据复制到一台或多台其他 MySQL 服务器(从服务器)的过程,其实现主要涉及以下三个步骤:
主服务器配置
- 开启二进制日志:在主服务器的
my.cnf
配置文件中,需要确保log-bin
参数已开启,该参数用于指定二进制日志文件的路径和名称前缀。例如:log-bin=mysql-bin
,这将使得主服务器在执行写操作时,会将这些操作以二进制的形式记录到二进制日志文件中。 - 设置服务器唯一 ID:为了在复制架构中唯一标识每台服务器,需要为每台服务器设置不同的
server-id
。在主服务器的my.cnf
配置文件中,设置server-id=1
,这里的1
只是一个示例,通常可以根据实际情况进行设置,但必须保证整个复制集群中server-id
的唯一性。
从服务器配置
- 配置连接主服务器信息:在从服务器的
my.cnf
配置文件中,需要指定要连接的主服务器的相关信息,包括主服务器的 IP 地址、端口号、用于复制的用户账号和密码等。例如:
server-id=2
relay-log=mysql-relay-bin
read-only=1
log-slave-updates=1
其中,server-id
设置为与主服务器不同的值,relay-log
指定了中继日志文件的名称,read-only=1
表示从服务器默认只提供读操作,log-slave-updates=1
表示从服务器在执行中继日志中的 SQL 语句时也会将其记录到自己的二进制日志中。
- 启动复制线程:在从服务器上执行
CHANGE MASTER TO
语句来配置与主服务器的连接信息,如CHANGE MASTER TO MASTER_HOST='master_ip', MASTER_PORT=3306, MASTER_USER='repl_user', MASTER_PASSWORD='repl_password';
,其中master_ip
是主服务器的 IP 地址,repl_user
和repl_password
是在主服务器上创建的用于复制的用户账号和密码。配置完成后,在从服务器上执行START SLAVE
语句启动复制线程,从服务器会连接到主服务器并开始等待接收主服务器发送的二进制日志事件。
复制过程
- 二进制日志转储线程(Binlog Dump Thread):在主服务器上,当有数据修改操作发生时,会将这些操作记录到二进制日志中。同时,主服务器会启动一个二进制日志转储线程,该线程负责将二进制日志中的事件发送给从服务器。它会根据从服务器的请求,将二进制日志中的事件按照顺序依次发送给从服务器。
- I/O 线程(I/O Thread):在从服务器上,I/O 线程负责连接主服务器,并接收主服务器发送的二进制日志事件。它会将接收到的二进制日志事件写入到从服务器的中继日志(Relay Log)中。中继日志是从服务器上用于临时存储主服务器二进制日志事件的文件,其格式与二进制日志类似。
- SQL 线程(SQL Thread):从服务器上的 SQL 线程会从中继日志中读取事件,并将这些事件在从服务器上执行,从而实现数据的复制。SQL 线程会按照中继日志中事件的顺序依次执行,确保数据的一致性。
6,MySQL InnoDB 和 MyISAM 的区别是什么?
MySQL 中的 InnoDB 和 MyISAM 是两种常用的存储引擎,它们在事务支持、锁机制、并发性能等多个方面存在区别,以下是详细介绍:
事务支持
- InnoDB:支持事务处理,具有事务的四大特性 ACID(原子性、一致性、隔离性、持久性)。通过事务日志和回滚段等机制来保证事务的正确执行和数据的一致性,适用于对数据完整性和一致性要求较高的应用场景,如银行转账、电商订单处理等。
- MyISAM:不支持事务,在执行写操作时,如果发生错误或异常,可能会导致数据不一致。对于一些简单的、对事务要求不高的应用场景,如数据仓库、日志记录等,可以使用 MyISAM 存储引擎。
锁机制
- InnoDB:支持行级锁和表级锁,默认使用行级锁。行级锁可以在并发操作时,只锁定需要修改的行,提高并发性能。在事务处理过程中,会根据事务的隔离级别和操作的类型自动选择合适的锁类型。
- MyISAM:只支持表级锁,在对表进行写操作时,会锁定整个表,导致其他并发的读写操作都需要等待锁的释放。因此,在高并发环境下,MyISAM 的并发性能相对较差。
并发性能
- InnoDB:由于支持行级锁,在高并发环境下,多个事务可以同时对不同的行进行操作,并发性能较好。同时,InnoDB 还支持多版本并发控制(MVCC),可以在不加锁的情况下,实现对数据的并发读取,进一步提高并发性能。
- MyISAM:在并发写入时,由于表级锁的限制,只能串行执行,并发性能较差。但在并发读取时,MyISAM 的性能相对较好,因为它不需要像 InnoDB 那样处理复杂的事务和锁机制。
存储结构
- InnoDB:数据和索引存储在同一个文件中,即表空间文件(.ibd 文件)。表空间可以由多个文件组成,支持自动扩展。InnoDB 还会将数据存储在内存中的缓冲池(Buffer Pool)中,以提高数据的读写速度。
- MyISAM:数据和索引分别存储在不同的文件中,数据文件的扩展名为.MYD,索引文件的扩展名为.MYI。在读取数据时,需要分别从数据文件和索引文件中获取信息,相对来说效率较低。
外键支持
- InnoDB:支持外键约束,通过外键可以建立表与表之间的关联关系,保证数据的完整性和一致性。在进行数据插入、更新和删除操作时,会自动检查外键约束,避免出现数据不一致的情况。
- MyISAM:不支持外键约束,需要在应用程序中通过代码来实现表与表之间的关联关系和数据一致性检查。
缓存机制
- InnoDB:使用缓冲池(Buffer Pool)来缓存数据和索引,提高数据的读写效率。缓冲池中的数据会根据一定的算法进行淘汰和更新,以保证缓存的命中率。
- MyISAM:只缓存索引文件,不缓存数据文件。在读取数据时,如果数据不在缓存中,需要从磁盘中读取,相对来说效率较低。
数据恢复
- InnoDB:在发生故障时,可以通过事务日志和备份文件进行数据恢复。事务日志记录了所有的事务操作,通过重放事务日志,可以将数据库恢复到故障前的状态。
- MyISAM:在发生故障时,只能通过备份文件进行恢复。如果没有及时备份,可能会导致数据丢失。
7,MySQL 中的 MVCC 是什么?
MVCC 即多版本并发控制(Multi-Version Concurrency Control),是 MySQL 中 InnoDB 存储引擎实现并发控制的一种重要机制,以下是其详细介绍:
基本原理
- MVCC 通过为每行数据维护多个版本来实现并发控制,在事务执行过程中,每个事务看到的都是数据的某个特定版本,而不是最新的版本。这样可以在不加锁的情况下,实现多个事务对同一行数据的并发读取,提高并发性能。
- 当一个事务对某行数据进行修改时,InnoDB 会为该行数据创建一个新的版本,并将旧版本保留在系统中。其他事务在读取该行数据时,可以根据自己的事务时间戳或其他条件,选择读取合适的版本,而不会受到当前正在进行的修改操作的影响。
实现机制
- 事务版本号:每个事务在开始时都会被分配一个唯一的事务版本号,这个版本号随着事务的执行而递增。事务版本号用于标识事务的先后顺序和确定事务能够看到的数据版本。
- 隐藏列:InnoDB 在每行数据中都添加了一些隐藏列,用于存储数据的版本信息。这些隐藏列包括创建版本号(DB_TRX_ID)和删除版本号(DB_ROLLBACK_SEGMENT_ID)。创建版本号记录了该行数据被创建时的事务版本号,删除版本号记录了该行数据被删除时的事务版本号。
- 版本链:对于每一行数据,InnoDB 会根据其修改历史形成一个版本链。版本链中的每个节点都对应着该行数据的一个版本,节点之间通过指针相连。当一个事务对该行数据进行修改时,会在版本链的头部插入一个新的版本节点。
- ReadView:ReadView 是 MVCC 的核心概念之一,它是一个事务在某个时刻对数据库的一个视图。ReadView 中包含了一些重要的信息,如创建该 ReadView 的事务版本号、当前系统中活跃的事务列表等。当一个事务进行读取操作时,会根据自己的 ReadView 来判断应该读取哪个版本的数据。
工作过程
- 数据读取:当一个事务进行读取操作时,InnoDB 会首先根据该事务的 ReadView 来确定能够看到的数据版本。如果数据的创建版本号小于或等于该事务的版本号,并且删除版本号大于该事务的版本号或为空,则该事务可以读取该数据版本。
- 数据修改:当一个事务对某行数据进行修改时,InnoDB 会为该行数据创建一个新的版本,并将旧版本保留在系统中。新的版本会记录当前事务的版本号作为创建版本号,同时将旧版本的删除版本号设置为当前事务的版本号。
- 事务提交与回滚:当事务提交时,其对数据的修改会正式生效,其他事务在后续的读取操作中可能会看到新的版本。如果事务回滚,InnoDB 会根据版本链将数据恢复到事务开始前的状态。
优势
- 提高并发性能:MVCC 允许多个事务同时对同一行数据进行并发读取,而不需要加锁,大大提高了数据库的并发性能。
- 保证数据一致性:通过为每个事务提供一个一致的数据库视图,MVCC 可以保证事务在执行过程中看到的数据是一致的,即使在并发操作的情况下也不会出现数据不一致的情况。
- 减少锁冲突:由于不需要对数据进行加锁,MVCC 可以减少锁冲突的发生,提高系统的稳定性和可扩展性。
8,什么是 Java 中的双亲委派模型?
双亲委派模型是 Java 中类加载器的一种工作机制,以下是关于它的详细介绍:
工作原理
- 当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
- 只有当父类加载器在其搜索范围内无法找到所需的类时,子类加载器才会尝试自己去加载。
类加载器层次结构
- Bootstrap ClassLoader:它是 Java 类加载层次结构中的顶层类加载器,主要负责加载 Java 核心库,如
java.lang
、java.util
等包中的类。它是用 C++ 实现的,是 JVM 的一部分,在 Java 中无法直接获取到它的实例。 - Extension ClassLoader:它的父类加载器是 Bootstrap ClassLoader,主要负责加载 Java 的扩展库,即位于
JRE/lib/ext
目录下的类库,或者通过java.ext.dirs
系统属性指定的目录下的类库。 - Application ClassLoader:它的父类加载器是 Extension ClassLoader,也称为系统类加载器,是 Java 应用程序中默认的类加载器,负责加载应用程序的类路径(
classpath
)下的所有类。
实现代码示例
以下是在 Java 中模拟双亲委派模型的部分代码示例:
public class ClassLoaderTest {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader applicationClassLoader = ClassLoader.getSystemClassLoader();
// 获取扩展类加载器
ClassLoader extensionClassLoader = applicationClassLoader.getParent();
// 获取引导类加载器
ClassLoader bootstrapClassLoader = extensionClassLoader.getParent();
try {
// 使用系统类加载器加载类
Class<?> clazz1 = applicationClassLoader.loadClass("java.lang.String");
System.out.println(clazz1.getClassLoader());
// 使用扩展类加载器加载类
Class<?> clazz2 = extensionClassLoader.loadClass("javax.swing.JButton");
System.out.println(clazz2.getClassLoader());
// 使用自定义类加载器加载类
ClassLoader customClassLoader = new CustomClassLoader();
Class<?> clazz3 = customClassLoader.loadClass("com.example.MyClass");
System.out.println(clazz3.getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (!name.startsWith("com.example")) {
return super.loadClass(name);
}
try {
// 自定义类加载逻辑
String fileName = name.substring(name.lastIndexOf('.') + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
}
优势
- 避免类的重复加载:确保了 Java 核心库中的类在整个系统中只有一份,避免了不同类加载器重复加载相同类可能导致的冲突和混乱。
- 保证类的安全性:防止恶意代码自定义一个与 Java 核心库中同名的类来破坏系统的稳定性,因为核心库的类总是由 Bootstrap ClassLoader 加载,而自定义类加载器无法覆盖它。
- 实现类的隔离性:不同的类加载器可以加载相同名称的类,这些类在不同的类加载器命名空间中是相互隔离的,这在一些复杂的应用场景中,如插件化开发、容器化部署等非常有用。
9,synchronized 和 lock 有什么区别?
在 Java 中,synchronized
和Lock
都可用于实现多线程同步,但在使用方式、功能特性等方面存在一些区别,以下是详细介绍:
用法与语法
- synchronized:它是 Java 中的关键字,可用于修饰方法或代码块。修饰方法时,在方法声明中加上
synchronized
关键字,如public synchronized void method()
,表示该方法是同步方法,同一时刻只有一个线程可以访问该方法。修饰代码块时,使用synchronized(this)
或synchronized(obj)
的形式,其中this
表示当前对象,obj
表示指定的对象,在该代码块执行期间,其他线程无法访问被同步的资源。 - Lock:它是一个接口,位于
java.util.concurrent.locks
包中,常用的实现类是ReentrantLock
。使用Lock
时,需要先通过Lock
接口的实现类创建一个锁对象,如Lock lock = new ReentrantLock();
,然后在需要同步的代码块前调用lock.lock()
方法获取锁,在代码块执行完毕后调用lock.unlock()
方法释放锁。
功能特性
- 锁的获取与释放
- synchronized:由 Java 虚拟机自动获取和释放锁,当线程执行完同步方法或代码块时,锁会自动释放,无需手动干预。
- Lock:需要手动调用
lock()
方法获取锁,unlock()
方法释放锁,如果忘记释放锁,可能会导致死锁等问题,所以通常在finally
块中释放锁,以确保锁一定会被释放。
- 锁的可重入性
- synchronized:具有隐式的可重入性,即同一个线程在已经获取了某个对象的锁的情况下,可以再次进入该对象的同步方法或代码块,不会发生死锁。
- Lock:通过
ReentrantLock
等实现类实现可重入性,在构造ReentrantLock
对象时,可以传入一个布尔值参数来指定是否为公平锁,默认是非公平锁。
- 锁的公平性
- synchronized:是非公平锁,即线程获取锁的顺序是不确定的,可能会导致某些线程长时间等待。
- Lock:可以通过构造函数指定是否为公平锁,公平锁按照线程请求锁的顺序来分配锁,避免了线程饥饿问题,但公平锁的性能通常会比非公平锁略低。
- 锁的等待与唤醒机制
- synchronized:使用
Object
类的wait()
、notify()
和notifyAll()
方法来实现线程的等待与唤醒,这些方法必须在同步代码块或同步方法中使用,且必须通过获取到锁的对象来调用。 - Lock:通过
Condition
接口的await()
、signal()
和signalAll()
方法来实现线程的等待与唤醒,Condition
对象可以通过Lock
对象的newCondition()
方法获取。
- synchronized:使用
性能差异
- 在低竞争场景下,
synchronized
的性能与Lock
相当,甚至可能更好,因为synchronized
是 Java 内置的同步机制,由虚拟机进行了优化。 - 在高竞争场景下,
Lock
的性能通常优于synchronized
,尤其是使用非公平锁时,Lock
可以提供更好的并发性能,因为它可以更灵活地控制锁的获取和释放,减少线程的等待时间。
使用场景
- synchronized:适用于简单的同步场景,如对共享资源的单次访问进行同步,或者对整个方法进行同步。如果不需要复杂的锁控制和等待唤醒机制,使用
synchronized
更加简洁方便。 - Lock:适用于复杂的同步场景,如需要手动控制锁的获取和释放、实现公平锁、多个条件变量的等待与唤醒等。在高并发场景下,如果对性能有较高要求,也可以考虑使用
Lock
。
10,什么是指令重排序,如何解决?
指令重排序是指在程序执行过程中,编译器和处理器为了优化程序性能,对指令执行的顺序进行重新排列的一种现象。以下是关于指令重排序的详细介绍以及解决方法:
产生原因
- 编译器优化:在不改变程序语义的前提下,编译器会对代码进行优化,调整指令的执行顺序,以提高程序的运行速度和效率。
- 处理器乱序执行:现代处理器为了充分利用硬件资源,采用了乱序执行技术,允许指令在不影响程序最终结果的情况下,按照一定的规则进行乱序执行。
可能导致的问题
- 多线程并发问题:在多线程环境下,指令重排序可能会导致程序的执行结果与预期不符,出现数据竞争、线程安全等问题。例如,在一个线程中对共享变量进行写操作,另一个线程中对该共享变量进行读操作,如果写操作的指令被重排序到读操作之后,就可能导致读操作读取到错误的值。
解决方法
- 使用 volatile 关键字:当一个变量被声明为
volatile
时,编译器和处理器会对该变量的访问进行特殊处理,确保对该变量的读写操作不会被重排序。在多线程环境下,如果一个共享变量被多个线程访问,并且其中至少有一个线程对该变量进行写操作,那么可以将该变量声明为volatile
,以保证变量的可见性和有序性。 - 使用锁机制:通过使用
synchronized
关键字或Lock
接口来实现锁机制,可以保证在同一时刻只有一个线程能够访问被锁定的代码块或方法,从而避免指令重排序导致的问题。在使用锁机制时,需要确保在对共享变量进行读写操作时,始终持有锁,以保证操作的原子性和有序性。 - 使用原子类:Java 提供了一系列的原子类,如
AtomicInteger
、AtomicLong
等,这些原子类在内部使用了CAS
(比较并交换)算法来实现原子操作,并且保证了操作的可见性和有序性。在多线程环境下,如果需要对共享变量进行原子操作,可以使用原子类来代替普通的变量,以避免指令重排序导致的问题。 - 使用内存屏障:内存屏障是一种特殊的指令,它可以阻止编译器和处理器对指令进行重排序。在 Java 中,可以通过
Unsafe
类来使用内存屏障,但是Unsafe
类是一个底层的、不安全的类,不建议直接使用。不过,一些框架和库会在内部使用内存屏障来解决指令重排序的问题,例如Disruptor
框架。
11,Spring loC 和 AOP 是什么?
Spring 是一个开源的 Java 应用程序框架,在企业级 Java 开发中广泛使用。其核心特性包括控制反转(IoC)和面向切面编程(AOP),以下是对它们的详细介绍:
Spring IoC(Inversion of Control,控制反转)
- 概念:是一种设计模式,通过将对象的创建和依赖关系的管理交给容器来实现,而不是由对象自身去负责。在传统的程序设计中,对象之间的依赖关系通常是在代码中通过
new
关键字等硬编码的方式创建和管理的。而在 Spring IoC 中,对象的创建和依赖注入由 Spring 容器来完成,对象只需要关心自身的业务逻辑,降低了对象之间的耦合度。 - 实现原理:Spring 容器在启动时,会读取配置文件(如 XML 配置文件或 Java 配置类),根据配置信息创建对象,并将对象之间的依赖关系进行注入。当一个对象需要依赖其他对象时,它不需要自己去创建,而是由 Spring 容器将所依赖的对象注入进来。
- 依赖注入方式
- 构造函数注入:通过类的构造函数将依赖对象注入进来。例如:
public class UserService {
private UserDao userDao;
public UserService(UserDao userDao) {
this.userDao = userDao;
}
}
- Setter 方法注入:通过类的 Setter 方法将依赖对象注入进来。例如:
public class UserService {
private UserDao userDao;
public void setUserDao(UserDao userDao) {
this.userDao = userdao;
}
}
- 字段注入:直接在类的字段上使用
@Autowired
等注解进行注入。例如:
public class UserService {
@Autowired
private UserDao userDao;
}
Spring AOP(Aspect Oriented Programming,面向切面编程)
- 概念:是一种编程范式,它允许将与业务逻辑无关的横切关注点(如日志记录、事务管理、安全检查等)从业务逻辑中分离出来,形成独立的切面(Aspect),然后在程序运行时将这些切面动态地织入到目标对象的业务逻辑中,从而实现对业务逻辑的增强,而无需修改业务逻辑代码本身。
- 相关术语
- 切面(Aspect):是一个包含了横切关注点的模块,通常由切点和通知组成。
- 切点(Pointcut):用于定义在哪些连接点上应用切面,通常使用表达式来指定。
- 通知(Advice):是在切点所定义的连接点上执行的代码,包括前置通知(在目标方法执行前执行)、后置通知(在目标方法执行后执行)、环绕通知(在目标方法执行前后都执行)、异常通知(在目标方法抛出异常时执行)和返回通知(在目标方法正常返回时执行)等。
- 连接点(Join Point):是程序执行过程中的一个点,如方法调用、方法执行、异常抛出等,在这些点上可以插入切面的通知。
- 实现原理:Spring AOP 基于代理模式实现,当一个目标对象需要被增强时,Spring 会为其创建一个代理对象,代理对象在调用目标对象的方法时,会根据切点的定义判断是否需要执行切面的通知,如果需要,则在目标方法执行前后或抛出异常时等执行相应的通知。
- 使用示例
// 定义切面
@Aspect
public class LoggingAspect {
// 定义切点
@Pointcut("execution(* com.example.service.UserService.*(..))")
public void userServicePointcut() {}
// 定义前置通知
@Before("userServicePointcut()")
public void beforeMethod(JoinPoint joinPoint) {
System.out.println("Before method: " + joinPoint.getSignature().getName());
}
}
12,解决 Hash 碰撞的方法有哪些?
哈希碰撞(Hash Collision)是指不同的输入经过哈希函数计算后得到了相同的哈希值。解决哈希碰撞的方法有多种,以下是一些常见的方法:
开放定址法
- 线性探测法:当发生哈希碰撞时,从当前哈希地址开始,依次向后探测空闲的存储单元,直到找到一个空闲位置为止。例如,哈希表大小为 10,哈希函数为
hash(key)=key % 10
,插入键值对(15, "value1")
和(25, "value2")
时,hash(15)=5
,hash(25)=5
,发生碰撞,此时使用线性探测法,会将(25, "value2")
存储在hash(25)+1=6
的位置。 - 二次探测法:当发生哈希碰撞时,按照二次函数的规律来探测下一个空闲位置,即探测位置为
hash(key)+i^2
(i
为探测次数)。例如,哈希表大小为 10,哈希函数为hash(key)=key % 10
,插入键值对(15, "value1")
和(25, "value2")
时,发生碰撞后,第一次探测位置为hash(25)+1^2=6
,如果6
位置也被占用,则第二次探测位置为hash(25)+2^2=9
,以此类推。 - 随机探测法:在发生哈希碰撞时,通过一个随机数生成器生成一个随机的步长,然后按照这个步长来探测下一个空闲位置。
链地址法
- 基本原理:将所有哈希地址相同的元素构成一个单链表,即把发生碰撞的元素用链表连接起来,存储在同一个哈希桶中。例如,对于哈希函数
hash(key)=key % 10
,键值对(15, "value1")
、(25, "value2")
和(35, "value3")
都哈希到5
这个位置,那么在哈希表的5
号桶中,会形成一个链表,依次存储这三个键值对。 - 优化:可以将链表替换为其他更高效的数据结构,如红黑树、跳表等,以提高在哈希桶中查找元素的效率。
再哈希法
- 基本原理:当发生哈希碰撞时,使用另一个哈希函数对该键再次进行哈希计算,直到找到一个空闲的位置为止。例如,有哈希函数
hash1(key)=key % 10
和hash2(key)=(key / 10) % 10
,插入键值对(15, "value1")
和(25, "value2")
时,hash1(15)=5
,hash1(25)=5
发生碰撞,此时使用hash2(25)=2
,将(25, "value2")
存储在2
号位置。 - 多哈希函数选择:可以准备多个不同的哈希函数,在发生碰撞时依次尝试,或者根据一定的规则动态选择哈希函数。
建立公共溢出区
- 基本原理:将哈希表分为基本表和溢出表两部分,当发生哈希碰撞时,将冲突的元素都存储到溢出表中。在查找元素时,先在基本表中查找,如果找不到,则再到溢出表中查找。
13,什么是 ABA 问题?
ABA 问题是在多线程并发编程中,由于对共享资源的访问和修改顺序不一致而导致的一种特殊问题,以下是具体介绍:
问题描述
- 在多线程环境下,一个线程对共享变量进行了多次操作,使得该变量的值从 A 变成 B,又变回 A,而在这个过程中,其他线程可能在该变量值为 A 时进行了一些操作,这些操作可能会因为变量值看似未变而产生错误的结果,即线程看到的变量状态是 A,但是实际上这个 A 已经不是之前的那个 A 了,中间发生了变化又变回了 A,这就是 ABA 问题。
产生原因
- 并发操作:多个线程同时对同一个共享变量进行读写操作,且没有进行适当的同步控制。
- 指令重排:在没有正确同步的情况下,编译器和处理器可能会对指令进行重排序,导致操作的执行顺序与代码的书写顺序不一致,从而增加了 ABA 问题出现的可能性。
示例
import java.util.concurrent.atomic.AtomicReference;
public class ABAProblemExample {
private static AtomicReference<String> atomicReference = new AtomicReference<>("A");
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
String prev = atomicReference.get();
System.out.println("Thread 1 read value: " + prev);
// 模拟一些耗时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = atomicReference.compareAndSet("A", "B");
System.out.println("Thread 1 CAS result: " + result);
result = atomicReference.compareAndSet("B", "A");
System.out.println("Thread 1 CAS result: " + result);
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = atomicReference.compareAndSet("A", "C");
System.out.println("Thread 2 CAS result: " + result);
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}
在上述示例中,thread1
首先读取atomicReference
的值为"A"
,然后经过一些操作将其值从"A"
修改为"B"
,再修改回"A"
。而thread2
在thread1
操作完成后,也尝试将atomicReference
的值从"A"
修改为"C"
,此时thread2
的compareAndSet
操作会成功,因为它看到的值也是"A"
,但实际上这个"A"
已经不是最初的那个"A"
了,这就可能导致程序出现意外的结果。
解决方法
- 使用版本号或时间戳:在共享变量中增加一个版本号或时间戳字段,每次对变量进行修改时,同时更新版本号或时间戳。在进行比较和交换操作时,不仅要比较变量的值,还要比较版本号或时间戳,只有两者都相等时,才进行交换操作。
- 使用
AtomicStampedReference
或AtomicMarkableReference
:Java
中的AtomicStampedReference
和AtomicMarkableReference
类可以在原子操作中同时携带一个版本号或标记位,通过这种方式来解决 ABA 问题。
14,算法:反转链表
以下是使用 Java 语言实现反转链表的几种常见算法,这里以单链表为例进行介绍:
迭代法
- 思路:通过遍历链表,依次改变当前节点的指针方向,使其指向前一个节点,从而实现链表的反转。需要使用两个指针,一个指针
prev
指向当前节点的前一个节点,初始时为null
;另一个指针curr
指向当前正在处理的节点,初始时指向链表的头节点。在遍历过程中,先保存当前节点的下一个节点,然后将当前节点的指针指向前一个节点,接着更新prev
和curr
指针,继续下一个节点的处理,直到遍历完整个链表。 - 代码示例:
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
public class ReverseLinkedList {
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr!= null) {
ListNode nextTemp = curr.next;
curr.next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
}
递归法
- 思路:递归地反转链表,将问题逐步分解为更小的子问题。对于一个链表,先反转除了头节点之外的其余部分链表,然后将头节点的指针指向已反转的子链表的末尾,最后返回反转后的头节点。递归的终止条件是当链表为空或者只有一个节点时,直接返回该链表。
- 代码示例:
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
public class ReverseLinkedList {
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode reversedSubList = reverseList(head.next);
head.next.next = head;
head.next = null;
return reversedSubList;
}
}
在实际应用中,可以根据具体的场景选择合适的方法来反转链表,迭代法相对来说更容易理解和实现,递归法则代码更加简洁,但在处理较长链表时可能会有栈溢出的风险(取决于递归深度)。