JVM基础篇

141 阅读16分钟

JVM介绍

机器码是电脑CPU直接读取运行的机器指令,运行速度最快,但是非常晦涩难懂,也比较难编写, 一般从业人员接触不到。

字节码是一种中间状态(中间码)的二进制代码(文件)。需要直译器转译后才能成为机器码。

OracleJDK和OpenJDK

查看JDK的版本

java -version

(1) 如果是SUN/OracleJDK, 显示信息为:

[root@localhost ~]# java -version 
java version "1.8.0_162" 
Java(TM) SE Runtime Environment (build 1.8.0_162-b12) 
Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, mixed mode)

Java HotSpot(TM) 64-Bit Server VM 表明, 此JDK的JVM是Oracle的64位HotSpot虚拟机, 运行在Server模式下(虚拟机有Server和Client两种运行模式).

Java(TM) SE Runtime Environment (build 1.8.0_162-b12) 是Java运行时环境(即JRE)的版本信息.

(2) 如果OpenJDK, 显示信息为:

[root@localhost ~]# java -version 
openjdk version "1.8.0_144" 
OpenJDK Runtime Environment (build 1.8.0_144-b01) 
OpenJDK 64-Bit Server VM (build 25.144-b01, mixed mode)

OpenJDK 的来历

Java由SUN公司(Sun Microsystems, 发起于美国斯坦福大学, SUN是Stanford University Network的缩写)发明, 2006年SUN公司将Java开源, 此时的JDK即为OpenJDK.

也就是说, OpenJDK是Java SE的开源实现, 它由SUN和Java社区提供支持, 2009年Oracle收购了 Sun公司, 自此Java的维护方之一的SUN也变成了Oracle .

大多数JDK都是在OpenJDK的基础上编写实现的, 比如IBM J9, Azul Zulu, Azul Zing和Oracle JDK. 几乎现有的所有JDK都派生自OpenJDK, 它们之间不同的是许可证:

Oracle JDK的来历

Oracle JDK之前被称为SUN JDK, 这是在2009年Oracle收购SUN公司之前, 收购后被命名为 Oracle JDK。

实际上, Oracle JDK是基于OpenJDK源代码构建的, 因此Oracle JDK和OpenJDK之间没有重大的技 术差异。

Oracle JDK与OpenJDK的区别

OpenJDK使用的是开源免费的FreeType, 可以按照GPL v2许可证使用.GPL V2允许在商业上使 用;

Oracle JDK则采用JRL(Java Research License,Java研究授权协议) 放出.JRL只允许个人研 究使用,要获得Oracle JDK的商业许可证, 需要联系Oracle的销售人员进行购买。

JVM和Hotspot的关系

JVM是《JVM虚拟机规范》中提出来的规范

Hotspot是使用JVM规范的商用产品,除此之外还有Oracle JRockit、IBM的J9也是JVM产品

JVM的运行模式

JVM有两种运行模式:Server模式与Client模式

两种模式的区别在于:

  • Client模式启动速度较快,Server模式启动较慢;
  • 但是启动进入稳定期长期运行之后Server模式的程序运行速度比Client要快很多。
  • 因为Server模式启动的JVM采用的是重量级的虚拟机,对程序采用了更多的优化;而Client模式启 动的JVM采用的是轻量级的虚拟机。所以Server启动慢,但稳定后速度比Client远远要快。

JIT(即时编译器)

在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的,当 虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代 码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,保存到方法区中,并进行各种层次的 优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文统称JIT编译器)。

哪些是热点代码

1、方法被频繁调用N次

2、循环体被循环N次

N 是 1500(client) / 10000(server)

JIT编译是以整个方法为单位进行编译的

怎么记录N的个数那?

有方法调用计数器:在方法对象中储存调用次数

回边计数器:每一次循环回去,都记录一次

方法调用必须是频繁的,如果调用时间间隔过长,调用次数将缩小一半

JVM加载运行过程

.class文件的描述符

  • 类型描述 除了Long 用 J Boolean 用 Z 其他都是首字母大写
  • 方法描述 除了 类的构造器用 、静态初始化方法用 其他都是和源文件方法名相同

类加载加载时机

  • new、读取或设置静态字段(除了在编译时期已经把常量放入常量池除外)、调用静态方法
  • 使用反射
  • 初始化一个类的时候,先初始化父类
  • 虚拟机开启时,需要指定主类,主类会先初始化

类加载过程

1、加载

加载就是将.class文件通过类加载器转成Class对象,加载到内存中(方法区中)

特例:数组是有Java虚拟机来转化的,数组元素还是通过类加载器

2、链接

  • 验证:格式、语义、字节码、符号引用验证等

  • 准备:为静态成员变量分配内存并初始化0值,即使public static int x = 1000; 在准备阶段也是初始化0值。但这里不包括在类编译期放入常量池的静态成员变量

  • 解析:将符号引用替换成直接引用

3、初始化

调用方法,初始化静态成员以及静态代码块

方法是编译器⾃动收集类中所有类静态变量的赋值动作和静态语句块中的语句合并产⽣的,编译器收集 的顺序是由语句在源⽂件中出现的顺序所决定的

public class Test {
	static { 
    i=0; 
    System.out.println(i);//编译失败:"⾮法向前引⽤" 
	} 
	static int i = 1;
}

准备阶段是赋0值,初始化阶段才是赋真正的值

在多线程的情况下可能会导致数据不正确结果,虚拟机为了保证正确性,所以加锁,即类的初始化是线程安全的

在多线程的情况下可能会导致数据不正确结果,虚拟机为了保证正确性,所以加锁,即类的初始化是线程安全的

准备过程是为静态成员变量初始化0值,而初始化则是赋真正的值

运行时数据区

JVM启动起来也是一个进程,进程申请了一部分内存空间,运行时数据区都是在这个内存空间之内的,

JDK1.8之后,方法区(元空间)在进程申请的空间之外,为了大概率的防止OOM

JVM运行数据区的分类

JVM运行数据区按照线程使用情况和职责分成两大类

  • 线程独享

    本地方法栈、程序计数器、虚拟机栈

    程序执行

    不需要垃圾回收

  • 线程共享

    堆(heap)、方法区(method Area)

    负责储存数据

    垃圾回收、储存类的静态数据和对象数据

方法区

1、方法区储存的内容

  • 类型信息(Class)
  • 方法信息(method)
  • 字段信息(Field)
  • 静态变量和运行时常量池 JDK1.7之后放在堆中
  • JIT编译后的代码缓存

2、方法区、永久代和元空间的区别

方法区是抽象出来的,而永久代和元空间是具体的实现

永久代和元空间的区别

  • JDK1.8之前方法区用的是永久代实现的,JDK1.8及以后使用的是元空间
  • 永久代使用的内存空间是JVM进程的内存空间,它的大小受整个JVM大小限制。元空间使用的内存是物理内存,大小受物理内存限制
  • 元空间只储存类的元信息,而静态变量饿运行时常量池都挪到堆中了

为什么用元空间替换永久代

  • Oracle收购了Java,收购之前sun公司用的是Hotspot,方法区是永久代,而Oracle有自己的JVM实现,是JRockit,它的方法区实现是元空间。收购之后打算合二为一
  • 字符串常量池在永久代中,容易出现性能问题和永久代内存溢出
  • 类及方法的信息比较难确定其大小,因此对永久代的大小指定比较困难,太小容易出现内存溢出,太大则容易导致老年代溢出
  • 永久代会为GC带来不必要的复杂度,并且回收效率低

3、永久代和元空间存储位置和存储内容的区别

  • 存储位置:永久代是占JVM进程的内存空间,元空间是占物理内存空间
  • 元空间存储类的元信息,静态变量和运行时常量池放在堆(heap)里,相当于将永久代的数据分到元空间和堆中

堆(heap)

1、堆中存什么

  • JDK 1.6 对象和数组
  • JDK 1.7 对象、数组、静态变量、字符串常量池

2、堆内存分配

  • 初始化大小和最大大小,最好一样,不用每次给分内存,-Xmx(初始大小) -Xms(最大大小)配置
  • 新生代和老年代一般是1:2比例。新生代分为Eden和from、to区,比例为8:1:1。
  • form和to是相互转换的,称为,某一刻只有一个在使用,另一个留着做垃圾收集复制对象用,在Eden区变满时,GC会将存活的对象移到空闲的Survivor区中,经过几次垃圾回收后,仍然存活的survivor区移到到老年区中。

3、堆中对象内存分配

  • 分配原则

新对象优先分配到新生代(如果Eden区空间不足,虚拟机会进行一次minorGc),基本上90%的对象都是朝生夕死

什么样的对象会被分到老年代?

1、gc发生15次,依旧存活的对象

2、大对象(一般指超过Eden区一半大小的对象)

3、年轻代已经没有空间存放了,发生了内存担保

  • 对象分配内存方式

年轻代用的指针碰撞,内存地址是连续的

老年代用的是空闲列表,内存地址是不连续的

  • 对象内存分配安全问题

在JVM中为每个线程用CAS机制就是乐观锁方式占用内存区。

用TLAB(本地线程分配缓冲):为每个本地线程分配一块内存

  • 对象的内部布局

对象头(类型指针、运行时信息)

实例数据

对齐填充

  • 对象内存担保

用串行垃圾收集器内存担保,即客户端模式下的serial+serial old模式

1、当Eden区存储不下新的对象时,会触发minorGC

2、GC之后,还存活的对象,需要存入survivor区中

3、当无法存入survivor区时,会触发担保机制

4、将发生客户端内存担保时,需要将Eden区GC之后还活着的对象放入老年代。后面新的对象放入Eden区

用并行垃圾收集器担保机制,即服务端模式下的parallel scavenge + serial old

1、在parallel scavenge + serial old模式下,先判断新的对象内存大小是不是 大于等于 Eden区大小的一半,如果是直接将该对象(也是大对象)放入老年代,否则启用内存担保机制

程序计数器

  • 程序计数器用于记录下一个要执行的 字节码指令 行号,如果是Native方法,计数器的值为空
  • 此内存区是唯一一个在虚拟机中没有规定任何OOM异常的情况区域

每个线程为什么有一个程序计数器

因为线程的调用需要竞争CPU时间片,避免出现继续执行之前的代码的情况,这个时候需要记录上次执行的位置

Java虚拟机栈(stack)

一个虚拟机栈内包含多个栈帧(调用几个方法),每个栈帧都包含局部变量表、操作数栈、动态链接、方法出口。

栈内存是线程私有空间,每个线程都会创建私有的栈内存

栈内存设置过大,创建的线程数过多时,可能导致栈内存溢出StackOverflowError

如果过小,可能会导致方法调用的深度较小,如递归调用次数

栈帧

栈帧存储了⽅法的局部变量表、操作数栈、动态连接和⽅法返回地址等信息。每⼀个⽅法从调⽤⾄ 执⾏完成的过程,都对应着⼀个栈帧在虚拟机栈⾥从⼊栈到出栈的过程。

什么时候创建栈帧

调⽤新的⽅法时,新的栈帧也会随之创建。并且随着程序控制权转移到新⽅法,新的栈帧成为了当前栈 帧。⽅法返回之际,原栈帧会返回⽅法的执⾏结果给之前的栈帧(返回给⽅法调⽤者),随后虚拟机将会丢 弃此栈帧。

局部变量表

局部变量表存储方法参数和方法内部定义的局部变量

如果是非静态的,那么this引用会放在第一个位置

其次顺序为方法参数、方法内声明的变量

操作数栈

操作数栈是用于每个方法内,每步的计算过程,也就是说,方法内部的变量放在局部变量表中,而这些变量的运算,放在操作数栈

结论

结论:一个线程的执行中,需要进行两个栈的入栈出栈操作,一个是JVM栈,一个是操作数栈(参与计算的值进行入栈出栈)

动态链接

动态链接就是在执行时候用来替换符号引用的,而在类加载阶段已经替换一部分符号引用。

那么问什么没有全部替换?

因为在类加载时候解析不出,具体调用的方法,比如用多态,不知道具体是哪个类的方法

方法返回

当⼀个⽅法开始执⾏时,可能有两种⽅式退出该⽅法:

  • 正常完成出⼝
  • 异常完成出⼝

⽅法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执⾏的操作有:恢复上层⽅法的局部 变量表和操作数栈,把返回值(如果有的话)压⼊调⽤者的操作数栈中,调整PC计数器的值以指向⽅ 法调⽤指令后的下⼀条指令。

⼀般来说,⽅法正常退出时,调⽤者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。⽽⽅法 异常退出时,返回地址是通过异常处理器表确定的,栈帧中⼀般不会保存此部分信息。

本地方法栈

本地⽅法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执⾏Java服务(字节码服务),⽽本地⽅法栈 为虚拟机使⽤到的Native⽅法(⽐如C++⽅法)服务。

常量池

字符串常量池

1、存储的内容

字符串常量池逻辑上是运行时常量池的一部分,主要存储用双引号引起来的字符串的值

2、字符串常量池存储的位置

JDK1.6及以前在方法区中

JDK1.6以后在堆空间(heap)中

3、字符串常量池的存储

字符串常量池是用stringtable储存的底层是hashtable,key是待查询的字符串hash值,value是指向字符串对象的内存地址

stringtable被设计出来是为了快速搜索字符串的,stringtable底层是hashtable所以底层数据结构是数组加链表,链表的长度是可以设置的,JDK1.6是固定的 1009。

-XX:StringTableSize=99991

数组下标是由如果hash(字符串值)/ 数组⻓度 = 余数(数组下标)

所以StringPool的String非常多,会导致链表过长,每次查找会性能,stringtable的长度可以用来做优化

4、字符串常量池的好处

为Java节省内存空间,所有的类共享一个字符串常量池,否则同一个字符串对象会有很多

5、String中的intern方法

  • intern方法作用

    1、Java会先计算string的hashcode值

    2、查找stringtable中是否有string对应的引用

    3、如果有返回当前引用

    4、如果没有,需要把字符串的地址(new出来的对象地址)放到stringtable中,并返回字符串的引用地址

    判断两个字符串的终极方法:看stringtable的key是用stringtable自己生成的,还是new出来的对象地址。拿到key和对象进行比较,自己生成的就不一样,反之就相等(此对象是放入key中的对象地址,其他对象还是不等)

    String a = "cc";
    String b = "c";
    String b1 = new String(a+b);
    b1.intern();
    String b2 = new String("ccc");
    System.out.println(b1 == b2.intern()); //true
    
  • intern的方法好处

    如果程序中有很多相同的字符串产生,并且这些字符串只能在运行期确定下来,我们只能通过intern方法显示的加入常量池中,减少对象的创建(对象还是回创建的,只是无引用,不需要为字符串开辟内存空间,很快会被垃圾回收),否则会在堆内存中开辟空间

    由于intern是动态加入string pool 中,要考虑string pool的大小,JDK1.7以前string pool是放在方法区(永久代PremGen)内的,大小受限。JDK1.7及以后是在堆内存中,大小可控。

三种常量池的区别

常量池有:class常量池、运行时常量池、字符串常量

1、运行时常量池每个Class对象拥有一个,和class文件中的class常量池一样,一个类文件一个class常量池

字符串常量池全局只有一个,从逻辑上被划分为运行时常量池中

2、class常量池有啥,运行时常量池基本一样。但是字符串具有动态性,可以在运行期用String中intern方法放入常量池

方法调用流程

1、方法映射

  • 如果是静态绑定(非虚方法:private、static、构造⽅法)类加载的解析阶段,就将符号引用改为直接引用
  • 动态绑定是由于在编辑期间无法确定调用的是哪个类的方法,只有在运⾏期间才能根据 对象类型进⾏动态确定。
  • 方法表:动态绑定会产生方法表,每个类都有自己的方法表,在多态的情况下父类和子类同名的方法,索引一样,如果子类没有的话,指向的地址是父类的

2、找到方法

  • 静态绑定的⽅法直接找到引⽤(运⾏时常量池)去调⽤⽅法即可
  • 动态绑定:需要先根据⽗类的⽅法表去确定要查找的⽅法的索引(编译看左边)。先从⼦类的⽅法表中开始查找,如果没有找到,去⽗类找,如果⼀直没找 到,则报错