深入思考JAVA虚拟机

527 阅读15分钟

归纳Java基础不得不说说JVM虚拟机,这是使得Java语言跨平台的原因。JVM虚拟机屏蔽了各种操作系统的差异。JVM虚拟机的构成包括程序计数器PC、方法调用栈、native方法调用栈、堆区、方法区(类、方法和常量池) 、直接内存等基本组成。堆空间和方法区是所有线程公享的。Jvm虚拟机的内容可以认为是进程的虚拟地址空间了。

编译

Java代码被javac编译成类字节码。字节码也是挺有意思的,IDE可以直接将class文件反编译为Java源文件,虽然和源文件是有一点点出入,我们可以用Javap工具以可读的方式去读取字节码,-verbose就可以看到一条条的指令了(Prints stack size, number of locals and arguments for methods.后面聊到方法栈的时候,再回头来看这部分可能会更加深入。

另外注意一点:为了提高程序的执行性能,编译器和处理器都会对指令做重排序。所谓的重排序其实就是指执行的指令顺序。

这个编译也不是一般意义上的编译,一般的编译就是编译为机器码,机器码对应汇编语言。C语言和Cpp的编译可以说成是编译,那java执行的时候其实还是解释执行的。为什么要用解释执行这个词,Java是解释型语言,相当于JVM是解释器。

(这里说的是解释执行的过程,这些指令最终还是需要编译为机器码,普通的虚拟机应该就是解释执行)AOT 应该是就是说提前编译,那这个提前编译好的文件应该也还是class文件了,那AOT会藏在哪里了呢,是什么类型的文件了。JIT是提前编译部分热点的字节码。怎么判断热点代码,JVM也是采用了引用计数法和采样判断栈顶的代码,这里感觉和垃圾收集的想法是一致的。

class文件结构

class文件是一组以8进制为单位的二进制流

Java对象结构

对象头包括对象哈希吗、对象分代年龄、指向锁记录的指针、重量级锁的指针、偏向线程ID,偏向时间戳等等

实例数据

对齐填充

常量池

class文件里面的常量池,可以认为是常量和符号引用。

加载和方法区

程序运行的时候,class文件类通过类加载器Classloader(把字节码加载到常量池,并进行校验、准备、解析和初始化(这里的初始化是指给类变量也就是静态变量赋值,并且执行静态代码块))加载到常量池。

所以严格的生命周期是记载、校验、准备、解析和初始化。(是不是loadClass做了这么多的事情呢?)

所以方法区里面也有常量池了。

类加载的时机

某一个类加载的时机?JVM虚拟机并没有强制规定什么时候需要,但是对于这里的初始化的时机却是有强制规定的,总结为5点:

  1. 虚拟机启动时,用户需要指定一个要执行的主类(包含main方法。。),虚拟机会先初始化这个主类
  2. 当初始化一个类的时候,如果父类还没进行初始化,那一定先触发父类的初始化。(所以在虚拟机中,Object类一定是最先开始进行初始化的)
  3. 遇到new getstatic putstatic invokestatic这四条字节码指令,如果类还没进行过初始化,则触发其初始化。
  4. reflect 的包对类进行反射调用的时候,如果类还没有进行过初始化,则需要先进行初始化。
  5. 1.7对动态语言的支持,MethodHandle实例最后的解析结果REF_getStatic的方法句柄,如果还没有进行过初始化,则需要先触发其初始化。
  6. final字段有些特殊,在编译阶段(常量传播优化)就放入调用类的常量池了,所以直接引用类的静态常量字段,也不会导致类的初始化。

加载其实要做三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。用字节流就很方便从网络获取,字节码的二进制流还可以运行时计算生成,动态代理技术Proxy就是用了ProxyGenerator来为特定接口生成形式为$Proxy的二进制字节流)
  2. 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构(意思就是虚拟内存,还有一些特定的方法区数据结构)
  3. 在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据结构的访问入口。

加载

非数组类的加载阶段可以使用系统提供的引导类加载器完成,也可以使用用户自定义的类加载器去完成,开发者可以通过定义自己的类加载器去控制字节流的获取方式。

数组类是特殊的?数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的,虚拟机创建一个数组主要是考虑分配多大的内存。数组类的元素类型最终要类加载器去创建,那么如果数据类型的元素类是引用类型,数组将在加载该组件类型的类加载器的类名称空间被标志。非引用类型,就是和引导类加载器关联。

加载阶段完成之后,这些二进制字节流就存放在了虚拟机的方法区,存储格式由虚拟机实现。然后在内存中实例化一个Class类的对象(也可以放在方法区中,也可以放在堆中),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

验证

验证字节码是否符合规则,格式正确。

准备

为类变量分配内存并且初始化,也就是说静态变量,这些变量使用的内存都会在方法区中分配。

初始化是指虚拟机的初始值,而不是自己设置的初始值。

解析

将常量池内的符号引用替换为直接引用的过程。

常量池是.class文件中的资源仓库。常量池就是包含字面量和符号引用的。

类被加载器加载时就进行解析还是等到一个符号引用将要被使用前才去解析它由虚拟机自定义。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。对于前面4个,可以具体讲一下。其实也就是把一个从未解析过的符号引用解析为一个类或接口C的直接引用,也就是去加载这个类,成为一个直接引用了。解析过程也还都是在方法区的。

初始化

准备阶段,类变量已经初始化过系统的初值。那么初始化阶段,就根据代码逻辑去初始化变量和其他资源。初始化阶段是执行类构造器clinit的过程。

clinit是编译器自动收集类变量的赋值操作和静态语句块中的语句合并产生的。

但是clinit的执行逻辑确实有点让人费解,静态语句块中只能访问到定义在静态语句块之前的变量,定义在她之后的变量,在前面的静态语句块不能访问,但是可以赋值。

可以赋值为什么不可以访问呢?

因为这个变量的准备阶段已经有初始值了,所以还可以赋值。但是因为这个静态变量的定义和初始化赋值在这个静态语句块之后,所以这个最后的赋值还没有结束,那么这里读到的就不是最终的值,所以不能读取。如果读取了,可能会产生歧义,那就是在整个JVM生命周期还没初始化的变量先使用了,这是不符合规则的。

classloader

类加载阶段的“通过一个类的全限定名去获取这个类的二进制字节流”的操作放到虚拟机的外部去实现。这是让程序员自己去决定如何获取所需要的类,实现这个动作的代码模块称为类加载器。

类和类加载器是绑定的关系。

虚拟机推荐的使用是双亲继承。

但是也可以破坏双亲继承关系,有JNDI,这个就是rt.jar实现的,本来是通过引导类记载器来加载的,但是这个不认识第三方代码。 还有线程的contextClassLoader,还有OSGI,好像是一个模块化的热部署技术。 基础类如果想要调用用户类的代码,怎么办?这个是第二次大规模破坏,也就是JNDI的场景,

classloader采用了双亲委派机制,不仅仅是JVM内部的黑盒机制,还是开放给开发者使用的,我们可以定义自己的classloader做一些很有用的事情,比如热加载、热部署、加载加密的字节码等等。

springboot-devtools就是自己定义了这个加载的机制,定义了一个Restart的classloader。当我们在springboot的pom文件中依赖了springboot-devtools这个第三方库的时候。

当springboot程序启动的时候,如果使用了devtools,则会使用一个后台线程,一直观测字节码文件有没有变化,如果有变化,则发送event,那么listener则启动重新加载字节码的一段逻辑,使用自定义的Restart classloader来加载变化的字节码,生成新的类和对象(这个调试看来主要是main这个程序相关的),然后反射调用main来重启程序。 debug的时候存有很多疑问,其中的关键点其实在于每次listener处理并restart的时候都new RestartClassLoader了,所有的类都需要重新加载,在restart之前是先加载了几个类的。

springboot启动的时候会使用LaunchedURLClassLoader来作为主要的类加载器,但是使用IDE启动的时候也还是AppClassLoader。

方法区

JVM通过类加载器加载资源的时候,这些资源包括类,接口,枚举,注解。JVM必须在方法区中存储这些信息:

  • 这个类型的完整的有效名称
  • 这个类型的父类的完整有效的名称
  • 这个类型直接接口的有序列表
  • 这个类型的修饰符,比如public final abstract等

理解注解

注解算是比较难以理解的,是JDK 5引入。 注解的Retentation策略包括Source Compile Runtime等。

JVM加载字节码的时候还需要加载的注解只有Retentation策略是Runtime的。需要运行时的注解处理器,这个倒是很常见,比如放在切面里面去处理。

注解在类加载之后是一个类,里面的属性都是静态final字段。

Retentation策略是source类型的注解是为了给源码看的,如果没有也不会报错。但是标记一下可能会比较清楚,比如Override。也可以提供信息给编译器。

class策略的是加载的时候,可以利用这个来生成文档或者代码的。后面加载的时候不保留。

注解不是代码的一部分,对程序的运行逻辑没有直接影响。也就是不影响程序运行栈。

还有一些元注解: @Repeatable 用于表示可以重复

@Inherited 用于表示它的子类能继承父类的这个注解

比如怎么理解注解上面的注解? 其实就是类上面的注解。非元注解的注解同样作用于目标对象(方法、类)。

可以继承吗? 注解没有继承的概念。但是类可以继承父类的一些注解,如果那些注解加了@Inherited修饰,比如Slf4j不可以继承,但是SpringBootTest是可以被继承的。

原来Repository注解和Aspect注解一样,是有特殊情况的。

@Repository注解可以标记在任何的类上,用来表明该类是用来执行与数据库相关的操作(即dao对象),在注解了@Repository的类上如果数据库操作中抛出了异常,就能对其进行处理,转而抛出的是翻译后的spring专属数据库异常,方便我们对异常进行排查处理。

有个PersistenceExceptionTranslationAdvisor的注解,就是spring在初始化bean 3池的时候要做的事情,在这个阶段将Reposity的bean就给代理好了。

运行时Runtime

Java在解释字节码运行时,会编译为机器码,这个过程也可以做很多的优化,如果不优化会对性能有很大的影响。JVM 8默认采用的什么呢?我觉得JIT默认是启用的。Java的编译过程是要分为前端编译器和后端编译器的。

运行

Java程序通过main函数的入口开始运行,程序计数器存放下一条指令单元的地址,也就是main函数的第一条字节码。

虚拟机调用栈对应方法的一个个的frame,也就做栈帧。上面的frame执行完了之后,会将结果返回给下面的frame。

每一个栈帧也是有固定的结构的。 栈帧包括局部变量表,工作区等几个部分。 每一个线程,局部变量表也是要在方法运行的时候才动态生成的,和工作区的栈一起合作来工作。PC寄存器来驱动这个动态的过程,PC寄存器【也是jvm虚拟的PC寄存器】指向下一条运行的指令【其实是字节码】。

工作区用来存放当前的临时结果。临时的操作数,比如a+b ,首先a入栈,然后是b入栈,然后计算add指令,A,B出栈,再将结果入栈。

如果不是在静态方法里面,那么局部变量表的第一个slot,slot0指向的是this指针,对象引用那就是占32位, 存的是对象的地址(后面讲到了为什么32位也可以表示32G的堆空间)

  • 怎么知道64位虚拟机有没有带指针压缩呢? 直接使用GraphLayout来打印
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)

4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)

8 4 (object header) 4a ca 22 00 (01001010 11001010 00100010 00000000) (2280010)

12 4 (loss due to the next object alignment)

压缩指针默认开启(-XX:+UseCompressedOops),-则是关闭的意思。

32位的JVM和64位的JVM的寻址有什么区别?64位的可用内存可以大于4G,但32位就不行。但是因为64位压缩指针,则64位可以使用32位寻址,大大减小了对象的大小。

如果改变了局部变量表的引用对象的时候,这个是直接修改的堆吗?其实应该是这样的。但是因为CPU存在高速缓存,这个过程不一定是可靠的。如果使用synchronized或者其他的加锁的方式,那么这个一定会写回到内存。如果不是,则不是那么可靠,有并发读写的时候,需要判断是否需要使用volatile来帮助获得变量的可见性。

于是也引入Java的内存模型JMM了,这是硬件层面CPU的高速缓存和总线的一些协议。

一般说起volatile的时候,大家都会说可见性和防止指令重排。

frame存放局部变量表,操作数栈、动态链接(那就是指向运行时常量池的方法引用(自己是哪一个方法))和方法返回类型returnAddress(PC寄存器的地址),也是占用一个slot。参数在局部变量表里面,放在this之后。

局部变量表的大小是编译字节码的时候确定下来的,后面是不会再改变的。

描述一个方法引用了另外一个方法,就是通过引用常量池的符号链接来确定的【在解析过程的时候就会变成直接引用】。这里会涉及到一些invoke指令,还有虚方法的invoke 。