众所周知,jvm和的一个最大的特点是可移植性。无论在何种系统只只要有jre的开发环境便能启动jvm,实现程序的运行。我们先来看下从一行代码如何变成可运行的java程序的:
public class HelloWorld {
public static void main(String[] args){
System.out.println("hello java world!");
}
}
接着我们使用javac命令将文件编译成字节码
javac HelloWorld.java
这个过程具体经历了什么
这几部分主要做了以下的几项工作:
- 词法分析: 将源代码中的文字解析成一个一个的词如int a,b。会解析出int,a,b等Token单元
- 语法分析: 将代码中的token,通过二叉树生成抽象语法树。包、类型、接口,变量信息,返回值等都是语法结构
- 语义分析:语法树只能保证代码结构上正确,但并不能保证逻辑上正确。这里检查语法的上下文关联,同时做类型检查。如泛形,自动拆箱装箱等
- 注解过程:jdk1.5加入了对注解的支持
- 字节码生成: 生成16进制的字节码文件,主要包含两个部分。一个是常量池和方法字节码。常量池存放token中的常量数据,类名,成员变量等。方法代码中存放方法
生成完字节码之后,我们就可以运行.class文件了
java HelloWorld.class
在来看下这里发生了什么?
从磁盘中的字节码加载到内存中主要有5大过程:加载,连接,初始化,使用,卸载
这几个过程分别做了什么?
- 加载: 这个过程主要是jvm从.class文件中将字节流存储到内存中,然后将数据存储到运行时数据区的方法区,同时生成与之对应的java.lang.Class对象做为数据访问的入口
- 连接:
连接分为三个阶段:
(1) 验证: 对内存中的字节码流做验证是否符合java格式规范,校验语义是否符合java要求、校验方法在运行时是否会危害虚拟机、符号引用进行匹配性校验
(2)准备:主要是为类变量进行内存分配,并初始化。主要是像8中基本数据类型。像int 赋值为0,对象引用赋值为null等
(3)解析:将符号引用替换为直接引用。即内存中实际的地址。符号引用即字符串如helloWorld(),helloWorld就是符号引用实际内存中的地址就是直接引用 - 初始化:为标记为常量池的字段赋值的过程,就是对static标记的变量或语句块进行初始化。通常以下几种情况回进行初始化:
(1)创建类的实例,也就是new一个对象
(2)访问某个类或接口的静态变量,或者对该静态变量赋值
(3)调用类的静态方法
(4)反射(Class.forName(“com.lyj.load”))
(5)初始化一个类的子类(会首先初始化子类的父类)
JVM启动时标明的启动类,即文件名和类名相同的那个类 - 使用:实例化类进行使用
- 卸载: 类的回收
这里在另外看两个问题:
- 是.class文件里面到底是些什么内容?
执行javap -v HelloWorld.class 文件后出来的内容如下:
Classfile /Users/heroschool/workspace/HelloWorld.class
Last modified 2020-5-17; size 431 bytes
MD5 checksum 7de693cba67d5a489a864cd3b53ef8f4
Compiled from "HelloWorld.java"
public class HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER // 这里表示类是public
// 下面是常量池的内容
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // hello java world!
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // HelloWorld
#6 = Class #22 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 HelloWorld.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 hello java world!
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 HelloWorld
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
{
// 下面是方法的内容
public HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
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: ACC_PUBLIC, ACC_STATIC // 方法是public修饰的static方法
Code:
stack=2, locals=1, args_size=1 // 表示栈的深度为2
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello java world! 常量值
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
- 类加载时涉及的类加载器
前面知道了类从字节码加载到内存的过程,而类加载器就是第一步的加载过程生成类关联的Class文件所用到的,只要是可以获取到.class文件的方式都可以加载到类,包含最常见的jar包。也可从网络中获取。
基础的类加载器是遵循双亲委派机制的,底层的加载器加载数据时会先判断上一层是否已加载了相关的类。
jvm根据ClassName + PackageName + ClassLoader去判断两个类是否已经相同,所以不同类加载器加载的类jvm认为是两个类。
为什么需要用户自定义加载类:
因为类默认从jar包或文件中加载,所以如果有其他的类加载需求如从网络或数据库中加载,则需要自己编写类加载器。
看下classLoader中的loadClass方法:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查是类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 如果存在父加载器则从父加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 否在使用bootstrapclass加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name); // 从这里写自己的加载方法
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
了解到这里,从一个java文件到类的过程大致也就了解了。现在我们来看下从字节码加载内容到内存时,是怎么个存储方式,也就是jvm的内存结构是什么样子的?
JVM的内存结构
JVM在执行程序的时的内存空间叫做运行时数据空间,jvm会在逻辑上把它划分为几块内容:
其中方法区和堆是所有线程共享的区域,一般对于jvm的优化主要也是在堆和方法区中。
- 程序计数器: 是内存空间一块小的区域,可以理解为用来指向当前运行字节码中的行号指示,指向下一条要执行的代码指令。就是说字节码解释器会修改程序计数器的值用来标识下一条要运行的指令, 记录的指令由执行引擎读取。每一个线程都有一个计数器,用来在上下文切换的时候恢复到当前线程状态。
- 虚拟机栈和本地方法栈
这两个栈的主要区别是虚拟机栈运行的是java方法中的数据,本地方法栈运行的是由native标识的本地方法,这里主要介绍虚拟机栈:
虚拟机栈里面存储的是什么东西呢?
栈的内存结构大致是这样子的。当一个方法开始时就往栈中压如一个栈针。
(1)局部变量表:
局部变量表,用来存储当前放大中的基本数据类型如int,float等和局部变量,和变量的引用,但不存储引用的对象。局部变量表的大小在jvm编译期间已经固定了,不会在改变。
局部变量表的最小单位为槽(slot),一个slot可存储32位的数据,如果要存储64位数据就需要两个连续的槽空间。局部变量表的索引从0开始,非static方法和static方法索引的区别在于,非static方法索引0指向的是自身对象的引用,static方法则直接指向数据。
局部变量表中有slot复用的概念,如果slot超出了使用它的域,则slot会被回收服用。这时候如果有其他值使用到slot,则原始的对象才会被回收
public void test(boolean flag){
if(flag){
byte[] bytes = new byte[1024];
}
System.gc()
}
这段代码中,bytes作用域只在if语句块中,按照理解超出语句块后slot会被回收,但可以发现gc时并没有回收内存空间,这是由于代码中还包含着bytes的引用,造成gc无法回收,这里存在风险。
if(flag){
byte[] bytes = new byte[1024];
}
int a =1;
System.gc()
如果此时加入a变量,复用slot则bytes空间会被回收
(2) 操作数栈
操作数栈是一个先进后出表,当方法中需要算数运算的时用到操作数栈,或者给其他方法传递参数的时候。操作数栈可以理解为存放临时数据的地方
(3)动态链接
动态链接的理解上有些复杂,我的理解是。首先每一个栈帧都会持有常量池中当前该栈帧所属方法的引用。主要是为了在运行期间进行符号引用的动态链接。因为Class文件在加载后保存的都是符号引用,在连接期间会初始化一部分的符号引用,但java中存在多集成的关系,当使用方法重写时,调用具体方法时会去校验当前方法与常量池中的描述是否像符合,如果不符合则至底向上的去查找关系。都没找到抛出异常。
讲的更直白一点就是,当在一个方法中调用另一个方法的时候,需要知道该方法具体的地址。但对于Class中只记录了符号引用,所以需要在运行期间把这个符号引用解析成直接引用,这种解析和链接的过程就叫做动态引用
(4)返回地址
记录调用该方法pc寄存器的值,如果正常则指向的是下一条指令。如果异常则要通过异常表去确定。
-
堆
java的堆是一块共享的内存空间,所有使用new创建的对象或者数组都在这块空间中存储。堆中的内存结构如下:
新生成的对象在eden区中,如果eden满时会触发一次MinorGc将存活的数据放入eden和fromspace中去,等下次gc时又将eden和fromspace中数据方到toSpace中,新生代的gc算法采用复制拷贝,这样经过几次gc后依然存活的对象则拷贝到oldGen中去,如果老年代满了则引发FullGc老年代主要使用标记算法。
java8后出现了直接内存的用法,直接内存不属于jvm,是计算机的内存可以使用DirectByteBuffer进行操作,也会有内存溢出的情况。 -
方法区
jdk8以前,方法区其实是一种jvm的规范,永久带是它具体的实现。方法区中会存放常量池,符号引用,类信息、方法信息,静态变量等。
jdk8后使用元数据存储这些信息,并使用直接内存,不使用jvm内存。
既然看到堆中内存的处理,我们就来顺便了解以下jvm的gc算法。
首先介绍gc是如何知道对象需要被回收的:
- 引用计数法: 每个具体创建的对象都有一个引用计数器,如果被其他对象引用则这个计数器+1。如果不被引用则计数器-1,当计数器为0时表示该对象可以被回收,但该方法无法解决互相引用的情况,jdk1.1之后就不使用了。
- 可达性分析算法
通过一些列称为GCRoot的对象作为起点向下搜索有没有任何的引用链相连,当一个对象到gcroot没有任何引用时,即该对象不可达。会被gc回收
可作为gcroot的对象有:
(1)虚拟机栈帧的本地变量表中引用的对象
(2)方法区中静态属性引用的变量
(3)方法区中常量引用的变量
(4)本地方法栈中(native)方法引用的对象
知道了哪些对象需要回收之后,gc便可以开始垃圾回收了:
先来看下有几种回收算法:
(1)标记清理算法,先通过可达性算法标记那些可达的对象,再通过清理清理掉不可达对象,但问题是这样会造成内存碎片
(2)标记整理算法,也先标记可达对象,然后将可达对象往一端空闲的空间移动,然后清理掉边界一端的对象
(3)复制,将内存空间分为同等的两部分,回收时将存活的对象复制到另一份空间完成后对整个半区进行内存回收。
这三种方法都存在优缺点,因此在jvm中使用到了分代的概念:
在新生代中分成三份空间,直接使用复制算法。而针对老年代则使用标记整理算法或标记清理