往期文章回顾整理列表:
- Java十道由浅入深的笔面试题第一期(上) 详细解析
- Java十道由浅入深的面试题第一期(下) 详细解析
- Java十一道由浅入深的笔面试题第二期 详细解析
- Java十道由浅入深的笔面试题第三期 详细解析
一、分布式 Session 都有哪几种实现方式?
随着单体应用现在已经很难支撑起企业级的项目,微服务的概念以及实战越来越变得流行,相关实战也已经变得愈发成熟,所以分布式现在越来越变得越重要。这个分布式中的 Session 处理以前是经常被面试官问的问题,随着现在的前后端分离越来越变得流行起来,以 JWT Token 的形式使用的越来越多,而像这种采用 Session 的方式其实是在逐步减少的,不过还是很有值得探讨的意义。
最常用的老四个人觉得就是用非关系型数据库如 Redis、Memcached 来进行 Session 的存储,首先是因为这类数据库性能优越,基于缓存策略,另外就是可以搭建比如 Redis 集群来保证高可用性,简单便捷一些。这种处理方式可以近似认为当前的项目业务是由一台单独的「Session 服务器」,相对来讲适合比较大的业务系统,对用户或者数据行为要求也比较高。
退而求其次一些,可以考虑 Session 复制和特定服务器 Session,也就是网上说的 Session Replication 方式和 Session Sticky 方式。所谓 Session 复制就是当有一台服务器存储 Session 后,通过技术手段将 Session 复制到其余的服务器上达到同步的目的,所以缺点你就能感受到会有 Session 丢失,Session 延迟等问题,不适合大流量结构采用。特定服务器 Session 指的是当用户请求到了某一台服务器的时候,再通过技术手段让这个用户以后就只访问这台服务器,所以方式依然没那么好,局限性、复杂性都比较高,所以我觉得一般只做了解即可。至于具体的技术手段因为我没用过,我也就不说了。面试中也是一样,千万不要背答案,类似于这种题一是要考察你的经验,二是考察你解决问题的思路,如果你去执着于回答「基于 resin/tomcat web 容器本身的 session 复制机制、基于TT/Redis 或 jbosscache 进行 session 共享」这类固定的答案那就务必要理解这些东西,亲手实战过或者了解底层。
二、简单的说一下 Spring 是如何初始化的?
关于这个问题,其实说的是 Web 和 Spring 的结合,我们一般都是在 web.xml 中配置 ContextLoaderListener,通过它来装配 applicationContext.xml。ContextLoaderListener 实现的是 ServletContextListener 接口,并且实现了 contextInitialized 方法,在这个方法进行 WebApplicationContext 的实现。老四之前在《Java 知识体系思维导图 GitHub 开源分享》文章中分享的脑图项目中的 Spring Framework -> Spring Framework 源码.xmind 中也有所总结过,可以前往这里参考一下。这里贴一下文字版:
ContextLoaderListener
- 实现 ServletContextListener 接口,从而实现 contextInitialized 方法,每一个 Web 应用都有一个 ServletContext 与之关联,启动创建,关闭销毁。
- contextInitialized(ServletContextEvent event)
- initWebApplicationContext(ServletContext servletContext),初始化的大致步骤
- WebApplicationContext 存在性的校验
查看 ServletContext 实例中是否有对应 key 的属性。这个 key 就是「String ROOTWEBAPPLICATIONCONTEXTATTRIBUTE = WebApplicationContext.class.getName() + “.ROOT”;」
- 创建 WebApplicationContext 实例
- createWebApplicationContext(ServletContext sc)
在初始化的过程中,程序首先会读取 Contextloader 类的同目录下的属性文件 ContextLoader.properties,并根据其中的配置提取将要实现 WebApplicationContext 接口的实现类,并根据这个实现类通过反射的方式进行实例的创建。
- 将实例记录在 servletContext 中。
- 映射当前的类加载器与创建的实例到全局变量 currentContextPerThread 中。
- initWebApplicationContext(ServletContext servletContext),初始化的大致步骤
三、简单的说一下什么是一致性 Hash 算法?
熟悉一致性 Hahs 算法之前你需要了解一下常规 Hash 算法和分布式缓存相关的一些概念,正是前者的使用局限和后者的扩展、容灾要求衍生出来了一致性 Hash 算法。假设有这样的场景,我有 8 台服务器,现在需要缓存 800 万的数据。普通的 Hash 算法,就是对每一条数据进行 Hash 然后对服务器的个数进行取余(%)运算,这种方式正常情况下没什么问题,但是当我们的服务器环境发生变化,比如说一台服务器挂了,或者数据量激增 1000 万需要增加两台服务器,那么这种对服务器取余运算的 Hash 方式,在存储数据的时候数据的位置要发生变化,之前原来的缓存数据也会失效(Hash(Data A) % 8 –> Hahs(Data A) % 10)。
从数据处理的角度来讲,这是灾难性的。我们需要的是,当服务器的数量发生变化或者宕机,要对本身已经缓存的数据不要产生致命式的影响,换句话说,要把对原有数据的影响程度降到最低,所以一致性 Hash 算法应运而生,他依然是采用取余运算的方式,不过不是对服务器的数量取模(在这里的概念说取余和取模我觉得都行),而是先设置一个圆,整个圆所有点代表了无符号整数(2^32-1),用服务器对这个整数取模然分别定为到这个圆上面。同理数据也是这样找到自己的定位点,然后顺时针将自己放入到离自己最近的那一台服务器上面。
通过这样的改造,我们可以发现,当我们新增一台服务器或者是挂掉一台服务器的时候,数据的影响只会有一台服务器,其余的服务器不会受到任何影响,这样的灾难就做到了最低化。
另外,在一致性 Hash 算法中还有一个虚拟节点的问题,这是因为涉及的服务器数量少会引起数据缓存都会被集中 Hash 到某一台特定的服务器上面。比如两台服务器在圆上位置接近,那么按照顺时针的走向基本都把数据 Hash 到一台服务器上。如下图所示:
应对这种情况,就有一个虚拟节点的概念,比如说现在有服务器 A 和服务器 B,那么就将每台服务器虚拟出来三台服务器假似的认为现在有 6 台服务器,分别是 Server A0、Server A1、Server A2、Server B0、Server B1、Server B2,然后将这六台服务器 Hash 到圆上,当有数据的时候,如果缓存到 Server A 属性上的结点,就把数据缓存到服务器 A 上面,要么就是缓存到服务器 B 上面。
最后摘抄「crossoverJie」总结的一致性 Hash 算法的特点:
- 构造一个 0 ~ 2^32-1 大小的环。
- 服务节点经过 hash 之后将自身存放到环中的下标中。
- 客户端根据自身的某些数据 hash 之后也定位到这个环中。
- 通过顺时针找到离他最近的一个节点,也就是这次路由的服务节点。
- 考虑到服务节点的个数以及 hash 算法的问题导致环中的数据分布不均匀时引入了虚拟节点。
四、简单的说一下 Java 中的引用传递和值传递?
首先面试官考你这个问题有两种情况,一是他也不知道 Java 中其实没有「引用传递」的这种说法,二就是拿来引导你看你是否明确 Java 中值传递的清晰概念。所以关于这个问题的探索,首先明确死掐的一点就是:Java 中其实没有引用传递的说法,在 Java 中一切参数都是按值传递的。这一点在 Java 语言规范说明(8.4.1)或者是官方 Java 教程中我们可以找到。相关地址如下:
那么关于「引用传递」这个概念是怎么来的呢?其实这里的「引用传递」我们要理解为是引用是按照值来传递的。还是举例子吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
package com.glorze.java; /** * Java 值传递测试类 * @ClassName Glorze * @author: 高老四 * @since: 2019年6月28日 下午4:51:14 */ public class Glorze { private String name; public Glorze(String name) { super(); this.name = name; } public static void changeName(Glorze glorze) { glorze.setName("DownHub"); glorze = new Glorze("付老三"); System.out.println("执行 changeName 方法:" + glorze.getName()); } public static void main(String[] args) { Glorze glorze = new Glorze("高老四"); changeName(glorze); System.out.println("改变 name 的结果:" + glorze.getName()); } public String getName() { return name; } public void setName(String name) { this.name = name; } } |
代码的结果输出:
执行 changeName 方法:付老三
改变 name 的结果:DownHub
这里我们看到 main 方法中输出的结果是 DownHub 而不是付老三,正好证明了值传递。那底层原理是怎么回事呢?我大概说一下,也希望我的表达你能看懂。我们都知道「Glorze glorze」这样的声明其实不是创建对象,而是创建一个 Glorze 对象的引用、指向(在 C++ 中就是那个折磨人的指针概念),我们假设「Glorze glorze = new Glorze(“高老四”);」这个对象在内存中指向的地址是 88,然后这个指向就会传递给 changeName 方法当中,在这个方法中,执行了「glorze.setName(“DownHub”);」代表的是将 88 这个地址的 name 属性的值设置为「DownHub」,管他原来是什么,反正现在是「DownHub」了,但是地址 88 没有变化。然后执行「glorze = new Glorze(“付老三”);」代表又创建了一个新的对象,咱们假设他的地址值是 98,然后这个地址的值又赋给了传过来的 glorze,后面同理,为 98 这个地址的 name 属性设置值为「付老三」。
然后我们回到 main 方法上面,在 main 方法里面,glorze 的地址其实没有改变,因为他代表其实不是对象,而是一个指向,或者说引用,总之不是一个具体的对象,glorze 的在内存中的地址指向的依然是 88,但是 name 属性的值的确是被改变了。这也就是我们常说的「改变了所指对象的属性没有改变指向」。Java 就是这么玩的,记住就可以了,不要让别的说法影响这套理论就好,我们总是被形参误导,以为传过去的就是一个活生生的对象,但其实传递过去的只是指针指向对象的数据而已。
以上这些说法老四参考的是 stackoverflow,原文地址如下:
五、计算两个日期之间的差距?
这个问题老四主要不是想写这个问题有多少种写法,而是想借这个问题主要描述一下 Java 8 新增的一些关于日期的新特性,在 Java 8 中新增了「java.time」工具包。老四在之前开源的脑图项目《Java 知识体系思维导图 GitHub 开源分享》中的「Java基础->J2SE->Java 8 新特性->日期/时间类」有所总结过,可以点击这里参考一下,下面贴一下文字版:
日期/时间类
- java.time
- Clock,获取指定时区的当前日期、时间,可以取代 System.currentTimeMills()
- Duration,代表持续时间
- Instant,代表一个具体的时刻,可以精确到纳秒。
- LocalDate,代表不带时区的日期。
- LocalTime,代表不带时区的时间
- LocalDateTime,不带时区的日期、时间
- MonthDay,代表月日
- Year,年
- YearMonth,年月
- ZonedDateTime,时区化的日期、时间
- ZoneId,代表一个时区
- DayOfWeek,枚举类,定义周日到周六的枚举值
- Month,枚举类,定义一月到十二月的枚举值。
贴一下相关的代码,更多知识自己可以看一下 API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
package com.glorze; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.Date; /** * Java 日期类 * * @ClassName Glorze * @author: 高老四 * @since: 2019年6月28日 下午4:51:14 */ public class Glorze { /** * 获取两个日期之间的毫秒数 * * @Title: getDistanceOfTwoDate * @param before * @param after * @return double */ public static Long getDistanceOfTwoDate(Date before, Date after) { long beforeTime = before.getTime(); long afterTime = after.getTime(); return afterTime - beforeTime; } /** * Java 8 下 获取两个日期之间的毫/纳秒数 * @Title: getDistanceBetweenDate * @throws InterruptedException * @return Long */ public static Long getDistanceBetweenDate() throws InterruptedException { Instant now = Instant.now(); Thread.sleep(60000); // Long used = ChronoUnit.MILLIS.between(now, Instant.now()); Long result = ChronoUnit.NANOS.between(now, Instant.now()); // 使用 Duration.between(inst1, inst2).toMillis() Instant inst1 = Instant.now(); Instant inst2 = inst1 .plus(Duration.ofSeconds(8)); Long result2 = Duration.between(inst1, inst2).toMillis(); return result; } /** * 获取两个日期之间的分钟数、小时、天数 * * @Title: getDistanceOfTwoDate * @param before * @param after * @return double */ public static Long getMinuteOfTwoDate(Date before, Date after) { Long millisecond = getDistanceOfTwoDate(before, after); return millisecond / (60 * 1000); } /** * Java 8 下日期的简单使用,计算间隔天数 * @Title: getDistanceOfDay * @param beginDate 开始日期 * @param endDate 结束日期 * @return Long */ public static Long getDistanceOfDay(LocalDate beginDate, LocalDate endDate) { Long days = endDate.toEpochDay() - beginDate.toEpochDay(); return days; } public static void main(String[] args) throws InterruptedException { LocalDate beginDate = LocalDate.of(2019, 6, 1); LocalDate endDate = LocalDate.of(2019, 7, 1); Long days = getDistanceOfDay(beginDate, endDate); System.out.println("相差: " + days + "天."); Long result = getDistanceBetweenDate(); System.out.println("相差: " + result + "纳秒."); } } |
六、说一下 Spring 中 「@Autowired」和「@Resource」两个注解的区别?
说起这两个注解之前,首先我们需要明确一些关于 Spring 装配 Bean 的方式都有哪些,Spring 自动装配 Bean 都有哪些模式。关于 Spring 管理处理 Bean 的一系列相关知识点老四也整理了思维导图,也一并放在了《Java 知识体系思维导图 GitHub 开源分享》项目中,可以前往这里参考一下。明确了 Spring 管理 Bean 的方式后,我们也就清晰的知道了「@Autowired」和「@Resource」的区别。这里贴一下文字版:
Spring 管理 Bean 相关知识总结
- 装配 bean 的三种方式
- 在 XML 中显式配置,由于现在基本是 Spring Boot 的天下了,所以现在 XML 的配置主键被淡化。
- 在 Java 类中显式配置
- @Bean 注解
- 隐式的 bean 发现机制和自动装配
- @Component 注解
声明这是一个 bean 组件,在 Spring 启动时就可以将这个类作为一个 bean 加入上下文中。
- @Autowired 注解
如果两个类之间存在依赖时,就需要用到 @Autowired 注解,这个注解的作用就是将类自动注入到所用到的参数中。
- 自动装配 bean
- byName 模式
- byType 模式
- 构造函数模式
- 与 byType 模式功能上相同,只不过是使用的是构造函数而不是 setter 来执行注入
- 默认模式
- 自动在构造函数和 byType 模式之间选择
- 如果 Bean 默认无参构造函数,就使用 byType 模式
- 如果存在显示构造函数,就使用构造函数模式
- 自动在构造函数和 byType 模式之间选择
- 无
- 这就是 Spring 的默认设置
首先,这两个注解都是用来自动注入的,两个注解也都既可以注入一个接口,也可以直接注入一个实例。但是注意 @Resource 是 JDK 中的注解,它的注入逻辑分三种判断:
- 如果指定了 name 属性,那么就按 name 属性的名称装配;即 byName 模式
- 如果没有指定 name 属性,那就按照要注入对象的字段名查找依赖对象;
- 如果按默认名称也查找不到依赖对象,那么就按照类型查找。即 byType 模式。
而 @Autowired 则是 byType 模式,它可以配合 @Qualifier 注解实现 byName 模式。但是他的限制是「当注入一个接口的时候,接口的实现类智能有一个,如果存在多个,它整不明白该选择哪一个来注入」。
七、说说你知道的几种主要的 jvm 参数?
主要是考察你有没有过 JVM 调优的经验,所以 Java 虚拟机的原理也是面试中常考的系列之一,需要好好掌握,老四也会慢慢讲,后期也会将思维导图梳理出来供你参考。关于 JVM 调优和 JVM 的参数调整要求我们必须先掌握各种 JVM 参数都是干嘛的,才能尝试着进行调优,否则就是自寻死路。而 JVM 中的参数还是比较多的,要是讲起来需要花费大量时间和精力,最后你没实战的话可能也记不住太多。老四为此单独画了一张脑图,放在了开源项目《Java 知识体系思维导图 GitHub 开源分享》中,可以点击这里直达。
简单的说一下这个 JVM 使用参数的整理,它分为三类,分别是标准参数、X 参数和 XX 参数,这里面的难点就是 XX 参数,所以需要重点掌握。另外就是早所有的参数调优当中「内存调优」、「垃圾回收」这两类是需要重点看一下的,甚至你选找一下资料模拟环境来进行实战来加深印象。相关知识点老四都进行了思维导图的总结,可以根据我的思路进行整理学习,希望对你有所帮助。
八、简述 Java 中的强引用(Strong Reference)、 软引用(Soft Reference)、 弱引用(Weak Reference)、 虚引用(Phantom Reference)?
关于这部分知识其实是属于 JVM 知识体系中垃圾收集与内存分配策略的基础,关于判断对象的生死存亡,我们都知道在 Java 虚拟机中判断对象的存活用的是可达性分析法,而引用计数法由于很难解决对象循环引用的问题而没有采用。但是关于引用,我们的前辈设计者们希望引用不单单是「被引用」和「没有被引用」两种状态,更多的是希望通过的对象的存货形式和重要性以及根据内存的分配策略来进行引用强弱的区分,从而更好的控制对象的垃圾回收。所以在 JDK 1.2 之后这四个引用的概念应运而生。关于对象的存活判断的基本知识点,老四也在《Java 知识体系思维导图 GitHub 开源分享》项目中的 JVM 目录内画了知识思维脑图,由于文字过多,这里贴一下图片(右键新标签打开图片可进行高清放大查看),更多相关知识点也可点击这里直达参考。
九、简单说一下 REST、RESTful 是什么?
突然发现很多人其实不是很深入的了解到底什么是「REST」,在面试过程中很多人也将「RESTful」风格描述成一种协议,最多也就说出一句统一资源定位这样的概念。其实在现在微服务、分布式大行其道的天下,REST 风格的 WEB 对于我们来说越来越重要。所以老四顺便整理了关于 REST、JAX-RS 相关的知识点,虽然说这里面涉及到的技术面比较薄弱,但是这里面涉及到的很多概念都是比较重要的。希望对大家有所帮助。目前整理的是一些比较基础的概念,后期会围绕 Java 继续整理。具体参考老四的开源脑图项目《Java 知识体系思维导图 GitHub 开源分享》 ,也可以点击这里直达。
十、Java 数组和链表两种结构的操作效率,在哪些情况下(从开头开始,从结尾开始,从中间开始),哪些操作(插入,查找,删除)的效率高?
其实这是数据结构的基本知识,我们都知道数组在内存中是连续存储的,基于下标标识的随机读取,所以查找相当快,相比较之下,链表在内存中是不连续存储,基于节点的指针进行元素之间的连接,所以对于元素的查找,就必须从头结点按顺序查找。所以在读取元素的情况下,当然是数组效率高。
再说插入的情况,对于数组,无论从头还是从中间开始,后面的元素都需要往后移动,新的元素才能插入进来,所以时间复杂度是 O(n),对于尾部插入,其实相当于数组的更新操作,所以时间复杂度是 O(1);那么对于链表,排除查找的过程,单论插入元素而言,无论怎么插入,都是改变节点指针的指向,所以时间复杂度都是 O(1),效率极高。同理,对于删除和修改道理一样,老四不多哔哔了。
关于数据结构的知识梳理,老四也放在了开源脑图项目当中,写完这十道笔面试题发现都是在为我的开源脑图做推广呢?但是话说回来,万物皆脑图,这个东西的确是个好东西,可以让我们的知识体系变得清晰起来,所以老四也建议大家平时学习的时候多画一些这类东西,梳理自己的知识体系。一篇文章看起来十道题比较少,但是这里面涉及到知识和拓展还是相当多的,所有的文字都不是老四在个八小时之内就完成的,所以我们在看题做题的时候,题目考察的核心以及相关的核心扩展才是最重要的,这些知识点非常值得我们去研究和记忆,希望对你有所帮助吧。
更博不易,如果觉得文章对你有帮助并且有能力的老铁烦请捐赠盒烟钱,点我去赞助。或者扫描文章下面的微信/支付宝二维码打赏任意金额(点击「给你买杜蕾斯」),也可以加入本站封闭式交流论坛「DownHub」开启新世界的大门,老四这里抱拳谢谢诸位了。捐赠时请备注姓名或者昵称,因为您的署名会出现在赞赏列表页面,您的捐赠钱财也会被用于小站的服务器运维上面,再次抱拳感谢。