如果面试都是这样开放设计的题目会发生什么
题目1:多线程
原题目:给定N个线程,依次交替打印自然数。例如N=3,打印结果为线程1:1,线程2:2,线程3:3,线程1:4,线程2:5,线程3:6… …
首先分析两点① 问题上下文 ② 关键点。注意的是这个面试题的用意是考察(多线程相关)工具使用,因此上下文可以不考虑。关键点在于线程(状态)的不可控的问题,例如将线程阻塞,但并不会立刻实现此状态。
对于控制并发等问题,我会首先考虑阻塞队列尝试实现,原因有二:①大部分JDK并发控制工具的“本源”也就是AQS从片面上来讲是一个阻塞队列,众多并发工具是AQS的完善与封装版本,因此使用阻塞队列也有回归本质的意思 ② 容易理解。队列是基本数据结构大部分程序员容易接受,并发最大问题就是代码逻辑难理解而且分布在各处的并发控制工具会让程序员感到头疼。
我们可以参考一下IO模型,5类IO模型中有一个经典的架构为selector/worker结构,可以使用这个结构实现题目要求的效果。大致思路是:一个dispatcher(分发器)和若干worker。Worker为继承thread的类包含一个长度为一的阻塞队列,主逻辑是一个循环,不断尝试从阻塞队列中获取数据并打印(换言之阻塞队列中没有数据则会阻塞在此),如果可以获取数据则打印之后调用dispatcher的dispatch方法,调用dispatcher的dispatch方法目的是让dispatcher将数据投放至下一个线程中,以此循环。
public class DemoTest {
public static void main(String[] args) throws InterruptedException {
DemoContext.newDemoContext(4).init().start();//开4个线程依次交替打印自然数
}
}
public class Worker extends Thread {
private BlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue(1);
private DemoContext demoContext;
private String name;
private Integer message;
public Worker(DemoContext demoContext, String name) {
this.demoContext = demoContext;
this.name = name;
blockingQueue.clear();
}
@Override
public void run() {
while (true) {
try {
message = blockingQueue.take();
System.out.println(name + ":" + message);
Thread.sleep(500);
next();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void next() throws InterruptedException {
demoContext.sendMessage(this);
}
public Integer getMessage() {
return message;
}
public void setMessage(Integer message) throws InterruptedException {
blockingQueue.put(message);
}
}
public class DemoContext {
private Worker[] workers;
private DemoContext(int size) {
workers = new Worker[size];
}
public static DemoContext newDemoContext(int size) {
return new DemoContext(size);
}
public void sendMessage(Worker worker) throws InterruptedException {
int message = worker.getMessage()+1;
workers[message % workers.length].setMessage(message);
}
public DemoContext init(){
for (int i = 0; i < workers.length; i++) {
workers[i] = new Worker(this, "thread-" + i);
}
return this;
}
public void start() throws InterruptedException {
workers[0].setMessage(0);
for (int i = 0; i < workers.length; i++) {
workers[i].start();
}
}
}
题目二:缓存
原题目:设计一个缓存,不能使用第三方库(换言之只能使用JDK API),如果发现数据距上次更新时间超过30mins,更新缓存,注意需支持并发。
首先分析两点① 问题上下文 ② 关键点。上下文换言之是针对这个问题的环境,也就是会出现何种情况;关键点也就是难点。实际上难点是并发操作的问题之一,多线程对同一区域进行读写造成的问题。
对于缓存问题涉及到的情况,如果简单分析一般有三个维度:缓存读取频率、读取准确度、数据失效频率(需更新频率)。(简单分析是因为还没有涉及到其他问题,例如更新可能涉及到的大面积缓存失效,当然如果存在这种情况也就意味着整个缓存架构存在一定问题了)
准确读(必须读取到最新数据) | 容许非准确读(允许读取到非最新数据) | |
---|---|---|
读取频率高 | 集群支持 | 集群支持 |
读取频率低 | 队列缓冲、数据分区(利用取余、hash等方法)与路由、读写锁 | 队列缓冲、数据分区(利用取余、hash等方法)与路由、读写锁(根据需要可适当减少组件) |
对于这道面试题,我们不考虑更新频率的问题或者我们默认为普通级别的更新频率这样将额外的因素带来的失效问题(因为更新可能带来大面积缓存失效的写操作带来更高层级的复杂度),对于读取频率较高的情况,虽然单台服务器可以安装多个网卡进行负载均衡,CPU和内存可以达到高要求,但是如果宕机或暂时停止服务面对涌来的读取请求只能拒绝或者直接打到后端数据库(如果不考虑缓存串联的情况)后果会比较严重,因此对于读取频率高的场合使用集群服务会比较保险。
因此这道面试题的上下文我们局限为读取频率适中且缓存失效频率不至于影响到整个缓存架构与性能的级别。所以读取准确度如何处理?也就是在读之前要把最新数据更新到缓存中去。考虑到我们的上下文,读取频率适中也就是读取存在一定间隔,因此使用读写锁(ReadWriteLock)可以满足需求(利用读写锁的读连续写锁定特性,如果考虑具体步骤大致为当缓存失效时读取返回Miss,然后重新写缓存,再读取),当然可以把数据分在多个地方,但是分布在多个地方要考虑分布规则与路由问题(这个问题思路相对直接一些),如果想更近一步,可以在客户端和缓存服务端加入队列,客户端队列可以对请求进行检测(防止恶意请求,去重,次数限制等等),服务端队列可以进行排序(优先级、读写等等)削峰等等。
如果考虑其他情况
① 缓存集体失效更新
回到这个问题关键点——多线程对同一区域读写问题。因为大量操作针对同一区域造成性能影响因此解决方案也顺理成章的为不针对同一区域进行操作(常见的就是读写分离),当然还需要解决同步问题。有没有简单且高效的方法呢。
可以考虑如下方法:我们看上图有两个圆圈1和2,假设圆圈1所表示缓存即将大面积失效,但我们在后台默默的准备一份可用的缓存也就是圆圈2,当准备完毕时我们将所有缓存请求导引至圆圈2也就是新准备的缓存,如果新的缓存因为某些原因即将大面积失效,我们在圆圈1处又准备好新的缓存,以此类推。圆圈1和圆圈2依次轮换,一个在工作,另一个在准备。当然“圆圈”数量不仅局限于2,可以多个。
② 缓存淘汰策略
刚才所讨论缓存涉及到的情况我们忽略了缓存失效这一个维度,实际上缓存失效的处理策略涉及到很多经典的算法(LRU、LFU、FIFO等等),其中不乏有很多经典文章讨论相关知识。不过说到这里,一般而言我们自己去实现一个缓存机制情况比较少见,可能更多的是使用第三方库或框架(guava/redis等等),不过能自己思考加之能够阅读相关源码想必也是极好。