前言
去年年底面试了京东,现整理面经,希望各位不要觉得太迟(这该死的拖延症😂)。
问题1.我看你简历中写了solr,那solr倒排索引是什么?
答:正常的文档索引是,描述一个文档有哪些关键字,也就是文档—关键字列表这种结构,但是倒排索引则是反过来,呈现出关键字—文档列表这种方式。
正排索引从文档编号找词:
倒排索引是从词找文档编号:
我们来具体看下:
-
设有两篇文章1和2
文章1的内容为:Tom lives in Guangzhou,I live in Guangzhou too
文章2的内容为:He once lived in Shanghai
- 获取关键字
全文分析:由于lucene是基于关键词索引和查询的,首先我们要取得这两篇文章的关键词,
通常我们需要如下处理措施:
a.分词,我们现在有的是文章内容,即一个字符串,我们先要找出字符串中的所有单词,即分词。英文单词由于用空格分隔,比较好处理。中文单词间是连在一起的需要特殊的分词处理。(smartcn或IK)
b.去除停顿词,文章中的"in", "once" "too"等词没有什么实际意义,中文中的"的""是"等字通常也无具体含义, 这些不代表概念的词可以过滤掉
c.用户通常希望查"He"时能把含"he","HE"的文章也找出来,所以所有单词需要统一大小写。
d.用户通常希望查"live"时能把含"lives","lived"的文章也找出来,所以需要把"lives","lived"还原成"live"
e.文章中的标点符号通常不表示某种概念,也可以过滤掉
经过上面处理后:
文章1的所有关键词为:[tom] [live] [guangzhou] [i] [live] [guangzhou]
文章2的所有关键词为:[he] [live] [shanghai]
- 建立倒排索引
有了关键词后,我们就可以建立倒排索引了。
上面的对应关系是:"文章号"对"文章中所有关键词"。
倒排索引把这个关系倒过来,变成:"关键词"对"拥有该关键词的所有文章号"。
文章1,2经过倒排后变成:
关键词 | 文章号 |
guangzhou | 1 |
he | 2 |
i | 1 |
live | 1,2 |
shanghai | 2 |
tom | 1 |
通常仅知道关键词在哪些文章中出现还不够,我们还需要知道关键词在文章中出现次数和出现的位置,
通常有两种位置:
a)字符位置,即记录该词是文章中第几个字符(优点是关键词亮显时定位快);
b)关键词位置,即记录该词是文章中第几个关键词(优点是节约索引空间、词组(phase)查询快),lucene中记录的就是这种位置。
加上"出现频率"和"出现位置"信息后,我们的索引结构变为:
关键词 | 文章号 | 出现频率 | 出现位置 |
guangzhou | 1 | 2 | 3,6 |
he | 2 | 1 | 1 |
i | 1 | 1 | 4 |
live | 1,2 | 2 | 2,5 |
shanghai | 2 | 1 | 3 |
tom | 1 | 1 | 1 |
问题2.synchronized和reentrantlock区别
答:主要有五个区别,一是synchronized是JVM层面的锁,是Java关键字,reentrantlock是类,而是jdk1.5以后提供的API层面的锁,二是synchronized不需要用户手动去释放锁,reentrantlock需要手动去释放锁,三是synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断,四是synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。五是synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。
① 从底层实现上来说
synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),对象只有在同步块或同步方法中才能调用wait/notify方法,ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。
synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁,(这边锁的升级过程,可以参考我另外一篇文章,关于Synchronized的偏向锁,轻量级锁,重量级锁,锁升级过程,自旋优化,你该了解这些)
synchronized (new Object()){
}
ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。
② 是否可手动释放
synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。
private int number = 0;
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
private AtomicInteger atomicInteger;
public void increment() throws Exception {
lock.lock();
try {
while (number != 0) {
condition.await();
}
//do something
number++;
System.out.println(Thread.currentThread().getName() + "\t" + number);
condition.signalAll();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
③ 是否可中断
synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
④ 是否公平锁
synchronized为非公平锁。 ReentrantLock可以手动选择公平锁或非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
⑤ 锁的对象
synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;
在JVM中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐数据,如下图:
其中Mark Word值在不同锁状态
下的展示如下:(重点看线程id,是否为偏向锁,锁标志位信息)
ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。
问题3.谈谈你对分布式的理解,为什么引入分布式?
答:为了解决传统单体服务架构带来的各种问题,代码数量庞大,迭代测试维护困难,可能因为一处改动测试不到位造成整个服务瘫痪等问题;很容易出现单点故障,如果一个节点挂了,整个服务都会瘫痪;服务器压力也很大,容易到达服务器的瓶颈,所以分布式系统就是将一个大的服务拆分成几十个甚至上百个微小的服务。每个人都负责部分服务,只要熟悉自己负责的模块即可,同时也方便大家分工开发。
比如阿里的 Dubbo,还有 Spring 全家桶里的 Spring Cloud,都是解决分布式微服务架构的优秀框架。
问题4.刚才谈到分布式,那你知道CAP理论吗?
答:C代表一致性 (Consistency),A代表可用性(Availability) ,P代表分区容错性 (Partition Tolerance),一个分布式系统最多同时这三项中的任意两项,一般为CP或AP。
我们先来看看CAP理论中三个指标的含义。
一致性(Consistence)
一致性,指的是客户端的每次读操作,不管访问哪个节点,要么读到的都是同一份最新数据,要么读取失败。
注意,一致性是站在客户端的视角出发的,并不是说在某个时间点分布式系统的所有节点的数据是一致的。事实上,在一个事务执行过程中,系统就是处于一种不一致状态,但是客户端是无法读取事务未提交的数据的,此时客户端会直接读取失败。
CAP理论中的一致性是强一致性,举个例子来理解下:
初始时,节点1和节点2的数据是一致的,然后客户端向节点1写入了新数据“X = 2”:
节点1在本地更新数据后,通过节点间的通讯,同步数据到节点2,确认节点2写入成功后,然后返回成功给客户端:
这样两个节点的数据就是一致的了,之后,不管客户端访问哪个节点,读取到的都是同一份最新数据。如果节点2在同步数据的过程中,有另外的客户端来访问任意节点,都会拒绝,这就是强一致性。
可用性(Availability)
可用性,指的是客户端的请求,不管访问哪个节点,都能得到响应数据,但不保证是同一份最新数据。你也可以把可用性看作是分布式系统对访问本系统的客户端的另外一种承诺:我尽力给你返回数据,不会不响应你,但是我不保证每个节点给你的数据都是最新的。
这个指标强调的是服务可用,但不保证数据的强一致。
注意注意,这边问题就来了,C和A是两个不同的极端,C表示宁愿拒绝,也不会返回旧的数据,而A则是相反,可以返回旧的数据,先保证服务的可用性。
分区容错性(Network partitioning)
分区容错性,指的是当节点间出现消息丢失、高延迟或者已经发生网络分区时,系统仍然可以继续提供服务。也就是说,分布式系统在告诉访问本系统的客户端:不管我的内部出现什么样的数据同步问题,我会一直运行,提供服务。
因为分布式系统与单机系统不同,它涉及到多节点间的通讯和交互,节点间的分区故障是必然发生的,所以在分布式系统中分区容错性是必须要考虑的。
既然分区容错是必须要考虑的,那么这时候系统该如何运行呢?是选择一致性(C)呢,还是选择可用性(P)呢?这就引出了著名的“CAP不可能三角”。
问题5.jdk8的新特性
答:主要有Lambda表达式,函数式接口,Stream API,新时间日期API等。
Lamdba表达式:本质上一段匿名内部类,允许函数作为一个方法的参数。
//匿名内部类
Comparator<Integer> cpt = new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return Integer.compare(o1,o2);
}
};
TreeSet<Integer> set = new TreeSet<>(cpt);
System.out.println("=========================");
//使用lambda表达式
Comparator<Integer> cpt2 = (x,y) -> Integer.compare(x,y);
TreeSet<Integer> set2 = new TreeSet<>(cpt2);
函数式接口:只定义了一个抽象方法的接口(Object类的public方法除外),就是函数式接口,并且还提供了注解:@FunctionalInterface,是为了给Lambda表达式的使用提供更好的支持。
常见的四大函数式接口
Consumer 《T》:消费型接口,有参无返回值
@Test
public void test(){
changeStr("hello",(str) -> System.out.println(str));
}
/**
* Consumer<T> 消费型接口
* @param str
* @param con
*/
public void changeStr(String str, Consumer<String> con){
con.accept(str);
}
Supplier 《T》:供给型接口,无参有返回值
@Test
public void test2(){
String value = getValue(() -> "hello");
System.out.println(value);
}
/**
* Supplier<T> 供给型接口
* @param sup
* @return
*/
public String getValue(Supplier<String> sup){
return sup.get();
}
Function 《T,R》::函数式接口,有参有返回值
@Test
public void test3(){
Long result = changeNum(100L, (x) -> x + 200L);
System.out.println(result);
}
/**
* Function<T,R> 函数式接口
* @param num
* @param fun
* @return
*/
public Long changeNum(Long num, Function<Long, Long> fun){
return fun.apply(num);
}
Predicate《T》: 断言型接口,有参有返回值,返回值是boolean类型
public void test4(){
boolean result = changeBoolean("hello", (str) -> str.length() > 5);
System.out.println(result);
}
/**
* Predicate<T> 断言型接口
* @param str
* @param pre
* @return
*/
public boolean changeBoolean(String str, Predicate<String> pre){
return pre.test(str);
}
在四大核心函数式接口基础上,还提供了诸如BiFunction、BinaryOperation、toIntFunction等扩展的函数式接口,都是在这四种函数式接口上扩展而来的,不做赘述。
问题6.如何判断链表存在环形?
题意:
给定一个链表,判断链表中是否有环。
分析:
方法一、穷举遍历(适合没有重复数据的链表)
首先从头节点开始,依次遍历单链表的每一个节点。每遍历到一个新节点,就从头节点重新遍历新节点之前的所有节点,用新节点ID和此节点之前所有节点ID依次作比较。如果发现新节点之前的所有节点当中存在相同节点ID,则说明该节点被遍历过两次,链表有环;如果之前的所有节点当中不存在相同的节点,就继续遍历下一个新节点,继续重复刚才的操作。
例如上图的链表, 当遍历到节点E的时候,我们需要比较的是之前的节点ABCD,不存在E,这个时候要遍历的下一个新节点是C,我们需要比较的是之前的节点ABCDE,存在C,判断出链表有环。
方法二、哈希表缓存(适合没有重复数据的链表)
首先创建一个以节点ID为键的HashSet集合,用来存储曾经遍历过的节点。然后同样是从头节点开始,依次遍历单链表的每一个节点。每遍历到一个新节点,就用新节点和HashSet集合当中存储的节点作比较,如果发现HashSet当中存在相同节点ID,则说明链表有环,如果HashSet当中不存在相同的节点ID,就把这个新节点ID存入HashSet,之后进入下一节点,继续重复刚才的操作。
这个方法在流程上和方法一类似,本质的区别是使用了HashSet作为额外的缓存。如上图,当遍历到节点E的时候,此时HashSet里面已经有ABCD,添加E,即为ABCDE,当遍历到在再下一个节点C的时候,判断HashSet中已经存在C,即判断有环。
方法三、快慢指针
首先创建两个指针1和2(在java里就是两个对象引用),同时指向这个链表的头节点。然后开始一个大循环,在循环体中,让指针1每次向下移动一个节点,让指针2每次向下移动两个节点,然后比较两个指针指向的节点是否相同。如果相同,则判断出链表有环,如果不同,则继续下一次循环。
第一步:
第二步:
第三步:
第四步:
例如链表A->B->C->D->B->C->D,两个指针最初都指向节点A,进入第一轮循环,指针1移动到了节点B,指针2移动到了C。第二轮循环,指针1移动到了节点C,指针2移动到了节点B。第三轮循环,指针1移动到了节点D,指针2移动到了节点D,此时两指针指向同一节点,判断出链表有环。
此方法也可以用一个更生动的例子来形容:在一个环形跑道上,两个运动员在同一地点起跑,一个运动员速度快,一个运动员速度慢。当两人跑了一段时间,速度快的运动员必然会从速度慢的运动员身后再次追上并超过,原因很简单,因为跑道是环形的。
public static <T> boolean isLoopList(ListNode<T> head){
ListNode<T> slowPointer, fastPointer;
//使用快慢指针,慢指针每次向前一步,快指针每次两步
slowPointer = fastPointer = head;
while(fastPointer != null && fastPointer.next != null){
slowPointer = slowPointer.next;
fastPointer = fastPointer.next.next;
//两指针相遇则有环
if(slowPointer == fastPointer){
return true;
}
}
return false;
}
问题7.如何找到环形的入口?
如何找出有环链表的入环点?
根据这篇文章:链表中环形的入口,我们来分析一下入环口和我们上面这个快慢指针相遇点的关系。
当fast若与slow相遇时,slow肯定没有走遍历完链表(不是一整个环,有开头部分,如上图)或者恰好遍历一圈(未做验证,看我的表格例子,在1处相遇)。于是我们从链表头、相遇点分别设一个指针,每次各走一步,两个指针必定相遇,且相遇第一点为环入口点(慢指针走了n步,第一次相遇在c点,对慢指针来说n=s+p,也就是说如果慢指针从c点再走n步,又会到c点,那么顺时针的CB距离是n-p=s,但是我们不知道s是几,那么当快指针此时在A点一步一步走,当快慢指针相遇时,相遇点恰好是圆环七点B(AB=CB=s))。
第一步:
第二步:
第三步:
/**
* 找到有环链表的入口
* @param head
* @return
*/
public static <T> ListNode<T> findEntranceInLoopList(ListNode<T> head){
ListNode<T> slowPointer, fastPointer;
//使用快慢指针,慢指针每次向前一步,快指针每次两步
boolean isLoop = false;
slowPointer = fastPointer = head;
while(fastPointer != null && fastPointer.next != null){
slowPointer = slowPointer.next;
fastPointer = fastPointer.next.next;
//两指针相遇则有环
if(slowPointer == fastPointer){
isLoop = true;
break;
}
}
//一个指针从链表头开始,一个从相遇点开始,每次一步,再次相遇的点即是入口节点
if(isLoop){
slowPointer = head;
while(fastPointer != null && fastPointer.next != null){
//两指针相遇的点即是入口节点
if(slowPointer == fastPointer){
return slowPointer;
}
slowPointer = slowPointer.next;
fastPointer = fastPointer.next;
}
}
return null;
}