一问一答之基础
参照这篇文章
Java部分
谈谈Java的优势
- 面向对象(相较于C的面向过程)
- 核心类库丰富
- 跨平台性,一次编写,到处运行
- 内存管理。和上一部分都是依赖于Java虚拟机(JVM)程序员不需要去关心操作系统带来的差异,不需要关心内存分配与回收
JVM,JRE,JDK之间的关系
Java文件执行过程
Java源码 —> 编译器 —> 字节码文件class —> 再JVM中解释 —> 机器可执行的二进制机器码 —> 程序运行
数据类型
基本数据和引用数据
基本有8种按占用内存分别为,byte(1),boolean(1),short(2),char(2),int(4),float(4),long(8),double(8)
访问权限
面向对象与面向过程的区别
面向过程:性能高,适用于单片机,嵌入式开发,Linux/unix,比较看重性能,缺点就是难维护,扩展与复用
面向对象:上面的反过来
面向过程强调一步步的过程实现,流程化的解决问题,面向对象是抽象的,只需要使用就可以,不需要关心是怎么去实现的
反射应用场景
- 框架(spring通过xml装载bean)
- jdbc
OSI模型
三次握手
确保双方的一个收发数据的能力
A发给B,B收到了,B知道了自己的的收数据能力和A的发数据能力是可以的
B发给A,A收到了,A知道了B的发数据与自己的收数据能力
而此时B还不知道自己的发件和A的收件能力,所以A还会给B再发送一次
总之就是AB都需要确保四点,我的发送,我的接收,他的发送,他的接收
为什么是4次挥手
3次只是表明我同意你关闭连接了,但还不确定是否还有剩余的数据需要发送
DNS解析过程
缓存 —> 本地hosts —> LDNS —> RDNS
- 首先去浏览器缓存中寻找,如果缓存中有,则解析停止
- 如果1没有则去操作系统本身去做域名解析,windows的路径(C:\Windows\System32\drivers\etc\hosts)
- 向本地域名解析器(LDNS)发起域名请求,在win中可以通过ipconfig/all 查询。LDNS一般保存了大部分的解析结果,缓存时间受到域名失效时间控制,LDNS负责了大部分的解析工作
- 如果还没有解析,转向根域名解析服务器(RDNS),它返回通用顶级域名解析服务器(gTDL)的地址(返回给LDNS)
- LDNS向返回的这个地址发请求
- 接受请求的gTLD查找并返回这个域名对应的Name Server的地址,这个Name Server就是网站注册的域名服务器
- Name Server根据映射关系表找到目标ip,返回给LDNS
- LDNS缓存这个域名和对应的ip
- LDNS把解析的结果返回给用户,用户根据TTL值缓存到本地系统缓存中,域名解析过程至此结束
CDN原理
内容分发网络(Content Delivery Network),通过网络发到最靠近用户的节点,访问的时候可以就近获取
,加快访问速度,如同迅雷的种子链接
通过给源站添加CNAME别名,向源站发送请求,会通过dns解析访问CNAME域名,调用加速节点的域名
servlet声明周期
web容器加载servlet将其实例化,开始声明周期,执行init方法初始化,调用doService()方法,会根据请求去调用doGet或者doPost方法
init和destory只会调用一次
String为什么被final修饰
- final表示不可被继承,如果在常量池创建,则不可被修改,可以直接拿来用
- 这样也保证了线程安全,不可变对象不可以被写
- 在创建的时候也缓存了hashcode,不需要重新计算。这也是hashmap键为何常使用String的原因,相比于其他对象更快
集合部分
Collection集合主要有List、Set和Queue接口
聊聊List
Java的一个接口,常见的实现类有ArrayList和LinkedList,开发中常用的时ArrayList,底层结构是数组,另一个是链表
直接用数组不行?为什么要用ArrayList
数组需要指定大小,而ArrayList可以进行动态扩容。在每次添加元素的时候都会判断空间是否足够
怎么进行扩容
其中有个grow方法,每一次扩之前的1.5倍
,扩完会利用arraycopy进行拷贝
为什么日常使用不用LinkedList而是ArrayList
由底层的数据结构决定,平时遍历比增删元素要多,而且ArrayList增删也进行过优化,在尾部增删的时间复杂度在O(1),速度也不会比LinkedList慢
了解vector吗
线程安全,但不怎么使用了,扩容为之前的两倍。为什么不用?缺点是效率低,而且有好的替代品CopyOnWriteArrayList
说说CopyOnWriteArrayList
文件在进行操作的时候,不直接在上面的基础上修改,而是重新找个位置修改,好处就是可以保证数据完整性,挂掉可以瞬间恢复。
CopyOnWriteArrayList是一个线程安全的list,底层通过赋值数组的方式实现。简述add方法,执行的时候会使用lock上锁,复制一个新的数组,在新数组中add元素,最后将array指向新数组。缺点就是耗费内存,每次add都会申请一块内存,只能保证数据最终一致性,而不能保证实时一致性
说说ArrayList
底层由数组实现,默认容量10
,以无参方式构造时,赋值的是一个空数组,在添加元素的时候才会为其扩容,即扩为10,如果容量不够,则会触发grow()进行扩容,grow是扩容的核心方法,将新容量扩为之前的1.5倍
1 | private void grow(int minCapacity) { |
向ArrayList添加大量元素之前最好先使用ensureCapacity
方法,以减少增量重新分配的次数。
1 | public void ensureCapacity(int minCapacity) { |
常用的add方法
1 | //添加一个特定的元素到list的末尾 |
根据索引删除元素
会把指定下标到数组末尾的元素挨个向前移动一个单位,并且会把数组最后一个元素设置为null,这样是为了方便之后将整个数组不被使用时,会被GC,可以作为小的技巧使用。
1 | //根据索引删除指定位置的元素 |
什么是集合快速失败“fail-fast”?
多线程操作,在用增强for循环,其实就是集合的迭代器遍历时,如果同时进行遍历修改,就会抛出ConcurrentModificationException异常
原因:ArrayList的父类AbstarctList中有一个modCount变量,每次对集合进行修改时都会modCount++。ArrayList的Iterator中有一个expectedModCount变量,该变量会初始化和modCount相等,每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount的值和expectedmodCount的值是否相等,如果集合进行增删操作,modCount变量就会改变,就会造成expectedModCount!=modCount,此时就会抛出ConcurrentModificationException异常
解决方式
- 使用普通for循环
- 使用CopyOnWriteArrayList(在copy的数据上修改不会影响原数据)
ArrayList 和 LinkedList 的区别?
- A是动态数组数据结构进行实现,L是双向链表的方式实现
- A的查询效率高,插入删除低,L次之
- L更占内存,除了存数据还存向前向后的引用
- 都是线程不安全的
谈谈Map
HashMap由数组 + 链表 / 红黑树组成
LinkedHashMap = 数组 + 链表 + 双向链表
TreeMap底层是红黑树
ConcurrentHashMap同HashMap
为什么是线程不安全的
1.7。在临界点将要扩容的时候,都会进行reseize和rehash操作,而rehash在并发的情况下可能形成闭环,造成死循环的问题,这是put引发的,导致cpu利用率接近100%,所以在并发情况下不能使用hashmap。
名词解释
哈希冲突:当key的hash值对数组大小取模后,如果值相同,那么就是哈希冲突,会形成一条entry链
加载因子:为了减少哈希冲突,官方设定了一个值为0.75,当键值对达到总容量的75%时,则进行扩容。可以空间换时间,将加载因子手动调低
与hashTable和treeMap的区别
- hashTable键值不能为null,treeMap同样是,而hashMap可以,但键只能有一个null
- 除了treeMap另外两个都是无序的,所以tree会效率低一点
HashSet和HashMap的区别
- map实现Map接口,set实现Set接口
- map存储k,v,set存储对象
- map使用put,set使用add()
- map使用key计算hashcode,对于set的对象需要判断hashcode是否相同,如果相同还需判断equlas是否相同
- map比set获取值的速度要快
HashMap
基于哈希散列实现,默认大小为16
,加载因子为0.75
1.8之前
数组+链表也就是链表散列,链表用来解决hash冲突,在并发条件下rehash会造成元素之间形成一个循环链表 ,头插。通过key的扰动函数(hash处理hash值),目的是为了减少碰撞,1.8之前扰动4次,性能比1.8稍差
1.8之后
数组+链表/红黑树。当链表长度大于8且当前数组长度大于64,则将链表转为红黑树存储,减少搜索时间。否则先进行扩容。
rehash:hash表在数据插入的时候都会检查有没有超过给定容量,若超过,需要进行扩容,整个hash表的元素都需要重算一遍,这个就叫rehash,成本非常大
1.7之前采取头插的方式,扩容会改变元素原本的顺序,在并发场景下会导致链的问题,1.8尾插就不会出现这个问题了
允许由null值作为key只能有一个。初始化大小为16,扩容变为原来的2的幂次方大小。
为什么改为尾插还需要ConCurrentHashMap来保证并发
在JDK1.7中,扩容引发线程不安全(死循环和数据丢失),在JDK1.8中虽然得到解决,但是仍有数据覆盖的问题。
在JDK1.8中,已经得到了很好的解决1.7的问题,如果你去阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。数据覆盖: 不安全性体现在多线程并发对 HashMap 进行 put 操作上。如果有两个线程 A 和 B ,首先 A 希望插入一个键值对到 HashMap 中,在决定好桶的位置进行 put 时,此时 A 的时间片正好用完了,轮到 B 运行,B 运行后执行和 A 一样的操作,只不过 B 成功把键值对插入进去了。如果 A 和 B 插入的位置(桶)是一样的,那么线程 A 继续执行后就会覆盖 B 的记录,造成了数据不一致问题。
如何保证是2的幂次方
tableSizeFor方法,可以保证返回的值为大于=n的2的幂次方的数
1 | static final int tableSizeFor(int cap) { |
为什么扩容是2倍,不是3倍?或者为什么要保证是2的幂次方?
- 减少元素位置的移动
- 使元素分配更加均匀,减少哈希碰撞
HashMap的put过程
首先计算key的hash值,调用hash(),hash = key.hashCode() ^ key.hashCode() >>> 16
,高位补0。
计算下标为index = (table.length - 1) & hash
相关源码
1 | public V put(K key, V value) { |
源码翻译
- 判断键值对数组table[i]是否为空或为null,是的话执行resize()进行扩容;
- 根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向6,如果table[i]不为空,转向3;
- 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向4,这里的相同指的是hashCode以及equals;
- 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向5;
- 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
- 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
为什么用异或计算hash
得到的值更加均匀
个人观点:补充一些,hashcode为int类型,4个字节32位,为了确保散列性,肯定是32位都能进行散列算法计算是最好的。 首先要明白,为什么用亦或计算,二进制位计算,a 只可能为0,1,b只可能为0,1。a中0出现几率为1/2,1也是1/2,b同理。 位运算符有三种,|,&,……,或,与,亦或。 a,b进行位运算,有4种可能 00,01,10,11 a或b计算 结果为1的几率为3/4,0的几率为1/4 a与b计算 结果为0的几率为3/4,1的几率为1/4, a亦或b计算 结果为1的几率为1/2,0的几率为1/2 所以,进行亦或计算,得到的结果肯定更为平均,不会偏向0或者偏向1,更为散列。 右移16位进行亦或计算,我将其拆分为两部分,前16位的亦或运算,和后16位的亦或运算, 后16位的亦或运算,即原hashcode后16位与原hashcode前16位进行亦或计算,得出的结果,前16位和后16位都有参与其中,保证了 32位全部进行计算。 前16位的亦或运算,即原hasecode前16位与0000 0000 0000 0000进行亦或计算,结果只与前16位hashcode有关,同时亦或计算,保证 结果为0的几率为1/2,1的几率为1/2,也是平均的。 所以为什么是右移16位,个人觉得博主说的原因是一部分, 也有一个原因是右移16位进行亦或计算的结果中, (1)结果的后16位保证了hashcode32位全部参与计算,也保证了0,1平均,散列性 (2)结果的前16位保证hashcode前16位了0,1平均散列性,附带hashcode前16位参与计算。 (3) 16与16位数相同,利于计算,不需要补齐,移去位数数据 更多情况,hashmap只会用到前16位(临时数据一般不会这么大),所以(1)占主因
如何散列
直接进行hash取余操作,可能导致散列不均匀的情况(参与运算的只有hashcode的低位),最坏情况会形成一个单链表。所以对hashCode做了一定的优化,让高位也参与运算,使其更加平均,减少哈希碰撞,这样的操作叫做扰动
1 | static final int hash(Object key) { |
这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);
如何解决hash冲突
1.8之前,如果有冲突将值加入链表中,又叫拉链法
1.8之后,链表长度大于8,调用treeifyBin方法,会判断hashmap数组长度是否大于等于64,如果是则转为红黑树,以减少搜索时间,否则执行resize方法扩容
扩容条件是什么
大于容量*扩容因子(默认0.75f),loadFactor 越大数组越密,越少越稀疏。0.75是官方给出的一个比较好的临界值。threshold = capacity \* loadFactor
,用来衡量是否需要扩容
扩容怎么实现的
- 在jdk1.8中,添加元素个数大于阀值时或者初始化时,就调用resize方法进行扩容;
- 每次扩展的时候,新容量为旧容量的2倍;
- 扩展后元素的位置要么在原位置,要么移动到原位置 + 旧容量的位置;
在putVal()中使用到了2次resize()方法,resize()方法在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+旧容量的位置
resize源码解读
1 | final Node<K,V>[] resize() { |
HashSet如何检查重复元素?如何保证数据是不可重复的?
add()添加时,不仅比较hash值,还会使用equlas比较,HashSet的add()会调用HashMap的put()
底层就是利用HashMap实现的,在添加相同的key时会覆盖原有的value值,返回原有的value值,所以不会重复
部分源码
1 | private static final Object PRESENT = new Object(); |
谈谈ConcurrentHashMap
1.7底层使用segment数组 + hashmap数组 + 链表
Java7 中 ConcurrentHashMap 使用的分段锁,也就是每一个 Segment 上同时只有一个线程可以操作,每一个 Segment 都是一个类似 HashMap 数组的结构,它可以扩容,它的冲突会转化为链表。但是 Segment 的个数一但初始化就不能改变。
1.8与hashmap一样,node数组 + 链表 / 红黑树
Java8 中的 ConcurrentHashMap 使用的 Synchronized 锁加 CAS 的机制。结构也由 Java7 中的 Segment 数组 + HashEntry 数组 + 链表 进化成了 Node 数组 + 链表 / 红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
谈谈NIO
从1.4开始有的,目的是为了提高速度。可以翻译为new-io或者non-blocking-io
与传统IO的区别
传统io是一个一个字节处理的,nio是以块处理的,还是非阻塞的,传统io是阻塞的
nio有三个核心部分:选择器(selector)- 用于检查多个channel的变更情况
管道(channel),缓冲区(buffer)
谈谈反射
可以在类运行时获取类的信息,运行时解释:java文件编译后会变为class文件,而class文件会被jvm装载运行。
为什么匿名内部类要用final常量
生命周期不一致,局部变量存于栈中,当方法执行结束后,非final被销毁,而局部内部类的引用还存在,此时内部类在调用的时候就会出错,加了final约束了对象的强一致性,避免了此问题
谈谈BIO,NIO,AIO的区别
简答
- BIO:Block IO 同步阻塞式 IO,就是我们平常使用的传统 IO,它的特点是模式简单使用方便,并发处理能力低。
- NIO:Non IO 同步非阻塞 IO,是传统 IO 的升级,客户端和服务器端通过 Channel(通道)通讯,实现了多路复用。
- AIO:Asynchronous IO 是 NIO 的升级,也叫 NIO2,实现了异步非堵塞IO ,异步 IO 的操作基于事件和回调机制。
详细回答:BIO (Blocking I/O): 同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
NIO (Non I/O): NIO是一种同步非阻塞的I/O模型,在Java 1.4 中引入了NIO框架,对应 java.nio 包,提供了 Channel , Selector,Buffer等抽象。NIO中的N可以理解为Non-blocking,不单纯是New。它支持面向缓冲的,基于通道的I/O操作方法。 NIO提供了与传统BIO模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
AIO (Asynchronous I/O): AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步IO的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。
Java内存模型
是什么
Java内存模型(java memory model)是一种抽象概念,约定或规范,关键技术点是围绕多线程的原子性,可见性与有序性展开的为什么会出现
屏蔽掉各种硬件和操作系统的内存访问差异解决了什么
- 让Java程序在各个平台下都能达到一致的内存访问效果
- JMM规范了JVM与计算机内存是如何协同工作的:规定了一个线程如何看到共享变量的值,以及如何同步的访问共享变量
三大特性
可见性:一个线程修改了某一个共享变量的值,其他线程能够立即知道该变更。JMM规定所有变量都存于主内存中
有序性:程序只保证结果相等,编译器和处理器为了提升性能会对指定序列进行重排序。但只保证串行语义一致,没有义务保证多线程语义也一致(可能产生脏读)
原子性:一次或多次操作,要么全部执行,要么都不执行
JMM内存模型从1.5重新修订,1.8仍然在使用
扩展
更多内容可点击参照这篇文章
谈谈你对JMM的理解
JMM希望屏蔽各种硬件和操作系统的访问差异,保证Java在各种平台下对内存访问都能达到一致的效果
解决多线程存在的原子性,可见性,和有序性的问题,JMM主要内容有以下几块
- JMM的抽象结构。线程的所有操作必须于本地内存(私有的),不能操作主内存
JMM的操作指令及其作用,从上而下有used,load,store,read,write,lock,unlock - happen-before原则。阐述可见性,有了这个原则我们的代码才不会发生重排序
- volatile。解决可见性和重排。实现依据是内存屏障
谈谈Object object = new Object()
引用在方法区,变量在栈,对象在堆中
占多少内存?在64位系统中16个字节,只有一个对象头占内存,mark word占了8字节,类型指针(classpoint)占了8个字节
hashcode记录在什么地方?
锁在哪个地方?
gc信息在什么地方?
- 启动了压缩指针 -XX:UseCompressedClassPointers 就是4个byte
new一个对象要经历哪些过程?
- 加载初始化类。查看对象所属的类是否加载到内存中,如果没有则通过对象所属的class文件加载到内存中
- 创建对象化。初始化完成后,在进行对象的创建工作
加载初始化类
类加载器收到请求,会将请求委托给父类(以此类推)只有当父类无法加载,子加载器才会尝试加载(可以保证全局唯一性)
该模型称为双亲委派模型
- 加载
- 验证(语义,操作)
- 准备(为静态变量分配空间)
- 解析
- 初始化(先父后子,静态变量赋值,执行static代码块)
创建对象
- 在堆中分配对象所需要的内存
- 对所有成员变量赋初值(方法区copy到堆赋值)
- 执行实例化代码
什么是双亲委派机制?它的工作流程是什么样的?模型的好处是什么?
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在JVM中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将class文件加载到JVM 内存,然后再转化为 class 对象。
双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去加载,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载器无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载此类。
自下而上检查类是否已经被加载,自上而下尝试加载类
双亲委派模型工作流程:
当Application ClassLoader 收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器Extension ClassLoader去完成。
当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给父类加载器Bootstrap ClassLoader去完成。
Bootstrap ClassLoader尝试加载此类,如果Bootstrap ClassLoader加载失败,就会让Extension ClassLoader尝试加载。
Extension ClassLoader尝试加载此类,如果Extension ClassLoader也加载失败,就会让Application ClassLoader尝试加载。
Application ClassLoader尝试加载此类,如果Application ClassLoader也加载失败,就会让自定义加载器尝试加载。
如果均加载失败,就会抛出ClassNotFoundException异常。
双亲委派模型的好处:保证核心类库不被覆盖
。如果没有使用双亲委派模型,由各个类加载器自行加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统将会出现多个不同的Object类, Java类型体系中最基础的行为就无法保证,应用程序也将会变得一片混乱。
谈谈动态代理
动态代理有常见的两种实现方式,jdk代理和cglib代理
jdk利用了反射的机制
cglib利用的是asm框架,通过修改字节码生成子类来处理