写点什么

JVM 内存模型、字节码、垃圾回收面试要点

用户头像
escray
关注
发布于: 2020 年 08 月 31 日
JVM 内存模型、字节码、垃圾回收面试要点

极客时间《面试现场》学习笔记

06 | 考官面对面:我们是如何面试架构师的?      



者老师说,架构师软硬实力应该是三七开,而我在硬实力(基础技术、架构理解、发展能力)这方面的确有短板,软实力的考察是与硬实力同步进行的。



“精于此道,以此为生”,很喜欢老师的这个说法,自勉。



先试着回答有关 JVM 的几个问题,大部分内容来自于极客时间邓雨迪的《深入理解 Java 虚拟机》和王宝令的《Java 并发编程实战》专栏。



TL;DR



太长了,轻易别看。如果面试的时候能说出七成来,估计就没问题了。



JVM 内存模型


现代计算机体系大部分采用对称多处理器体系架构,每个处理器都有独立的寄存器组和缓存,多个处理器可以同时执行同一进程中的不同线程,称为处理器的乱序执行



在 Java 中,不同线程可能访问同一个共享变量。如果任由编译器或处理器对这些访问进行优化的话,有可能出现各种问题,称为编译器的重排序



为了让应用程序免于数据竞争(data race)的干扰,Java 语言规范引入了 Java 内存模型,通过定义一些规则,对编译器和处理器进行限制,针对可见性和有序性,解决处理器的乱序执行、编译器的重排序以及内存系统重排序带来的影响。



Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。Java 内存莫能行通过定义了一系列的 happens-before 操作,让应用程序开发者能够表达不同线程的操作之间的内存可见性。



volatile 关键字(字段),禁用 CPU 缓存,告诉编译器,对这个变量的读写不能用 CPU 缓存,必须从内存中读取或者写入。



final 关键字(修饰符)告诉编译器,这个变量生而不变,可以优化



Happens-Before 规则



Happen-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定要遵守 Happens-Before 规则(前面一个操作的结果对后续操作是可见的)。



  1. 程序顺序性:在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作

  2. volatile 变量:对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作

  3. 传递性:如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

  4. 管程中锁:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁

  5. 线程 start():主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作

  6. 线程 join():主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。

  7. 线程中断:对线程 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生

  8. 对象终结:一个对象的初始化完成(构造函数执行结束)Happens-Before 于它的 finalize() 方法的开始



管程是一种通用的同步原语,在 Java 中指的是 synchronized



Java 内存模型通过内存屏障(memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。内存屏障可以限制编译器的重排序优化,并且导致处理器的缓存刷新操作。



JVM 字节码


Java 字节码是 Java 虚拟机所使用的指令集。感觉有点类似于汇编语言,但是其实可读性更好一些。



在解释执行过程中,Java 虚拟机需要开辟一块额外的空间作为操作数栈(Operand Stacks),用于存放计算的操作数以及返回结果。Java 方法栈桢还有一部分作为局部变量区(Local Variables),用于存放 this 指针(仅非静态方法),所窜入的参数,以及字节码中的局部变量。



Java 方法栈桢



(图片和部分文字来自极客时间《深入拆解Java虚拟机》11 | 垃圾回收(上)郑雨迪)



存储在局部变量区的值,通常需要加载至操作数栈中,方能进行计算,得到计算结果后再存储至局部变量数组中。



可以简单的写一个 HelloWorld 程序,然后使用 javap 看一下字节码。



public class HelloWorld{
public static void main(String [] args){
System.out.println("Hello World");
}
}



需要先用 javac 编译一下,然后再 javap,可以带上 -verbose 显示附加信息。



# > javap -verbose HelloWorld 2.7.0
Classfile geektime/JavaCamp/HelloWorld.class
Last modified Aug 30, 2020; size 425 bytes
SHA-256 checksum 32da89ad838f86f609140642ef39f806c89eee23e5e2dc79fe17030176915375
Compiled from "HelloWorld.java"
public class HelloWorld
minor version: 0
major version: 58
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // HelloWorld
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool: // 常量池
#1 = Methodref #2.#3 // 方法 java/lang/Object."<init>":()V
#2 = Class #4 // 类 java/lang/Object
#3 = NameAndType #5:#6 // 变量名的类型 "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // 字段java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // 字符串 Hello World
#14 = Utf8 Hello World
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // HelloWorld
#22 = Utf8 HelloWorld
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 HelloWorld.java
{
public HelloWorld(); // 默认构造函数
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1 // locals 局部变量个数;
// args_size 参数个数
0: aload_0
1: invokespecial #1 // 调用静态方法 Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "HelloWorld.java"



locals 表示方法内局部变量个数,该例中是 1,而 HelloWorld() 没有参数,为什么不是 0 ?



当线程调用一个方法的时候,jvm 会开辟一个帧出来,这个帧包括操作栈、局部变量列表、常量池的引用。对于非 static 方法,在调用的时候都会给方法默认加上一个当前对象(this)类型的参数,不需要在方法中定义,这个时候局部变量列表中 index 为 0 的位置保存的是 this,其他索引号按变量定义顺序累加;static 方法不依赖对象,所以不用传 this



同样,Args_size 表示参数个数,HelloWorld() 会传一个 this 进去,所以 value 是 1



如果想要验证这一点,可以写一个静态方法,然后在看一下字节码,比如:

public static void print(){
System.out.println("Hello World");
}

可以得到:

  public static void print();
    descriptor: ()V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #7   // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #13  // String Hello World
         5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8
}



之前学习 C# 的时候,也看过 C# 的中间代码(IL),有点类似。



参考链接:



  1. HelloWorld的javap -verbose HelloWorld 字节码初探



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 虚拟机分代回收



(图片和文字来自极客时间《深入拆解Java虚拟机》12 | 垃圾回收(下)郑雨迪)



JVM 虚拟机在垃圾回收方面还有两个技术,一个是 TLAB(Thread Local Allocation Buffer),用以解决线程共享的堆空间争用的问题,类似于为每个司机预先申请多个停车位;一个是卡表(Card Table),避免在 Minor GC 的时候扫描整个老年代(枚举 GC Roots 时,需要考虑从老年代到新生代的引用)。



后来还出现了横跨新生代和老年代的 G1(Garbage First),G1 直接将堆分成多个区域,打破之前的碓结构,采用标记-压缩算法,针对每个细分区域进行垃圾回收,优先回收死亡对象较多的区域。



Java 11 引入了宣称暂停时间更短的 ZGC。



发布于: 2020 年 08 月 31 日阅读数: 53
用户头像

escray

关注

Let's Go 2017.11.19 加入

大龄菜鸟项目经理

评论

发布
暂无评论
JVM 内存模型、字节码、垃圾回收面试要点