JVM 内存模型、字节码、垃圾回收面试要点
极客时间《面试现场》学习笔记
06 | 考官面对面:我们是如何面试架构师的?
者老师说,架构师软硬实力应该是三七开,而我在硬实力(基础技术、架构理解、发展能力)这方面的确有短板,软实力的考察是与硬实力同步进行的。
“精于此道,以此为生”,很喜欢老师的这个说法,自勉。
先试着回答有关 JVM 的几个问题,大部分内容来自于极客时间邓雨迪的《深入理解 Java 虚拟机》和王宝令的《Java 并发编程实战》专栏。
太长了,轻易别看。如果面试的时候能说出七成来,估计就没问题了。
JVM 内存模型
现代计算机体系大部分采用对称多处理器体系架构,每个处理器都有独立的寄存器组和缓存,多个处理器可以同时执行同一进程中的不同线程,称为处理器的乱序执行。
在 Java 中,不同线程可能访问同一个共享变量。如果任由编译器或处理器对这些访问进行优化的话,有可能出现各种问题,称为编译器的重排序。
为了让应用程序免于数据竞争(data race)的干扰,Java 语言规范引入了 Java 内存模型,通过定义一些规则,对编译器和处理器进行限制,针对可见性和有序性,解决处理器的乱序执行、编译器的重排序以及内存系统重排序带来的影响。
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。Java 内存莫能行通过定义了一系列的 happens-before 操作,让应用程序开发者能够表达不同线程的操作之间的内存可见性。
volatile 关键字(字段),禁用 CPU 缓存,告诉编译器,对这个变量的读写不能用 CPU 缓存,必须从内存中读取或者写入。
final 关键字(修饰符),告诉编译器,这个变量生而不变,可以优化
Happens-Before 规则
Happen-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定要遵守 Happens-Before 规则(前面一个操作的结果对后续操作是可见的)。
程序顺序性:在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作
volatile 变量:对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作
传递性:如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
管程中锁:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
线程 start():主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
线程 join():主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。
线程中断:对线程 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生
对象终结:一个对象的初始化完成(构造函数执行结束)Happens-Before 于它的 finalize() 方法的开始
管程是一种通用的同步原语,在 Java 中指的是 synchronized
Java 内存模型通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。内存屏障可以限制编译器的重排序优化,并且导致处理器的缓存刷新操作。
JVM 字节码
Java 字节码是 Java 虚拟机所使用的指令集。感觉有点类似于汇编语言,但是其实可读性更好一些。
在解释执行过程中,Java 虚拟机需要开辟一块额外的空间作为操作数栈(Operand Stacks),用于存放计算的操作数以及返回结果。Java 方法栈桢还有一部分作为局部变量区(Local Variables),用于存放 this 指针(仅非静态方法),所窜入的参数,以及字节码中的局部变量。
(图片和部分文字来自极客时间《深入拆解Java虚拟机》11 | 垃圾回收(上)郑雨迪)
存储在局部变量区的值,通常需要加载至操作数栈中,方能进行计算,得到计算结果后再存储至局部变量数组中。
可以简单的写一个 HelloWorld 程序,然后使用 javap 看一下字节码。
需要先用 javac 编译一下,然后再 javap,可以带上 -verbose 显示附加信息。
locals 表示方法内局部变量个数,该例中是 1,而 HelloWorld() 没有参数,为什么不是 0 ?
当线程调用一个方法的时候,jvm 会开辟一个帧出来,这个帧包括操作栈、局部变量列表、常量池的引用。对于非 static 方法,在调用的时候都会给方法默认加上一个当前对象(this)类型的参数,不需要在方法中定义,这个时候局部变量列表中 index 为 0 的位置保存的是 this,其他索引号按变量定义顺序累加;static 方法不依赖对象,所以不用传 this
同样,Args_size 表示参数个数,HelloWorld() 会传一个 this 进去,所以 value 是 1
如果想要验证这一点,可以写一个静态方法,然后在看一下字节码,比如:
可以得到:
之前学习 C# 的时候,也看过 C# 的中间代码(IL),有点类似。
参考链接:
JVM 垃圾回收
按照记忆中的 Java 知识,我以为垃圾回收使用的引用计数法,后来发现已经有点过时了。JVM 一般采用的是可达性分析算法进行垃圾回收。
以 GC Roots 的集合作为起点,然后探索所有被引用到的对象,并将其加入集合,称为标记(mark),最终没有被探索到的,就可以回收了。
GC Roots 是指由堆外指向堆内的引用,主要有Java 方法栈桢中的局部变量;已加载类的静态变量;JNI handles;已启动且未停止的 Java 线程。
Java 虚拟机通过安全点(safepoint)机制来实现 Stop-the-world,即时编译器会插入安全点检测,用以保证在可接受的性能开销和内存开销之内,避免机器码长时间不进入安全点,从而间接减少垃圾回收的暂停时间(GC pause)。
垃圾回收的三种方式为清除(sweep)、压缩(compact)、复制(copy)。
清除:简单,内存碎片,分配效率低
压缩:解决内存碎片化问题,留下连续内存空间,压缩算法有一定的性能开销
复制:解决内存碎片化问题,堆空间使用效率低下
Java 虚拟机分代回收,将对空间分为新生代和老年代。新生代用来存储新建对象,对象存活时间足够长,就移动到老年代。
新生代被分为一个 Eden 区和两个大小一致的 Survivor 区,其中一个 Survivor 区是空的。
在只针对新生代的 Minor GC 中,Eden 区和非空 Survivor 区的存活对象会被复制到空的 Survivor 区中,当 Survivor 区中的存活对象复制次数超过一定数值时,晋升至老年代。
(图片和文字来自极客时间《深入拆解Java虚拟机》12 | 垃圾回收(下)郑雨迪)
JVM 虚拟机在垃圾回收方面还有两个技术,一个是 TLAB(Thread Local Allocation Buffer),用以解决线程共享的堆空间争用的问题,类似于为每个司机预先申请多个停车位;一个是卡表(Card Table),避免在 Minor GC 的时候扫描整个老年代(枚举 GC Roots 时,需要考虑从老年代到新生代的引用)。
后来还出现了横跨新生代和老年代的 G1(Garbage First),G1 直接将堆分成多个区域,打破之前的碓结构,采用标记-压缩算法,针对每个细分区域进行垃圾回收,优先回收死亡对象较多的区域。
Java 11 引入了宣称暂停时间更短的 ZGC。
版权声明: 本文为 InfoQ 作者【escray】的原创文章。
原文链接:【http://xie.infoq.cn/article/8cf18ed2e6225f212da9e8d73】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论