🌟 前言
作者简介:我是廖志伟,一名Java资深开发工程师、Java领域优质创作者、CSDN博客专家、51CTO专家博主、幕后大佬社区创始人等头衔。拥有多年一线研发和团队管理经验,研究过主流框架的底层源码(Spring、SpringBoot、Spring MVC、SpringCould、Mybatis、Dubbo、Zookeeper),消息中间件底层架构原理(RabbitMQ、RockerMQ、Kafka)、Redis缓存、MySQL关系型数据库、 ElasticSearch全文搜索、MongoDB非关系型数据库、Apache ShardingSphere分库分表读写分离、设计模式、领域驱动DDD。
文章背景:大概几年前吧,一次在Boss上面找工作的时候发现了一些企业招聘的时候,发现招聘要求上面写了有个人技术博客和开源项目的优先,后面我也是慢慢的积累Java面试相关的知识点,不仅限于网上千篇一律的八股文,也有很多实战面试被问到过的,后面还组了一个百来号人的微信群,大家相互面试,加上前一段时间也面试过几家公司(每家二轮技术面试都是三个小时以上,面试比工作更心累),所以将自己的面试备战的知识点罗列分享出来给大家。
读者需知:全文总字数近二十五万字,阅读时间过长,建议收藏起来,方便下次查找。当然如果大家觉得我写的还不错的话,给文章点一下赞,让文章上热榜,那就更好了,嘿嘿。
文章更新:这篇文章是我前前后后大概花了好几个月时间累积的写出来的,文章性质还是有些偏向面试相关的,有很多底层细节知识点,未来也会继续添加新内容,如果文章中出现知识点不对的情况,欢迎大家在评论区矫正。
个人技术博客主页:欢迎大家来关注我哟:https://blog.csdn.net/java_wxid
个人开源项目主页:欢迎大家来评价(Star) :https://gitee.com/java_wxid/java_wxid
前期java_wxid项目可能会以博文总结为主,后期会录制视频投放到哔哩哔哩上面去
该项目专栏地址:https://blog.csdn.net/java_wxid/category_11976766.html
希望这个项目到后期可以成功加入GVP计划
好了,废话不多说,正文开始
🌟 Java基础知识点
🍊 计算机基础问题
🎉 深拷贝和浅拷贝
深拷贝和浅拷贝是用来描述对象或者对象数组这种引用数据类型的复制场景的。
浅拷贝就是只复制某个对象的指针,而不复制对象本身。
这种复制方式意味着两个引用指针指向被复制对象的同一块内存地址。
深拷贝会完全创建一个一模一样的新对象,新对象和老对象不共享内存,也就意味着对新对象的修改不会影响老对象的值。
在Java里面,无论是深拷贝还是浅拷贝,都需要通过实现Cloneable接口,并实现clone()方法。
然后我们可以在clone()方法里面实现浅拷贝或者深拷贝的逻辑。
实现深拷贝的方法有很多,比如
- 通过序列化的方式实现,也就是把一个对象先序列化一遍,然后再反序列化回来,就会得到一个完整的新对象。
- 在clone()方法里面重写克隆逻辑,也就是对克隆对象内部的引用变量再进行一次克隆。
🎉 伪共享的概念以及如何避免
首先,计算机工程师为了提高CPU的利用率,平衡CPU和内存之间的速度差异,在CPU里面设计了三级缓存。
CPU在向内存发起IO操作的时候,一次性会读取64个字节的数据作为一个缓存行,缓存到CPU的高速缓存里面。
在Java中一个long类型是8个字节,意味着一个缓存行可以存储8个long类型的变量。
这个设计是基于空间局部性原理来实现的,也就是说,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
所以缓存行的设计对于CPU来说,可以有效的减少和内存的交互次数,从而避免了CPU的IO等待,以提升CPU的利用率。
正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题。
像这样一种情况,CPU0上运行的线程想要更新变量X、CPU1上的线程想要更新变量Y,而X/Y/Z都在同一个缓存行里面。
每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。
一旦运行在某个CPU上的线程获得了所有权并执行了修改,就会导致其他CPU中的缓存行失效。
这就是伪共享问题的原理。
因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响。
这个问题的解决办法有两个:
- 使用对齐填充,因为一个缓存行大小是64个字节,如果读取的目标数据小于64个字节,可以增加一些无意义的成员变量来填充。
- 在Java8里面,提供了@Contented注解,它也是通过缓存行填充来解决伪共享问题的,被@Contented注解声明的类或者字段,会被加载到独立的缓存行上。
在Netty里面,有大量用到对齐填充的方式来避免伪共享问题。
🎉 网络四元组
四元组,简单理解就是在TCP协议中,去确定一个客户端连接的组成要素,它包括源IP地址、目标IP地址、源端口号、目标端口号。
正常情况下,我们对于网络通信的认识可能是这样
服务端通过ServerSocket建立一个对指定端口号的监听,比如8080。 客户端通过目标ip和端口就可以和服务端建立一个连接,然后进行数据传输。
但是我们知道的是,一个Server端可以接收多个客户端的连接,比如像这种情况
那,当多个客户端连接到服务端的时候,服务端需要去识别每一个连接。
并且TCP是全双工协议,也就是说数据允许在连接的两个方向上同时传输,因此这里的客户端,如果是反向通信,它又变成了服务端。
所以基于这两个原因,就引入了四元组的设计,也就是说,当一个客户端和服务端建立一个TCP连接的时候,通过源IP地址、目标IP地址、源端口号、目标端口号来确定一个唯一的TCP连接。因为服务器的IP和端口是不变的,只要客户端的IP和端口彼此不同就OK了。
比如像这种情况
同一个客户端主机上有三个连接连到Server端,那么这个时候源IP相同,源端口号不同。此时建立的四元组就是(10.23.15.3,59461 , 192.168.8.135,8080)
其中,源端口号是每次建立连接的时候系统自动分配的。
🎉 TCP协议为什么要设计三次握手?
关于这个问题,我会从下面3个方面来回答。
1.TCP协议,是一种可靠的,基于字节流的,面向连接的传输层协议。
- 可靠性体现在TCP协议通信双方的数据传输是稳定的,即便是在网络不好的情况下,TCP都能够保证数据传输到目标端,而这个可靠性是基于数据包确认机制来实现的。
- TCP通信双方的数据传输是通过字节流来实现传输的
- 面向连接,是说数据传输之前,必须要建立一个连接,然后基于这个连接进行数据传输
2.因为TCP是面向连接的协议,所以在进行数据通信之前,需要建立一个可靠的连接,TCP采用了三次握手的方式来实现连接的建立。所谓的三次握手,就是通信双方一共需要发送三次请求,才能确保这个连接的建立。
- 客户端向服务端发送连接请求并携带同步序列号SYN。
- 服务端收到请求后,发送SYN和ACK, 这里的SYN表示服务端的同步序列号,ACK表示对前面收到请求的一个确认,表示告诉客户端,我收到了你的请求。
- 客户端收到服务端的请求后,再次发送ACK,这个ACK是针对服务端连接的一个确认,表示告诉服务端,我收到了你的请求。
3.之所以TCP要设计三次握手,我认为有三个方面的原因:
- TCP是可靠性通信协议,所以TCP协议的通信双方都必须要维护一个序列号,去标记已经发送出去的数据包,哪些是已经被对方签收的。而三次握手就是通信双方相互告知序列号的起始值,为了确保这个序列号被收到,所以双方都需要有一个确认的操作。
- TCP协议需要在一个不可靠的网络环境下实现可靠的数据传输,意味着通信双方必须要通过某种手段来实现一个可靠的数据传输通道,而三次通信是建立这样一个通道的最小值。当然还可以四次、五次,只是没必要浪费这个资源。
- 防止历史的重复连接初始化造成的混乱问题,比如说在网络比较差的情况下,客户端连续多次发送建立连接的请求,假设只有两次握手,那么服务端只能选择接受或者拒绝这个连接请求,但是服务端不知道这次请求是不是之前因为网络堵塞而过期的请求,也就是说服务端不知道当前客户端的连接是有效还是无效。
🍊 HashMap
hashmap几乎是Java面试必问题,相关的知识点其实有很多,更为详细的hashmap知识点,我也有写,全部讲一遍,差不多要一个小时以上,有时间的同学可以去看看,这里提供地址:https://blog.csdn.net/java_wxid/article/details/124788118,面试官想问的可能就那么几个,另外还需要控制hashmap讲解的时长,挑几个比较重要的,进行讲解即可,下面由浅到深讲解,专门针对面试题,归总的知识点列举出来。
🎉 HashMap底层实现
向HashMap中添加一个元素时,当前元素的key会调用hashCode方法来决定它在数组中存放的位置。如果这个位置没有其他元素,会把这个键值对直接放到一个node类型的数组中,这个数组就是hashmap底层基础的数据结构。如果这个位置有其他元素,会继续拿着这个key调用equals方法和这个位置已存在的元素key进行对比,对比二个元素的key。key一样,返回true,原来的value值会被替换成新的value。key不一样,返回flase,这个位置就用链表的形式把多个元素串起来存放。
jdk1.7版本的HashMap数据结构就是数组加链表的形式存储元素的,但是会有弊端,当链表中的数据较多时,查询的效率会下降。所以JDK1.8版本做了一个升级,当链表长度大于8,并且数组长度大于64时,会转换为红黑树。因为红黑树需要进行左旋,右旋,变色操作来保持平衡,如果当数组长度小于64,使用数组加链表比使用红黑树查询速度要更快、效率更高。
在HashMap源码有这样一段描述,大致意思是说在理想状态下受随机分布的hashCode影响,链表中的节点遵循泊松分布,节点数是8的概率接近千分之一,这个时候链表的性能很差,所以在这种比较罕见和极端的情况下才会把链表转变为红黑树,大部分情况下HashMap还是使用链表,如果理想情况下,均匀分布,节点数不到8就已经自动扩容了。
🎉 1.7版本和1.8版本的差异
jdk1.7的hashmap有二个无法忽略的问题。
- 第一个是扩容的时候需要rehash操作,将所有的数据重新计算HashCode,然后赋给新的HashMap,rehash的过程是非常耗费时间和空间的。
- 第二个是当并发执行扩容操作时会造成环形链和数据丢失的情况,开多个线程不断进行put操作,当旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,就是因为头插法,所以最后的结果打乱了插入的顺序,就有可能发生环形链和数据丢失的问题,引起死循环,导致CPU利用率接近100%。
在JDK1.8中,对HashMap这二点进行了优化。
- 第一点是经过rehash之后元素的位置,要么是在原位置,要么是原位置+原数组长度。不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了。在数组的长度扩大到原来的2倍, 4倍,8倍时,在resize(也就是length - 1)这部分,相当于在高位新增一个或多个1bit。
举个例子,hashmap默认的初始长度是16,负载因子是0.75,当元素被使用75%以上时,触发扩容操作,并且每次扩容一倍。扩容时:将旧数组中的元素转换后,填充到新数组中。通过底层获取索引indexfor方法里面有个(length -1)公式,取它的二进制,它的二进制位后八位是0000 1111,扩容二倍到32,通过公式(length -1)取31的二进制,它的后八位0001 1111,可以发现它的高位进的是1,然后和原来的hash码进行与操作,这样元素在数组中映射的位置要么不变,要不就是在原位置再移动2次幂的位置。
高位上新增的是1的话索引变成原位置+原数组长度,是0的话索引没变。这样既省去了重新计算hash值的时间,而且由于高位上新增的1bit是0还是1,可以认为是随机的,复杂度更高,从而让分布性更高些。 - 第二点,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况。
举个例子,如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。
🎉 并发修改异常解决方案
HashMap在高并发场景下会出现并发修改异常,导致原因:并发争取修改导致,一个线程正在写,一个线程过来争抢,导致线程写的过程被其他线程打断,导致数据不一致。
- 第一种解决方案:使用HashTable:HashTable是线程安全的,只不过实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
- 第二种解决方案:使用工具类
Collections.synchronizedMap(new HashMap<>());
和Hashtable一样,实现上在操作HashMap时自动添加了synchronized来实现线程同步,都对整个map进行同步,在性能以及安全性方面不如ConcurrentHashMap。 - 第三种解决方案:使用写时复制(CopyOnWrite):往一个容器里面加元素的时候,不直接往当前容器添加,而是先将当前容器的元素复制出来放到一个新的容器中,然后新的元素添加元素,添加完之后,再将原来容器的引用指向新的容器,这样就可以对它进行并发的读,不需要加锁,因为当前容器不添加任何元素。利用了读写分离的思想,读和写是不同的容器。缺点也很明显,会有内存占用问题,在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存。会有数据一致性问题,CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。
- 第四种解决方案:使用ConcurrentHashMap:ConcurrentHashMap大量的利用了volatile,CAS等技术来减少锁竞争对于性能的影响。在JDK1.7版本中ConcurrentHashMap避免了对全局加锁,改成了局部加锁(分段锁),分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。不过这种结构的带来的副作用是Hash的过程要比普通的HashMap要长。所以在JDK1.8版本中CurrentHashMap内部中的value使用volatile修饰,保证并发的可见性以及禁止指令重排,只不过volatile不保证原子性,使用为了确保原子性,采用CAS(比较交换)这种乐观锁来解决。
🎉 加载因子
加载因子是用来判断当前HashMap<K,V>中存放的数据量,默认的加载因子是0.75。
加载因子比较大,扩容发生的频率比较低,浪费的空间比较小,发生hash冲突的几率比较大。
- 比如,加载因子是1的时候,hashmap长度为128,实际存储元素的数量在64至128之间时间段比较多,这个时间段发生hash冲突比较多,造成数组中其中一条链表比较长,会影响性能。
加载因子比较小,扩容发生的频率比较高,浪费的空间比较多,发生hash冲突的几率比较小。
- 比如,加载因子是0.5的时候,hashmap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个,浪费了。综合了一下,取了一个平均数0.75作为加载因子。
🎉 长度恒定为2的n次方
HashMap的数组长度恒定为2的n次方,也就是说只会为16,32,64这种数。即便你给的初始值是13,最后数组长度也会变成16,它会取你传进来的数,最近一个2的n次方的数。这么设计的目的主要是为了解决底层运算后的值可以落到数组的每个下标上面。
hashMap获取索引的方法:
//indexFor中的h是hashCode通过变换之后的值,是一个32位的二进制数 public static int indexFor(int h, int length) { return h & (length-1); }
HashMap中运算数组的位置,使用的是length-1,每次扩容会把原数组的长度*2,在二进制上的表现就是高位进1,并且后四位始终都是1111。
初始长度为16的数组,对应的length-1就是15,原数组15二进制后八位为0000 1111。 扩容后的长度为32的数组,对应的length-1就是31,二进制就变成了0001 1111。 再次扩容长度为64的数组,对应的length-1就是63,二进制是0011 1111。
假设hashMap容量为16
hash值&运算:
11001110 11001111 00010011 11110001(hash值)
&
00000000 00000000 00000000 00001111(16-1的2进制)
=
00000000 00000000 00000000 00000001
hash的2进制的后4位和1111比较,hash值的后4位范围是0000-1111之间,与上1111,最后的值是在0000-1111,也就是0-15之间。这样就保证运算后的值可以落到数组的每一个下标。
如果数组长度不是2的幂次,后四位就不可能是1111,0000~1111的一个数和有可能不是1111的数进行&运算,数组的某几位下标就有可能永远不会有值,这就没法保证运算后的值可以落到数组的每个下标上面。
🎉 散列均匀分布
hashMap获取索引的indexFor方法里面的h是hashCode通过变换之后的值,是一个32位的二进制数,如果直接用如此长的二进制数和目标length-1直接进行与运算,结果会导致高位会大量丢失。
假如我们以16位为划分,任何两个高16位不一样,低16位一样的数。这两个数的hashCode与length-1做与运算(hashCode & length-1),结果会是一样的,这样的两个数,却产生了相同的hash结果,发生hash冲突。
于是hashMap想到了一种处理方式:底层算法通过让32位hashcode中保持高16位不变,高16与低16异或结果,作为新的低16位,然后用hash得到的结果(int h)传入方法indexFor获取到hashMap的索引。
计算中只有低位16位参与&运算,计算效率高,同时也保证的hash的高16位参与了索引运算,这样得到的索引能呈较为理想的散列分布,在将条目放入hashMap中时,最大限度避免hash碰撞。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//把hash值异或了hash值右移16位,即取高16位 }
绝大多数情况下length一般都小于2^16即小于65536,所以indexFor方法中return h & (length-1)的结果始终是h的低16位与(length-1)进行&运算。hashmap为了考虑性能的设计还是非常精妙的。
🎉 hashmap优化
对hashmap使用的优化,我个人看法有五点。
- 第一点,建议采用短String,Integer这样的类作为键。特别是String,他是不可变的,也是final的,而且已经重写了equals和hashCode方法,契合HashMap要求的计算hashCode的不可变性要求,核心思想就是保证键值的唯一性,不变性,其次是不可变性还有诸如线程安全的问题,这么定义键,可以最大限度的减少碰撞的出现。如果hashCode不冲突,那查找效率很高,但是如果hashCode一旦冲突,要调用equals一个字节一个自己的去比较,key越短效率越高。
- 第二点不使用for循环遍历map,而是使用迭代器遍历Map,使用迭代器遍历entrySet在各个数量级别效率都比较高。
- 第三点使用线程安全的ConcurrentHashMap来删除Map中的元素,或者在迭代器Iterator遍历时,使用迭代器iterator.remove()来删除元素。不可以for循环遍历删除,否则会产生并发修改异常CME。
- 第四点考虑加载因子地设定初始大小,设定时一定要考虑加载因子的存在,使用的时候最好估算存储的大小。可以使用
Maps.newHashMapWithExpectedSize(预期大小)
来创建一个HashMap,计算的过程guava会帮我们完成,Guava的做法是把默认容量的数字设置成预期大小 / 0.75F + 1.0F
。 - 第五点减小加载因子,如果Map是一个长期存在而不是每次动态生成的,而里面的key又是没法预估的,那可以适当加大初始大小,同时减少加载因子,降低冲突的机率。毕竟如果是长期存在的map,浪费点数组大小不算啥,降低冲突概率,减少比较的次数更重要。
🍊 Fail-safe机制/Fail-fast机制
Fail-safe和Fail-fast,是多线程并发操作集合时的一种失败处理机制。
Fail-fast : 表示快速失败,在集合遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出ConcurrentModificationException异常,从而导致遍历失败,像这种情况
定义一个Map集合,使用Iterator迭代器进行数据遍历,在遍历过程中,对集合数据做变更时,就会发生Fail-fast。
java.util包下的集合类都是快速失败机制的, 常见的的使用Fail-fast方式遍历的容器有HashMap和ArrayList等。
Fail-safe:表示失败安全,也就是在这种机制下,出现集合元素的修改,不会抛出ConcurrentModificationException。
原因是采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,
在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到
比如这种情况
定义了一个CopyOnWriteArrayList,在对这个集合遍历过程中,对集合元素做修改后,不会抛出异常,但同时也不会打印出增加的元素。
java.util.concurrent包下的容器都是安全失败的,可以在多线程下并发使用,并发修改。
常见的的使用Fail-safe方式遍历的容器有ConcerrentHashMap和CopyOnWriteArrayList等。