2019.12-2020.02后端面试材料分享,架构/中间件篇。
拿到了字节offer,走完了Hello单车和达达的面试流程(没给offer),蚂蚁的前三轮(接了字节Offer,放弃后续流程)。
以下问题汇总在一个类anki的小程序:一进制。默认隐藏答案,思考后再点开对照;根据你反馈的难度,安排复习时间。
-问题1: 边用iterator遍历hashMap,边通过hashMap自身方法修改数据,有什么问题?
-tags: java,hashmap,并发
-解答:
会拋出ConcurrentModificationException
。
因为HashMap的modCount和Iterator维护的expectedModCount不相同了。正确的做法是只通过HashMap
本身或者只通过Iterator
去修改数据。
-问题2: 相比Java7,Java8的ConcurrentHashMap做了什么改进?
-tags: java,hashmap,并发
-解答:
- 数据结构。Java7为实现并行访问,引入了
Segment
这一结构,实现了分段锁,理论上最大并发度与Segment
个数相等。Java8为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时,Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(long(N)))。- 同步方式。如果数据未初始化,或者是更新桶的第一个元素,则通过
CAS
操作,无需加锁。否则通过synchronized
获取桶(链表或红黑树第一个节点)的锁,相对分段锁,锁的颗粒度更细了。实际上,生产环境中,map
在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待,且java8的内置锁比之前版本优化了很多,相较ReentrantLock
,性能不并差。数组本身用volatile
修饰:transient volatile Node
,数组元素由[] table; Unsafe
的getObjectVolatile
保证可见性,元素内的字段要么是final
要么是volatile
修饰,可见性也有保障。
static class Node
implements Map.Entry { final int hash; final K key; volatile V val; volatile Node next; } - size方法。在Java7中,会使用不加锁的模式去尝试多次计算
ConcurrentHashMap
的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 如果计算不一致,就会给每个 Segment 加上锁,然后计算ConcurrentHashMap
的size
返回。
- 同步方式。如果数据未初始化,或者是更新桶的第一个元素,则通过
Java8中,put
方法和remove
方法都会通过addCount
方法维护Map的size,size方法通过sumCount获取由addCount方法维护的Map的size。
addCount
方法统计数值baseCount
(正常无并发下的节点数量)和counterCells
(并发插入下的节点数量),以精确计算并发读写情况下table中元素的数量。它首先尝试用CAS更新baseCount
,成功,如果CAS操作失败,则表示有竞争,有其他线程并发插入,则修改的数量会被记录到CounterCell
中。
-问题3: 静态数据、构造函数、代码段、字段,执行的顺序是怎样的?
当创建类的实例时,父类和子类的静态数据、构造函数、代码段、字段,执行的顺序是怎样的?
-tags: java
-解答:
按照先父类后子类、先静态后动态的顺序:
- 父类静态变量
- 父类静态代码块
- 子类静态变量
- 子类静态代码块
- 父类非静态变量(父类实例成员变量)
- 父类构造函数
- 子类非静态变量(子类实例成员变量)
- 子类构造函数
-问题4: 有没有有顺序的 Map 实现类, 如果有, 它们是怎么保证有序的?
-tags: java
-解答:
-
TreeMap
,默认按key升序,可按创建时给定的Comparator
排序。 - LinkedHashMap,双向链表,记录了插入顺序;也支持访问顺序,内部维护LRU使得key从Least Recently Accessed到Most Recently Accessed排序。
-问题5: error和exception的区别,CheckedException,RuntimeException的区别
-tags: java
-解答:
- error,程序无法处理的错误,发生于虚拟机自身,可能会导致线程中断。
- exception,操作或操作可能会引发的错误,可以被程序处理
- CheckedException,检查型异常,强制要求必须做异常处理。
- RuntimeException,运行时异常,因操作引发的异常,“程序虽然无法继续执行,但是还能抢救一下”,不要求强制做异常处理。
额外补充:
- 异常实例的构造十分昂贵是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。要逐一访问当前线程的 Java 栈帧,并且记录下调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名和异常行号等。
- finally能实现无论异常与否都能被执行的,是由于编译器在编译Java代码时,会复制finally代码块的内容,然后分别放在try-catch代码块所有的正常执行路径及异常执行路径的出口中。
- 如果finally有return语句,catch内throw的异常会被忽略。因为catch里抛的异常会被finally捕获,在执行完finally代码后重新抛出该异常。由于finally代码块有个return语句,在重新抛出前就返回了。
-问题6: 自己创建一个java.lang.String对象,会被类加载器加载吗?
在自己的代码中,创建一个java.lang.String
对象,会被类加载器加载吗,为什么
-tags: java
-解答:
不可以
启动类加载器(bootstrap)会将Java_Home/lib下面的类库加载到内存中,所以自己创建的java.lang.String对象无法被加载
-问题7: 分代GC中,对象从创建到回收的一般流程是怎样的?
-tags: java
-解答:
HotSpot JVM把年轻代分为了三部分: 1个Eden区和2个Survivor区(分别叫from和to),默认大小比例为8:1。
一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”。
Full GC 是发生在老年代的垃圾收集动作,采用的是标记-清除算法。该算法会产生内存碎片,此后为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。
-问题8: GC的触发条件有哪些?
-tags: java
-解答:
GC 触发条件
young GC:当young gen中的eden区分配满的时候触发。(有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高)。
-
full GC:
当准备要触发一次young GC时,如果发现之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(包括young gen,所以不需要事先触发一次单独的young GC);
如果有perm gen的话,要在perm gen分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。
-问题9: 谈谈对Spring框架设计理念的理解
Spring解决了什么关键的问题?它有哪几个核心组件?为什么需要这些组件?它们是如何结合在一起构成Spring的骨骼架构的?
-tags: java,spring
-解答:
Spring框架中的核心组件只有三个:Context ,Core和Beans,它们构建起了整个Spring 的骨骼架构。其中Beans又是核心中的核心。
关键问题
Spring如此流行,是因为解决了一个非常关键的问题:它把对象间的依赖关系转而用配置文件来管理,也就是它的依赖注入机制。而这个注入关系在一个叫IOC 的容器中管理。Spring 正是通过把对象包装在Bean中来达到对这些对象管理以及额外操作的目的。
协同工作
我们把Bean比作一场演出中的演员,那么Context就是舞台背景,Core就是演出的道具。
对Context 来说它就是要发现每个Bean之间的关系,为它们建立这种关系并维护好这种关系。所以Context就是一个 Bean关系的集合,这个关系集合又叫IOC容器。一旦建立起这个IOC容器之后, Spring就可以为你工作了。那么Core组件又有何用武之地呢?其实Core 就是发现,建立和维护Bean之间的关系所需要的一系列工具,从这个角度看Core这个组件叫Util更能让你理解。
IOC容器如何工作
IOC容器实际上就是 Context组件结合其他两个组件共同构建了一个Bean的关系网。网的构建的入口就在AbstractApplicationContext类的refresh方法中,它做了以下事情:
- 构建BeanFactory,以便产生所需的“演员”
- 注册可能感兴趣的事件
- 创建Bean实例对象
- 触发被监听的时间
-问题10: 准备用HashMap存1w条数据,构造时传10000还会触发扩容吗?
准备用HashMap存1w条数据,构造时传10000还会触发扩容吗?
如果是准备存1k条,构造时传1000呢?
-tags: java
-解答:
不会。
以JDK8
的源码来说明:
- HashMap是否扩容,由threshold决定,而threshold又由初始容量和 loadFactor决定。
- 构造方法传递的 initialCapacity,最终会被 tableSizeFor() 方法动态调整为2 的 N 次幂,以方便在扩容的时候,计算数据在 newTable 中的位置。
- 如果设置了 table 的初始容量,会在初始化 table 时,将扩容阈值 threshold 重新调整为 table.size * loadFactor。
那么
- 当我们从外部传递进来 1w 时,实际上经过 tableSizeFor() 方法处理之后,就会变成 2 的 14 次幂 16384,再算上负载因子 0.75f,实际在不触发扩容的前提下,可存储的数据容量是 12288(16384 * 0.75f),已经大于10000了。
- 当HashMap 初始容量指定为1000时,会被调整为 1024,但是它只是表示 table 数组大小,扩容的重要依据扩容阈值会在 resize() 中调整为 768(1024 * 0.75)。
它是不足以承载 1000 条数据的。
-问题11: Cpu飙高,jstack发现最耗cpu的线程却是waiting状态
通过top -Hp $pid 找到最耗CPU的线程,再jstack, 到输出里查这个线程,发现它却被LockSupport.park挂起了,处于WAITING状态。这是怎么回事?
-tags: java,并发
-解答:
首先,LockSupport.park的注释里明确提到park的线程不会再被CPU调度的。所以可以大胆推断不是线程本身的代码消耗cpu。
那么,最有可能的便是线程的上下文切换。如果不确信它可以占用多少资源,可以做个实验,启动几千个线程,用LockSupport.park()不断挂起这些线程, 再使用LockSupport.unpark(t)不断地唤醒这些线程.,唤醒之后又立马挂起,观察期间cpu的使用情况便可知道。
那么,问题是,具体是什么消耗了cpu呢?从代码调用栈中找答案。
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199)
AQS是很多jdk并发库的底层框架,
- AQS有个临界变量state,当一个线程获取到state==0时, 表示这个线程进入了临界代码(获取到锁), 并原子地把这个变量值+1
- 没能进入临界区(获取锁失败)的线程,会利用CAS操作添加到到CLH队列尾去, 并被LockSupport.park挂起
- 当线程释放锁的时候, 会唤醒head节点的下一个需要唤醒的线程
- 被唤醒的线程检查一下自己的前置节点是不是head节点(CLH队列的head节点就是之前拿到锁的线程节点)的下一个节点,如果不是则继续挂起, 如果是的话, 与其他线程重新争夺临界变量,即重复第1步
在AQS的第2步中, 如果竞争锁失败的话, 是会使用CAS乐观锁的方式添加到队列尾的, 核心代码如下
/**
* Inserts node into queue, initializing if necessary. See picture above.
* @param node the node to insert
* @return node's predecessor
*/
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
在并发量非常高的情况下, 每一次执行compareAndSetTail都失败(即返回false)的话,那么这段代码就相当是一个死循环,消耗cpu就不奇怪了。
更进一步,如果进入临界区后很快就做完了业务逻辑,会导致CLH队列的线程被频繁唤醒,而又由于抢占锁失败频繁地被挂起,也会带来大量的上下文切换, 消耗系统的cpu资源。
-问题12: 诡异的NaN
浮点数NaN(Not-a-Number)有什么“诡异”的特性?
-tags: java
-解答:
先绕圈讲点别的。
在 Java 中,正无穷和负无穷是有确切的值,在内存中分别等同于十六进制整数 0x7F800000 和 0xFF800000。
这个范围之外,即:[0x7F800001, 0x7FFFFFFF] 和 [0xFF800001, 0xFFFFFFFF] 对应的就是NaN
。
NaN 有一个有趣的特性:除了“!=”始终返回 true 之外,所有其他比较结果都会返回 false。举例来说,“NaN<1.0F”返回 false,而“NaN>=1.0F”同样返回 false。对于任意浮点数 f,不管它是 0 还是 NaN,“f!=NaN”始终会返回 true,而“f==NaN”始终会返回 false。
-问题13: java中byte和long类型变量分别占用多少内存空间
-tags: java
-解答:
boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用4个字节;而在 64 位的 HotSpot 中,他们将占8个字节。
当然,对于 byte、char 以及 short 这三种类型,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。
无论在哪里,long
和double
类型都占用8字节。
-问题14: jvm加载类的过程
-tags: java
-解答:
jvm加载java类就是将字节流文件加入到内存中的过程,分为以下三步:加载、链接、初始化。
加载:查找字节流并且据此创建类的过程,每一种类加载器加载一部分类。
- 加载规则:双亲委派机制
- 类的唯一性:类加载器名称+类全限定名称
- 类加载器:
- 启动类加载器:无对应的java对象,负责加载最基础的类。如jre/lib下的类。
- 扩展类加载器:有对应的java对象,父类启动类加载器,负责加载jre/ext下类,该类加载器被启动类加载器加载之后方能加载其他类。
- 应用类加载器:有对应的java对象,父类是扩展类加载器,负责加载应用程序路径下的类,classpath、系统变量java.class.path或者环境变量classpath指定的类。
链接:验证、准备、解析
- 验证:在于确定被加载类满足jvm的约束条件。
- 准备:为被加载类的静态字段分配内存,构造与该类相关联的方法表。
- 解析:将符号引用解析为实际引用,符号引用是在编译阶段由编译器生成,包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型。
初始化:为标记为常量值的字段(基本类型或字符串且被修饰为final)赋值,以及执行
- 类的初始化过程是线程安全的,并且只能被初始化一次。jvm会通过加锁来保证
方法仅被执行一次 - 初始化的时机:对一个类的主动引用。
被动引用并不会引发类的初始化,如引用类的静态常量,引用父类的静态字段不会初始化子类,数组定义带来的引用不会导致初始化。
-问题15: Java虚拟机调用方法的大致过程
方法是如何被找到和执行的?
-tags: java
-解答:
Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的虚方法调用。这里只解释虚方法的调用 过程。
如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。
而动态绑定又是通过方法表这一数据结构来实现的。方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
在解析虚方法调用时,Java 虚拟机会纪录下所声明目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。这个过程便是动态绑定。
Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。
-问题16: java反射慢的原因是什么
都说java反射慢,究竟慢在哪了,为什么慢?
-tags: java
-解答:
以使用java
反射调用方法为例,一般流程是:
先用Class.forName获取类对象,再用Class.getMethod获取方法,最后执行Method.invoke进行动态调用。
其中,Class.forName会调用本地方法,Class.getMethod则会遍历该类的公有方法。如果没有匹配到,还将遍历父类的公有方法。所以这两个操作都非常费时。
另外,getMethod返回的是结果的一份拷贝。在热点代码中频率使用它或类似的getMethods或getDeclaredMethods会带来较多的堆空间消耗。
反射调用本身也有较高性能消耗。
- Method.invoke是个变长参数方法,编译器会在调用处生成一个Object 数组,保存传入的参数。如果参数是基本类型,还得进行自动装箱(因为Object数组只能保存包装类型)。最重要的一点是,因为是间接调用,导致反射调用无法被内联。
-问题17: invokedynamic指令有何特点?
和其它方法调用指令invokestatic,invokespecial,invokevirtual等相比,invokedynamic有何特点?
-tags: java
-解答:
上述指令中,Java 虚拟机明确要求方法调用需要提供目标方法的类名。它们使用上不够灵活,执行效率也不高。
为此,Java 7 引入了一条新的指令 invokedynamic。该指令抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。
其底层依赖方法句柄,这是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。
方法句柄的权限检查发生在创建过程中,相较于反射调用,节省了调用时反复检查权限的开销。
-问题18: gc的安全点概念
- 什么是安全点?
- 哪些状态属于安全点?
- 会出现长时间达到不安全点的情况吗?
-tags: java
-解答:
什么是安全点
安全点(safepoint)是在代码执行过程中的特殊位置,当线程执行到这些位置时,可以暂停,安全地进行GC,而不会引发混乱。
哪些状态属于安全点?
对线程可能在干的事一个个讨论:
- 执行 JNI 本地代码,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变。这种情况下可以作为安全点。
- 解释执行字节码,字节码与字节码之间可作为安全点。
- 执行即时编译器生成的机器码,比较复杂,在第三问中一并解释。
- 线程阻塞,此时线程被虚拟机线程调度器管理,属于安全点。
会出现长时间达到不安全点的情况吗?
不会。从第二问的分析看,当在虚拟机掌握范围内时,虚拟机可以方便地进行安全点检测。需要考虑的是执行本地代码或即时编译的机器码时。
- 事实上,由于本地代码需要通过 JNI 的 API 来访问java对象、调用java方法,或返回java方法,因此虚拟机仅需在 API 的入口处进行安全点检测即可搞定所有本地代码情形。
- 针对机器码,因为不受 Java 虚拟机掌控,因此需要在生成机器码时,插入安全点检测,以避免机器码长时间没有安全点检测的情况。
-问题19: 垃圾回收的基本方式有哪几种,分别有何优缺点
比如其中一种是“清除”
-tags: java
-解答:
一,清除
把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
缺点是
- 易造成内存碎片。
- 分配效率较低。需要遍历列表,来查找足够大的空闲内存。
二,压缩
把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。
缺点是压缩算法有较大性能开销。
三,复制
把内存区域分为两等分,总是把存活对象复制到其中一半,腾空的另一半给新对象用,如此来回倒腾。缺点是堆空间的使用效率极其低下。
-问题20: 新生代和老年代分别有哪些适用的垃圾回收器?
各自有什么特点?
-tags: java
-解答:
新生代
- Serial,单线程
- Parallel New,多线程
- Parallel Scavenge,多线程+注重吞吐率 + 不能与CMS共用。
老年代
- Serial Old, 单线程,标记 - 压缩算法
- Parallel Old, 多线程, 标记 - 压缩算法
- CMS, 能与业务并发,标记 - 清除算法。
-问题21: 请描述G1垃圾回收器
-tags: java
-解答:
G1直接将堆分成很多个区域。每个区域都可以充当Eden区、Survivor区或者老年代中的一个。
它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。
G1在选择进行垃圾回收的区域时,会优先回收死亡对象较多的区域。
-问题22: synchronized所加的锁可能有几种,轻重排序是怎样的?
-tags: java
-解答:
按照从重到轻的排序,可能是重量级锁、轻量级锁、偏向锁。
重量级锁状态下,加锁失败的线程会被阻塞,唤醒也需要靠操作系统,成本比较高。为了改善成本,虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态。通俗地理解,阻塞好比熄火停车,自旋好比怠速停车。
而轻量级锁好比深夜的十字路口,四个方向都闪黄灯的情况,车很少,偶尔一两辆,自行观察后通过即可。
偏向锁更进一步,好比能识别救护车的红绿灯,如果匹配到救护车,直接亮绿灯放行。
-问题23: 轻量级锁的加锁和解锁过程是怎样的?
-tags: java
-解答:
加锁
首先在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,将锁对象的标记字段复制到该锁记录中。然后,虚拟机会尝试用CAS操作替换锁对象的标记字段。
解锁
- 如果当前锁记录的值为 0,则代表重复进入同一把锁,直接返回即可。
- 否则,Java 虚拟机会尝试用CAS操作,比较锁对象的标记字段的值是否为当前锁记录的地址。
- 如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。
- 如果不是,则意味着这把锁已经被膨胀为重量级锁。
-问题24: 什么是即时编译的分层编译
-tags: java
-解答:
从Java 8开始,JVM默认采用分层编译的方式,分为:
- 0 层,解释执行
- 1 层,使用C1即时编译器(对应参数-client),不带profiling
- 2 层,使用C1编译器,执行部分 profiling
- 3 层,使用C1编译器,执行全部 profiling
- 4 层,使用C2编译器(对应参数-server)
通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。
方法会首先被解释执行,然后被 3 层的 C1 编译,最后被 4 层的 C2 编译。
第3层C1编译中,profile收集的关于分支以及类型的数据,可用来推断程序今后的执行。这些推断会精简代码的控制流以及数据流。在假设失败的情况下,JVM将去优化,退回至解释执行并重新收集相关profile。
-问题25: Spring创建动态代理有哪些方式,各种有何特点?
-tags: java
-解答:
有两种方式:
-
一,使用java反射
- 利用反射机制生成一个实现代理接口的匿名类
- 使用InvokeHandler进行具体方法调用
二,使用cglib,利用asm加载代理对象类的class文件,通过修改其字节码生成子类
前者只有代理实现了接口的类;后者不能对final修饰的类进行代理,也不能处理final修饰的方法。
-问题26: ThreadLocal的基本结构是怎样的,什么情况下会出现内存泄漏?
另外,ThreadLocalMap是如何处理hash 冲突问题的?
-tags: java,并发
-解答:
如图,
- 每个
Thread
中都有一个ThreadLocalMap
, - 每个
ThreadLocalMap
中可以有多个Entry
-
Entry
以ThreadLocal
为key
,以想要存储的对象为value
。
再看下图:
-
Entry
使用ThreadLocal
的弱引用作为Key
,如果ThreadLocal
没有外部强引用来引用它(如图中的Thread Local Ref
为null),那么系统 GC 时,这个ThreadLocal
会被回收。 - 这样一来,
ThreadLocalMap
中就会出现key
为null
的Entry
,就没有办法访问这些Entry
的value
,如果这个线程迟迟不结束,就造成内存泄漏了。
这么分析似乎是“弱引用”导致了泄漏,其实不是。事实上,如果是强引用,情况只会更严重,弱引用试图减少泄漏的可能,只不过由于ThreadLocalMap
的生命周期跟Thread
一样长,如果没有手动删除对应key就会导致内存泄漏。
其实,对于key
为null
的Entry
,下一次ThreadLocalMap
调用set
、get
、remove
的时候会被清除。所以真正需要做的是在不需要再用到该ThreadLocal
时调用remove
清除之。
Hash冲突问题
HashMap 的数据结构是数组+链表,而
ThreadLocalMap的数据结构仅仅是数组。ThreadLocalMap是通过开放地址法来解决hash冲突的问题。
开放地址法的基本思想是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
比如说,我们的关键字集合为{12,33,4,5,15,25},表长为10。 我们用散列函数f(key) = key mod l0。 当计算前S个数{12,33,4,5}时,都是没有冲突的散列地址,直接存入。
计算key = 15时,发现f(15) = 5,此时就与5所在的位置冲突。于是我们应用上面的公式f(15) = (f(15)+1) mod 10 =6。于是将15存入下标为6的位置。
这种做法有明显的缺点:
- 容易产生堆积问题,不适于大规模的数据存储。
- 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
- 删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。
之所以被 ThreadLocal采用,是因为:
ThreadLocal 往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间,同时数组的查询效率也是非常高,加上内部Hash算法的设计能实现低冲突概率。
-问题27: wait/notify机制与park/unpark机制有何异同?
我们知道,Object对象的wait和notify方法可以实现线程的阻塞和唤醒。
LockSupport的park和unpark也可以实现,那么,它们有何相同点和不同点呢?
-tags: java,并发
-解答:
先看它们的使用姿势:
// wait/notify
synchronized (obj) {
while ()
……
obj.wait();
……
obj.notifyAll();
}
// LockSupport
LockSupport.unpark(Thread.currentThread());
LockSupport.park(Thread.currentThread());
不同点
从使用上都可以发现第一个不同点:
- 控制的对象不同
wait/notify的控制对线程本身来说是被动的,要准确的控制哪个线程、什么时候阻塞/唤醒很困难, 要不随机唤醒一个线程(notify)要不唤醒所有的(notifyAll)
LockSupport以“线程”作为方法的参数, 语义更清晰,使用起来也更方便
- 机制不同。Object的wait/notify需要获取对象的监视器,LockSupport的park/unpark不需要。
- 调用wait/notify前必须确保获取了对象的锁,没获取锁就调用会拋异常;而LockSupport机制是每次unpark给线程1个“许可”——最多只能是1,而park则相反,如果当前线程有许可,那么park方法会消耗许可并返回,否则会阻塞线程直到线程重新获得许可,所以你甚至可以连续调用
unpark
而不用担心出错:
- 调用wait/notify前必须确保获取了对象的锁,没获取锁就调用会拋异常;而LockSupport机制是每次unpark给线程1个“许可”——最多只能是1,而park则相反,如果当前线程有许可,那么park方法会消耗许可并返回,否则会阻塞线程直到线程重新获得许可,所以你甚至可以连续调用
// 1次unpark给线程1个许可
LockSupport.unpark(Thread.currentThread());
// 如果线程非阻塞重复调用没有任何效果
LockSupport.unpark(Thread.currentThread());
// 消耗许可
LockSupport.park(Thread.currentThread());
// 阻塞
LockSupport.park(Thread.currentThread());
开发可以不用担心park的时序问题,否则,如果park必须要在unpark之前,那么给编程带来很大的麻烦!wait/notify机制则比较麻烦。比如线程B要用notify通知线程A,那么线程B要确保线程A已经在wait调用上等待了,否则线程A可能永远都在等待。
相同点
LockSupport的park和Object的wait一样也能响应中断。
-问题28: G1垃圾回收器的MaxGCPauseMillis参数
- 这是允许的GC最大的暂停时间。G1是如何做到尽量不超过这个时间的呢?
- 这个值设置得过高/过低,对业务有何影响 ?
-tags: java
-解答:
第一问
先解释一个概念,CSet(collection set):在一次垃圾收集过程中被收集的区域集合。
- Young GC时:选定所有新生代里的region。通过控制新生代的region个数来控制young GC的开销。
- Mixed GC时:选定所有新生代里的region,外加根据全局并发标记统计得出收集收益高的几个老年代region。在用户指定的开销目标范围内尽可能选择收益高的老年代region。
第二问
先明确能容忍的最大暂停时间,我们需要在这个限度范围内设置。
注意需要在吞吐量跟MaxGCPauseMillis之间做一个平衡。
如果MaxGCPauseMillis设置的过小,那么GC就会频繁,吞吐量就会下降。如果MaxGCPauseMillis设置的过大,应用程序暂停时间就会变长。
-问题29: ThreadPoolExecutor的使用
- 有哪些搭配用的任务队列?
- 何时启用新的线程?
-tags: java,并发
-解答:
这是两个相关的问题,根据所选任务队列的类型,ThreadPoolExecutor 会决定何时启动一个新线程。
Direct handoffs
此时ThreadPoolExecutor 搭配的是 SynchronousQueue。如果所有的线程都在忙碌,而且池中的线程数尚未达到最大,则新任务会启动一个新线程。这个队列没办法保存等待的任务:如果来了一个任务,创建的线程数已经达到最大值,而且所有线程都在忙碌,则新的任务总是会被拒绝。所以,建议将最大线程数指定为一个非常大的值。
Bounded queues
使用了有界队列(如 ArrayBlockingQueue)的ThreadPoolExecutor 会采用一个非常复杂的算法。比如,假设池的核心大小为 4,最大为 8,所用的 ArrayBlockingQueue 最大为 10。随着任务到达并被放到队列中,线程池中最多会运行 4 个线程(也就是核心大小)。即使队列完全填满,也就是说有 10 个处于等待状态的任务,ThreadPoolExecutor 也是只利用 4 个线程。
如果队列已满,而又有新任务加进来,此时才会启动一个新线程。这里不会因为队列已满而拒绝该任务,相反,会启动一个新线程。新线程会运行队列中的第一个任务,为新来的任务腾出空间。
Unbounded queues
如果 ThreadPoolExecutor 搭配的是无界队列(比如 LinkedBlockedingQueue),则不会拒绝任何任务(因为队列大小没有限制)。这种情况下,ThreadPoolExecutor 最多仅会按最小线程数创建线程,也就是说,最大线程池大小被忽略了。
-问题30: ForkJoinPool的使用
- 什么是ForkJoinPool?
- 有何优缺点?
-tags: java,并发
-解答:
首先,ForkJoinPool实现了 ExecutorService 接口,是一个标准的线程池。独特之处在于,它是为配合分治算法的使用而设计的:任务可以递归地分解为子集。这些子集可以并行处理,然后每个子集的结果被归并到一个结果中。
实现分治算法时,会创建大量的任务,但希望这些任务只有相对较少的几个线程来管理。
一个问题是,所有任务都要等待它们派生出的任务先完成,然后才能完成。
这使得很难使用 ThreadPoolExecutor 高效实现这个算法。ThreadPoolExecutor 内的线程无法将另一个任务添加到队列中并等待其完成,一旦线程进入等待状态,就无法使用该线程执行它的某个子任务了。
ForkJoinPool 则允许其中的线程创建新任务,之后挂起当前的任务。当任务被挂起时,线程可以执行其他等待的任务。
看以下代码片段,fork() 和 join() 方法是这里的关键:没有这些方法,实现这类递归会非常痛苦。这些方法使用了一系列内部的、从属于每个线程的队列来操纵任务,并将线程从执行一个任务切换到执行另一个。细节对开发者是透明的。
ForkJoinTask left = new ForkJoinTask(left, mid);
left.fork();
ForkJoinTask right = new ForkJoinTask(mid, right);
right.fork();
Long count = left.join() + right.join();
ForkJoinPool 还有一个额外的特性,它实现了工作窃取(work-stealing)。每个工作线程都有自己所创建任务的队列。线程会优先处理自己队列中的任务,但如果这个队列已空,它会从其他线程的队列中窃取任务(这是个双端队列,从自己的队列中取时遵循LIFO,窃取任务时遵循FIFO)。其结果是,即使 200 万个任务中有一个需要很长的执行时间,ForkJoinPool 中的其他线程也可以分担其余的随便什么任务。ThreadPoolExecutor 则不会这样:如果一个任务需要很长的时间,其他线程并不能处理额外的工作。
一般而言,如果任务是均衡的,使用分段的ThreadPoolExecutor性能更好;而如果任务是不均衡的,则使用 ForkJoinPool性能更好。
-问题31: JVM是怎么实现synchronized的
即,用synchronized关键字来对程序进行加锁的原理
-tags: java,并发
-解答:
当声明 synchronized 代码块时,编译而成的字节码将包含monitorenter和 monitorexit指令。这两种指令均会使用synchronized关键字括号里的引用,作为所要加锁解锁的锁对象。
可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。
在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。
当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。
当进行加锁操作时,JVM还会判断锁的类型。
对象头中的标记字段(mark word)的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁。
重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。Java 虚拟机采取了自适应自旋,来避免线程在面对非常小的 synchronized 代码块时,仍会被阻塞、唤醒的情况。
轻量级锁采用CAS操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。
偏向锁只会在第一次请求时采用 CAS 操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。
-问题32: 什么是CAS的ABA问题,如何避免?
-tags: java,并发
-解答:
ABA问题的根本原因在于对象值本身与状态被画上了等号。解决方式就是去除这个等号,不使用值本身,而使用版本戳version做对比。如java中的AtomicStampedReference。其compareAndSet
变成:
boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp)
-问题33: Lock接口实现的锁和synchronized关键字实现的锁有何不同?
-tags: java,并发
-解答:
- synchronized是Java中的关键字,内置的语言实现;Lock是个接口,由java代码实现,底层数据结构是AQS,大量使用CAS操作。
- synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;Lock在发生异常时,如果没有主动释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。
- Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。
- Lock可以尝试非阻塞地获取锁,能获取就获取,无法获取就立刻返回;可以超时地获取锁,在指定时长内无法获取锁就返回。
-问题34: CountDownLatch和CyclicBarrier的使用有何区别?
-tags: java,并发
-解答:
它们都是阻塞一些行为直至某个事件发生,但Latch是等待某个事件发生,而Barrier是等待线程。
闭锁(Latch)就像一个大门,未到达结束状态相当于大门紧闭,不让任何线程通过。
而到达结束状态后,大门敞开,让所有的线程通过,但是一旦敞开后不会再关闭。
闭锁可以用来确保一些活动在某个事件发生后执行。
我们可以用栅栏(Barrier)将一个问题分解成多个独立的子问题,并在执行结束后在同一处进行汇集。
当线程到达汇集地后调用await,await方法会阻塞直至其他线程也到达汇集地。
如果所有的线程都到达就可以通过栅栏,也就是所有的线程得到释放,而且栅栏也可以被重新利用。
总之,Latch是听口令行动,Barrier是看人数行动。
-问题35: SynchronousQueue的特性
-tags: java,并发
-解答:
SynchronousQueue与其他BlockingQueue有着不同特性:
- SynchronousQueue没有容量。SynchronousQueue是一个不存储元素的BlockingQueue。每一个put操作必须要等待一个take操作,否则不能继续添加元素,反之亦然。
- 因为没有容量,所以对应 peek, contains, clear, isEmpty … 等方法其实是无效的。例如clear是不执行任何操作的,contains始终返回false,peek始终返回null。
- SynchronousQueue分为公平和非公平,默认情况下采用非公平性访问策略,即先来的却后被匹配。
显示,这是特殊的生产者-消费者模式。一个生产线程,当它生产产品(即put的时候),如果当前没有人想要消费产品(即当前没有线程执行take),此生产线程必须阻塞,等待一个消费线程调用take操作,take操作将会唤醒该生产线程,同时消费线程会获取生产线程的产品。
SynchronousQueue的一个使用场景是在线程池里。Executors.newCachedThreadPool()就使用了它。目的就是保证“对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务”。
SynchronousQueue的吞吐量高于LinkedBlockingQueue和ArrayBlockingQueue
-问题36: 什么是FutureTask,有什么用?
-tags: java,并发
-解答:
FutureTask除了实现Future接口,还实现了Runnable接口。因此,可以交给Executor执行,也可以由调用的线程直接执行(FutureTask.run())。FutureTask还可以确保即使调用了多次run方法,它都只会执行一次任务。
常用的场景是在高并发环境下确保任务只执行一次。
举一个例子,假设有一个带key的连接池,当key存在时,即直接返回key对应的对象;当key不存在时,则创建连接。
在高并发的情况下有可能出现Connection被创建多次的现象(想想如何出现?)。创造性的解决思路是,当key不存在时,不是先创建Connection再放到Pool中,而只是把这个“意愿”表达出来,并放到connectionPool之后执行。
public Connection getConnection(String key) throws Exception {
FutureTask connectionTask = connectionPool.get(key);
if (connectionTask != null) {
return connectionTask.get();
} else {
Callable callable = new Callable() {
@Override
public Connection call() throws Exception {
// 耗时的同步创建连接方法
return createConnection();
}
};
FutureTask newTask = new FutureTask(callable);
connectionTask = connectionPool.putIfAbsent(key, newTask);
if (connectionTask == null) {
connectionTask = newTask;
connectionTask.run();
}
return connectionTask.get();
}
}
核心是connectionPool.get
不再直接返回Connection
,而是返回FutureTask
。
如果为空,不是直接新建连接,而是通过callable
表达这个意愿,这是非常廉价快速的操作。哪怕因并发,两个线程都表达了这个意愿也没有关系,因为putIfAbsent保证只有一个意愿会被保存。设置这个意愿的线程会触发run
,其余的都在get
处阻塞,直到run
结束。
-问题37: Volatile关键字的特性
-tags: java,并发
-解答:
volatile修饰的变量具有下列特性:
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最新写入。JMM是这样实现的:对于任意一个变量,一旦修改,立即把本地内存更新回主内存。对于任意一个变量,一旦读取,理解把本地内存中置为无效,从主内存中获取该值并更新到本地内存。
- 原子性:对任意单个volatile变量的读/写具有原子性。对于long和double型变量,在32位系统中,是需要分两步读取的,因此声明为volatile后可实现原子读取(64位系统,本来就是原子读取)。需要注意的是,类似于volatile++这种复合操作不具有原子性,包含了渎与写的操作,但是在读写的中间过程是没有进行同步的,有可能被其他线程插入。
考虑下面代码:
instance = new Singleton()
这里看起来是一句话,但实际上它并不是一个原子操作。
这句话被编译成8条汇编指令,大致做了3件事情:
1.给Singleton的实例分配内存。
2.初始化Singleton的构造器
3.将instance对象指向分配的内存空间(注意到这步instance就非null了)。
第二点和第三点的顺序是无法保证的,也就是说,执行顺序可能是1-2-3也可能是1-3-2,如果是后者,并且在3执行完毕、2未执行之前,被切换到线程二上,这时候instance因为已经在线程一内执行过了第三点,instance已经是非空了,所以线程二直接拿走instance,然后使用,然后顺理成章地报错。
用volatile后,保证了instance变量的原子性,禁止把3重排序到前面,即禁止volatile变量赋值之前的重排序。
-问题38: 什么时候需要自定义类加载器?
-tags: java
-解答:
我们需要的类不一定存放在已经设置好的classPath下(有系统类加载器AppClassLoader加载的路径),对于自定义路径中的class类文件的加载,我们需要自己的ClassLoader
有时我们不一定是从类文件中读取类,可能是从网络的输入流中读取类,还可能需要做一些加密和解密操作,这就需要自己实现加载类的逻辑,当然其他的特殊处理也同样适用。
可以定义类的实现机制,实现类的热部署,如OSGi中的bundle模块就是通过实现自己的ClassLoader实现的。