目录
一、可以讲一下ArrayList的自动扩容机制吗?
二、什么是深拷贝和浅拷贝?
三、HashMap中的hash方法为什么要右移16位异或?
四、HashMap啥时候扩容,为什么扩容?
存储容器的设计
容器的大小
长度不够怎么办
HashMap是如何扩容的?
为什么扩容因子是0.75
面试题的标准回答
五、强引用、软引用、弱引用、虚引用有什么区别?
六、Java有几种文件拷贝方式,哪一种效率最高?
八、finally块一定会执行吗?
问题解析
回答
九、在Java中实现单例模式有哪些方法
考察目的
问题解析
回答
十、Java SPI是什么?有什么用?
考察目标
问题解析
回答
ArrayList是一个数组结构的存储容器,默认情况下,数组的长度是10。
当然们也可以在构建ArrayList对象的时候自己指定初始长度。
随着在程序里面不断的往ArrayList中添加数据,当添加的数据达到10个的时候,ArrayList就没有多余容量可以存储后续的数据。
这个时候ArrayList会自动触发扩容。
扩容的具体流程很简单,
1. 首先,创建一个新的数组,这个新数组的长度是原来数组长度的1.5倍。
2. 然后使用Arrays.copyOf方法把老数组里面的数据拷贝到新的数组里面。
扩容完成后再把当前要添加的元素加入到新的数组里面,从而完成动态扩容的过程。
深拷贝和浅拷贝是用来描述对象或者对象数组这种引用数据类型的复制场景的。
浅拷贝,(如图)就是只复制某个对象的指针,而不复制对象本身。
这种复制方式意味着两个引用指针指向被复制对象的同一块内存地址。
深拷贝,(如图)会完全创建一个一模一样的新对象,新对象和老对象不共享内存,也就意味着对新对象的修改不会影响老对象的值。
在Java里面,无论是深拷贝还是浅拷贝,都需要通过实现Cloneable接口,并实现clone()方法。
然后们可以在clone()方法里面实现浅拷贝或者深拷贝的逻辑。
实现深拷贝的方法有很多,比如
1. 通过序列化的方式实现,也就是把一个对象先序列化一遍,然后再反序列化回来,就会得到一个完整的新对象。
2. 在clone()方法里面重写克隆逻辑,也就是对克隆对象内部的引用变量再进行一次克隆。
面试点评
这个问题属于Java基础范畴,它很重要。
如果不小心使用错了拷贝方法,就会导致多个线程同时操作一个对象造成数据安全问题。一般情况下这个问题是针对1~3年左右的开发人员。
之所以要对hashCode无符号右移16位并且异或,核心目的是为了让hash值的散列度更高,尽可能减少hash表的hash冲突,从而提升数据查找的性能。
(如图)在HashMap的put方法里面,是通过Key的hash值与数组的长度取模计算得到数组的位置。
而在绝大部分的情况下,n的值一般都会小于2^16次方,也就是65536。
所以也就意味着i的值,始终是使用hash值的低16位与(n-1)进行取模运算,这个是由与运算符&的特性决定的。
这样就会造成key的散列度不高,导致大量的key集中存储在固定的几个数组位置,很显然会影响到数据查找性能。
因此,为了提升key的hash值的散列度,在hash方法里面,做了位移运算。
(如图)首先使用key的hashCode无符号右移16位,意味着把hashCode的高位移动到了低位。
然后再用hashCode与右移之后的值进行异或运算,就相当于把高位和低位的特征进行和组合。从而降低了hash冲突的概率。
在任何语言中,们希望在内存中临时存放一些数据,可以用一些官方封装好的集合(如图),比如:List、HashMap、Set等等。作为数据存储的容器。
当们创建一个集合对象的时候,实际上就是在内存中一次性申请一块内存空间。而这个内存空间大小是在创建集合对象的时候指定的。
比如List的默认大小是10、HashMap的默认大小是16。
在实际开发中,们需要存储的数据量往往大于存储容器的大小。
针对这种情况,通常的做法就是扩容。
当集合的存储容量达到某个阈值的时候,集合就会进行动态扩容,从而更好的满足更多数据的存储。
(如图)List和HashMap,本质上都是一个数组结构,所以基本上只需要新建一个更长的数组,然后把原来数组中的数据拷贝到新数组就行了。
以HashMap为例,它是什么时候触发扩容以及扩容的原理是什么呢?
当HashMap中元素个数超过临界值时会自动触发扩容,这个临界值有一个计算公式。threashold=loadFactor*capacity
loadFactor的默认值是0.75,capacity的默认值是16,也就是元素个数达到12的时候触发扩容。
扩容后的大小是原来的2倍。
由于动态扩容机制的存在,所以们在实际应用中,需要注意在集合初始化的时候明确指定集合的大小。
避免频繁扩容带来性能上的影响。
假设们要向HashMap中存储1024个元素,如果按照默认值16,随着元素的不断增加,会造成7次扩容。
而这7次扩容需要重新创建Hash表,并且进行数据迁移,对性能影响非常大。
最后,可能有些面试官会继续问,为什么扩容因子是0.75?
扩容因子表示Hash表中元素的填充程度,扩容因子的值越大,那么触发扩容的元素个数更多,虽然空间利用率比较高,但是hash冲突的概率会增加。
扩容因子的值越小,触发扩容的元素个数就越少,也意味着hash冲突的概率减少,但是对内存空间的浪费就比较多,而且还会增加扩容的频率。
因此,扩容因子的值的设置,本质上就是在冲突的概率以及空间利用率之间的平衡。
0.75这个值的来源,和统计学里面的泊松分布有关。
(如图)我们知道,HashMap里面采用链式寻址法来解决hash冲突问题,为了避免链表过长带来时间复杂度的增加,所以链表长度大于等于7的时候,就会转化为红黑树,提升检索效率。
当扩容因子在0.75的时候,链表长度达到8的可能性几乎为0,也就是比较好的达到了空间成本和时间成本的平衡。
当HashMap元素个数达到扩容阈值,默认是12的时候,会触发扩容。
默认扩容的大小是原来数组长度的2倍,HashMap的最大容量是Integer.MAX_VALUE,也就是2的31次方-1。
“强引用、软引用、弱引用、虚引用有什么区别?”
不同的引用类型,主要体现的是对象不同的可达性状态和对垃圾收集的影响。
强引用,就是普通对象的引用,只要还有强引用指向一个对象,就能表示对象还“活着”,垃圾收集器无法回收这一类对象。
只有在没有其他引用关系,或者超过了引用的作用域,再或者显示的把引用赋值为null的时候,垃圾回收器才能进行内存回收。
软引用,是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,
只有当JVM认为内存不足时,才会去试图回收软引用指向的对象。
软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
弱引用,相对强引用而言,它允许在存在引用关联的情况下被垃圾回收的对象
在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,垃圾回收期都会回收该内存虚引用,它不会决定对象的生命周期,它提供了一种确保对象被finalize以后,去做某些事情的机制。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收,然后们就可以在引用的对象的内存回收之前采取必要的行动。
第一种,使用java.io包下的库,使用FileInputStream读取,再使用FileOutputStream写出。
第二种,利用java.nio包下的库,使用transferTo或transfFrom方法实现。
第三种,Java标准类库本身已经提供了Files.copy的实现。
对于Copy的效率,这个其实与操作系统和配置等情况相关,在传统的文件IO操作里面,们都是调用操作系统提供的底层标准IO系统调用函数read()、write(),由于内核指令的调用会使得当前用户线程切换到内核态,然后内核线程负责把相应的文件数据读取到内核的IO缓冲区,再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作。而NIO里面提供的NIOtransferTo和transfFrom方法,也就是常说的零拷贝实现。它能够利用现代操作系统底层机制,避免不必要拷贝和上下文切换,因此在性能上表现比较好。
七、聊聊你知道的设计模式
大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
创建型模式:是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式、单例模式、构建器模式、原型模式。
结构型模式:是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。
常见的结构型模式,包括桥接模式、适配器模式、装饰者模式、代理模式、组合模式、外观模式、享元模式等。
行为型模式:是从类或对象之间交互、职责划分等角度总结的模式。
比较常见的行为型模式有策略模式、解释器模式、命令模式、观察者模式、迭代器模式、模板方法模式、访问者模式。
这个问题,很明显是考察Java基础。
finally语句块在实际开发中使用得非常多,它是和try语句块组合使用通常情况下,不管有没有触发异常,finally语句块中的代码是必然会执行的,所以们会把资源的释放、或者业务日志的打印放在finally语句块里面。
所以,当大家把这个理念当成是固定的公式以后,就很少会去思考finally语句块什么情况下不执行。
这也是难倒很多求职者的原因,所以认为这个问题主要考察两个方面:
1、对finally关键字的理解程度,其实就是考察Java基础,良好的Java基础能够写出更加稳定和健壮性的代码
2、是否具备对技术的探索精神,这样的人在技术的成长速度上会比一般人更快下面来看看高手怎么回答这个问题吧!
finally语句块在两种情况下不会执行:
1、程序没有进入到try语句块因为异常导致程序终止,这个问题主要是开发人员在编写代码的时候,异常捕获的范围不够。
2、在try或者cache语句块中,执行了System.exit(0)语句,导致JVM直接退出
这是属于设计模式里面的一个问题。
我们知道Java有23三种设计模式,但是真正在实际开发中,能够熟练使用设计模式的人很少。
很多人说没有场景,但其实只是因为他们只理解了设计模式的概念。
这个问题考察求职者对于设计模式的理解和应用。
本质上还是考察基本功,当然,针对不同工作年限,考察的深度不同。
对于刚工作的同学,只需要了解什么是单例以及如何写出一个单例就行
对于工作年限较长的同学,还需要考察单例模式的性能、以及避免破坏单例的情况等
单例模式,就是一个类在任何情况下绝对只有一个实例,并且提供一个全局访问点来获取该实例。
要实现单例,至少需要满足两个点:
1、私有化构造方法,防止被外部实例化造成多实例问题
2、提供一个静态方位作为全局访问点来获取唯一的实例对象
在Java里面,至少有6种方法来实现单例。
第一种:是最简单的实现,通过延迟加载的方式进行实例化,并且增加了同步锁机制避免多线程环境下的线程安全问题(如图)。
但是这种加锁会造成性能问题,而且同步锁只有在第一次实例化的时候才产生作用,后续不需要。第二种:改进方案,通过双重检查锁的方式,减少了锁的范围来提升性能。
第三种:通过饿汉式实现单例(如图)。
这种方式在类加载的时候就触发了实例化,从而避免了多线程同步问题。
第四种:与这个方式类似的实现(如图)
通过在静态块里面实例化,而静态块是在类加载的时候触发执行的,所以也只会执行一次。
上面两种方式,都是在类加载的时候初始化,没有达到延迟加载的效果,当然本身影响不大,但是其实还是可以更进一步优化,就是可以在使用的时候去触发初始化。
像这种写法,把INSTANCE写在一个静态内部类里面,由于静态内部类只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类。
所以当Singleton被加载的时候不会初始化INSTANCE,从而实现了延迟加载。
另外,我们还可以使用枚举类来实现(如图)。
这种写法既能避免多线程同步问题,又能防止反序列化重新创建新对象,也是一个比较好的方案。
当然,除了这些方案以外,也许还有更多的写法,只需要满足单例模式的特性就行了。
可以通过3种方式来实现单例:
第一种:是通过双重检查锁的方式,它是一种线程安全并且是延迟实例化的方式,但是因为加锁,所以会有性能上的影响。
第二种:是通过静态内部类的方式实现,它也是一种延迟实例化,由于它是静态内部类,所以只会使用的时候加载一次,不存在线程安全问题。
第三种:是通过枚举类的方式实现,它既是线程安全的,又能防止反序列化导致破坏单例问题。
但是,多线程、克隆、反序列化、反射,都有可能会造成单例的破坏。
而认为,通过枚举的方式实现单例,是能够解决所有可能被破坏的情况。
这道题考察难度偏中等,对于没怎么去研究过源码的同学来说,SPI是非常陌生的概念考察人群主要还是3到5年比较多。
3~5年属于中高端Java开发人群,因此考察目的也很明显:
1、了解求职者对于技术领域的理解程度
2、实现高级开发的人才选拔
Java这个行业没有人才评级标准,所以在面试的时候,面试官也比较难去界定你的职级。
所以在互联网企业,技术面的考察会比较深。
所以,要想回答好这个问题,还是要有一些自己的见解。
Java SPI,全称是Service Provider Interface。
它是一种基于接口的动态扩展机制,相当于Java里面提供了一套接口。
然后第三方可以实现这个接口来完成功能的扩展和实现。
(如图),举个简单的例子。
在Java的SDK里面,提供了一个数据库驱动的接口java.sql.Driver。
它的作用是提供数据库的访问能力。
不过,在Java里面并没有提供实现,因为不同的数据库厂商,会有不同的语法和实现。所以只能由第三方数据库厂商来实现,比如Oracle是oracle.jdbc.OracleDriver,mysql是com.mysql.jdbc.Driver.
然后在应用开发的时候,根据集成的驱动实现连接到对应数据库。
Java中SPI机制主要思想是将装配的控制权移到程序之外实现标准和实现的解耦,以及提供动态可插拔的能力,在模块化的设立中,这种思想非常重要。
实现JavaSPI,需要满足几个基本的格式(如图):
1、需要先定义一个接口,作为扩展的标准
2、在classpath目录下创建META-INF/service文件目录
3、在这个目录下,以接口的全限定名命名的配置文件,文件内容是这个接口的实现类
4、在应用程序里面,使用ServiceLoad,就可以根据接口名称找到classpath所有的扩展时间
然后根据上下文场景选择实现类完成功能的调用。
Java SPI 有一定的不足之处,比如,不能根据需求去加载扩展实现,每次都会加载扩展接口的所有实现类并进行实例化,实例化会造成性能开销,并且加载一些不需要用到的实现类,会导致内存资源的浪费。
Java SPI是Java里面提供的一种接口扩展机制。
作用有两个:
1、把标准定义和接口实现分离,在模块化开发中很好的实现了解耦
2、实现功能的扩展,更好的满足定制化的需求
除了Java的SPI以外,基于SPI思想的扩展实现还有很多,比如Spring里面的SpringFactoriesLoader。
Dubbo里面的ExtensionLoader,并且Dubbo还在SPI基础上做了更进一步优化,提供了激活扩展点、自适应扩展点。