Java常见面试题解析
1. 内存区域划分
-
程序计数器PC
和操作系统的PC类似,Java虚拟机也需要,都是指执行下一条的命令。每个线程私有的。
-
虚拟机栈
也可以称作线程栈,每个线程私有的。在每个方法执行的时候,jvm 都会同步创建一个栈帧去存储局部变量表,操作数栈,动态连接,方法出口等信息。一个方法的生命周期就贯彻了一个栈帧从入栈到出栈的全部过程。
-Xss控制线程栈大小,默认1MB,这也限制了线程数大小。如一个goroutine默认2KB,则可以开启更大量的『线程』。
-
堆
算是虚拟机中最大的一块儿内存区域了,它是所有线程共享的,GC垃圾回收也主要是针对这个区域。
-
方法区
方法区也是所有「线程共享」的区域,它「存储」了被JVM加载的「类型信息、常量、静态变量等数据」。运行时常量池就是方法区的一部分,编译期生成的各种字面量与符号引用就存储在其中。
-
本地方法栈
Java底层用了很多c的代码去实现,而其调用c端的方法上都会有native,代表本地方法服务,而本地方法栈就是为其服务的。
-
直接内存
NIO就会使用到直接内存,也可以说「堆外内存」,通常会「配合虚引用一起去使用」,就是为了资源释放,会将堆外内存开辟空间的信息存储到一个队列中,然后GC会去清理这部分空间。 Netty 使用堆外内存池来实现零拷贝技术。对于磁盘 IO 时,也可以使用内存映射,来提升性能。
2. 常见的垃圾收集器
-
Serial GC
它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的。其单线程设计也意味着精简的GC实现,无需维护复杂的数据结构,初始化也简单,所以一直是Client模式下JVM的默认选项。 从年代的角度,通常将其老年代实现单独称作Serial Old,它采用了标记-整理(Mark-Compact)算法,区别于新生代的复制算法。Serial GC的对应JVM参数是: -XX:+UseSerialGC
-
ParNew GC
很明显是个新生代GC实现,它实际是Serial GC的多线程版本,最常见的应用场景是配合老年代的CMS GC工作,下面是对应参数 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
-
CMS(Concurrent Mark Sweep)GC
基于标记-清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于Web等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用CMS GC。
(1). 初始标记 (CMS initial mark)
只是标记一下GC Roots能直接关联到的对象,速度很快。
(2). 并发标记 (CMS concurrent mark)
从GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
(3). 重新标记 (CMS remark)
为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。要暂停用户线程。
(4). 并发清除 (CMS concurrent sweep)
清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
缺点:
- 和用户线程并发,占用资源,导致应用变慢,降低总吞吐。
- 产生浮动垃圾(并发阶段,用户线程运行有新垃圾产生),所以需要预留一部分空间给用户线程用。如果垃圾收集期间产生新对象不那么快,可以降低预留的阈值,降低回收频率。要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。
- CMS基于标记-清除算法,会有内存碎片。可能导致总空间够用,连续空间不够,大对象就是没法分配,不得不提前触发一次Full GC的情况。
-
Parallel GC
在早期JDK 8等版本中,它是server模式JVM的默认GC选择,也被称作是吞吐量优先的GC。它的算法和Serial GC比较相似,尽管实现要复杂的多,其特点是新生代和老年代GC都是并行进行的,在常见的服务器环境中更加高效。
Parallel Scavenge年轻代基于标记复制算法,Parallel Old老年代基于标记-整理算法。
开启选项是: -XX:+UseParallelGC
Parallel GC引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标,JVM会自动进行适应性调整,例如下面参数:
-XX:MaxGCPauseMillis=value
-XX:GCTimeRatio=N // GC时间和用户时间比例 = 1 / (N+1)
-
G1 GC
兼顾吞吐量和停顿时间的GC实现,是Oracle JDK 9以后的默认GC选项。G1可以直观的设定停顿时间的目标,相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。
G1 GC仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个region。Region之间是复制算法,但整体上实际可看作是标记-整理(Mark-Compact)算法,可以有效地避免内存碎片,尤其是当Java堆非常大的时候,G1的优势更加明显。
G1 面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃 圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:M axGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。
垃圾对象收集 方法
-
引用计数
为对象添加一个引用计数,用于记录对象被引用的情况,如果计数为0,即表示对象可回收。处理循环引用上比较困难。
-
可达性分析
将对象及其引用关系看作一个图,选定活动的对象作为 GC Roots,然后跟踪引用链条,如果一个对象和GC Roots之间不可达,也就是不存在引用链条,那么即可认为是可回收对象。JVM会把虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量,作为GC Roots。
Java哪些对象是GC Roots
除了上述的虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量,还有
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(空指针异常、OOM等),还有类加载器。
- 被 Synchronized 持有的对象。
- 反应 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调本地代码缓存等。
常见的垃圾收集算法
-
复制(Copying)算法
新生代GC,基本都是基于复制算法,可以避免内存碎片化,提前预留内存空间,有一定的浪费。
另外,对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,这个开销也不小,不管是内存占用或者时间开销。
-
标记-清除(Mark-Sweep)算法
标识出所有要回收的对象,然后进行清除。不可避免的出现碎片化问题,这就导致其不适合特别大的堆;否则,一旦出现Full GC,暂停时间可能无法接受。
-
标记-整理(Mark-Compact)
类似于标记-清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。
概念
OopMap
方便快速枚举根节点。一旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
安全点、安全区域
HotSpot没有为每条指令都生成OopMap(否则成本非常高昂),只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safep oint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集(否则根节点枚举异常),而是强制要求必须执行到达安全点后才能够暂停。
例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
-
抢先式中断
抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
-
主动式中断
当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。通常虚拟机采用这种方案。
安全区域 :
指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域
3. 三色标记法工作原理
三种颜色标记不同对象扫描状态:
- 白色:没有检查(或者检查过了,确实没有引用指向它了)
- 黑色:自身和成员都被检查完了,存活的对象
- 灰色:自身被检查了,成员没被检查完(可以认为访问到了,但是正在被检查,就是图的遍历里那些在队列中的节点)
我们知道在 并发标记 的时候 可能会 出现 误标 的情况,这里举两个例子:
-
刚开始标记为 垃圾 的对象,但是在并发标记过程中 变为了存活对象
影响还不算很大,只是浮动垃圾,待下一次清理的时候再清理一下就好了。
-
刚开始标记为 存活 的对象,但是在并发标记过程中 变为了垃圾对象
正在使用的对象的突然被清理掉了,后果会很严重。
那么产生上述第二种情况的原因是什么呢?
如图举例:D 是黑色的,E 是灰色的,同时业务线程执行时,E 断开了指向 G,D 又引用了 G。 因为 D 已经标记了是黑色,E 也认为所引用的对象都扫描完了。所以 G 就当做了是白色的。这个时候 GC 时就会把 G 错杀掉。
- 导致 case 的两个条件
-
新增了 一条或多条 黑色到白色 对象的新引用 (D -> G)
-
删除了 灰色 对象 到该白色对象 的直接 引用或间接引用 (E -> G)
当这两种情况都满足的时候就会出现这种问题了。为了解决这个问题,引入了 增量更新 (Incremental Update)和 原始快照 (SATB)的方案:
-
增量更新破坏了第一个条件:增加黑色到白色的新引用时,记录该引用信息,在后续 STW 扫描中重新扫描这些黑色为根的引用,这下引用链上就能知道这个白色变成黑色。(CMS 的使用方案)
-
原始快照破坏了第二个条件:删除引用时记录下来这个引用关系(快照),在后续 STW 扫描时将这些记录过的灰色对象为根再扫描一次,这个白色的也会被标记黑色非垃圾了。(G1 的使用方案)
4. 如何监控GC和内存情况
-
可视化界面 jdk自带工具
jconsole、jvisualvm 在shell执行后,能选择java进程连接,可视化查看。
-
命令行工具
jstat和jmap等工具都提供了一些选项,可以查看堆、方法区等使用数据。
-
GC日志,利用工具分析 如在线工具 gceasy.io
-
jmap 生成堆转储(Heap Dump)文件,然后利用MAT、Jprofler等堆转储分析工具进行详细分析。
-
堆外内存中的直接内存,可以使用JDK自带的Native Memory Tracking(NMT)特性,它会从JVM本地内存分配的角度进行解读。
5. 堆外内存
6. GC调优思路
- https://blog.51cto.com/alex4dream/2951022
- https://blog.csdn.net/CSDN_WYL2016/article/details/125464879
- https://blog.51cto.com/zero01/2150696
7. Java内存模型 JMM理解
硬件内存架构,主存->高速cache->寄存器。主存共享,但cpu核不只一个,对应高速cache和寄存器也会有多个。
通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
- 线程之间的共享变量存储在主内存(Main Memory)中
- 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成: lock、unlock、read、load、use、assign、store、write
这些不同的命令在不同的条件下,如Java语法volatile、synchronized等,在编译时,针对不同的指令架构,在前后插入这些命令,去保障原子性、可见性、有序性的一种或几种。
Happen Before
JMM提供了happens-before规则(JSR-133规范),满足了内存可见性保证。
在Java中,有以下天然的happens-before关系:
- 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作、
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
8. synchronized底层如何实现?什么是锁的升级、降级?
通常Synchronized实现同步锁的方式有两种,一种是修饰方法,一种是修饰方法块。
// 关键字在实例方法上,锁为当前实例
public synchronized void method1(){
// code
}
// 关键字在静态方法上,锁为当前类对象
public static synchronized void method2(){
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void method2(){
Object o = new Object();
synchronized (o){
// code
}
}
反编译看字节码:
- Synchronized在修饰同步代码块时,是由 monitorenter和monitorexit指令来实现同步的。
- 当Synchronized修饰同步方法时,并没有发现monitorenter和monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。
重量级锁基于进入和退出管程(Monitor,Monitor是依靠操作系统的Mutex Lock来实现互斥)对象实现的。每个对象实例都会有一个Monitor,Monitor可以和对象一起创建、销毁。
当多个线程同时访问一段同步代码时,多个线程会先被存放在ContentionList和EntryList集合中,处于block状态的线程,都会被加入到该列表。接下来当线程获取到对象的Monitor时,线程申请并持有Mutex成功,其它线程将无法获取到该Mutex,竞争失败的线程会再次进入ContentionList被挂起。
如果线程调用wait() 方法,就会释放当前持有的Mutex,并且该线程会进入WaitSet集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放Mutex。 因Monitor是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。
锁升级
对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中Java对象头由Mark Word、指向类的指针以及数组长度三部分组成。 Mark Word记录了对象和锁有关的信息。
锁升级功能主要依赖于Mark Word中的锁标志位、释放偏向锁标志位、锁指针等,Synchronized同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。
- 偏向锁
-
优化场景
偏向锁主要用来优化同一线程多次申请同一个锁的竞争,在某些情况下,大部分时间是同一个线程竞争锁资源。
-
加锁流程
当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是01,“是否偏向锁”标志位设置为1,并且记录抢到锁的线程ID,表示进入偏向锁状态。
-
锁升级
一旦出现其它线程竞争锁资源时,偏向锁就会被撤销,进入升级锁流程。
- 轻量级锁
-
优化场景
轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
-
加锁流程
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头Mark Word中的线程ID不是自己的线程ID,就会进行CAS操作获取锁,如果获取成功,直接替换Mark Word中的线程ID为自己的ID,该锁会保持偏向锁状态;锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
CAS加锁,加锁成功,持有轻量级锁。加锁失败,重试几次,最终失败要继续锁升级。
-
重量级锁
锁优化前的陷入内核。
9. AtomicInteger底层实现原理是什么?CAS理解
AtomicIntger是对int类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS(compare-and-swap)技术。
所谓CAS,表征的是一些列操作的集合,获取当前数值,进行一些运算,利用CAS指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。
从AtomicInteger的内部属性可以看出,它依赖于Unsafe提供的一些底层能力,进行底层操作。
需要重试的场景即do while循环,重新拿最新的value,然后cas,如:
public final int getAndAddInt(Object o,long offset,int delta){
int v;
do{
v=getIntVolatile(o,offset);
} while(!weakCompareAndSetInt(o,offset,v,v+delta));
return v;
}
CAS算是无锁机制,以上循环操作,如果并发修改非常高频,CAS空自旋会浪费大量CPU资源。
ABA问题
因为CAS只对比value值是否相等,相等则改,但可能中间经过多个版本后回来的。这和互斥锁不同,但一般不影响预期。针对这种情况,Java提供了AtomicStampedReference工具类,通过为引用建立类似版本号(stamp)的方式,来保证CAS的正确性。
10. AQS理解
AbstractQueuedSynchronizer(AQS),其是Java并发包中,实现各种同步结构和部分其他组成单元(如线程池中的Worker)的基础。
基础的同步相关操作抽象在AbstractQueuedSynchronizer中,利用AQS为我们构建同步结构提供了范本。
AQS内部数据和方法,可以简单拆分为:
- 一个volatile的整数成员表征状态,同时提供了setState和getState方法
private volatile int state;
-
一个先入先出(FIFO)的等待线程双向链表,以实现多线程间竞争和等待,这是AQS机制的核心之一。
-
队列节点类 Node,封装了线程信息、等待状态信息和前后节点指针。
-
各种基于CAS的基础操作方法,以及各种期望具体同步结构去实现的acquire/release方法。
利用AQS实现一个同步结构,至少要实现两个基本类型的方法,分别是acquire操作,获取资源的独占权;还有就是release操作,释放对某个资源的独占。
ReentrantLock
以ReentrantLock为例,它内部通过扩展AQS实现了Sync类型,以AQS的state来反映锁的持有情况。
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
}
static final class NonfairSync extends Sync {
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
/**
* AQS 内提供
* @param arg
*/
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
lock() 调用 acquire(),acquire()内部先调用 tryAcquire() ,tryAcquire()由AQS的实现类定义。 对非公平的ReentrantLock来说,tryAcquire() 如下,CAS快速尝试加锁。如果失败,去调用acquireQueued(),该方法由AQS内部提供,如下。
tryAcquire是按照特定场景需要开发者去实现的部分,而线程间竞争则是AQS通过Waiter队列与acquireQueued提供的,在release方法中,同样会对队列进行对应操作。
/**
* 非公平的ReentrantLock的tryAcquire()实现
*/
final boolean nonfairTryAcquire(int acquires){
final Thread current=Thread.currentThread();
int c=getState();
if(c==0){
// CAS更改state,成功则加锁成功
if(compareAndSetState(0,acquires)){
setExclusiveOwnerThread(current);
return true;
}
}
// 可重入锁,自身线程再加锁
else if(current==getExclusiveOwnerThread()){
int nextc=c+acquires;
if(nextc< 0) // overflow
throw new Error("Maximum lock count exceeded");
// state是总个数
setState(nextc);
return true;
}
return false;
}
/**
* AQS提供
* 构造队列里的节点 线程信息
*/
private Node addWaiter(Node mode){
Node node=new Node(Thread.currentThread(),mode);
// Try the fast path of enq; backup to full enq on failure
Node pred=tail;
if(pred!=null){
node.prev=pred;
// CAS入队
if(compareAndSetTail(pred,node)){
pred.next=node;
return node;
}
}
enq(node);
return node;
}
/**
* AQS提供
*/
final boolean acquireQueued(final Node node,int arg){
boolean failed=true;
try{
boolean interrupted=false;
for(;;){
final Node p=node.predecessor();
// 当前节点的前节点是head,则尝试获取锁
if(p==head && tryAcquire(arg)){
setHead(node);
p.next=null; // help GC
failed=false;
return interrupted;
}
// shouldParkAfterFailedAcquire 判断是否要park
// parkAndCheckInterrupt park当前线程并等着被unpark
// 且要检查是否被中断
if(shouldParkAfterFailedAcquire(p,node) && parkAndCheckInterrupt())
interrupted=true;
}
} finally{
if(failed)
cancelAcquire(node);
}
}
/**
* AQS提供
*/
public final boolean release(int arg){
if(tryRelease(arg)){
Node h=head;
if(h!=null && h.waitStatus!=0)
// 释放锁后唤醒下一个节点
unparkSuccessor(h);
return true;
}
return false;
}
11. ConcurrentHashMap原理
java7 vs java8 数据结构:
java7
- 分段锁 锁基于Reentrantlock
- key hash到某一个segment
- 每个段内部是hashmap,hash冲突是链表追加
- 扩容时,segment容量不动,各自内部hashmap扩容
java8
- 槽位上的节点加锁 大量synchronized
- hash冲突解决是 链表 + 红黑树
- 扩容时,可多个线程同时扩容,每个线程划分一部分
12. 动态代理是基于什么原理
通过反射我们可以直接操作类或者对象,比如获取某个对象的类定义,获取类声明的属性和方法,调用方法或者构造对象,甚至可以运行时修改类定义。
动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装RPC调用、面向切面的编程(AOP)。
实现动态代理的方式很多,比如JDK自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似ASM、cglib(基于ASM)、Javassist等。
JDK proxy demo
public class MyDynamicProxy {
public static void main(String[] args) {
HelloImpl hello = new HelloImpl();
MyInvocationHandler handler = new MyInvocationHandler(hello);
// 构造代码实例
Hello proxyHello = (Hello) Proxy.newProxyInstance(HelloImpl.class.getClassLoader(), HelloImpl.class.getInterfaces(), handler);
// 调用代理方法
proxyHello.sayHello();
}
}
/**
* 定义接口
* JDK动态代理需要实现一个接口
*/
interface Hello {
void sayHello();
}
/**
* 被代理对象
*/
class HelloImpl implements Hello {
@Override
public void sayHello() {
System.out.println("Hello World");
}
}
class MyInvocationHandler implements InvocationHandler {
private Object target;
/**
* 目标被代理对象传进来
* @param target
*/
public MyInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.println("Invoking sayHello");
Object result = method.invoke(target, args);
return result;
}
}
13. NIO机制
14. 类加载过程,什么是双亲委派模型
类加载
类加载过程分为三个主要步骤:加载、链接(验证、准备、解析)、初始化
-
加载
加载阶段(Loading),它是Java将字节码数据从不同的数据源读取到JVM中,并映射为JVM认可的数据结构(Class对象),这里的数据源可能是各种各样的形态,如jar文件、class文件,甚至是网络数据源等;如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。
-
链接
把原始的类定义信息平滑地转化入JVM运行的过程中
2.1 验证
这是虚拟机安全的重要保障,JVM需要核验字节信息是符合Java虚拟机规范的,否则就被认为是VerifyError,这样就防止了恶意信息或者不合规的信息危害JVM的运行,验证阶段有可能触发更多class的加载。
2.2 准备
创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的JVM指令。
2.3 解析
在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。
-
初始化
真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。
双亲委派
当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载Java类型。
自定义类加载器 -> 应用程序类加载器(默认 classpath) -> 扩展类加载器(jre/lib/ext/) -> 启动类加载器(rt.jar)
值得注意的是,不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。 例如,Java 中JNDI、JDBC、文件系统、Cipher等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
SPI机制
SPI(Service Provider Interface),是JDK内置的一种服务提供发现机制,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,比如java.sql.Driver接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL和PostgreSQL都有不同的实现提供给用户,而Java的SPI机制可以为某个接口寻找服务实现。Java中SPI机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是解耦。
当服务的提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个jar包(一般都是以jar包做依赖)的META-INF/services/中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader。
demo
// ServiceLoader.load 加载 指定接口的实现类,返回值可遍历
ServiceLoader<MyTestService> serviceLoader = ServiceLoader.load(MyTestService.class);
serviceLoader.forEach((MyTestService test) -> {
if (test instanceof ATestServiceImpl) {
System.out.println(test.getName());
}
else if (test instanceof BTestServiceImpl) {
System.out.println(test.getName());
}
});
15. 并发容器问题
LinkedBlockingQueue 和 ConcurrentLinkedQueue 区别
Concurrent类型基于lock-free(CAS),在常见的多线程访问场景,一般可以提供较高吞吐量。
而LinkedBlockingQueue内部则是基于锁,并提供了BlockingQueue的等待性方法。
Concurrent类型没有类似CopyOnWrite之类容器相对较重的修改开销。
但Concurrent往往提供了较低的遍历一致性。例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历。
与弱一致性对应的,就是同步容器常见的行为“fail-fast”,也就是检测到容器在遍历过程中发生了修改,则抛出ConcurrentModificationException,不再继续遍历。
弱一致性的另外一个体现是,size等操作准确性是有限的,未必是100%准确。与此同时,读取的性能具有一定的不确定性。
16. Spring Bean的生命周期和作用域
生命周期
Spring Bean生命周期比较复杂,可以分为创建+销毁两个过程。
创建:
-
实例化Bean对象。
构造函数调用
-
设置Bean属性。
依赖注入
-
如果我们通过各种Aware接口声明了依赖关系,则会注入Bean对容器基础设施层面的依赖。
具体包括BeanNameAware、BeanFactoryAware和ApplicationContextAware,分别会注入Bean ID、Bean Factory或者ApplicationContext。
-
调用BeanPostProcessor的前置初始化方法postProcessBeforeInitialization。
-
如果实现了InitializingBean接口,则会调用afterPropertiesSet方法。
-
调用Bean自身定义的init方法。
-
调用BeanPostProcessor的后置初始化方法postProcessAfterInitialization。
销毁
Spring Bean的销毁过程会依次调用DisposableBean的destroy方法和Bean自身定制的destroy方法。
作用域
Spring Bean有五个作用域,其中最基础的有下面两种:
-
Singleton
Spring的默认作用域,也就是 IOC容器中创建唯一的一个Bean实例。(beanName唯一,bean实例可能有多个)
-
Prototype
针对每个getBean请求,容器都会单独创建一个Bean实例。
-
Request,为每个HTTP请求创建单独的Bean实例。
-
Session,很显然Bean实例的作用域是Session范围。
-
GlobalSession,用于Portlet容器,因为每个Portlet有单独的Session,GlobalSession提供一个全局性的HTTP Session。
17. Spring Bean 三级缓存理解
- 单例池 singletonObjects (存放的 经过完整创建周期的 Bean 对象)
- 二级缓存 earlySingletonObjects (存放的 未经过完整创建周期的 Bean对象)
- 三级缓存 singletonFactories,AOP 时更加方便 (值得注意的是 Map 中的 value 是个 lambda 表达式,包含 BeanName + BeanDefination + 原始Bean)
18. Java的线程状态
Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。这6种状态分别是:
- 新建(New): 创建后尚未启动的线程处于这种状态。
- 运行(Runnable): 包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
- 无限期等待(Waiting): 处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置Timeout 参数的Object::wait()方法;
- 没有设置Timeout 参数的Thread::join()方法;
- LockSupport::park()方法。
- 限期等待(Timed Waiting): 处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
- Thread::sleep()方法;
- 设置了Timeout 参数的Object::wait()方法;
- 设置了Timeout 参数的Thread::join()方法;
- LockSupport::parkNanos()方法;
- LockSupport::parkUntil()方法。
- 阻塞(Blocked): 线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生; 而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated): 已终止线程的线程状态,线程已经结束执行。
19. Synchronized 和 ReentrantLock 区别
- Synchronized 语言层面提供的关键字,块结构的同步语法。同一个线程可重入,无法强制正在等待锁的线程中断等待或超时退出。
- ReentrantLock J.U.C类库层面提供互斥手段。与synchronized相比增加了一些高级功能,主要有以下三项:
- 等待可中断:reentrantLock.lockInterruptibly(); 可中断特性对处理执行时间非常长的同步块很有帮助。
- 可实现公平锁
- 锁可以绑定多个条件: 一个ReentrantLock对象可以同时绑定多个Condition对象。