这些都是我在真实面试时被问到的问题,当时可能因为知识储备不足或其他原因,没能答上来。现在重新整理一下,希望能对以后的面试有所帮助。注意文中的前半部分都是问题,答案统一放在最后,这样方便先在脑海中思考一遍,快速过掉简单的问题,想不出来的再去看参考答案。
选择题
(1)在一台 Linux 服务器上运行一个高并发的在线支付系统时,发现系统性能下降严重,CPU 负载高,支付处理速度变慢。通过使用 vmstat 命令,观察到 si(swap in)和 so(swap out)频繁增加,内存的 free 值很低。此时应该优先采取哪种措施来提升系统性能?
A. 增加系统的虚拟内存空间
B. 调整 CPU 的运行频率
C. 降低系统的进程优先级
D. 增加物理内存
(2)如果要在多线程 Java 应用程序中保证线程安全,ArrayList 应该怎么做?下面说法正确的是:
A. 自己写个包装类,根据业务一般是 add/update/remove 加锁
B. ArrayList 本身就是线程安全的,不用做其他操作
C. 使用 Collections.synchronizedList(new ArrayList<>()); 内部使用了 synchronized 加锁
D. 使用 CopyOnWriteArrayList<>(); 内部使用了 ReentrantLock 加锁
(3)以下关于 Java 垃圾收集的叙述,哪些是对的:
A. 开发者必须自己创建一个线程进行内存释放的工作
B. 垃圾收集将检查并释放不再使用的内存
C. 垃圾收集允许开发者明确指定并立即释放该内存
D. 垃圾收集能够在期望的时间释放内存
问答题
(4)有一个日志文件,里面记录了访问过某网站的 IP 地址,现在想找出某一天访问该网站次数最多的 IP 地址,但由于此文件过大而无法直接读取,请你提供统计方法。
(5)设计一个抢红包程序。比如:领红包人数 6,总金额 10.00。红包金额分别为:0.42 元、1.95 元、2.03 元、1.71 元、2.16 元、1.73 元,累加后的红包总和为 10.00,红包个数为 6。(备注:红包精确到分)请回答以下问题:
(5.1)简述你的方案和用到的技术/组件。
(5.2)你如何保证并发抢红包的情况下,大家能够获得正确的红包数字而不会产生问题?
(5.3)如果同时有数千个红包,每个红包最多可能一千个人抢,你会如何设计这个系统,为什么?
(5.4)还有什么需要注意的细节?
编程题
(6)假设你是一家电商公司的 Java 开发工程师,最近公司的业务量迅速增长,需要开发一个线程安全的商品库存管理系统。系统需要满足以下要求:
- 系统能够添加新的商品和对应的库存量;
- 可以根据商品 ID 查询商品库存量;
- 支持并发的商品销售和库存更新操作。当销售某商品时,库存应减少相应数量;如果库存不足,应抛出一个自定义的库存不足异常;
- 在商品库存量被更新时,如果库存量小于 0,应抛出一个自定义的无效库存异常;
- 所有操作都应该是线程安全的。
系统要求实现以下主要接口:
(6.1)void addProduct(String productId, int amount):添加新的商品和库存量,若商品 ID 已存在,则更新库存量。
(6.2)void sellProduct(String productId, int amount):销售商品,如果库存不足,应抛出库存不足异常。
(6.3)public int getAmount(String productId):获取商品 ID 所对应的商品的库存。
要求:
- 所有方法必须是线程安全的;
- 你需要自定义两个异常类:库存不足异常 InsufficientStockException 和无效库存异常 InvalidStockException;
- 你不能使用 synchronized 关键字以及 java.util.concurrent 包中的 ConcurrentHashMap 类。
提示:
- 你可以用一些数据结构来模拟库存数据库,如 Map、List、Array 等;
- 你可以在 SampleTest.java 中看到本题的测试逻辑,并可通过 JUnit 运行测试。
参考答案
(1)D
(2)ACD
(3)B
分析:Java 是自动管理内存,虽然它提供了一些与垃圾收集器交互的方法,比如 System.gc(),但这只是建议 JVM 进行垃圾收集,并不能保证立即执行,更不能保证释放特定的内存,垃圾收集器的工作时间是不确定的。此外,过度调用 System.gc() 可能会影响性能。
(4)思路一:用分块读取的思想,将大文件按照一定的块大小进行分块,每次只读取一块数据到内存中进行处理,处理完后再读取下一块数据。这样可以避免一次性读入整个文件造成内存不足的问题。
思路二:用 MapReduce 的思想,将 IP 地址按照某种 hash 映射规则,映射到不同的单独小文件,再把这 n 个小文件载入内存进行统计(Map 的过程),找出每个小文件中访问量前十的 IP 地址,最后汇总数据找出访问次数最多的 IP 地址(Reduce 的过程)。
(5)这里不以回答问题为主,而是借助这个题目,正好整体了解下红包系统的架构该如何设计,可能会跟题目要问的有所出入。红包是一种瞬时流量很大的应用,以 2017 年除夕为例,微信红包峰值 QPS 在 76w 左右,除夕当天收发微信红包的数量为 142 亿个。抢红包比秒杀有更海量的并发要求,而且整个系统核心功能和支付相关,需要做好高并发、高可用、高可靠。
(5.1)主要包含三部分:发红包、抢红包、拆红包。需要的数据库表主要有两张:红包表(红包 ID - 主键、发送红包的用户 ID、红包总金额、红包剩余金额、红包总数、剩余红包总数)、红包领取记录表(记录 ID - 主键、红包 ID、用户 ID、抢到的金额)。而红包金额的分配有两种方案:预先生成和实时拆分,一般选择实时拆分:
发红包:接收两个参数:红包个数和总金额,再调用支付接口,支付成功后,在红包表中新增一条数据,为了保证实时性和抢红包的效率,在 Redis 中也增加一条记录,保存红包 ID 和总个数 n,然后把红包消息发送到群中。
抢红包:用户点开红包,此动作存在高并发。首先校验红包是否被抢完、是否过期,如果还能抢,则原子递减保存在 Redis 中的红包个数,个数递减为 0 时说明已抢光。
拆红包:用户拆开红包,红包的金额也在此时实时计算出来,系统会在拆红包时取 0.01 到剩余金额平均值 * 2 之间的数值作为红包金额(二倍均值法),这种实时计算的方式基于内存进行,不需要额外的存储空间,且计算效率很高。用户获取到红包金额后,通过 MQ 异步进行数据库的事务操作,累加已经领取的个数和金额,更新红包表和领取记录表。为了提升效率,最终的转账也用 MQ 异步操作,即异步调用支付接口,将领取到的红包金额更新到钱包里,同时增加对账机制,保证资金的最终一致性。注意,核心流程外的步骤都可考虑异步化。
(5.2)要保证操作的原子性:
思路一:在拆红包过程中使用 JVM 的 CAS(Compare-and-Set)乐观锁算法,确保每次只有一个并发用户拆红包成功,如果拆红包 CAS 失败,系统自动进行重试,但可能在重试过程中被其他用户抢得先机而空手而归。
思路二:使用 Redis 的 Lua 脚本,它本身具备原子性,可用它来实时计算红包金额。
(5.3)设计思路如下:
(A)首先采用单元化架构,就是把系统拆分成若干个独立的单元,而每个单元都包含完成业务操作所需的所有服务和数据。这些单元可以独立部署、管理和监控,就像一个个小房子,每个房子都有自己的客厅、卧室和厨房(服务),也有自己的食物和水(数据)。系统根据红包 ID,按一定的规则切分为单元 SET(如按红包 ID 尾号取模等,每个 SET 都包含 Server 和 DB),各 SET 之间相互独立,互相解耦,同一个红包 ID 的所有请求,包括发红包、抢红包、拆红包、查详情等,都垂直 stick 到同一个 SET 内处理,高度内聚。通过这样的方式,系统将所有红包请求这个巨大的洪流分散为多股小流,互不影响,分而治之。
(B)用先进先出消息队列存放拆红包请求,这样做的好处是能将对数据库的并发操作改为串行,避免数据库在进行事务操作时并发抢锁。
(C)增加 Redis 缓存,防止请求队列过载,即预先设定一个最大数值,利用 Redis 的 CAS 原子递增操作或者分布式锁,控制同时进入 DB 执行拆红包事务的请求数,一旦超过预设值,则直接拒绝服务。Redis 的高性能得益于小操作,注意不能把一个很耗时的操作放到 Redis 里。
(D)增加负载均衡,分摊请求,缓解服务器压力。
(E)双维度分库分表,DB 的性能与单表数据量有一定相关性,随着红包数据量逐渐增大,单表数据量也逐渐增加,当达到一定程度时,DB 性能会有大幅度下降,所以需要分库分表。红包系统分库分表结合了两个维度,一是根据红包 ID 的 hash 值来分,二是根据数据的冷热来分,将历史冷数据与当前热数据分开存储,因为一般红包发出去三天后,99% 的用户就不会再去点开这个红包了。具体来说,就是分库表规则像 db_xx.t_y_dd 这样设计,其中 xx/y 是红包 ID 的 hash 值后三位,dd 的取值范围是 01 ~ 31,代表一个月的天数,最多 31 天。通过这种双维度分库分表的方式,保障了系统性能的稳定性,同时,在冷热分离的问题上,又使得数据搬迁变得简单而优雅。
(5.4)资金交易的安全性;红包过期,剩余金额需要退回给发红包的账户等等。
(6)题目要求的核心接口实现如下:(JUnit 测试类和自定义异常类同样值得研究,代码不多,放在后面)
1 | package com.test; |
JUnit 测试类 SampleTest,包含模拟并发操作的测试:(在 IDE 中可直接右键运行看成功率和代码覆盖率)
1 | package com.test; |
接下来是自定义的两个异常类,这里还额外加上了全局的异常处理器,虽然题目没有要求写,但在实际的开发中,全局的异常处理器是必不可少的,故补充上加深记忆。库存不足异常 InsufficientStockException:
1 | package com.test; |
无效库存异常 InvalidStockException:
1 | package com.test; |
全局的异常处理器,主要借助 @RestControllerAdvice 和 @ExceptionHandler 这两个注解实现。
@RestControllerAdvice 是一个组合注解,由 @ControllerAdvice、@ResponseBody 组成,而 @ControllerAdvice 继承了 @Component,所以可以理解为 @RestControllerAdvice 本质上就是 @Component (@Component 本质上是一个类,泛指各种组件,当一个类不能明确划分到某个类别的时候,比如不属于 @Controller、@Service 等的时候,就可以用 @Component,它的作用是实现 bean 的注入,取代 Spring 的 XML 配置文件)。
- @RestControllerAdvice 注解将对所有注解了 @RequestMapping 的控制器的方法起作用;
- @ControllerAdvice 的实现基于 AOP,因此它才可以将对于控制器的全局配置放在同一个位置;
- 注解了 @RestControllerAdvice 的类可以使用 @ExceptionHandler、@InitBinder、@ModelAttribute 注解到方法上;
- @ExceptionHandler:全局异常处理,用于指定异常处理方法;
- @InitBinder:全局数据预处理,用于在请求被处理之前设置 WebDataBinder;
- @ModelAttribute:全局数据绑定,用于添加键值对到 Model 中,全局中注解了 @RequestMapping 的方法都能获得此键值对。
1 | package com.test; |
(完)