Fari

《深入理解java虚拟机》第三版笔记

- 第一款商用虚拟机ClassicVM使用的是基于句柄的对象查找方式。这样做的目的是当对象移动时不需要修改对象引用的位置。缺点是需要两次定位对象。

- GraalVM,是一个再HotSpot的基础上增强而来的跨语言全栈虚拟机,可以运行java、Scala、Kotlin等基于jvm的语言,还有C、C++、Rust等基于LLVM的语言,同时还支持JS、Ruby、Python等。GraalVM可以无额外开销地混用这些语言

- 自JDK10起,HotSpot加入了全新的Graal编译器,顾名思义,它源于GraalVM。它本身就是用java编写的,目的是替换C2编译器

- NIO引入基于Channel与Buffer的IO方式,直接分配堆外内存,避免java堆与Native堆来回复制数据

- 对象分配内存使用指针碰撞或者空闲列表的方式,具体使用哪个取决于垃圾回收器的实现。不管哪种方式都可能出现并发问题,jvm通过CAS和TLAB解决

- new对象过程:检查类有没有被加载,如果没有则先执行 加载链接初始化的过程 -> 堆内存中分配地址并赋0值 -> 设置对象头信息包括GC分代、锁状态等 -> 执行对象构造器方法

- 对象的内容包括 对象头、实例数据、对齐填充

- 对象头信息中包含markword、类型指针(非必须)、数组长度(数组类型才有)

- 实例数据中保存着对象中的各个字段内容,包括从父类继承下来的

- 不停添加栈帧是报栈溢出还是内存溢出异常?

当栈内存固定时报栈溢出(hotspot),如果是动态扩展的(classic),则是内存溢出

- 基于分区的垃圾回收器(G1、CMS等)可能出现待清理的分区中含有别的分区所引用的对象,为了避免整堆扫描,会给每个区分配一个 Remember Set 用于记录哪些对象被其他区引用,它也将加入GCroot中进行扫描。记忆集只是一个抽象接口,它的一个实现是卡表,卡表记录了分区之间是否存在对象依赖关系(只记录两个分区的依赖,并不记录分区内部具体哪个对象的依赖),卡表本质上只是一个标记数组

- 卡表是如何维护的?

如果其他区的对象引用了本区中的对象,则那个区的卡表就应该被修改,形成脏表。问题是如何实现这一过程,HotSpot是使用写屏障(和内存屏障不同)实现的,所谓写屏障,可以理解为是aop的过程,即在对象引用这个动作前后加上一段维护卡表的代码

- 进行GCroot扫描时,由于栈空间也很大,如果直接扫也很浪费时间,所以会将对象的引用专门存储到一个名为 OopMap 的数据结构中,扫描时只扫它就够了

- ParNew是Serial多线程版本,Parallel Scavenge与ParNew差不多,区别在于PS更关注吞吐量(用户线程时间/总时间),而CMS、ParNew等关注缩短单次STW的时间,两者是不一样的,缩短单次的时间意味着收集的次数变多,而每次收集都会有额外开销,所以反而使得吞吐量降低了。

- G1垃圾收集器中,为每一个region都设计了两个名为TAMS的指针,在并发标记过程中,如果有新的对象需要分配内存,就会分配到这两个指针内,这两个指针内区域的对象默认都是存活的,不会被垃圾回收

- 三色标记法如何解决并发标记过程中的动态引用变化?

CMS使用增量更新算法实现:已经标记为黑色(所有的引用它的对象都已经扫描过)对象再引用其他对象时就会被修改为灰色

G1使用原始快照算法实现:有新的引用关系建立时,就记录下这个关系,后续重新扫描这些记录

- Shenandoah是一款只有OpenJDK才有的垃圾收集器,它能实现再任何大小的堆内存中都将STW时间控制在十毫秒内,对比于G1和CMS,它不仅能进行并发标记,还能进行并发内存整理,并且其没有年轻代和老年代的区别,并将G1的每个region维护的记忆集修改为一个“连接矩阵”,便于统一管理维护。其他基本和G1差不多

- Shenandoah如何实现并发整理?

在原本的对象头前面增加一个指向该对象的指针,正常情况下,该指针指向本身这个对象,但在并发整理时,会将该对象复制到其他地方,该指针就会指向新的那个对象地址。缺点是每次对象访问都是两次定位,类似于句柄访问对象的方式。Shenandoah使用CAS的方式保证这一过程的安全

- ZGC的目标和Shenandoah相似,都是要实现不影响吞吐量的情况下,将任意堆的收集时间控制在10ms。它也是基于Region并且不设分代。区别在于ZGC的region可以动态变化,并且ZGC的并发整理要优秀很多(见下)

- ZGC的并发整理的原理?

实际上,GC过程中,很多步骤不需要知道对象的具体内容,而只需要知道对象的状态,例如在三色标记过程中,只需要知道对象之间的依赖关系,而不需要知道对象的内容。传统的做法是将一些与对象本身无关的信息记录在对象头,例如GC分代年龄、锁信息等,但如此一来,就是你只需要对象头中的某些信息,你也必须找到该对象的具体位置(因为对象头也是对象的一部分),这样会造成多余的消耗。而ZGC就是直接将一部分信息存放到对象的指针上,这样只要知道对象的指针就够了,而不用真正去访问对象的内容。这种方法称之为“染色指针”

为什么可以这样做?

因为实际上,在64位系统中,一个指针的范围非常大,而实际上现在的cpu架构也只会用到部分长度(AMD64只支持到52位长度),所以很大一分部都浪费掉了,ZGC就是利用指针的这部分空间存储相关数据

- 向 HashMap<Long, Long> 添加一个kv会额外占多大内存?

long类型占8字节,而包装称Long类型,则需要添加8字节的Markword,8字节的Klass指针,需要24字节,两个Long则需要48字节,然后这两个Long组成一个Map.Entry对象后,又需要16字节的对象头(markword和klass指针),并且Entry需要一个8字节的next字段和一个4字节的hash值(另外需要4字节填充),这一共又是32字节,然后HashMap需要有一个指向该Entry的8字节指针,所以一共占了 48 + 32 + 8 = 88 字节

- Class文件是一组以8个字节位基础单位的二进制流,各个数据项紧凑地排列在一起,没有任何分隔符。

- class文件中的数据只有两种数据结构:无符号数和表,无符号数属于基本数据类型,分为u1、u2、u4、u8分别表示1字节、2字节等长度的无符号数,可以用于表示数字、索引、或者UTF8编码的字符串。表是由多个无符号数或其他表组成的复合数据结构,所有表都以“__info” 为结尾命名。整个Class文件本质上就是一个表

- 在java语言中,方法的特征签名不包含返回值,所以不能仅依靠返回值的不同作为方法的重载条件。但在JVM规范中,方法的返回值也是方法特征签名的一部分。注意:JVM规范和java语言规范是两种东西,前者还可以运行许多非java代码

- bool、byte、short、char 会转换为int进行操作

- invokevirtual用于调用对象的实例方法,invokespecial用于调用不能被重写的方法,例如初始化方法、私有方法

- 加载-验证-准备-解析-初始化,是类加载的基本过程,但有时解析会出现在初始化后(晚期绑定)。且这些过程可能会出现并行

- 加载(指的是上面的那个第一步)类的时机有:使用new关键字时、调用类的静态字段(final修饰的除外)或静态方法时、反射调用类时、使用子类需先初始化父类、如果某接口实现了默认方法(jdk8中的default修饰的接口方法),则使用其实现类时要先加载该接口、main类、jdk的动态语言支持

- 数组的引用不会触发类加载

- 类中的 final static 属性会直接放到常量池中,调用这种常量时不会触发类的加载过程,所以这种常量本质上已经和它所在的类无关了

- 接口也会有同样的类加载过程,只不过它不能使用 static {} 代码块,但jvm仍然会为它生产 () 类构造器,用于初始化接口中定义的成员变量

- 加载阶段指找到class的二进制流并在方法区生产Class对象,但数组的加载有点特殊,数组本身不需要类加载器加载,而是直接由JVM在内存中构建出一个数据结构。但是数组仍然是有类加载器的,只不过它的类加载器是由内部元素决定的:如果是基本类型(例如 int[ ] ),则该数组的类加载器为启动类加载器,如果是引用类型,则是该引用类型的类加载器。

- 验证阶段会验证该class是否符合jvm规范,例如class文件的魔数、class中是否有不支持的数据类型,也包括类是否有父类(除了Object应该都有父类)、是否继承了final类、是否实现了抽象类的所有方法、赋值类型是否匹配等(反正编译会出现的问题这里都会检查,这些东西本来应该都编译不过,为什么还需要验证呢?因为class文件不仅可以由java编写)

- 准备阶段是给类变量赋零值(final的直接赋值),赋值语句和静态代码块都还没执行(<clinit()>方法是在类初始化阶段才执行)

- 解析阶段并不是一个固定的阶段,因为它实质上就是将符号引用转换为直接引用,如果我使用反射加载一个类时,这个过程也是会发生解析的(class类名到指向方法区的一个地址)

- 解析阶段当然还是有的,只不过是部分解析,例如对于私有方法、静态方法、构造方法、final方法的符号引用就能在这一阶段完成解析。

- 一个类中有一个字段,它的父类和接口中都存在一个同名的字段,会发生什么?

允许同时在类与其父类或接口中同时存在相同名称的字段,以变量类型为准。例如父类 F 中存在一个变量 a = 1,子类 S 中存在变量 a = 2,则创建对象 F s = new S(),调用 s.a = 1,而 S s = new S(),调用 s.a = 2。

但是不允许多个接口或者父类中同时存在同名字段,编译会报错(但如果不用该字段就没问题)。但从解析阶段来看,这也是允许的,jvm会先从上往下解析接口中的字段,然后才从下往上解析父类中的字段。

但是,如果像题目所述三者都存在,则编译也没问题,以变量类型中的变量位置。

注:上面说的变量类型又称为静态类型,变量的实际类型称为实际类型,静态类型是编译期就能确定的类型,而实际类型则只有运行期才能知道。例如上述中,如果父类F有两个子类S1和S2,则我可以定义一个F类型的变量,而实际变量可以随机生成S1或S2。

注:如果是调用实例属性,以静态类型为准,但是,如果是调用重写的方法,则以实际类型为准。这里涉及到动态分派。所谓动态分派就是说,执行 invokevirtual 指令时,它会先找到栈顶元素的实际类型,然后从实际类型开始依次遍历其父类进行方法的搜索,所以对于虚方法是在程序运行中才确定的。这是重写的本质,也是多态的一种实现。(对于一个子类的实例,即使是父类中调用的方法,还是会优先调用子类的重写方法,如果要调用父类的方法,可以使用super关键字)

当然,动态分派查找过程比较复杂,且调用频率很高,所以jvm会在方法区缓存一个虚方法表,直接记录各个方法对应的实际类型,避免反复查找

java中只有方法是可以为虚的,字段永远不能为虚,所以字段不会出现多态

- 重载和重写的区别?

重载发生在单个类内部,而重写发生在类的继承上。更深层次来说,重载方法的调用是一个静态的过程,即编译期就能确定下来(静态分派),而重写的方法则只有在程序运行期间才能确定(动态分派)。

- java和动态类型支持

所谓动态类型语言,就是指变量的静态类型也在运行期间才确定下来,这就会直接导致当你调用某个方法时,如果是静态类型语言,则可以直接在编译期确定,如果调用了不存在的方法也能在编译期提示,而动态类型语言则只有在程序运行后才知道变量类型,所以即使调用一个不存在的方法编译期也不会报错。

事实上,如果单从这个角度来看,java本身就支持了动态类型,因为我完全可以通过反射实现上述的点。这也没错,但是要知道,反射也只是java中才有的,而jvm可并不只打算让java用,所以,为了能让其他动态类型语言也能用上jvm,必须在jvm层面实现这种机制。所以在jdk7中新增了 invokedynamic 指令用于提供更底层的动态语言支持。

- jdk9之前,接口中的方法都是public的,但jdk9中新增了静态私有方法,定义为 private static void method() {// 必须有方法体}

- 对于jdk9中的模块,可以简单理解为包的另一种形式,它和包差不多是并列关系,你可以将你的项目打包成jar包,让别的项目导入,也能打包成模块让别的项目导入。

- 局部变量表复用引发内存泄漏

局部变量表以slot存储数据或引用,且这些slot是可以复用的,例如某个函数体内有一个 {…} 定义的小作用域,一般情况下,我们认为如果代码执行离开了该作用域,其内部定义的变量就会被回收,但实际上并不会被立即回收(即使立即调用System.gc()),这是因为其内部定义的变量会使用局部变量表中的slot,当离开其作用域时,它的slot会被设置为可复用状态,但此时其内容并没有被清空。所以gc时gcRoot仍然能扫描到其引用的对象。解决方法是变量使用完后手动将其置为null,或者在离开其作用域后重新定义新的变量,这些新的变量就会占用这些slot

- 什么是 OSGi?

它是一个java动态模块的规范(jdk9引入的是静态模块,动态模块简单理解为热插拔)。它有很多实现,例如 Equinox、Felix等。eclipse IDE就使用了该技术,使得其安装、卸载、更新插件后不需要重新启动。

OSGi中的模块称为Bundle,它和java的类库差别不大,两者都是使用Jar格式进行封装,并且内部存储的都是java的package和class。区别在于,一个bundle可以声明它所依赖的package,也能声明它能导出的package。一个模块中只有被导出的package才能被外界访问。当然,这些特性在静态模块中也是一样的,只不过OSGi会朝着动态方向发展

OSGi可以实现热插拔的主要原因是它设计的类加载器实现的,它为每一个bundle都设置一个类加载器,当一个bundle依赖一个package时,就会调用发布该package的bundle类加载器进行加载。而bundle没有发布的package则由自己加载。如此一来,当替换一个bundle时,它所发布的package就会重新加载,别的bundle用的package也是新的

简单说不就是bundle自己的package都由自己加载吗?但实际上,可以想象,很多个bundle之间各种package依赖就会导致类加载器的调用形成一个图的结构,这其实就是对双亲委派机制的一个破坏,而且容易死锁。解决死锁的方法是,不再对整个类加载器加锁,而是只对要加载的class进行加锁

- 类中不仅可以写静态代码块,还能有非静态代码块。静态代码块会被写进 <clinit()> 方法中,而非静态代码块则会被写入所有的构造方法中,且位于方法最上面

- 由于java中的泛型本身只是语法糖,它只存在于源代码中,编译成字节码文件后会被替换为原来的类型,并且相应的地方使用强制类型转换,所以,其实 ArrayList 与 ArrayList 本质上是同一个类型

- 分层编译的意思是:根据运行jvm的参数不同,jvm会选择配合使用 解释器、jit编译器(c1/c2/graal)。

- jit编译器会将多次执行循环体所在的整个方法进行编译,如此一来,这个方法就有两套可执行的方式:class字节码指令和编译后的机器码指令。当创建栈帧时,栈帧中的代码其实仍然是字节码指令,但方法执行的入口其实指向了机器码的地址,这个过程称为 栈上替换(不同于逃逸分析中的栈上分配对象)

- Graal编译器

Graal编译器的目标是替换c2编译器,它使用java进行开发,不同于C1、C2编译器耦合在hotspot中,Graal编译器使用了HotSpot提供的编译器接口 JVM CI,使得其可以单独编译并在hotspot中使用。

Tags: