嚎羸的博客

因为Hexo是静态博客,部署多有不便,建议查看我的语雀文档

0%

03、运行时数据区

运行时数据区概述

运行时数据区的位置和作用

image-20201228111823533"

我们可以看到,在类加载子系统后,运行时数据区就开始工作了,执行引擎其实就是依靠运行时数据区里面的数据开始执行的。

其实运行时数据区并不做什么东西,它就类似一个存储容器,执行引擎需要什么东西就去运行时数据区里面去拿。

可以看作是内存和CPU的关系,内存就是一个存储容器,而执行引擎才是执行者

举个例子,我们现在在厨房里要去做菜。

厨房里的不同位置放着不同的东西,比如餐具区域放着餐具、厨具部分放着厨具、蔬菜部分放着蔬菜等等

这些不同部分的综合起来就叫做运行时数据区,而这些部分就是运行时数据区中的不同部分。

厨师要做菜,需要餐具,蔬菜等等。厨师就是执行引擎,执行引擎就去运行时数据区里面拿到内容来进行数据的加工。

在做饭完成之后,要对案板,餐具,蔬菜进行打扫,这就是GC垃圾回收。


运行时数据区其实就是使用的内存

内存这东西我们知道,它是非常重要的系统资源,是硬盘和CPU的仓库和桥梁,承担着操作系统和应用程序的实时运行。

或者简单理解,就是皇帝身边的太监。硬盘或者网络上的什么东西不能直接交给CPU读取,必须先交给内存,然后内存汇总之后交给CPU。

JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。

==不同的JVM对于内存的划分方式和管理机制存在差异==。


运行时数据区对应的结构

image-20201228111934688

对应JDK8

对于JIT产生的缓存来讲,不同的人有不同的见解。深入理解JVM里面归类到了元空间里面,但是Alibaba又拿出来了

这个不要紧,我们只需要知道它是非堆空间即可,具体的所属位置没有那么明确


在Java虚拟机中,定义了很多程序运行期间会使用到的运行时数据区,也就是上面的这张图。

在这其中,有一些是跟随虚拟机启动而创建,随着虚拟机的退出而销毁的。

还有一些是和线程一一对应的,它们会随着线程的开始和结束而创建和销毁

在下图中,灰色为单独线程私有的,红色的为多个线程共享的

image-20201229210223805

单独线程私有:程序计数器、栈、本地栈

线程共享:堆、堆外内存(永久带/元空间、代码缓存)

正因为堆、方法区这种东西都是线程公用的,所以会涉及到我们所讲到的线程安全问题

再比如,针对垃圾回收也有一些点需要明白

其实堆和方法区都是可以进行垃圾回收的,但是可以说95%都在堆中,5%在方法区中


Rumtime实例

每一个JVM都有独一无二的Runtime实例,这个Rumtime就可以把它理解为我们的运行时数据区

image-20201229211213400


JVM中的线程

线程是一个程序中的运行单元。JVM允许一个应用有多个线程并行执行。

==在Hotspot JVM中,每个线程都与操作系统的本地线程直接映射==,因为线程是操作系统的本地线程,而Java层面不能直接去调用操作系统,所以我们需要一个映射关系来将Java的线程去对应操作系统。

Java线程准备好执行之后,本地线程才开始创建。这是因为线程中有刚才我们提到的程序计数器、栈存储等等东西,要等到这些东西准备好之后才会创建一个本地的线程。

一旦本地线程初始化成功,就会调用Java线程的run()方法。

假如我们run()方法出现了一些异常,Java线程就会终止,那么我们的本地线程还要再做一件事情:决定JVM要不要终止。

这个决定主要是参考我们当前终止的Java线程是不是最后一个非守护线程。

也就是说,假如程序中只剩下守护线程了,那么JVM就可以退出了。

这些后台系统线程在Hotspot JVM主要有下面几个(了解一下即可)

  • 虚拟机线程
  • 周期任务线程
  • GC线程
  • 编译线程
  • 信号调度线程

程序计数器(PC寄存器)

  • 程序计数器(PC寄存器)是对物理PC寄存器的一种模拟,实际上不是一个东西。

  • PC寄存器中存储指向下一条指令的地址,也就是即将执行的指令代码。执行引擎会读取这个指令去执行

    任何时间点,一个线程只有一个方法在运行,也就是所谓的==当前方法==

    程序计数器会存储当前线程正在执行的Java方法的JVM指令地址

    但是假如是执行native方法(本地方法栈中的)就是undefind

  • PC寄存器是一块很小的空间,几乎可以忽略不计。它也是运行速度最快的存储区域

  • 在JVM规范中,每个线程都有自己的程序计数器,是线程私有的,生命周期和线程的生命周期保持一致

  • 字节码解释器工作时就是依靠PC寄存器的值来选取下一条需要执行的字节码指令

  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

举个例子:
image-20201229214509255

在上图中,PC寄存器存储指令地址,执行引擎根据这个地址去执行具体的操作


那么为什么需要PC寄存器这种东西呢?

因为CPU在不断切换各个线程,当线程切换回来之后,我们得知道它要从哪里开始执行。

PC寄存器为什么要设置为私有?

CPU在多线程情况下会不断切换任务执行,这样必然会造成中断和回复,但是这个时候假如寄存器是共有的就全部都乱了,所以最好的办法是每一个线程有独立的PC寄存器,这样一来各个线程之间都可以进行独立运算而不会出现相互干扰的情况。


虚拟机栈

虚拟机栈概述

虚拟机栈出现的背景

由于跨平台性的设计,Java的很多指令都是基于栈来设计的。

不同平台CPU架构不同,所以不能设计基于寄存器的指令集架构。

基于栈的指令集架构优点是跨平台、指令集小,编译器容易实现。缺点是性能下降,实现同样的功能需要更多的指令


内存中的栈和堆

虽然JVM的内存结构不仅只有栈和堆,但是我们还是要把这两个提出来单独说一说,因为确实很重要。

栈是运行时的单位,而堆是存储时的单位。

也就是说,堆解决的是数据存储问题:数据怎么放,应该放在哪。

栈解决的是程序运行的问题:程序如何去执行,如何去处理数据。

当然了,以上也不是绝对的,比如局部基本数据类型和对象引用也是要放在栈中的,但是总体上来看是这么个效果没错

举个例子:堆中存放的就是我们食材的原材料,而栈中就是如何使用这些原材料。


Java虚拟机栈是什么

Java虚拟机栈早期也叫做Java栈,它有如下特点:

1、Java虚拟机栈是线程私有的,也就是说每一个线程都对应着一个Java栈

2、Java虚拟机栈的生命周期和线程的生命周期一致

3、Java虚拟机栈内部保存着一个个的栈帧,一个栈帧就对应着一个方法

4、Java虚拟机栈主管Java程序的运行,它参与方法的调用和返回,并且保存着方法的局部变量(8种基本数据类型+引用类型地址)和部分结果

对于栈帧来讲,我们刚才讲一个方法对应着一个栈帧,这没有错,但是栈帧不仅仅是这么一个宏观的概念,栈帧还能具体分为很多部分。对于栈帧来讲,我们先来看这样一幅图像,加深印象:

image-20201231143211754

图中的画面只开启了一个main线程,所以只有一个Java栈


栈的优点

栈是一种快速有效的分配存储方式,它的访问速度==仅次于程序计数器==

JVM直接对栈的操作只有两个:

  • 每个方法执行,伴随着压栈(也就是入栈)
  • 方法之行结束后的出栈

对于栈来说,方法执行完成之后就出栈,方法没有执行完不出栈


栈的异常

我们说的这个异常是异常体系的顶层类Throwable,不是我们讲的Exception

我们刚才讲到,对于栈来说,不存在垃圾回收问题,但是会出现其他的问题。

对于Java虚拟机来讲,它是允许Java栈的大小是==动态==的,也可以设置为==固定不变==的,但是这两种情况都会导致一些问题

1、假如Java栈是固定不变的,那么当一个栈只有入栈操作没有出栈操作的时候,那么栈的内存迟早会撑爆,这也就是我们说的StackOverFlowError

2、假如Java栈是动态分配的,那么还是当只有入栈没有出栈的时候,栈的内存没问题了,但是电脑的总内存迟早会撑爆,这也就是我们说的OutOfMemoryError

当然了,发生OOM还有可能是栈太多了,一个栈或许没有问题,但是假如有非常多的栈,那么电脑的总内存一样会撑爆

注意SOF和OOM一定要区分开来,它们是不一样的


设置栈内存大小

我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度


栈的存储单位

栈的概述

刚才其实我们已经透露过了,每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在的

在这个线程上每一个正在执行的方法都对应着一个栈帧

但是这并不是说栈帧的结构十分简单,事实上,栈帧是一个内存区块,是一个数据集,它的结构不是我们想象中的那么简单


栈运行原理

  • 栈的操作只有两种:压栈和出栈,遵循先进后出(First In,Last Out)的原则
  • ==在一条活动线程中,在某一刻只会有一个栈帧在执行,也就栈的顶部的栈帧==,这个栈帧被称为==当前栈帧==(Current Frame),与当前栈帧相对应的方法就是==当前方法==,定义这个方法的类就是==当前类==
  • 执行引擎运行的所有字节码指令只对当前栈帧进行操作
  • 如果在该方法中调用了其他的方法,对应的新的栈帧会被创建出来并入栈,称为新的栈顶

  • 不同的线程中所包含的栈帧是不允许存在相互引用的

也就是说,你不能在一个线程的方法中引用另一个线程中的方法,即使是同名方法。

因为方法所在的位置是栈帧,而栈帧是栈私有的,而栈又是线程私有的。

虽然不能相互调用线程的私有内容,但是可以调用进程中的共享内容,比如堆空间和方法区中的内容

  • 假如当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,让前一个栈帧称为当前栈帧
  • Java方法有两种返回函数的方式。一种是使用return正常返回、一种是抛出异常返回。但是不管哪种方式,都会导致栈帧被弹出

我们平时没有返回值的方法其实也是有return的,只不过在平时中我们都省略不写了,假如要写上return;也是没有错误的


栈帧的内部结构

栈帧概述

我们在上面说道,栈中存放着栈帧,一个栈帧就对应着一个方法,那么我们接下来看一下栈帧中的内部结构:

1、局部变量表(Local Variables)

2、操作数栈(Operand Stack)或者叫做表达式栈

3、动态链接(Dynamic Linking)或指向运行时常量池的方法引用

4、方法返回地址(Return Address)或方法正常退出或者异常退出的定义

5、一些附加信息

image-20210102095024054

我们说,栈能存储多少栈帧完全取决于栈帧内部结构的大小如何,而栈帧的大小很大程度上来自局部变量表和操作数栈

局部变量表:Local Variables

局部变量表概述

局部变量表也被称为局部变量数组或者本地变量表

==定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量==,这些数据类型包括各类基本数据类型,对象引用(reference),以及返回值(return Address)类型

我们说这个局部变量表其实是一个一维的数组,从形参开始算起,到方法内部的变量,再到返回值都算局部变量表中的内容

其中基本数据类型,对象的引用和返回值都算作在内

但是我们其实这里说的一个关键点是:定义为一个数字数组

这个数字数组是很有意思的,因为我们知道,就算是基本数据类型也是有非数字的类型的,这个时候JVM是这样去处理的:

byte、short、char这三种类型在存储前被转换为int

对于char来讲,我们知道它们也是有对应的ASCII码值或者这种Unicode值的,所以可以进行转换,而且我们的char值可以和int进行运算也证明了这一点

boolean也被转换为int,其中0表示false,非零表示true

甚至我们的对象引用地址,返回值类型,其实那就都可以使用int来表示了

由于局部变量表是建立在线程的栈上,是==线程的私有数据==,因此==不存在数据安全问题==

==局部变量表所需的容量大小是在编译期就确定下来的==,并保存在方法的Code属性的 maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

说明一点,这个局部变量表的容量大小不是说变量加起来占用多大的内存,而是说变量的最大数量,有几个变量

image-20210105151536976

局部变量表的生命周期和方法栈帧的生命周期一致,并且局部变量表中的变量只能在当前方法中使用

局部变量表是影响性能调优和栈内存比较密切的一个结构

因为栈帧中的局部变量表中会存放一些对象的引用,那么只要这个对象被引用(也就是放在局部变量表中)就不能被GC

==这个引用不管是直接引用还是间接引用,被引用的对象都不会被GC==


字节码中方法内部结构的剖析

现在有这样一段代码

1
2
3
4
5
6
7
8
9
10
11
package com.howling.localvariables;

public class Demo {
public static void main(String[] args) {

int a = 1;


int b = 2;
}
}

我们使用JClassLib这个插件来分析一下在字节码指令中,这段代码应该是什么结构

image-20210222184715443


变量槽slot

我们在上面讲,局部变量表其实就是一个数值类型的数组,索引从0开始,到数组长度-1。

但是我们这里不可能规定它具体是什么数值类型的数组了,所以它的基本的存储单元我们称为变量槽slot

刚才我们讲过,局部变量表里面可以存放编译器可以知道的8种基本数据类型和引用类型还有返回值的变量。

那么在我们的局部变量表中,32位的只占一个slot,64位的占用两个slot

比如我们说byte、short、char在存储之前被转换为int,boolean被转换为int,那么它们就占用一个slot

float是32位的,自然也占用一个slot

对于long和double来讲,它们就占用两个slot

对于占用两个slot的变量来讲,它对应的局部变量表的数组对应两个下标,那么我们应该使用它的起始索引

比如Long l,它可能占用局部变量表的0和1下标,那么我们选择0代表它

当我们当前的方法(栈帧)是通过构造方法或者是非静态方法(实例方法)创建的,那么该对象的引用(this)会存放在当前栈帧的局部变量表的索引0处

但是静态方法不会将对象的引用存放到局部变量表中

这也就理解了我们为什么不能够在静态方法中调用this,是因为this变量不存在于静态变量的局部变量表中

这也就说明了为什么我们说this变量是属于对象的,还是因为栈帧中的this指针会不会存放在局部变量表中

slot这种局部变量表中的槽位是可以回收的,只要一个局部变量出了它的作用域,那么就可以被回收然后使用其他的变量占用这个槽位,主要的目的就是资源的回收利用


下面我们来看一下关于slot中字节码的具体表现

1
2
3
4
5
6
7
8
9
10
11
package com.howling;

public class Demo {

int count = 0;

/**
* 因为没有任何的定义,并且是静态的方法,所以连this也没有
*/
public static void test() {}
}

image-20210104095115981

可以看到,连局部变量表都没有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.howling;

public class Demo {

int count = 0;

/**
* 因为没有其他的定义
* 所以在局部变量表中,只有一个指向当前对象的指针this
*/
Demo() {
}

/**
* 1、this这个指向当前引用的变量
* 2、double类型的d,因为是64位,所以占用两个Slot
* 3、int类型的a和b,占用一个Slot
* 4、对于第二次调用的test2()来说,因为没有变量保存所以没有占用Slot
*/
public void test1(double d) {
int a = 1;
int b = test2();
test2();
count++;
}

/**
* 只有一个this
*/
public int test2() {
return 0;
}

}

image-20210104095601822

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.howling;

public class Demo {
/**
* 一共有4个Slot被占用
* 1、this
* 2、int类型的a,占用索引为1的Slot
* 3、double类型的b,占用索引为2和3的Slot,但是作用范围有限
* 4、int类型的c,当double类型的b作用范围消失后会复用Slot,索引为2
*/
public void test3() {
int a = 0;

if (a >= 0) {
double b = a;
// 注意这一行,变量不使用那么就不会占用槽位
System.out.println(b);
}
int c = a;
}

}

image-20210104100239800


静态变量和局部变量的对比

==我们在这里要进行静态变量和局部变量的对比,注意是类变量和局部变量的对比==

按照数据类型进行分类

  • 基本数据类型
  • 引用数据类型

按照位置的分类

  • 成员变量:在类中声明

    • 类变量:在类中使用static修饰的变量

      在类的加载过程中的链接阶段中的准备阶段有一次默认的赋值

      然后在类的加载过程的初始化阶段显示赋值

    • 实例变量:在类中直接声明的变量,属于对象的变量

      随着对象的创建会在堆空间中分配实例变量空间,然后进行默认的赋值操作

  • 局部变量:在方法中声明

    在使用前必须显示赋值,它没有默认的赋值操作


操作数栈:Operand Stack

操作数栈概述

每一个栈帧中,除了局部变量表之外,也有另一个结构,这就是==操作数栈==,它也叫做操作栈

我们说的==JVM是基于栈的执行引擎==,这个栈就是指的操作数栈

操作数栈==是使用数组来实现的==,但是它也有栈的特点:==先进后出==

或者说,只要是栈,只能做push和pop这两个操作

操作数栈的具体作用就是==用来保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间==

操作数栈其实就是JVM执行引擎的一个工作区

具体的流程是这样的:操作数栈从局部变量表里面拿数据,执行引擎从操作数栈里拿数据,计算完成之后的中间值先放到操作数栈里,最终的结果同步到局部变量表里

它们之间的关系类似于硬盘–内存–CPU之间的关系

为什么这么像呢,这就是因为JVM本来就是仿照这样的结构来设计的

因为是数组实现的,所以==操作数栈的具体大小在编译期就已经决定了==,保存在方法的Code属性中

image-20210105151605549

当一个方法开始执行的时候,自然创建出一个新的栈帧,这个栈帧中的操作数栈是空的

但是注意,操作数栈是空的并不代表它是null,现在它有容量但是还没有数据

操作数栈的具体分配空间大小是这样的:

  • 32bit类型占用一个栈单位深度
  • 64bit类型占用两个栈单位深度

byte、short、char、boolean都会转换为int来保存

这几点和局部变量表是一样的

注意,操作数栈虽然是使用数组来实现的,但是它并不是基于访问索引的方式来进行数据访问的,而是使用入栈出栈

==如果被调用的方法带有返回值,那么返回值会压入当前栈帧的操作数栈中==,并更新PC寄存器中下一条需要执行的字节码指令


代码追踪

代码追踪的意思是,一起看一下字节码是如何在执行中使用到局部变量表,操作数栈,执行引擎的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.howling.operandstack;

public class Demo {
public static void main(String[] args) {
test();
}

public static void test() {
byte i = 15;

int j = 8;

int k = i + j;
}
}
  • 字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# stack=2:操作数栈的深度为2、locals=3:局部变量表的个数为3个
stack=2, locals=3, args_size=0

# 将15push进操作数栈Operand Stack中
0: bipush 15
# 将15从Operand Stack 中pop出来,并且存放到 Local Variables中索引为0的位置中
2: istore_0
# 将8push到Operand Stack中
3: bipush 8
# 将8从Operand Stack中pop出来,并存放到Local Variables中索引为1的位置中
5: istore_1
# 将Local Variables中索引为0的位置中的数据取出,push进Operand Stack中
6: iload_0
# 将Local Variables中索引为1的位置中的数据取出,push进Operand Stack中
7: iload_1
# 将Operand Stack栈顶和栈顶前一位的数据相加,并重新push到Operand Stack中
8: iadd
# 将Operand Stack中的数据pop出来,并存放到Local Variables中索引为2的位置中
9: istore_2
# 退出
10: return

栈顶缓存技术

前面我们说过,JVM是基于栈的指令集架构,而不是基于寄存器的指令集架构,这种指令集架构能够在不使用任何地址的情况下进行处理,但是相应的也多出了许多的入栈出栈操作

由于操作数是存储的内存中的,这也就说明频繁执行内存的读写必定会影响执行速度。

所以为了解决这个问题,HotSpot JVM的设计者提出了一个叫做栈顶缓存的解决方案,也就是说==将栈顶元素全部都缓存到物理CPU的寄存器中,以此来降低对内存的读写次数,提高执行引擎的执行效率==

在这里,我们先做一个了解,清楚有这么个东西即可


动态链接

栈帧中的内部结果:动态链接

老规矩,还是先上这个图:

image-20210102095024054

在这个图中,栈帧里面的东西我们已经讲过了局部变量表,操作数栈,下面就是我们的动态链接

其实再说一嘴,==动态链接,方法返回地址,附加信息 这三者在有些地方会叫做帧数据区==

这个动态链接其实不是真正动态链接概念,它是一个容器,里面保存的其实就是一个地址,这个地址==指向的是在运行时常量池中的这个方法的引用==,保存这个目的是为了实现动态链接(真正的动态链接)

在Java源文件中被编译为字节码文件中时,所有的变量和方法引用都作为符号引用保存在常量池中

那么==动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用==

可能看到这里有点懵逼,但是没关系,举个例子马上就可以理解了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.howling.dynamiclink;

public class DynamicLinkDemo {

int num = 1;

public void A() {
}

public void B() {
A();
num++;
}
}

假如,现在我们定义两个方法:A、B,然后我们在B中使用A,使用一个变量,来看它的字节码文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
上面省略
--------------------------------
注意了,这里是运行时常量池
在运行时常量池中会存在 #7 = Utf8 I 之类的东西
左边叫做符号引用,右边对应着真实的地址位置
--------------------------------
Constant pool:
#1 = Methodref #5.#19 // java/lang/Object."<init>":()V
#2 = Fieldref #4.#20 // com/howling/dynamiclink/DynamicLinkDemo.num:I
--我们可以看到,#3引用了#4和#21,我们去看这两个符号引用--
#3 = Methodref #4.#21 // com/howling/dynamiclink/DynamicLinkDemo.A:()V
--我们可以看到,#4引用了#22--
#4 = Class #22 // com/howling/dynamiclink/DynamicLinkDemo
#5 = Class #23 // java/lang/Object
#6 = Utf8 num
#7 = Utf8 I
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcom/howling/dynamiclink/DynamicLinkDemo;
#15 = Utf8 A
#16 = Utf8 B
#17 = Utf8 SourceFile
#18 = Utf8 DynamicLinkDemo.java
#19 = NameAndType #8:#9 // "<init>":()V

--这里是引用了#6和#7,其实就是一个num变量和一个I(int)--
#20 = NameAndType #6:#7 // num:I

--这是#3引用的#21,我们可以看到这里引用了#15和#9,其实这里就是一个字符A,一个返回值V(void)--
#21 = NameAndType #15:#9 // A:()V
--这里是#4引用的#22,保存的是类方法的名称--
#22 = Utf8 com/howling/dynamiclink/DynamicLinkDemo

--这里是Object,因为类首先要继承Object--
#23 = Utf8 java/lang/Object
{
int num;
descriptor: I
flags:
-----------------------
省略
-----------------------
我们可以看到,B方法
-----------------------
public void B();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
--在这边引用了一个MethodA(我并没有贴出来),对应的符号引用是#3,那么我们去找#3--
1: invokevirtual #3 // Method A:()V
4: aload_0
5: dup
--符号引用是#2,我们去找--
6: getfield #2 // Field num:I
9: iconst_1
10: iadd
--同上--
11: putfield #2 // Field num:I
14: return
----------
省略
----------
}
SourceFile: "DynamicLinkDemo.java"

看这个可能会有点蒙圈,提示一下,先看方法B,然后去找对应的符号引用,就不懵了

注意里面的运行时常量池,现在是放在方法区中的,所以运行时常量池是可以线程共享的,这个先有一个概念即可


方法的调用:解析与分派

方法的调用其实并不是栈帧的内部结构,而是我们每天都在做的,比如方法A调用方法B,指的是这个东西

但是我们虽然每天都在调用方法,但是其实并不是非常清楚它的内部结构最后是什么样子的,下面就来看一下

我们在上面的动态链接中说道了符号引用,但是假如我们要最终调用某个方法,就必须通过符号引用找到对应的地址,才能最终调用另一个方法

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关系,那么绑定机制有两种:动态链接、静态链接

静态链接

在一个字节码被加载到JVM中时,假如被调用的对象在编译期就已经明确了,并且在运行期保持不变

那么这种符号引用转换为直接引用的过程就叫做静态链接

动态链接

首先要明确一点,这个动态链接不是刚才我们说的栈帧内部结构,那个虽然也叫做动态链接,但其实是一个容器,里面存放的是指向运行时常量池中的方法引用

而我们这个动态链接指的是方法的绑定机制

假如被调用的方法在编译期间没有办法确定下来,也就是说只能在程序运行期间将符号引用转换为直接引用的过程,叫做动态链接

动态链接和静态链接的区别

动态链接和静态链接都需要将符号引用转换为直接引用,区别就是

  • 动态链接在编译期不能确定将符号引用转换为什么直接引用,只有在运行期才能确定
  • 静态链接在编译期就可以确定将符号引用转换为什么直接引用

方法的调用

动态链接和静态链接

我们在写Java程序的时候,我们离不开方法的调用,其实方法的调用并不是栈帧中的内部结构,但是它仍然是十分重要

在上面我们说过这个动态链接和静态链接的区别,可能有些难以理解,所以我们在这里再次将它进行说明

我们知道,面向对象的语言有一个多态的特性,那么虚拟机是如何确定最终应该调用哪个方法呢?那这个就牵扯到我们从符号引用转换到直接引用的绑定上,而我们需要的两种方法的绑定机制:动态链接、静态链接

静态链接和动态链接的概念在上面其实已经有了,但是那个东西完全不像人话,所以我们要重新解释,但是在解释之前要先解释什么是符号引用,毕竟我们说从符号引用转换到我们需要绑定的方法上

我们来看一组图片

image-20210128193950603

从上面这组图片上我们很清晰的看到,#x其实就是符号引用,符号引用可能还会指向另一个符号引用,但是最终总是会有一个符号引用指向最终的处理

这个过程就是我们刚才在上面说的,符号引用转换到了直接引用的转换过程

那么我们说,这个转换过程和方法的绑定机制有关,我们刚才说方法的绑定机制:动态链接、静态链接

再说的清楚一些,符号引用肯定是要转化到直接引用的,也就是说符号引用最终肯定是要落实到一个方法的

但是问题就在于,它是在编译期就从符号引用转换到了直接引用,还是到了运行期才从符号引用转换到了直接引用

这两种从符号引用转换为直接引用的过程,就对应着我们的动态链接和静态链接

动态链接和静态链接最大的区别就是:

静态链接被调用的目标方法在编译期就可知,但是动态链接被调用的目标方法不能在编译期确定下来

完整的叙述应该是这样的:

假如一个字节码文件被装载到JVM内部时

假如==被调用的目标方法在编译期可知==,并且==在运行期保持不变==,则这种情况称为静态链接

假如被调用的方法==在编译期无法确定下来==,也就是说只能在程序运行期将符号引用转换为直接引用,这种转换过程具备动态性,也就是我们说的动态链接

早期绑定和晚期绑定

那么动态链接和静态链接是针对于方法来讲的

那么我们现在把它的范围扩大一些,又有了两个新的名词:==早期绑定、晚期绑定==

其中早期绑定是和静态链接对应的,晚期绑定是和动态链接对应的

早期绑定就是目标对象在编译期可知,并且在运行期保持不变

晚期绑定就是在编译期无法确定,只有在运行期才可以确认的

那么绑定和链接有什么区别呢?链接只是针对于我们的方法,绑定的范围则更大一些,方法、类、变量都算作在内

虚方法和非虚方法

我们刚才在说动态链接的时候,说道最后符号引用转换为直接引用的过程叫做动态链接

但是我们在实际上调用的时候,还是使用的父类的形参去调用的方法,这个就表现为多态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.howling.dynamiclink;

public class DynamicLinkDemo {
static void eat(Animal animal) {
animal.eat();
}

public static void main(String[] args) {
eat(new Cat());
}
}

class Animal {
void eat() {
System.out.println("吃");
}
}

class Cat extends Animal {

void eat() {
System.out.println("猫吃鱼");
}
}

毫无疑问,这个最终输出的是猫吃鱼,这个过程就叫做动态链接,但是我们传入的这个形参Animal,有个名字叫做虚方法

那么有了虚方法,自然有非虚方法,对应的概念自然就是静态链接

那么非虚方法有这么几种:

1、静态方法

2、私有方法

3、final方法

4、实例构造器

5、父类方法

其余的都是虚方法

我们看到,这些方法其实都不能实现多态,也就是不能被重写的方法

这里边可能有几个还不太明确的,这里说明一下

首先是实例构造器,构造器肯定是不能被重写的,所以我们的调用就非常明确

父类方法,这个父类方法其实并不是我们在子类中重写之后,然后传一个父类的这个概念

这个父类方法的意思是我们在子类中super.方法,是这个意思

虚拟机的指令

明确了上面这些概念之后,我们看一下JVM中的指令:

普通调用指令:

1、invokestatic:调用静态方法,解析阶段确定唯一方法版本

2、invokespecial:调用<init>方法、私有方法、父类方法,解析阶段确定唯一方法版本

注意,这里的调用父类方法并不包括父类的静态私有方法

静态方法属于invokestatic

3、invokevirtual:调用所有虚方法

事实上并不完全准确,因为比方说final方法也在这个指令中调用,但是final也是非虚方法

所以这个指令只能说是:大部分调用的都是虚方法和一部分非虚方法

4、invokeinterface:调用接口方法

接口自己都没有方法体,所以接口的肯定是动态链接

注意了,我们在这个调用指令问题上,其实有很多细节值得探究

1、final其实是使用的invokevirtual,但是它并不是虚方法

2、父类有一个可以被重写的方法,在子类中明确使用super.方法调用,那么指令是invokespecial

3、父类有一个可以被重写的方法,在子类中没有使用super.方法调用,那么指令是invokevirtual,因为这个时候他会认为子类是有可能进行重写的

动态调用指令:

invokedynamic:动态解析出需要调用的方法,然后执行

Java的指令集一直比较稳定,直到JDK7才出现一个invokedynamic指令,这是Java为了实现动态类型语言而出现的一种改进

其实在Java7中并没有直接使用这个指令,而是需要借助一些工具来产生invokedynamic指令,直到Java8才出现的Lambda表达式中,才直接出现的这个指令

那么其实上面说到了动态类型语言,其实还有一个静态类型语言:

其实动态类型语言和静态类型语言的区别就是对类型的检查是在编译器还是在运行期。

假如是在编译期对类型进行检查那么就是静态类型语言,假如是在运行期间对类型进行检查就是动态类型语言

所以我们说Java其实是静态类型语言,但是有了invokedynamic这个指令,使Java在一定程度上也具备了动态类型语言的特性


虚方法表

我们知道,在面向对象的过程中,肯定是需要动态链接,或者说晚期绑定这种特性

那么在这种特性中,JVM会动态去寻找它的类型,但是假如每次都要去搜索,那么就会影响到执行效率

所以为了提高性能,JVM在==类的方法区==中建立了一个==虚方法表==,专门用来存放虚方法,以用来使用索引来代替查找

这样就不用每次都去大范围中去寻找了

每一个类中都有一个虚方法表,表中存放各个方法的实际入口

假如我现在有一个Father类,有一个Son类,Son继承了Father并重写了方法

那么假如我去调用son的方法,它首先会去虚方法表中寻找,假如虚方法表中存在,那么直接调用

假如虚方法表中不存在,它才会向上寻找

那么虚方法表其实是在类的加载的链接阶段中的解析阶段


方法返回地址

前面我们讲过了局部变量表、操作数栈、方法返回地址,动态链接

那么这次我们要讲解方法的返回地址

我们再次说明,有一些帖子会将方法的返回地址、动态链接、附加信息全部都归类到帧数据区中

所以假如我们看到有帖子说:局部变量表、操作数栈、帧数据区,也不要大惊小怪

介绍一下方法的返回地址:

方法的返回地址其实就是栈帧中的一块内存结构,它里面存放的是==调用该方法的PC寄存器的值==

我们知道,PC寄存器里面其实就是存放着下一条要执行的指令的地址值,那么我们说方法的返回地址其实就存放该方法的PC寄存器的值,那么就说明存放着下一条指令的地址值

所以它的作用是它所在的方法执行完成之后,交给执行引擎去执行下一条指令

但是PC寄存器和方法的返回地址并不冲突

PC寄存器是上升到整个线程的,但是方法的返回地址只是方法中的

但是这种情况仅仅适用于方法正常退出的情况,方法出现了异常是要在异常表来确定的,栈帧不会保存这种信息

一些附加信息

其实一些附加信息没啥东西,主要是看Java虚拟机的一些实现,这个是不确定是不是有,只知道有这个东西即可


本地方法接口和本地方法库

我们看图,其实这一块并不属于运行时数据区的内容,但是要将这里部分单独拿出来进行讲解,穿插讲解

image-20210203172919917

什么是本地方法

简单来说,一个Native Method(本地方法)其实就是一个Java调用非Java的接口(不一定是C,还有可能是别的方法)

其实不仅仅是Java,很多语言都有这样的特性,比如在C++中会调用C的函数,所以这种情况时比较常见的。

那么本地方法的接口的作用就是融合不同的编程语言为Java所用,他的初衷是融合C/C++的程序

在一些类中,我们常常可以看到使用native来修饰的方法,比方说Object

image-20210203173327259

这种方法看起来类似抽象方法没有方法体,但其实它是有方法体的,但是方法体不在Java

为什么要使用本地方法

有的的时候,Java应用需要和Java外界的环境进行交互,这其实是本地方法存在的主要原因。

有的时候,考虑到执行效率的问题,我们也需要使用本地方法。

而且当时Java刚出世,必须要经过其他的语言才能够崛起。

但是到目前为止,除非是要直接和硬件打交道,其实Java底层优化和执行效率,生态上,基本都可以和C/C++媲美了

本地方法栈

刚才我们说了程序计数器、虚拟机栈,现在来讲本地方法栈

image-20201228111934688

我们在说Java虚拟机栈,其实是用于管理Java方法的调用,而本地方法栈则是用于管理本地方法的调用。

本地方法栈其实非常类似虚拟机栈

1、也是==线程私有==的

2、允许动态扩展或者固定,但是要注意也会出现内存溢出

3、==当某一个线程调用一个本地方法时,这个线程就进入和一个不受虚拟机限制的世界,它和虚拟机拥有同样的权限==

  • 本地方法可以通过本地方法接口访问虚拟机内部的运行时数据区
  • 可以直接使用本地处理器的寄存器
  • 直接从本地内存的堆中分配任意数量的内存

4、==并不是所有JVM都支持本地方法==,因为Java虚拟机规范中并没有明确要求本地方法栈所使用的语言,具体的实现方式,数据结构等


概述

image-20201228111934688

在运行时数据区中,堆空间可以说是最大的一个空间了,在我们调优的这个过程中,我们主要是对堆进行调优,所以堆的内存结构一定要门清

那么我们说,一个Java程序是一个进程,而方法区和堆空间其实实对应一个进程的,那么进程中有多个线程,所以说多个线程共享一个堆空间和方法区,我们这里主要讲堆。

1、一个JVM实例只存在一个堆,堆是Java内存管理的核心区域

2、Java堆区在JVM启动时即被创建,其空间大小也被确定了,是JVM管理的最大的一块内存空间。

但是我们在JVM启动之前可以进行设置,堆内存的大小是可以调整的

3、《Java虚拟机规范》中规定,堆可以处于物理上不连续的内存空间中,但是在逻辑上应该被认为是连续的

4、所有的线程共享Java堆,在这里还可以划分线程的私有的缓冲区(ThreadLocal Allocation Buffer,TLAB)

完整的堆空间不一定全部的东西都是共享的

这是因为假如我们每一个共享数据都加锁来实现并发控制,那么这种并发性比较差

我们使用堆中的一小块部分,每一个线程都占用这一小部分的一块,这样每个线程都是自己的空间,并发问题更好了

5、==几乎所有==的对象实例和数组都应该分配在堆上

6、在方法结束之后,==堆中的对象不会立刻移除==,而是要等到垃圾回收的时候来移除

7、尽量减少GC的次数,因为GC需要消耗资源,这样会影响正常的用户线程


为了更加直观地理解,我们现在写一段很简单的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.howling.heapdemo;

public class HeapDemp {
public static void main(String[] args) {


System.out.println("start...");

new Thread(()->{
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();


}
}

然后我们打开JDK安装目录–>bin–>jvisualvm.exe,双击运行,会看到这个东西

image-20210203183718949

这个其实就是检测JVM的一个工具,我们暂时先用着,点击工具–>插件–>可用插件,在搜索框中搜索Visual GC,安装(可能需要代理)

image-20210203184024883

给Java程序配置如下一行代码:-Xms10m -Xmx10m,这行代码设置了堆空间的大小,最小和最大都是10m

image-20210203184554192

启动Java程序,并在jvisualvm中监控查看

image-20210203184759141


堆内存划分

现代的垃圾收集器大部分是基于分代收集理论设计

那么啥叫作分代收集呢?这其实涉及到我们的堆空间的划分了。

因为堆的空间比较大,所以我们将堆划分出来为一些小部分,称为不同的代,这些代实现不同的功能。

在Java7之前,堆内存逻辑上分为三个部分:==新生区、养老区、永久区==

在Java8及之后,堆内存逻辑上分为三个部分:==新生区(PSYoungGen)、养老区(ParOldGen)、元空间(Metaspace)==

也就是说,从8开始,Java就永久放弃了永久区,换成了元空间

那么无论是Java7还是Java8,都是在逻辑上的三部分构成了堆的空间

那么为什么说是逻辑上呢?这是因为==无论是永久代,还是元空间==,在事实上都是==属于方法区的落地实现==

==永久代和元空间其实只是在逻辑上属于堆,但是事实上它们是方法区的落地实现==

方法区在后面讲

一般来说,新生区我们也叫做新生代、年轻代,养老区我们也叫做老年区、老年代,永久区我们也叫永久代


设置堆空间的大小

  • -Xms:表示设置堆空间的起始内存,等价于-XX:InitialHeapSize
  • -Xmx:表示设置堆空间的最大内存,等价于-XX:MaxHeapSize
  • -XX:+PrintGCDetails:查看内存详细信息

-X是JVM的运行参数

mxmemory start,用来设置堆空间(新生代+老年代)的初始内存大小

单位可以不写:默认为字节,可以为k,可以为m,可以为g

一旦内存使用大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError,也是我们常说的OOM

==通常将最大内存和最小内存设置为同样的值==,这是为了能够==在Java垃圾回收机制清理完堆区后不需要重新计算堆区的大小,用来提高性能==

默认情况下,堆的初始内存大小为:电脑存储 / 64,最大内存为:电脑内存 / 4


新生代和老年代

基本的内存结构

内存结构

前面我们说过堆内存的划分,包括新生代、老年代、元空间,而元空间在逻辑上属于堆,事实上是方法区的实现

那么我们在这里就不说元空间了,元空间等到方法区的时候专门讲,我们这里讲一下新生代和老年代

存储在JVM中的Java对象可以分为两类:

1、一类生命周期比较短,是瞬时对象,创建和销毁都十分迅速

2、一类生命周期比较长,在某一些极端的情况下甚至能和JVM的生命周期保持一致

堆内存更细划分,可以划分为新生代(YoungGen),老年代(OldGen)

而新生代又可以划分为伊甸园区(Eden)、幸存者零区(Survivor0),幸存者一区(Survivor1)

有时候,幸存者零区和一区也被称为from区,to区

image-20210204172211092

设置新生代和老年代的内存占用

  • -XX:NewRatio=2:表示新生代占用1,老年代占用2,这也是默认的比例

假如我们设置为:-XX:NewRatio=4,表示新生代占用1,老年代占用4,新生代占用整个堆的1/5

image-20210204175725429

image-20210204175707748

在默认情况下,新生代中的伊甸园区和另外两个幸存者区的比例是8:1:1

  • -XX:SurvivorRatio=8:这就是代表Eden:Survivor0:Survivor18:1:1

假如我们设置为:-XX:SurviorRatio=3,这就代表Eden:Survivor0:Survivor13:1:1

image-20210204175402966\

image-20210204175434109

但是现在有一个问题,虽然JDK的官方文档上默认说的是8:1:1,但是事实上并不是8:1:1

那么为什么会这样呢?这是因为默认开了自适应的内存优化比例,所以出现这种情况


这里要注意几点:

1、几乎所有的Java对象都是在Eden区被new出来的

为什么说几乎呢,举一个例子:假如我们new一个对象,Eden区放不下了,那么可能直接转到Old Gen中

2、绝大部分的Java对象的销毁都在新生代中进行了

IBM专门研究表明,新生代的80%对象都是很快就死亡

3、可以使用-Xmn设置新生代最大内存大小

这个参数一般使用默认值就可以了,一般情况下我们其实是设置一个比例来设置大小的

假如我们的比例和-Xmn同时出现,使用-Xmn设置的大小


对象分配的过程

一般过程

我们在这里说明一下,对象中分配的一般过程,特殊情况在下面会讲

对象内存的分配是一个比较复杂的过程,涉及到新生代(Eden、S0、S1)和老年代中对象转移的问题,同时有垃圾回收的问题等

我们首先看一张图,也就是新生代-老年代的图

image-20210208101807059

1、对象创建,首先放到Eden区中

image-20210208101952041

2、Eden区满,进行一次YGC,或者说Minor GC,将Eden区和S0、S1区的所有垃圾全部回收

但是因为是首次YGC,所以没有发现S0、S1的垃圾,只发现了Eden区中的垃圾,所以回收Eden区中的垃圾

有一些对象暂时不是垃圾,所以我们将其放到S0区中

我们每一个对象都有一个年龄计数器,这些放到S0区中的对象中的年龄+1,也就是在一次GC中存活

image-20210208102235833

3、同样的进行发展,Eden区中的对象再次满了

image-20210208102543155

4、进行一次YGC,这次回收Eden区、S0、S1区中的垃圾

可能有一些垃圾回收了,但是同样有可能有一些对象还不是垃圾,这次我们回收一个S0区的,回收大部分Eden区的

使用年龄计数器,将没有回收的对象的年龄全部+1

同时将S0区、Eden区的对象放到S1区

我们之前讲过,S0、S1区中,总有一个是空的,就是现在的情况

image-20210208102747058

5、下一次,Eden区再次满的时候,将Eden、S0、S1区中的垃圾全部回收,其他非垃圾的对象年龄全部+1,放到S0区

image-20210208102950756

6、长此以往,直到年龄计数器中的某一些对象的年龄到达了15,并且下一次GC的时候要到达16

image-20210208103115759

7、下一次GC,同样回收一些垃圾,但是注意对象的转移,已经到达16的年龄的对象,将这些对象晋升到老年代中,其余不变

image-20210208103335379

8、继续,按照上面的规律进行对象内存的分配

对象内存分配的注意点

1、YGC的触发只有在Eden区满的时候才会触发,S0和S1区满不会触发

2、YGC会同时回收Eden、S0、S1区的垃圾

3、默认情况下,对象的年龄高于15,则会放到Old Gen中,但是我们可以设置参数

-XX:MaxTenuringThreshold=xx来设置当对象的年龄到达对应的年龄之后,才会放到老年代中

4、我们S0、S1区也叫做from、to区,但是from和to是动态的,也就是向哪个区转移,哪个区是to,另一个是from

5、垃圾回收频繁在新生代收集,很少在老年代收集,几乎不在永久代/元空间收集


对象内存分配的特殊情况

image-20210208104831099

上面这张图配合下面的文字进行比对观看

1
2
3
4
5
6
7
8
9
10
11
12
1、新对象申请内存
2、判断Eden区是否可以放下此对象
2.1、Eden区可以放下此对象,那么直接为此对象分配内存
2.2、Eden区不可以放下此对象,那么进行一次YGC(YGC后,Eden区被清空了),再次判断Eden区是否可以放下此对象
2.2.1、Eden区可以放下此对象,那么分配内存
2.2.2、Eden区放不下此对象,说明是超大内存,直接判断是否可以放到老年代中
2.2.2.1、老年代可以放下此对象,则直接将对象放到老年代中,分配内存
2.2.2.2、老年代放不下此对象,进行一次FGC(可以暂时理解为类似YGC),判断是否可以到Old Gen中
2.2.2.2.1、可以放到Old Gen中,则放到Old Gen中,分配内存
2.2.2.2.2、Old Gen也放不下,判断是否可以进行JVM动态内存扩容
2.2.2.2.2.1、可以扩容,那么扩容,只要不到电脑的内存限制,那么扩容,放到Old Gen中
2.2.2.2.2.2、不能扩容,触发OOM

上面的文字是新对象分配内存出现的问题,下面我们来讲不是新对象分配的内存出现的问题

1
2
3
4
5
6
7
8
9
10
11
12
1、新对象申请内存
2、Eden区可以放下,那么直接放到Eden区
3、随对象越来越多,触发YGC,判断是否可以存放到S0/S1区
(我们的Eden:S0:S1默认是8:1:1。所以可能发生Eden区可以放下但是S0/S1放不下的情况)
3.1、可以放下,那么直接放下
3.2、不可以放到S0/S1区,那么直接判断是否可以放到Old Gen区中
3.2.1、可以放,那么直接放到Old Gen中
3.2.2、不可以放到Old Gen区中,触发一次FGC,再次判断是否可以放到Old Gen中
3.2.1、可以放,那么放
3.2.2、不可以放,那么判断是否可以JVM动态扩容
3.2.2.1、可以动态扩容,那么只要电脑内存足够,则扩容,放到Old Gen中
3.2.2.2、不可以动态扩容,OOM

常用的调优工具

有下面几个:

1、JDK命令行

2、Eclipse:Memory Analyzer Tool

3、Jconsole

4、VisualVM(之前我们都是用的这个)

5、Jprofiler

6、Java Flight Recorder

7、GCViewer

8、GC Easy

我们建议使用Jprofiler11来进行我们的查看

安装需要两步:

1、安装Jprofiler11,这个在康师傅讲的JVM下面有工具链接

2、安装IDEA插件:Jprofiler


Minor GC、Major GC、Full GC

为什么要减少GC

其实我们说过了,GC的过程其实会有一个STW的情况出现,STW其实就是Stop The World,也就是停止所有的服务,让用户线程去寻找垃圾的过程

那么只要所有的服务暂时停止,对我们其实是一个很大的损失,因为这样就不能为用户提供服务,可能用户会感觉非常卡

所以调优的过程其实就是减少GC的过程,下面我们来了解一下几种GC


GC分类

那么JVM来进行GC的时候,不是每次都对新生代、老年代、方法区同时回收的,而是大部分时间回收都只是回收新生代,但是这不代表我们只回收新生代

在HotSpot JVM中,它里面的GC按照回收区域分为两种类型:

1、部分收集(Partial GC):不是收集整个Java堆,而是分为

  • ==Minor GC/YGC/Yong GC==:只是收集新生代
  • ==Major GC/Old GC==:只是收集老年代,目前只有GMS GC有单独收集老年代的行为,啥是GMS GC以后再说
  • ==Mixed GC==:收集整个新生代和部分老年代,目前只有G1 GC有这种行为,G1 GC是啥以后再说

2、整堆收集(Full GC)

  • ==Full GC==:回收整个Java==堆和方法区==

注意,有的时候很多人会把Major GC和Full GC混淆,但是我们需要具体分辨是老年代回收还是整堆回收


GC的触发机制

Minor GC触发机制:

当新生代空间不足时,会触发Minor GC,这里的年轻代满指的是Eden区满,而不是Survivor区满,两个Survivor区满并不会触发Minor GC

因为大部分的Java对象都是朝生夕死,所以Minor GC非常频繁

虽然Minor GC会触发STW,但是不要担心,Minor GC的回收速度非常快

STW也就是说Stop The World,就是说暂停所有其他的线程,等待垃圾回收结束才恢复线程

这样可能就会产生一种让人觉得卡了一会的感觉

Major GC的触发机制:

对象要向老年代转移时,假如发现老年代空间不足,会尝试触发Minor GC,假如之后空间还是不足,则会触发Major GC

也就是说老年代的GC一般都伴随着至少一次Minor GC,但是这也不是绝对的,比如Parallel Scavenge收集器的手机策略中,就有直接进行Major GC的过程

Major GC的时间一般在Manor GC的十倍以上,STW的时间更长,所以要尽量避免Major GC

假如Major GC后内存仍然不足就会报OOM

Full GC触发机制:后面细讲

Full GC会回收全部的堆和方法区,那么Full GC的执行时间十分长,要尽量避免,Full GC触发情况有以下几种

1、调用System.gc()时,系统建议首先执行Full GC,但是不必然执行

2、老年代空间不足时

3、方法区空间不足时

4、通过 Minor GC后进入老年代的平均大小大于老年代的可用内存时(其实就是老年代不足)

5、Eden和From区向To区复制时,对象大小大于To可用内存,则会将对象转到老年代,并且老年代的可用内存也放不下时(其实就是老年代不足)

在这里有一个老年代空间不足的情况,我们刚才在讲Major GC的时候,曾将讲过Major GC是在老年区不足时进行的,那么这里也说,当老年代不足时,进行Full GC

那么老年代不足时,到底是进行Major GC还是Full GC?其实它是属于一种混合使用的情况


对象内存分配的策略

我们这里的对象内存分配策略是一个总结性的描述

1、优先分配到Eden区

2、大对象(Eden区放不下的)直接放到老年代(注意,我们要尽量避免程序中出现过多的大对象)

更痛苦的是这些大对象是朝生夕死的,放到了老年代中一堆没卵用的对象

3、长期存活的分配到老年代

默认是超过15,但是可以使用参数设置-XX:MaxTenuringThreshold=xx

4、动态年龄判断

假如survivor区中相同年龄的所有对象大小的总和大于survivor空间的一半,年龄大于或等于此年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄

5、空间分配担保

比如Minor GC之后,大量的对象存活,但是survivor区比较小,那么我们就把一些原本应该放到survivor区的对象放到老年代-XX:HandlePromotionFailure


堆空间为每个线程分配的TLAB

为什么要有TLAB

TLAB其实就是Thread Local Allocation Buffer的缩写,就是在堆空间中为每一个线程单独分配的一块空间

那么为什么要有这个TLAB呢?我们之前讲过:

  • 堆是线程共享的区域,任何线程都可以访问到堆中的共享数据
  • 由于对象实例的创建在JVM是非常频繁的,所以在并发环境下从堆区中划分内存空间是不安全的
  • 为了避免多个线程操作同一个地址,我们需要使用加锁等机制,但是加锁的机制会影响分配的速度

那么在这个时候,TLAB就应运而生

什么是TLAB

从内存分配的角度来讲,我们将Eden区继续划分,JVM为每一个线程分配了一个私有的缓存区,它就在Eden区中

那么在多线程进行分配的时候,使用TLAB可以避免一系列的安全问题,同时我们可以避免加锁产生的效率低的问题

在这个过程中,不仅提升了内存的吞吐量,而且还避免了线程安全问题,所以我们将这个内存分配方式称为==快速分配策略==

目前所知的OpenJDK衍生出来的JVM都支持TLAB

TLAB的说明

其实==不是所有的对象实例都能够在TLAB中成功分配内存==,因为TLAB的空间确实是不大(默认占用Eden的1%),==但是JVM确实是将TLAB作为内存分配的首选==,假如不够则放到Eden区

我们可以使用选项-XX:UseTLAB来设置是否开启TLAB空间,他的默认情况下是开启的

我们可以使用选项-XX:TLABWasteTargetPercent来设置TLAB所占用的空间大小,默认是1%

我们以一个普通的对象初始化来作为一个例子,讲解TLAB中起到的作用

1
2
3
4
5
6
1、字节码文件
2、类的加载步骤:加载、链接、初始化
3、进入Eden区域中,判断TLAB是否可以进行分配
3.1、TLAB可以放下,那么放到Eden区中的TLAB区中
3.2、TLAB不能放下,那么放到Eden区的一般部分
4、对象调用构造方法进行实例化

我们这里说的其实是对象分配的一般情况,假如对象过大,Eden区放不下或者要触发Minor GC的情况我们不说


堆空间的常用参数设置

1
2
3
4
5
6
7
8
9
10
11
12
13
-XX:+PrintFlagsInitial:查看所有的参数的默认初始值
-XX:+PrintFlagsFinal:查看所有参数的最终值
jinfo -flag 参数 进程ID:通过jps查看当前运行的进程的参数的值,比如:jinfo -flag SurvivorRatio 9875
-Xms:初始堆内存空间,默认为物理内存的1/64)
-Xmx:最大堆内存空间,默认为物理内存的1/4
-Xmn:设置新生代的大小,初始值和最大值
-XX:NewRatio:配置新生代和老年代在堆结构的占比,默认为1:2
-XX:SurvivorRatio:设置新生代中Eden:S0:S1的比例,默认为8:1:1,可能会被自动分配修改
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄,默认大于15会被转移到老年代
-XX:+PrintGCDetails:输出详细的GC日志
-XX:+PrintGC:打印GC简要信息
-verbose:gc:同样是打印GC简要信息
-XX:HandlePromotionFailure:是否设置空间分配担保,JDK7之后不会影响了,也就是可以理解为true

对象分配存储

堆是分配对象存储的唯一选择吗

这个其实不是

我们的堆空间是比较特别的,我们知道,堆是性能调优的具体方面,性能瓶颈其实主要看堆空间了

随着JIT编译器和==逃逸分析技术==的逐渐发展,==栈上分配、标量替换优化技术==将会导致一些微妙的变化,所有对象分配到堆上也渐渐变得不再那么绝对了

逃逸分析

现在有一种特殊情况:假如经过==逃逸分析(Escape Analysis)==发现,一==个对象并没有逃逸出方法的话,那么就可能被优化为栈上分配==,这样就无需在堆上分配内存,也无需进行垃圾回收。这也是堆外存储技术

逃逸出方法,这个意思是看这个对象是否是只在这个方法上运行的,没有出这个方法的范围

其实TaoBaoVM有一个创新性的行为:GCIH,也就是说它能够将生命周期比较长的Java对象从Heap中转移到Heap外,并且GC不能管理GCIH内部的Java对象,也就是说不能将GCIH中的对象进行GC,这样就会降低GC的回收频率,提升效率

逃逸分析的基本行为就是分析对象动态作用域

  • 当一个对象在方法中被定义之后,对象只在方法内部使用,则认为没有发生逃逸,那么可以进行栈上分配
  • 当一个对象在方法中被定义之后,这个对象被外部方法引用,则认为发生逃逸,比如作为实参传递到其他地方

栈上分配有好处:

首先栈是线程独有的,这样可以不用考虑并发问题

栈是一个一个的栈帧,当方法执行之后栈帧就弹出栈了,这样没有了GC

image-20210214140714283

栈上分配

刚才我们简单地讲了一下逃逸分析和栈上分配,那么常见的发生逃逸的三种情况:

  • 在方法中给成员变量赋值
  • 方法返回值
  • 实例引用传递

没有实现逃逸的,我们可以使用栈上分配

同步省略

假如一个对象只能从一个线程中被访问,那么对于这个对象的操作就不用考虑同步

我们说同步是降低并发性和性能,同步的代价是非常高的

在使用动态编译同步块的时候,==JIT可以使用逃逸分析来确定同步块所使用的锁对象是否只能被一个线程访问==

假如没有那么JIT在编译这个同步块的时候就会取消对这部分代码的同步,这样就会大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫做==锁消除==

我们从字节码的角度仍然能够看到同步代码块的使用:monitorentermonitorexit,但是在我们运行的时候我们才会考虑同步省略,因为我们的同步省略是发生在JIT时刻的,而这个时候我们的字节码早就编译完成了

image-20210214155515954

上图中的synchronized的作用范围其实就是monitorenter–>moniterexit的作用范围

标量替换

有的对象可能不需要作为一个连续的内存结构存在,也可以访问到

那么对象的部分可以不存储在堆,而是存储到栈中

标量指的其实就是无法分解为更小的数据的数据。Java的原始数据类型就是标量。

相对的,那些还可以进行分开的数据叫做聚合量。Java对象就是聚合量,可以分为其他聚合量和标量。

在JIT阶段,假如经过逃逸分析,发现一个对象不会被外界访问到,那么经过JIT的优化,就会将这个对象拆解为若干个其中包含个若干个成员变量来代替。这个过程就叫做==标量替换==

用人话来讲,假如一个对象没有逃逸出这个方法,那么这个对象就会被JIT分解为几个标量来带一个聚合量

那么标量替换是很有好处的,因为不需要new对象了,那么就不需要在堆中分配空间了,只需要在栈上的空间就好了

那么大大减少了GC和堆的占用

可以通过:-XX:+EliminateAllocations开启标量替换,它默认是开启的

逃逸分析小结

1、逃逸分析本身的性能消耗也是一个相对耗时的操作,它本身并不成熟

用一个极端的例子来讲,假如一个方法进行一次逃逸分析,结果对象全部都逃逸了,那么这次逃逸分析就浪费掉了

2、虽然不成熟,但是它是即时编译器优化中一个十分重要的手段

现在已经被应用起来的手段是TaoBaoVM的GCIH

3、其实在HotSpot并没有进行栈上分配的技术,主要的手段还是标量替换

所以按照这个结论我们还是可以说,对象分配还是在堆空间上,但是不能说对象分配只能在堆空间上

即使我们说上字符串的缓存,在JDK8也已经转移到了堆上


方法区

方法区概述

image-20210215101805306

从运行时数据区的角度来说,方法区是我们要讲解的最后一个结构

堆、栈、方法区之间的交互

看下面的代码:

1
Person person = new Person();

image-20210215102540783

在上面的这行代码中

Person类这个.class文件被放到了方法区中

new Person()是一个新创建的对象,放到了堆中

person是一个引用堆中的变量,放到了栈中的局部变量表中

image-20210215102551342


方法区的理解

《Java虚拟机规范》中明确说明,方法区是在逻辑上属于堆的一部分,但是一些可以不必进行一些简单的实现。比如GC或者压缩。

对于HotSpotJVM来讲,方法区还有一个别名:Non-Heap(非堆),目的就是为了让它和堆区分开

所以,其实方法区可以看作是一块独立于Java堆的内存空间

  • 方法区和Java堆一样,都是线程共享的区域

  • 方法区在JVM创建时被创建,而且方法区的实际物理内存和Java堆一样,都是可以不连续的

  • 方法区的大小和堆空间一样,可以选择固定或者动态扩展

  • 方法区的大小决定了系统可以保存多少个类

    假如系统定义了太多类,导致方法区溢出,同样会导致内存溢出错误

    比如加载大量的第三方jar包,大量的动态生成反射类等

    永久代的:java.lang.OutOfMemoryError:PermGen space

    或者

    元空间的:java.lang.OutOfMemoryError:Metaspace

  • 关闭JVM就会释放这个区域的内存

方法区的演进细节

在JDK7及以前,习惯上将方法区称为永久代,JDK8开始我们将方法区称为元空间

它们三个的关系类似接口和实现类的关系,方法区类似接口,而元空间和永久代类似接口的实现

本质上来讲,方法区和永久代并不是等价的,我们只能说针对于HotSpot来讲是等价的,对于其他的虚拟机来讲,可能还没有永久代的实现

《Java虚拟机规范》并没有对如何实现方法区做统一要求,例如 BEA JRockit/ IBM J9中不存在永久代的概念

从现在的角度来讲,永久代不是一个好的方法,他会导致Java程序更加容易OOM,超过-XX:MaxPermSize上限

到了JDK8,终于永久废弃了永久代的概念,改用JRockit、J9一样的,在本地内存中实现的元空间Metaspace来代替

元空间的本质和永久代类似,只不过它们两个的最大区别是:==元空间不在虚拟机设置的内存中,而是使用本地内存==

也就是说在永久代的时候,它实现在JVM虚拟机中设置的内存中

但是在元空间时,直接使用本地内存,也就是说不再占用JVM中的内存了

永久代和元空间不仅仅是名字变了,结构也有了一些调整,我们在后面讲解


设置方法区大小和OOM

我们在前面讲,方法区是可以设置为固定大小或者是动态扩容的,那么我们在这里讲一下如何进固定大小的设置

因为我们讲,在JDK7和JDK8有了一些变化,所以他们的指令也有所不同

JDK7和以前

-XX:PermSize=xx设置永久代初始分配空间,默认值是20.75M

-XX:MaxPermSize=xx设置永久代的最大可用分配空间,32位电脑默认64M,64位电脑默认是82M

image-20210215105644454

当JVM加载的类信息超过了这个值,会报错java.lang.OutOfMemory:PermGen space

JDK8及之后

-XX:MetaspaceSize=xx:元数据区初始大小设置,默认是21M

-XX:MaxMetaspaceSize=xx:设置最大大小,默认是-1也就是无限制

和永久代不同,假如不指定大小,那么虚拟机会耗尽所有的可用系统内存,最终仍然会抛出异常OOM

对于我们的初始的元空间大小,对于一个64位的服务器端JVM来说,默认的-XX:MetaspaceSize设置值为21M

==假如触及了这个位置,Full GC将会被触发,并且卸载没用的类,随后这个21M将会被自动重置==

==新的数值是多少取决于GC后释放了多少元空间==

假如释放的空间不足,那么会不超过MaxMetaspaceSize的情况下适当提高值,反之会降低该值

假如初始化的MetaspaceSize设置太低,那么这个值也会调整多次,为了避免频繁Full GC,应该设置一个合理的的值


方法区的内部结构

首先要知道方法区中存放什么东西:

1、类信息

当然,这里说是类信息,这个类是一个泛指,不仅有class,还有接口,注解,enum,…..

2、运行时常量池

注意,这里的运行时常量池中有一个常量池叫做字符串常量池,其中字符串常量池在JDK的不同版本中有一些变化

在《深入理解Java虚拟机》中,针对方法区的存储包含描述:

类型信息(包含域信息和方法信息)、常量(在运行时常量池)、静态变量、JIT代码缓存

但是随着JDK的版本变化,以上描述其实并不适用于所有的JDK版本,所以不要硬背这个,这个只是一个比较经典的

类型信息

JVM必须在方法区存储以下信息

1、类型的完整有效名称:包名.类名

2、直接父类的完整有效名:对于interface或者是java.lang.Object都没有父类

3、这个类型的修饰符

4、这个类型实现的接口的一个有序列表

域信息(成员变量 Field)

JVM必须在方法区存储以下信息

1、保存类型的所有域的相关信息和域的声明顺序

2、域的相关信息包括:名称、类型、修饰符

对于变量这方面顺便提一嘴,在使用final修饰的变量,其实在编译阶段就已经将值写入到了class文件中

而使用static修饰的变量则没有在编译写入到class文件中,而是在类加载中的链接中的准备阶段将static的值进行了一次初始化

方法信息

JVM必须在方法区存储以下信息

1、方法名称

2、返回类型(或者void)

3、参数的数量和类型,按顺序

4、修饰符

5、字节码、操作数栈和他的大小、局部变量表和他的的大小

6、异常表


运行时常量池

字节码文件中的常量池

方法区中包含了运行时常量池,字节码中包含常量池

要明白方法区就要明白字节码文件,要明白运行时常量池就要明白字节码中的常量池

一个有效的字节码文件中除了包含类的版本信息、字段、方法和接口等描述信息之外,还包含一项信息就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用

image-20210218094352316

图中就是我们字节码中的的常量池

简而言之,常量池其实是存储基本原材料,类似我们的颜色都由RGB构成,常量池中存储的就是R、G、B

常量池也可以看作一张表,虚拟机指令根据这张常量表找到要执行的类名分、方法名、参数类型、字面量等信息


运行时常量池

运行时常量池是方法区的一部分,是常量池在运行时的表现形式

运行时常量池相对于Class文件中的常量池的一个重要特征是:具备动态性

比如说有一些常量池中没有的,但是能够在代码中表现出来,比如String.intern()

这就说明运行时常量池要比常量池具有更多内容信息


方法区的演进细节

1、首先记住,只有HotSpot才有永久带

2、HotSpot方法区中的变化

JDK版本 方法区
JDK6及以前 静态变量存储在在永久带
JDK7 有永久带
但是逐渐”去永久带”
字符串常量池、静态变量移除,保存在堆中
JDK8及以后 无永久带
类型信息、字段、方法、常量保存在本地内存的元空间
字符串常量池、静态变量仍然保存在堆中

image-20210218101525334

image-20210218101542041

image-20210218101604798


为什么永久带要被元空间替换

1、被Oracle收购了,所以JRockit和J9融合

2、永久带的大小是很难确定的,对永久带的调优比较困难


字符串常量池为什么要进行变化

在JDK7我们知道将字符串常量池放到了堆中,因为永久带的回收率很低,在Full GC时才会被触发

而Full GC只有当老年代和永久带满时才会被触发


方法区的垃圾回收

一般来说,方法区是难以垃圾回收的,因为不太好实现,但是这个区域的垃圾回收又是必须要实现的

方法区的垃圾回收主要回收两种内容:

1、常量池中废弃的常量

2、不再使用的类型

判断一个常量是否被废弃还是比较简单,但是判断一个类型是否属于不再被使用就比较麻烦了,需要满足:

1、该类的所有实例都被回收,也就是堆中不包含任何此类和其子类的实例

2、加载该类的类加载器已经被回收

在字节码被类的加载器加载之后,其实类加载器会记录下加载了谁

当字节码放到方法区之后,方法区中也会存一份是哪个加载器加载了这个字节码文件

所以这个是一个相互记录的过程

3、该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法